Skip to content

Commit ddf5302

Browse files
committed
fix(opencode): isolate Opus 5 recovery state
1 parent 5363465 commit ddf5302

7 files changed

Lines changed: 239 additions & 47 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one
3232
- **Persistent Claude cache controls**: manage Anthropic 1-hour prompt caching from `/claude-cache` with explicit, automatic, or hybrid modes.
3333
- **Cache keepalive**: use `/claude-cachekeep always` or `/claude-cachekeep HH-HH` to pre-warm hybrid cache anchors for active sessions before the 1-hour TTL expires.
3434
- **Fast mode toggle**: use `/claude-fast on|off` to request Anthropic fast mode for supported Opus models.
35-
- **Fable/Mythos reasoning visibility**: request summarized adaptive thinking for Claude Fable 5 and Mythos 5 so agents can display reasoning summaries instead of blank signed-thinking blocks.
36-
- **Fable content-filter recovery (OpenCode)**: when Fable ends a session response with Anthropic's `refusal` content-filter reason, transparently retry with Opus 4.8 for 10 successful model responses. After each Opus response, a zero-output Fable prewarm advances the same prompt cache using the OAuth account that served the filtered Fable request. The latest Opus cache boundary is retained so a later refusal can bridge back to Opus even after more than 20 Fable content blocks; the selected OpenCode model remains Fable. The TUI sidebar shows the per-session recovery countdown and return to Fable, while OpenCode Desktop receives ignored `promptAsync` notices for both transitions.
35+
- **Adaptive reasoning visibility**: request summarized adaptive thinking for Claude Fable 5, Mythos 5, and Opus 5. OpenCode receives native `low`, `medium`, `high`, `xhigh`, and `max` Opus 5 effort variants rather than legacy manual-thinking budgets.
36+
- **Fable/Opus 5 content-filter recovery (OpenCode)**: when Fable or Opus 5 ends a session response with Anthropic's `refusal` content-filter reason, transparently retry that source-model family with Opus 4.8 for 10 successful model responses. Recovery state and pending cache warms remain independent when a session switches between Fable and Opus 5. After each downgraded response, a zero-output prewarm advances the source model's prompt cache using the OAuth account that served the filtered request. The latest Opus cache boundary is retained so a later refusal can bridge back even after more than 20 content blocks. The TUI sidebar and OpenCode Desktop report both transitions while preserving the selected model.
3737
- **Live quota visibility**: use `/claude-quota` to see main and fallback quota state, reset times, and refresh errors.
3838
- **Quota sidebar widget**: register the OpenCode TUI plugin in `tui.json` to render a live sidebar with per-account quota, routing, cache, and health state.
3939
- **Killswitch**: per-account hard-block thresholds that stop requests before hitting Anthropic's rate limits, with synthetic 429 retry-after when all accounts are exhausted.

packages/e2e-tests/tests/tool-prefix.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,35 @@ describe('OpenCode Anthropic auth e2e', () => {
4747
}
4848
}, 90_000)
4949

50+
it('publishes adaptive Opus 5 effort variants to OpenCode', async () => {
51+
harness = await E2EHarness.create()
52+
const response = await fetch(`${harness.opencode.url}/provider`)
53+
expect(response.status).toBe(200)
54+
const providers = (await response.json()) as {
55+
all: Array<{
56+
id: string
57+
models: Record<
58+
string,
59+
{ variants?: Record<string, Record<string, unknown>> }
60+
>
61+
}>
62+
}
63+
const opus5 = providers.all.find((provider) => provider.id === 'anthropic')
64+
?.models['claude-opus-5']
65+
66+
expect(Object.keys(opus5?.variants ?? {})).toEqual([
67+
'low',
68+
'medium',
69+
'high',
70+
'xhigh',
71+
'max',
72+
])
73+
expect(opus5?.variants?.max).toEqual({
74+
thinking: { type: 'adaptive', display: 'summarized' },
75+
effort: 'max',
76+
})
77+
}, 90_000)
78+
5079
it('strips mcp_ tool names even when Anthropic SSE chunks split the name field', async () => {
5180
harness = await E2EHarness.create()
5281
harness.script([

packages/opencode/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one
3131
- **Persistent Claude cache controls**: manage Anthropic 1-hour prompt caching from `/claude-cache` with explicit, automatic, or hybrid modes.
3232
- **Cache keepalive**: use `/claude-cachekeep always` or `/claude-cachekeep HH-HH` to pre-warm hybrid cache anchors for active sessions before the 1-hour TTL expires.
3333
- **Fast mode toggle**: use `/claude-fast on|off` to request Anthropic fast mode for supported Opus models.
34-
- **Fable/Mythos reasoning visibility**: request summarized adaptive thinking for Claude Fable 5 and Mythos 5 so agents can display reasoning summaries instead of blank signed-thinking blocks.
35-
- **Fable content-filter recovery**: when Fable ends a session response with Anthropic's `refusal` content-filter reason, transparently retry with Opus 4.8 for 10 successful model responses. After each Opus response, a zero-output Fable prewarm advances the same prompt cache using the OAuth account that served the filtered Fable request. The latest Opus cache boundary is retained so a later refusal can bridge back to Opus even after more than 20 Fable content blocks; the selected OpenCode model remains Fable. The TUI sidebar shows the per-session recovery countdown and return to Fable, while OpenCode Desktop receives ignored `promptAsync` notices for both transitions.
34+
- **Adaptive reasoning visibility**: request summarized adaptive thinking for Claude Fable 5, Mythos 5, and Opus 5. OpenCode receives native `low`, `medium`, `high`, `xhigh`, and `max` Opus 5 effort variants rather than legacy manual-thinking budgets.
35+
- **Fable/Opus 5 content-filter recovery**: when Fable or Opus 5 ends a session response with Anthropic's `refusal` content-filter reason, transparently retry that source-model family with Opus 4.8 for 10 successful model responses. Recovery state and pending cache warms remain independent when a session switches between Fable and Opus 5. After each downgraded response, a zero-output prewarm advances the source model's prompt cache using the OAuth account that served the filtered request. The latest Opus cache boundary is retained so a later refusal can bridge back even after more than 20 content blocks. The TUI sidebar and OpenCode Desktop report both transitions while preserving the selected model.
3636
- **Live quota visibility**: use `/claude-quota` to see main and fallback quota state, reset times, and refresh errors.
3737
- **Killswitch**: per-account hard-block thresholds that stop requests before hitting Anthropic's rate limits, with synthetic 429 retry-after when all accounts are exhausted.
3838
- **User-owned Cloudflare relay**: optionally provision your own Worker relay to reduce repeated client upload bytes for large OpenCode or Pi requests.

packages/opencode/src/fable-fallback.ts

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ export type FableStandbyCacheAnchor = {
1313
oauthAccountId: string
1414
}
1515

16-
const MAX_TRACKED_SESSIONS = 128
17-
const SESSION_TTL_MS = 24 * 60 * 60_000
16+
const MAX_TRACKED_RECOVERIES = 128
17+
const RECOVERY_TTL_MS = 24 * 60 * 60_000
1818

1919
export type FableFallbackPlan = {
2020
sessionId: string
21+
recoveryKey: string
2122
requestedModel: string
2223
effectiveModel: string
2324
bodyText: string
@@ -49,13 +50,29 @@ type FableFallbackState = {
4950
* gate. The single shared fallback id matches Anthropic's default for both
5051
* source models, so the recovery period is uniform.
5152
*/
52-
function isRecoverableRefusalModel(model: unknown): model is string {
53-
if (typeof model !== 'string') return false
54-
if (isClaudeOpus5Model(model)) return true
55-
return (
53+
type RecoverableRefusalFamily = 'fable-5' | 'opus-5'
54+
55+
function recoverableRefusalFamily(
56+
model: unknown,
57+
): RecoverableRefusalFamily | null {
58+
if (typeof model !== 'string') return null
59+
if (isClaudeOpus5Model(model)) return 'opus-5'
60+
if (
5661
model === CLAUDE_FABLE_5_MODEL_ID ||
5762
model.startsWith(`${CLAUDE_FABLE_5_MODEL_ID}-`)
58-
)
63+
) {
64+
return 'fable-5'
65+
}
66+
return null
67+
}
68+
69+
function isRecoverableRefusalModel(model: unknown): model is string {
70+
return recoverableRefusalFamily(model) !== null
71+
}
72+
73+
function fallbackRecoveryKey(sessionId: string, model: string) {
74+
const family = recoverableRefusalFamily(model)
75+
return family ? `${sessionId}\0${family}` : null
5976
}
6077

6178
function parseBody(bodyText: string) {
@@ -68,21 +85,22 @@ function parseBody(bodyText: string) {
6885
}
6986

7087
export class FableFallbackManager {
71-
private readonly sessions = new Map<string, FableFallbackState>()
88+
private readonly recoveries = new Map<string, FableFallbackState>()
7289
private nextCycle = 1
7390

7491
constructor(private readonly now: () => number = Date.now) {}
7592

7693
private prune() {
7794
const now = this.now()
78-
for (const [sessionId, state] of this.sessions) {
79-
if (now - state.updatedAt >= SESSION_TTL_MS)
80-
this.sessions.delete(sessionId)
95+
for (const [recoveryKey, state] of this.recoveries) {
96+
if (now - state.updatedAt >= RECOVERY_TTL_MS) {
97+
this.recoveries.delete(recoveryKey)
98+
}
8199
}
82-
while (this.sessions.size > MAX_TRACKED_SESSIONS) {
83-
const oldest = this.sessions.keys().next().value
100+
while (this.recoveries.size > MAX_TRACKED_RECOVERIES) {
101+
const oldest = this.recoveries.keys().next().value
84102
if (typeof oldest !== 'string') break
85-
this.sessions.delete(oldest)
103+
this.recoveries.delete(oldest)
86104
}
87105
}
88106

@@ -93,12 +111,15 @@ export class FableFallbackManager {
93111
if (!sessionId || typeof bodyText !== 'string') return null
94112
const parsed = parseBody(bodyText)
95113
if (!parsed || !isRecoverableRefusalModel(parsed.model)) return null
114+
const recoveryKey = fallbackRecoveryKey(sessionId, parsed.model)
115+
if (!recoveryKey) return null
96116

97117
this.prune()
98-
const state = this.sessions.get(sessionId)
118+
const state = this.recoveries.get(recoveryKey)
99119
if (!state || state.remaining <= 0) {
100120
return {
101121
sessionId,
122+
recoveryKey,
102123
requestedModel: parsed.model,
103124
effectiveModel: parsed.model,
104125
bodyText,
@@ -109,11 +130,12 @@ export class FableFallbackManager {
109130

110131
state.updatedAt = this.now()
111132
state.nextRequestSequence++
112-
this.sessions.delete(sessionId)
113-
this.sessions.set(sessionId, state)
133+
this.recoveries.delete(recoveryKey)
134+
this.recoveries.set(recoveryKey, state)
114135
parsed.body.model = FABLE_FALLBACK_MODEL_ID
115136
return {
116137
sessionId,
138+
recoveryKey,
117139
requestedModel: state.model,
118140
effectiveModel: FABLE_FALLBACK_MODEL_ID,
119141
bodyText: JSON.stringify(parsed.body),
@@ -127,15 +149,15 @@ export class FableFallbackManager {
127149

128150
recoveryAccount(plan: FableFallbackPlan) {
129151
if (plan.cycle === undefined) return plan.cacheAccountId
130-
const state = this.sessions.get(plan.sessionId)
152+
const state = this.recoveries.get(plan.recoveryKey)
131153
return state?.cycle === plan.cycle
132154
? state.cacheAccountId
133155
: plan.cacheAccountId
134156
}
135157

136158
bindRecoveryAccount(plan: FableFallbackPlan, oauthAccountId: string) {
137159
if (!plan.downgraded || plan.cycle === undefined) return false
138-
const state = this.sessions.get(plan.sessionId)
160+
const state = this.recoveries.get(plan.recoveryKey)
139161
if (!state || state.cycle !== plan.cycle) return false
140162
if (state.cacheAccountId === oauthAccountId) return false
141163
state.cacheAccountId = oauthAccountId
@@ -149,7 +171,7 @@ export class FableFallbackManager {
149171

150172
activate(plan: FableFallbackPlan, cacheAccountId?: string): number {
151173
if (plan.downgraded || !isRecoverableRefusalModel(plan.requestedModel)) {
152-
return this.remaining(plan.sessionId)
174+
return this.remaining(plan)
153175
}
154176
const state: FableFallbackState = {
155177
model: plan.requestedModel,
@@ -161,8 +183,8 @@ export class FableFallbackManager {
161183
standbyAnchorSequence: 0,
162184
updatedAt: this.now(),
163185
}
164-
this.sessions.delete(plan.sessionId)
165-
this.sessions.set(plan.sessionId, state)
186+
this.recoveries.delete(plan.recoveryKey)
187+
this.recoveries.set(plan.recoveryKey, state)
166188
this.prune()
167189
return state.remaining
168190
}
@@ -172,10 +194,10 @@ export class FableFallbackManager {
172194
standbyCacheAnchor?: FableStandbyCacheAnchor,
173195
): { counted: boolean; remaining: number } {
174196
if (!plan.downgraded || plan.cycle == null || plan.completed) {
175-
return { counted: false, remaining: this.remaining(plan.sessionId) }
197+
return { counted: false, remaining: this.remaining(plan) }
176198
}
177199
plan.completed = true
178-
const state = this.sessions.get(plan.sessionId)
200+
const state = this.recoveries.get(plan.recoveryKey)
179201
if (!state || state.cycle !== plan.cycle || state.remaining <= 0) {
180202
return { counted: false, remaining: state?.remaining ?? 0 }
181203
}
@@ -195,13 +217,13 @@ export class FableFallbackManager {
195217
// Retain the zero-remaining state so a later refusal can bridge back
196218
// to this model-specific Opus cache boundary without continuously warming
197219
// Opus while the original model is healthy.
198-
this.sessions.delete(plan.sessionId)
199-
this.sessions.set(plan.sessionId, state)
220+
this.recoveries.delete(plan.recoveryKey)
221+
this.recoveries.set(plan.recoveryKey, state)
200222
return { counted: true, remaining: state.remaining }
201223
}
202224

203-
remaining(sessionId: string): number {
225+
remaining(plan: FableFallbackPlan): number {
204226
this.prune()
205-
return this.sessions.get(sessionId)?.remaining ?? 0
227+
return this.recoveries.get(plan.recoveryKey)?.remaining ?? 0
206228
}
207229
}

packages/opencode/src/index.ts

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,36 @@ function addFableMythos5Models<
708708
} as T
709709
}
710710

711+
const CLAUDE_OPUS_5_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
712+
713+
function createClaudeOpus5Variants() {
714+
return Object.fromEntries(
715+
CLAUDE_OPUS_5_EFFORTS.map((effort) => [
716+
effort,
717+
{
718+
thinking: { type: 'adaptive', display: 'summarized' },
719+
effort,
720+
},
721+
]),
722+
)
723+
}
724+
725+
function applyClaudeOpus5Variants<
726+
T extends Record<string, AnthropicProviderModel>,
727+
>(models: T) {
728+
return Object.fromEntries(
729+
Object.entries(models).map(([id, model]) => {
730+
const modelId = model.api?.id ?? model.id ?? id
731+
return [
732+
id,
733+
isClaudeOpus5Model(modelId)
734+
? { ...model, variants: createClaudeOpus5Variants() }
735+
: model,
736+
]
737+
}),
738+
) as T
739+
}
740+
711741
function zeroModelCosts<T extends Record<string, AnthropicProviderModel>>(
712742
models: T,
713743
) {
@@ -1165,9 +1195,9 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
11651195
},
11661196
})
11671197

1168-
const fableWarmChains = new Map<string, Promise<void>>()
1198+
const recoveryWarmChains = new Map<string, Promise<void>>()
11691199

1170-
function warmFableAfterOpus(context: FableRequestContext) {
1200+
function warmRecoverySourceAfterOpus(context: FableRequestContext) {
11711201
const sessionId = context.plan.sessionId
11721202
const modelLabel = isClaudeOpus5Model(context.plan.requestedModel)
11731203
? 'Opus 5'
@@ -1206,7 +1236,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
12061236
if (result.ok) {
12071237
logger.debug('fable-fallback', `${modelLabel} cache warmed`, {
12081238
session: sessionId,
1209-
remaining: fableFallbackManager.remaining(sessionId),
1239+
remaining: fableFallbackManager.remaining(context.plan),
12101240
...(result.usage && { usage: result.usage }),
12111241
})
12121242
return
@@ -1224,12 +1254,13 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
12241254
}
12251255
}
12261256

1227-
const previous = fableWarmChains.get(sessionId) ?? Promise.resolve()
1257+
const { recoveryKey } = context.plan
1258+
const previous = recoveryWarmChains.get(recoveryKey) ?? Promise.resolve()
12281259
const current = previous.then(run, run)
1229-
fableWarmChains.set(sessionId, current)
1260+
recoveryWarmChains.set(recoveryKey, current)
12301261
void current.finally(() => {
1231-
if (fableWarmChains.get(sessionId) === current) {
1232-
fableWarmChains.delete(sessionId)
1262+
if (recoveryWarmChains.get(recoveryKey) === current) {
1263+
recoveryWarmChains.delete(recoveryKey)
12331264
}
12341265
})
12351266
return current
@@ -2401,7 +2432,9 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
24012432
provider: { models: Record<string, AnthropicProviderModel> },
24022433
context: { auth?: { type?: string } },
24032434
) {
2404-
const models = addFableMythos5Models(provider.models)
2435+
const models = applyClaudeOpus5Variants(
2436+
addFableMythos5Models(provider.models),
2437+
)
24052438
// Zero OAuth model costs by default (quota-based, not per-token billed).
24062439
// Opt out via persisted config costZeroing.enabled=false to show real costs.
24072440
// initialStorage is nullable (no config file yet) → default to enabled.
@@ -3899,7 +3932,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
38993932
incomingHeaders.get('x-opencode-session')
39003933
let fablePlan = fableFallbackManager.plan(sessionId, init?.body)
39013934
if (fablePlan && !fablePlan.downgraded) {
3902-
const finalWarm = fableWarmChains.get(fablePlan.sessionId)
3935+
const finalWarm = recoveryWarmChains.get(fablePlan.recoveryKey)
39033936
if (finalWarm) {
39043937
await finalWarm
39053938
fablePlan = fableFallbackManager.plan(sessionId, init?.body)
@@ -3989,13 +4022,11 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
39894022
storage,
39904023
auth,
39914024
)
3992-
const warm = warmFableAfterOpus(fableRequest)
4025+
const warm = warmRecoverySourceAfterOpus(fableRequest)
39934026
if (completed.remaining === 0) {
39944027
const notifyRestored = () => {
39954028
if (
3996-
fableFallbackManager.remaining(
3997-
fablePlan.sessionId,
3998-
) !== 0
4029+
fableFallbackManager.remaining(fablePlan) !== 0
39994030
)
40004031
return
40014032
publishFableRecoveryNotice(

packages/opencode/src/tests/fable-fallback.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ describe('FableFallbackManager', () => {
136136
manager.activate(manager.plan('session-a', body())!)
137137
const failed = manager.plan('session-a', body())!
138138

139-
expect(manager.remaining('session-a')).toBe(10)
139+
expect(manager.remaining(failed)).toBe(10)
140140
expect(manager.complete(failed)).toEqual({ counted: true, remaining: 9 })
141141
expect(manager.complete(failed)).toEqual({ counted: false, remaining: 9 })
142142
})
@@ -151,6 +151,38 @@ describe('FableFallbackManager', () => {
151151
expect(manager.plan(undefined, body())).toBeNull()
152152
})
153153

154+
test('tracks Fable and Opus 5 recovery independently in the same session', () => {
155+
const manager = new FableFallbackManager()
156+
const fable = manager.plan('session-a', body('claude-fable-5'))!
157+
manager.activate(fable, 'fable-account')
158+
159+
const healthyOpus = manager.plan('session-a', body('claude-opus-5'))!
160+
expect(healthyOpus).toMatchObject({
161+
requestedModel: 'claude-opus-5',
162+
effectiveModel: 'claude-opus-5',
163+
downgraded: false,
164+
})
165+
expect(JSON.parse(healthyOpus.bodyText).model).toBe('claude-opus-5')
166+
167+
manager.activate(healthyOpus, 'opus-account')
168+
const downgradedFable = manager.plan('session-a', body('claude-fable-5'))!
169+
const downgradedOpus = manager.plan('session-a', body('claude-opus-5'))!
170+
171+
expect(downgradedFable).toMatchObject({
172+
requestedModel: 'claude-fable-5',
173+
effectiveModel: FABLE_FALLBACK_MODEL_ID,
174+
cacheAccountId: 'fable-account',
175+
downgraded: true,
176+
})
177+
expect(downgradedOpus).toMatchObject({
178+
requestedModel: 'claude-opus-5',
179+
effectiveModel: FABLE_FALLBACK_MODEL_ID,
180+
cacheAccountId: 'opus-account',
181+
downgraded: true,
182+
})
183+
expect(downgradedFable.cycle).not.toBe(downgradedOpus.cycle)
184+
})
185+
154186
test('a new filter cycle does not let an older Opus response decrement it', () => {
155187
const manager = new FableFallbackManager()
156188
const initial = manager.plan('session-a', body())!

0 commit comments

Comments
 (0)