Skip to main content
Retour au blog
Claude CodeAICLIBashAutomationResilience

Une cascade d'agents : le terminal choisit celui qui fonctionne

Quand Claude est tombé en partie et que certains modèles ont cessé de répondre, j'ai voulu passer à Gemini — et j'ai découvert que mon repli était cassé depuis des mois. Voici comment en bâtir un qui tienne vraiment.

Publié 29 août 20269 min de lecture

TL;DR

Un lanceur bash nommé agent qui choisit la meilleure CLI d'IA opérationnelle avant de démarrer. Il dégrade selon deux axes : d'abord le modèle (Opus → Sonnet, sans quitter Anthropic), puis le transport, et seulement ensuite le fournisseur. Chaque sonde effectue un appel réel, car les listes de modèles mentent. Un chien de garde vérifie les échelons bas toutes les deux heures et alerte sur Telegram.

$ agent --status
  OK   claude / claude-opus-5         api.anthropic.com reachable, claude-opus-5 answered
  OK   claude / claude-sonnet-5       api.anthropic.com reachable, claude-sonnet-5 answered
  OK   claude-proxy / claude-opus-5   claude-opus-5 answered
  OK   claude-proxy / claude-sonnet-5 claude-sonnet-5 answered
  OK   agy                            antigravity answered on Gemini 3.6 Flash (Low)
  OK   gemini                         localhost:8317 answered for gemini-3.1-pro-low

Le problème

Quand Claude est indisponible, je veux que mon terminal continue de fonctionner. La version naïve est « si Claude tombe, lance Gemini » — et c'est ce que je croyais avoir. Mais les pannes intéressantes ne sont pas des pannes globales. La plus fréquente : Opus atteint sa limite alors que Sonnet répond parfaitement. Basculer chez un autre fournisseur est alors absurde.

Un vrai repli doit donc dégrader par modèle avant de dégrader par fournisseur. Et il doit savoir quel échelon est réellement vivant — ce fut la partie difficile, et je me suis trompé deux fois.

Le mensonge de cinq mois

Avant d'écrire quoi que ce soit, j'ai vérifié l'existant. Le répertoire d'authentification du proxy racontait tout en deux horodatages :

claude-newiqa@gmail.com.json — renouvelé aujourd'hui, 06:41 · gemini-newiqa@gmail.com-….json — dernière fois le 27 mars

Le jeton Claude se renouvelait chaque jour. Celui de Gemini n'avait pas bougé depuis cinq mois. Mon contrôle de santé ne regardait que claude-*.json, donc personne n'a rien vu. Le repli était décoratif depuis le printemps — un voyant vert au-dessus d'une pièce vide. Cette seule observation a façonné tout le reste : un repli que personne n'appelle vraiment n'est pas un repli.

Pourquoi une passerelle ne résout pas cela

Le réflexe évident est une passerelle LLM locale — Bifrost, LiteLLM — avec bascule de fournisseur dans la config. J'en fais déjà tourner une (CLIProxyAPI) ; en ajouter une seconde n'aurait apporté qu'un démon de plus à surveiller.

Plus important : une passerelle échoue sur la tâche réelle. La bascule au niveau API revient à remplacer le modèle sous un même client. Donnez à Claude Code une réponse Gemini via une couche de traduction et il casse sur le tool-use et le streaming. Le repli doit changer la CLI, pas le modèle derrière.

Six échelons, deux axes

Fournisseur et modèle tombent indépendamment, donc l'échelle les entrelace. Chaque échelon apporte quelque chose de précis, et le coût de la descente est explicite :

RungWhat it survivesCost of getting there
claude + opusnothing — this is the happy path
claude + sonnetOpus capped or overloadedweaker model, same session shape
claude-proxy + opusstale local login, broken CLI authseparate OAuth token
claude-proxy + sonnetboth of the above at onceweaker model via proxy
agyAnthropic outage / exhausted plandifferent vendor, different CLI
geminiagy itself brokendifferent CLI again, shares agy's quota

Dans le code, un échelon est un triplet : backend, modèle à sonder, modèle d'exécution. Le modèle vide du premier échelon compte plus qu'il n'y paraît : ne pas passer --model préserve le opus[1m] du profil et son contexte de 1M. Le nommer explicitement le réduirait silencieusement à un Opus ordinaire.

agent
# Each rung: backend | model to probe | model to run with
# An empty run-model means "let the CLI use its configured default", which keeps
# the profile's opus[1m] (and its 1M context) instead of downgrading it to plain
# opus just to name it explicitly.
RUNGS=(
  "claude|$AGENT_OPUS_MODEL|"
  "claude|$AGENT_SONNET_MODEL|sonnet"
  "claude-proxy|$AGENT_OPUS_MODEL|$AGENT_OPUS_MODEL"
  "claude-proxy|$AGENT_SONNET_MODEL|$AGENT_SONNET_MODEL"
  "agy||"
  "gemini||"
)

Quand l'échelon supérieur est indisponible, la descente est annoncée plutôt que silencieuse — une session dégradée ne doit jamais passer pour une session normale :

$ agent -p "explain this bug"
[agent] Claude Code (claude-opus-5) unavailable — claude-opus-5 unavailable: {"error":...}
[agent] using Claude Code (claude-sonnet-5)

# ...the session continues on Sonnet, inside Anthropic, with no vendor switch.

Les listes de modèles mentent

Le premier réflexe est de sonder à moindre coût : demander /v1/models et vérifier la présence du modèle. C'est sans valeur. Mon proxy annonçait joyeusement gemini-* pendant tout le temps où chaque appel renvoyait auth_unavailable. La CLI Antigravity fait pareil : agy models répond depuis un catalogue.

Idem pour la santé d'un modèle. Aucun endpoint de listage ne dira qu'Opus est actuellement limité. Seule une vraie requête le montre — un 429 ou un 529. Chaque sonde en fait donc une :

agent
# Real /v1/messages call for ONE model. This is what distinguishes "Opus is
# capped" from "Anthropic is down" — no model list can tell you that.
probe_model() {
  local m="$1" body
  body=$(curl -sS --max-time "$PROBE_TIMEOUT" \
    "$AGENT_PROXY_URL/v1/messages" \
    -H "Authorization: Bearer $AGENT_PROXY_KEY" \
    -H 'content-type: application/json' \
    -d "{\"model\":\"$m\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}")
  case "$body" in
    *'"type":"message"'*) REASON="$m answered"; return 0 ;;
  esac
  REASON="$m unavailable: $(printf '%s' "$body" | tr -d '\n' | cut -c1-140)"
  return 1
}

La disponibilité par modèle des échelons officiels est empruntée au proxy, qui sert le même compte Anthropic. Tout le préambule a été mesuré à 7 tokens et environ 1,3 seconde, le résultat d'Opus étant mis en cache.

Deux filets, pas un

Une sonde préalable ne capte pas tout. Si le quota s'épuise entre la sonde et le lancement, la sonde avait raison et fut néanmoins inutile. D'où un second filet : code de sortie plus temps écoulé.

agent
start=$SECONDS
run_rung "$backend" "$rmodel" "$@"
rc=$?
elapsed=$(( SECONDS - start ))

# Clean exit, or the user interrupted it — either way, done.
if [ $rc -eq 0 ] || [ $rc -eq 130 ]; then
  exit $rc
fi

# Survived long enough to have been genuinely used: a real error, not a rung
# that never started. Do not silently rerun the work somewhere else.
if [ $elapsed -ge $FASTFAIL_SECONDS ]; then
  exit $rc
fi

warn "$(label "$backend" "$pmodel") exited $rc after ${elapsed}s — treating as unavailable"

Le seuil de 25 secondes encode un jugement. Un échelon qui meurt presque aussitôt n'a jamais vraiment démarré ; passer au suivant est donc sûr. Un échelon qui a tourné des minutes puis échoué travaillait vraiment, et refaire silencieusement ce travail ailleurs serait pire que d'afficher l'erreur.

Le chien de garde et son piège

Le lanceur ne tourne que quand je le lance, un échelon peut donc pourrir entre deux sessions. Un contrôle intégré à mon droïde de santé (launchd, toutes les deux heures, alertes Telegram) appelle le même code de sonde. Ma première version avait exactement le bug dont parle cet article : elle renvoyait un succès dès le premier échelon vert.

# Healthy — every non-Anthropic rung answers
$ agent --check-fallback
all provider-independent fallbacks OK: agy — antigravity answered...; gemini — ...
rc=0

# One rung rotted while the other still works. This is the case that used to
# pass silently, and the whole reason the check exists.
$ GEMINI_API_KEY=broken agent --check-fallback
DEGRADED: still covered by agy — antigravity answered on Gemini 3.6 Flash (Low)
but a rung died: gemini — gemini call failed: {"error":"Invalid API key"}
rc=1

C'est faux, car les deux échelons non-Anthropic détiennent des jetons distincts — la CLI Antigravity le sien, l'échelon Gemini celui du proxy. L'un peut pourrir pendant que l'autre paraît parfait. Le contrôle sonde donc tous les échelons et traite DEGRADED comme un échec.

Trois erreurs à noter

Chacune a produit un signal vert au-dessus d'une mécanique cassée — le même mode de défaillance, trois fois dans l'après-midi :

  • La sonde testait un modèle que je n'utilise jamais. Je sondais gemini-3.1-pro-preview alors que la CLI était configurée sur gemini-3.1-pro-low. Ils se résolvent vers des fournisseurs différents ; j'ai diagnostiqué un « jeton expiré » inexistant. Une sonde doit emprunter exactement le chemin du vrai client.
  • Une variable vide en silence. Analyser la clé YAML du proxy avec awk et \x27 donnait une chaîne vide sous BSD awk — sans erreur ni avertissement. Le contrôle passait quand même. Corrigé via awk -v q="'".
  • La sonde passait alors que le réel échouait. Une sonde curl renvoyait 200, mais le vrai gemini -p refusait de démarrer : le mode headless bloque sur les répertoires non approuvés. L'échelon était cassé précisément dans la situation qui justifie son existence.
Le motif commun : un contrôle qui ne fait pas la vraie chose finira par être un contrôle qui ment. Les sondes bon marché séduisent parce qu'elles sont rapides et concordent d'ordinaire avec la réalité — jusqu'au moment précis où l'on a besoin qu'elles divergent.

Utilisation

Taper agent donne le meilleur échelon disponible avec l'expérience Claude Code habituelle. La sélection explicite saute entièrement le sondage : demandez Gemini, vous obtenez Gemini :

agent                          # auto-pick the best live rung
agent --use gemini             # force a backend
agent --use claude:sonnet      # force a backend AND a model
agent --use claude-proxy:opus  # opus/sonnet/haiku map to full ids per backend
agent --list                   # backend names
agent --status                 # probe every rung and report
agent --check-fallback         # exit 1 if a non-Anthropic rung is missing

agent -p "..."                 # flags pass straight through to the chosen CLI

Conclusions

L'ingénierie ici est banale : un script bash, quelques appels curl, un tableau ordonné. L'effort a porté sur la méfiance envers mes propres contrôles. Trois fois j'ai construit quelque chose qui annonçait le succès alors que le dessous était cassé.

La résilience n'est pas la liste des backends configurés. C'est de savoir si quelque chose vérifie que cette liste est encore vraie. La mienne comptait six échelons sur le papier et un seul réel pendant cinq mois.

Sources

Vous voyez une erreur ?

Un fait erroné, une traduction bancale, quelque chose qui sonne faux dans cet article ? Écrivez-moi — dans votre propre langue.