From 69c286b4588fdc9c2ed9f343384dc845e9c4c77c Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:35:48 +0200 Subject: [PATCH 1/2] fix(opencode): arm client-side content-filter recovery as backstop in 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. --- packages/opencode/src/index.ts | 28 +-- packages/opencode/src/tests/index.test.ts | 272 ++++++++++++++++++++++ 2 files changed, 283 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 0ebbcc0..0ecce96 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -4143,27 +4143,25 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { incomingHeaders.get('x-session-affinity') || incomingHeaders.get('x-opencode-session') const requestModel = parseRequestModel(init?.body) - const serverFallbackModel = - fallbackMode === 'server' && - isRecoverableRefusalModel(requestModel) - ? requestModel - : undefined let fablePlan = fableFallbackManager.plan(sessionId, init?.body) - if ( - fallbackMode === 'legacy' && - fablePlan && - !fablePlan.downgraded - ) { + if (fablePlan && !fablePlan.downgraded) { const finalWarm = recoveryWarmChains.get(fablePlan.recoveryKey) if (finalWarm) { await finalWarm fablePlan = fableFallbackManager.plan(sessionId, init?.body) } } + const serverFallbackModel = + fallbackMode === 'server' && + isRecoverableRefusalModel( + fablePlan?.effectiveModel ?? requestModel, + ) + ? (fablePlan?.effectiveModel ?? requestModel) + : undefined const fableRequest: FableRequestContext | undefined = fablePlan ? { plan: fablePlan } : undefined - if (fallbackMode === 'legacy' && fablePlan?.downgraded) { + if (fablePlan?.downgraded) { init = { ...init, body: fablePlan.bodyText } } @@ -4178,9 +4176,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { createStrippedStream(response, { perf: (stage, data) => trace.mark(stage, data), contentFilterModel: fablePlan?.requestedModel, - ...(fallbackMode === 'legacy' && - !fablePlan?.downgraded && - fablePlan + ...(!fablePlan?.downgraded && fablePlan ? { onContentFilter: () => { if (!fableRequest?.warmTarget) { @@ -4218,9 +4214,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { }, } : {}), - ...(fallbackMode === 'legacy' && - fablePlan?.downgraded && - fableRequest + ...(fablePlan?.downgraded && fableRequest ? { onComplete: (finishReason: string) => { const completed = fableFallbackManager.complete( diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index bbc3c08..ed1a88c 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -7917,6 +7917,278 @@ describe('auth.loader', () => { ).toBe(0) }) + test('server mode — refusal with no server-side handoff downgrades to Opus (wedge regression)', async () => { + delete process.env.OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + claudeCache: { enabled: false }, + cacheKeep: { enabled: false }, + }), + ) + const models: string[] = [] + let firstFable = true + const refusalSse = [ + 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_filtered"}}\n\n', + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"refusal"},"usage":{"output_tokens":0}}\n\n', + 'event: message_stop\ndata: {"type":"message_stop"}\n\n', + ].join('') + const successSse = [ + 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_ok"}}\n\n', + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\n', + 'event: message_stop\ndata: {"type":"message_stop"}\n\n', + ].join('') + + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + if (!url.includes('/v1/messages')) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + const body = JSON.parse(String(init?.body)) as Record + if (body.max_tokens === 0) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + models.push(String(body.model)) + if (body.model === 'claude-fable-5' && firstFable) { + firstFable = false + return Promise.resolve(new Response(refusalSse, { status: 200 })) + } + return Promise.resolve(new Response(successSse, { status: 200 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + const request = { + method: 'POST', + headers: { 'x-session-affinity': 'ses_wedge' }, + body: JSON.stringify({ + model: 'claude-fable-5', + max_tokens: 128_000, + stream: true, + system: [{ type: 'text', text: 'stable system' }], + messages: [{ role: 'user', content: 'hello' }], + }), + } + + // First request hits the refusal — onContentFilter fires, stream rejects. + // The second request must carry the downgraded Opus model. + const filtered = await result.fetch(MESSAGES_URL, request) + await expect(filtered.text()).rejects.toThrow() + const second = await result.fetch(MESSAGES_URL, request) + await second.text() + + expect(models).toEqual(['claude-fable-5', 'claude-opus-4-8']) + }) + + test('server mode — absorbed server-side fallback does NOT activate client-side downgrade', async () => { + delete process.env.OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + claudeCache: { enabled: false }, + cacheKeep: { enabled: false }, + }), + ) + const models: string[] = [] + const frame = (event: string, data: unknown) => + `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` + const fallbackSse = [ + frame('message_start', { + type: 'message_start', + message: { id: 'msg_fallback', model: 'claude-opus-5' }, + }), + frame('content_block_start', { + type: 'content_block_start', + index: 0, + content_block: { + type: 'fallback', + from: { model: 'claude-fable-5' }, + to: { model: 'claude-opus-5' }, + }, + }), + frame('content_block_stop', { type: 'content_block_stop', index: 0 }), + frame('content_block_start', { + type: 'content_block_start', + index: 1, + content_block: { type: 'text', text: '' }, + }), + frame('content_block_delta', { + type: 'content_block_delta', + index: 1, + delta: { type: 'text_delta', text: 'safe answer' }, + }), + frame('content_block_stop', { type: 'content_block_stop', index: 1 }), + frame('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { output_tokens: 2 }, + }), + frame('message_stop', { type: 'message_stop' }), + ].join('') + + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + if (!url.includes('/v1/messages')) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + const body = JSON.parse(String(init?.body)) as Record + if (body.max_tokens === 0) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + models.push(String(body.model)) + return Promise.resolve(new Response(fallbackSse, { status: 200 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + const request = { + method: 'POST', + headers: { 'x-session-affinity': 'ses_no_double' }, + body: JSON.stringify({ + model: 'claude-fable-5', + max_tokens: 128_000, + stream: true, + system: [{ type: 'text', text: 'stable system' }], + messages: [{ role: 'user', content: 'hello' }], + }), + } + + const first = await result.fetch(MESSAGES_URL, request) + await first.text() + const second = await result.fetch(MESSAGES_URL, request) + await second.text() + + // Both requests stayed on Fable — no client-side downgrade triggered. + expect(models).toEqual(['claude-fable-5', 'claude-fable-5']) + }) + + test('server mode — downgraded Opus request does not carry server-side fallback opt-in', async () => { + delete process.env.OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + claudeCache: { enabled: false }, + cacheKeep: { enabled: false }, + }), + ) + const models: string[] = [] + const bodies: Array> = [] + let firstFable = true + const refusalSse = [ + 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_filtered"}}\n\n', + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"refusal"},"usage":{"output_tokens":0}}\n\n', + 'event: message_stop\ndata: {"type":"message_stop"}\n\n', + ].join('') + const successSse = [ + 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_ok"}}\n\n', + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\n', + 'event: message_stop\ndata: {"type":"message_stop"}\n\n', + ].join('') + + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + if (!url.includes('/v1/messages')) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + const body = JSON.parse(String(init?.body)) as Record + if (body.max_tokens === 0) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + models.push(String(body.model)) + bodies.push(body) + if (body.model === 'claude-fable-5' && firstFable) { + firstFable = false + return Promise.resolve(new Response(refusalSse, { status: 200 })) + } + return Promise.resolve(new Response(successSse, { status: 200 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + const request = { + method: 'POST', + headers: { 'x-session-affinity': 'ses_no_optin' }, + body: JSON.stringify({ + model: 'claude-fable-5', + max_tokens: 128_000, + stream: true, + system: [{ type: 'text', text: 'stable system' }], + messages: [{ role: 'user', content: 'hello' }], + }), + } + + // First request hits the refusal. After the fix, onContentFilter fires + // causing the stream to reject with a ContentFilterError. + const filtered = await result.fetch(MESSAGES_URL, request) + await expect(filtered.text()).rejects.toThrow() + const opus = await result.fetch(MESSAGES_URL, request) + await opus.text() + + expect(models).toEqual(['claude-fable-5', 'claude-opus-4-8']) + // The Opus request must NOT carry the server-side fallback opt-in. + expect(bodies[1]?.fallbacks).toBeUndefined() + }) + test('uses the sidebar instead of promptAsync when the matching TUI is connected', async () => { await useTempAccountFile( createFallbackStorage({ From e8ebeaeb3750bfdd69312b70572840a3bc33c0a2 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:15:59 +0200 Subject: [PATCH 2/2] fix(opencode): strip server-side fallback opt-in from source-model cache prewarm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/opencode/src/tests/transform.test.ts | 21 +++++++++++++++++++ packages/opencode/src/transform.ts | 3 +++ 2 files changed, 24 insertions(+) diff --git a/packages/opencode/src/tests/transform.test.ts b/packages/opencode/src/tests/transform.test.ts index 11b07c5..441197c 100644 --- a/packages/opencode/src/tests/transform.test.ts +++ b/packages/opencode/src/tests/transform.test.ts @@ -4,6 +4,7 @@ import { FAST_MODE_BETA, OPENCODE_IDENTITY_PREFIX, REQUIRED_BETAS, + selectClaudeCodeBetas, } from '@cortexkit/anthropic-auth-core' import dedent from 'dedent' import { @@ -984,6 +985,26 @@ describe('prepareFableCacheWarmSource', () => { expect(body.model).toBe('claude-opus-5') expect(body.thinking).toEqual({ type: 'adaptive', display: 'summarized' }) }) + + test('strips the server-side fallback opt-in so the source-model prewarm never triggers Anthropic fallback routing', () => { + const source = prepareFableCacheWarmSource( + JSON.stringify({ + model: 'claude-opus-4-8', + fallbacks: 'default', + speed: 'fast', + messages: [{ role: 'user', content: 'same input' }], + }), + ) + + expect(source.ok).toBe(true) + if (!source.ok) throw new Error(source.reason) + 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', + ) + }) }) describe('sanitizeSystemText', () => { diff --git a/packages/opencode/src/transform.ts b/packages/opencode/src/transform.ts index bbf329c..56c49a1 100644 --- a/packages/opencode/src/transform.ts +++ b/packages/opencode/src/transform.ts @@ -1022,6 +1022,9 @@ export function prepareFableCacheWarmSource( const body = JSON.parse(bodyText) as Record body.model = fableModel delete body.speed + // The prewarm must reach the source model (not be fallback-routed), + // so strip any server-side fallback opt-in inherited from the captured body. + delete body.fallbacks normalizeFableMythosRequest(body) normalizeOpus5Request(body) return { ok: true, bodyText: JSON.stringify(body) }