TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1// Registre dynamique des modèles OpenRouter : récupération, normalisation, cache,2// détection de capacités, surcharges administrateur, préréglages configurables.34import { all, getSetting } from "../db/index.ts";56export type ModelInfo = {7 id: string;8 name: string;9 provider: string;10 description: string;11 contextLength: number;12 pricing: { prompt: number; completion: number }; // $ / 1M jetons13 supportsImages: boolean;14 supportsFiles: boolean;15 supportsTools: boolean;16 supportsReasoning: boolean;17 supportsStructured: boolean;18 isFree: boolean;19 costTier: "économique" | "modéré" | "coûteux";20 enabled: boolean;21 favorite: boolean;22 note: string;23};2425type RawModel = {26 id: string;27 name?: string;28 description?: string;29 context_length?: number;30 pricing?: { prompt?: string; completion?: string };31 architecture?: { input_modalities?: string[]; output_modalities?: string[] };32 supported_parameters?: string[];33};3435let cache: { at: number; models: ModelInfo[] } | null = null;36const TTL_MS = 15 * 60 * 1000;3738// Identifiants vérifiés contre le registre OpenRouter réel (2026-08) ; resolvePreset()39// prend le premier disponible et activé, donc chaque liste inclut des replis plus anciens.40export const DEFAULT_PRESETS: Record<string, { label: string; models: string[]; description: string }> = {41 recommande: {42 label: "Recommandé",43 models: ["anthropic/claude-sonnet-5", "openai/gpt-5.6-sol", "google/gemini-3.5-flash", "anthropic/claude-sonnet-4.5"],44 description: "Équilibre qualité/coût pour l'étude quotidienne",45 },46 rapide: {47 label: "Rapide",48 models: ["google/gemini-3.5-flash", "openai/gpt-5.4-mini", "anthropic/claude-haiku-4.5", "google/gemini-2.5-flash"],49 description: "Réponses vives pour les questions simples",50 },51 raisonnement: {52 label: "Raisonnement approfondi",53 models: ["anthropic/claude-opus-5", "openai/gpt-5.5-pro", "deepseek/deepseek-v4-pro", "anthropic/claude-opus-4.8"],54 description: "Calculs à étapes multiples et cas complexes",55 },56 images: {57 label: "Meilleur pour les images",58 models: ["google/gemini-3.1-pro-preview", "anthropic/claude-sonnet-5", "openai/gpt-5.6-sol", "google/gemini-2.5-pro"],59 description: "Plans, photos d'immeubles, exercices manuscrits",60 },61 documents: {62 label: "Meilleur pour les documents",63 models: ["google/gemini-3.1-pro-preview", "anthropic/claude-sonnet-5", "google/gemini-2.5-pro"],64 description: "Longs PDF et tableaux",65 },66 calculs: {67 label: "Meilleur pour les calculs",68 models: ["anthropic/claude-opus-5", "openai/gpt-5.5-pro", "anthropic/claude-sonnet-5"],69 description: "DCF, ventilation de dépréciation, grilles d'ajustement",70 },71 contexte: {72 label: "Grand contexte",73 models: ["google/gemini-3.1-pro-preview", "anthropic/claude-sonnet-5", "google/gemini-2.5-pro"],74 description: "Analyse de documents volumineux",75 },76 economique: {77 label: "Économique",78 models: ["deepseek/deepseek-v4-flash", "google/gemini-3.5-flash-lite", "openai/gpt-5.4-nano", "deepseek/deepseek-chat-v3-0324"],79 description: "Coût minimal",80 },81};8283function normalize(raw: RawModel, overrides: Map<string, { enabled: number; favorite: number; note: string }>): ModelInfo {84 const promptPrice = parseFloat(raw.pricing?.prompt ?? "0") * 1_000_000;85 const completionPrice = parseFloat(raw.pricing?.completion ?? "0") * 1_000_000;86 const inputs = raw.architecture?.input_modalities ?? ["text"];87 const params = raw.supported_parameters ?? [];88 const isFree = promptPrice === 0 && completionPrice === 0;89 const blended = promptPrice * 0.75 + completionPrice * 0.25;90 const o = overrides.get(raw.id);91 return {92 id: raw.id,93 name: raw.name ?? raw.id,94 provider: raw.id.split("/")[0] ?? "",95 description: (raw.description ?? "").slice(0, 400),96 contextLength: raw.context_length ?? 8192,97 pricing: { prompt: promptPrice, completion: completionPrice },98 supportsImages: inputs.includes("image"),99 supportsFiles: inputs.includes("file"),100 supportsTools: params.includes("tools"),101 supportsReasoning: params.includes("reasoning") || params.includes("include_reasoning"),102 supportsStructured: params.includes("structured_outputs") || params.includes("response_format"),103 isFree,104 costTier: isFree || blended < 1 ? "économique" : blended < 8 ? "modéré" : "coûteux",105 enabled: o ? !!o.enabled : true,106 favorite: o ? !!o.favorite : false,107 note: o?.note ?? "",108 };109}110111export async function listModels(opts: { includeDisabled?: boolean } = {}): Promise<ModelInfo[]> {112 if (!cache || Date.now() - cache.at > TTL_MS) {113 const base = process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";114 const res = await fetch(`${base}/models`, {115 headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}` },116 });117 if (!res.ok) {118 if (cache) return filterEnabled(cache.models, opts);119 throw new Error(`OpenRouter /models a répondu ${res.status}`);120 }121 const json = (await res.json()) as { data: RawModel[] };122 const overrides = new Map(123 all<{ model_id: string; enabled: number; favorite: number; note: string }>(124 "SELECT model_id, enabled, favorite, note FROM model_overrides"125 ).map((r) => [r.model_id, r])126 );127 const models = json.data128 .map((m) => normalize(m, overrides))129 .sort((a, b) => a.name.localeCompare(b.name));130 cache = { at: Date.now(), models };131 }132 return filterEnabled(cache.models, opts);133}134135function filterEnabled(models: ModelInfo[], opts: { includeDisabled?: boolean }): ModelInfo[] {136 return opts.includeDisabled ? models : models.filter((m) => m.enabled);137}138139export function invalidateModelCache() {140 cache = null;141}142143export async function getModel(id: string): Promise<ModelInfo | undefined> {144 const models = await listModels({ includeDisabled: true });145 return models.find((m) => m.id === id);146}147148export function getPresets(): Record<string, { label: string; models: string[]; description: string }> {149 return getSetting("model_presets", DEFAULT_PRESETS);150}151152/** Premier modèle disponible d'un préréglage (avec repli). */153export async function resolvePreset(presetKey: string): Promise<string> {154 const presets = getPresets();155 const preset = presets[presetKey] ?? presets["recommande"] ?? Object.values(presets)[0];156 const models = await listModels();157 for (const id of preset.models) {158 const m = models.find((x) => x.id === id && x.enabled);159 if (m) return m.id;160 }161 return models.find((m) => m.costTier !== "coûteux")?.id ?? models[0]?.id ?? "anthropic/claude-sonnet-4.5";162}163164export function estimateCost(model: ModelInfo, tokensIn: number, tokensOut: number): number {165 return (tokensIn * model.pricing.prompt + tokensOut * model.pricing.completion) / 1_000_000;166}167