import type OpenAI from "openai"; import { type PolyModel, modelKey } from "@/lib/ai/core/types"; import { createOpenAICompatAdapter } from "../shared/openai-compat/factory"; import { MISTRAL_CATALOG, mistralPricingFor, MISTRAL_NON_CHAT } from "./catalog"; /** * Mistral AI (La Plateforme) — `https://api.mistral.ai/v1/chat/completions` is OpenAI-shaped. * `GET /v1/models` returns rich capability metadata (completion_chat, function_calling, vision, * reasoning, max_context_length, aliases, deprecation) which drives the registry. * Quirks (docs/provider-research/mistral.md): `random_seed` instead of `seed`, `max_tokens`, * tool_choice `any`, Magistral reasoning streamed as `thinking` content chunks, `safe_prompt`. */ interface MistralModel { id: string; name?: string; description?: string; created?: number; max_context_length?: number; aliases?: string[]; deprecation?: string | null; deprecation_replacement_model?: string | null; default_model_temperature?: number | null; type?: string; capabilities?: { completion_chat?: boolean; function_calling?: boolean; vision?: boolean; reasoning?: boolean; completion_fim?: boolean; ocr?: boolean; audio?: boolean }; } export function normalizeMistralModel(m: MistralModel): PolyModel | null { const caps = m.capabilities ?? {}; if (!caps.completion_chat) return null; if (MISTRAL_NON_CHAT.test(m.id)) return null; const cat = MISTRAL_CATALOG.get(m.id) ?? MISTRAL_CATALOG.get(m.name ?? ""); const reasoning = caps.reasoning === true; const deprecated = Boolean(m.deprecation) && new Date(m.deprecation!).getTime() < Date.now(); const isAlias = m.name && m.name !== m.id; return { key: modelKey("mistral", m.id), id: m.id, provider: "mistral", displayName: cat?.displayName ?? prettyMistral(m.id, m.name), family: cat?.family ?? familyOf(m.id), capabilities: { text: true, vision: caps.vision === true, audioInput: caps.audio === true, audioOutput: false, imageGeneration: false, video: false, reasoning, tools: caps.function_calling === true, structuredOutput: true, streaming: true, files: true, // PDF via `document_url` data URI works on every chat model (probed) webSearch: false, ...(cat?.capabilityOverrides ?? {}), }, limits: { contextTokens: m.max_context_length ?? cat?.limits?.contextTokens, maxOutputTokens: cat?.limits?.maxOutputTokens }, parameters: { temperature: true, topP: true, topK: false, maxTokens: true, stop: true, seed: true, frequencyPenalty: true, presencePenalty: true, reasoningEffort: reasoning, reasoningEffortLevels: reasoning ? (m.id.includes("glm") ? ["none", "minimal", "low", "medium", "high", "xhigh", "max"] : ["none", "high"]) : undefined, thinkingBudget: false, temperatureRange: { min: 0, max: 1.5 }, ...(cat?.parameters ?? {}), }, status: deprecated ? "deprecated" : m.deprecation ? "deprecated" : cat?.status ?? (m.id.startsWith("labs-") ? "preview" : "active"), pricing: mistralPricingFor(m.id, m.name), metadata: { ...(cat?.metadata ?? {}), aliases: m.aliases ?? [], resolvesTo: isAlias ? m.name : undefined, description: m.description, defaultTemperature: m.default_model_temperature, deprecation: m.deprecation, replacement: m.deprecation_replacement_model, createdAt: m.created ? new Date(m.created * 1000).toISOString() : undefined, sortWeight: cat?.sortWeight ?? sortWeightOf(m.id), }, }; } function familyOf(id: string): string { if (id.startsWith("magistral")) return "Magistral"; if (id.startsWith("codestral") || id.startsWith("mistral-code")) return "Codestral"; if (id.startsWith("ministral")) return "Ministral"; if (id.startsWith("mistral-large")) return "Mistral Large"; if (id.startsWith("mistral-medium")) return "Mistral Medium"; if (id.startsWith("mistral-small")) return "Mistral Small"; if (id.includes("glm")) return "GLM (hosted)"; if (id.startsWith("labs-")) return "Labs"; return "Mistral"; } function sortWeightOf(id: string): number { if (id === "mistral-large-latest") return 100; if (id === "mistral-medium-latest") return 96; if (id === "magistral-medium-latest") return 94; if (id === "mistral-small-latest") return 90; if (id === "magistral-small-latest") return 88; if (id === "codestral-latest") return 84; if (id.startsWith("ministral-14b-latest")) return 80; if (id.startsWith("ministral-8b-latest")) return 78; if (id.startsWith("ministral-3b-latest")) return 76; if (id.endsWith("-latest")) return 60; return 30; } function prettyMistral(id: string, name?: string): string { const base = id.replace(/-latest$/, ""); const words = base.split("-").map((w) => (/^\d/.test(w) ? w : w.charAt(0).toUpperCase() + w.slice(1))); const pretty = words.join(" ").replace(/\b(\d+)b\b/i, "$1B"); return id.endsWith("-latest") ? `${pretty} (latest${name && name !== id ? ` → ${name}` : ""})` : pretty; } async function listMistral(client: OpenAI, apiKey: string, signal?: AbortSignal): Promise { void client; const res = await fetch("https://api.mistral.ai/v1/models", { headers: { Authorization: `Bearer ${apiKey}` }, signal }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { message?: string; detail?: unknown }; throw Object.assign(new Error(body.message ?? `HTTP ${res.status}`), { status: res.status, error: body, headers: res.headers }); } const data = (await res.json()) as { data: MistralModel[] }; return data.data.map(normalizeMistralModel).filter((m): m is PolyModel => Boolean(m)); } export const mistralAdapter = createOpenAICompatAdapter({ id: "mistral", name: "Mistral AI", baseURL: "https://api.mistral.ai/v1", keyDocsUrl: "https://console.mistral.ai/api-keys", listModels: listMistral, useMaxTokens: true, // PDFs go in natively as `document_url` data URIs (probed OK, even on Codestral). messageOptions: { inlineFiles: true, pdfPart: (data) => ({ type: "document_url", document_url: `data:application/pdf;base64,${data}` }) as unknown as import("openai/resources/chat/completions").ChatCompletionContentPart }, tweakParams: (params, settings, req) => { const p = params as unknown as Record; // Mistral validates bodies strictly (422 on unknown keys): rename `seed` → `random_seed`, never send `max_completion_tokens`. if (params.seed !== undefined) { p.random_seed = params.seed; delete p.seed; } // tool_choice "required" is spelled "any" on Mistral. if (params.tool_choice === "required") p.tool_choice = "any"; // Hybrid reasoning (Mistral Medium 3.5 / Small 4 / Magistral aliases): off by default, `reasoning_effort: "high"` // turns it on and `"none"` keeps it off (docs/provider-research/mistral.md). Third-party GLM accepts the full ladder. if (req.modelInfo?.capabilities.reasoning && settings.reasoningEffort) { const levels = req.modelInfo.parameters.reasoningEffortLevels ?? ["none", "high"]; const e = settings.reasoningEffort; p.reasoning_effort = levels.includes(e) ? e : e === "minimal" ? (levels.includes("low") ? "low" : "none") : levels.includes("high") ? "high" : levels[levels.length - 1]; } delete p.stream_options; // Mistral returns usage on the final chunk without it }, streamUsage: false, refineError: (status, code, message) => { if (status === 401) return "INVALID_API_KEY"; if (status === 422 && /model|not found/i.test(message)) return "MODEL_NOT_FOUND"; if (status === 422) return "INVALID_PARAMETER"; if (status === 429 && /quota|capacity|tier/i.test(message)) return "RATE_LIMITED"; return undefined; }, });