SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
6.7 KB · 167 lines typescript
Raw Blame History
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