mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 13:42:09 +03:00
* 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>
172 lines
5.0 KiB
TypeScript
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;
|
|
}
|