diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index bbdc9d16f0d..b8cbd65265a 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -442,7 +442,7 @@ "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1062, "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 1051, "src/shared/components/ModelSelectModal.tsx": 1138, - "src/shared/constants/providers/apikey/gateways.ts": 1250, + "src/shared/constants/providers/apikey/gateways.ts": 1255, "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1387, "_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).", "src/lib/modelCapabilities.ts": 1006, @@ -609,5 +609,6 @@ "_rebaseline_2026_08_06_9006_reconcile_onto_tip_2": "PR #9006 (fix/vertex-claude-catalog-dispatch) second rebase-onto-tip reconciliation. Same two files as _rebaseline_2026_08_04_9006_reconcile_onto_tip below, further inherited drift, still not this PR's own growth (verified via git diff against the fresh upstream/release/v3.8.50 merge-base — this PR's own commits still touch neither file): open-sse/executors/base.ts 1623->1640 (+17) and src/sse/handlers/chat.ts 1847->1881 (+34), both measured post-merge via split(\"\\n\").length. More already-merged release/v3.8.50 PRs grew these files without updating their baseline entries (same root cause as every other entry in this chain: fast-gates PR->release does not run check:file-size). No offending branch left to fix.", "_rebaseline_2026_08_12_v3850_basereds_round3": "Base-reds round 3 (#9985, 2026-08-12): ModelSelectModal.tsx 1135->1138 = base drift from the #10198 SWR/build repair (flagged as non-blocking drift by Release-Green run 31634993212, rebaselined here so the PR queue's Fast Quality Gates stop failing on inherited drift); gateways.ts 1215->1250 = base drift from the 08-12 merges (#10131 regolo/naga-ac repair, #9210 void-ai+helixmind) plus this PR restoring the chatanywhere metadata entry that round 2 dropped along with its duplicate (wave3 audited entry, +16 lines; same god-file no-split rationale as the 2026-08-11 annotation). Owner-authorized sweep (/sweep-reds).", "_rebaseline_2026_08_12_proxyfetch_redaction": "Base-reds round 3 (#9985): proxyFetch.ts 1220->1239 (+19) = redactProxyDetailsInMessage() helper closing the credential leak #10032 reintroduced (raw proxy URL with user:password appended to the propagated error, Hard Rule #12); irreducible security fix at the existing error-surface chokepoint. Covered by tests/unit/tls-proxy-context.test.ts (strengthened leak guards).", - "_rebaseline_2026_08_12_modelcapabilities_snapshot_routing": "Base-reds round 3 (#9985): modelCapabilities.ts crossed the new-file cap at 1006 (+~10) when the context/max-input-token override lookups were routed through the #9199 bulk snapshot (fixing 323 per-model SQLite reads per catalog prepare — auto-combo-context-advertising guard); cohesive change at the existing resolution chokepoints, not extractable. Covered by tests/unit/auto-combo-context-advertising.test.ts + model-capability-resolution-snapshot-9199.test.ts." + "_rebaseline_2026_08_12_modelcapabilities_snapshot_routing": "Base-reds round 3 (#9985): modelCapabilities.ts crossed the new-file cap at 1006 (+~10) when the context/max-input-token override lookups were routed through the #9199 bulk snapshot (fixing 323 per-model SQLite reads per catalog prepare — auto-combo-context-advertising guard); cohesive change at the existing resolution chokepoints, not extractable. Covered by tests/unit/auto-combo-context-advertising.test.ts + model-capability-resolution-snapshot-9199.test.ts.", + "_rebaseline_2026_08_14_imagetotext_servicekinds": "Image-to-Text category (#10275/#10291): gateways.ts grew 1250→1255 by data lines only — the serviceKinds: [\"llm\", \"imageToText\"] declarations on the openrouter and chutes catalog entries, plus the 3-line comment recording why chutes needs no static dots.ocr entry (passthroughModels discovery). No new logic or branching; the file is a provider catalog of declarative metadata. Splitting a catalog for five lines would be worse than the growth (semantic-families rule)." } diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index 7c7f2bb67b9..a33109e099a 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -102,7 +102,7 @@ "_rebaseline_2026_07_28_v3849_release": "75.5 -> 99 (+23.5). Aperto EXIGIDO pelo modo --require-tighten do ratchet: a métrica melhorou de verdade no ciclo v3.8.49. A causa é o workflow assíncrono de tradução, que finalmente alcançou o denominador em EN — as rebaselines anteriores (v3.8.39/.44/.47) foram todas afrouxamentos registrando o atraso das traduções, e agora ele foi pago. O coletor SUBTRAI os placeholders (present - placeholder em scripts/quality/collect-metrics.mjs), então os 317 marcadores __MISSING__ que esta release introduziu para o drift de valor já estão descontados dos 99 — o número é honesto, não inflado por placeholder. Medido pelo collect-metrics do CI no run 30404226939." }, "deadExports": { - "value": 409, + "value": 415, "direction": "down", "_rebaseline_2026_08_09_v3850_post_sweep": "227 -> 230. Measured by npm run check:dead-code on the unmodified release/v3.8.50 tip 382449d593 during the mandatory --full-ci pre-flight. The +3 is inherited cycle drift from the authorized merge sweep; this repair adds no production exports. Rebaseline records the actual tip so ci.yml quality-gate can run, while structural cleanup remains separate debt.", "_rebaseline_2026_07_01_v3843_release": "225->227 (+2). v3.8.43 cycle drift, surfaced in the Quality Ratchet job after eslintWarnings was rebaselined (check:dead-code runs there). 227 = measured by check:dead-code (knip) on the release tip 4635076eb. The 5 CI fixes add 0 dead exports: safeHttpHref in linkify.ts is module-local AND used (called by linkifyText); no new exports; test files are not scanned. Tighten via --update next cycle.", @@ -111,7 +111,8 @@ "_rebaseline_2026_06_27_v3838_release": "345->346 (+1). v3.8.38 cycle drift surfaced by the release-green pre-flight (Quality Ratchet does NOT run on PR->release fast-gates). Net +1 inherited from this cycle's feature/fix merges (new executors/providers, compression fidelity-gate module) minus #5138's removal of dead legacy store modules. Release-finalize working tree touches ONLY CHANGELOG.md + i18n mirrors + README + baselines — 0 production-code change. Structural cleanup tracked as debt.", "_rebaseline_2026_06_26_v3837_release": "343->345. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.", "_rebaseline_2026_08_11_v3850_merge_storm": "230 -> 248. Own drift from the 2026-08-11 merge storm (99 PRs into release/v3.8.50 via authorized sweep): new providers/executors/handlers added dead exports that knip cannot see as used. Measured on the base-fix tip (7ca73697b0 + this repair PR). Owner authorized rebaseline (2026-08-11) — structural cleanup remains separate debt.", - "_rebaseline_2026_08_13_v3850_knip_bump": "248 -> 409. NOT code-added dead exports: dependabot bump #10043 (2026-08-13) upgraded knip 6.27.0 -> 6.32.x, and the new knip detects 162 MORE genuinely-unused exports (331 vs 169 deadExports) that 6.27 missed. DEAD_FILES unchanged (78). Reproduced identically on the clean release/v3.8.50 tip 266e39d3 with a fresh knip 6.32 node_modules — so every PR is born red on this gate until the tool change is absorbed. Owner authorized rebaseline (2026-08-13, via base-reds PR #10260). Structural cleanup of the 162 newly-surfaced dead exports remains separate debt." + "_rebaseline_2026_08_13_v3850_knip_bump": "248 -> 409. NOT code-added dead exports: dependabot bump #10043 (2026-08-13) upgraded knip 6.27.0 -> 6.32.x, and the new knip detects 162 MORE genuinely-unused exports (331 vs 169 deadExports) that 6.27 missed. DEAD_FILES unchanged (78). Reproduced identically on the clean release/v3.8.50 tip 266e39d3 with a fresh knip 6.32 node_modules — so every PR is born red on this gate until the tool change is absorbed. Owner authorized rebaseline (2026-08-13, via base-reds PR #10260). Structural cleanup of the 162 newly-surfaced dead exports remains separate debt.", + "_rebaseline_2026_08_14_ocr_imagetotext_series": "OCR/image-to-text series (#10275/#10283/#10287/#10289/#10291): deadExports 409 -> 415. Each PR in the series adds public util/registry exports that are exercised by their unit tests but not yet by a second production caller — normalizeImageBuffer (imageNormalize), MISTRAL_PASSTHROUGH / AZURE_DI_TRANSFORMATION / getOcrTransformation (ocrRegistry), resolveOcrCredentials (v1/ocr route). They are the documented public surface of the new modules and are covered by tests; structural cleanup stays tracked in #3501." }, "cognitiveComplexity": { "value": 1223, diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 430404df6c6..123db6af67a 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -6840,9 +6840,17 @@ paths: - Images summary: Document OCR description: >- - Mistral OCR–compatible 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 OCR–compatible 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: diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 7f1bf3a13ad..f1d4fce2e9d 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -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 diff --git a/open-sse/config/constants.ts b/open-sse/config/constants.ts index 6ddeabe7b5b..c45cf542a66 100644 --- a/open-sse/config/constants.ts +++ b/open-sse/config/constants.ts @@ -179,6 +179,24 @@ export const HTTP_STATUS = { SERVICE_UNAVAILABLE: 503, GATEWAY_TIMEOUT: 504, }; + +/** + * #10360 — stable error code for an INTERNAL violation of the executor + * `execute()` result contract (`normalizeExecutorResult` received something + * that is neither a Response nor `{ response: Response }`). + * + * This is our own bug, never a provider/account health signal, so every + * resilience layer must treat it as request-scoped and terminal: no connection + * cooldown, no provider circuit-breaker trip, no retry. It rides on the error's + * `.code` (read by `getUpstreamErrorIdentifier`) and therefore reaches + * `checkFallbackError` as `structuredError.code` and the chat/combo predicates + * as `result.errorCode`. + * + * Lives here (leaf config module) so both `open-sse/handlers/` and + * `open-sse/services/` can import it without creating a cycle. + */ +export const EXECUTOR_CONTRACT_VIOLATION_CODE = "executor_contract_violation"; + export { BACKOFF_CONFIG, COOLDOWN_MS, diff --git a/open-sse/config/mediaServiceKinds.ts b/open-sse/config/mediaServiceKinds.ts index 22a692c971c..77141c77576 100644 --- a/open-sse/config/mediaServiceKinds.ts +++ b/open-sse/config/mediaServiceKinds.ts @@ -13,9 +13,11 @@ * derives membership from here instead of duplicating it by hand, so adding a * provider to a registry automatically surfaces it — no second edit, no drift. * - * Kinds without a backing registry (imageToText, webSearch, webFetch, llm) are - * still declared explicitly via `serviceKinds` on the provider entry; callers - * union the two sources. + * `imageToText` is additionally derived from `OCR_PROVIDERS` (see + * `resolveProviderServiceKinds`): a provider registered in the OCR registry gets + * `imageToText` for free, no manual `serviceKinds` edit needed. Kinds without any + * backing registry (webSearch, webFetch, llm) are still declared explicitly via + * `serviceKinds` on the provider entry; callers union declared + derived sources. */ import { AUDIO_TRANSCRIPTION_PROVIDERS, AUDIO_SPEECH_PROVIDERS } from "./audioRegistry.ts"; import { VIDEO_PROVIDERS } from "./videoRegistry.ts"; @@ -58,7 +60,8 @@ export function getRegistryMediaKinds(providerId: string): RegistryMediaKind[] { /** * Full set of serviceKinds for a provider: the explicitly declared ones (llm, - * web*, imageToText) unioned with the media kinds derived from the registries. + * web*, imageToText) unioned with the media kinds derived from the registries, + * plus `imageToText` derived from the OCR registry when not already declared. */ export function resolveProviderServiceKinds( providerId: string, @@ -66,5 +69,8 @@ export function resolveProviderServiceKinds( ): string[] { const set = new Set(declared ?? []); for (const kind of getRegistryMediaKinds(providerId)) set.add(kind); + if (Object.prototype.hasOwnProperty.call(OCR_PROVIDERS, providerId)) { + set.add("imageToText"); + } return [...set]; } diff --git a/open-sse/config/ocrRegistry.ts b/open-sse/config/ocrRegistry.ts index fdf47d44f1a..4bcc141d4cd 100644 --- a/open-sse/config/ocrRegistry.ts +++ b/open-sse/config/ocrRegistry.ts @@ -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; +} + +export interface OcrTransformation { + buildRequest(args: { + baseUrl: string; + token: string; + body: Record; + 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 | undefined): Record { + 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)), + }, + }; + }, + 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 = { mistral: { id: "mistral", @@ -31,6 +112,14 @@ export const OCR_PROVIDERS: Record = { 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, + }, }; /** diff --git a/open-sse/handlers/chatCore/upstreamTimeouts.ts b/open-sse/handlers/chatCore/upstreamTimeouts.ts index 5de972dcae3..9f0ace2b0af 100644 --- a/open-sse/handlers/chatCore/upstreamTimeouts.ts +++ b/open-sse/handlers/chatCore/upstreamTimeouts.ts @@ -1,4 +1,8 @@ -import { FETCH_TIMEOUT_MS } from "../../config/constants.ts"; +import { + EXECUTOR_CONTRACT_VIOLATION_CODE, + FETCH_TIMEOUT_MS, + HTTP_STATUS, +} from "../../config/constants.ts"; import { getModelTimeoutMs } from "../../config/providerModels.ts"; import { getLoggedInputTokens, @@ -98,6 +102,62 @@ export function getExecutorTimeoutMs(executor: unknown, provider?: string, model return resolveProviderTimeoutMs(executor); } +/** + * Cross-realm Response detection (#10360). + * + * `instanceof Response` is a NOMINAL check against `globalThis.Response`, and + * OmniRoute's default egress does not use the global one: `proxyFetch.ts` + * dispatches through the npm `undici` package's `fetch`, whose `Response` is a + * different class from the Node built-in. A bare `instanceof` therefore + * rejected virtually every real upstream response as a "contract violation". + * + * Accept the built-in fast path first, then fall back to a structural probe: + * the `Symbol.toStringTag` brand plus the members the pipeline actually reads + * (`status`/`ok`/`headers.get`/`text`/`clone`). A plain `{ status, ok }` bag + * still fails, so the guard keeps its value. + */ +export function isResponseLike(value: unknown): value is Response { + if (value instanceof Response) return true; + if (!value || typeof value !== "object") return false; + const candidate = value as { + status?: unknown; + ok?: unknown; + headers?: { get?: unknown } | null; + text?: unknown; + clone?: unknown; + }; + return ( + Object.prototype.toString.call(value) === "[object Response]" && + typeof candidate.status === "number" && + typeof candidate.ok === "boolean" && + !!candidate.headers && + typeof candidate.headers.get === "function" && + typeof candidate.text === "function" && + typeof candidate.clone === "function" + ); +} + +/** + * Builds the terminal error thrown on a genuine contract violation (#10360). + * + * Carries `status = 500` and `code = EXECUTOR_CONTRACT_VIOLATION_CODE` so the + * failure is classified as an INTERNAL, non-retryable defect instead of falling + * through chatCore's `BAD_GATEWAY` default. A 502 made every layer treat our own + * bug as a flaky provider: the connection was cooled down as "rate limited", the + * provider breaker counted it, and the batch runner (which retries 429/502/504) + * span for its full 24h window on an error that can never resolve itself. + */ +export function createExecutorContractError(): Error & { status: number; code: string } { + const err = new TypeError("Executor result must contain a Response") as TypeError & { + status: number; + code: string; + }; + err.name = "ExecutorContractError"; + err.status = HTTP_STATUS.SERVER_ERROR; + err.code = EXECUTOR_CONTRACT_VIOLATION_CODE; + return err; +} + export function normalizeExecutorResult(result: unknown): { response: Response; url: string; @@ -105,16 +165,16 @@ export function normalizeExecutorResult(result: unknown): { transformedBody: unknown; transport?: string; } { - if (result instanceof Response) { + if (isResponseLike(result)) { return { response: result, url: "", headers: {}, transformedBody: null }; } if ( !result || typeof result !== "object" || !("response" in result) || - !(result.response instanceof Response) + !isResponseLike(result.response) ) { - throw new TypeError("Executor result must contain a Response"); + throw createExecutorContractError(); } const normalized = result as { response: Response; diff --git a/open-sse/handlers/ocr.ts b/open-sse/handlers/ocr.ts index bf0c553ff08..3edf0b5e614 100644 --- a/open-sse/handlers/ocr.ts +++ b/open-sse/handlers/ocr.ts @@ -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} */ -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 { + 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} 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"); } diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index d8175272c9c..ec8ab35cd19 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -1,5 +1,6 @@ import { BACKOFF_STEPS_MS, + EXECUTOR_CONTRACT_VIOLATION_CODE, PROVIDER_PROFILES, RateLimitReason, HTTP_STATUS, @@ -1458,6 +1459,21 @@ export function checkFallbackError( * caller can persist an explicit reset window instead of the engine's scaled cooldown. */ configuredCooldownMs?: number; } { + // #10360: an executor-result contract violation is OUR bug, not the provider's. + // Retrying reproduces it verbatim, and cooling the connection down (or tripping + // the provider breaker) punishes a healthy account for an internal defect. Must + // run before every other classification — the surfaced status is a plain 500, + // which the retryable set below would otherwise treat as a transient upstream + // failure and hand a backoff cooldown. + if (structuredError?.code === EXECUTOR_CONTRACT_VIOLATION_CODE) { + return { + shouldFallback: false, + cooldownMs: 0, + reason: EXECUTOR_CONTRACT_VIOLATION_CODE, + skipProviderBreaker: true, + }; + } + const svc = serviceSupervisorCooldown(status, headers); if (svc) return svc; const rg = rot.gateFor(status, rotation?.account); diff --git a/open-sse/services/combo/comboPredicates.ts b/open-sse/services/combo/comboPredicates.ts index 6a424d7439e..dc875092365 100644 --- a/open-sse/services/combo/comboPredicates.ts +++ b/open-sse/services/combo/comboPredicates.ts @@ -6,6 +6,7 @@ * predicates are re-exported from combo.ts for backward compatibility. */ +import { EXECUTOR_CONTRACT_VIOLATION_CODE } from "../../config/constants.ts"; import { errorResponse } from "../../utils/error.ts"; import { parseModel } from "../model.ts"; import { isSelfInflictedUpstreamTimeout } from "../../handlers/chatCore/cooldownClassification.ts"; @@ -201,6 +202,9 @@ const REQUEST_SCOPED_UPSTREAM_ERROR_CODES: Record = { rate_limit_queue_timeout: true, rate_limit_queue_full: true, rate_limit_queue_wedged: true, + // #10360: our own executor-result contract violation. An internal defect, not + // a provider/account fault — it must never cool a connection or trip a breaker. + [EXECUTOR_CONTRACT_VIOLATION_CODE]: true, }; /** Request/model-specific failures must not poison provider-wide resilience state. */ diff --git a/src/app/api/v1/models/catalogHelpers.ts b/src/app/api/v1/models/catalogHelpers.ts index bb1c600a169..05996252a73 100644 --- a/src/app/api/v1/models/catalogHelpers.ts +++ b/src/app/api/v1/models/catalogHelpers.ts @@ -16,6 +16,7 @@ export interface CustomModelEntry { apiFormat?: string; supportedEndpoints?: string[]; inputTokenLimit?: number; + outputTokenLimit?: number; isHidden?: boolean; // User-set "vision-capable" flag (persisted by addCustomModel / replaceCustomModels // in src/lib/db/models.ts). Surfaced into `/v1/models` via diff --git a/src/app/api/v1/ocr/route.ts b/src/app/api/v1/ocr/route.ts index 13047927227..6dde40c609c 100644 --- a/src/app/api/v1/ocr/route.ts +++ b/src/app/api/v1/ocr/route.ts @@ -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 }, +>(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); } diff --git a/src/shared/constants/providers/apikey/gateways.ts b/src/shared/constants/providers/apikey/gateways.ts index 860117943da..c9c3ceeffb3 100644 --- a/src/shared/constants/providers/apikey/gateways.ts +++ b/src/shared/constants/providers/apikey/gateways.ts @@ -932,6 +932,10 @@ export const APIKEY_PROVIDERS_GATEWAYS = { "No free tier as of 2026 — Chutes moved to pay-as-you-go (free Early Access ended 2026-03).", authHint: "Bearer API key for the Chutes OpenAI-compatible gateway.", passthroughModels: true, + // dots.ocr (rednote-hilab/dots.ocr) is served via Chutes discovery — no static + // model entry needed (passthroughModels). Declare imageToText alongside llm + // (declaring serviceKinds means "llm" must be explicit too, see #10275). + serviceKinds: ["llm", "imageToText"], }, // Factory AI ("Factory Droids") subscription gateway — the same backend the // local `droid` CLI shells into, exposed here as an OpenAI-compatible HTTP diff --git a/stryker.conf.json b/stryker.conf.json index 1214fc37f88..7894fae4ab4 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -219,6 +219,7 @@ "tests/unit/edgetts-provider.test.ts", "tests/unit/embeddings-auth.test.ts", "tests/unit/error-classification.test.ts", + "tests/unit/executor-contract-violation-terminal.test.ts", "tests/unit/error-message-sanitization.test.ts", "tests/unit/error-sensitive-redaction.test.ts", "tests/unit/execute-chat-resource-pressure-breaker.test.ts", diff --git a/tests/unit/executor-contract-violation-terminal.test.ts b/tests/unit/executor-contract-violation-terminal.test.ts new file mode 100644 index 00000000000..e6c06c8741c --- /dev/null +++ b/tests/unit/executor-contract-violation-terminal.test.ts @@ -0,0 +1,173 @@ +/** + * #10360 — the executor-result contract guard must not hot-loop the router. + * + * Two defects, one symptom (`tests/unit/batch_api.test.ts` hanging forever): + * + * 1. CROSS-REALM FALSE POSITIVE. The guard added in #10256 used a bare + * `result.response instanceof Response`. OmniRoute's default egress + * (`open-sse/utils/proxyFetch.ts`) is the npm `undici` package's `fetch`, + * whose `Response` class is NOT `globalThis.Response` — so every ordinary + * upstream response arrived as a "contract violation". The guard must + * recognize a structurally valid Response from any realm. + * + * 2. TRANSIENT MISCLASSIFICATION. A genuine contract violation is an INTERNAL + * bug, not a flaky upstream. It carried no `.status`, so chatCore's default + * mapped it to 502 → the connection got cooled down as "rate limited", the + * provider breaker counted it, and `processSingleItemWithRetry` (which + * retries 429/502/504 up to 200×/24h) span forever. It must surface as a + * terminal internal 500 carrying a stable error code, and every resilience + * layer must treat that code as request-scoped: no cooldown, no breaker. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Response as UndiciResponse } from "undici"; + +import { normalizeExecutorResult } from "../../open-sse/handlers/chatCore/upstreamTimeouts.ts"; +import { EXECUTOR_CONTRACT_VIOLATION_CODE } from "../../open-sse/config/constants.ts"; +import { + isRequestScopedUpstreamFailure, + shouldSkipConnDisable, +} from "../../open-sse/services/combo/comboPredicates.ts"; +import { shouldTripProviderBreakerForResult } from "../../src/sse/handlers/chatPredicates.ts"; +import { checkFallbackError } from "../../open-sse/services/accountFallback.ts"; + +// ─── 1. Cross-realm Response acceptance ────────────────────────────────────── + +test("undici's Response is a different class than the global one (premise)", () => { + assert.notEqual( + UndiciResponse as unknown, + globalThis.Response as unknown, + "if these ever become the same class the cross-realm guard below is moot" + ); + assert.equal( + new UndiciResponse("x", { status: 200 }) instanceof globalThis.Response, + false, + "premise: an undici Response fails a bare `instanceof Response`" + ); +}); + +test("normalizeExecutorResult accepts a cross-realm Response in the capture-object arm", () => { + const response = new UndiciResponse(JSON.stringify({ ok: true }), { status: 401 }); + + const normalized = normalizeExecutorResult({ + response, + url: "https://api.openai.com/v1/chat/completions", + headers: { "x-req": "1" }, + transformedBody: { a: 1 }, + }); + + assert.equal(normalized.response, response as unknown); + assert.equal(normalized.response.status, 401); + assert.equal(normalized.url, "https://api.openai.com/v1/chat/completions"); + assert.deepEqual(normalized.headers, { "x-req": "1" }); + assert.deepEqual(normalized.transformedBody, { a: 1 }); +}); + +test("normalizeExecutorResult accepts a bare cross-realm Response", () => { + const response = new UndiciResponse("body", { status: 503 }); + + const normalized = normalizeExecutorResult(response); + + assert.equal(normalized.response, response as unknown); + assert.equal(normalized.response.status, 503); + assert.equal(normalized.url, ""); + assert.deepEqual(normalized.headers, {}); + assert.equal(normalized.transformedBody, null); +}); + +// ─── 2. A genuine violation is terminal, not a transient provider failure ──── + +function captureThrow(run: () => unknown): Error & { status?: unknown; code?: unknown } { + try { + run(); + } catch (err) { + return err as Error & { status?: unknown; code?: unknown }; + } + throw new assert.AssertionError({ message: "expected normalizeExecutorResult to throw" }); +} + +test("a genuinely malformed executor result still throws", () => { + assert.throws(() => normalizeExecutorResult({}), /must contain a Response/); + assert.throws(() => normalizeExecutorResult(undefined), /must contain a Response/); + assert.throws(() => normalizeExecutorResult({ response: "not-a-response" }), /must contain a/); + // A partial look-alike (no body readers) must NOT slip past the duck-type. + assert.throws( + () => normalizeExecutorResult({ response: { status: 200, ok: true } }), + /must contain a Response/ + ); +}); + +test("the contract-violation error carries an internal-terminal status + stable code", () => { + const err = captureThrow(() => normalizeExecutorResult({ response: "not-a-response" })); + + assert.equal(err.status, 500, "an internal contract violation is a 500, never a provider 502"); + assert.equal( + err.code, + EXECUTOR_CONTRACT_VIOLATION_CODE, + "chatCore reads `.code` (getUpstreamErrorIdentifier) to tag the surfaced error" + ); + assert.equal(EXECUTOR_CONTRACT_VIOLATION_CODE, "executor_contract_violation"); +}); + +test("the contract-violation code is classified as a request-scoped failure", () => { + assert.equal(isRequestScopedUpstreamFailure({ code: EXECUTOR_CONTRACT_VIOLATION_CODE }), true); +}); + +test("a contract violation must not cool the connection down", () => { + assert.equal( + shouldSkipConnDisable( + { + status: 500, + errorCode: EXECUTOR_CONTRACT_VIOLATION_CODE, + errorType: null, + error: "Executor result must contain a Response", + }, + false, + false, + "openai" + ), + true, + "our own bug must never mark the operator's account as rate-limited/unavailable" + ); +}); + +test("a contract violation must not trip the provider circuit breaker", () => { + assert.equal( + shouldTripProviderBreakerForResult( + { + status: 500, + errorCode: EXECUTOR_CONTRACT_VIOLATION_CODE, + errorType: null, + error: "Executor result must contain a Response", + }, + false, + false + ), + false, + "500 is a breaker-failure status, but this one never reached the provider" + ); +}); + +test("checkFallbackError treats the contract violation as terminal — no retry, no cooldown", () => { + const decision = checkFallbackError( + 500, + "[500]: Executor result must contain a Response", + 0, + "gpt-4o-mini", + "openai", + null, + null, + { code: EXECUTOR_CONTRACT_VIOLATION_CODE } + ); + + assert.equal(decision.shouldFallback, false, "retrying our own bug just reproduces it"); + assert.equal(decision.cooldownMs, 0, "no connection cooldown for an internal defect"); + assert.equal(decision.skipProviderBreaker, true); +}); + +test("a real provider 500 is still retryable (the terminal branch is not over-broad)", () => { + const decision = checkFallbackError(500, "Internal server error", 0, null, "openai"); + + assert.equal(decision.shouldFallback, true); + assert.ok(decision.cooldownMs > 0, "a genuine upstream 500 keeps its backoff cooldown"); +}); diff --git a/tests/unit/imagetotext-derivation.test.ts b/tests/unit/imagetotext-derivation.test.ts new file mode 100644 index 00000000000..59cfad7db8d --- /dev/null +++ b/tests/unit/imagetotext-derivation.test.ts @@ -0,0 +1,21 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resolveProviderServiceKinds } from "../../open-sse/config/mediaServiceKinds.ts"; +import { AI_PROVIDERS } from "../../src/shared/constants/providers.ts"; + +test("OCR-registry providers derive imageToText without manual declaration", () => { + assert.ok(resolveProviderServiceKinds("mistral", undefined).includes("imageToText")); + assert.ok( + resolveProviderServiceKinds("azure-document-intelligence", undefined).includes("imageToText") + ); +}); + +test("non-OCR providers do not gain imageToText implicitly", () => { + assert.ok(!resolveProviderServiceKinds("groq", undefined).includes("imageToText")); +}); + +test("chutes declares llm + imageToText (dots.ocr seed, served via passthrough discovery)", () => { + const kinds = resolveProviderServiceKinds("chutes", AI_PROVIDERS.chutes.serviceKinds); + assert.ok(kinds.includes("imageToText")); + assert.ok(kinds.includes("llm")); +}); diff --git a/tests/unit/model-token-limit-catalog.test.ts b/tests/unit/model-token-limit-catalog.test.ts index f176cf087df..b456ce42add 100644 --- a/tests/unit/model-token-limit-catalog.test.ts +++ b/tests/unit/model-token-limit-catalog.test.ts @@ -251,6 +251,11 @@ test("v1 model catalog overlays same-id custom metadata before final overrides", { outputTokenLimit: 32000 }, false ); + + const customProjected = await getModel(`${prefix}/${modelId}`); + assert.ok(customProjected); + assert.equal(customProjected.max_output_tokens, 32000); + assert.equal( capabilityOverrides.setModelCapabilityOverride( `${prefix}/${modelId}`, diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index 2db47dbf9c3..cebb7cc89d6 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -1398,8 +1398,15 @@ test("v1 models catalog skips duplicate built-ins and custom models from inactiv const duplicateBuiltins = body.data.filter((item) => item.id === "openai/gpt-4o-2024-11-20"); assert.equal(response.status, 200); + // Still exactly one entry: the custom row overlays the built-in, it does not duplicate it. assert.equal(duplicateBuiltins.length, 1); - assert.equal(duplicateBuiltins[0].custom === true, false); + // #10248 changed the contract: a custom row for an id that already exists is the + // operator-owned overlay for that model (catalog.ts:1330) — its explicitly stored + // fields win over the discovered metadata, and the merged entry is flagged `custom`. + // Before #10248 the duplicate was skipped outright, so this asserted `false`. + assert.equal(duplicateBuiltins[0].custom, true); + // The overlay must keep the catalog identity rather than becoming a detached entry. + assert.equal(duplicateBuiltins[0].id, "openai/gpt-4o-2024-11-20"); assert.equal( body.data.some((item) => item.id === "cl/inactive-only" || item.id === "cline/inactive-only"), false diff --git a/tests/unit/ocr-handler-dispatch.test.ts b/tests/unit/ocr-handler-dispatch.test.ts new file mode 100644 index 00000000000..2474f6b0b29 --- /dev/null +++ b/tests/unit/ocr-handler-dispatch.test.ts @@ -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; 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); +}); diff --git a/tests/unit/ocr-registry-transformations.test.ts b/tests/unit/ocr-registry-transformations.test.ts new file mode 100644 index 00000000000..ce7454ac7dd --- /dev/null +++ b/tests/unit/ocr-registry-transformations.test.ts @@ -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).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)["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"); +}); diff --git a/tests/unit/ocr-route-contract.test.ts b/tests/unit/ocr-route-contract.test.ts new file mode 100644 index 00000000000..c5caf8982c5 --- /dev/null +++ b/tests/unit/ocr-route-contract.test.ts @@ -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); +}); diff --git a/tests/unit/ocr-route.test.ts b/tests/unit/ocr-route.test.ts index 5047311c381..31bfc7cffed 100644 --- a/tests/unit/ocr-route.test.ts +++ b/tests/unit/ocr-route.test.ts @@ -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 /")); });