Multi-Model Routing — Choisir le bon modèle au bon moment, et survivre aux outages
TL;DR En 2026, te limiter à un seul fournisseur LLM c'est risquer un outage (Anthropic ~3 jours d'instabilité/an), un changement de prix, ou un veto souveraineté FR. Tu dois bâtir un router multi-modèle : (1) routing par difficulté (Haiku → Sonnet → Opus), (2) routing par souveraineté (Mistral FR pour clients régulés, Anthropic US pour les autres), (3) fallback chain (Anthropic primary, OpenAI fallback, Mistral last-resort), (4) A/B testing (10% traffic sur un nouveau modèle pour valider perf et coût), (5) per-tenant policy (chaque client peut imposer son provider). Les outils 2026 : LiteLLM (open-source, gateway proxy, le standard), Portkey (commercial, observability + routing UI), OpenRouter (marketplace de modèles), Together (focus open-weight). Pattern recommandé : LiteLLM proxy en NestJS sidecar, config YAML par environnement, fallback automatique, dashboard cost par modèle. Mission freelance type : 22-40 k€.
🧠 Mental model
┌────────────────────────┐
│ APP / API │
│ (single LLM client) │
└────────────┬───────────┘
│
▼
┌───────────────────────────────┐
│ ROUTER LAYER │
│ │
│ 1) tenant policy lookup │
│ 2) feature flag check │
│ 3) difficulty classifier │
│ 4) cost ceiling check │
│ 5) primary provider call │
│ 6) on fail → fallback chain │
│ 7) audit + cost log │
└────┬───────┬───────┬───────────┘
│ │ │
┌──────┘ │ └──────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│Anthropic│ │ OpenAI │ │ Mistral │
│ EU │ │ EU │ │ FR │
└─────────┘ └─────────┘ └─────────┘
│ │ │
└──────────────┴──────────────┘
│
▼
OBSERVABILITY
(Langfuse + OTel)Analogie : un router LLM, c'est un standard téléphonique d'entreprise. Quand un client appelle, l'opératrice (router) regarde : qui appelle (tenant), pour quel sujet (difficulté), depuis où (souveraineté), quel agent est dispo (provider up). Elle achemine. Si le service A est down, elle bascule vers B. Sans elle, l'appel se perd ou tombe sur le mauvais service.
Trois axes orthogonaux :
- Qualité (Opus > Sonnet > Haiku ; GPT-5 > GPT-4o > 4o-mini).
- Prix (Haiku < Sonnet < Opus, etc.).
- Souveraineté / disponibilité (Mistral FR vs Anthropic US vs OpenAI US/EU).
Le router optimise la combinaison selon le contexte.
Le piège que personne ne te dit : l'abstraction OpenAI-compatible fuit
LiteLLM, OpenRouter et Portkey exposent tous une API OpenAI-compatible pour que tu changes de provider en changeant une string. C'est l'argument de vente, et c'est aussi le mensonge. L'abstraction tient pour le cas trivial (messages + max_tokens) et fuit dès que tu touches une feature provider-spécifique. Trois fuites qui te coûtent un incident en prod :
- Le reasoning / thinking n'est pas portable. En 2026, le thinking étendu d'Anthropic se pilote via
thinking: {type: "adaptive"}+output_config.effort(low/medium/high/xhigh/max). L'ancienne formebudget_tokensest supprimée sur Opus 4.7/4.8 et Fable 5 et renvoie un HTTP 400. OpenAI a sonreasoning_effort, Gemini a sonthinkingBudget. Aucun des trois ne se mappe automatiquement par le gateway. Si tu routes une requête « hard » censée raisonner vers un fallback OpenAI, tu perds le reasoning silencieusement — même latence facturée, qualité effondrée, zéro erreur. drop_params: truemasque les régressions. C'est le défaut conseillé partout (et dans la config ci-dessus). Il fait que LiteLLM jette en silence tout paramètre que le provider cible ne comprend pas. Pratique pour ne pas planter — catastrophique pour le débogage : ta requête Anthropic avecoutput_config.effortroutée vers Mistral perd l'effort sans bruit. Tu crois faire du « hard », tu fais du « default ».- Le coût de tokens diffère par provider et par tokenizer. Le même prompt ne coûte pas le même nombre de tokens chez Anthropic, OpenAI et Mistral (tokenizers distincts). Ton estimateur de coût pré-routing doit être par-provider, sinon ton
cost_capper-tenant est faux de 15-35 %.
Comment un staff y pense : le gateway unifie le transport, pas la sémantique. Tu maintiens une couche fine de capability mapping (qui sait que « reasoning haut » = effort: "high" chez Anthropic, reasoning_effort: "high" chez OpenAI, thinkingBudget: N chez Gemini) au-dessus du gateway, et tu logges requested_capability vs delivered_capability pour détecter les dégradations silencieuses au fallback. Sans ça, ton A/B « Claude vs GPT sur les cas hard » compare un Claude qui raisonne à un GPT qui ne raisonne pas — et tu prends une mauvaise décision avec un joli dashboard.
Tableau de décision — quel axe domine selon le contexte
| Contexte | Axe dominant | Stratégie de routing | Piège |
|---|---|---|---|
| Chatbot revenue-generating, trafic continu | Disponibilité | Fallback chain agressive + circuit breaker rapide (<30 s) | Cost explosion si le fallback est plus cher |
| Client régulé (banque, public) | Souveraineté | Hard-pin Mistral FR / Scaleway, lint CI anti-appel-direct | Backdoor embedding qui sort de l'UE |
| Pipeline batch (compta, extraction) | Coût | Classifier Haiku → route par difficulté, A/B continu | Classifier qui se trompe sur les sensitive |
| Agent long-horizon (coding, research) | Qualité | Opus 4.8 / effort high, pas de fallback qualité-dégradante | Fallback vers un modèle non-raisonnant qui casse l'agent |
| POC / pré-PMF | Vélocité | 2 providers en config, zéro classifier | Sur-ingénierie : tu construis un router pour 200 req/jour |
La règle de staff : un seul axe domine par feature. Un router qui essaie d'optimiser qualité + coût + souveraineté + latence simultanément sur chaque requête est ingérable et impossible à débugger. Tu fixes l'axe dominant par feature/tenant, et les autres axes deviennent des contraintes (pas des objectifs).
🛠️ Code minimal
LiteLLM proxy + client NestJS qui consomme l'API OpenAI-compatible.
# infra/litellm/config.yaml
model_list:
# Anthropic primary
- model_name: claude-sonnet
litellm_params:
model: claude-sonnet-4-6
api_key: os.environ/ANTHROPIC_API_KEY
api_base: https://api.anthropic.com
- model_name: claude-haiku
litellm_params:
model: claude-haiku-4-5
api_key: os.environ/ANTHROPIC_API_KEY
- model_name: claude-opus
litellm_params:
model: claude-opus-4-8
api_key: os.environ/ANTHROPIC_API_KEY
# OpenAI EU (fallback)
- model_name: gpt-fallback
litellm_params:
model: azure/gpt-4o
api_base: https://acme-eu.openai.azure.com
api_key: os.environ/AZURE_OPENAI_KEY
api_version: "2024-12-01-preview"
# Mistral souverain FR
- model_name: mistral-large
litellm_params:
model: mistral/mistral-large-2
api_key: os.environ/MISTRAL_API_KEY
router_settings:
routing_strategy: simple-shuffle
fallbacks:
- claude-sonnet: ["gpt-fallback", "mistral-large"]
- claude-haiku: ["mistral-large"]
num_retries: 2
timeout: 30
litellm_settings:
drop_params: true
cache: true
cache_params:
type: redis
host: os.environ/REDIS_HOST
port: 6379
general_settings:
master_key: os.environ/LITELLM_MASTER_KEY
database_url: os.environ/DATABASE_URL// libs/llm/router-client.ts
import OpenAI from "openai"; // LiteLLM expose une API OpenAI-compatible
export const router = new OpenAI({
baseURL: process.env.LITELLM_BASE_URL!, // http://litellm:4000/v1
apiKey: process.env.LITELLM_MASTER_KEY!,
});
export async function ask(opts: {
question: string;
tenantId: string;
model?: "claude-opus" | "claude-sonnet" | "claude-haiku" | "mistral-large";
}) {
const res = await router.chat.completions.create({
model: opts.model ?? "claude-haiku",
messages: [{ role: "user", content: opts.question }],
max_tokens: 600,
user: `tenant:${opts.tenantId}`,
});
return { text: res.choices[0].message.content, model: res.model, usage: res.usage };
}🎬 Cas d'usage concrets
Cas 1 — SaaS B2B mixed US/FR avec contrainte souveraineté
Le client : éditeur SaaS RH FR, 70 clients dont 12 régulés (banque + public) qui refusent les LLM US (souveraineté). Les autres 58 clients sont OK avec Anthropic.
Solution routing :
- Par défaut : Anthropic Sonnet.
- Si
tenant.policy.sovereignty = strict-fr→ Mistral Large 2 hébergé Scaleway. - Si Anthropic down (>5 erreurs 529 dans 30 sec) → fallback OpenAI Azure EU pour les non-strict, Mistral pour les strict.
Implémentation : table tenant_policies en Postgres avec colonne llm_policy (JSON). Au login, le tenant policy est mis en cache Redis et le routing s'aligne. Chaque tenant régulé a une DPA spécifique avec son provider (signée).
Mission : 28 k€, 5 semaines. Inclut docs DPA aide aux commerciaux.
Cas 2 — Failover Anthropic → OpenAI en cas d'outage
Le client : startup AI Sales FR, agent commercial sur Anthropic Sonnet. En janvier 2026, outage Anthropic 4h pendant un Black Monday d'un client. Pertes estimées : ~80 k€ CA.
Refactor :
- LiteLLM gateway déployé en sidecar NestJS.
- Fallback chain :
claude-sonnet → gpt-4o → mistral-large(Azure EU pour le 2e). - Détection outage : circuit breaker (cf chapitre 06) ouvre après 50% d'erreurs sur 60 sec → bypass direct vers fallback (pas de tentative inutile).
- Re-test périodique : toutes les 30 sec, ping léger sur Anthropic → si OK, half-open.
- Alerting : Slack
#opsquand fallback actif. Email post-mortem auto.
Mission : 18 k€. Le client a déclenché le mécanisme 2 fois la 1ère année → ROI immédiat.
Cas 3 — Agent compta avec routing par complexité d'écriture
Le client : ESN compta (cf chapitre 03). Constat : 80% des factures sont simples (1 ligne, TVA standard). 15% sont complexes (multi-ligne, multi-TVA, analytique). 5% sont juridiquement sensibles (achat immobilisé, refacturation interco, autoliquidation).
Routing intelligent :
- Classifier (Haiku) lit le résumé de la facture + métadonnées (fournisseur, montant, TVA, multi-ligne ?) → classe
simple | complex | sensitive. simple→ GPT-4o-mini (très bon en classification déterministe, coût mini).complex→ Claude Sonnet (raisonnement multi-étape).sensitive→ Claude Opus 4.8 (effort: "high", thinking adaptatif) + double check par Mistral Large (consensus 2/2 ou flag à l'expert).
Détail staff sur le consensus : router une requête
sensitivevers deux modèles n'est pas « le router envoie deux fois ». Tu dois (a) appeler les deux en parallèle (asyncio.gathercôté Python,Promise.allcôté Node), pas en séquence — sinon tu doubles la latence sur le cas le plus cher ; (b) ne pas dégrader le reasoning sur le chemin Opus quand tu passes par le gateway (effortdoit survivre — vérifie ledelivered_capability, cf. supra) ; (c) définir le quorum avant de voir les réponses (2/2 d'accord → auto-validé ; désaccord → flag humain), sinon tu fais du cherry-picking post-hoc qui détruit la valeur du double-check.
- A/B continu : 5% du
simpletrafic envoyé en Sonnet pour benchmark → si Sonnet meilleur de >2% sur cette catégorie, on bascule.
Résultat : qualité +4% global, coût -55% global. Mission 30 k€.
🛠️ Exemple end-to-end — LiteLLM router en NestJS avec config YAML + fallback + cost dashboard + per-tenant policy
Contexte : SaaS multi-tenant (12 tenants production), besoin d'un router robuste, observable, configurable par tenant.
1) Déploiement LiteLLM proxy
# infra/k8s/litellm.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: litellm-proxy
namespace: llm
spec:
replicas: 3
selector:
matchLabels: { app: litellm }
template:
metadata:
labels: { app: litellm }
spec:
containers:
- name: litellm
image: ghcr.io/berriai/litellm:main-stable
args: ["--config", "/etc/litellm/config.yaml", "--port", "4000"]
envFrom:
- secretRef: { name: litellm-secrets }
volumeMounts:
- name: cfg
mountPath: /etc/litellm
ports:
- containerPort: 4000
volumes:
- name: cfg
configMap: { name: litellm-config }
---
apiVersion: v1
kind: Service
metadata:
name: litellm
namespace: llm
spec:
selector: { app: litellm }
ports: [{ port: 4000, targetPort: 4000 }]2) Config avec virtual keys per-tenant
# infra/litellm/config.yaml (extension)
model_list:
# ... (cf code minimal)
router_settings:
routing_strategy: simple-shuffle
fallbacks:
- claude-sonnet: ["gpt-fallback", "mistral-large"]
num_retries: 2
cooldown_time: 30 # sec après échec avant retry sur la même cible
general_settings:
master_key: os.environ/LITELLM_MASTER_KEY
database_url: os.environ/DATABASE_URL # virtual keys & spend tracking
allowed_routes: ["/v1/chat/completions", "/v1/embeddings"]
# Virtual keys per-tenant créées via API LiteLLM3) NestJS — Tenant policy service
// libs/llm/tenant-policy.service.ts
import { Injectable } from "@nestjs/common";
import { Redis } from "ioredis";
import { PrismaService } from "@app/db";
interface TenantLLMPolicy {
sovereignty: "global" | "eu" | "strict-fr";
primaryModel: string;
fallbackChain: string[];
costEurDailyCap: number;
allowedFeatures: string[]; // e.g. ["streaming", "vision"]
}
@Injectable()
export class TenantPolicyService {
constructor(private prisma: PrismaService, private redis: Redis) {}
async get(tenantId: string): Promise<TenantLLMPolicy> {
const cached = await this.redis.get(`pol:${tenantId}`);
if (cached) return JSON.parse(cached);
const row = await this.prisma.tenant.findUniqueOrThrow({
where: { id: tenantId },
select: { llmPolicy: true },
});
const policy = row.llmPolicy as TenantLLMPolicy;
await this.redis.set(`pol:${tenantId}`, JSON.stringify(policy), "EX", 300);
return policy;
}
resolveModelForFeature(policy: TenantLLMPolicy, feature: "easy" | "standard" | "hard") {
if (policy.sovereignty === "strict-fr") {
return feature === "hard" ? "mistral-large" : "mistral-small";
}
if (policy.sovereignty === "eu") {
return feature === "easy" ? "gpt-4o-mini-eu" : "claude-sonnet";
}
return feature === "easy" ? "claude-haiku" : feature === "hard" ? "claude-opus" : "claude-sonnet";
}
}4) Router orchestrator
// libs/llm/router.service.ts
import { Injectable } from "@nestjs/common";
import OpenAI from "openai";
import { TenantPolicyService } from "./tenant-policy.service";
import { Difficulty, ClassifierService } from "./classifier.service";
import { Langfuse } from "langfuse";
@Injectable()
export class RouterService {
private llm = new OpenAI({
baseURL: process.env.LITELLM_BASE_URL,
apiKey: process.env.LITELLM_MASTER_KEY,
});
constructor(
private policy: TenantPolicyService,
private classifier: ClassifierService,
private lf: Langfuse,
) {}
async ask(opts: {
tenantId: string;
userId: string;
question: string;
feature: "qa" | "summarize" | "agent";
abFlag?: { variant: "A" | "B"; modelB: string };
}) {
const policy = await this.policy.get(opts.tenantId);
const trace = this.lf.trace({
name: "router.ask",
userId: opts.userId,
tags: [`tenant:${opts.tenantId}`, `feature:${opts.feature}`],
});
// A/B routing
let chosenModel: string;
if (opts.abFlag && Math.random() < 0.1) {
// 10% experimental
chosenModel = opts.abFlag.modelB;
trace.update({ metadata: { ab_variant: "B", ab_model: opts.abFlag.modelB } });
} else {
const difficulty = await this.classifier.classify(opts.question);
chosenModel = this.policy.resolveModelForFeature(policy, difficulty);
trace.update({ metadata: { difficulty, routed_model: chosenModel } });
}
// LiteLLM call - fallback chain is configured on LiteLLM side
const start = Date.now();
const res = await this.llm.chat.completions.create({
model: chosenModel,
messages: [{ role: "user", content: opts.question }],
max_tokens: 800,
user: `${opts.tenantId}:${opts.userId}`,
metadata: { tenant_id: opts.tenantId, langfuse_trace_id: trace.id },
} as any);
trace.update({
output: { text: res.choices[0].message.content },
metadata: { actual_model: res.model, latency_ms: Date.now() - start },
});
return {
text: res.choices[0].message.content,
requestedModel: chosenModel,
actualModel: res.model, // may differ if fallback fired
usage: res.usage,
};
}
}5) Cost dashboard
LiteLLM stocke spend per-key et per-model en Postgres. Grafana datasource Postgres :
-- per-tenant cost last 30d
SELECT
key_name as tenant,
model,
date_trunc('day', "startTime") as day,
SUM(spend) as eur
FROM "LiteLLM_SpendLogs"
WHERE "startTime" >= now() - interval '30 days'
GROUP BY tenant, model, day
ORDER BY day, tenant;
-- fallback rate
SELECT
date_trunc('hour', "startTime") as hour,
SUM(CASE WHEN metadata->>'fallback_used' = 'true' THEN 1 ELSE 0 END)::float /
COUNT(*) as fallback_rate
FROM "LiteLLM_SpendLogs"
WHERE "startTime" >= now() - interval '24 hours'
GROUP BY hour;6) A/B testing UI
Front interne admin pour activer/désactiver des A/B :
// admin-api/ab.controller.ts
@Post("ab/enable")
async enableAb(@Body() body: { feature: string; modelB: string; pct: number }) {
await this.featureFlags.set(`ab:${body.feature}`, {
enabled: true,
modelB: body.modelB,
pct: body.pct,
});
}Quand un A/B est actif sur une feature, le router envoie pct% du trafic vers modelB. Les scores Langfuse sont taguées ab:A ou ab:B. Au bout de 7 jours, un job calcule les scores comparés et propose une décision automatique.
7) Resilience tests
Tu simules :
- Anthropic 100% down → 100% trafic doit basculer sur fallback OpenAI en < 30 sec.
- OpenAI EU 100% down → fallback sur Mistral.
- Réseau dégradé (Toxiproxy 1500ms latency) → timeout court (30s), fallback.
- Cost cap atteint pour tenant X → requêtes refusées avec 429 explicite (
reason: cost_cap).
Mission complète : 36 k€, 6 semaines. Inclut docs + runbook + dashboards.
🎯 Patterns courants
- LiteLLM en sidecar : déploie LiteLLM comme service interne, l'app appelle
http://litellm:4000/v1. API OpenAI-compatible → switch facile depuis n'importe quel SDK. - Virtual keys per-tenant : LiteLLM crée une clé virtuelle par tenant. Tu peux révoquer/limiter sans toucher au code.
- Fallback chain ordonnée : du moins cher / plus dispo au plus cher / moins dispo (ou inverse selon stratégie).
- Difficulty classifier en Haiku : ne pas surcoûter le classif. < 50 ms / 0.0001 €.
- Sovereignty tag : ajoute
sovereignty: strict-fr | eu | globalà chaque tenant dès le sign-up. Force le routing. - A/B traffic split : 10% sur new model. Score auto via Langfuse.
- Cost cap per-tenant : hard-stop si dépasse le budget journalier. Évite la facture surprise.
- Track
actual_modelvsrequested_model: si fallback a fired, l'actual diffère. C'est ton signal d'outage. - Re-test périodique du provider down : ne pas rester bloqué sur le fallback éternellement.
- Audit per-call : qui a routé où, pourquoi, combien ça a coûté.
- Embedding routing aussi : Voyage FR pour FR, OpenAI ada pour EN, Cohere multilingual fallback.
🔄 Versions & écosystème 2026
- LiteLLM (open-source MIT) : 2.0+, support 100+ modèles, proxy + Python SDK. Standard de fait.
- Portkey : commercial, dashboard UI très propre, cost tracking, prompt management. Cher au scale.
- OpenRouter : marketplace, 200+ modèles unifiés, idéal pour expérimenter (pas pour prod critique car latence variable).
- Together AI : hosted open-weight (Llama, Mixtral, Llama-Guard). Bonne perf/prix.
- AWS Bedrock : multi-provider (Anthropic, Cohere, Meta) en un seul SDK. Région EU dispo. Cher.
- Azure AI Studio : Azure OpenAI + Cohere + Mistral + Llama hosted. France Central.
- Scaleway IA : Mistral hosted FR souverain.
- OVHcloud AI Endpoints : Mistral + Llama, FR souverain.
- Vercel AI Gateway (2025) : routing edge + observability, pratique pour Next.js / Vercel-natives.
Bon défaut 2026 : LiteLLM open-source, hébergé en interne, observability via Langfuse + cost via DB LiteLLM. Si besoin d'UI commerciale → Portkey.
⚠️ Pitfalls
- Fallback infinite loop : si tout est down, retry-fallback en boucle. Toujours capper
num_retries+ circuit breaker. - Cost explosion sur fallback : tu fallback sur GPT-4o qui est 3x plus cher → la facture explose pendant un outage. Préserver hierarchy de coût.
- Confondre
model_name(LiteLLM alias) et le vrai model_id du provider : tu écrisclaude-sonnetdans le code mais ta config map sur Opus → tu paies 5x. - Pas de cost tracking per-tenant : impossible de facturer correctement, ou de capper.
- A/B sans eval : tu route 10% vers GPT-5 mais tu ne mesures rien → décision aveugle.
- Failover trop lent : timeout 60s + 3 retries = 3 min de souffrance user. Cap timeout à 15-30s + breaker rapide.
- Souveraineté mal documentée : un tenant régulé pense être en Mistral FR, mais une feature appelle Anthropic US en backdoor (par ex : embedding) → DPO furieux.
- Latency overhead du proxy : LiteLLM ajoute 20-50 ms. Pas critique mais à mesurer.
- Pas d'audit du model utilisé en réponse : tu réponds via fallback OpenAI mais marketing communique "powered by Claude" → mensonge produit.
- Single point of failure proxy : si LiteLLM down, tout est down. Run en 3 replicas + ALB.
💰 Pricing / ROI client
Mission types :
- Multi-provider audit + plan (1 sem) : 8-10 k€.
- LiteLLM stack + fallback + dashboards (4-6 sem) : 22-40 k€.
- A/B framework + classifier difficulty + per-tenant policy (6-10 sem) : 40-65 k€.
- Maintenance + nouveaux modèles : 1500-2000€/j 1-2j/mois.
ROI client :
- 1 outage Anthropic 4h évité = 30-100 k€ de CA (selon taille).
- Cost saved par routing = 30-60% facture LLM (cf chapitre 03).
- Souveraineté débloque des deals régulés (banque, public) = millions de CA potentiel.
🧪 Testing / Eval
- Chaos test failover : couper Anthropic via Toxiproxy → mesurer temps bascule fallback.
- Eval cross-model : 100 questions golden, comparer Claude / GPT / Mistral. Scores par catégorie.
- A/B significance test : minimum 1000 traces par variant pour conclure.
- Cost regression :
expected_cost vs actual_costper route → alerte si drift. - Souveraineté lint : test CI qui scan le code pour
anthropic.messages.create(direct → fail si présent (force passage par router).
🔁 Quand utiliser / éviter
Utiliser :
- SaaS multi-tenant.
- Clients régulés (souveraineté).
- Volume LLM significatif (>10 k€/mois facture).
- Mission-critical (chatbot revenue-generating).
Use minimal :
- POC < 1 mois (juste 2 providers en config).
- Startup pré-PMF (focus sur features).
- Internal tool très simple.
🧩 Bonus — Patterns routing avancés FR
A. Routing par langue détectée
User écrit en arabe → route vers Anthropic (meilleur multilingue). User écrit en français → Mistral ou Anthropic (équivalent). User écrit en anglais technique → GPT (souvent meilleur en code). Un détecteur de langue rapide (fasttext ou simple heuristique) fait le job.
B. Routing par taille de context
Si l'user envoie un PDF de 80k tokens, ne pas router vers GPT-4o (limite 128k mais perf dégrade > 64k) — utiliser Claude Sonnet 200k ou Gemini 2.5 (1M tokens).
const ctxTokens = approxTokens(model, allMessagesText);
const model =
ctxTokens > 100_000 ? "gemini-2.5-pro" :
ctxTokens > 50_000 ? "claude-sonnet" :
"claude-haiku";C. Capacity routing (load-aware)
Si Anthropic répond avec un X-RateLimit-Remaining faible, switche pré-emptivement vers OpenAI. Permet d'éviter d'atteindre le 429.
LiteLLM expose health endpoints qui retournent la dispo perçue.
D. Cost-quality Pareto
Pour chaque feature, tu maintiens un tableau Pareto :
feature: agent_qa
- opus: quality 0.95, cost 1.0 (reference)
- sonnet: quality 0.93, cost 0.25
- haiku: quality 0.81, cost 0.04
- mistral-large: quality 0.88, cost 0.15
- gpt-4o-mini: quality 0.82, cost 0.05La politique du SaaS : "qualité min 0.85, choisir le moins cher" → Mistral Large. Auto-décision via job nightly qui relit les scores eval.
E. Region pinning per tenant
const REGION_MAP = {
"tenant_paris_bank": "eu-west-3",
"tenant_us_startup": "us-east-1",
"tenant_zurich_law": "eu-central-1",
};Le router pin la base URL provider selon la région.
F. Multi-modal routing
Si l'input contient image → router vers GPT-4o ou Claude (vision). Si pas → Mistral Large (text-only, moins cher).
const hasImage = messages.some(m => Array.isArray(m.content) && m.content.some((c: any) => c.type === "image"));
const model = hasImage ? "claude-sonnet" : "mistral-large";G. Streaming-aware fallback
Si tu streames la réponse et l'Anthropic plante à mi-chemin (event error durant le stream), tu ne peux pas "reprendre" propre. Solution : buffer les 200 premiers tokens, si erreur après → re-stream depuis 0 sur fallback avec le même prompt. User voit "un petit hiccup" sans perte totale.
H. Audit du model usé par tenant
Tu maintiens un rapport mensuel par tenant : "Vos requêtes ont été traitées par Anthropic Claude Sonnet à 92%, OpenAI à 5%, Mistral à 3%". Important pour les contrats régulés.
🔬 Failure modes & observabilité (niveau staff)
Un router multi-modèle ajoute une couche réseau, une couche de décision et N dépendances externes. Chacune a son mode de panne. Ce que tu instrumentes décide si un incident dure 5 minutes ou 4 heures.
Les 4 signaux non-négociables à logger par requête :
requested_modelvsactual_model— divergence = fallback a fired = signal d'outage primaire. C'est ta métrique #1.requested_capabilityvsdelivered_capability— détecte les dégradations silencieuses de reasoning/effort au fallback (cf. l'abstraction qui fuit). Sans ça, tu ne vois pas qu'un fallback OpenAI a tué le thinking.usagecomplet (input/output tokens, etcache_read_input_tokenssi prompt caching) — pour le coût réel par tenant et par modèle. Anthropic facture Opus 4.8 à 5 $ / 25 $ par M tokens (input/output), Haiku 4.5 à 1 $ / 5 $ ; un mauvais routing qui envoie dusimplevers Opus multiplie ta facture par 5.decision_reason— pourquoi cette requête a été routée ici (tenant policy ? difficulté classée ? A/B variant ? cost cap ?). Sans la raison, ton audit régulé est inutile et ton débogage est de la divination.
Modes de panne classés par insidiosité (du plus bruyant au plus silencieux — les silencieux sont les dangereux) :
| Mode | Symptôme | Détection | Mitigation |
|---|---|---|---|
| Proxy LiteLLM down | 100 % des requêtes 5xx | Health check + alerte | 3 replicas + LB ; jamais 1 seul replica |
| Fallback infinite loop | Latence explose, budget brûle | num_retries capé + circuit breaker | Cap dur + breaker à ouverture rapide |
| Cost cap dépassé | Facture surprise en fin de mois | Spend tracking temps réel par tenant | Hard-stop 429 reason: cost_cap |
| Capability silently dropped | Qualité baisse, zéro erreur | requested_capability != delivered | Capability mapping explicite + alerte |
| Tokenizer drift sur le coût | cost_cap faux de 15-35 % | Estimateur de coût par provider | Re-baseline par modèle, pas de multiplicateur global |
| Backdoor souveraineté | Embedding US sur tenant FR strict | Lint CI sur appels directs au SDK | Forcer tout passage par le router |
| Cache invalidé au switch modèle | cache_read = 0, coût 1,25× au lieu de 0,1× | usage.cache_read_input_tokens à zéro | Le cache est par-modèle : ne switche pas de modèle en milieu de conversation |
Le mode de panne préféré des incidents post-mortem : le silencieux. Un proxy down déclenche une alerte en 30 secondes. Un fallback qui a tué le reasoning d'Opus tourne pendant des semaines, dégrade la qualité de 8 %, et personne ne le voit parce que le dashboard est vert (200 OK, latence normale, coût normal). C'est exactement pourquoi delivered_capability est dans les 4 signaux : c'est le seul qui attrape la dégradation qui ne lève aucune exception.
🏋️ Exercices
Stack de référence : LiteLLM proxy (OpenAI-compatible) + NestJS sidecar + Langfuse + Postgres pour le spend. Adapte si tu utilises Portkey/OpenRouter.
Exercice 1 — Router par difficulté avec classifier Haiku (mise en route)
Objectif : implémenter un router à 3 niveaux (easy → claude-haiku-4-5, standard → claude-sonnet-4-6, hard → claude-opus-4-8) avec un classifier Haiku qui lit la question + métadonnées et renvoie la difficulté.
Indice/Solution : le classifier est lui-même un appel LLM — utilise Haiku 4.5 avec output_config: {format: ...} (structured output, schéma {difficulty: "easy"|"standard"|"hard"}) plutôt que du parsing XML maison. Cap le classifier à max_tokens: 256. Mesure son coût : il doit rester < 5 % du coût total de la requête, sinon le routing intelligent coûte plus cher qu'il n'économise. Logge difficulty + routed_model dans Langfuse.
Exercice 2 — Fallback chain avec circuit breaker et re-test périodique (résilience)
Objectif : claude-sonnet-4-6 → gpt-4o → mistral-large. Quand Anthropic dépasse 50 % d'erreurs sur 60 s, le circuit breaker ouvre et bypasse directement vers le fallback (pas de tentative inutile). Toutes les 30 s, ping léger Anthropic → si OK, half-open puis closed.
Indice/Solution : côté SDK Anthropic, utilise AsyncAnthropic, max_retries, un timeout per-call (15-30 s, pas 60), et catch les exceptions typées (OverloadedError → 529, RateLimitError → 429, APITimeoutError). Distingue retry (transitoire, même provider) de fallback (provider down, autre provider). Le breaker doit être par-provider, pas global — sinon une panne Anthropic ferme aussi le circuit OpenAI. Teste avec Toxiproxy : 100 % down → bascule < 30 s ; latence 1500 ms → timeout court → fallback.
Exercice 3 — Préserver le reasoning au fallback (le piège de l'abstraction)
Objectif : router une requête hard censée raisonner. Cas nominal → Opus 4.8 avec thinking: {type: "adaptive"} + output_config: {effort: "high"}. Cas fallback (Anthropic down) → OpenAI avec son reasoning_effort. Prouve, en logs, que le reasoning n'est pas silencieusement perdu.
Indice/Solution : c'est ici que drop_params: true te trahit. Construis une couche de capability mapping : {capability: "reasoning_high"} → {effort: "high"} (Anthropic) / {reasoning_effort: "high"} (OpenAI). Logge requested_capability ET delivered_capability. Test de validation : envoie 50 requêtes hard pendant une panne Anthropic simulée, vérifie qu'aucune n'a delivered_capability: "none". Bonus : ajoute une métrique Langfuse capability_degradation_rate et alerte si > 0.
Exercice 4 — Cost cap per-tenant exact malgré le tokenizer drift (défends le chiffre)
Objectif : un tenant a un budget journalier de 50 €. Implémente un hard-stop qui refuse en 429 reason: cost_cap avant de dépasser, avec une estimation de coût correcte à ± 5 % malgré le fait que les tokenizers Anthropic/OpenAI/Mistral diffèrent.
Indice/Solution : n'utilise pas un multiplicateur global tokens→€. Compte les tokens par provider : client.messages.count_tokens(model=...) côté Anthropic (jamais tiktoken, qui sous-compte les tokens Claude de 15-20 %). Pré-route : estime le coût, compare au budget restant (lu depuis Redis), refuse si dépassement. Post-route : logge usage réel et réconcilie l'estimé vs réel. Métrique de drift : abs(estimated - actual) / actual — si > 5 % de façon systématique, ton estimateur est cassé. Défends ton chiffre : « le cap est à ± 4,2 % parce que je compte par-tokenizer, pas avec un facteur magique ».
Exercice 5 — Casse-le puis répare : le fallback silencieux qui dégrade la qualité (production-grade)
Objectif : reproduis l'incident le plus insidieux. Configure un fallback claude-opus-4-8 → gpt-4o-mini (volontairement faible) sur une feature sensitive. Avec drop_params: true, déclenche une panne Anthropic et observe : le dashboard reste vert (200 OK, latence normale, coût baisse), mais la qualité s'effondre. Puis instrumente la détection.
Indice/Solution : le piège est que gpt-4o-mini est moins cher → le coût baisse pendant la panne, donc même l'alerte de coût ne sonne pas. La seule détection fiable est (a) actual_model != requested_model (fallback a fired) couplé à (b) un score d'éval continu (LLM-judge ou golden set) par modèle. Ajoute une éval shadow : 5 % du trafic re-scoré, et alerte si le score d'une feature chute de > 3 % pendant que fallback_rate > 0. Bonus production : interdis par policy le fallback qualité-dégradant sur les features sensitive (mieux vaut un 503 explicite qu'une réponse subtilement fausse sur de l'autoliquidation TVA).
Exercice 6 — Lint de souveraineté en CI (sécurité régulée)
Objectif : un tenant régulé FR ne doit jamais voir une requête (chat ou embedding) sortir vers un provider US. Écris un test CI qui échoue si du code appelle directement anthropic.messages.create( / openai.chat.completions.create( sans passer par le router, et un test runtime qui vérifie qu'aucun appel d'un tenant strict-fr n'a touché un provider non-FR.
Indice/Solution : la fuite classique est l'embedding — l'équipe route bien le chat vers Mistral mais oublie que le RAG appelle voyage/openai ada en backdoor. Le lint statique (grep/AST sur les appels SDK directs) attrape l'oubli au commit ; le test runtime (assertion sur actual_provider par tenant dans les logs Langfuse sur 24 h) attrape la régression en prod. Livrable de mission régulée : un rapport mensuel par tenant (« 100 % de vos requêtes traitées par Mistral FR/Scaleway, 0 % US ») — c'est une exigence contractuelle DPA, pas un nice-to-have.
🎤 En entretien
« Le gateway expose une API OpenAI-compatible, donc je peux switcher de provider sans rien changer. Vrai ou faux ? » Faux. Le gateway unifie le transport, pas la sémantique. Les features provider-spécifiques (reasoning/thinking, structured outputs, caching) ne se mappent pas automatiquement ; en 2026 le
budget_tokensd'Anthropic est même supprimé (→effort+ thinking adaptatif). Sans capability mapping explicite, un fallback dégrade silencieusement la qualité.« Comment tu détectes qu'un fallback a dégradé la qualité sans lever d'erreur ? » Je logge
delivered_capabilityà côté deactual_model, et je tourne une éval shadow (LLM-judge ou golden set) par modèle. Un fallback silencieux donne 200 OK + latence normale + coût qui baisse — seul un score d'éval qui chute pendant quefallback_rate > 0l'attrape.« Ton cost cap per-tenant est faux de 30 %. Pourquoi, et comment tu le corriges ? » Tokenizer drift : j'utilisais un multiplicateur global tokens→€ alors que Anthropic, OpenAI et Mistral tokenisent différemment (et
tiktokensous-compte les tokens Claude de 15-20 %). Je corrige en comptant les tokens par provider (count_tokensnatif côté Anthropic) et en réconciliant l'estimé pré-route vs l'usageréel post-route.« Quel est le single point of failure le plus dangereux d'un router multi-modèle, et comment tu l'élimines ? » Le proxy lui-même : si LiteLLM est en 1 replica, tout tombe quand il tombe — ironie d'avoir bâti la résilience multi-provider derrière un SPOF. Je le déploie en ≥ 3 replicas derrière un LB avec health checks, et je traite le breaker comme par-provider pour qu'une panne Anthropic ne ferme pas le circuit OpenAI.
🔗 Liens
- LiteLLM docs : https://docs.litellm.ai
- Portkey : https://docs.portkey.ai
- OpenRouter : https://openrouter.ai/docs
- AWS Bedrock : https://docs.aws.amazon.com/bedrock
- Azure AI Studio : https://learn.microsoft.com/azure/ai-studio
- Scaleway IA : https://www.scaleway.com/en/ai-services
- OVHcloud AI Endpoints : https://www.ovhcloud.com/fr/public-cloud/ai-endpoints
- Vercel AI Gateway : https://vercel.com/docs/ai-gateway
- Together AI : https://docs.together.ai
- Mistral docs : https://docs.mistral.ai
- Anthropic models comparison : https://docs.anthropic.com/claude/docs/models-overview
- Routing strategies (LiteLLM) : https://docs.litellm.ai/docs/routing