Skip to content

fix(opencode): keep client-side content-filter recovery armed in server fallback mode - #146

Open
iceteaSA wants to merge 2 commits into
cortexkit:mainfrom
iceteaSA:fix/content-filter-backstop
Open

fix(opencode): keep client-side content-filter recovery armed in server fallback mode#146
iceteaSA wants to merge 2 commits into
cortexkit:mainfrom
iceteaSA:fix/content-filter-backstop

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Server-side safety fallback and the client-side content-filter recovery are currently mutually exclusive. fallbackMode defaults to server, and the client-side recovery is gated behind fallbackMode === 'legacy', so in the default configuration a refusal that Anthropic's server-side fallback does not absorb reaches the client as a fatal ContentFilterError with no downgrade path — and the session stays broken for every subsequent turn.

Hit live on a Fable 5 session today:

22:10:50  assistant  ContentFilterError "The response was blocked by the provider's content filter"
22:13:29  INFO [fable-fallback] Anthropic server-side safety fallback active … targetModel=claude-opus-4-8 handoff=true
22:17:15  assistant  ContentFilterError

No recovery line anywhere, and the 22:13 fallback never logged "ended". An earlier refusal in the same session (activeended in 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 malformed curl command).

Change

Removes the fallbackMode === 'legacy' gate from the four client-side recovery sites in index.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 and onContentFilter does not fire. The backstop only engages on the path that currently dies.

serverFallbackModel now derives from fablePlan?.effectiveModel ?? requestModel (and moved below the warm-chain re-plan so the plan is final). Without this, a downgraded turn would carry a claude-opus-4-8 body while still requesting server-side fallback for the original model.

The default mode and the OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE escape hatch are unchanged.

Tests

Three tests added, all in server (default) mode:

  • refusal with no server-side handoff activates the client-side downgrade — the wedge regression. Fails on main (both requests carry claude-fable-5), passes here.
  • an absorbed server-side fallback does not activate the client-side downgrade — no double-recovery.
  • a downgraded Opus request does not carry the server-side fallback opt-in.

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 on main: verified 0/3 pass on a pristine 41e9aee checkout with no commits from this branch, and 0/3 with them. Unrelated to this change.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with 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 in opencode.

  • Bug Fixes
    • Removed the fallbackMode === 'legacy' gate for warm-chain replan, downgraded-body rewrite, onContentFilter, and onComplete.
    • Derived serverFallbackModel from fablePlan?.effectiveModel ?? requestModel after re-plan, so downgraded claude-opus-4-8 requests don’t opt into server-side fallback.
    • Prevented double-recovery: absorbed server fallbacks are rewritten to normal completions, so client-side handlers do not fire.
    • Defaults unchanged: fallbackMode remains server; OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE still works.
    • Stripped fallbacks opt-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.

Review in cubic

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.

  • Removes legacy-mode gating from fallback planning and recovery callbacks.
  • Computes server fallback handling from the final effective request model.
  • Strips server fallback opt-in from source-model cache prewarms.
  • Adds regression coverage for unabsorbed refusals, absorbed server fallbacks, and downgraded requests.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/opencode/src/index.ts Enables client-side refusal recovery in server mode and aligns server fallback stream handling with the final planned request model.
packages/opencode/src/transform.ts Removes the server-side fallback body opt-in when constructing source-model cache prewarm requests.
packages/opencode/src/tests/index.test.ts Adds server-mode regression tests covering unabsorbed refusals, absorbed fallbacks, and downgraded request routing.
packages/opencode/src/tests/transform.test.ts Verifies that cache prewarm transformation removes fallback and fast-mode opt-ins.

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
    end
Loading

Reviews (2): Last reviewed commit: "fix(opencode): strip server-side fallbac..." | Re-trigger Greptile

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

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Loading

Re-trigger cubic

…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.
@iceteaSA

Copy link
Copy Markdown
Contributor Author

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-in

Correct, and it was a real defect introduced by this change. prepareFableCacheWarmSource rewrote model and deleted speed but left fallbacks: 'default' in the captured body, and selectClaudeCodeBetas re-adds server-side-fallback-2026-07-01 whenever that key is 'default' — so the source-model prewarm could itself be fallback-routed to Opus 4.8 and never warm the source cache. Harmless before this PR (the prewarm never ran in server mode); live once the recovery is armed there.

e8ebeae deletes body.fallbacks in the prewarm alongside body.speed, with a test asserting both the absent key and that selectClaudeCodeBetas no longer emits the beta. Red before, green after.

The incident is real: 5 ContentFilterError turns, all on the source model

The claim that the session shows zero content-filter finishes doesn't match the database. Assistant messages for ses_021c4e03…, UTC:

20:10:44  claude-fable-5  ok
20:10:50  claude-fable-5  ContentFilterError
20:13:05  claude-fable-5  ok
20:13:29  claude-fable-5  ok          ← "server-side safety fallback active" logged here
20:14:31  claude-fable-5  ok
20:16:51  claude-fable-5  ok
20:17:15  claude-fable-5  ContentFilterError
20:19:37  claude-fable-5  ContentFilterError
20:20:16  claude-fable-5  ContentFilterError
20:21:55  claude-fable-5  ContentFilterError

Five ContentFilterError assistant messages, four of them after the last active event, each carrying "The response was blocked by the provider's content filter". (Searching opencode's own log for the string finds only unrelated hits — my own tool-call arguments containing the identifier. The errors are persisted on the message rows, not in that log.)

The refusals are on Fable 5, not on the fallback target

The 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:

28 × claude-fable-5
69 × claude-opus-5
 0 × claude-opus-4-8

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 fallbacks: "default" and the server-side-fallback-2026-07-01 beta and returned HTTP 200; there were 6 signed fallback markers in the session, so the mechanism was live throughout, not misconfigured.

The clearest evidence that the server-side path is best-effort is within one session, 7 minutes apart, identical request shape: 20:06:27 active → 20:06:39 ended (absorbed, 12s) versus 20:13:29 active → never ended, followed by four refusals surfacing to the client.

On the architecture point

Fair 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 stop_reason: "refusal" never reaches the finish-state handler and the client-side hook cannot fire (there is a test for that). Happy to add a docs update to this PR describing the layering, or to split the doc change out, whichever you prefer.

Branch now at e8ebeae: opencode 1015 / core 69 / pi 57 pass, typecheck, build, lint, biome clean.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 because selectClaudeCodeBetas(body) does not read body.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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant