// Registre dynamique des modèles OpenRouter : récupération, normalisation, cache, // détection de capacités, surcharges administrateur, préréglages configurables. import { all, getSetting } from "../db/index.ts"; export type ModelInfo = { id: string; name: string; provider: string; description: string; contextLength: number; pricing: { prompt: number; completion: number }; // $ / 1M jetons supportsImages: boolean; supportsFiles: boolean; supportsTools: boolean; supportsReasoning: boolean; supportsStructured: boolean; isFree: boolean; costTier: "économique" | "modéré" | "coûteux"; enabled: boolean; favorite: boolean; note: string; }; type RawModel = { id: string; name?: string; description?: string; context_length?: number; pricing?: { prompt?: string; completion?: string }; architecture?: { input_modalities?: string[]; output_modalities?: string[] }; supported_parameters?: string[]; }; let cache: { at: number; models: ModelInfo[] } | null = null; const TTL_MS = 15 * 60 * 1000; // Identifiants vérifiés contre le registre OpenRouter réel (2026-08) ; resolvePreset() // prend le premier disponible et activé, donc chaque liste inclut des replis plus anciens. export const DEFAULT_PRESETS: Record = { recommande: { label: "Recommandé", models: ["anthropic/claude-sonnet-5", "openai/gpt-5.6-sol", "google/gemini-3.5-flash", "anthropic/claude-sonnet-4.5"], description: "Équilibre qualité/coût pour l'étude quotidienne", }, rapide: { label: "Rapide", models: ["google/gemini-3.5-flash", "openai/gpt-5.4-mini", "anthropic/claude-haiku-4.5", "google/gemini-2.5-flash"], description: "Réponses vives pour les questions simples", }, raisonnement: { label: "Raisonnement approfondi", models: ["anthropic/claude-opus-5", "openai/gpt-5.5-pro", "deepseek/deepseek-v4-pro", "anthropic/claude-opus-4.8"], description: "Calculs à étapes multiples et cas complexes", }, images: { label: "Meilleur pour les images", models: ["google/gemini-3.1-pro-preview", "anthropic/claude-sonnet-5", "openai/gpt-5.6-sol", "google/gemini-2.5-pro"], description: "Plans, photos d'immeubles, exercices manuscrits", }, documents: { label: "Meilleur pour les documents", models: ["google/gemini-3.1-pro-preview", "anthropic/claude-sonnet-5", "google/gemini-2.5-pro"], description: "Longs PDF et tableaux", }, calculs: { label: "Meilleur pour les calculs", models: ["anthropic/claude-opus-5", "openai/gpt-5.5-pro", "anthropic/claude-sonnet-5"], description: "DCF, ventilation de dépréciation, grilles d'ajustement", }, contexte: { label: "Grand contexte", models: ["google/gemini-3.1-pro-preview", "anthropic/claude-sonnet-5", "google/gemini-2.5-pro"], description: "Analyse de documents volumineux", }, economique: { label: "Économique", models: ["deepseek/deepseek-v4-flash", "google/gemini-3.5-flash-lite", "openai/gpt-5.4-nano", "deepseek/deepseek-chat-v3-0324"], description: "Coût minimal", }, }; function normalize(raw: RawModel, overrides: Map): ModelInfo { const promptPrice = parseFloat(raw.pricing?.prompt ?? "0") * 1_000_000; const completionPrice = parseFloat(raw.pricing?.completion ?? "0") * 1_000_000; const inputs = raw.architecture?.input_modalities ?? ["text"]; const params = raw.supported_parameters ?? []; const isFree = promptPrice === 0 && completionPrice === 0; const blended = promptPrice * 0.75 + completionPrice * 0.25; const o = overrides.get(raw.id); return { id: raw.id, name: raw.name ?? raw.id, provider: raw.id.split("/")[0] ?? "", description: (raw.description ?? "").slice(0, 400), contextLength: raw.context_length ?? 8192, pricing: { prompt: promptPrice, completion: completionPrice }, supportsImages: inputs.includes("image"), supportsFiles: inputs.includes("file"), supportsTools: params.includes("tools"), supportsReasoning: params.includes("reasoning") || params.includes("include_reasoning"), supportsStructured: params.includes("structured_outputs") || params.includes("response_format"), isFree, costTier: isFree || blended < 1 ? "économique" : blended < 8 ? "modéré" : "coûteux", enabled: o ? !!o.enabled : true, favorite: o ? !!o.favorite : false, note: o?.note ?? "", }; } export async function listModels(opts: { includeDisabled?: boolean } = {}): Promise { if (!cache || Date.now() - cache.at > TTL_MS) { const base = process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1"; const res = await fetch(`${base}/models`, { headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}` }, }); if (!res.ok) { if (cache) return filterEnabled(cache.models, opts); throw new Error(`OpenRouter /models a répondu ${res.status}`); } const json = (await res.json()) as { data: RawModel[] }; const overrides = new Map( all<{ model_id: string; enabled: number; favorite: number; note: string }>( "SELECT model_id, enabled, favorite, note FROM model_overrides" ).map((r) => [r.model_id, r]) ); const models = json.data .map((m) => normalize(m, overrides)) .sort((a, b) => a.name.localeCompare(b.name)); cache = { at: Date.now(), models }; } return filterEnabled(cache.models, opts); } function filterEnabled(models: ModelInfo[], opts: { includeDisabled?: boolean }): ModelInfo[] { return opts.includeDisabled ? models : models.filter((m) => m.enabled); } export function invalidateModelCache() { cache = null; } export async function getModel(id: string): Promise { const models = await listModels({ includeDisabled: true }); return models.find((m) => m.id === id); } export function getPresets(): Record { return getSetting("model_presets", DEFAULT_PRESETS); } /** Premier modèle disponible d'un préréglage (avec repli). */ export async function resolvePreset(presetKey: string): Promise { const presets = getPresets(); const preset = presets[presetKey] ?? presets["recommande"] ?? Object.values(presets)[0]; const models = await listModels(); for (const id of preset.models) { const m = models.find((x) => x.id === id && x.enabled); if (m) return m.id; } return models.find((m) => m.costTier !== "coûteux")?.id ?? models[0]?.id ?? "anthropic/claude-sonnet-4.5"; } export function estimateCost(model: ModelInfo, tokensIn: number, tokensOut: number): number { return (tokensIn * model.pricing.prompt + tokensOut * model.pricing.completion) / 1_000_000; }