mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-17 20:52:15 +03:00
* 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>
254 lines
8.1 KiB
TypeScript
254 lines
8.1 KiB
TypeScript
/**
|
|
* OCR Provider Registry
|
|
*
|
|
* Defines providers that support the /v1/ocr endpoint.
|
|
* Follows Mistral's OCR API format.
|
|
*/
|
|
|
|
export interface OcrModel {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
export interface OcrProvider {
|
|
id: string;
|
|
baseUrl: string;
|
|
authType: string;
|
|
authHeader: string;
|
|
models: OcrModel[];
|
|
transformation?: OcrTransformation;
|
|
}
|
|
|
|
export interface ParsedOcrModel {
|
|
provider: string | null;
|
|
model: string | null;
|
|
}
|
|
|
|
export interface OcrResponseShape {
|
|
pages: Array<{ index: number; markdown: string }>;
|
|
model: string;
|
|
usage_info?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface OcrTransformation {
|
|
buildRequest(args: {
|
|
baseUrl: string;
|
|
token: string;
|
|
body: Record<string, unknown>;
|
|
modelId: string;
|
|
}): { url: string; init: RequestInit };
|
|
parseResponse(raw: unknown): OcrResponseShape;
|
|
/** Async providers (Azure DI): return the poll URL from the first response, else null. */
|
|
pollUrl?(res: Response): string | null;
|
|
}
|
|
|
|
export const MISTRAL_PASSTHROUGH: OcrTransformation = {
|
|
buildRequest({ baseUrl, token, body, modelId }) {
|
|
return {
|
|
url: baseUrl,
|
|
init: {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({ ...body, model: modelId }),
|
|
},
|
|
};
|
|
},
|
|
parseResponse(raw) {
|
|
return raw as OcrResponseShape;
|
|
},
|
|
};
|
|
|
|
export function getOcrTransformation(providerId: string): OcrTransformation {
|
|
return OCR_PROVIDERS[providerId]?.transformation ?? MISTRAL_PASSTHROUGH;
|
|
}
|
|
|
|
const AZURE_DI_API_VERSION = "2024-11-30";
|
|
|
|
function azureDiSource(document: Record<string, unknown> | undefined): Record<string, string> {
|
|
if (!document) return {};
|
|
const url = String(document.document_url ?? document.image_url ?? "");
|
|
if (url.startsWith("data:")) {
|
|
const comma = url.indexOf(",");
|
|
return { base64Source: comma >= 0 ? url.slice(comma + 1) : "" };
|
|
}
|
|
return url ? { urlSource: url } : {};
|
|
}
|
|
|
|
export const AZURE_DI_TRANSFORMATION: OcrTransformation = {
|
|
buildRequest({ baseUrl, token, body, modelId }) {
|
|
const root = baseUrl.replace(/\/+$/, "");
|
|
return {
|
|
url: `${root}/documentintelligence/documentModels/${modelId}:analyze?api-version=${AZURE_DI_API_VERSION}&outputContentFormat=markdown`,
|
|
init: {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "Ocp-Apim-Subscription-Key": token },
|
|
body: JSON.stringify(azureDiSource(body.document as Record<string, unknown>)),
|
|
},
|
|
};
|
|
},
|
|
pollUrl(res) {
|
|
return res.headers.get("Operation-Location");
|
|
},
|
|
parseResponse(raw) {
|
|
const r = raw as {
|
|
analyzeResult?: { content?: string; pages?: unknown[] };
|
|
};
|
|
const pageCount = r.analyzeResult?.pages?.length ?? 1;
|
|
// Azure returns the whole-document markdown in `content`; we mirror it into the
|
|
// Mistral shape as a single aggregated "page" (index 0), preserving pageCount.
|
|
return {
|
|
pages: [{ index: 0, markdown: r.analyzeResult?.content ?? "" }],
|
|
model: "prebuilt-read",
|
|
usage_info: { pages_processed: pageCount },
|
|
};
|
|
},
|
|
};
|
|
|
|
/**
|
|
* 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",
|
|
baseUrl: "https://api.mistral.ai/v1/ocr",
|
|
authType: "apikey",
|
|
authHeader: "bearer",
|
|
models: [{ id: "mistral-ocr-latest", name: "Mistral OCR" }],
|
|
},
|
|
"azure-document-intelligence": {
|
|
id: "azure-document-intelligence",
|
|
baseUrl: "",
|
|
authType: "apikey",
|
|
authHeader: "Ocp-Apim-Subscription-Key",
|
|
models: [{ id: "prebuilt-read", name: "Azure Document Intelligence (Read)" }],
|
|
transformation: AZURE_DI_TRANSFORMATION,
|
|
},
|
|
"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,
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Get OCR provider config by ID.
|
|
*/
|
|
export function getOcrProvider(providerId: string): OcrProvider | null {
|
|
return OCR_PROVIDERS[providerId] || null;
|
|
}
|
|
|
|
/**
|
|
* Parse an OCR model string.
|
|
*
|
|
* Accepts either a "provider/model" prefixed string or a bare model id that
|
|
* matches one of the registered OCR models.
|
|
*/
|
|
export function parseOcrModel(modelStr: string | null | undefined): ParsedOcrModel {
|
|
if (!modelStr) return { provider: null, model: null };
|
|
|
|
for (const providerId of Object.keys(OCR_PROVIDERS)) {
|
|
if (modelStr.startsWith(providerId + "/")) {
|
|
return { provider: providerId, model: modelStr.slice(providerId.length + 1) };
|
|
}
|
|
}
|
|
|
|
for (const [providerId, config] of Object.entries(OCR_PROVIDERS)) {
|
|
if (config.models.some((m) => m.id === modelStr)) {
|
|
return { provider: providerId, model: modelStr };
|
|
}
|
|
}
|
|
|
|
return { provider: null, model: modelStr };
|
|
}
|
|
|
|
/**
|
|
* Get all OCR models as a flat list.
|
|
*/
|
|
export function getAllOcrModels(): Array<{ id: string; name: string; provider: string }> {
|
|
const models: Array<{ id: string; name: string; provider: string }> = [];
|
|
for (const [providerId, config] of Object.entries(OCR_PROVIDERS)) {
|
|
for (const model of config.models) {
|
|
models.push({
|
|
id: `${providerId}/${model.id}`,
|
|
name: model.name,
|
|
provider: providerId,
|
|
});
|
|
}
|
|
}
|
|
return models;
|
|
}
|