Compare commits

...

6 Commits

Author SHA1 Message Date
Xiangzhe
e911807066 feat(ocr): route/docs for multi-provider /v1/ocr
- Route: map the connection's providerSpecificData.baseUrl onto
  credentials.baseUrl (resolveOcrCredentials) so azure-document-intelligence
  connections resolve their endpoint the same way every other custom-endpoint
  provider does (src/lib/providers/validation/*); previously handleOcr only
  saw a baseUrl when a caller set it directly, so the DB-backed Azure
  connection endpoint was never forwarded.
- v1OcrSchema.model is already a free-form string, no schema change needed.
- Docs: add the /v1/ocr provider table + example + Azure poll-flow note to
  API_REFERENCE.md, and describe the provider/model prefix + async poll
  behavior in openapi.yaml.
- Test: tests/unit/ocr-route-contract.test.ts covers getAllOcrModels/
  parseOcrModel for both providers and resolveOcrCredentials's mapping.
2026-08-13 16:07:03 -03:00
Xiangzhe
ee675a233c fix(ocr): fail fast on non-ok poll responses instead of misleading 504
pollOcrOperation now checks pollRes.ok and returns a sanitized 502
immediately (logging the upstream status via console.error) instead of
looping until the 30-attempt cap and surfacing a misleading timeout for
what was actually an auth/upstream error during polling.
2026-08-13 15:56:14 -03:00
Xiangzhe
0dbc44121f test(ocr): align sanitized-500 assert with HR#12 error sanitization
The test's own title ("returns a sanitized 500") describes the new
behavior mandated by HR#12 (never leak err.message in a response body).
The old regex asserted the pre-sanitization leak (`OCR request failed:
socket closed`) as expected output, which contradicted its own title
and the sanitization this task intentionally introduced in
open-sse/handlers/ocr.ts. Scoped to this single assertion only.
2026-08-13 15:51:28 -03:00
Xiangzhe
0f73d4e432 feat(ocr): generic dispatch with per-provider transformation and DI poll loop 2026-08-13 15:49:38 -03:00
Xiangzhe
ec91f760c6 feat(ocr): Azure Document Intelligence provider (prebuilt-read, analyze+poll) 2026-08-13 15:41:36 -03:00
Xiangzhe
8ede1cc801 feat(ocr): transformation layer on ocrRegistry (Mistral shape canonical) 2026-08-13 15:40:41 -03:00
9 changed files with 535 additions and 36 deletions

View File

@@ -6840,9 +6840,17 @@ paths:
- Images
summary: Document OCR
description: >-
Mistral OCRcompatible document OCR endpoint. Accepts a JSON body
referencing a document/image and returns extracted text. Success
responses carry the `X-OmniRoute-*` cost-telemetry headers.
Multi-provider document OCR endpoint (Mistral OCRcompatible request
and response shape). Accepts a JSON body referencing a document/image
and returns extracted text. `model` selects the provider via a
`provider/model` prefix (e.g. `mistral/mistral-ocr-latest`,
`azure-document-intelligence/prebuilt-read`); a bare model id (e.g.
`mistral-ocr-latest`) resolves to its registered provider, and an
omitted `model` defaults to Mistral. Azure Document Intelligence is
asynchronous upstream — the handler polls the returned operation
until it succeeds or fails before responding, so this endpoint can
take longer to return for that provider. Success responses carry the
`X-OmniRoute-*` cost-telemetry headers.
security:
- BearerAuth: []
requestBody:
@@ -6854,6 +6862,11 @@ paths:
properties:
model:
type: string
description: >-
`provider/model` id or bare model id. Registered ids:
`mistral/mistral-ocr-latest`,
`azure-document-intelligence/prebuilt-read`. Defaults to
`mistral-ocr-latest` when omitted.
document:
type: object
responses:

View File

@@ -17,6 +17,7 @@ Complete reference for all OmniRoute API endpoints.
- [Chat Completions](#chat-completions)
- [Embeddings](#embeddings)
- [Image Generation](#image-generation)
- [Document OCR](#document-ocr)
- [List Models](#list-models)
- [Provider Plugin Manifest](#provider-plugin-manifest)
- [Compatibility Endpoints](#compatibility-endpoints)
@@ -199,6 +200,53 @@ GET /v1/images/generations
---
## Document OCR
```bash
POST /v1/ocr
Authorization: Bearer your-api-key
Content-Type: application/json
{
"model": "mistral/mistral-ocr-latest",
"document": {
"type": "document_url",
"document_url": "https://example.com/invoice.pdf"
}
}
```
`model` selects the OCR provider via a `provider/model` prefix; a bare model id (e.g.
`mistral-ocr-latest`) resolves to its registered provider, and an omitted `model` defaults to
Mistral (`mistral-ocr-latest`). Registered providers (`open-sse/config/ocrRegistry.ts`):
| Provider id | Model id | `model` value | Notes |
| ----------------------------- | -------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `mistral` | `mistral-ocr-latest` | `mistral/mistral-ocr-latest` (or bare `mistral-ocr-latest`) | Synchronous — the response is returned directly from the single upstream call. |
| `azure-document-intelligence` | `prebuilt-read` | `azure-document-intelligence/prebuilt-read` | Asynchronous upstream (`analyze` + poll) — see below. |
Both providers respond in the same Mistral-shaped body:
```json
{
"pages": [{ "index": 0, "markdown": "# Extracted text..." }],
"model": "mistral-ocr-latest",
"usage_info": { "pages_processed": 1 }
}
```
### Azure Document Intelligence poll flow
Azure Document Intelligence's `analyze` API is asynchronous: the initial request returns an
`Operation-Location` header instead of a body, and the result must be polled for. The handler
(`open-sse/handlers/ocr.ts`) polls that URL every second for up to 30 attempts, fails fast (does
not keep polling) on a non-`ok` poll response or a `"failed"` status, and returns `504` if the
operation is still running after the attempt budget is exhausted. The final Azure response is
normalized into the same `pages`/`markdown` shape used by Mistral before being returned to the
caller, so client code does not need to special-case the provider.
---
## List Models
```bash
@@ -489,18 +537,18 @@ call**, so the reported `X-OmniRoute-Response-Latency` is near-zero
(benchmarking, p50/p99 monitoring) should check the
`X-OmniRoute-Cache-Latency` response header:
| Value | Meaning |
|-------|---------|
| Value | Meaning |
| ----------- | ------------------------------------------------------------- |
| `synthetic` | Response served from cache; latency is not real upstream time |
| *(absent)* | Response from real upstream call |
| _(absent)_ | Response from real upstream call |
### Per-key cache bypass
API keys can opt out of semantic cache reads via `cacheDefaultMode`:
| Value | Behavior |
|-------|----------|
| `legacy` | Normal cache behavior (default) |
| Value | Behavior |
| -------- | ----------------------------------------------- |
| `legacy` | Normal cache behavior (default) |
| `bypass` | Skip cache lookup entirely; always hit upstream |
Set at key creation (`POST /api/keys`) or update (`PATCH /api/keys/[id]`):
@@ -603,13 +651,13 @@ X-OmniRoute-No-Cache: true
### Monitoring
| Endpoint | Method | Description |
| ------------------------ | ---------- | ---------------------------------------------------------------------------------------------------- |
| `/api/sessions` | GET | Active session tracking |
| `/api/rate-limits` | GET | Per-account rate limits |
| `/api/monitoring/health` | GET | Health check + provider summary (`catalogCount`, `configuredCount`, `activeCount`, `monitoredCount`) |
| `/api/cache/stats` | GET/DELETE | Cache stats / clear |
| `/api/modality-bridge/stats` | GET | In-memory Modality Bridge telemetry — per-modality `bridged`/`cacheHits`/`failures`/`lastUsedAt` counters (reset on restart; management auth) |
| Endpoint | Method | Description |
| ---------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `/api/sessions` | GET | Active session tracking |
| `/api/rate-limits` | GET | Per-account rate limits |
| `/api/monitoring/health` | GET | Health check + provider summary (`catalogCount`, `configuredCount`, `activeCount`, `monitoredCount`) |
| `/api/cache/stats` | GET/DELETE | Cache stats / clear |
| `/api/modality-bridge/stats` | GET | In-memory Modality Bridge telemetry — per-modality `bridged`/`cacheHits`/`failures`/`lastUsedAt` counters (reset on restart; management auth) |
### Backup & Export/Import

View File

@@ -16,6 +16,7 @@ export interface OcrProvider {
authType: string;
authHeader: string;
models: OcrModel[];
transformation?: OcrTransformation;
}
export interface ParsedOcrModel {
@@ -23,6 +24,86 @@ export interface ParsedOcrModel {
model: string | null;
}
export interface OcrResponseShape {
pages: Array<{ index: number; markdown: string }>;
model: string;
usage_info?: Record<string, unknown>;
}
export interface OcrTransformation {
buildRequest(args: {
baseUrl: string;
token: string;
body: Record<string, unknown>;
modelId: string;
}): { url: string; init: RequestInit };
parseResponse(raw: unknown): OcrResponseShape;
/** Async providers (Azure DI): return the poll URL from the first response, else null. */
pollUrl?(res: Response): string | null;
}
export const MISTRAL_PASSTHROUGH: OcrTransformation = {
buildRequest({ baseUrl, token, body, modelId }) {
return {
url: baseUrl,
init: {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ ...body, model: modelId }),
},
};
},
parseResponse(raw) {
return raw as OcrResponseShape;
},
};
export function getOcrTransformation(providerId: string): OcrTransformation {
return OCR_PROVIDERS[providerId]?.transformation ?? MISTRAL_PASSTHROUGH;
}
const AZURE_DI_API_VERSION = "2024-11-30";
function azureDiSource(document: Record<string, unknown> | undefined): Record<string, string> {
if (!document) return {};
const url = String(document.document_url ?? document.image_url ?? "");
if (url.startsWith("data:")) {
const comma = url.indexOf(",");
return { base64Source: comma >= 0 ? url.slice(comma + 1) : "" };
}
return url ? { urlSource: url } : {};
}
export const AZURE_DI_TRANSFORMATION: OcrTransformation = {
buildRequest({ baseUrl, token, body, modelId }) {
const root = baseUrl.replace(/\/+$/, "");
return {
url: `${root}/documentintelligence/documentModels/${modelId}:analyze?api-version=${AZURE_DI_API_VERSION}&outputContentFormat=markdown`,
init: {
method: "POST",
headers: { "Content-Type": "application/json", "Ocp-Apim-Subscription-Key": token },
body: JSON.stringify(azureDiSource(body.document as Record<string, unknown>)),
},
};
},
pollUrl(res) {
return res.headers.get("Operation-Location");
},
parseResponse(raw) {
const r = raw as {
analyzeResult?: { content?: string; pages?: unknown[] };
};
const pageCount = r.analyzeResult?.pages?.length ?? 1;
// Azure returns the whole-document markdown in `content`; we mirror it into the
// Mistral shape as a single aggregated "page" (index 0), preserving pageCount.
return {
pages: [{ index: 0, markdown: r.analyzeResult?.content ?? "" }],
model: "prebuilt-read",
usage_info: { pages_processed: pageCount },
};
},
};
export const OCR_PROVIDERS: Record<string, OcrProvider> = {
mistral: {
id: "mistral",
@@ -31,6 +112,14 @@ export const OCR_PROVIDERS: Record<string, OcrProvider> = {
authHeader: "bearer",
models: [{ id: "mistral-ocr-latest", name: "Mistral OCR" }],
},
"azure-document-intelligence": {
id: "azure-document-intelligence",
baseUrl: "",
authType: "apikey",
authHeader: "Ocp-Apim-Subscription-Key",
models: [{ id: "prebuilt-read", name: "Azure Document Intelligence (Read)" }],
transformation: AZURE_DI_TRANSFORMATION,
},
};
/**

View File

@@ -5,21 +5,43 @@ import { CORS_HEADERS } from "../utils/cors.ts";
* Handles POST /v1/ocr (Mistral OCR API format).
*/
import { getOcrProvider, parseOcrModel } from "../config/ocrRegistry.ts";
import {
getOcrProvider,
getOcrTransformation,
parseOcrModel,
OCR_PROVIDERS,
} from "../config/ocrRegistry.ts";
import { errorResponse } from "../utils/error.ts";
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
import { generateRequestId } from "@/shared/utils/requestId";
const OCR_POLL_MAX_ATTEMPTS = 30;
const OCR_POLL_INTERVAL_MS = 1000;
const defaultSleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Handle OCR request
*
* Dispatches to the per-provider transformation (see `open-sse/config/ocrRegistry.ts`)
* to build the upstream request, then (for async providers like Azure Document
* Intelligence) polls the returned operation URL until it succeeds or fails,
* before normalizing the response into the Mistral OCR shape.
*
* @param {Object} options
* @param {Object} options.body - JSON body { model, document }
* @param {Object} options.credentials - Provider credentials { apiKey }
* @param {Object} options.credentials - Provider credentials { apiKey, accessToken, baseUrl }
* @param {Function} [options.fetchImpl] - DI hook for tests; defaults to global fetch
* @param {Function} [options.sleepImpl] - DI hook for tests; defaults to a real setTimeout-based sleep
* @returns {Response}
*/
/** @returns {Promise<unknown>} */
export async function handleOcr({ body, credentials }) {
export async function handleOcr({
body,
credentials,
fetchImpl = fetch,
sleepImpl = defaultSleep,
}) {
const startTime = Date.now();
if (!body.document) {
return errorResponse(400, "document is required");
@@ -31,7 +53,10 @@ export async function handleOcr({ body, credentials }) {
const providerConfig = providerId ? getOcrProvider(providerId) : null;
if (!providerConfig) {
return errorResponse(400, `No OCR provider found for model "${model}". Available: mistral`);
return errorResponse(
400,
`No OCR provider found for model "${model}". Available: ${Object.keys(OCR_PROVIDERS).join(", ")}`
);
}
const token = credentials?.apiKey || credentials?.accessToken;
@@ -39,18 +64,15 @@ export async function handleOcr({ body, credentials }) {
return errorResponse(401, `No credentials for OCR provider: ${providerId}`);
}
const baseUrl = credentials?.baseUrl || providerConfig.baseUrl;
if (!baseUrl) {
return errorResponse(400, `No base URL configured for OCR provider: ${providerId}`);
}
try {
const res = await fetch(providerConfig.baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
...body,
model: modelId,
}),
});
const transformation = getOcrTransformation(providerId);
const { url, init } = transformation.buildRequest({ baseUrl, token, body, modelId });
const res = await fetchImpl(url, init);
if (!res.ok) {
const errText = await res.text();
@@ -63,7 +85,17 @@ export async function handleOcr({ body, credentials }) {
});
}
const data = await res.json();
const pollUrl = transformation.pollUrl?.(res) ?? null;
let data: unknown;
if (pollUrl) {
const authHeader = buildAuthHeader(providerConfig.authHeader, token);
data = await pollOcrOperation({ pollUrl, authHeader, fetchImpl, sleepImpl });
if (data instanceof Response) return data;
} else {
data = await res.json();
}
const parsed = transformation.parseResponse(data);
const headers = new Headers({ ...CORS_HEADERS, "Content-Type": "application/json" });
attachOmniRouteMetaHeaders(headers, {
provider: providerId,
@@ -72,8 +104,48 @@ export async function handleOcr({ body, credentials }) {
latencyMs: Date.now() - startTime,
requestId: generateRequestId(),
});
return new Response(JSON.stringify(data), { status: 200, headers });
return new Response(JSON.stringify(parsed), { status: 200, headers });
} catch (err) {
return errorResponse(500, `OCR request failed: ${err.message}`);
console.error("[OCR]", err);
return errorResponse(500, "OCR request failed");
}
}
/**
* Build the same auth header used for the initial upstream request, so the
* poll GET (e.g. Azure Document Intelligence's Operation-Location) authenticates
* identically.
*/
function buildAuthHeader(authHeader: string, token: string): Record<string, string> {
if (authHeader === "bearer") {
return { Authorization: `Bearer ${token}` };
}
return { [authHeader]: token };
}
/**
* Poll an async OCR operation (Azure Document Intelligence) until it succeeds or fails.
*
* @returns {Promise<unknown|Response>} the parsed JSON body on success, or an error Response
*/
async function pollOcrOperation({ pollUrl, authHeader, fetchImpl, sleepImpl }) {
for (let attempt = 0; attempt < OCR_POLL_MAX_ATTEMPTS; attempt++) {
await sleepImpl(OCR_POLL_INTERVAL_MS);
const pollRes = await fetchImpl(pollUrl, {
method: "GET",
headers: authHeader,
});
if (!pollRes.ok) {
console.error("[OCR] poll error", pollRes.status);
return errorResponse(502, "OCR analysis failed");
}
const json = await pollRes.json();
if (json.status === "succeeded") {
return json;
}
if (json.status === "failed") {
return errorResponse(502, "OCR analysis failed");
}
}
return errorResponse(504, "OCR analysis timed out");
}

View File

@@ -15,6 +15,25 @@ import {
rateLimitedProviderResponse,
} from "@/app/api/v1/_shared/rateLimit";
/**
* Custom-endpoint providers (e.g. azure-document-intelligence) store the
* connection's resource endpoint under providerSpecificData.baseUrl, not as
* a top-level credentials field — mirror the convention used across
* src/lib/providers/validation/* (see e.g. urlHelpers.ts). handleOcr reads
* credentials.baseUrl, so surface it here. An existing top-level baseUrl
* always wins (kept for tests/callers that pass it directly).
*/
export function resolveOcrCredentials<
T extends { baseUrl?: string; providerSpecificData?: Record<string, unknown> },
>(credentials: T): T {
if (credentials?.baseUrl) return credentials;
const providerSpecificBaseUrl = credentials?.providerSpecificData?.baseUrl;
if (typeof providerSpecificBaseUrl === "string" && providerSpecificBaseUrl.trim()) {
return { ...credentials, baseUrl: providerSpecificBaseUrl };
}
return credentials;
}
/**
* Handle CORS preflight
*/
@@ -66,7 +85,9 @@ async function postHandler(request, context) {
return rateLimitedProviderResponse(resolvedProvider, credentials);
}
const response = await handleOcr({ body: { ...body, model }, credentials });
const ocrCredentials = resolveOcrCredentials(credentials);
const response = await handleOcr({ body: { ...body, model }, credentials: ocrCredentials });
if (response?.ok) {
await clearRecoveredProviderState(credentials);
}

View File

@@ -0,0 +1,133 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { handleOcr } from "../../open-sse/handlers/ocr.ts";
function fetchStub(
script: Array<{ status: number; headers?: Record<string, string>; json?: unknown }>
) {
const calls: Array<{ url: string; init: RequestInit }> = [];
const impl = async (url: string, init: RequestInit) => {
calls.push({ url, init });
const step = script.shift()!;
return new Response(step.json !== undefined ? JSON.stringify(step.json) : null, {
status: step.status,
headers: { "Content-Type": "application/json", ...(step.headers ?? {}) },
});
};
return { impl, calls };
}
const noSleep = async () => {};
test("mistral path posts once and returns the upstream body", async () => {
const { impl, calls } = fetchStub([
{ status: 200, json: { pages: [{ index: 0, markdown: "ok" }], model: "mistral-ocr-latest" } },
]);
const res = await handleOcr({
body: {
model: "mistral/mistral-ocr-latest",
document: { type: "image_url", image_url: "https://x/y.png" },
},
credentials: { apiKey: "sk" },
fetchImpl: impl,
sleepImpl: noSleep,
});
assert.equal(res.status, 200);
assert.equal(calls.length, 1);
const data = await res.json();
assert.equal(data.pages[0].markdown, "ok");
});
test("azure DI path polls Operation-Location until succeeded", async () => {
const { impl, calls } = fetchStub([
{ status: 202, headers: { "Operation-Location": "https://poll/op/1" } },
{ status: 200, json: { status: "running" } },
{ status: 200, json: { status: "succeeded", analyzeResult: { content: "# md", pages: [{}] } } },
]);
const res = await handleOcr({
body: {
model: "azure-document-intelligence/prebuilt-read",
document: { type: "document_url", document_url: "https://x/d.pdf" },
},
credentials: { apiKey: "azkey", baseUrl: "https://r.cognitiveservices.azure.com" },
fetchImpl: impl,
sleepImpl: noSleep,
});
assert.equal(res.status, 200);
assert.ok(calls.length >= 3);
const data = await res.json();
assert.equal(data.pages[0].markdown, "# md");
});
test("unknown model lists available providers dynamically and errors do not leak internals", async () => {
const res = await handleOcr({
body: { model: "nope/none", document: { type: "image_url", image_url: "https://x" } },
credentials: { apiKey: "k" },
fetchImpl: async () => new Response("{}", { status: 200 }),
sleepImpl: noSleep,
});
assert.equal(res.status, 400);
const body = await res.json();
assert.ok(body.error.message.includes("azure-document-intelligence"));
assert.ok(!body.error.message.includes("at /"));
});
test("azure DI poll returns failed status maps to 502", async () => {
const { impl } = fetchStub([
{ status: 202, headers: { "Operation-Location": "https://poll/op/1" } },
{ status: 200, json: { status: "failed" } },
]);
const res = await handleOcr({
body: {
model: "azure-document-intelligence/prebuilt-read",
document: { type: "document_url", document_url: "https://x/d.pdf" },
},
credentials: { apiKey: "azkey", baseUrl: "https://r.cognitiveservices.azure.com" },
fetchImpl: impl,
sleepImpl: noSleep,
});
assert.equal(res.status, 502);
const body = await res.json();
assert.ok(!body.error.message.includes("at /"));
});
test("azure DI poll returns a non-ok response (401) and fails fast without exhausting the loop", async () => {
const { impl, calls } = fetchStub([
{ status: 202, headers: { "Operation-Location": "https://poll/op/1" } },
{ status: 401, json: { error: "unauthorized" } },
]);
const res = await handleOcr({
body: {
model: "azure-document-intelligence/prebuilt-read",
document: { type: "document_url", document_url: "https://x/d.pdf" },
},
credentials: { apiKey: "azkey", baseUrl: "https://r.cognitiveservices.azure.com" },
fetchImpl: impl,
sleepImpl: noSleep,
});
assert.equal(res.status, 502);
// 1 initial POST + 1 poll: the loop stopped immediately, it did not run all 30 attempts.
assert.equal(calls.length, 2);
const body = await res.json();
assert.ok(!body.error.message.includes("at /"));
});
test("azure DI poll never resolves and times out after 30 attempts with a 504", async () => {
const script = [{ status: 202, headers: { "Operation-Location": "https://poll/op/1" } }];
for (let i = 0; i < 30; i++) {
script.push({ status: 200, json: { status: "running" } });
}
const { impl, calls } = fetchStub(script);
const res = await handleOcr({
body: {
model: "azure-document-intelligence/prebuilt-read",
document: { type: "document_url", document_url: "https://x/d.pdf" },
},
credentials: { apiKey: "azkey", baseUrl: "https://r.cognitiveservices.azure.com" },
fetchImpl: impl,
sleepImpl: noSleep,
});
assert.equal(res.status, 504);
// 1 initial POST + 30 poll attempts (the max cap), no more.
assert.equal(calls.length, 31);
});

View File

@@ -0,0 +1,74 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
OCR_PROVIDERS,
getOcrTransformation,
MISTRAL_PASSTHROUGH,
} from "../../open-sse/config/ocrRegistry.ts";
test("mistral resolves the passthrough transformation by default", () => {
const t = getOcrTransformation("mistral");
assert.equal(t, MISTRAL_PASSTHROUGH);
const { url, init } = t.buildRequest({
baseUrl: OCR_PROVIDERS.mistral.baseUrl,
token: "sk-test",
body: { document: { type: "image_url", image_url: "https://x/y.png" } },
modelId: "mistral-ocr-latest",
});
assert.equal(url, "https://api.mistral.ai/v1/ocr");
assert.equal(init.method, "POST");
assert.equal((init.headers as Record<string, string>).Authorization, "Bearer sk-test");
const sent = JSON.parse(String(init.body));
assert.equal(sent.model, "mistral-ocr-latest");
});
test("passthrough parseResponse returns the body unchanged (Mistral is the canonical shape)", () => {
const raw = { pages: [{ index: 0, markdown: "hello" }], model: "mistral-ocr-latest" };
assert.deepEqual(MISTRAL_PASSTHROUGH.parseResponse(raw), raw);
});
test("azure-document-intelligence builds the prebuilt-read:analyze request", () => {
const t = getOcrTransformation("azure-document-intelligence");
const { url, init } = t.buildRequest({
baseUrl: "https://myres.cognitiveservices.azure.com",
token: "azkey",
body: { document: { type: "document_url", document_url: "https://x/d.pdf" } },
modelId: "prebuilt-read",
});
assert.equal(
url,
"https://myres.cognitiveservices.azure.com/documentintelligence/documentModels/prebuilt-read:analyze?api-version=2024-11-30&outputContentFormat=markdown"
);
assert.equal((init.headers as Record<string, string>)["Ocp-Apim-Subscription-Key"], "azkey");
const sent = JSON.parse(String(init.body));
assert.equal(sent.urlSource, "https://x/d.pdf");
});
test("azure-document-intelligence extracts poll URL and parses analyzeResult into Mistral shape", () => {
const t = getOcrTransformation("azure-document-intelligence");
const res = new Response(null, {
status: 202,
headers: { "Operation-Location": "https://poll/op/1" },
});
assert.equal(t.pollUrl?.(res), "https://poll/op/1");
const parsed = t.parseResponse({
status: "succeeded",
analyzeResult: { content: "# doc text", pages: [{ pageNumber: 1 }] },
});
assert.equal(parsed.pages.length, 1);
assert.equal(parsed.pages[0].index, 0);
assert.equal(parsed.pages[0].markdown, "# doc text");
assert.equal(parsed.model, "prebuilt-read");
});
test("azure DI maps base64/image_url documents to base64Source/urlSource", () => {
const t = getOcrTransformation("azure-document-intelligence");
const { init } = t.buildRequest({
baseUrl: "https://r.example.com",
token: "k",
body: { document: { type: "image_url", image_url: "data:image/png;base64,AAAA" } },
modelId: "prebuilt-read",
});
const sent = JSON.parse(String(init.body));
assert.equal(sent.base64Source, "AAAA");
});

View File

@@ -0,0 +1,48 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { getAllOcrModels, parseOcrModel } from "../../open-sse/config/ocrRegistry.ts";
import { resolveOcrCredentials } from "../../src/app/api/v1/ocr/route.ts";
test("getAllOcrModels exposes both the mistral and azure-document-intelligence OCR models", () => {
const ids = getAllOcrModels().map((m) => m.id);
assert.ok(ids.includes("mistral/mistral-ocr-latest"));
assert.ok(ids.includes("azure-document-intelligence/prebuilt-read"));
});
test("parseOcrModel resolves the azure-document-intelligence provider prefix", () => {
assert.deepEqual(parseOcrModel("azure-document-intelligence/prebuilt-read"), {
provider: "azure-document-intelligence",
model: "prebuilt-read",
});
});
// ── resolveOcrCredentials — maps the connection's custom endpoint (stored
// under providerSpecificData.baseUrl per the src/lib/providers/validation/*
// convention) onto the top-level credentials.baseUrl field that handleOcr
// reads, so azure-document-intelligence connections resolve their endpoint. ──
test("resolveOcrCredentials surfaces providerSpecificData.baseUrl to the top level", () => {
const credentials = {
apiKey: "azkey",
providerSpecificData: { baseUrl: "https://r.cognitiveservices.azure.com" },
};
assert.deepEqual(resolveOcrCredentials(credentials), {
apiKey: "azkey",
providerSpecificData: { baseUrl: "https://r.cognitiveservices.azure.com" },
baseUrl: "https://r.cognitiveservices.azure.com",
});
});
test("resolveOcrCredentials keeps an existing top-level baseUrl untouched", () => {
const credentials = {
apiKey: "azkey",
baseUrl: "https://explicit.example.com",
providerSpecificData: { baseUrl: "https://ignored.example.com" },
};
assert.equal(resolveOcrCredentials(credentials).baseUrl, "https://explicit.example.com");
});
test("resolveOcrCredentials is a no-op when there is no providerSpecificData.baseUrl (mistral)", () => {
const credentials = { apiKey: "sk-mistral" };
assert.deepEqual(resolveOcrCredentials(credentials), credentials);
});

View File

@@ -183,6 +183,7 @@ test("handleOcr returns a sanitized 500 when the upstream request throws", async
const payload = (await response.json()) as any;
assert.equal(response.status, 500);
assert.match(payload.error.message, /OCR request failed: socket closed/);
assert.ok(payload.error.message.includes("OCR request failed"));
assert.ok(!payload.error.message.includes("socket closed"));
assert.ok(!payload.error.message.includes("at /"));
});