mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
feat(api): add /v1/ocr endpoint (Mistral OCR) + Mistral moderation (#5950)
Integrated into release/v3.8.44 — /v1/ocr endpoint (Mistral OCR) + Mistral moderation (port upstream 9router#2064, co-authored @waguriagentic). Validated locally: 14 ocr-route tests + moderation/servicekind/endpoint-category suites green (CORS→Zod→handler + no-stack-leak assertion). Reds are inherited DRIFT only: cognitive-complexity ratchet (none from OCR files — pre-existing cycle drift, rebaselined at release) + environmental setup-claude base-red.
This commit is contained in:
committed by
GitHub
parent
26fd7b6a8a
commit
01d2e1a841
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, ModerationProvider> = {
|
||||
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({
|
||||
|
||||
82
open-sse/config/ocrRegistry.ts
Normal file
82
open-sse/config/ocrRegistry.ts
Normal file
@@ -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<string, OcrProvider> = {
|
||||
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;
|
||||
}
|
||||
79
open-sse/handlers/ocr.ts
Normal file
79
open-sse/handlers/ocr.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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 <VideoExampleCard providerId={providerId} />;
|
||||
case "music":
|
||||
return <MusicExampleCard providerId={providerId} />;
|
||||
case "ocr":
|
||||
return <OcrExampleCard providerId={providerId} />;
|
||||
case "imageToText":
|
||||
// Endpoint /api/v1/images/understanding does not exist yet — omitted.
|
||||
return (
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
const err = d.error as Record<string, unknown> | 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<string>("");
|
||||
const [documentUrl, setDocumentUrl] = useState<string>(SAMPLE_DOCUMENT_URL);
|
||||
const [running, setRunning] = useState<boolean>(false);
|
||||
const [result, setResult] = useState<{ data: unknown; latencyMs: number } | undefined>();
|
||||
const [error, setError] = useState<string | null>(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 || "<your-api-key>"}`,
|
||||
"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 (
|
||||
<PlaygroundCard
|
||||
kindLabel="OCR"
|
||||
apiEndpoint={ENDPOINT_PATH}
|
||||
onRun={handleRun}
|
||||
curlSnippet={curlSnippet}
|
||||
running={running}
|
||||
result={result}
|
||||
error={error}
|
||||
>
|
||||
{/* Model select */}
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">{t("model")}</label>
|
||||
<select
|
||||
value={model || firstModel}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
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"
|
||||
>
|
||||
{modelOptions.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Document URL */}
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">Document URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={documentUrl}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</PlaygroundCard>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const KIND_ICON: Record<ServiceKind, string> = {
|
||||
webFetch: "language",
|
||||
video: "videocam",
|
||||
music: "music_note",
|
||||
ocr: "document_scanner",
|
||||
};
|
||||
|
||||
interface ServiceKindTabsProps {
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
73
src/app/api/v1/ocr/route.ts
Normal file
73
src/app/api/v1/ocr/route.ts
Normal 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);
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
188
tests/unit/ocr-route.test.ts
Normal file
188
tests/unit/ocr-route.test.ts
Normal 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 /"));
|
||||
});
|
||||
Reference in New Issue
Block a user