Compare commits

..

3 Commits

Author SHA1 Message Date
dependabot[bot]
0908ea5473 chore(deps): bump github/codeql-action from 4.37.4 to 4.37.6
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.4 to 4.37.6.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4.37.4...v4.37.6)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-14 18:24:34 +00:00
Diego Rodrigues de Sa e Souza
7837e46908 feat(ocr): Vertex AI DeepSeek-OCR provider (#10398)
* feat(sse): add Vertex AI DeepSeek OCR transformation to the registry

Adds VERTEX_DEEPSEEK_TRANSFORMATION (request/response mapping for the
Vertex AI DeepSeek OCR MaaS endpoint) and registers the
"vertex-deepseek-ocr" provider in OCR_PROVIDERS, modeled on litellm's
VertexAIDeepSeekOCRConfig. buildRequest treats the resolved baseUrl as
the complete Vertex endpoint URL (project/location resolved upstream),
matching the existing Mistral passthrough pattern.

* feat(sse): resolve Vertex AI DeepSeek OCR auth and endpoint URL

Adds resolveVertexOcrAccessToken (mints a Vertex OAuth access token from
a Service Account JSON apiKey, reusing open-sse/executors/vertex.ts's
existing JWT-bearer exchange — no new OAuth flow) and
resolveVertexOcrBaseUrl (derives the project/location "openapi/chat/
completions" endpoint from providerSpecificData or the Service Account
JSON's project_id). Both live in open-sse/handlers/ocr.ts, not the
src/app/api/v1/ocr route, since routes may not import executor
implementations directly (EXECUTOR_IMPORT_RESTRICTION in
eslint.config.mjs) — the route re-exports/consumes them across that
boundary. handleOcr now prefers credentials.accessToken over apiKey so
the minted token (not the raw Service Account JSON) is sent upstream.

* docs(api): document the vertex-deepseek-ocr /v1/ocr provider

Adds the vertex-deepseek-ocr row to the /v1/ocr provider table and a
short section on its Vertex AI auth/endpoint resolution, and lists the
new provider/model id in openapi.yaml alongside mistral and
azure-document-intelligence.

* docs(skills): regenerate omni-inference skill for the Vertex OCR provider

---------

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-14 15:15:12 -03:00
Diego Rodrigues de Sa e Souza
ff64716c31 feat(dashboard): opt-in CSP relaxation for VS Code Simple Browser embedding (#10273) (#10386)
OmniRoute ships `frame-ancestors 'none'` + `X-Frame-Options: DENY` on every
route, so the VS Code Simple Browser renders a blank tab — which is what the
OmniCopilot extension's `dashboardOpen: "editor"` mode uses.

Add the build-time opt-in `DASHBOARD_ALLOW_EMBED=vscode`. When set, the HTML
pages are served with `frame-ancestors 'self' vscode-webview:` and without
`X-Frame-Options` (XFO cannot express a custom scheme and would veto the
relaxed CSP). Unset — the default — nothing changes.

The API surface stays strictly unframable in both modes. Its exclusion list is
derived from the `rewrites()` table plus `/api`, `/a2a`, `/healthz`, so a future
root-level API alias is excluded automatically instead of silently becoming
framable. The two generated `source` patterns are complementary by construction:
every pathname matches exactly one, so there is no gap (a page with no security
headers) and no order-dependent overlap.

Closes #10273

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-14 15:04:59 -03:00
18 changed files with 1419 additions and 591 deletions

View File

@@ -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

View File

@@ -372,7 +372,7 @@ jobs:
- name: Upload Trivy SARIF to Security tab
if: needs.prepare.outputs.version != 'main'
continue-on-error: true
uses: github/codeql-action/upload-sarif@v4.37.4
uses: github/codeql-action/upload-sarif@v4.37.6
with:
sarif_file: trivy-results.sarif
category: trivy-image

View 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)

View File

@@ -6844,7 +6844,8 @@ paths:
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.
`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
@@ -6865,7 +6866,8 @@ paths:
description: >-
`provider/model` id or bare model id. Registered ids:
`mistral/mistral-ocr-latest`,
`azure-document-intelligence/prebuilt-read`. Defaults to
`azure-document-intelligence/prebuilt-read`,
`vertex-deepseek-ocr/deepseek-ocr-maas`. Defaults to
`mistral-ocr-latest` when omitted.
document:
type: object

View File

@@ -220,12 +220,13 @@ Content-Type: application/json
`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. |
| 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. |
Both providers respond in the same Mistral-shaped body:
All three providers respond in the same Mistral-shaped body:
```json
{
@@ -245,6 +246,19 @@ operation is still running after the attempt budget is exhausted. The final Azur
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

View File

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

View File

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

View File

@@ -104,6 +104,80 @@ export const AZURE_DI_TRANSFORMATION: OcrTransformation = {
},
};
/**
* 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",
@@ -120,6 +194,14 @@ export const OCR_PROVIDERS: Record<string, OcrProvider> = {
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,
},
};
/**

View File

@@ -14,12 +14,83 @@ import {
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
*
@@ -59,7 +130,11 @@ export async function handleOcr({
);
}
const token = credentials?.apiKey || credentials?.accessToken;
// 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;
if (!token) {
return errorResponse(401, `No credentials for OCR provider: ${providerId}`);
}

996
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -257,7 +257,7 @@
"alibaba:sync-allowlist": "node --import tsx/esm scripts/ops/sync-alibaba-allowlist.mjs"
},
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.1107.0",
"@aws-sdk/client-bedrock-runtime": "^3.1073.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -266,40 +266,40 @@
"@monaco-editor/react": "^4.7.0",
"@ngrok/ngrok": "^1.7.0",
"@swc/helpers": "0.5.23",
"@toon-format/toon": "^4.1.1",
"@toon-format/toon": "^4.1.0",
"@types/mdx": "^2.0.13",
"@xyflow/react": "^12.11.1",
"axios": "^1.19.0",
"axios": "^1.16.1",
"bcryptjs": "^3.0.3",
"bottleneck": "^2.19.5",
"clsx": "^2.1.1",
"commander": "^15.0.0",
"cron-parser": "^5.8.1",
"csv-stringify": "^6.8.3",
"cron-parser": "^5.6.2",
"csv-stringify": "^6.7.0",
"dompurify": "^3.4.13",
"express": "^5.2.1",
"fetch-socks": "^1.3.3",
"fflate": "^0.8.3",
"fumadocs-core": "^16.14.3",
"fumadocs-ui": "^16.14.3",
"fumadocs-core": "^16.10.5",
"fumadocs-ui": "^16.10.5",
"http-proxy-middleware": "^4.0.0",
"https-proxy-agent": "^9.0.0",
"ink": "^7.0.3",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"ioredis": "^5.10.1",
"jose": "^6.2.8",
"js-yaml": "^5.2.3",
"jose": "^6.2.3",
"js-yaml": "^5.2.2",
"jsonc-parser": "^3.3.1",
"lowdb": "^7.0.1",
"lucide-react": "^1.21.0",
"marked": "^18.0.9",
"marked": "^18.0.4",
"marked-terminal": "^7.3.0",
"material-symbols": "^0.45.10",
"material-symbols": "^0.45.2",
"mermaid": "^11.15.0",
"monaco-editor": "^0.56.0",
"next": "16.3.0",
"next-intl": "^4.13.6",
"next": "16.2.12",
"next-intl": "^4.12.0",
"next-themes": "^0.4.6",
"node-machine-id": "^1.1.12",
"omniglyph": "^1.0.2",
@@ -309,7 +309,7 @@
"pino": "^10.3.1",
"pino-abstract-transport": "^3.0.0",
"pino-pretty": "^13.1.3",
"playwright": "1.62.1",
"playwright": "1.62.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-is": "^19.2.6",
@@ -319,23 +319,23 @@
"safe-regex": "^2.1.1",
"selfsigned": "^5.5.0",
"sharp": "^0.35.3",
"smol-toml": "1.7.2",
"smol-toml": "1.7.1",
"socks": "^2.8.7",
"sql.js": "^1.14.1",
"tailwind-merge": "^3.6.0",
"tsx": "^4.23.12",
"turndown": "7.2.4",
"tsx": "^4.23.0",
"turndown": "7.2.0",
"turndown-plugin-gfm": "1.0.2",
"undici": "^8.10.0",
"update-notifier": "^7.3.1",
"uuid": "^14.0.0",
"ws": "^8.21.3",
"ws": "^8.18.0",
"xxhash-wasm": "^1.1.0",
"yazl": "^3.3.1",
"zod": "^4.4.3",
"zustand": "^5.0.13",
"@huggingface/transformers": "^4.2.0",
"onnxruntime-node": "~1.27.0"
"onnxruntime-node": "~1.24.3"
},
"optionalDependencies": {
"@atjsh/llmlingua-2": "2.0.3",
@@ -344,7 +344,7 @@
"js-tiktoken": "^1.0.20",
"keytar": "^7.9.0",
"tls-client-node": "^0.2.0",
"wreq-js": "^3.0.0",
"wreq-js": "^2.3.1",
"sqlite-vec": "^0.1.9"
},
"devDependencies": {

View File

@@ -7,7 +7,7 @@
"main": "./src/index.ts",
"types": "./src/index.ts",
"dependencies": {
"playwright": "1.62.1"
"playwright": "1.61.1"
},
"devDependencies": {
"@types/node": "^22"

View 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 },
];
}

View File

@@ -304,7 +304,7 @@ curl -X POST https://localhost:20128/api/v1/management/proxy-subscriptions/{id}/
Document OCR
Multi-provider document OCR endpoint (Mistral OCRcompatible request and response shape). Accepts a JSON body referencing a document/image and returns extracted text. `model` selects the provider via a `provider/model` prefix (e.g. `mistral/mistral-ocr-latest`, `azure-document-intelligence/prebuilt-read`); a bare model id (e.g. `mistral-ocr-latest`) resolves to its registered provider, and an omitted `model` defaults to Mistral. Azure Document Intelligence is asynchronous upstream — the handler polls the returned operation until it succeeds or fails before responding, so this endpoint can take longer to return for that provider. Success responses carry the `X-OmniRoute-*` cost-telemetry headers.
Multi-provider document OCR endpoint (Mistral OCRcompatible request and response shape). Accepts a JSON body referencing a document/image and returns extracted text. `model` selects the provider via a `provider/model` prefix (e.g. `mistral/mistral-ocr-latest`, `azure-document-intelligence/prebuilt-read`, `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.
```bash
curl -X POST https://localhost:20128/api/v1/ocr \

View File

@@ -1,4 +1,9 @@
import { handleOcr } from "@omniroute/open-sse/handlers/ocr.ts";
import {
handleOcr,
resolveVertexOcrAccessToken,
resolveVertexOcrBaseUrl,
VERTEX_DEEPSEEK_OCR_PROVIDER_ID,
} from "@omniroute/open-sse/handlers/ocr.ts";
import {
getProviderCredentialsWithQuotaPreflight,
clearRecoveredProviderState,
@@ -15,22 +20,34 @@ import {
rateLimitedProviderResponse,
} from "@/app/api/v1/_shared/rateLimit";
export { resolveVertexOcrAccessToken };
/**
* 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).
* 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; providerSpecificData?: Record<string, unknown> },
>(credentials: T): T {
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;
}
@@ -85,7 +102,8 @@ async function postHandler(request, context) {
return rateLimitedProviderResponse(resolvedProvider, credentials);
}
const ocrCredentials = resolveOcrCredentials(credentials);
const tokenReadyCredentials = await resolveVertexOcrAccessToken(resolvedProvider, credentials);
const ocrCredentials = resolveOcrCredentials(tokenReadyCredentials, resolvedProvider);
const response = await handleOcr({ body: { ...body, model }, credentials: ocrCredentials });
if (response?.ok) {

View 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}`
);
}
});

View File

@@ -4,6 +4,7 @@ import {
OCR_PROVIDERS,
getOcrTransformation,
MISTRAL_PASSTHROUGH,
VERTEX_DEEPSEEK_TRANSFORMATION,
} from "../../open-sse/config/ocrRegistry.ts";
test("mistral resolves the passthrough transformation by default", () => {
@@ -72,3 +73,94 @@ test("azure DI maps base64/image_url documents to base64Source/urlSource", () =>
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");
});

View File

@@ -0,0 +1,142 @@
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;
}
});