SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
7.7 KB · 166 lines typescript
Raw Blame History
1import type OpenAI from "openai";2import { type PolyModel, modelKey } from "@/lib/ai/core/types";3import { createOpenAICompatAdapter } from "../shared/openai-compat/factory";4import { MISTRAL_CATALOG, mistralPricingFor, MISTRAL_NON_CHAT } from "./catalog";56/**7 * Mistral AI (La Plateforme) — `https://api.mistral.ai/v1/chat/completions` is OpenAI-shaped.8 * `GET /v1/models` returns rich capability metadata (completion_chat, function_calling, vision,9 * reasoning, max_context_length, aliases, deprecation) which drives the registry.10 * Quirks (docs/provider-research/mistral.md): `random_seed` instead of `seed`, `max_tokens`,11 * tool_choice `any`, Magistral reasoning streamed as `thinking` content chunks, `safe_prompt`.12 */13interface MistralModel {14  id: string;15  name?: string;16  description?: string;17  created?: number;18  max_context_length?: number;19  aliases?: string[];20  deprecation?: string | null;21  deprecation_replacement_model?: string | null;22  default_model_temperature?: number | null;23  type?: string;24  capabilities?: { completion_chat?: boolean; function_calling?: boolean; vision?: boolean; reasoning?: boolean; completion_fim?: boolean; ocr?: boolean; audio?: boolean };25}2627export function normalizeMistralModel(m: MistralModel): PolyModel | null {28  const caps = m.capabilities ?? {};29  if (!caps.completion_chat) return null;30  if (MISTRAL_NON_CHAT.test(m.id)) return null;31  const cat = MISTRAL_CATALOG.get(m.id) ?? MISTRAL_CATALOG.get(m.name ?? "");32  const reasoning = caps.reasoning === true;33  const deprecated = Boolean(m.deprecation) && new Date(m.deprecation!).getTime() < Date.now();34  const isAlias = m.name && m.name !== m.id;35  return {36    key: modelKey("mistral", m.id),37    id: m.id,38    provider: "mistral",39    displayName: cat?.displayName ?? prettyMistral(m.id, m.name),40    family: cat?.family ?? familyOf(m.id),41    capabilities: {42      text: true,43      vision: caps.vision === true,44      audioInput: caps.audio === true,45      audioOutput: false,46      imageGeneration: false,47      video: false,48      reasoning,49      tools: caps.function_calling === true,50      structuredOutput: true,51      streaming: true,52      files: true, // PDF via `document_url` data URI works on every chat model (probed)53      webSearch: false,54      ...(cat?.capabilityOverrides ?? {}),55    },56    limits: { contextTokens: m.max_context_length ?? cat?.limits?.contextTokens, maxOutputTokens: cat?.limits?.maxOutputTokens },57    parameters: {58      temperature: true,59      topP: true,60      topK: false,61      maxTokens: true,62      stop: true,63      seed: true,64      frequencyPenalty: true,65      presencePenalty: true,66      reasoningEffort: reasoning,67      reasoningEffortLevels: reasoning ? (m.id.includes("glm") ? ["none", "minimal", "low", "medium", "high", "xhigh", "max"] : ["none", "high"]) : undefined,68      thinkingBudget: false,69      temperatureRange: { min: 0, max: 1.5 },70      ...(cat?.parameters ?? {}),71    },72    status: deprecated ? "deprecated" : m.deprecation ? "deprecated" : cat?.status ?? (m.id.startsWith("labs-") ? "preview" : "active"),73    pricing: mistralPricingFor(m.id, m.name),74    metadata: {75      ...(cat?.metadata ?? {}),76      aliases: m.aliases ?? [],77      resolvesTo: isAlias ? m.name : undefined,78      description: m.description,79      defaultTemperature: m.default_model_temperature,80      deprecation: m.deprecation,81      replacement: m.deprecation_replacement_model,82      createdAt: m.created ? new Date(m.created * 1000).toISOString() : undefined,83      sortWeight: cat?.sortWeight ?? sortWeightOf(m.id),84    },85  };86}8788function familyOf(id: string): string {89  if (id.startsWith("magistral")) return "Magistral";90  if (id.startsWith("codestral") || id.startsWith("mistral-code")) return "Codestral";91  if (id.startsWith("ministral")) return "Ministral";92  if (id.startsWith("mistral-large")) return "Mistral Large";93  if (id.startsWith("mistral-medium")) return "Mistral Medium";94  if (id.startsWith("mistral-small")) return "Mistral Small";95  if (id.includes("glm")) return "GLM (hosted)";96  if (id.startsWith("labs-")) return "Labs";97  return "Mistral";98}99function sortWeightOf(id: string): number {100  if (id === "mistral-large-latest") return 100;101  if (id === "mistral-medium-latest") return 96;102  if (id === "magistral-medium-latest") return 94;103  if (id === "mistral-small-latest") return 90;104  if (id === "magistral-small-latest") return 88;105  if (id === "codestral-latest") return 84;106  if (id.startsWith("ministral-14b-latest")) return 80;107  if (id.startsWith("ministral-8b-latest")) return 78;108  if (id.startsWith("ministral-3b-latest")) return 76;109  if (id.endsWith("-latest")) return 60;110  return 30;111}112function prettyMistral(id: string, name?: string): string {113  const base = id.replace(/-latest$/, "");114  const words = base.split("-").map((w) => (/^\d/.test(w) ? w : w.charAt(0).toUpperCase() + w.slice(1)));115  const pretty = words.join(" ").replace(/\b(\d+)b\b/i, "$1B");116  return id.endsWith("-latest") ? `${pretty} (latest${name && name !== id ? ` → ${name}` : ""})` : pretty;117}118119async function listMistral(client: OpenAI, apiKey: string, signal?: AbortSignal): Promise<PolyModel[]> {120  void client;121  const res = await fetch("https://api.mistral.ai/v1/models", { headers: { Authorization: `Bearer ${apiKey}` }, signal });122  if (!res.ok) {123    const body = (await res.json().catch(() => ({}))) as { message?: string; detail?: unknown };124    throw Object.assign(new Error(body.message ?? `HTTP ${res.status}`), { status: res.status, error: body, headers: res.headers });125  }126  const data = (await res.json()) as { data: MistralModel[] };127  return data.data.map(normalizeMistralModel).filter((m): m is PolyModel => Boolean(m));128}129130export const mistralAdapter = createOpenAICompatAdapter({131  id: "mistral",132  name: "Mistral AI",133  baseURL: "https://api.mistral.ai/v1",134  keyDocsUrl: "https://console.mistral.ai/api-keys",135  listModels: listMistral,136  useMaxTokens: true,137  // PDFs go in natively as `document_url` data URIs (probed OK, even on Codestral).138  messageOptions: { inlineFiles: true, pdfPart: (data) => ({ type: "document_url", document_url: `data:application/pdf;base64,${data}` }) as unknown as import("openai/resources/chat/completions").ChatCompletionContentPart },139  tweakParams: (params, settings, req) => {140    const p = params as unknown as Record<string, unknown>;141    // Mistral validates bodies strictly (422 on unknown keys): rename `seed` → `random_seed`, never send `max_completion_tokens`.142    if (params.seed !== undefined) {143      p.random_seed = params.seed;144      delete p.seed;145    }146    // tool_choice "required" is spelled "any" on Mistral.147    if (params.tool_choice === "required") p.tool_choice = "any";148    // Hybrid reasoning (Mistral Medium 3.5 / Small 4 / Magistral aliases): off by default, `reasoning_effort: "high"`149    // turns it on and `"none"` keeps it off (docs/provider-research/mistral.md). Third-party GLM accepts the full ladder.150    if (req.modelInfo?.capabilities.reasoning && settings.reasoningEffort) {151      const levels = req.modelInfo.parameters.reasoningEffortLevels ?? ["none", "high"];152      const e = settings.reasoningEffort;153      p.reasoning_effort = levels.includes(e) ? e : e === "minimal" ? (levels.includes("low") ? "low" : "none") : levels.includes("high") ? "high" : levels[levels.length - 1];154    }155    delete p.stream_options; // Mistral returns usage on the final chunk without it156  },157  streamUsage: false,158  refineError: (status, code, message) => {159    if (status === 401) return "INVALID_API_KEY";160    if (status === 422 && /model|not found/i.test(message)) return "MODEL_NOT_FOUND";161    if (status === 422) return "INVALID_PARAMETER";162    if (status === 429 && /quota|capacity|tier/i.test(message)) return "RATE_LIMITED";163    return undefined;164  },165});166