spb/chat-spboucher-ai
Public
1import type { AIModel } from "./types";2import { blendedPrice } from "./cost";34export type RoutingMode = "manual" | "fast" | "quality" | "economy" | "auto";5export type TaskClass = "code" | "math" | "reasoning" | "vision" | "longctx" | "french" | "json" | "tools" | "general";67export interface RoutingDecision { mode: RoutingMode; requested?: string; selected: string; reason: string; task?: TaskClass; candidates: string[] }89/** Cheap heuristic task classifier (no model call). */10export function classifyTask(text: string, opts: { hasImages?: boolean; approxTokens?: number; wantsTools?: boolean } = {}): TaskClass {11 if (opts.hasImages) return "vision";12 if ((opts.approxTokens ?? 0) > 20_000) return "longctx";13 const t = text.toLowerCase();14 if (/```|\b(function|class|def |import |const |let |var |#include|sql|regex|typescript|python|javascript|rust|bash|script|bug|stack ?trace|compile|refactor|unit test)\b/.test(t)) return "code";15 if (/\b(prove|theorem|integral|derivative|equation|solve for|probability|matrix|eigen|calcul|démontre|intégrale|dérivée|équation|résous)\b|\d+\s*[\+\-\*\/\^]\s*\d+/.test(t)) return "math";16 if (/\b(json|schema|structured|table of|csv)\b/.test(t)) return "json";17 if (/\b(why|explain|analyse|analyze|compare|strategy|plan|évalue|pourquoi|explique|analyse|stratégie|réfléchis)\b/.test(t) && t.length > 200) return "reasoning";18 if (/[àâçéèêëîïôûùüÿœ]/.test(t) || /\b(le|la|les|des|une|est|pour|avec|dans)\b/.test(t)) return "french";19 return "general";20}2122const q = (m: AIModel) => m.benchmark?.qualityScore ?? -1;23const lat = (m: AIModel) => m.benchmark?.p50LatencyMs ?? 1e9;24const price = (m: AIModel) => blendedPrice(m) ?? 1e9;25const chatOnly = (ms: AIModel[]) => ms.filter(m => m.available && !m.hidden && ["chat", "search"].includes(m.kind) && m.benchmark?.qualityScore != null);2627export function rankFor(mode: Exclude<RoutingMode, "manual">, models: AIModel[], task: TaskClass = "general"): AIModel[] {28 const pool = chatOnly(models);29 const passes = (m: AIModel, t: string) => m.benchmark?.perTest?.[t] === "pass";30 switch (mode) {31 case "fast": return pool.filter(m => q(m) >= 89).sort((a, b) => lat(a) - lat(b));32 case "economy": return pool.filter(m => q(m) >= 89).sort((a, b) => price(a) - price(b) || q(b) - q(a));33 case "quality": return pool.sort((a, b) => q(b) - q(a) || (blendedPrice(b) ?? -1) - (blendedPrice(a) ?? -1) || lat(a) - lat(b));34 case "auto": {35 const testFor: Record<TaskClass, string | null> = { code: "code", math: "math", reasoning: "reasoning", vision: "vision", longctx: "longctx", french: "french", json: "json", tools: "tools", general: null };36 const t = testFor[task];37 const eligible = t ? pool.filter(m => passes(m, t)) : pool;38 // balance: quality first, then price-per-quality, then latency; prefer mid-tier (exclude the very top price tier unless task is reasoning/math/code)39 const heavy = task === "reasoning" || task === "math" || task === "code" || task === "longctx";40 const pool2 = eligible.filter(m => !/chat-latest$/.test(m.id)); // ChatGPT-mirror alias: manual use only41 if (heavy) {42 // strongest tier under a price cap (blended ≤ $12/M): quality, then higher list price, then latency43 const capped = pool2.filter(m => price(m) <= 12);44 return (capped.length ? capped : pool2).sort((a, b) => q(b) - q(a) || price(b) - price(a) || lat(a) - lat(b));45 }46 // light tasks: full-score models at ≤ $4/M blended, fastest first; fall back to any eligible by quality then price47 const light = pool2.filter(m => q(m) >= 100 && price(m) <= 4).sort((a, b) => lat(a) - lat(b));48 return light.length ? light : pool2.sort((a, b) => q(b) - q(a) || price(a) - price(b) || lat(a) - lat(b));49 }50 }51}5253export function route(mode: RoutingMode, models: AIModel[], requested: string | undefined, text: string, opts: Parameters<typeof classifyTask>[1] = {}): RoutingDecision {54 if (mode === "manual") {55 if (!requested) throw new Error("manual routing requires a model");56 return { mode, requested, selected: requested, reason: "Modèle choisi explicitement.", candidates: [requested] };57 }58 const task = classifyTask(text, opts);59 let ranked = rankFor(mode, models, task);60 if (opts.hasImages) ranked = ranked.filter(m => m.capabilities.vision === true);61 if (opts.wantsTools) ranked = ranked.filter(m => m.capabilities.tools === true);62 if (!ranked.length) ranked = rankFor("quality", models).filter(m => (!opts.hasImages || m.capabilities.vision === true) && (!opts.wantsTools || m.capabilities.tools === true));63 const sel = ranked[0];64 if (!sel) throw new Error("No available model satisfies the request");65 const why: Record<string, string> = {66 fast: `Mode rapide : latence médiane la plus basse (${sel.benchmark?.p50LatencyMs} ms) parmi les modèles ≥ 89 % au benchmark.`,67 economy: `Mode économique : prix mixte le plus bas (${blendedPrice(sel)?.toFixed(2)} $/M) parmi les modèles ≥ 89 %.`,68 quality: `Mode qualité : score ${sel.benchmark?.qualityScore} %, palier le plus élevé du fournisseur.`,69 auto: `Mode automatique : tâche détectée « ${task} », meilleur compromis qualité/prix/latence (${sel.benchmark?.qualityScore} %, ${blendedPrice(sel)?.toFixed(2)} $/M, ${sel.benchmark?.p50LatencyMs} ms).`,70 };71 return { mode, requested, selected: sel.id, reason: why[mode], task, candidates: ranked.slice(0, 5).map(m => m.id) };72}73