Skip to main content
Zurück zum Blog
Claude CodeAICLIBashAutomationResilience

Eine Kaskade von Agenten: das Terminal wählt den, der gerade läuft

Als Claude teilweise ausfiel und einige Modelle nicht mehr liefen, wollte ich auf Gemini wechseln — und merkte, dass mein Fallback seit Monaten kaputt war. So baut man einen, der wirklich hält.

Veröffentlicht 29. August 20269 Min. Lesezeit

TL;DR

Ein Bash-Launcher namens agent, der vor dem Start das beste funktionierende AI-Coding-CLI auswählt. Er degradiert entlang zweier Achsen: erst das Modell (Opus → Sonnet, innerhalb von Anthropic), dann der Transport, und erst zuletzt der Anbieter. Jede Probe macht einen echten API-Aufruf, denn Modelllisten lügen. Ein Watchdog prüft die unteren Stufen alle zwei Stunden und meldet per Telegram, wenn eine ausfällt.

$ 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

Das Problem

Wenn Claude nicht verfügbar ist, soll mein Terminal weiterarbeiten. Die naive Variante lautet „wenn Claude ausfällt, starte Gemini“ — und genau das glaubte ich zu haben. Aber die interessanten Fehler sind keine Ausfälle. Der häufigste: Opus ist am Limit, während Sonnet einwandfrei antwortet. Dann zu einem anderen Anbieter zu springen ist absurd — das Modell nebenan ist gesund.

Ein echter Fallback muss also erst das Modell degradieren und dann den Anbieter. Und er muss wissen, welche Stufe tatsächlich lebt — das war der schwierige Teil, und den habe ich zweimal falsch gemacht.

Die fünfmonatige Lüge

Bevor ich irgendetwas schrieb, prüfte ich den Bestand. Das Auth-Verzeichnis des Proxys erzählte die ganze Geschichte in zwei Zeitstempeln:

claude-newiqa@gmail.com.json — heute erneuert, 06:41 · gemini-newiqa@gmail.com-….json — zuletzt am 27. März

Das Claude-Token wurde täglich erneuert. Das Gemini-Token hatte sich fünf Monate nicht bewegt. Mein Health-Check sah nur auf claude-*.json, also fiel es niemandem auf. Der Fallback war seit dem Frühjahr Dekoration — ein grünes Licht über einem leeren Raum. Diese eine Beobachtung prägte alles Weitere: ein Fallback, den niemand wirklich aufruft, ist kein Fallback.

Warum ein Gateway das nicht löst

Der naheliegende Schritt ist ein lokales LLM-Gateway — Bifrost, LiteLLM — mit Anbieter-Failover in der Konfiguration. Eines läuft bei mir bereits (CLIProxyAPI), ein zweites hätte nur einen weiteren Daemon zum Betreuen gebracht.

Wichtiger: Ein Gateway scheitert an der eigentlichen Aufgabe. Failover auf API-Ebene bedeutet, das Modell unter einem Client auszutauschen. Schickt man Claude Code eine Gemini-Antwort durch eine Übersetzungsschicht, zerbricht es an Tool-Use und Streaming. Der Fallback muss das CLI wechseln, nicht das Modell dahinter. Das ist Aufgabe eines Launchers, nicht eines Proxys.

Sechs Stufen, zwei Achsen

Anbieter und Modell fallen unabhängig voneinander aus, deshalb verschränkt die Leiter beides. Jede Stufe bringt etwas Bestimmtes, und der Preis des Abstiegs steht explizit da:

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

Im Code ist eine Stufe ein Tripel: Backend, Modell zum Proben, Modell zum Ausführen. Das leere Ausführungsmodell auf der obersten Stufe ist wichtiger als es aussieht: kein --model zu übergeben bewahrt das opus[1m] des Profils samt 1M-Kontext. Das Modell explizit zu benennen würde das bei jedem Start still auf einfaches Opus kürzen.

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||"
)

Ist die oberste Stufe nicht verfügbar, wird der Abstieg angesagt statt verschwiegen — eine degradierte Sitzung darf nie für eine normale gehalten werden:

$ 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.

Modelllisten lügen

Der erste Reflex ist eine billige Probe: das Gateway nach /v1/models fragen und prüfen, ob das Modell dabei ist. Das ist wertlos. Mein Proxy führte gemini-* die ganze Zeit fröhlich auf, während jeder Aufruf auth_unavailable zurückgab. Das Antigravity-CLI verhält sich genauso — agy models antwortet aus einem Katalog, nicht aus einer funktionierenden Sitzung.

Dasselbe gilt für den Modellzustand. Kein Listen-Endpunkt verrät, dass Opus gerade im Rate-Limit steckt. Das zeigt nur eine echte Anfrage — ein 429 oder 529. Also macht jede Probe genau das:

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
}

Die Modellverfügbarkeit der offiziellen Stufen wird vom Proxy geliehen, der dasselbe Anthropic-Konto bedient — ein dort gedeckeltes Modell ist auch im offiziellen CLI gedeckelt. Der gesamte Preflight wurde bei 7 Tokens und rund 1,3 Sekunden gemessen: ein kostenloser unauthentifizierter 401-Check plus ein Ein-Token-Aufruf, wobei das Opus-Ergebnis gecacht wird.

Zwei Netze, nicht eines

Eine Preflight-Probe fängt nicht alles. Läuft das Kontingent zwischen Probe und Start aus, hatte die Probe recht und war trotzdem nutzlos. Deshalb gibt es ein zweites Netz: Exit-Code plus verstrichene Zeit.

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"

Die 25-Sekunden-Schwelle kodiert ein Urteil. Eine Stufe, die fast sofort stirbt, ist nie wirklich gestartet — schlechte Auth, gedeckeltes Modell — weiterzugehen ist also sicher. Eine Stufe, die minutenlang lief und dann scheiterte, hat echt gearbeitet, und diese Arbeit still bei einem anderen Anbieter zu wiederholen wäre schlimmer, als den Fehler zu zeigen.

Der Watchdog und die Falle darin

Der Launcher läuft nur, wenn ich ihn starte, also kann eine Stufe zwischen Sitzungen verrotten. Ein Check in meinem bestehenden Health-Droid (launchd, alle zwei Stunden, Telegram-Alerts) ruft denselben Probe-Code auf. Meine erste Version hatte genau den Bug, um den es hier geht: Sie meldete Erfolg bei der ersten grünen Stufe.

# 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

Das ist falsch, denn die beiden Nicht-Anthropic-Stufen halten getrennte Tokens — das Antigravity-CLI ein eigenes, die Gemini-Stufe das des Proxys. Eine kann verrotten, während die andere perfekt aussieht, und der Abbruch beim ersten Erfolg würde das genauso verbergen wie damals den fünfmonatigen Ausfall. Deshalb prüft der Check jede Stufe und wertet DEGRADED ebenfalls als Fehler: Frühwarnung, solange noch Reserve da ist.

Drei Fehler, die man sich notieren sollte

Jeder davon erzeugte ein grünes Signal über kaputter Mechanik — derselbe Fehlermodus, dreimal an einem Nachmittag:

  • Die Probe testete ein Modell, das ich nie nutze. Ich probte gemini-3.1-pro-preview, während das CLI auf gemini-3.1-pro-low konfiguriert war. Beide lösen zu verschiedenen Anbietern auf, also diagnostizierte ich ein „abgelaufenes Token“, das es nie gab — tatsächlich war ein Anbieter schlicht nicht eingeloggt. Eine Probe muss exakt den Pfad des echten Clients gehen.
  • Eine still leere Variable. Das Parsen des Proxy-Schlüssels mit awk und \x27 für das Anführungszeichen ergab unter BSD awk einen leeren String — ohne Fehler, ohne Warnung. Der Check bestand trotzdem, weil die geprüfte Stufe keinen Schlüssel brauchte. Behoben durch awk -v q="'".
  • Die Probe bestand, während die Realität scheiterte. Eine curl-Probe gegen den Gemini-Endpunkt lieferte 200, doch das echte gemini -p startete nicht: Der Headless-Modus blockiert bei nicht vertrauenswürdigen Verzeichnissen. Die Stufe war genau in der Situation kaputt, für die sie existiert.
Das Muster hinter allen dreien: eine Prüfung, die nicht das Echte tut, wird irgendwann eine Prüfung, die lügt. Billige Proben sind verführerisch, weil sie schnell sind und meist mit der Realität übereinstimmen — genau bis zu dem Moment, in dem sie es nicht sollen.

Benutzung

agent tippen liefert die beste verfügbare Stufe mit der gewohnten Claude-Code-Erfahrung. Explizite Auswahl überspringt das Proben komplett — wer Gemini verlangt, bekommt 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

Fazit

Die Technik hier ist unspektakulär: ein Bash-Skript, ein paar curl-Aufrufe, ein geordnetes Array. Aufwand kostete das Misstrauen gegen die eigenen Prüfungen. Dreimal baute ich etwas, das Erfolg meldete, während darunter alles kaputt war — und jedes Mal war die billige, schnelle Probe schuld.

Resilienz ist nicht die Liste konfigurierter Backends. Sie ist die Frage, ob irgendetwas überprüft, dass die Liste noch stimmt. Meine hatte auf dem Papier sechs Stufen und fünf Monate lang eine echte.

Quellen

Einen Fehler entdeckt?

Ein falscher Fakt, eine schiefe Übersetzung, etwas, das in diesem Artikel falsch klingt? Schreib mir — in deiner eigenen Sprache.