"use client"; import { useMemo, useState, useEffect, useRef } from "react"; import { useSearchParams } from "next/navigation"; import useSWR from "swr"; import { Star, EyeOff, Eye, ChevronDown, ChevronRight, RefreshCw, CheckCircle2, XCircle, HelpCircle, Ban, BadgeCheck, Search, FlaskConical, ArrowUpDown, Columns2 } from "lucide-react"; import Link from "next/link"; import { useModels, PROVIDER_LABEL } from "@/lib/client/use-models"; import type { AIModel } from "@/lib/client/types"; import { api, fetcher, fmtMs, relTime } from "@/lib/client/fetcher"; import { cn, Badge, Button, Tabs, Toggle, toast, Spinner } from "@/components/ui"; import { CapIcons } from "@/components/chat/model-picker"; import { Markdown } from "@/components/chat/markdown"; type Status = "verified" | "unavailable" | "untested" | "unsupported"; type SortKey = "quality" | "latency" | "ttft" | "price" | "name" | "context"; const TESTS = ["basic", "reasoning", "math", "code", "french", "english", "json", "tools", "vision", "longctx", "streaming"]; const TEST_LABEL: Record = { basic: "Base", reasoning: "Raisonnement", math: "Maths", code: "Code", french: "Français", english: "Anglais", json: "JSON", tools: "Outils", vision: "Vision", longctx: "Long contexte", streaming: "Streaming", image: "Image", tts: "TTS", stt: "STT", embedding: "Embedding", moderation: "Modération", "audio-chat": "Audio" }; const STATUS_LABEL: Record = { verified: "vérifié", unavailable: "indisponible", untested: "non testé", unsupported: "non pris en charge" }; const STATUS_TONE: Record = { verified: "ok", unavailable: "danger", untested: "neutral", unsupported: "warn" }; const KIND_LABEL: Record = { chat: "chat", "chat-pro": "pro", search: "recherche web", image: "image", tts: "TTS", stt: "STT", embedding: "embedding", moderation: "modération", realtime: "temps réel", video: "vidéo", "legacy-completion": "legacy", "deep-research": "deep research", "audio-chat": "audio" }; const blended = (m: AIModel) => (m.pricing?.input != null && m.pricing?.output != null ? (3 * m.pricing.input + m.pricing.output) / 4 : null); const isChat = (m: AIModel) => ["chat", "chat-pro", "search"].includes(m.kind); export function ModelLab() { const { models, defaultModel, registry, isLoading, mutate } = useModels(true); const sp = useSearchParams(); const select = sp.get("select"); const [tab, setTab] = useState<"catalog" | "report" | "routing">("catalog"); const [provider, setProvider] = useState("all"); const [status, setStatus] = useState("all"); const [kind, setKind] = useState("all"); const [caps, setCaps] = useState>(new Set()); const [q, setQ] = useState(""); const [showHidden, setShowHidden] = useState(false); const [sort, setSort] = useState("quality"); const [dir, setDir] = useState<1 | -1>(-1); const [open, setOpen] = useState>(new Set(select ? [select] : [])); const [refreshing, setRefreshing] = useState(false); const { data: report } = useSWR(tab === "report" ? "/api/benchmarks?file=benchmark-report.md" : null, fetcher); const { data: routing } = useSWR(tab === "routing" ? "/api/benchmarks?file=routing-recommendations.md" : null, fetcher); const selRef = useRef(null); useEffect(() => { if (select && selRef.current && !isLoading) selRef.current.scrollIntoView({ block: "center" }); }, [select, isLoading]); const kinds = useMemo(() => [...new Set(models.map(m => m.kind))].sort(), [models]); const list = useMemo(() => { const ql = q.trim().toLowerCase(); const f = models.filter(m => (showHidden || !m.hidden || m.id === select) && (provider === "all" || m.provider === provider) && (status === "all" || m.status === status) && (kind === "all" || m.kind === kind) && (!ql || m.id.toLowerCase().includes(ql) || m.displayName.toLowerCase().includes(ql)) && [...caps].every(c => m.capabilities[c as keyof AIModel["capabilities"]] === true)); const val = (m: AIModel): number | string => sort === "quality" ? m.benchmark?.qualityScore ?? -1 : sort === "latency" ? m.benchmark?.p50LatencyMs ?? Infinity : sort === "ttft" ? m.benchmark?.p50TtftMs ?? Infinity : sort === "price" ? blended(m) ?? Infinity : sort === "context" ? m.contextWindow ?? -1 : m.displayName.toLowerCase(); return f.sort((a, b) => { const x = val(a), y = val(b); if (x === y) return a.id.localeCompare(b.id); if (typeof x === "string" || typeof y === "string") return dir * String(x).localeCompare(String(y)); return dir * ((x as number) - (y as number)); }); }, [models, q, provider, status, kind, caps, showHidden, sort, dir, select]); const verifiedChat = models.filter(m => m.status === "verified" && isChat(m)).length; const unavailable = models.filter(m => m.status === "unavailable").length; const specialized = models.filter(m => m.status === "verified" && !isChat(m)).length; const untested = models.filter(m => m.status === "untested" || m.status === "unsupported").length; async function patch(id: string, p: Record, msg?: string) { try { await api("/api/models", { method: "PATCH", json: { id, ...p } }); await mutate(); if (msg) toast(msg, "ok"); } catch (e) { toast((e as Error).message, "error"); } } async function refresh() { setRefreshing(true); try { const r = await api<{ log: string[] }>("/api/models/refresh", { method: "POST" }); await mutate(); toast(r.log.at(-1) ?? "Catalogue rafraîchi", "ok"); } catch (e) { toast((e as Error).message, "error"); } finally { setRefreshing(false); } } function toggleSort(k: SortKey) { if (sort === k) setDir(d => (d === 1 ? -1 : 1)); else { setSort(k); setDir(k === "name" ? 1 : k === "quality" || k === "context" ? -1 : 1); } } const toggleOpen = (id: string) => setOpen(s => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; }); return (

Laboratoire de modèles

Catalogue vivant des deux comptes : chaque modèle est vérifié par requête réelle, mesuré et tarifé d'après les pages officielles. Rien n'est supposé.

Comparer
{tab === "catalog" && ( <>
setQ(e.target.value)} placeholder="Filtrer par nom ou id…" className="input !pl-8 !py-2" />
{[["vision", "vision"], ["tools", "outils"], ["structuredOutput", "JSON"], ["reasoning", "raisonnement"]].map(([k, l]) => )}
ModèleQualitép501er jetonPrix in/out · mixteCtxCapacitésActions
{isLoading &&
Chargement du registre…
} {!isLoading && !list.length &&
Aucun modèle ne correspond aux filtres.
} {list.map(m => toggleOpen(m.id)} onFav={() => patch(m.id, { favorite: !m.favorite })} onHide={() => patch(m.id, { hidden: !m.hidden }, m.hidden ? "Modèle réaffiché" : "Modèle masqué du sélecteur")} onDefault={() => patch(m.id, { default: true }, `${m.displayName} est le modèle par défaut`)} />)}

{list.length} modèle{list.length > 1 ? "s" : ""} affiché{list.length > 1 ? "s" : ""}. Score de qualité = part des 9 tests notés réussis (une passe par test, résultats indicatifs). Prix en $ US par million de jetons ; « mixte » = (3 × entrée + sortie) / 4.

)} {tab === "report" &&
{report ? :
Chargement du rapport…
}
} {tab === "routing" &&
{routing ? :
Chargement…
}
}
); } function Tile({ label, value, tone, hint }: { label: string; value: number; tone?: "ok" | "danger"; hint?: string }) { return
{label}
{value}
{hint &&
{hint}
}
; } function Th({ k, cur, dir, onClick, children }: { k: SortKey; cur: SortKey; dir: 1 | -1; onClick: (k: SortKey) => void; children: React.ReactNode }) { return ; } function Row({ m, isDefault, isOpen, selected, refProp, onToggle, onFav, onHide, onDefault }: { m: AIModel; isDefault: boolean; isOpen: boolean; selected: boolean; refProp?: React.RefObject; onToggle: () => void; onFav: () => void; onHide: () => void; onDefault: () => void }) { const b = m.benchmark; const p = m.pricing; const bl = blended(m); const chat = isChat(m); return (
Qualité{b?.qualityScore != null ? <>= 100 ? "bg-ok" : b.qualityScore >= 89 ? "bg-accent" : "bg-warn")} style={{ width: `${b.qualityScore}%` }} />{b.qualityScore} % : {chat ? "—" : b?.passed ? "✓ modalité" : "—"}}
p50 {b?.p50LatencyMs != null ? fmtMs(b.p50LatencyMs) : —}
1er jeton {b?.p50TtftMs != null ? fmtMs(b.p50TtftMs) : —}
{p?.input != null && p?.output != null ? <>{p.input} $ / {p.output} $ · {bl!.toFixed(2)} $ : p && Object.keys(p).length ? tarif spécialisé : prix non vérifié}
{m.contextWindow ? `${Math.round(m.contextWindow / 1000)}k` : inconnu}
{Object.values(m.capabilities).some(v => v === "unknown") && inconnu}{!Object.keys(m.capabilities).length && —}
{isOpen && (
{m.unavailableReason &&
{m.status === "unavailable" ? : }{m.unavailableReason}
} {b && Object.keys(b.perTest).length > 0 && (
Résultats par test ({b.passed}/{b.tests})
{(chat ? TESTS : Object.keys(b.perTest)).map(t => { const v = b.perTest[t]; return
{v === "pass" ? : v === "fail" ? : v === "error" ? ⚠️ : ·}{TEST_LABEL[t] ?? t}
; })}
)}
{p?.notes &&
Note tarifaire : {String(p.notes)}
} {b?.errors?.length ?
Erreurs rencontrées
    {b.errors.map((e, i) =>
  • {e}
  • )}
: null} {chat && m.available &&
Comparer ce modèle
}
)}
); } function Info({ k, v }: { k: string; v: string }) { return
{k} : {v}
; }