Repository navigation
feat(opencode): fall back to a configured sibling model when a provider is down - #42
Merged
Merged
Conversation
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Do not merge. Opened so we can look at the actual size of doing model fallback inside opencode instead of around it.
What this does
When a step's LLM call fails with a status the coordinator lists (or never reaches a provider at all), and the same-provider retries are spent, the step swaps to the mapped sibling model and continues. The failing provider is marked degraded for the configured cooldown, so later steps in the same sandbox start on the healthy route instead of paying the retries again. The assistant message is updated to name the model that actually answered.
Config comes from
REPLO_OPENCODE_FALLBACK_CONFIG, which the coordinator already injects into every sandbox (apps/cloudflare-agent-coordinator/src/lib/opencode-fallback-config.tsin andytown). Nothing in this fork read it before. No env var means no behaviour change.Also fixes a caching bug seen in prod:
applyCachingmarked an empty trailing text part withcache_control, which Anthropic rejects (cache_control cannot be set for empty text blocks). An empty tail now falls through to the message-level marker.Shape of the change
src/session/fallback.ts(new)qualifies(error),next(), degraded-provider map with cooldown,healthy()chain walk with cycle guardsrc/session/retry.tspolicy()gains a provider getter, an optional same-provider attempt cap, and afallbackhook that runs once retries are exhausted or the error is non-retryablesrc/session/processor.tsProvider.Service, streams withctx.modelinstead of the input model, rewritesproviderID/modelIDon the assistant message, logs[model-fallback]src/session/prompt.tsSessionFallback.healthy()beforegetModelsrc/provider/transform.tstest/session/fallback.test.ts(config, qualification, cooldown, cycle), a retry-policy handoff test, and an end-to-end processor test: two 503s from the test LLM server, third call answered by the fallback provider, message stampedfallback/fallback-model,testmarked degradedWhat happens when both routes are down
The coordinator map is bidirectional for the Claude transport pair (
openrouter/anthropic/claude-sonnet-5<->anthropic/claude-sonnet-5). That cannot loop:maxFallbackAttempts(2 in the coordinator config). With both routes down the sequence is: OpenRouter fails, 3 same-route retries at 2 s / 4 s / 8 s, swap to direct Anthropic, 3 retries, swap back to OpenRouter (second and last swap), 3 retries, then the error surfaces as an ordinary failed turn. Roughly 45 s of waiting, then it stops. Set the cap to 1 to skip the bounce back.Both cases are covered by
test/session/fallback.test.ts(cycle) and the retry-policy test (swap cap, fresh budget after a swap).Decisions worth a look
maxFallbackAttempts); the cooldown is module state so the next step skips the dead provider. It is process memory, so a sandbox restart forgets it, which is fine.fallbackOnErrors, whether they arrive as an HTTP status or as OpenRouter's in-stream{code}chunk, plus retryable errors with no status (fetch failed, connection reset). Invalid prompts, quota errors, context overflow and aborts never swap. 402 is not special-cased; the coordinator lists it (replohq/andytown#27790).maxUpstreamRetryAttemptsis unset, so upstream opencode semantics are unchanged without our env var.Partial output from the failed route
Reasoning and text parts a failed attempt already wrote are removed from the assistant message when the step switches models, so the fallback never replays another model's signed reasoning as its own. Tool parts stay, since they record effects that really happened. Covered by a stubbed-provider test that streams partial output and then fails with OpenRouter's in-stream
{code: 503}.Verification
bun run typecheckclean.bun test test/session/fallback.test.ts test/session/retry.test.ts test/session/processor-effect.test.ts: 63 pass.bun test test/provider/transform.test.ts: 296 pass, including a wire-level check that the OpenRouter provider never serializescache_controlon an empty text block.bun test test/provider: one pre-existing failure (chunkTimeout raises a response stream error when SSE body stalls) that fails identically ondev.Live run on a sandbox (2026-09-16)
Ran on one of our own internal agent sandboxes with this branch's linux-x64 build swapped in for the fleet binary. OpenRouter traffic was routed through a local stub returning 503, and the fallback map pointed the primary route at direct Anthropic. Prompts were sent straight to opencode's HTTP API on the box.
s6-svcrestart to/global/health200[model-fallback] switched model, real completion from direct Anthropic, 19 s total (nearly all of it the three retries the coordinator config asks for)providerID/modelIDname the model that answered, on both turnsThe box was restored afterwards: fleet binary, coordinator env, stub removed.
Review round 1 (Codex, 2026-09-09)
All four findings applied in 19e6164:
provider/modelroute now, so the coordinator's Claude to GPT to Gemini chain on OpenRouter walks correctly. Covered by a test using those exact routes.Follow-ups in andytown (not here)
fallbackOnErrors, the eval opt-out, and the pin bump to this release.🤖 Generated with Claude Code
Summary by cubic
Adds a configured sibling-model fallback inside session retries when a provider fails, and fixes two prompt-caching bugs that Anthropic and OpenRouter reject. Reads
REPLO_OPENCODE_FALLBACK_CONFIG; without it, behavior is unchanged.Fallback
Caching
Written for commit f4f5b69. Summary will update on new commits.