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>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-14 15:15:12 -03:00
committed by GitHub
parent ff64716c31
commit 7837e46908
8 changed files with 444 additions and 19 deletions

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

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

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

@@ -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;
}
});