diff --git a/CHANGELOG.md b/CHANGELOG.md index 293f2887fa..42cd455f1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### ✨ New Features -- **feat(providers):** add ClinePass as a first-class API-key provider (Cline's BYOK gateway). (thanks @adentdk) +- **feat(api):** add `/v1/ocr` endpoint (Mistral OCR), an OCR provider category, and Mistral moderation support. (thanks @waguriagentic) ### 🔧 Bug Fixes diff --git a/open-sse/config/mediaServiceKinds.ts b/open-sse/config/mediaServiceKinds.ts index 95e831ce40..22a692c971 100644 --- a/open-sse/config/mediaServiceKinds.ts +++ b/open-sse/config/mediaServiceKinds.ts @@ -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>; export type RegistryMediaKind = keyof typeof MEDIA_KIND_REGISTRIES; diff --git a/open-sse/config/moderationRegistry.ts b/open-sse/config/moderationRegistry.ts index 4a3f621998..c63d9e17b4 100644 --- a/open-sse/config/moderationRegistry.ts +++ b/open-sse/config/moderationRegistry.ts @@ -5,7 +5,25 @@ * Follows OpenAI's moderation API format. */ -export const MODERATION_PROVIDERS = { +export interface ModerationModel { + id: string; + name: string; +} + +export interface ModerationProvider { + id: string; + baseUrl: string; + authType: string; + authHeader: string; + models: ModerationModel[]; +} + +export interface ParsedModerationModel { + provider: string | null; + model: string | null; +} + +export const MODERATION_PROVIDERS: Record = { openai: { id: "openai", baseUrl: "https://api.openai.com/v1/moderations", @@ -16,22 +34,29 @@ 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" }], + }, }; /** - * Get moderation provider config by ID + * Get moderation provider config by ID. */ -export function getModerationProvider(providerId) { +export function getModerationProvider(providerId: string): ModerationProvider | null { return MODERATION_PROVIDERS[providerId] || null; } /** - * Parse moderation model string + * Parse a moderation model string. */ -export function parseModerationModel(modelStr) { +export function parseModerationModel(modelStr: string | null | undefined): ParsedModerationModel { if (!modelStr) return { provider: null, model: null }; - for (const [providerId, config] of Object.entries(MODERATION_PROVIDERS)) { + for (const providerId of Object.keys(MODERATION_PROVIDERS)) { if (modelStr.startsWith(providerId + "/")) { return { provider: providerId, model: modelStr.slice(providerId.length + 1) }; } @@ -47,10 +72,10 @@ export function parseModerationModel(modelStr) { } /** - * Get all moderation models as a flat list + * Get all moderation models as a flat list. */ -export function getAllModerationModels() { - const models = []; +export function getAllModerationModels(): Array<{ id: string; name: string; provider: string }> { + const models: Array<{ id: string; name: string; provider: string }> = []; for (const [providerId, config] of Object.entries(MODERATION_PROVIDERS)) { for (const model of config.models) { models.push({ diff --git a/open-sse/config/ocrRegistry.ts b/open-sse/config/ocrRegistry.ts new file mode 100644 index 0000000000..fdf47d44f1 --- /dev/null +++ b/open-sse/config/ocrRegistry.ts @@ -0,0 +1,82 @@ +/** + * 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[]; +} + +export interface ParsedOcrModel { + provider: string | null; + model: string | null; +} + +export const OCR_PROVIDERS: Record = { + 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: 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; +} diff --git a/open-sse/handlers/ocr.ts b/open-sse/handlers/ocr.ts new file mode 100644 index 0000000000..bf0c553ff0 --- /dev/null +++ b/open-sse/handlers/ocr.ts @@ -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} */ +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}`); + } +} diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/MediaProviderPageClient.tsx b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/MediaProviderPageClient.tsx index 9ca2963b66..c1eed94d7c 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/MediaProviderPageClient.tsx +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/MediaProviderPageClient.tsx @@ -14,6 +14,7 @@ import { WebSearchExampleCard } from "../../components/WebSearchExampleCard"; import { WebFetchExampleCard } from "../../components/WebFetchExampleCard"; import { VideoExampleCard } from "../../components/VideoExampleCard"; import { MusicExampleCard } from "../../components/MusicExampleCard"; +import { OcrExampleCard } from "../../components/OcrExampleCard"; interface Connection { id: string; @@ -53,6 +54,8 @@ function renderPlayground(kind: MediaKind, providerId: string) { return ; case "music": return ; + case "ocr": + return ; case "imageToText": // Endpoint /api/v1/images/understanding does not exist yet — omitted. return ( diff --git a/src/app/(dashboard)/dashboard/media-providers/components/OcrExampleCard.tsx b/src/app/(dashboard)/dashboard/media-providers/components/OcrExampleCard.tsx new file mode 100644 index 0000000000..f5a675b034 --- /dev/null +++ b/src/app/(dashboard)/dashboard/media-providers/components/OcrExampleCard.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { useApiKey } from "../../providers/hooks/useApiKey"; +import { useProviderModels } from "../../providers/hooks/useProviderModels"; +import { buildCurl } from "../../providers/utils/buildCurl"; +import { PlaygroundCard } from "./PlaygroundCard"; + +interface Props { + providerId: string; +} + +const ENDPOINT_PATH = "/api/v1/ocr"; +const SAMPLE_DOCUMENT_URL = "https://arxiv.org/pdf/2201.04234"; + +function extractError(data: unknown): string | null { + if (!data || typeof data !== "object") return null; + const d = data as Record; + const err = d.error as Record | undefined; + if (err?.message) return String(err.message); + if (typeof d.message === "string") return d.message; + return null; +} + +export function OcrExampleCard({ providerId }: Props) { + const t = useTranslations("miniPlayground"); + const { apiKey } = useApiKey(); + const { models } = useProviderModels(providerId); + + const firstModel = models[0]?.id ?? ""; + const [model, setModel] = useState(""); + const [documentUrl, setDocumentUrl] = useState(SAMPLE_DOCUMENT_URL); + const [running, setRunning] = useState(false); + const [result, setResult] = useState<{ data: unknown; latencyMs: number } | undefined>(); + const [error, setError] = useState(null); + + const effectiveModel = model || firstModel; + + const buildBody = () => ({ + model: effectiveModel, + document: { type: "document_url", document_url: documentUrl }, + }); + + const curlSnippet = buildCurl({ + endpoint: + (typeof window !== "undefined" ? window.location.origin : "http://localhost:20128") + + ENDPOINT_PATH, + headers: { + Authorization: `Bearer ${apiKey || ""}`, + "Content-Type": "application/json", + }, + body: buildBody(), + }); + + const handleRun = async () => { + setRunning(true); + setError(null); + setResult(undefined); + const t0 = performance.now(); + try { + const res = await fetch(ENDPOINT_PATH, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + "x-connection-id": providerId, + }, + body: JSON.stringify(buildBody()), + }); + const data: unknown = await res.json(); + const latencyMs = performance.now() - t0; + const errMsg = extractError(data); + if (!res.ok || errMsg) { + setError(errMsg ?? `HTTP ${res.status}`); + } else { + setResult({ data, latencyMs }); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Request failed"); + } finally { + setRunning(false); + } + }; + + const modelOptions = models.length > 0 ? models : [{ id: "mistral-ocr-latest" }]; + + return ( + + {/* Model select */} +
+ + +
+ {/* Document URL */} +
+ + setDocumentUrl(e.target.value)} + placeholder={SAMPLE_DOCUMENT_URL} + className="w-full rounded-md border border-border bg-bg-subtle text-sm px-2 py-1.5 text-text-main focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/media-providers/components/ServiceKindTabs.tsx b/src/app/(dashboard)/dashboard/media-providers/components/ServiceKindTabs.tsx index bcd50300e4..2e54dd4841 100644 --- a/src/app/(dashboard)/dashboard/media-providers/components/ServiceKindTabs.tsx +++ b/src/app/(dashboard)/dashboard/media-providers/components/ServiceKindTabs.tsx @@ -15,6 +15,7 @@ const KIND_ICON: Record = { webFetch: "language", video: "videocam", music: "music_note", + ocr: "document_scanner", }; interface ServiceKindTabsProps { diff --git a/src/app/(dashboard)/dashboard/media-providers/components/mediaKinds.ts b/src/app/(dashboard)/dashboard/media-providers/components/mediaKinds.ts index a3ded4173e..05b5586e34 100644 --- a/src/app/(dashboard)/dashboard/media-providers/components/mediaKinds.ts +++ b/src/app/(dashboard)/dashboard/media-providers/components/mediaKinds.ts @@ -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", ]; diff --git a/src/app/api/v1/ocr/route.ts b/src/app/api/v1/ocr/route.ts new file mode 100644 index 0000000000..8b717c7a58 --- /dev/null +++ b/src/app/api/v1/ocr/route.ts @@ -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); diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 8c1d17ae05..62122d8bc4 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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", diff --git a/src/shared/constants/endpointCategories.ts b/src/shared/constants/endpointCategories.ts index 509b534282..fd761f9987 100644 --- a/src/shared/constants/endpointCategories.ts +++ b/src/shared/constants/endpointCategories.ts @@ -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", diff --git a/src/shared/constants/serviceKinds.ts b/src/shared/constants/serviceKinds.ts index 2121edb1d6..7b29a888ad 100644 --- a/src/shared/constants/serviceKinds.ts +++ b/src/shared/constants/serviceKinds.ts @@ -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", ]; diff --git a/src/shared/validation/schemas/apiV1.ts b/src/shared/validation/schemas/apiV1.ts index e941a3dc54..e29e734ed6 100644 --- a/src/shared/validation/schemas/apiV1.ts +++ b/src/shared/validation/schemas/apiV1.ts @@ -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, diff --git a/tests/unit/endpoint-categories.test.ts b/tests/unit/endpoint-categories.test.ts index b64a63e8a8..b51215300d 100644 --- a/tests/unit/endpoint-categories.test.ts +++ b/tests/unit/endpoint-categories.test.ts @@ -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"); }); diff --git a/tests/unit/minimax-media-servicekinds.test.ts b/tests/unit/minimax-media-servicekinds.test.ts index 5cb64cb638..6eecb2a1c2 100644 --- a/tests/unit/minimax-media-servicekinds.test.ts +++ b/tests/unit/minimax-media-servicekinds.test.ts @@ -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)) { diff --git a/tests/unit/moderations-handler.test.ts b/tests/unit/moderations-handler.test.ts index 06cff14788..68ec32847a 100644 --- a/tests/unit/moderations-handler.test.ts +++ b/tests/unit/moderations-handler.test.ts @@ -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" }, diff --git a/tests/unit/ocr-route.test.ts b/tests/unit/ocr-route.test.ts new file mode 100644 index 0000000000..5047311c38 --- /dev/null +++ b/tests/unit/ocr-route.test.ts @@ -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 /")); +});