Skip to content

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 :

  1. 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 forme budget_tokens est supprimée sur Opus 4.7/4.8 et Fable 5 et renvoie un HTTP 400. OpenAI a son reasoning_effort, Gemini a son thinkingBudget. 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.
  2. drop_params: true masque 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 avec output_config.effort routée vers Mistral perd l'effort sans bruit. Tu crois faire du « hard », tu fais du « default ».
  3. 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_cap per-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

ContexteAxe dominantStratégie de routingPiège
Chatbot revenue-generating, trafic continuDisponibilité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-directBackdoor embedding qui sort de l'UE
Pipeline batch (compta, extraction)CoûtClassifier Haiku → route par difficulté, A/B continuClassifier qui se trompe sur les sensitive
Agent long-horizon (coding, research)QualitéOpus 4.8 / effort high, pas de fallback qualité-dégradanteFallback vers un modèle non-raisonnant qui casse l'agent
POC / pré-PMFVélocité2 providers en config, zéro classifierSur-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.

yaml
# 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
ts
// 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 :

  1. LiteLLM gateway déployé en sidecar NestJS.
  2. Fallback chain : claude-sonnet → gpt-4o → mistral-large (Azure EU pour le 2e).
  3. 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).
  4. Re-test périodique : toutes les 30 sec, ping léger sur Anthropic → si OK, half-open.
  5. Alerting : Slack #ops quand 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 :

  1. Classifier (Haiku) lit le résumé de la facture + métadonnées (fournisseur, montant, TVA, multi-ligne ?) → classe simple | complex | sensitive.
  2. simple → GPT-4o-mini (très bon en classification déterministe, coût mini).
  3. complex → Claude Sonnet (raisonnement multi-étape).
  4. 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 sensitive vers deux modèles n'est pas « le router envoie deux fois ». Tu dois (a) appeler les deux en parallèle (asyncio.gather côté Python, Promise.all cô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 (effort doit survivre — vérifie le delivered_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.

  1. A/B continu : 5% du simple trafic 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

yaml
# 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

yaml
# 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 LiteLLM

3) NestJS — Tenant policy service

ts
// 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

ts
// 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 :

sql
-- 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 :

ts
// 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_model vs requested_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

  1. Fallback infinite loop : si tout est down, retry-fallback en boucle. Toujours capper num_retries + circuit breaker.
  2. 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.
  3. Confondre model_name (LiteLLM alias) et le vrai model_id du provider : tu écris claude-sonnet dans le code mais ta config map sur Opus → tu paies 5x.
  4. Pas de cost tracking per-tenant : impossible de facturer correctement, ou de capper.
  5. A/B sans eval : tu route 10% vers GPT-5 mais tu ne mesures rien → décision aveugle.
  6. Failover trop lent : timeout 60s + 3 retries = 3 min de souffrance user. Cap timeout à 15-30s + breaker rapide.
  7. 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.
  8. Latency overhead du proxy : LiteLLM ajoute 20-50 ms. Pas critique mais à mesurer.
  9. Pas d'audit du model utilisé en réponse : tu réponds via fallback OpenAI mais marketing communique "powered by Claude" → mensonge produit.
  10. 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_cost per 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).

ts
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.05

La 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

ts
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).

ts
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 :

  1. requested_model vs actual_model — divergence = fallback a fired = signal d'outage primaire. C'est ta métrique #1.
  2. requested_capability vs delivered_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.
  3. usage complet (input/output tokens, et cache_read_input_tokens si 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 du simple vers Opus multiplie ta facture par 5.
  4. decision_reasonpourquoi 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) :

ModeSymptômeDétectionMitigation
Proxy LiteLLM down100 % des requêtes 5xxHealth check + alerte3 replicas + LB ; jamais 1 seul replica
Fallback infinite loopLatence explose, budget brûlenum_retries capé + circuit breakerCap dur + breaker à ouverture rapide
Cost cap dépasséFacture surprise en fin de moisSpend tracking temps réel par tenantHard-stop 429 reason: cost_cap
Capability silently droppedQualité baisse, zéro erreurrequested_capability != deliveredCapability mapping explicite + alerte
Tokenizer drift sur le coûtcost_cap faux de 15-35 %Estimateur de coût par providerRe-baseline par modèle, pas de multiplicateur global
Backdoor souverainetéEmbedding US sur tenant FR strictLint CI sur appels directs au SDKForcer tout passage par le router
Cache invalidé au switch modèlecache_read = 0, coût 1,25× au lieu de 0,1×usage.cache_read_input_tokens à zéroLe 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_tokens d'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é de actual_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 que fallback_rate > 0 l'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 tiktoken sous-compte les tokens Claude de 15-20 %). Je corrige en comptant les tokens par provider (count_tokens natif côté Anthropic) et en réconciliant l'estimé pré-route vs l'usage ré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

Bibliothèque tech perso — Achref