Aller au contenu
Blog
Technique26 octobre 2026

Router deux LLM sans se ruiner

Pas de méta-routeur, pas de SDK, pas de dixième provider. Deux fetch() nus, un timeout, un fallback, et des quotas en muffins qui transforment chaque appel IA en plafond de dépense. Visite guidée du routing LLM de Grimoire Culinaire.

Le piège du méta-routeur

Quand j'ai attaqué les features IA de Grimoire Culinaire, mon app de recettes, la tentation était classique : construire une belle abstraction multi-provider dès le jour 1. Une interface générique, N adaptateurs, un routeur intelligent, peut-être LangChain pour faire sérieux. Tout le monde fait ça, donc ça doit être la bonne réponse.

J'ai fait l'inverse. Le client IA de Grimoire tient dans un seul fichier d'une Edge Function Deno : deux fonctions, un fetch() nu vers OpenAI, un fetch() nu vers Gemini. Zéro SDK, zéro framework d'orchestration. La doctrine est écrite en commentaire en tête de fichier : clé API jamais dans l'app, Structured Outputs pour zéro parsing hasardeux, fallback automatique OpenAI vers Gemini, journalisation minimale de chaque appel.

Un provider de LLM, c'est une API HTTP. Deux providers, c'est deux appels HTTP. Le méta-routeur que je n'ai pas construit aurait résolu un problème que je n'ai pas : je n'ai pas dix providers, j'en ai deux, choisis pour des raisons précises.

Primaire, fallback, timeout : c'est tout

Le primaire est OpenAI gpt-4o-mini, appelé avec Structured Outputs : je passe un schéma JSON strict et l'API garantit que la réponse le respecte. Pour structurer une recette dictée à la voix ou collée depuis un site, c'est exactement ce qu'il faut : je parse le résultat sans filet et sans regex de rattrapage.

Le fallback est Gemini 2.0 Flash. Et il est structurellement moins fiable, ce que j'assume : Gemini ne reçoit pas le schéma comme contrainte d'API, il le reçoit injecté dans le prompt, avec un responseMimeType JSON et une prière. Ça marche presque toujours. Presque. C'est le prix d'un fallback : il est là pour sauver la requête quand le primaire tombe, pas pour être aussi bon que lui.

Entre les deux, un seul mécanisme de bascule : un Promise.race entre l'appel OpenAI et un timer.

brigit@7sailtypescript
// supabase/functions/_shared/ai-client.ts (extrait simplifié)

/** Timeout pour l'appel OpenAI.
 * 30s car les Structured Outputs avec schema complet + texte long
 * prennent regulierement 5-15s, et le reseau Docker ajoute de la latence. */
const OPENAI_TIMEOUT_MS = 30_000;

export async function callAiWithFallback(systemPrompt, userContent, jsonSchema) {
  try {
    return await Promise.race([
      callOpenAI(systemPrompt, userContent, jsonSchema),
      new Promise((_, reject) =>
        setTimeout(() => reject(new Error("OpenAI timeout")), OPENAI_TIMEOUT_MS),
      ),
    ]);
  } catch (openaiError) {
    // Primaire en echec ou trop lent : on tente le secours.
  }
  return await callGemini(systemPrompt, userContent, jsonSchema);
}

Le routing s'inverse selon la tâche

Voilà le détail qui tue l'idée du « meilleur provider » : il n'existe pas. Il n'existe que le meilleur provider par tâche et par prix.

Pour tout ce qui est texte, OpenAI est primaire et Gemini secours. Mais pour l'OCR photo, quand l'utilisateur photographie une recette manuscrite de sa grand-mère, l'ordre s'inverse : Gemini Vision devient le primaire et GPT-4o Vision le fallback. Sur cette tâche précise, à mon volume, Gemini lit mieux pour moins cher. Le fichier ai-vision l'annonce dès son commentaire d'en-tête, et le code fait exactement ça : Gemini d'abord, OpenAI si ça casse.

Deux providers ne veut donc pas dire une hiérarchie figée. Ça veut dire deux briques, et une décision de routage par type d'appel. C'est tout l'intérêt d'avoir gardé le client simple : inverser un ordre de préférence, c'est inverser deux lignes, pas reconfigurer un framework.

Les muffins comme plafond de dépense

La vraie protection contre la ruine n'est pas dans le client IA. Elle est dans la monnaie interne de l'app : les muffins.

Chaque action IA a un coût fixe, déclaré dans un enum côté app et vérifié côté serveur : structurer une recette coûte 15 muffins, estimer la nutrition 5, un OCR photo 20, un plan de repas 70. L'utilisateur a un wallet, débité avant l'appel. La doctrine de l'Edge Function est explicite : vérification du quota avant l'appel IA, et jamais de débit si l'utilisateur n'a pas reçu un résultat exploitable.

La conséquence est mécanique. Un utilisateur ne peut pas déclencher plus d'appels que son wallet n'en autorise, et chaque appel a un coût borné en tokens. Mon exposition financière par utilisateur est donc plafonnée par construction, pas par surveillance.

brigit@7saildart
// app/lib/shared/constants/muffin_pricing.dart (extrait simplifié)
enum AiActionCost {
  structureRecipe(15),   // structurer une recette (texte ou voix)
  nutritionEstimate(5),  // estimation nutritionnelle
  ocrRecipe(20),         // OCR photo via Gemini Vision
  mealPlan(70);          // plan de repas 7 jours

  const AiActionCost(this.cost);
  final int cost;
}
Le quota utilisateur n'est pas une feature de monétisation qui limite l'IA : c'est mon plafond de facture API, déguisé en produit.

Piloter sans redéployer, et l'aveu qui va avec

Le dernier étage est une table de configuration en base, remplie au seed : provider primaire, modèle, provider de secours, timeout, longueur max d'entrée. L'idée : pouvoir changer de modèle ou resserrer un paramètre depuis l'admin, sans redéployer une Edge Function.

Et c'est là que je dois avouer quelque chose. En relisant le code pour cet article, j'ai trouvé une incohérence : le seed déclare ai.timeout_ms à 10000, dix secondes. Le code, lui, définit OPENAI_TIMEOUT_MS à 30 000 en dur, avec un commentaire qui justifie très bien pourquoi 30 secondes. Les deux valeurs ne se sont jamais rencontrées : le client IA ne lit pas cette entrée de config. La couche « pilotable depuis l'admin » existe en base, mais pour le timeout, le câblage n'a jamais été fait.

Je pourrais le corriger discrètement avant de publier. Je préfère le laisser en exemple : une config que le code ne lit pas est pire qu'une valeur en dur, parce qu'elle donne l'illusion du contrôle. Celui qui changera 10000 en 5000 dans l'admin croira avoir resserré le timeout. Il n'aura rien changé du tout.

brigit@7sailsql
-- supabase/seed.sql (extrait)
('ai.primary_provider', '"openai"',      'Provider IA principal'),
('ai.primary_model',    '"gpt-4o-mini"', 'Modèle IA principal'),
('ai.fallback_provider','"gemini"',      'Provider IA de secours'),
('ai.timeout_ms',       '10000',         'Timeout avant fallback (ms)'),
-- ... pendant que le code dit : const OPENAI_TIMEOUT_MS = 30_000;

Ce que je retiens

La fiabilité de la couche IA de Grimoire ne vient pas du nombre de providers ni d'une abstraction savante. Elle vient de la netteté du contrat : un schéma JSON imposé au primaire, un timeout explicite, un fallback assumé comme moins bon, un coût fixe débité avant chaque appel.

Deux providers suffisent quand chacun a un rôle clair, et le rôle peut s'inverser selon la tâche : OpenAI devant pour le texte, Gemini devant pour la vision. Le plafond de dépense le plus robuste n'est pas une alerte de facturation, c'est un wallet débité avant l'appel.

Et la leçon bonus, offerte par mon propre repo : chaque paramètre « pilotable » doit prouver qu'il est lu par le code. Mon timeout configurable à 10 secondes tourne en réalité à 30 depuis le début. Personne ne s'en était aperçu, et c'est exactement le problème.