Skip to main content
Tillbaka till bloggen
Claude CodeAICLIBashAutomationResilience

En kaskad av agenter: terminalen väljer den som faktiskt lever

När Claude delvis låg nere och vissa modeller slutade svara försökte jag byta till Gemini — och upptäckte att min reserv varit trasig i månader. Så här bygger man en som faktiskt håller.

Publicerad 29 augusti 20269 min läsning

TL;DR

En bash-startare kallad agent som väljer den bästa fungerande AI-kodnings-CLI:n innan den startar. Den degraderar längs två axlar: först modellen (Opus → Sonnet, kvar inom Anthropic), sedan transporten och först därefter leverantören. Varje sond gör ett riktigt API-anrop, eftersom modellistor ljuger. En vakthund kontrollerar de nedre stegen varannan timme och larmar via 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

Problemet

När Claude är otillgänglig vill jag att min terminal fortsätter fungera. Den naiva varianten är ”om Claude ligger nere, kör Gemini” — och det trodde jag att jag hade. Men de intressanta felen är inte avbrott. Det vanligaste: Opus tar slut medan Sonnet svarar utmärkt. Att hoppa till en annan leverantör där är absurt.

Ett riktigt reservläge måste alltså degradera per modell innan det degraderar per leverantör. Och det måste veta vilket steg som faktiskt lever — vilket visade sig vara den svåra delen.

Fem månaders lögn

Innan jag skrev något kontrollerade jag vad jag redan hade. Proxyns auth-katalog berättade hela historien i två tidsstämplar:

claude-newiqa@gmail.com.json — förnyad idag, 06:41 · gemini-newiqa@gmail.com-….json — senast 27 mars

Claude-token förnyades dagligen. Gemini-token hade inte rört sig på fem månader. Min hälsokontroll tittade bara på claude-*.json, så ingen märkte något. Reserven hade varit dekorativ sedan våren — en grön lampa över ett tomt rum. Den enda observationen formade allt nedan: en reserv som ingen faktiskt anropar är ingen reserv.

Varför en gateway inte löser detta

Det uppenbara draget är en lokal LLM-gateway — Bifrost, LiteLLM — med leverantörsväxling i konfigurationen. Jag kör redan en (CLIProxyAPI), så en till hade bara gett ännu en daemon att sköta.

Viktigare: en gateway misslyckas med själva uppgiften. Växling på API-nivå betyder att byta modell under samma klient. Ge Claude Code ett Gemini-svar via ett översättningslager och den faller isär på tool-use och streaming. Reserven måste byta CLI, inte modellen bakom.

Sex steg, två axlar

Leverantör och modell fallerar oberoende, så stegen varvas. Varje steg ger något specifikt, och priset för att kliva ner är uttalat:

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

I koden är ett steg en trippel: backend, modell att sondera, modell att köra med. Den tomma körmodellen på översta steget betyder mer än den ser ut: att inte skicka --model bevarar profilens opus[1m] och dess 1M-kontext.

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

När det översta steget saknas annonseras nedstigningen i stället för att ske tyst — en degraderad session får aldrig misstas för en normal:

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

Modellistor ljuger

Första instinkten är att sondera billigt: fråga gatewayen efter /v1/models. Det är värdelöst. Min proxy annonserade glatt gemini-* hela tiden medan varje anrop gav auth_unavailable. Antigravity-CLI:n gör detsamma — agy models svarar från en katalog.

Detsamma gäller modellhälsa. Ingen listningsendpoint berättar att Opus är hastighetsbegränsad just nu. Bara en riktig förfrågan visar det — en 429 eller 529:

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
}

Modelltillgängligheten för de officiella stegen lånas från proxyn, som fronter samma Anthropic-konto. Hela förkontrollen mättes till 7 tokens och cirka 1,3 sekunder, med Opus-resultatet cachat.

Två skyddsnät, inte ett

En förkontroll fångar inte allt. Om kvoten tar slut i glappet mellan sond och start hade sonden rätt och var ändå värdelös. Därför finns ett andra nät: slutkod plus förfluten tid.

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"

Tröskeln på 25 sekunder kodar ett omdöme. Ett steg som dör nästan omedelbart startade aldrig på riktigt. Ett steg som körde i minuter och sedan misslyckades arbetade faktiskt, och att tyst köra om det arbetet hos en annan leverantör vore sämre än att visa felet.

Vakthunden och fällan i den

Startaren körs bara när jag kör den, så ett steg kan ruttna mellan sessioner. En kontroll inbyggd i min hälsodroid (launchd, varannan timme, Telegram-larm) anropar samma sondkod. Min första version hade precis den bugg artikeln handlar om: den returnerade framgång vid första gröna steget.

# 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

Det är fel, eftersom de två icke-Anthropic-stegen håller separata tokens — Antigravity-CLI:n sin egen, Gemini-steget proxyns. Ett kan ruttna medan det andra ser perfekt ut. Därför sonderar kontrollen alla steg och behandlar DEGRADED som ett fel.

Tre misstag värda att skriva ner

Vart och ett gav grön signal över trasigt maskineri — samma feltyp, tre gånger på en eftermiddag:

  • Sonden testade en modell jag aldrig använder. Jag sonderade gemini-3.1-pro-preview medan CLI:n var konfigurerad för gemini-3.1-pro-low. De löses till olika leverantörer, så jag diagnostiserade en ”utgången token” som aldrig fanns. En sond måste gå exakt den väg den riktiga klienten tar.
  • En tyst tom variabel. Att tolka proxyns YAML-nyckel med awk och \x27 gav tom sträng under BSD awk — utan fel eller varning. Kontrollen gick ändå igenom. Löstes med awk -v q="'".
  • Sonden gick igenom medan det riktiga föll. En curl-sond gav 200, men det riktiga gemini -p vägrade starta: headless-läget blockerar på icke betrodda kataloger. Steget var trasigt precis i den situation det finns för.
Mönstret bakom alla tre: en kontroll som inte gör den riktiga saken kommer till slut att vara en kontroll som ljuger. Billiga sonder lockar för att de är snabba och oftast stämmer med verkligheten — ända fram till stunden då man behöver att de avviker.

Användning

Att skriva agent ger bästa tillgängliga steg med den vanliga Claude Code-upplevelsen. Explicit val hoppar över sondering helt — ber du om Gemini får du 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

Slutsatser

Tekniken här är oanmärkningsvärd: ett bash-skript, några curl-anrop, en ordnad array. Det som krävde arbete var misstron mot mina egna kontroller. Tre gånger byggde jag något som rapporterade framgång medan det undertill var trasigt — och varje gång var den billiga, snabba sonden boven.

Motståndskraft är inte listan av backends du konfigurerat. Det är huruvida något verifierar att listan fortfarande stämmer. Min hade sex steg på pappret och ett riktigt i fem månader.

Källor

Hittade du ett fel?

Ett felaktigt faktum, en skev översättning, något som känns falskt i den här artikeln? Skriv till mig — på ditt eget språk.