Repository navigation
feat(session): add configurable fallback model chain - #27939
loss-and-quick wants to merge 11 commits into
Conversation
Signed-off-by: minicx <minicx@disroot.org>
Signed-off-by: minicx <minicx@disroot.org>
|
The following comment was made by an LLM, it may be inaccurate: Based on my search, I found one related PR that should be noted: Related PR:
Other potentially relevant PRs (not duplicates):
The current PR (#27939) is a continuation/improvement of #26292, not a duplicate. It's the successor that fixes issues found in production use of the original feature. |
|
@nexxeln, Can you please review? |
Fallback notices (e.g. 'Using GLM-4.5-Air while ... is cooling down') are stored with ignored: true but were not filtered out when converting assistant parts to model messages, causing them to leak into the LLM context and be repeated in responses.
SessionRetry's built-in heuristics only catch English-language quota /
rate-limit messages (e.g. "Overloaded", "weekly", 429, 5xx). Providers
that return localised quota errors (Zhipu/DeepSeek-style "用户额度不足",
custom "balance" / "payment-required" responses, 402 / 403 / 451
statuses) slip through and the session fails instead of falling back.
Adds opt-in extension points to opencode config:
{
"fallbacks": ["..."],
"fallback_on_errors": {
"patterns": ["额度不足", "insufficient.*balance"],
"status_codes": [402, 403]
}
}
- `patterns` is matched against error.data.message + responseBody.
Tried first as a JS RegExp (case-insensitive), then as a plain
substring if the pattern is not a valid regex — invalid regex never
crashes classification.
- `status_codes` is matched against error.data.statusCode.
- User overrides win; if nothing matches, falls through to
SessionRetry's hardcoded heuristics (anthropic/openai/etc work
out-of-the-box).
`matchUserFallbackConfig` lives in fallback.ts and is unit-tested
against the actual Chinese quota string from a real session
(see fallback.test.ts).
|
Suggestion for title generation fallback: Currently, title fallback only works if users explicitly configure
Also, the title generation call site still uses Happy to help if you'd like these changes incorporated here. |
# Conflicts: # packages/opencode/src/session/llm.ts
Fallback chain injects text deltas with IDs like 'fallback-notice' when switching providers. These were passing through the title stream filter and appearing in generated session titles.
…ll models When users don't configure agent.title.fallbacks, title generation silently fails on provider errors. Now we automatically build a fallback chain from small models of all other available providers.
|
Automated PR Cleanup Thank you for contributing to opencode. Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions. This PR was closed because it matched the following cleanup criteria:
PRs created within the last month are not affected by this cleanup. If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate. Thanks again for taking the time to contribute. |
Agents can now configure a fallback model chain (agents.<name>.fallback in config, or the older top-level agent: format). When the selected model fails after RequestExecutor's transport retry budget is spent (429/5xx/529), or with a quota/transport/model-not-found failure, the V2 session runner switches to the next untried fallback and retries the turn -- rather than erroring the whole turn out to the user. Ported from real, working upstream implementations rather than written from scratch (per the standing "reuse upstream PR work" directive for this fork): anomalyco#35188 already targeted this repo's V2 session runner (unlike anomalyco#49125/anomalyco#26292/anomalyco#27939/anomalyco#42424, which patch the legacy V1 processor.ts our own AGENTS.md says new orchestration code must not bridge through) and is the primary source for the config schema shape and general approach. The retry-exhaustion classifier's names/shape come from anomalyco#49125; its logic reuses RequestExecutor's existing LLMError.retryable flag as the single source of truth for "is this retryable" instead of re-deriving a second status-code list. Model-switch attribution is recorded via the session's existing SessionEvent.ModelSwitched event rather than mutating an in-flight input object in place -- a lesson taken directly from anomalyco#27939's writeup of a real bug in its own V1-era approach. New packages/core/src/session/runner/fallback.ts (~40 lines: a classifier + a candidate resolver) and a new ContinueAfterFallback turn-transition in packages/core/src/session/runner/llm.ts, following the exact pattern already used for post-compaction turn continuation. Config plumbing threads the new field through packages/schema/src/ agent.ts, packages/core/src/config/agent.ts (+ plugin and v1 config variants). Regenerated the client and legacy JS SDKs. Known, honestly-scoped gaps: the switch is sticky for the rest of the session (no cooldown/return-to-primary); no mid-stream failover once assistant output has started; only errors marked retryable trigger a switch, so some in-stream provider errors won't while an equivalent HTTP status will; fallback is per-agent only, not per-model or global; a fallback model uses its default variant; no TUI/docs yet. New packages/core/test/session-runner-fallback.test.ts (9 tests) exercises real RequestExecutor retry counts against a scripted fake HTTP client -- exact call sequences, session projection correctness (model-switched message inserted, no leaked failed assistant message, correct final session model), chain-walking past an unresolvable model, non-retryable failures correctly not switching, and chain exhaustion still surfacing the real terminal error. Full packages/core suite: 1164 pass (only the pre-existing, unrelated pty flake). bun typecheck clean across all 30 packages. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Issue for this PR
Closes #7602
Related: refactors and extends the approach from #26292.
Type of change
What does this PR do?
Adds a configurable fallback chain: when the primary model returns a retryable error (rate limit, 5xx, overload, quota), the session switches to the next model in the chain instead of failing. The failed model is parked in a process-local cooldown so subsequent turns skip it until it recovers, then traffic flows back to primary on its own.
{ "model": "anthropic/claude-sonnet-4-20250514", "fallbacks": ["openai/gpt-4.1", "deepseek/deepseek-v4"], "cooldown_seconds": 300 }fallbackscan be set at the top level (applies to the main chat agent) or per-agent.cooldown_secondsdefaults to300. Quota errors (weekly / monthly / "exceeded your …") take a hardcoded 6h cooldown instead, andretry-after/retry-after-msheaders are honoured when the provider sends them.This is a continuation of #26292 with three correctness fixes that came up while running it:
Model attribution actually updates. The original patch communicated "we used a fallback" by mutating the
StreamInputobject, butllm.tsspreads that object on the way in (run({ ...input, abort })) so the mutation never reachedprompt.ts. As a result the DB kept the primary model,wasOnFallbackwas alwaysfalse, the "Switched to …" toast fired on every subsequent turn, and the model name under each assistant message didn't change.Fix:
withFallbackpublishes aFallbackUsedbus event instead.SessionProcessor.processsubscribes for the lifetime of one process call and updatesctx.assistantMessage.modelID/providerID+ publishesSessionEvent.Model.Updated. The subscription is released viaEffect.ensuring. This also makes the flow work for subsessions and title generation, which both go through the same processor.Hang after the first fallback. If every model in the chain was on cooldown,
pickStartreturned awaitdecision but the caller proceeded straight intodeps.call(primary)without actually waiting — on some providers (notably self-hosted) this hung on a kept-alive socket. Replaced with a realEffect.sleepbounded byWAIT_CAP_MS = 30s, then re-pick the start entry.Toast spam. The original patch fired both
FallbackTriggeredandFallbackUsedtoasts, and there is also an inline~> Switching to …notice in the message stream — three notifications for one fallback. Kept only theFallbackTriggeredwarning toast; dropped theFallbackUsedinfo toast since the inline notice already shows the same info with attribution to the specific message.A few smaller things:
sync-v2.tsxhandler forsession.next.model.updatedwas usingdraft.find(...)(first assistant in the session) — changed toactiveAssistant(draft) ?? [...].reverse().find(...)so it updates the current message.using/switch/resume) with different colours so it's obvious whether we're starting on a fallback because primary is cooling down, switched mid-stream after an error, or returning to primary after recovery.agent.fallbacksonly, not the top-levelfallbacks. The top-level list is sized for the main chat model and is typically too expensive for the small title pass; users who want title fallbacks configure them on the title agent.How did you verify your code works?
Screenshots / recordings
Checklist