fix(opencode): keep client-side content-filter recovery armed in server fallback mode - #146
Conversation
… server fallback mode v1.19.0 gated the client-side Fable/Opus refusal recovery behind fallbackMode === 'legacy', leaving server mode with no recovery path when Anthropic does not absorb the refusal. Arm the recovery in both modes: server-side fallback remains the first line; the client-side downgrade is now a backstop for unabsorbed refusals. Derive serverFallbackModel from the effective model (fablePlan post-downgrade) instead of the original body so a downgraded claude-opus-4-8 request never opts into server-side fallback.
There was a problem hiding this comment.
No issues found across 2 files
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
Architecture diagram
sequenceDiagram
participant Client as OpenCode Client
participant Plugin as Anthropic Auth Plugin
participant FableP as Fable Fallback Manager
participant StreamR as Stream Rewriter
participant Anthropic as Anthropic API
Note over Client,Anthropic: Request Flow with Dual Recovery (Server + Client)
Client->>Plugin: POST /v1/messages (model=claude-fable-5)
Plugin->>FableP: plan(sessionId, body)
FableP-->>Plugin: fablePlan (downgraded=false)
Plugin->>Plugin: Compute serverFallbackModel from fablePlan.effectiveModel
Plugin->>Anthropic: POST /v1/messages + server-side fallback opt-in
alt Server Absorbs Refusal (Happy Path)
Anthropic-->>StreamR: SSE with fallback handoff + normal completion
StreamR-->>StreamR: Rewrite refusal → normal stream
StreamR-->>Client: Normal completions (onContentFilter NOT fired)
Note over StreamR,Client: Server-side fallback absorbed, no double-recovery
else Refusal NOT Absorbed (Failure Path — The Bug)
Anthropic-->>StreamR: SSE with stop_reason: "refusal"
StreamR->>StreamR: onContentFilter fires (no fallbackMode gate)
StreamR-->>Client: ContentFilterError (retryable)
Note over Client,Plugin: Client-side downgrade activates
Client->>Plugin: Retry (same session)
Plugin->>FableP: plan(sessionId, body)
FableP-->>Plugin: fablePlan (downgraded=true, effectiveModel=claude-opus-4-8)
Plugin->>Plugin: Rewrite body with downgraded model
Plugin->>Anthropic: POST /v1/messages (model=claude-opus-4-8, NO server fallback opt-in)
Note over Plugin,Anthropic: CHANGED: serverFallbackModel=undefined for downgraded
Anthropic-->>Client: Normal completion
alt onComplete fires for downgraded
Plugin->>FableP: complete(sessionId, plan)
end
end
Note over Plugin,Anthropic: Warm-chain recovery (pre-request)
Plugin->>FableP: plan(sessionId, body)
opt fablePlan exists and not downgraded
Plugin->>Plugin: Check recoveryWarmChains for pending recovery
alt Warm chain found
Plugin->>Plugin: await finalWarm (waits for recovery)
Plugin->>FableP: plan(sessionId, body) — re-plan after recovery
FableP-->>Plugin: Updated fablePlan (now downgraded)
end
end
Plugin->>Plugin: Compute serverFallbackModel from final plan
…che prewarm After 69c286b armed the client-side recovery in server mode, prepareFableCacheWarmSource could inherit fallbacks: 'default' from the captured request body. That caused the prewarm (which is meant to reach the source model — Fable 5 or Opus 5) to itself carry the server-side-fallback beta, risking fallback routing and defeating the cache warm. Delete body.fallbacks alongside the existing body.speed removal so the prewarm request never opts into server-side fallback routing.
|
A reviewer raised three objections. One was correct and is now fixed; the other two I can refute with the request dumps and the session DB. Detail below so it's checkable rather than asserted. Fixed: the prewarm inherited the fallback opt-inCorrect, and it was a real defect introduced by this change.
The incident is real: 5 ContentFilterError turns, all on the source modelThe claim that the session shows zero content-filter finishes doesn't match the database. Assistant messages for Five The refusals are on Fable 5, not on the fallback targetThe objection that the test doesn't reproduce the incident rests on Opus 4.8 having been the active model that then refused. The request dumps say otherwise — every wire request in that session across the window: Opus 4.8 was never sent. So the refusals are source-model refusals arriving with the fallback opt-in accepted but not acted on — which is exactly what the test exercises, and it is not a retry of a model that just refused. Each of those requests carried The clearest evidence that the server-side path is best-effort is within one session, 7 minutes apart, identical request shape: On the architecture pointFair that this changes documented rollback modes into stacked policies, and I should have said so explicitly. The reasoning: server-side fallback is best-effort by nature, so treating it as a replacement for client-side recovery leaves the unabsorbed path with no handler at all. The stacking is narrow — when the server-side path absorbs a refusal the rewriter converts it to a normal completion, so Branch now at |
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Confidence score: 4/5
- In
packages/opencode/src/tests/transform.test.ts, the assertion about prewarm never triggering Anthropic fallback routing is not actually exercised becauseselectClaudeCodeBetas(body)does not readbody.fallbacks; this can let a routing regression slip through while tests still pass — update the test to drive and verify fallback-related behavior explicitly (or align the claim to what the function truly covers).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/opencode/src/tests/transform.test.ts">
<violation number="1" location="packages/opencode/src/tests/transform.test.ts:1004">
P2: The test's core claim — that the prewarm "never triggers Anthropic fallback routing" — isn't actually validated. `selectClaudeCodeBetas(body)` never looks at `body.fallbacks`: it only assembles the base/structured/full-agent betas plus the fast-mode beta (from `speed`), and appends whichever betas are passed as `extraBetas` by the caller. The server-side-fallback beta is added by `setOAuthHeaders` (which maps `body.fallbacks === 'default'` into the `SERVER_SIDE_FALLBACK_BETA` via `extraBetas`), not by `selectClaudeCodeBetas` itself. So this assertion would pass even if the `delete body.fallbacks` line were removed — it never exercises the real opt-in path. The only assertion that actually reflects the change is `expect(body.fallbacks).toBeUndefined()`. Consider calling `setOAuthHeaders` (or `selectClaudeCodeBetas(body, [SERVER_SIDE_FALLBACK_BETA])` with the extraBetas the plugin injects) to make the test meaningful.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| const body = JSON.parse(source.bodyText) | ||
| expect(body.fallbacks).toBeUndefined() | ||
| expect(body.speed).toBeUndefined() | ||
| expect(selectClaudeCodeBetas(body).split(',')).not.toContain( |
There was a problem hiding this comment.
P2: The test's core claim — that the prewarm "never triggers Anthropic fallback routing" — isn't actually validated. selectClaudeCodeBetas(body) never looks at body.fallbacks: it only assembles the base/structured/full-agent betas plus the fast-mode beta (from speed), and appends whichever betas are passed as extraBetas by the caller. The server-side-fallback beta is added by setOAuthHeaders (which maps body.fallbacks === 'default' into the SERVER_SIDE_FALLBACK_BETA via extraBetas), not by selectClaudeCodeBetas itself. So this assertion would pass even if the delete body.fallbacks line were removed — it never exercises the real opt-in path. The only assertion that actually reflects the change is expect(body.fallbacks).toBeUndefined(). Consider calling setOAuthHeaders (or selectClaudeCodeBetas(body, [SERVER_SIDE_FALLBACK_BETA]) with the extraBetas the plugin injects) to make the test meaningful.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/tests/transform.test.ts, line 1004:
<comment>The test's core claim — that the prewarm "never triggers Anthropic fallback routing" — isn't actually validated. `selectClaudeCodeBetas(body)` never looks at `body.fallbacks`: it only assembles the base/structured/full-agent betas plus the fast-mode beta (from `speed`), and appends whichever betas are passed as `extraBetas` by the caller. The server-side-fallback beta is added by `setOAuthHeaders` (which maps `body.fallbacks === 'default'` into the `SERVER_SIDE_FALLBACK_BETA` via `extraBetas`), not by `selectClaudeCodeBetas` itself. So this assertion would pass even if the `delete body.fallbacks` line were removed — it never exercises the real opt-in path. The only assertion that actually reflects the change is `expect(body.fallbacks).toBeUndefined()`. Consider calling `setOAuthHeaders` (or `selectClaudeCodeBetas(body, [SERVER_SIDE_FALLBACK_BETA])` with the extraBetas the plugin injects) to make the test meaningful.</comment>
<file context>
@@ -984,6 +985,26 @@ describe('prepareFableCacheWarmSource', () => {
+ const body = JSON.parse(source.bodyText)
+ expect(body.fallbacks).toBeUndefined()
+ expect(body.speed).toBeUndefined()
+ expect(selectClaudeCodeBetas(body).split(',')).not.toContain(
+ 'server-side-fallback-2026-07-01',
+ )
</file context>
Server-side safety fallback and the client-side content-filter recovery are currently mutually exclusive.
fallbackModedefaults toserver, and the client-side recovery is gated behindfallbackMode === 'legacy', so in the default configuration a refusal that Anthropic's server-side fallback does not absorb reaches the client as a fatalContentFilterErrorwith no downgrade path — and the session stays broken for every subsequent turn.Hit live on a Fable 5 session today:
No recovery line anywhere, and the 22:13 fallback never logged "ended". An earlier refusal in the same session (
active→endedin 12s) shows the server-side path working when Anthropic does absorb the refusal — the failure is specifically the unabsorbed path. Both refusals were triggered by benign content (a documentation URL, and a malformedcurlcommand).Change
Removes the
fallbackMode === 'legacy'gate from the four client-side recovery sites inindex.ts(warm-chain re-plan, downgraded-body rewrite,onContentFilter,onComplete), making server-side fallback the first line and the client-side downgrade a backstop rather than an alternative.This does not double-recover: when server-side fallback absorbs a refusal, the stream rewriter turns it into a normal completion, so
stop_reason: "refusal"never reaches the finish-state handler andonContentFilterdoes not fire. The backstop only engages on the path that currently dies.serverFallbackModelnow derives fromfablePlan?.effectiveModel ?? requestModel(and moved below the warm-chain re-plan so the plan is final). Without this, a downgraded turn would carry aclaude-opus-4-8body while still requesting server-side fallback for the original model.The default mode and the
OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODEescape hatch are unchanged.Tests
Three tests added, all in server (default) mode:
main(both requests carryclaude-fable-5), passes here.Verification
typecheck, build, lint, biome check clean. opencode 1014 / core 69 / pi 57 pass.
e2e is 25 pass / 1 fail —
tool-prefix.test.ts:301"bridges back to a stale Opus cache after more than 20 Fable blocks", which is pre-existing onmain: verified 0/3 pass on a pristine41e9aeecheckout with no commits from this branch, and 0/3 with them. Unrelated to this change.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Summary by cubic
Keeps client-side content-filter recovery armed in server fallback mode so unabsorbed refusals downgrade instead of throwing a fatal
ContentFilterError. Also prevents cache prewarm requests from opting into server-side fallback routing inopencode.fallbackMode === 'legacy'gate for warm-chain replan, downgraded-body rewrite,onContentFilter, andonComplete.serverFallbackModelfromfablePlan?.effectiveModel ?? requestModelafter re-plan, so downgradedclaude-opus-4-8requests don’t opt into server-side fallback.fallbackModeremainsserver;OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODEstill works.fallbacksopt-in from source-model cache prewarm so warm requests reach the source model and don’t trigger server-side fallback routing.Written for commit e8ebeae. Summary will update on new commits.
Greptile Summary
The PR keeps client-side content-filter recovery active as a backstop in server fallback mode while ensuring downgraded and prewarm requests use routing options appropriate to their effective models.
Confidence Score: 5/5
The PR appears safe to merge.
No blocking failure remains.
Important Files Changed
Sequence Diagram
sequenceDiagram participant Client participant Plugin participant Anthropic Client->>Plugin: Fable request Plugin->>Anthropic: Fable request with server fallback enabled alt Server fallback absorbs refusal Anthropic-->>Plugin: Normal rewritten completion Plugin-->>Client: Successful response else Refusal remains visible Anthropic-->>Plugin: refusal Plugin->>Plugin: Activate client-side downgrade Plugin-->>Client: ContentFilterError for current stream Client->>Plugin: Next request Plugin->>Anthropic: Opus request without server fallback opt-in Anthropic-->>Plugin: Completion Plugin-->>Client: Successful response endReviews (2): Last reviewed commit: "fix(opencode): strip server-side fallbac..." | Re-trigger Greptile