mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 19:22:32 +03:00
Compare commits
2 Commits
feat/ocr-v
...
feat/10273
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49e6623751 | ||
|
|
8a1edda52a |
11
.env.example
11
.env.example
@@ -109,6 +109,17 @@ PORT=20128
|
||||
# stay consistent without relying on window.location.origin alone:
|
||||
# NEXT_PUBLIC_BASE_URL=https://host/omniroute
|
||||
|
||||
# Opt-in iframe embedding of the OmniRoute HTML pages (issue #10273). Off by default:
|
||||
# every route ships `frame-ancestors 'none'` + `X-Frame-Options: DENY`, which is why the
|
||||
# VS Code Simple Browser (used by the OmniCopilot extension's "Open Dashboard → editor"
|
||||
# mode) renders a blank tab. Set this to `vscode` to switch the HTML pages — dashboard,
|
||||
# login, docs, landing — to `frame-ancestors 'self' vscode-webview:` and drop
|
||||
# X-Frame-Options for them (XFO cannot express a custom scheme). The API surface
|
||||
# (/api, /v1, /v1beta, /a2a, /healthz and the root-level aliases) keeps the strict
|
||||
# headers regardless. Only `vscode` is recognised; `1`/`true` do NOT enable it.
|
||||
# Used by: next.config.mjs via scripts/build/dashboardEmbed.mjs — build-time, rebuild after changing.
|
||||
# DASHBOARD_ALLOW_EMBED=vscode
|
||||
|
||||
# Split-port mode: serve Dashboard and API on separate ports for network isolation.
|
||||
# Used by: src/lib/runtime/ports.ts — overrides PORT for each service.
|
||||
# API_PORT=20129
|
||||
|
||||
1
changelog.d/features/10273-dashboard-embed-csp.md
Normal file
1
changelog.d/features/10273-dashboard-embed-csp.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(dashboard): opt-in `DASHBOARD_ALLOW_EMBED=vscode` relaxes CSP `frame-ancestors` to `'self' vscode-webview:` and drops `X-Frame-Options` for HTML pages only, so the dashboard renders inside the VS Code Simple Browser (OmniCopilot). Default posture unchanged — API routes stay unframable (#10273)
|
||||
@@ -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": 415,
|
||||
"value": 409,
|
||||
"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,8 +111,7 @@
|
||||
"_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_14_ocr_imagetotext_series": "OCR/image-to-text series: new public util/registry exports covered by unit tests but without a second production caller yet. Structural cleanup tracked in #3501."
|
||||
"_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."
|
||||
},
|
||||
"cognitiveComplexity": {
|
||||
"value": 1223,
|
||||
|
||||
@@ -6840,18 +6840,9 @@ paths:
|
||||
- Images
|
||||
summary: Document OCR
|
||||
description: >-
|
||||
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`,
|
||||
`vertex-deepseek-ocr/deepseek-ocr-maas`); 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.
|
||||
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.
|
||||
security:
|
||||
- BearerAuth: []
|
||||
requestBody:
|
||||
@@ -6863,12 +6854,6 @@ paths:
|
||||
properties:
|
||||
model:
|
||||
type: string
|
||||
description: >-
|
||||
`provider/model` id or bare model id. Registered ids:
|
||||
`mistral/mistral-ocr-latest`,
|
||||
`azure-document-intelligence/prebuilt-read`,
|
||||
`vertex-deepseek-ocr/deepseek-ocr-maas`. Defaults to
|
||||
`mistral-ocr-latest` when omitted.
|
||||
document:
|
||||
type: object
|
||||
responses:
|
||||
|
||||
@@ -17,7 +17,6 @@ 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)
|
||||
@@ -200,67 +199,6 @@ 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. |
|
||||
| `vertex-deepseek-ocr` | `deepseek-ocr-maas` | `vertex-deepseek-ocr/deepseek-ocr-maas` | Synchronous, via Vertex AI's `openapi/chat/completions` partner endpoint — see below for auth/URL. |
|
||||
|
||||
All three 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.
|
||||
|
||||
### Vertex AI DeepSeek OCR auth and endpoint resolution
|
||||
|
||||
`vertex-deepseek-ocr` reuses the same Vertex AI authentication OmniRoute already supports for
|
||||
chat/image traffic (`open-sse/executors/vertex.ts`): the connection's API key is either a
|
||||
Service Account JSON credential (exchanged for a short-lived OAuth access token via the JWT-bearer
|
||||
flow) or an already-minted OAuth access token used as-is. The upstream endpoint URL is Vertex's
|
||||
generic `openapi/chat/completions` partner endpoint, built from the connection's project and
|
||||
region — an explicit `providerSpecificData.project`/`providerSpecificData.region` always wins;
|
||||
otherwise the project is derived from the Service Account JSON's `project_id` and the region
|
||||
defaults to `us-central1`. Both resolutions happen in `open-sse/handlers/ocr.ts`
|
||||
(`resolveVertexOcrAccessToken`, `resolveVertexOcrBaseUrl`), consumed by
|
||||
`src/app/api/v1/ocr/route.ts` before dispatching to `handleOcr`.
|
||||
|
||||
---
|
||||
|
||||
## List Models
|
||||
|
||||
```bash
|
||||
@@ -551,18 +489,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]`):
|
||||
@@ -665,13 +603,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
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
|
||||
| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). |
|
||||
| `OMNIROUTE_BASE_PATH` | _(empty = root)_ | `next.config.mjs`, `scripts/docker/ensure-docker-base-path.mjs` | URL subpath for serving OmniRoute behind a reverse proxy (sets Next.js `basePath`; auth redirects are basePath-aware). E.g. `/omniroute`. In Docker the value is baked during `docker build` (`ARG OMNIROUTE_BASE_PATH`); pre-built root images can apply a different runtime value once at container start before Next.js boots. Set `NEXT_PUBLIC_BASE_URL` to the public origin including the same subpath. |
|
||||
| `NEXT_PUBLIC_OMNIROUTE_BASE_PATH` | _(empty = root)_ | `src/shared/hooks/useDisplayBaseUrl.ts` | Browser-visible mirror of `OMNIROUTE_BASE_PATH`, inlined at build time so the dashboard endpoint display shows `https://host/omniroute/v1` instead of `https://host/v1`. Falls back to `OMNIROUTE_BASE_PATH` when unset. Rebuild after changing (Next `basePath` is build-time). |
|
||||
| `DASHBOARD_ALLOW_EMBED` | _(unset = never framable)_ | `next.config.mjs`, `scripts/build/dashboardEmbed.mjs` | Opt-in iframe embedding of the HTML pages. Unset, every route ships `frame-ancestors 'none'` + `X-Frame-Options: DENY`. Set to `vscode` to serve the pages (dashboard, login, docs, landing) with `frame-ancestors 'self' vscode-webview:` and no `X-Frame-Options`, so the VS Code Simple Browser can render them (OmniCopilot's `dashboardOpen: "editor"` mode). The API surface (`/api`, `/v1`, `/v1beta`, `/a2a`, `/healthz`, root-level aliases) keeps the strict headers either way. Only `vscode` is recognised — `1`/`true` do not enable it. Build-time: rebuild after changing. |
|
||||
| `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. |
|
||||
| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. |
|
||||
| `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. |
|
||||
|
||||
@@ -4,6 +4,11 @@ import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mitmManagerAliasFor } from "./scripts/build/mitm-stub-flag.mjs";
|
||||
import { normalizeBasePath } from "./scripts/build/normalizeBasePath.mjs";
|
||||
import {
|
||||
buildSecurityHeaderRules,
|
||||
nonPageRoutePrefixes,
|
||||
resolveDashboardEmbedMode,
|
||||
} from "./scripts/build/dashboardEmbed.mjs";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||
const distDir = process.env.NEXT_DIST_DIR || ".build/next";
|
||||
@@ -75,6 +80,11 @@ function isNextIntlExtractorDynamicImportWarning(warning) {
|
||||
// for security-sensitive environments. See docs/security/SOCKET_DEV_FINDINGS.md.
|
||||
const isMinimalBuild = process.env.OMNIROUTE_BUILD_PROFILE === "minimal";
|
||||
|
||||
// #10273: `null` unless the operator opts in with DASHBOARD_ALLOW_EMBED=vscode. Read at build
|
||||
// time like every other knob in this file (OMNIROUTE_BASE_PATH, OMNIROUTE_BUILD_PROFILE, …),
|
||||
// so changing it requires a rebuild. See scripts/build/dashboardEmbed.mjs.
|
||||
const dashboardEmbedMode = resolveDashboardEmbedMode(process.env);
|
||||
|
||||
const minimalBuildAliases = isMinimalBuild
|
||||
? {
|
||||
"@/mitm/cert/install": "./src/mitm/cert/install.stub.ts",
|
||||
@@ -389,11 +399,21 @@ const nextConfig = {
|
||||
},
|
||||
|
||||
async headers() {
|
||||
// #10273: opt-in embedding for the VS Code Simple Browser (OmniCopilot). Off by default —
|
||||
// `securityHeaders` then applies to `/:path*` exactly as it always has. When the operator
|
||||
// sets DASHBOARD_ALLOW_EMBED=vscode, buildSecurityHeaderRules() splits that catch-all into
|
||||
// two complementary rules: the API surface keeps `frame-ancestors 'none'` + X-Frame-Options,
|
||||
// the HTML pages get `frame-ancestors 'self' vscode-webview:` and no X-Frame-Options.
|
||||
// The exclusion list is DERIVED from the rewrite table below (self-reference is safe — the
|
||||
// config object is fully built by the time Next calls headers()), so a future root-level API
|
||||
// alias is excluded automatically instead of silently becoming framable.
|
||||
const embedRules = buildSecurityHeaderRules({
|
||||
mode: dashboardEmbedMode,
|
||||
securityHeaders,
|
||||
prefixes: dashboardEmbedMode ? nonPageRoutePrefixes(await nextConfig.rewrites()) : [],
|
||||
});
|
||||
return [
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: securityHeaders,
|
||||
},
|
||||
...embedRules,
|
||||
// G-10: allow OmniRoute's own dashboard to embed the 9Router UI via our reverse proxy.
|
||||
// `frame-ancestors 'self'` overrides the global `frame-ancestors 'none'` only for this
|
||||
// path. The route is already LOCAL_ONLY (routeGuard.ts) so remote origins cannot reach it.
|
||||
|
||||
@@ -16,7 +16,6 @@ export interface OcrProvider {
|
||||
authType: string;
|
||||
authHeader: string;
|
||||
models: OcrModel[];
|
||||
transformation?: OcrTransformation;
|
||||
}
|
||||
|
||||
export interface ParsedOcrModel {
|
||||
@@ -24,160 +23,6 @@ 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 },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Vertex AI DeepSeek OCR (deepseek-ai/deepseek-ocr-maas), served through Vertex's generic
|
||||
* OpenAI-compatible partner endpoint ("openapi/chat/completions"). Modeled on litellm's
|
||||
* VertexAIDeepSeekOCRConfig (litellm/llms/vertex_ai/ocr/deepseek_transformation.py):
|
||||
* - request: OpenAI chat-completions shape, model prefixed with "deepseek-ai/", the OCR
|
||||
* document sent as a single image_url content part (document_url documents are mapped to
|
||||
* the same image_url shape — Vertex accepts both gs:// and https:// URLs there).
|
||||
* - response: an OpenAI chat-completions body whose choices[0].message.content is either a
|
||||
* JSON string already in the canonical {pages,model,usage_info} shape, or plain markdown
|
||||
* text — both are normalized into OcrResponseShape.
|
||||
*
|
||||
* The full project/location endpoint URL is resolved into credentials.baseUrl upstream (see
|
||||
* resolveOcrCredentials in src/app/api/v1/ocr/route.ts, the same pattern Azure DI uses for its
|
||||
* resource endpoint) — buildRequest treats baseUrl as the complete URL, exactly like Mistral.
|
||||
*/
|
||||
function vertexDeepseekOcrContent(document: Record<string, unknown> | undefined): {
|
||||
type: string;
|
||||
image_url: string;
|
||||
} {
|
||||
const url = String(document?.document_url ?? document?.image_url ?? "");
|
||||
return { type: "image_url", image_url: url };
|
||||
}
|
||||
|
||||
export const VERTEX_DEEPSEEK_TRANSFORMATION: OcrTransformation = {
|
||||
buildRequest({ baseUrl, token, body, modelId }) {
|
||||
return {
|
||||
url: baseUrl,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
model: `deepseek-ai/${modelId}`,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [vertexDeepseekOcrContent(body.document as Record<string, unknown>)],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
parseResponse(raw) {
|
||||
const r = raw as {
|
||||
model?: string;
|
||||
choices?: Array<{ message?: { content?: unknown } }>;
|
||||
usage?: Record<string, unknown>;
|
||||
};
|
||||
const model = r.model ?? "deepseek-ocr-maas";
|
||||
const content = r.choices?.[0]?.message?.content;
|
||||
|
||||
if (typeof content === "string") {
|
||||
const trimmed = content.trim();
|
||||
if (trimmed.startsWith("{")) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Partial<OcrResponseShape>;
|
||||
if (Array.isArray(parsed.pages)) {
|
||||
return {
|
||||
pages: parsed.pages,
|
||||
model: parsed.model ?? model,
|
||||
usage_info: parsed.usage_info ?? r.usage,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Not JSON after all — fall through and treat it as plain markdown.
|
||||
}
|
||||
}
|
||||
return { pages: [{ index: 0, markdown: content }], model, usage_info: r.usage };
|
||||
}
|
||||
|
||||
return { pages: [{ index: 0, markdown: "" }], model, usage_info: r.usage };
|
||||
},
|
||||
};
|
||||
|
||||
export const OCR_PROVIDERS: Record<string, OcrProvider> = {
|
||||
mistral: {
|
||||
id: "mistral",
|
||||
@@ -186,22 +31,6 @@ 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,
|
||||
},
|
||||
"vertex-deepseek-ocr": {
|
||||
id: "vertex-deepseek-ocr",
|
||||
baseUrl: "",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [{ id: "deepseek-ocr-maas", name: "DeepSeek OCR (Vertex AI MaaS)" }],
|
||||
transformation: VERTEX_DEEPSEEK_TRANSFORMATION,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,114 +5,21 @@ import { CORS_HEADERS } from "../utils/cors.ts";
|
||||
* Handles POST /v1/ocr (Mistral OCR API format).
|
||||
*/
|
||||
|
||||
import {
|
||||
getOcrProvider,
|
||||
getOcrTransformation,
|
||||
parseOcrModel,
|
||||
OCR_PROVIDERS,
|
||||
} from "../config/ocrRegistry.ts";
|
||||
import { getOcrProvider, parseOcrModel } from "../config/ocrRegistry.ts";
|
||||
import { errorResponse } from "../utils/error.ts";
|
||||
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
|
||||
import { generateRequestId } from "@/shared/utils/requestId";
|
||||
import {
|
||||
getAccessToken,
|
||||
looksLikeServiceAccountJson,
|
||||
parseSAFromApiKey,
|
||||
} from "../executors/vertex.ts";
|
||||
|
||||
const OCR_POLL_MAX_ATTEMPTS = 30;
|
||||
const OCR_POLL_INTERVAL_MS = 1000;
|
||||
|
||||
const defaultSleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export const VERTEX_DEEPSEEK_OCR_PROVIDER_ID = "vertex-deepseek-ocr";
|
||||
const VERTEX_OCR_DEFAULT_REGION = "us-central1";
|
||||
|
||||
/**
|
||||
* Resolve the Vertex AI project id backing a vertex-deepseek-ocr connection: an explicit
|
||||
* providerSpecificData.project always wins; otherwise fall back to the project_id embedded in
|
||||
* the Service Account JSON credential (the same source VertexExecutor.buildUrl uses for the
|
||||
* chat/image pipeline — open-sse/executors/vertex.ts). Returns null when neither is available.
|
||||
* Kept in this handler (rather than the route) because routes may not import executors
|
||||
* directly (see EXECUTOR_IMPORT_RESTRICTION in eslint.config.mjs) — this stays behind the
|
||||
* open-sse handler boundary and is re-exported for the route to call.
|
||||
*/
|
||||
function resolveVertexOcrProject(credentials: {
|
||||
apiKey?: string;
|
||||
providerSpecificData?: Record<string, unknown>;
|
||||
}): string | null {
|
||||
const explicitProject = credentials.providerSpecificData?.project;
|
||||
if (typeof explicitProject === "string" && explicitProject.trim()) return explicitProject;
|
||||
if (credentials.apiKey && looksLikeServiceAccountJson(credentials.apiKey)) {
|
||||
try {
|
||||
const projectId = parseSAFromApiKey(credentials.apiKey).project_id;
|
||||
return typeof projectId === "string" && projectId.trim() ? projectId : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the full Vertex AI DeepSeek OCR endpoint URL (the generic Vertex
|
||||
* "openapi/chat/completions" partner endpoint — see VERTEX_DEEPSEEK_TRANSFORMATION in
|
||||
* open-sse/config/ocrRegistry.ts) from the resolved project + region, or null when the
|
||||
* project cannot be resolved (handleOcr then surfaces the standard "No base URL configured"
|
||||
* error, since OCR_PROVIDERS["vertex-deepseek-ocr"].baseUrl is intentionally empty).
|
||||
*/
|
||||
export function resolveVertexOcrBaseUrl(credentials: {
|
||||
apiKey?: string;
|
||||
providerSpecificData?: Record<string, unknown>;
|
||||
}): string | null {
|
||||
const project = resolveVertexOcrProject(credentials);
|
||||
if (!project) return null;
|
||||
const region = credentials.providerSpecificData?.region;
|
||||
const resolvedRegion =
|
||||
typeof region === "string" && region.trim() ? region : VERTEX_OCR_DEFAULT_REGION;
|
||||
return `https://aiplatform.googleapis.com/v1/projects/${project}/locations/${resolvedRegion}/endpoints/openapi/chat/completions`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a short-lived Vertex AI OAuth access token for vertex-deepseek-ocr connections that
|
||||
* authenticate with a Service Account JSON credential, reusing the exact JWT-bearer exchange
|
||||
* the chat/image executor already uses (open-sse/executors/vertex.ts::getAccessToken) — no new
|
||||
* OAuth flow. A raw (non-JSON) apiKey is treated as an already-minted OAuth access token and
|
||||
* used as-is (matches the Vertex provider's "Service Account JSON or OAuth access_token"
|
||||
* authHint), and an existing credentials.accessToken always wins.
|
||||
*/
|
||||
export async function resolveVertexOcrAccessToken<
|
||||
T extends { apiKey?: string; accessToken?: string },
|
||||
>(providerId: string, credentials: T): Promise<T> {
|
||||
if (providerId !== VERTEX_DEEPSEEK_OCR_PROVIDER_ID) return credentials;
|
||||
if (credentials.accessToken || !credentials.apiKey) return credentials;
|
||||
if (!looksLikeServiceAccountJson(credentials.apiKey)) return credentials;
|
||||
const accessToken = await getAccessToken(parseSAFromApiKey(credentials.apiKey));
|
||||
return { ...credentials, accessToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, 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
|
||||
* @param {Object} options.credentials - Provider credentials { apiKey }
|
||||
* @returns {Response}
|
||||
*/
|
||||
/** @returns {Promise<unknown>} */
|
||||
export async function handleOcr({
|
||||
body,
|
||||
credentials,
|
||||
fetchImpl = fetch,
|
||||
sleepImpl = defaultSleep,
|
||||
}) {
|
||||
export async function handleOcr({ body, credentials }) {
|
||||
const startTime = Date.now();
|
||||
if (!body.document) {
|
||||
return errorResponse(400, "document is required");
|
||||
@@ -124,30 +31,26 @@ export async function handleOcr({
|
||||
const providerConfig = providerId ? getOcrProvider(providerId) : null;
|
||||
|
||||
if (!providerConfig) {
|
||||
return errorResponse(
|
||||
400,
|
||||
`No OCR provider found for model "${model}". Available: ${Object.keys(OCR_PROVIDERS).join(", ")}`
|
||||
);
|
||||
return errorResponse(400, `No OCR provider found for model "${model}". Available: mistral`);
|
||||
}
|
||||
|
||||
// accessToken wins when both are present: providers like vertex-deepseek-ocr resolve a
|
||||
// short-lived OAuth token from a Service Account JSON apiKey (see resolveVertexOcrAccessToken
|
||||
// in src/app/api/v1/ocr/route.ts) while keeping the original apiKey around for other
|
||||
// resolution steps (e.g. deriving the project id) — the minted token must be the one sent.
|
||||
const token = credentials?.accessToken || credentials?.apiKey;
|
||||
const token = credentials?.apiKey || credentials?.accessToken;
|
||||
if (!token) {
|
||||
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 transformation = getOcrTransformation(providerId);
|
||||
const { url, init } = transformation.buildRequest({ baseUrl, token, body, modelId });
|
||||
const res = await fetchImpl(url, init);
|
||||
const res = await fetch(providerConfig.baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
model: modelId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
@@ -160,17 +63,7 @@ export async function handleOcr({
|
||||
});
|
||||
}
|
||||
|
||||
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 data = await res.json();
|
||||
const headers = new Headers({ ...CORS_HEADERS, "Content-Type": "application/json" });
|
||||
attachOmniRouteMetaHeaders(headers, {
|
||||
provider: providerId,
|
||||
@@ -179,48 +72,8 @@ export async function handleOcr({
|
||||
latencyMs: Date.now() - startTime,
|
||||
requestId: generateRequestId(),
|
||||
});
|
||||
return new Response(JSON.stringify(parsed), { status: 200, headers });
|
||||
return new Response(JSON.stringify(data), { status: 200, headers });
|
||||
} catch (err) {
|
||||
console.error("[OCR]", err);
|
||||
return errorResponse(500, "OCR request failed");
|
||||
return errorResponse(500, `OCR request failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
|
||||
142
scripts/build/dashboardEmbed.mjs
Normal file
142
scripts/build/dashboardEmbed.mjs
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Opt-in iframe embedding for OmniRoute's HTML pages (#10273).
|
||||
*
|
||||
* OmniRoute ships `frame-ancestors 'none'` + `X-Frame-Options: DENY` on every route, which
|
||||
* is the right default for a proxy that holds provider credentials. The OmniCopilot VS Code
|
||||
* extension, however, renders the dashboard inside the built-in Simple Browser — an iframe
|
||||
* whose ancestor is a `vscode-webview:` document — so the strict default paints a blank tab.
|
||||
*
|
||||
* Setting `DASHBOARD_ALLOW_EMBED=vscode` at build time swaps the page surface to
|
||||
* `frame-ancestors 'self' vscode-webview:` and drops `X-Frame-Options` for those pages.
|
||||
* XFO has no syntax for a custom scheme, and keeping `DENY` alongside a permissive
|
||||
* `frame-ancestors` would still block the frame in engines that honour XFO first — dropping
|
||||
* it is required, not cosmetic. (Modern engines ignore XFO entirely once `frame-ancestors`
|
||||
* is present, so nothing is lost where CSP is supported.)
|
||||
*
|
||||
* The API surface is deliberately left out: `/api/*`, `/v1*`, `/a2a`, `/healthz` and every
|
||||
* root-level rewrite alias keep the strict headers even in embed mode. Those are the
|
||||
* Hard-Rule-15/17 process-spawning and proxy surfaces and never need framing.
|
||||
*
|
||||
* Build-time by design: Next.js resolves `headers()` when the config loads, matching the
|
||||
* existing env-driven knobs in `next.config.mjs` (`OMNIROUTE_BASE_PATH`,
|
||||
* `OMNIROUTE_BUILD_PROFILE`, …). Changing the value requires a rebuild.
|
||||
*/
|
||||
|
||||
export const DASHBOARD_EMBED_ENV = "DASHBOARD_ALLOW_EMBED";
|
||||
|
||||
/** Ancestor allow-list per supported embed mode. Adding a mode here is the only extension point. */
|
||||
export const EMBED_FRAME_ANCESTORS = Object.freeze({
|
||||
// `vscode-webview:` is the scheme VS Code assigns to webview/Simple Browser documents.
|
||||
// `'self'` keeps OmniRoute's own same-origin frames (e.g. the G-10 9Router embed) working.
|
||||
vscode: "'self' vscode-webview:",
|
||||
});
|
||||
|
||||
/** The strict `frame-ancestors` token the CSP carries by default. */
|
||||
export const STRICT_FRAME_ANCESTORS = "frame-ancestors 'none'";
|
||||
|
||||
/**
|
||||
* App-router surfaces that are not HTML pages and have no `rewrites()` alias to derive them
|
||||
* from. Everything else in the exclusion list comes from the rewrite table, so a future API
|
||||
* alias is excluded automatically instead of silently becoming framable.
|
||||
*/
|
||||
export const STATIC_NON_PAGE_PREFIXES = Object.freeze(["api", "a2a", "healthz"]);
|
||||
|
||||
/**
|
||||
* Resolve the opt-in embed mode from the environment.
|
||||
* Unknown / truthy-looking values (`1`, `true`, `on`) intentionally do NOT enable embedding:
|
||||
* the operator must name the ancestor family they are opening up.
|
||||
*
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @returns {"vscode" | null}
|
||||
*/
|
||||
export function resolveDashboardEmbedMode(env = process.env) {
|
||||
const raw = env?.[DASHBOARD_EMBED_ENV];
|
||||
if (typeof raw !== "string") return null;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
return Object.hasOwn(EMBED_FRAME_ANCESTORS, normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first path segment of every route that must stay unframable, derived from the
|
||||
* `rewrites()` table plus the static app-router API surfaces.
|
||||
*
|
||||
* @param {{ source: string }[]} rewriteRules
|
||||
* @returns {string[]} sorted, de-duplicated prefixes
|
||||
*/
|
||||
export function nonPageRoutePrefixes(rewriteRules = []) {
|
||||
const prefixes = new Set(STATIC_NON_PAGE_PREFIXES);
|
||||
for (const { source } of rewriteRules) {
|
||||
const first = source.replace(/^\//, "").split("/")[0];
|
||||
// Skip parameterised first segments (`/:path*`) — they would exclude the whole site.
|
||||
if (first && !first.startsWith(":")) prefixes.add(first);
|
||||
}
|
||||
return [...prefixes].sort();
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Two complementary Next.js `source` patterns built from the same prefix list, so the union
|
||||
* covers every pathname exactly once — no gap (a page with no security headers) and no
|
||||
* overlap (an order-dependent merge).
|
||||
*
|
||||
* @param {string[]} prefixes
|
||||
* @returns {{ nonPageSource: string, pageSource: string }}
|
||||
*/
|
||||
export function complementarySources(prefixes) {
|
||||
const alternation = prefixes.map(escapeRegExp).join("|");
|
||||
const boundary = `(?:${alternation})(?:/|$)`;
|
||||
return {
|
||||
nonPageSource: `/((?=${boundary}).*)`,
|
||||
pageSource: `/((?!${boundary}).*)`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap only the `frame-ancestors` token of an existing CSP, leaving every other directive
|
||||
* byte-identical.
|
||||
*
|
||||
* @param {string} contentSecurityPolicy
|
||||
* @param {"vscode"} mode
|
||||
*/
|
||||
export function relaxFrameAncestors(contentSecurityPolicy, mode) {
|
||||
return contentSecurityPolicy.replace(
|
||||
STRICT_FRAME_ANCESTORS,
|
||||
`frame-ancestors ${EMBED_FRAME_ANCESTORS[mode]}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `headers()` rules carrying OmniRoute's baseline security headers.
|
||||
*
|
||||
* With embedding off this returns the single catch-all rule the config has always had, so a
|
||||
* default build is unchanged. With embedding on it returns two complementary rules: the API
|
||||
* surface keeps the strict headers, the page surface gets the relaxed CSP and no XFO.
|
||||
*
|
||||
* @param {{
|
||||
* mode: "vscode" | null,
|
||||
* securityHeaders: { key: string, value: string }[],
|
||||
* prefixes?: string[],
|
||||
* }} options
|
||||
* @returns {{ source: string, headers: { key: string, value: string }[] }[]}
|
||||
*/
|
||||
export function buildSecurityHeaderRules({ mode, securityHeaders, prefixes = [] }) {
|
||||
if (!mode) return [{ source: "/:path*", headers: securityHeaders }];
|
||||
|
||||
const { nonPageSource, pageSource } = complementarySources(prefixes);
|
||||
const pageHeaders = securityHeaders
|
||||
// X-Frame-Options cannot express `vscode-webview:` and would veto the relaxed CSP.
|
||||
.filter((header) => header.key !== "X-Frame-Options")
|
||||
.map((header) =>
|
||||
header.key === "Content-Security-Policy"
|
||||
? { key: header.key, value: relaxFrameAncestors(header.value, mode) }
|
||||
: header
|
||||
);
|
||||
|
||||
return [
|
||||
{ source: nonPageSource, headers: securityHeaders },
|
||||
{ source: pageSource, headers: pageHeaders },
|
||||
];
|
||||
}
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
handleOcr,
|
||||
resolveVertexOcrAccessToken,
|
||||
resolveVertexOcrBaseUrl,
|
||||
VERTEX_DEEPSEEK_OCR_PROVIDER_ID,
|
||||
} from "@omniroute/open-sse/handlers/ocr.ts";
|
||||
import { handleOcr } from "@omniroute/open-sse/handlers/ocr.ts";
|
||||
import {
|
||||
getProviderCredentialsWithQuotaPreflight,
|
||||
clearRecoveredProviderState,
|
||||
@@ -20,37 +15,6 @@ import {
|
||||
rateLimitedProviderResponse,
|
||||
} from "@/app/api/v1/_shared/rateLimit";
|
||||
|
||||
export { resolveVertexOcrAccessToken };
|
||||
|
||||
/**
|
||||
* Custom-endpoint providers (e.g. azure-document-intelligence, vertex-deepseek-ocr) store the
|
||||
* connection's resource endpoint under providerSpecificData, 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). The
|
||||
* vertex-deepseek-ocr project/location resolution itself lives in the open-sse handler
|
||||
* (resolveVertexOcrBaseUrl) — routes may not import executor implementations directly (see
|
||||
* EXECUTOR_IMPORT_RESTRICTION in eslint.config.mjs).
|
||||
*/
|
||||
export function resolveOcrCredentials<
|
||||
T extends {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
providerSpecificData?: Record<string, unknown>;
|
||||
},
|
||||
>(credentials: T, providerId?: string): T {
|
||||
if (credentials?.baseUrl) return credentials;
|
||||
const providerSpecificBaseUrl = credentials?.providerSpecificData?.baseUrl;
|
||||
if (typeof providerSpecificBaseUrl === "string" && providerSpecificBaseUrl.trim()) {
|
||||
return { ...credentials, baseUrl: providerSpecificBaseUrl };
|
||||
}
|
||||
if (providerId === VERTEX_DEEPSEEK_OCR_PROVIDER_ID) {
|
||||
const vertexBaseUrl = resolveVertexOcrBaseUrl(credentials);
|
||||
if (vertexBaseUrl) return { ...credentials, baseUrl: vertexBaseUrl };
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
*/
|
||||
@@ -102,10 +66,7 @@ async function postHandler(request, context) {
|
||||
return rateLimitedProviderResponse(resolvedProvider, credentials);
|
||||
}
|
||||
|
||||
const tokenReadyCredentials = await resolveVertexOcrAccessToken(resolvedProvider, credentials);
|
||||
const ocrCredentials = resolveOcrCredentials(tokenReadyCredentials, resolvedProvider);
|
||||
|
||||
const response = await handleOcr({ body: { ...body, model }, credentials: ocrCredentials });
|
||||
const response = await handleOcr({ body: { ...body, model }, credentials });
|
||||
if (response?.ok) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
}
|
||||
|
||||
324
tests/unit/dashboard-embed-csp-10273.test.ts
Normal file
324
tests/unit/dashboard-embed-csp-10273.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
// Regression guard for #10273: opt-in CSP relaxation so OmniRoute's HTML pages can be
|
||||
// embedded in the VS Code Simple Browser (the OmniCopilot extension's `dashboardOpen:
|
||||
// "editor"` mode renders them inside a `vscode-webview:` iframe).
|
||||
//
|
||||
// The default posture is UNCHANGED and must stay that way: `frame-ancestors 'none'` +
|
||||
// `X-Frame-Options: DENY` on every route. Only when the operator explicitly sets
|
||||
// DASHBOARD_ALLOW_EMBED=vscode do the HTML pages switch to
|
||||
// `frame-ancestors 'self' vscode-webview:` and drop `X-Frame-Options` (XFO has no syntax
|
||||
// for a custom scheme, and CSP frame-ancestors supersedes it in modern engines).
|
||||
//
|
||||
// The API surface (`/api`, `/v1`, `/v1beta`, the root-level rewrite aliases, `/a2a`,
|
||||
// `/healthz`) must keep the strict headers even in embed mode — those are the
|
||||
// Hard-Rule-15/17 surfaces and never need framing.
|
||||
//
|
||||
// These tests assert EFFECTIVE headers, not config shape: `effectiveHeaders()` replays
|
||||
// Next.js's own matching + last-wins merge (see
|
||||
// node_modules/next/dist/server/lib/router-utils/resolve-routes.js, `resHeaders[key] = value`)
|
||||
// so a rule that silently stops matching, or an ordering regression, fails here.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { getPathMatch } from "next/dist/shared/lib/router/utils/path-match.js";
|
||||
|
||||
import {
|
||||
DASHBOARD_EMBED_ENV,
|
||||
EMBED_FRAME_ANCESTORS,
|
||||
resolveDashboardEmbedMode,
|
||||
nonPageRoutePrefixes,
|
||||
buildSecurityHeaderRules,
|
||||
} from "../../scripts/build/dashboardEmbed.mjs";
|
||||
|
||||
const modulePath = path.join(process.cwd(), "next.config.mjs");
|
||||
const originalEmbed = process.env[DASHBOARD_EMBED_ENV];
|
||||
|
||||
interface HeaderEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
interface HeaderRule {
|
||||
source: string;
|
||||
headers: HeaderEntry[];
|
||||
}
|
||||
|
||||
async function loadHeaders(label: string): Promise<HeaderRule[]> {
|
||||
const mod = await import(`${pathToFileURL(modulePath).href}?case=${label}-${Date.now()}`);
|
||||
return mod.default.headers();
|
||||
}
|
||||
|
||||
/** Replay Next.js's header matching + last-wins merge for one pathname. */
|
||||
function effectiveHeaders(rules: HeaderRule[], pathname: string): Record<string, string> {
|
||||
const merged: Record<string, string> = {};
|
||||
for (const rule of rules) {
|
||||
if (getPathMatch(rule.source, { removeUnnamedParams: true })(pathname) === false) continue;
|
||||
for (const { key, value } of rule.headers) merged[key] = value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Pages an operator expects to reach inside the VS Code Simple Browser. `/login` is on the
|
||||
// list on purpose: the webview has its own cookie jar, so an embedded session ALWAYS starts
|
||||
// unauthenticated and `/dashboard` redirects there (src/server/authz/pipeline.ts).
|
||||
const PAGE_PATHS = [
|
||||
"/",
|
||||
"/dashboard",
|
||||
"/dashboard/providers",
|
||||
"/dashboard/combos/editor",
|
||||
"/login",
|
||||
"/forgot-password",
|
||||
"/docs/guides/i18n",
|
||||
"/landing",
|
||||
"/status",
|
||||
];
|
||||
|
||||
// Never framable — the process-spawning / proxy surfaces plus every root-level API alias
|
||||
// declared in next.config.mjs `rewrites()`.
|
||||
const API_PATHS = [
|
||||
"/api",
|
||||
"/api/v1/chat/completions",
|
||||
"/api/services/ninerouter/start",
|
||||
"/v1",
|
||||
"/v1/models",
|
||||
"/v1beta/models",
|
||||
"/chat/completions",
|
||||
"/responses",
|
||||
"/responses/abc/cancel",
|
||||
"/models",
|
||||
"/codex/responses",
|
||||
"/anthropic/v1/messages",
|
||||
"/openai/v1/models",
|
||||
"/metrics",
|
||||
"/debug",
|
||||
"/.env",
|
||||
"/a2a",
|
||||
"/healthz",
|
||||
];
|
||||
|
||||
function restoreEnv(): void {
|
||||
if (originalEmbed === undefined) delete process.env[DASHBOARD_EMBED_ENV];
|
||||
else process.env[DASHBOARD_EMBED_ENV] = originalEmbed;
|
||||
}
|
||||
|
||||
test.afterEach(restoreEnv);
|
||||
|
||||
// ── the opt-in switch itself ───────────────────────────────────────────────
|
||||
|
||||
test("#10273 embed mode is OFF unless DASHBOARD_ALLOW_EMBED names a known mode", () => {
|
||||
for (const raw of [undefined, "", " ", "0", "1", "true", "yes", "on", "browser", "vscode-web"]) {
|
||||
assert.equal(
|
||||
resolveDashboardEmbedMode(raw === undefined ? {} : { [DASHBOARD_EMBED_ENV]: raw }),
|
||||
null,
|
||||
`DASHBOARD_ALLOW_EMBED=${JSON.stringify(raw)} must not enable embedding`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("#10273 DASHBOARD_ALLOW_EMBED=vscode enables the vscode mode, case/space tolerant", () => {
|
||||
for (const raw of ["vscode", "VSCode", " vscode ", "VSCODE"]) {
|
||||
assert.equal(resolveDashboardEmbedMode({ [DASHBOARD_EMBED_ENV]: raw }), "vscode");
|
||||
}
|
||||
assert.equal(EMBED_FRAME_ANCESTORS.vscode, "'self' vscode-webview:");
|
||||
});
|
||||
|
||||
// ── default posture must not move ──────────────────────────────────────────
|
||||
|
||||
test("#10273 default build keeps frame-ancestors 'none' + X-Frame-Options: DENY everywhere", async () => {
|
||||
delete process.env[DASHBOARD_EMBED_ENV];
|
||||
const rules = await loadHeaders("embed-off");
|
||||
|
||||
assert.equal(
|
||||
rules[0].source,
|
||||
"/:path*",
|
||||
"the global rule must stay a plain catch-all by default"
|
||||
);
|
||||
|
||||
for (const pathname of [...PAGE_PATHS, ...API_PATHS]) {
|
||||
const headers = effectiveHeaders(rules, pathname);
|
||||
assert.match(
|
||||
headers["Content-Security-Policy"],
|
||||
/frame-ancestors 'none'/,
|
||||
`${pathname} must keep frame-ancestors 'none' when the opt-in is off`
|
||||
);
|
||||
assert.equal(
|
||||
headers["X-Frame-Options"],
|
||||
"DENY",
|
||||
`${pathname} must keep X-Frame-Options: DENY when the opt-in is off`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("#10273 default build emits no extra header rules (byte-identical to pre-feature config)", async () => {
|
||||
delete process.env[DASHBOARD_EMBED_ENV];
|
||||
const rules = await loadHeaders("embed-off-shape");
|
||||
assert.deepEqual(
|
||||
rules.map((rule) => rule.source),
|
||||
["/:path*", "/dashboard/providers/services/:name/embed/:path*"]
|
||||
);
|
||||
});
|
||||
|
||||
// ── embed mode ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("#10273 embed mode lets vscode-webview: frame the HTML pages and drops X-Frame-Options", async () => {
|
||||
process.env[DASHBOARD_EMBED_ENV] = "vscode";
|
||||
const rules = await loadHeaders("embed-on-pages");
|
||||
|
||||
for (const pathname of PAGE_PATHS) {
|
||||
const headers = effectiveHeaders(rules, pathname);
|
||||
assert.ok(
|
||||
headers["Content-Security-Policy"],
|
||||
`${pathname} must still receive a Content-Security-Policy`
|
||||
);
|
||||
assert.match(
|
||||
headers["Content-Security-Policy"],
|
||||
/frame-ancestors 'self' vscode-webview:/,
|
||||
`${pathname} must allow the vscode-webview: ancestor in embed mode`
|
||||
);
|
||||
assert.equal(
|
||||
headers["X-Frame-Options"],
|
||||
undefined,
|
||||
`${pathname} must NOT carry X-Frame-Options in embed mode (it would still block the iframe)`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("#10273 embed mode keeps the API surface strictly unframable", async () => {
|
||||
process.env[DASHBOARD_EMBED_ENV] = "vscode";
|
||||
const rules = await loadHeaders("embed-on-api");
|
||||
|
||||
for (const pathname of API_PATHS) {
|
||||
const headers = effectiveHeaders(rules, pathname);
|
||||
assert.match(
|
||||
headers["Content-Security-Policy"],
|
||||
/frame-ancestors 'none'/,
|
||||
`${pathname} is an API surface and must keep frame-ancestors 'none' even in embed mode`
|
||||
);
|
||||
assert.equal(
|
||||
headers["X-Frame-Options"],
|
||||
"DENY",
|
||||
`${pathname} is an API surface and must keep X-Frame-Options: DENY even in embed mode`
|
||||
);
|
||||
assert.ok(
|
||||
!headers["Content-Security-Policy"].includes("vscode-webview:"),
|
||||
`${pathname} must never allow the vscode-webview: ancestor`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("#10273 embed mode relaxes ONLY frame-ancestors — every other directive/header survives", async () => {
|
||||
process.env[DASHBOARD_EMBED_ENV] = "vscode";
|
||||
const relaxed = effectiveHeaders(await loadHeaders("embed-on-intact"), "/dashboard");
|
||||
|
||||
delete process.env[DASHBOARD_EMBED_ENV];
|
||||
const strict = effectiveHeaders(await loadHeaders("embed-off-intact"), "/dashboard");
|
||||
|
||||
assert.equal(
|
||||
relaxed["Content-Security-Policy"],
|
||||
strict["Content-Security-Policy"].replace(
|
||||
"frame-ancestors 'none'",
|
||||
`frame-ancestors ${EMBED_FRAME_ANCESTORS.vscode}`
|
||||
),
|
||||
"embed mode must swap the frame-ancestors token and change nothing else in the CSP"
|
||||
);
|
||||
|
||||
for (const key of [
|
||||
"X-Content-Type-Options",
|
||||
"Referrer-Policy",
|
||||
"Permissions-Policy",
|
||||
"Strict-Transport-Security",
|
||||
]) {
|
||||
assert.equal(relaxed[key], strict[key], `${key} must be identical in embed mode`);
|
||||
}
|
||||
});
|
||||
|
||||
test("#10273 embed mode preserves the G-10 9Router embed override (last rule still wins)", async () => {
|
||||
process.env[DASHBOARD_EMBED_ENV] = "vscode";
|
||||
const rules = await loadHeaders("embed-on-g10");
|
||||
const headers = effectiveHeaders(rules, "/dashboard/providers/services/ninerouter/embed/ui");
|
||||
|
||||
assert.equal(
|
||||
headers["Content-Security-Policy"],
|
||||
"frame-ancestors 'self'",
|
||||
"the G-10 same-origin override must keep the last word for the 9Router embed route"
|
||||
);
|
||||
});
|
||||
|
||||
// ── the API prefix list must stay derived from the config, not hand-maintained ──
|
||||
|
||||
test("#10273 every root-level rewrite alias is excluded from the embeddable page surface", async () => {
|
||||
const modUrl = `${pathToFileURL(modulePath).href}?case=prefixes-${Date.now()}`;
|
||||
const nextConfig = (await import(modUrl)).default;
|
||||
const rewrites = await nextConfig.rewrites();
|
||||
const prefixes = new Set(nonPageRoutePrefixes(rewrites));
|
||||
|
||||
for (const { source } of rewrites) {
|
||||
const first = source.replace(/^\//, "").split("/")[0];
|
||||
assert.ok(
|
||||
prefixes.has(first),
|
||||
`rewrite alias "${source}" must be excluded from the embeddable page surface — ` +
|
||||
`it proxies an API route and must never be framable`
|
||||
);
|
||||
}
|
||||
// The app-router API surfaces that have no rewrite alias.
|
||||
for (const literal of ["api", "a2a", "healthz"]) {
|
||||
assert.ok(prefixes.has(literal), `"${literal}" must be excluded from the page surface`);
|
||||
}
|
||||
});
|
||||
|
||||
test("#10273 Next.js accepts the generated header sources in BOTH modes (startup guard)", async () => {
|
||||
// `loadCustomRoutes` is the validation Next runs when the config loads: a `source` it
|
||||
// rejects aborts the build. The embed-mode sources are regexes, and CI never builds with
|
||||
// DASHBOARD_ALLOW_EMBED set — without this guard a malformed source would only blow up on
|
||||
// the operator's machine, at build time, with the feature already shipped.
|
||||
const require = createRequire(import.meta.url);
|
||||
const loadCustomRoutes = require("next/dist/lib/load-custom-routes.js").default;
|
||||
|
||||
for (const mode of [undefined, "vscode"]) {
|
||||
if (mode) process.env[DASHBOARD_EMBED_ENV] = mode;
|
||||
else delete process.env[DASHBOARD_EMBED_ENV];
|
||||
|
||||
const nextConfig = (
|
||||
await import(`${pathToFileURL(modulePath).href}?case=validate-${mode}-${Date.now()}`)
|
||||
).default;
|
||||
const routes = await loadCustomRoutes({
|
||||
...nextConfig,
|
||||
trailingSlash: false,
|
||||
skipTrailingSlashRedirect: false,
|
||||
basePath: "",
|
||||
i18n: undefined,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
routes.headers.length,
|
||||
mode ? 3 : 2,
|
||||
`mode=${mode ?? "off"} should produce ${mode ? 3 : 2} header routes`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("#10273 buildSecurityHeaderRules produces complementary sources with no gap", () => {
|
||||
const securityHeaders = [
|
||||
{ key: "Content-Security-Policy", value: "frame-ancestors 'none'; default-src 'self'" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
];
|
||||
const rules = buildSecurityHeaderRules({
|
||||
mode: "vscode",
|
||||
securityHeaders,
|
||||
prefixes: ["api", "v1"],
|
||||
});
|
||||
|
||||
// Every pathname must be covered by exactly one of the two rules — a gap would ship a
|
||||
// page with NO security headers at all, an overlap would make the merge order-dependent.
|
||||
for (const pathname of ["/", "/dashboard", "/login", "/api/v1/models", "/v1/models", "/apifoo"]) {
|
||||
const matched = rules.filter(
|
||||
(rule) => getPathMatch(rule.source, { removeUnnamedParams: true })(pathname) !== false
|
||||
);
|
||||
assert.equal(
|
||||
matched.length,
|
||||
1,
|
||||
`${pathname} must match exactly one rule, got ${matched.length}`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,166 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
OCR_PROVIDERS,
|
||||
getOcrTransformation,
|
||||
MISTRAL_PASSTHROUGH,
|
||||
VERTEX_DEEPSEEK_TRANSFORMATION,
|
||||
} 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");
|
||||
});
|
||||
|
||||
// ── Vertex AI DeepSeek OCR ──────────────────────────────────────────────────
|
||||
// URL/body/response shapes verified against the upstream reference
|
||||
// (litellm/llms/vertex_ai/ocr/deepseek_transformation.py): the endpoint is the
|
||||
// generic Vertex "openapi/chat/completions" partner endpoint, the model id is
|
||||
// prefixed with "deepseek-ai/", and the OCR document is sent as an
|
||||
// OpenAI-chat-shaped image_url content part.
|
||||
|
||||
test("vertex-deepseek-ocr resolves its own transformation (not the passthrough)", () => {
|
||||
const t = getOcrTransformation("vertex-deepseek-ocr");
|
||||
assert.equal(t, VERTEX_DEEPSEEK_TRANSFORMATION);
|
||||
});
|
||||
|
||||
test("vertex-deepseek-ocr builds an OpenAI-chat-shaped request against the resolved endpoint", () => {
|
||||
const t = getOcrTransformation("vertex-deepseek-ocr");
|
||||
const { url, init } = t.buildRequest({
|
||||
// resolveOcrCredentials (src/app/api/v1/ocr/route.ts) resolves the full
|
||||
// project/location endpoint into credentials.baseUrl before this runs —
|
||||
// buildRequest treats baseUrl as the complete URL, mirroring Mistral.
|
||||
baseUrl:
|
||||
"https://aiplatform.googleapis.com/v1/projects/proj-1/locations/us-central1/endpoints/openapi/chat/completions",
|
||||
token: "ya29.mock",
|
||||
body: { document: { type: "image_url", image_url: "https://x/y.png" } },
|
||||
modelId: "deepseek-ocr-maas",
|
||||
});
|
||||
assert.equal(
|
||||
url,
|
||||
"https://aiplatform.googleapis.com/v1/projects/proj-1/locations/us-central1/endpoints/openapi/chat/completions"
|
||||
);
|
||||
assert.equal(init.method, "POST");
|
||||
assert.equal((init.headers as Record<string, string>).Authorization, "Bearer ya29.mock");
|
||||
const sent = JSON.parse(String(init.body));
|
||||
assert.equal(sent.model, "deepseek-ai/deepseek-ocr-maas");
|
||||
assert.deepEqual(sent.messages, [
|
||||
{ role: "user", content: [{ type: "image_url", image_url: "https://x/y.png" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("vertex-deepseek-ocr maps a document_url document to the same image_url content shape", () => {
|
||||
const t = getOcrTransformation("vertex-deepseek-ocr");
|
||||
const { init } = t.buildRequest({
|
||||
baseUrl:
|
||||
"https://aiplatform.googleapis.com/v1/projects/p/locations/us-central1/endpoints/openapi/chat/completions",
|
||||
token: "t",
|
||||
body: { document: { type: "document_url", document_url: "https://x/d.pdf" } },
|
||||
modelId: "deepseek-ocr-maas",
|
||||
});
|
||||
const sent = JSON.parse(String(init.body));
|
||||
assert.deepEqual(sent.messages[0].content, [{ type: "image_url", image_url: "https://x/d.pdf" }]);
|
||||
});
|
||||
|
||||
test("vertex-deepseek-ocr parseResponse extracts a JSON pages payload embedded in choices[0].message.content", () => {
|
||||
const t = getOcrTransformation("vertex-deepseek-ocr");
|
||||
const raw = {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
pages: [{ index: 0, markdown: "# hi" }],
|
||||
model: "deepseek-ocr-maas",
|
||||
usage_info: { pages_processed: 1 },
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const parsed = t.parseResponse(raw);
|
||||
assert.deepEqual(parsed.pages, [{ index: 0, markdown: "# hi" }]);
|
||||
assert.equal(parsed.model, "deepseek-ocr-maas");
|
||||
assert.deepEqual(parsed.usage_info, { pages_processed: 1 });
|
||||
});
|
||||
|
||||
test("vertex-deepseek-ocr parseResponse wraps plain markdown content into a single page (Mistral shape)", () => {
|
||||
const t = getOcrTransformation("vertex-deepseek-ocr");
|
||||
const raw = {
|
||||
model: "deepseek-ocr-maas",
|
||||
choices: [{ message: { content: "# just markdown, not JSON" } }],
|
||||
usage: { total_tokens: 42 },
|
||||
};
|
||||
const parsed = t.parseResponse(raw);
|
||||
assert.deepEqual(parsed.pages, [{ index: 0, markdown: "# just markdown, not JSON" }]);
|
||||
assert.equal(parsed.model, "deepseek-ocr-maas");
|
||||
assert.deepEqual(parsed.usage_info, { total_tokens: 42 });
|
||||
});
|
||||
|
||||
test("vertex-deepseek-ocr parseResponse tolerates a missing/empty choices array", () => {
|
||||
const t = getOcrTransformation("vertex-deepseek-ocr");
|
||||
const parsed = t.parseResponse({ model: "deepseek-ocr-maas", choices: [] });
|
||||
assert.deepEqual(parsed.pages, [{ index: 0, markdown: "" }]);
|
||||
assert.equal(parsed.model, "deepseek-ocr-maas");
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
import {
|
||||
resolveOcrCredentials,
|
||||
resolveVertexOcrAccessToken,
|
||||
} from "../../src/app/api/v1/ocr/route.ts";
|
||||
|
||||
// ── resolveOcrCredentials — vertex-deepseek-ocr project/location resolution ─
|
||||
// Mirrors the Azure DI pattern (providerSpecificData.baseUrl → top-level
|
||||
// baseUrl) but synthesizes the full Vertex "openapi/chat/completions"
|
||||
// endpoint URL from providerSpecificData.project/region, or (when project is
|
||||
// not explicitly configured) from the Service Account JSON's project_id —
|
||||
// the same source VertexExecutor.buildUrl uses (open-sse/executors/vertex.ts).
|
||||
|
||||
test("resolveOcrCredentials builds the Vertex endpoint URL from explicit providerSpecificData.project/region", () => {
|
||||
const credentials = {
|
||||
apiKey: "ya29.raw-access-token",
|
||||
providerSpecificData: { project: "proj-explicit", region: "europe-west4" },
|
||||
};
|
||||
const resolved = resolveOcrCredentials(credentials, "vertex-deepseek-ocr");
|
||||
assert.equal(
|
||||
resolved.baseUrl,
|
||||
"https://aiplatform.googleapis.com/v1/projects/proj-explicit/locations/europe-west4/endpoints/openapi/chat/completions"
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveOcrCredentials defaults the Vertex region to us-central1 when unset", () => {
|
||||
const credentials = { apiKey: "ya29.tok", providerSpecificData: { project: "proj-1" } };
|
||||
const resolved = resolveOcrCredentials(credentials, "vertex-deepseek-ocr");
|
||||
assert.equal(
|
||||
resolved.baseUrl,
|
||||
"https://aiplatform.googleapis.com/v1/projects/proj-1/locations/us-central1/endpoints/openapi/chat/completions"
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveOcrCredentials derives the Vertex project from a Service Account JSON apiKey when providerSpecificData.project is absent", () => {
|
||||
const credentials = {
|
||||
apiKey: JSON.stringify({
|
||||
project_id: "proj-from-sa",
|
||||
client_email: "svc@x.iam",
|
||||
private_key: "x",
|
||||
}),
|
||||
};
|
||||
const resolved = resolveOcrCredentials(credentials, "vertex-deepseek-ocr");
|
||||
assert.equal(
|
||||
resolved.baseUrl,
|
||||
"https://aiplatform.googleapis.com/v1/projects/proj-from-sa/locations/us-central1/endpoints/openapi/chat/completions"
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveOcrCredentials leaves baseUrl unset when the Vertex project cannot be resolved (raw token, no providerSpecificData.project)", () => {
|
||||
const credentials = { apiKey: "ya29.raw-token-no-project" };
|
||||
const resolved = resolveOcrCredentials(credentials, "vertex-deepseek-ocr");
|
||||
assert.equal(resolved.baseUrl, undefined);
|
||||
});
|
||||
|
||||
test("resolveOcrCredentials keeps an explicit top-level baseUrl untouched for vertex-deepseek-ocr", () => {
|
||||
const credentials = {
|
||||
apiKey: "ya29.tok",
|
||||
baseUrl: "https://explicit.example.com",
|
||||
providerSpecificData: { project: "ignored" },
|
||||
};
|
||||
const resolved = resolveOcrCredentials(credentials, "vertex-deepseek-ocr");
|
||||
assert.equal(resolved.baseUrl, "https://explicit.example.com");
|
||||
});
|
||||
|
||||
test("resolveOcrCredentials is unaffected for non-vertex providers (mistral, azure-document-intelligence unchanged)", () => {
|
||||
const mistral = { apiKey: "sk-mistral" };
|
||||
assert.deepEqual(resolveOcrCredentials(mistral, "mistral"), mistral);
|
||||
const azure = {
|
||||
apiKey: "azkey",
|
||||
providerSpecificData: { baseUrl: "https://r.cognitiveservices.azure.com" },
|
||||
};
|
||||
assert.equal(
|
||||
resolveOcrCredentials(azure, "azure-document-intelligence").baseUrl,
|
||||
"https://r.cognitiveservices.azure.com"
|
||||
);
|
||||
});
|
||||
|
||||
// ── resolveVertexOcrAccessToken — mints a Vertex OAuth access token from a ─
|
||||
// Service Account JSON credential, reusing the exact same JWT-bearer flow
|
||||
// the chat executor uses (open-sse/executors/vertex.ts::getAccessToken) —
|
||||
// no new OAuth flow is implemented here.
|
||||
|
||||
test("resolveVertexOcrAccessToken is a no-op for non-vertex providers", async () => {
|
||||
const credentials = { apiKey: JSON.stringify({ client_email: "x", private_key: "y" }) };
|
||||
const resolved = await resolveVertexOcrAccessToken("mistral", credentials);
|
||||
assert.equal(resolved, credentials);
|
||||
});
|
||||
|
||||
test("resolveVertexOcrAccessToken is a no-op when an accessToken is already present", async () => {
|
||||
const credentials = { apiKey: "sa-json-ignored", accessToken: "ya29.already-here" };
|
||||
const resolved = await resolveVertexOcrAccessToken("vertex-deepseek-ocr", credentials);
|
||||
assert.equal(resolved, credentials);
|
||||
});
|
||||
|
||||
test("resolveVertexOcrAccessToken is a no-op for a raw (non-JSON) access token apiKey — used as-is", async () => {
|
||||
const credentials = { apiKey: "ya29.raw-preminted-token" };
|
||||
const resolved = await resolveVertexOcrAccessToken("vertex-deepseek-ocr", credentials);
|
||||
assert.equal(resolved, credentials);
|
||||
});
|
||||
|
||||
test("resolveVertexOcrAccessToken exchanges a Service Account JSON apiKey for a minted accessToken via the shared JWT-bearer flow", async () => {
|
||||
const { privateKey } = generateKeyPairSync("rsa", {
|
||||
modulusLength: 2048,
|
||||
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
});
|
||||
const saJson = JSON.stringify({
|
||||
project_id: "proj-ocr",
|
||||
private_key_id: "kid-ocr-1",
|
||||
client_email: "svc-ocr-route-test@example.iam.gserviceaccount.com",
|
||||
private_key: privateKey,
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: Array<{ url: string }> = [];
|
||||
globalThis.fetch = async (url: string | URL | Request, options?: RequestInit) => {
|
||||
calls.push({ url: String(url) });
|
||||
assert.match(String(url), /oauth2\.googleapis\.com\/token$/);
|
||||
assert.match(
|
||||
String(options?.body ?? ""),
|
||||
/grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer/
|
||||
);
|
||||
return new Response(JSON.stringify({ access_token: "ya29.minted-for-ocr", expires_in: 3600 }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const credentials = { apiKey: saJson };
|
||||
const resolved = await resolveVertexOcrAccessToken("vertex-deepseek-ocr", credentials);
|
||||
assert.equal(resolved.accessToken, "ya29.minted-for-ocr");
|
||||
// apiKey is preserved (resolveOcrCredentials may still need it to derive the project).
|
||||
assert.equal(resolved.apiKey, saJson);
|
||||
assert.equal(calls.length, 1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -183,7 +183,6 @@ 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.ok(payload.error.message.includes("OCR request failed"));
|
||||
assert.ok(!payload.error.message.includes("socket closed"));
|
||||
assert.match(payload.error.message, /OCR request failed: socket closed/);
|
||||
assert.ok(!payload.error.message.includes("at /"));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user