feat(api): add /v1/ocr endpoint (Mistral OCR) + Mistral moderation

Net-new public /v1/ocr endpoint (CORS -> Zod -> auth -> handler) proxying
Mistral OCR, built on OmniRoute's existing moderations pattern: new
ocrRegistry (OCR_PROVIDERS/parseOcrModel), handler open-sse/handlers/ocr.ts,
route src/app/api/v1/ocr, and v1OcrSchema. Adds a registry-backed "ocr" media
serviceKind so Mistral surfaces under an OCR category in the dashboard, plus
the ocr endpoint category. Fills the Mistral moderation registry gap
(api.mistral.ai/v1/moderations, mistral-moderation-latest).

Co-authored-by: Waguri Agent <waguriagent@gmail.com>
Inspired-by: https://github.com/decolua/9router/pull/2064
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-02 15:09:53 -03:00
parent be420afe1f
commit 5376f6d9a8
16 changed files with 512 additions and 14 deletions

View File

@@ -8,7 +8,7 @@
### ✨ New Features
_TBD_
- **feat(api):** add `/v1/ocr` endpoint (Mistral OCR), an OCR provider category, and Mistral moderation support. (thanks @waguriagentic)
### 🔧 Bug Fixes

View File

@@ -17,14 +17,12 @@
* still declared explicitly via `serviceKinds` on the provider entry; callers
* union the two sources.
*/
import {
AUDIO_TRANSCRIPTION_PROVIDERS,
AUDIO_SPEECH_PROVIDERS,
} from "./audioRegistry.ts";
import { AUDIO_TRANSCRIPTION_PROVIDERS, AUDIO_SPEECH_PROVIDERS } from "./audioRegistry.ts";
import { VIDEO_PROVIDERS } from "./videoRegistry.ts";
import { MUSIC_PROVIDERS } from "./musicRegistry.ts";
import { IMAGE_PROVIDERS } from "./imageRegistry.ts";
import { EMBEDDING_PROVIDERS } from "./embeddingRegistry.ts";
import { OCR_PROVIDERS } from "./ocrRegistry.ts";
/** Media kinds whose provider membership is defined by a backend registry. */
export const MEDIA_KIND_REGISTRIES = {
@@ -34,6 +32,7 @@ export const MEDIA_KIND_REGISTRIES = {
music: MUSIC_PROVIDERS,
image: IMAGE_PROVIDERS,
embedding: EMBEDDING_PROVIDERS,
ocr: OCR_PROVIDERS,
} as const satisfies Record<string, Record<string, unknown>>;
export type RegistryMediaKind = keyof typeof MEDIA_KIND_REGISTRIES;

View File

@@ -16,6 +16,13 @@ export const MODERATION_PROVIDERS = {
{ id: "text-moderation-latest", name: "Text Moderation Latest" },
],
},
mistral: {
id: "mistral",
baseUrl: "https://api.mistral.ai/v1/moderations",
authType: "apikey",
authHeader: "bearer",
models: [{ id: "mistral-moderation-latest", name: "Mistral Moderation" }],
},
};
/**

View File

@@ -0,0 +1,64 @@
/**
* OCR Provider Registry
*
* Defines providers that support the /v1/ocr endpoint.
* Follows Mistral's OCR API format.
*/
export const OCR_PROVIDERS = {
mistral: {
id: "mistral",
baseUrl: "https://api.mistral.ai/v1/ocr",
authType: "apikey",
authHeader: "bearer",
models: [{ id: "mistral-ocr-latest", name: "Mistral OCR" }],
},
};
/**
* Get OCR provider config by ID
*/
export function getOcrProvider(providerId) {
return OCR_PROVIDERS[providerId] || null;
}
/**
* Parse 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) {
if (!modelStr) return { provider: null, model: null };
for (const [providerId] of Object.entries(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() {
const models = [];
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;
}

79
open-sse/handlers/ocr.ts Normal file
View File

@@ -0,0 +1,79 @@
import { CORS_HEADERS } from "../utils/cors.ts";
/**
* OCR Handler
*
* Handles POST /v1/ocr (Mistral OCR API format).
*/
import { getOcrProvider, parseOcrModel } from "../config/ocrRegistry.ts";
import { errorResponse } from "../utils/error.ts";
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
import { generateRequestId } from "@/shared/utils/requestId";
/**
* Handle OCR request
*
* @param {Object} options
* @param {Object} options.body - JSON body { model, document }
* @param {Object} options.credentials - Provider credentials { apiKey }
* @returns {Response}
*/
/** @returns {Promise<unknown>} */
export async function handleOcr({ body, credentials }) {
const startTime = Date.now();
if (!body.document) {
return errorResponse(400, "document is required");
}
// Default to latest OCR model
const model = body.model || "mistral-ocr-latest";
const { provider: providerId, model: modelId } = parseOcrModel(model);
const providerConfig = providerId ? getOcrProvider(providerId) : null;
if (!providerConfig) {
return errorResponse(400, `No OCR provider found for model "${model}". Available: mistral`);
}
const token = credentials?.apiKey || credentials?.accessToken;
if (!token) {
return errorResponse(401, `No credentials for OCR provider: ${providerId}`);
}
try {
const res = await fetch(providerConfig.baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
...body,
model: modelId,
}),
});
if (!res.ok) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
},
});
}
const data = await res.json();
const headers = new Headers({ ...CORS_HEADERS, "Content-Type": "application/json" });
attachOmniRouteMetaHeaders(headers, {
provider: providerId,
model: modelId,
costUsd: 0,
latencyMs: Date.now() - startTime,
requestId: generateRequestId(),
});
return new Response(JSON.stringify(data), { status: 200, headers });
} catch (err) {
return errorResponse(500, `OCR request failed: ${err.message}`);
}
}

View File

@@ -15,6 +15,7 @@ const KIND_ICON: Record<ServiceKind, string> = {
webFetch: "language",
video: "videocam",
music: "music_note",
ocr: "document_scanner",
};
interface ServiceKindTabsProps {

View File

@@ -7,7 +7,8 @@ export type MediaKind =
| "webSearch"
| "webFetch"
| "video"
| "music";
| "music"
| "ocr";
export const MEDIA_KINDS: MediaKind[] = [
"embedding",
@@ -19,4 +20,5 @@ export const MEDIA_KINDS: MediaKind[] = [
"webFetch",
"video",
"music",
"ocr",
];

View File

@@ -0,0 +1,73 @@
import { handleOcr } from "@omniroute/open-sse/handlers/ocr.ts";
import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth";
import { withInjectionGuard } from "@/middleware/promptInjectionGuard";
import { parseOcrModel } from "@omniroute/open-sse/config/ocrRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { v1OcrSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import {
isAllRateLimitedCredentials,
rateLimitedProviderResponse,
} from "@/app/api/v1/_shared/rateLimit";
/**
* Handle CORS preflight
*/
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
});
}
/**
* POST /v1/ocr — document OCR
* Mistral OCR API compatible.
*/
async function postHandler(request, context) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
const validation = validateBody(v1OcrSchema, rawBody);
if (isValidationFailure(validation)) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message);
}
const body = validation.data;
const model = body.model || "mistral-ocr-latest";
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, model);
if (policy.rejection) return policy.rejection;
const { provider } = parseOcrModel(model);
// Default to mistral if no provider prefix
const resolvedProvider = provider || "mistral";
const credentials = await getProviderCredentials(resolvedProvider);
if (!credentials) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`No credentials for provider: ${resolvedProvider}`
);
}
if (isAllRateLimitedCredentials(credentials)) {
return rateLimitedProviderResponse(resolvedProvider, credentials);
}
const response = await handleOcr({ body: { ...body, model }, credentials });
if (response?.ok) {
await clearRecoveredProviderState(credentials);
}
return response;
}
export const POST = withInjectionGuard(postHandler);

View File

@@ -1856,7 +1856,8 @@
"webSearch": "Web Search",
"webFetch": "Web Fetch",
"video": "Video",
"music": "Music"
"music": "Music",
"ocr": "OCR"
},
"noProviders": "No providers configured for this kind yet.",
"addConnection": "Add Connection",

View File

@@ -22,12 +22,7 @@ export const ENDPOINT_CATEGORIES: readonly EndpointCategory[] = [
id: "chat",
label: "Chat / Messages",
description: "Chat completions, text completions, messages, and responses",
prefixes: [
"/v1/chat/completions",
"/v1/completions",
"/v1/messages",
"/v1/responses",
],
prefixes: ["/v1/chat/completions", "/v1/completions", "/v1/messages", "/v1/responses"],
},
{
id: "search",
@@ -83,6 +78,12 @@ export const ENDPOINT_CATEGORIES: readonly EndpointCategory[] = [
description: "Content moderation",
prefixes: ["/v1/moderations"],
},
{
id: "ocr",
label: "OCR",
description: "Optical character recognition",
prefixes: ["/v1/ocr"],
},
{
id: "batches",
label: "Batch Processing",

View File

@@ -16,7 +16,8 @@ export type ServiceKind =
| "webSearch"
| "webFetch"
| "video"
| "music";
| "music"
| "ocr";
export const SERVICE_KIND_VALUES: readonly ServiceKind[] = [
"llm",
@@ -29,4 +30,5 @@ export const SERVICE_KIND_VALUES: readonly ServiceKind[] = [
"webFetch",
"video",
"music",
"ocr",
];

View File

@@ -87,6 +87,31 @@ export const v1ModerationSchema = z
})
.catchall(z.unknown());
// Mistral OCR: `document` is a { type, document_url | image_url } object.
// Keep the schema permissive-but-typed — validate model + that a non-empty
// `document` object (or a document_url/image_url string shorthand) is present.
export const v1OcrDocumentSchema = z.union([
z
.object({
type: z.string().trim().min(1).optional(),
document_url: z.string().trim().min(1).optional(),
image_url: z.union([z.string().trim().min(1), z.record(z.string(), z.unknown())]).optional(),
})
.catchall(z.unknown())
.refine(
(value) => value.document_url !== undefined || value.image_url !== undefined,
"document must include document_url or image_url"
),
nonEmptyStringSchema,
]);
export const v1OcrSchema = z
.object({
model: modelIdSchema.optional(),
document: v1OcrDocumentSchema,
})
.catchall(z.unknown());
export const v1RerankSchema = z
.object({
model: modelIdSchema,

View File

@@ -80,6 +80,10 @@ test("resolveEndpointCategory: maps /v1/moderations to 'moderations'", () => {
assert.equal(resolveEndpointCategory("/v1/moderations"), "moderations");
});
test("resolveEndpointCategory: maps /v1/ocr to 'ocr'", () => {
assert.equal(resolveEndpointCategory("/v1/ocr"), "ocr");
});
test("resolveEndpointCategory: maps /v1/batches to 'batches'", () => {
assert.equal(resolveEndpointCategory("/v1/batches"), "batches");
});

View File

@@ -81,6 +81,19 @@ test("media listing filter surfaces minimax where the old declared-only filter m
assert.ok(oldListFor("tts").length < newListFor("tts").length, "fix surfaces additional tts providers");
});
test("ocr is a registry-backed media kind and mistral derives it", () => {
assert.ok(
(REGISTRY_MEDIA_KINDS as readonly string[]).includes("ocr"),
"REGISTRY_MEDIA_KINDS should include ocr once the OCR registry is wired"
);
assert.ok(
getRegistryMediaKinds("mistral").includes("ocr" as never),
"mistral should derive the ocr media kind from OCR_PROVIDERS"
);
const merged = resolveProviderServiceKinds("mistral", ["llm"]);
assert.ok(merged.includes("ocr"), `expected ocr in ${merged.join(",")}`);
});
test("derived kinds are always within the known media-kind set", () => {
for (const id of Object.keys(AI_PROVIDERS)) {
for (const kind of getRegistryMediaKinds(id)) {

View File

@@ -2,6 +2,9 @@ import test from "node:test";
import assert from "node:assert/strict";
const { handleModeration } = await import("../../open-sse/handlers/moderations.ts");
const { MODERATION_PROVIDERS, getModerationProvider, parseModerationModel } = await import(
"../../open-sse/config/moderationRegistry.ts"
);
const originalFetch = globalThis.fetch;
@@ -9,6 +12,42 @@ test.afterEach(() => {
globalThis.fetch = originalFetch;
});
test("MODERATION_PROVIDERS registers mistral with the Mistral moderations base URL", () => {
const provider = getModerationProvider("mistral");
assert.ok(provider);
assert.equal(provider.baseUrl, "https://api.mistral.ai/v1/moderations");
assert.ok(provider.models.some((m: { id: string }) => m.id === "mistral-moderation-latest"));
assert.ok(MODERATION_PROVIDERS.mistral);
});
test("parseModerationModel routes mistral moderation models to the mistral provider", () => {
assert.deepEqual(parseModerationModel("mistral/mistral-moderation-latest"), {
provider: "mistral",
model: "mistral-moderation-latest",
});
assert.deepEqual(parseModerationModel("mistral-moderation-latest"), {
provider: "mistral",
model: "mistral-moderation-latest",
});
});
test("handleModeration proxies mistral moderation requests to the mistral endpoint", async () => {
let captured: any;
globalThis.fetch = async (url: any, options: any = {}) => {
captured = { url: String(url), headers: options.headers };
return Response.json({ id: "modr-mistral", results: [{ flagged: false }] });
};
const response = await handleModeration({
body: { model: "mistral/mistral-moderation-latest", input: "check this" },
credentials: { apiKey: "sk-mistral" },
});
assert.equal(captured.url, "https://api.mistral.ai/v1/moderations");
assert.equal(captured.headers.Authorization, "Bearer sk-mistral");
assert.equal(response.status, 200);
});
test("handleModeration requires input", async () => {
const response = await handleModeration({
body: { model: "openai/omni-moderation-latest" },

View File

@@ -0,0 +1,188 @@
import test from "node:test";
import assert from "node:assert/strict";
const { POST, OPTIONS } = await import("../../src/app/api/v1/ocr/route.ts");
const { handleOcr } = await import("../../open-sse/handlers/ocr.ts");
const { OCR_PROVIDERS, getOcrProvider, parseOcrModel, getAllOcrModels } =
await import("../../open-sse/config/ocrRegistry.ts");
const { v1OcrSchema } = await import("../../src/shared/validation/schemas/apiV1.ts");
const originalFetch = globalThis.fetch;
test.afterEach(() => {
globalThis.fetch = originalFetch;
});
function ocrRequest(body: string) {
return new Request("http://localhost/v1/ocr", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
}
// ── Registry ───────────────────────────────────────────────────────────────
test("OCR_PROVIDERS registers mistral with the Mistral OCR base URL", () => {
assert.equal(OCR_PROVIDERS.mistral.baseUrl, "https://api.mistral.ai/v1/ocr");
const provider = getOcrProvider("mistral");
assert.ok(provider);
assert.equal(provider.authHeader, "bearer");
assert.ok(provider.models.some((m: { id: string }) => m.id === "mistral-ocr-latest"));
});
test("parseOcrModel routes bare and prefixed mistral models to the mistral provider", () => {
assert.deepEqual(parseOcrModel("mistral-ocr-latest"), {
provider: "mistral",
model: "mistral-ocr-latest",
});
assert.deepEqual(parseOcrModel("mistral/mistral-ocr-latest"), {
provider: "mistral",
model: "mistral-ocr-latest",
});
// Unknown model → no provider resolved
assert.deepEqual(parseOcrModel("mystery-model"), {
provider: null,
model: "mystery-model",
});
});
test("getAllOcrModels exposes the mistral OCR model with a provider prefix", () => {
const models = getAllOcrModels();
assert.ok(models.some((m: { id: string }) => m.id === "mistral/mistral-ocr-latest"));
});
// ── Schema (Zod, Rule #7) ────────────────────────────────────────────────────
test("v1OcrSchema rejects a body without a document", () => {
const result = v1OcrSchema.safeParse({ model: "mistral-ocr-latest" });
assert.equal(result.success, false);
});
test("v1OcrSchema accepts a document_url document object", () => {
const result = v1OcrSchema.safeParse({
model: "mistral-ocr-latest",
document: { type: "document_url", document_url: "https://example.com/a.pdf" },
});
assert.equal(result.success, true);
});
test("v1OcrSchema accepts an image_url document object", () => {
const result = v1OcrSchema.safeParse({
document: { type: "image_url", image_url: "https://example.com/a.png" },
});
assert.equal(result.success, true);
});
// ── Route (public /v1/ocr entry point) ───────────────────────────────────────
test("POST /v1/ocr returns 400 for invalid JSON without leaking a stack trace", async () => {
const response = await POST(ocrRequest("not json at all"));
const body = (await response.json()) as any;
assert.equal(response.status, 400);
assert.equal(body.error.message, "Invalid JSON body");
// Rule #12 — error responses must never leak stack traces.
assert.ok(!body.error.message.includes("at /"));
});
test("OPTIONS /v1/ocr answers the CORS preflight", async () => {
const response = await OPTIONS();
assert.equal(response.status, 200);
assert.match(response.headers.get("access-control-allow-methods") || "", /OPTIONS/);
});
// ── Handler ──────────────────────────────────────────────────────────────────
test("handleOcr requires a document", async () => {
const response = await handleOcr({
body: { model: "mistral-ocr-latest" },
credentials: { apiKey: "sk-test" },
});
const payload = (await response.json()) as any;
assert.equal(response.status, 400);
assert.equal(payload.error.message, "document is required");
});
test("handleOcr rejects unknown OCR models", async () => {
const response = await handleOcr({
body: { model: "mystery/ocr", document: { document_url: "x" } },
credentials: { apiKey: "sk-test" },
});
const payload = (await response.json()) as any;
assert.equal(response.status, 400);
assert.match(payload.error.message, /No OCR provider found/);
});
test("handleOcr requires credentials for the resolved provider", async () => {
const response = await handleOcr({
body: { document: { document_url: "x" } },
credentials: null,
});
const payload = (await response.json()) as any;
assert.equal(response.status, 401);
assert.equal(payload.error.message, "No credentials for OCR provider: mistral");
});
test("handleOcr proxies a successful request to the mistral OCR endpoint", async () => {
let captured: any;
globalThis.fetch = async (url: any, options: any = {}) => {
captured = {
url: String(url),
headers: options.headers,
body: JSON.parse(String(options.body || "{}")),
};
return Response.json({ pages: [{ index: 0, markdown: "hello" }] });
};
const response = await handleOcr({
body: { document: { type: "document_url", document_url: "https://example.com/a.pdf" } },
credentials: { apiKey: "sk-mistral" },
});
assert.equal(captured.url, "https://api.mistral.ai/v1/ocr");
assert.equal(captured.headers.Authorization, "Bearer sk-mistral");
// model defaults to mistral-ocr-latest and the document is forwarded upstream.
assert.equal(captured.body.model, "mistral-ocr-latest");
assert.deepEqual(captured.body.document, {
type: "document_url",
document_url: "https://example.com/a.pdf",
});
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { pages: [{ index: 0, markdown: "hello" }] });
});
test("handleOcr passes upstream error payloads through with the upstream status", async () => {
globalThis.fetch = async () =>
new Response('{"error":"bad request"}', {
status: 422,
headers: { "content-type": "application/json" },
});
const response = await handleOcr({
body: { model: "mistral/mistral-ocr-latest", document: { document_url: "x" } },
credentials: { apiKey: "sk-test" },
});
assert.equal(response.status, 422);
assert.equal(await response.text(), '{"error":"bad request"}');
});
test("handleOcr returns a sanitized 500 when the upstream request throws", async () => {
globalThis.fetch = async () => {
throw new Error("socket closed");
};
const response = await handleOcr({
body: { model: "mistral-ocr-latest", document: { document_url: "x" } },
credentials: { apiKey: "sk-test" },
});
const payload = (await response.json()) as any;
assert.equal(response.status, 500);
assert.match(payload.error.message, /OCR request failed: socket closed/);
assert.ok(!payload.error.message.includes("at /"));
});