Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 11 additions & 17 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
272 changes: 272 additions & 0 deletions packages/opencode/src/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
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<string, unknown>
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<Record<string, unknown>> = []
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<string, unknown>
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({
Expand Down
21 changes: 21 additions & 0 deletions packages/opencode/src/tests/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
FAST_MODE_BETA,
OPENCODE_IDENTITY_PREFIX,
REQUIRED_BETAS,
selectClaudeCodeBetas,
} from '@cortexkit/anthropic-auth-core'
import dedent from 'dedent'
import {
Expand Down Expand Up @@ -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(

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>

'server-side-fallback-2026-07-01',
)
})
})

describe('sanitizeSystemText', () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1022,6 +1022,9 @@ export function prepareFableCacheWarmSource(
const body = JSON.parse(bodyText) as Record<string, unknown>
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) }
Expand Down