Каскад агентов: терминал сам выбирает того, кто сейчас работает
Когда Claude частично отказал и часть моделей перестала работать, я попробовал перейти на Gemini — и обнаружил, что мой фоллбэк давно сломан. Вот как сделать тот, который действительно выдержит.
TL;DR
Bash-лаунчер agent, который перед стартом выбирает лучший работающий AI CLI. Он деградирует по двум осям: сначала модель (Opus → Sonnet, оставаясь внутри Anthropic), затем транспорт, и только потом вендор. Каждая проба делает реальный вызов, потому что списки моделей врут. Watchdog проверяет нижние ступени каждые два часа и шлёт алерт в 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Проблема
Когда Claude недоступен, я хочу, чтобы терминал продолжал работать. Наивная версия — «если Claude лёг, запусти Gemini», и я думал, что именно это у меня настроено. Но интересные сбои — не аварии. Самый частый: Opus исчерпан, а Sonnet отвечает нормально. Прыгать к другому вендору здесь абсурдно — соседняя модель жива.
Значит, настоящий фоллбэк должен деградировать по модели раньше, чем по провайдеру. И он должен знать, какая ступень действительно жива — это оказалось сложной частью, и здесь я ошибся дважды.
Пятимесячная ложь
Прежде чем что-то писать, я проверил то, что уже было. Каталог авторизации прокси рассказал всю историю двумя датами:
claude-newiqa@gmail.com.json — обновлён сегодня, 06:41 · gemini-newiqa@gmail.com-….json — последний раз 27 марта
Claude-токен обновлялся ежедневно. Gemini-токен не двигался пять месяцев. Мой health-check смотрел только на claude-*.json, поэтому никто ничего не заметил. Фоллбэк был декоративным с весны — зелёная лампочка над пустой комнатой. Именно это наблюдение и сформировало всё дальнейшее: фоллбэк, который никто по-настоящему не вызывает, — не фоллбэк.
Почему gateway это не решает
Очевидный ход — локальный LLM-gateway вроде Bifrost или LiteLLM с failover в конфиге. Один у меня уже работает (CLIProxyAPI), так что второй дал бы лишь ещё одного демона, за которым нужно следить.
Важнее другое: gateway не решает саму задачу. Failover на уровне API означает подмену модели под тем же клиентом. Скорми Claude Code ответ Gemini через слой трансляции — и он рассыплется на tool-use и стриминге. Фоллбэк должен переключать CLI, а не модель под ним.
Шесть ступеней, две оси
Провайдер и модель отказывают независимо, поэтому лестница их чередует. Каждая ступень даёт что-то конкретное, и цена спуска названа явно:
| Rung | What it survives | Cost of getting there |
|---|---|---|
| claude + opus | nothing — this is the happy path | — |
| claude + sonnet | Opus capped or overloaded | weaker model, same session shape |
| claude-proxy + opus | stale local login, broken CLI auth | separate OAuth token |
| claude-proxy + sonnet | both of the above at once | weaker model via proxy |
| agy | Anthropic outage / exhausted plan | different vendor, different CLI |
| gemini | agy itself broken | different CLI again, shares agy's quota |
В коде ступень — это тройка: бэкенд, модель для пробы, модель для запуска. Пустая модель запуска на верхней ступени важнее, чем кажется: не передавать --model значит сохранить opus[1m] из профиля и его 1M контекста. Явное имя модели тихо срезало бы это до обычного Opus при каждом запуске.
# 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||"
)Когда верхняя ступень недоступна, спуск объявляется, а не происходит молча — деградированную сессию нельзя спутать с обычной:
$ 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.Списки моделей врут
Первый инстинкт — пробовать дёшево: спросить у gateway /v1/models и проверить, что модель на месте. Это бесполезно. Мой прокси бодро отдавал gemini-* всё то время, пока каждый вызов к ним возвращал auth_unavailable. Antigravity CLI ведёт себя так же — agy models отвечает из каталога, а не из рабочей сессии.
То же со состоянием модели. Ни один эндпоинт со списком не скажет, что Opus сейчас под лимитом. Это показывает только реальный запрос — 429 или 529. Поэтому каждая проба его делает:
# 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
}Доступность моделей для официальных ступеней заимствуется у прокси, который фронтит тот же аккаунт Anthropic — исчерпанная там модель исчерпана и в официальном CLI. Весь preflight замерен на 7 токенов и около 1,3 секунды, причём результат Opus кешируется.
Две сетки, а не одна
Preflight ловит не всё. Если квота кончилась в промежутке между пробой и запуском, проба была права и всё равно бесполезна. Поэтому есть вторая сетка: код выхода плюс время работы.
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"Порог в 25 секунд кодирует суждение. Ступень, умершая почти сразу, по-настоящему и не стартовала — плохая авторизация, исчерпанная модель — значит идти дальше безопасно. Ступень, проработавшая минуты и затем упавшая, действительно работала, и молча перегонять эту работу другому вендору было бы хуже, чем показать ошибку.
Watchdog и ловушка внутри него
Лаунчер работает только когда я его запускаю, поэтому ступень может сгнить между сессиями. Проверка, встроенная в мой health-дроид (launchd, каждые два часа, алерты в Telegram), вызывает тот же код проб. В первой версии был ровно тот баг, о котором вся статья: она возвращала успех на первой зелёной ступени.
# 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Это неверно, потому что две не-Anthropic ступени держат разные токены — у Antigravity CLI свой, а Gemini-ступень пользуется проксёвым. Одна может сгнить, пока вторая выглядит идеально. Поэтому проверка проходит все ступени и считает DEGRADED тоже ошибкой: раннее предупреждение, пока запас ещё есть.
Три ошибки, которые стоит выписать
Каждая дала зелёный сигнал над сломанным механизмом — один и тот же тип отказа, трижды за день:
- Проба проверяла модель, которой я не пользуюсь. Я пробовал
gemini-3.1-pro-preview, тогда как CLI был настроен наgemini-3.1-pro-low. Они резолвятся в разных провайдеров, и я диагностировал «протухший токен», которого не существовало. Проба должна проходить тот же путь, что и реальный клиент. - Тихо пустая переменная. Разбор YAML-ключа прокси через
awkи\x27для кавычки давал пустую строку в BSD awk — без ошибки и предупреждения. Проверка всё равно проходила, потому что тестируемой ступени ключ был не нужен. Чинится передачей кавычки черезawk -v q="'". - Проба проходила, а реальный запуск падал.
curl-проба к Gemini-эндпоинту возвращала 200, но настоящийgemini -pотказывался стартовать: headless-режим блокируется на недоверенных каталогах. Ступень была сломана именно в той ситуации, ради которой существует.
Как этим пользоваться
Набираешь agent — получаешь лучшую доступную ступень с привычным Claude Code. Явный выбор вообще пропускает пробы: попросил Gemini — получил 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Выводы
Инженерия здесь заурядная: bash-скрипт, несколько curl-вызовов, упорядоченный массив. Усилий потребовало недоверие к собственным проверкам. Трижды я построил нечто, рапортующее об успехе, пока под ним всё было сломано.
Устойчивость — это не список настроенных бэкендов. Это наличие того, кто проверяет, что список всё ещё верен. У меня на бумаге было шесть ступеней и одна настоящая в течение пяти месяцев.
Источники
- Claude Code Documentation — headless mode,
ANTHROPIC_BASE_URL, model flags - CLIProxyAPI — the local multi-provider gateway behind the proxy rungs
- Gemini CLI — the last rung, and its trusted-folders behaviour
- Apple Developer — launchd jobs, used by the watchdog
Заметили ошибку?
Неверный факт, кривой перевод, что-то звучит неправдой в этой статье? Напишите мне — на своём языке.