Files
OmniRoute/open-sse/config/ocrRegistry.ts
Diego Rodrigues de Sa e Souza c62ace5a49 feat(ocr): multi-provider /v1/ocr with transformation layer (Azure Document Intelligence) (#10283)
* feat(ocr): transformation layer on ocrRegistry (Mistral shape canonical)

* feat(ocr): Azure Document Intelligence provider (prebuilt-read, analyze+poll)

* feat(ocr): generic dispatch with per-provider transformation and DI poll loop

* 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.

* 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.

* 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.

* chore(quality): rebaseline deadExports for the OCR/image-to-text series

* docs(skills): regenerate omni-inference skill for the multi-provider /v1/ocr

The generated agent skill mirrors docs/reference/API_REFERENCE.md; updating the
/v1/ocr section left it stale and tripped the merge-integrity gate.

---------

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-14 13:47:35 -03:00

172 lines
5.0 KiB
TypeScript

/**
* OCR Provider Registry
*
* Defines providers that support the /v1/ocr endpoint.
* Follows Mistral's OCR API format.
*/
export interface OcrModel {
id: string;
name: string;
}
export interface OcrProvider {
id: string;
baseUrl: string;
authType: string;
authHeader: string;
models: OcrModel[];
transformation?: OcrTransformation;
}
export interface ParsedOcrModel {
provider: string | null;
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",
baseUrl: "https://api.mistral.ai/v1/ocr",
authType: "apikey",
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,
},
};
/**
* Get OCR provider config by ID.
*/
export function getOcrProvider(providerId: string): OcrProvider | null {
return OCR_PROVIDERS[providerId] || null;
}
/**
* Parse an OCR model string.
*
* Accepts either a "provider/model" prefixed string or a bare model id that
* matches one of the registered OCR models.
*/
export function parseOcrModel(modelStr: string | null | undefined): ParsedOcrModel {
if (!modelStr) return { provider: null, model: null };
for (const providerId of Object.keys(OCR_PROVIDERS)) {
if (modelStr.startsWith(providerId + "/")) {
return { provider: providerId, model: modelStr.slice(providerId.length + 1) };
}
}
for (const [providerId, config] of Object.entries(OCR_PROVIDERS)) {
if (config.models.some((m) => m.id === modelStr)) {
return { provider: providerId, model: modelStr };
}
}
return { provider: null, model: modelStr };
}
/**
* Get all OCR models as a flat list.
*/
export function getAllOcrModels(): Array<{ id: string; name: string; provider: string }> {
const models: Array<{ id: string; name: string; provider: string }> = [];
for (const [providerId, config] of Object.entries(OCR_PROVIDERS)) {
for (const model of config.models) {
models.push({
id: `${providerId}/${model.id}`,
name: model.name,
provider: providerId,
});
}
}
return models;
}