Skip to content

Commit 5363465

Browse files
authored
feat: support Claude Opus 5 across core, opencode, and pi (#143)
- claude-opus-5 model support: adaptive thinking made visible (display defaults to omitted), explicit thinking disable preserved and canonicalized, and the disabled+xhigh/max effort combination lowered to high so requests do not 400 - fast mode support for claude-opus-5 - refusal recovery parity with Fable 5: Opus 5 content-filter refusals downgrade the session to claude-opus-4-8 for 10 responses with the same cache-anchor, prewarm, and bridge machinery; notices, sidebar text, and recovery logs name the actual requested model - Pi provider maps Opus 5 to adaptive summarized thinking with its own per-family constant
1 parent 54be7b5 commit 5363465

17 files changed

Lines changed: 772 additions & 31 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,7 @@ Both OpenCode and Pi packages can persistently request Anthropic fast mode for s
481481
/claude-fast off
482482
```
483483

484-
When enabled, supported requests add `speed: "fast"` to the Anthropic JSON body and include the `fast-mode-2026-02-01` beta header. Unsupported models are left at standard speed. Anthropic currently documents fast mode for `claude-opus-4-6`, `claude-opus-4-7`, and `claude-opus-4-8`; Claude Fable 5 and Mythos 5 are not fast-mode models.
484+
When enabled, supported requests add `speed: "fast"` to the Anthropic JSON body and include the `fast-mode-2026-02-01` beta header. Unsupported models are left at standard speed. Anthropic currently documents fast mode for `claude-opus-4-6`, `claude-opus-4-7`, `claude-opus-4-8`, and `claude-opus-5`; Claude Fable 5 and Mythos 5 are not fast-mode models.
485485

486486
Fast and standard speeds do not share prompt-cache prefixes, so switching this setting can cause cache misses.
487487

packages/core/src/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ export function isFastModeSupportedModel(model: unknown) {
4848
typeof model === 'string' &&
4949
(model.startsWith('claude-opus-4-6') ||
5050
model.startsWith('claude-opus-4-7') ||
51-
model.startsWith('claude-opus-4-8'))
51+
model.startsWith('claude-opus-4-8') ||
52+
model.startsWith('claude-opus-5'))
5253
)
5354
}
5455

packages/core/src/fast.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export function buildFastModeStatusSummary(input?: { enabled?: boolean }) {
4747
`- Enabled: ${enabled ? 'enabled' : 'disabled'}`,
4848
'- Persisted: ~/.config/opencode/anthropic-auth.json',
4949
'- Scope: adds Anthropic fast mode to supported Opus requests',
50-
'- Supported models: claude-opus-4-6, claude-opus-4-7, and claude-opus-4-8',
50+
'- Supported models: claude-opus-4-6, claude-opus-4-7, claude-opus-4-8, and claude-opus-5',
5151
'- Request changes: adds `speed: "fast"` and the `fast-mode-2026-02-01` beta header',
5252
'- Note: fast and standard speeds do not share prompt-cache prefixes',
5353
].join('\n')

packages/core/src/models.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,27 @@ export function isClaudeSonnet5Model(model: unknown) {
6969
)
7070
}
7171

72+
export const CLAUDE_OPUS_5_MODEL_ID = 'claude-opus-5'
73+
74+
/**
75+
* Opus 5 has the same adaptive-thinking-by-default + `display: "omitted"`
76+
* shape as Sonnet 5, so the injected thinking must also be summarized to be
77+
* visible. Kept as its own constant per the PR #100 lesson — hook callers
78+
* should reference the per-family name (`CLAUDE_OPUS_5_ADAPTIVE_THINKING`,
79+
* not the Fable/Mythos alias) so a future divergence between the families
80+
* only changes this re-export, not every call site.
81+
*/
82+
export const CLAUDE_OPUS_5_ADAPTIVE_THINKING =
83+
CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING
84+
85+
export function isClaudeOpus5Model(model: unknown) {
86+
return (
87+
typeof model === 'string' &&
88+
(model === CLAUDE_OPUS_5_MODEL_ID ||
89+
model.startsWith(`${CLAUDE_OPUS_5_MODEL_ID}-`))
90+
)
91+
}
92+
7293
export function isOpenAIReasoningSignature(value: unknown): boolean {
7394
if (typeof value !== 'string') return false
7495
if (value.startsWith('gAAAA')) return true

packages/core/src/tests/models.test.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, expect, test } from 'bun:test'
2-
import { isClaudeSonnet5Model } from '../models'
2+
import {
3+
CLAUDE_OPUS_5_ADAPTIVE_THINKING,
4+
CLAUDE_OPUS_5_MODEL_ID,
5+
isClaudeOpus5Model,
6+
isClaudeSonnet5Model,
7+
} from '../models'
38

49
describe('isClaudeSonnet5Model', () => {
510
test('matches the bare claude-sonnet-5 id', () => {
@@ -28,3 +33,58 @@ describe('isClaudeSonnet5Model', () => {
2833
expect(isClaudeSonnet5Model(42)).toBe(false)
2934
})
3035
})
36+
37+
describe('isClaudeOpus5Model', () => {
38+
test('exposes the bare id constant', () => {
39+
expect(CLAUDE_OPUS_5_MODEL_ID).toBe('claude-opus-5')
40+
})
41+
42+
test('matches the bare claude-opus-5 id', () => {
43+
expect(isClaudeOpus5Model('claude-opus-5')).toBe(true)
44+
})
45+
46+
test('matches the catalog claude-opus-5-fast variant', () => {
47+
expect(isClaudeOpus5Model('claude-opus-5-fast')).toBe(true)
48+
})
49+
50+
test('matches a dated claude-opus-5 snapshot', () => {
51+
expect(isClaudeOpus5Model('claude-opus-5-20260701')).toBe(true)
52+
})
53+
54+
test('matches the dated fast snapshot', () => {
55+
expect(isClaudeOpus5Model('claude-opus-5-fast-20260701')).toBe(true)
56+
})
57+
58+
test('does not match claude-opus-4-8', () => {
59+
expect(isClaudeOpus5Model('claude-opus-4-8')).toBe(false)
60+
})
61+
62+
test('does not match a non-dash prefix collision', () => {
63+
expect(isClaudeOpus5Model('claude-opus-5x')).toBe(false)
64+
})
65+
66+
test('does not match the Fable/Mythos ids', () => {
67+
expect(isClaudeOpus5Model('claude-fable-5')).toBe(false)
68+
expect(isClaudeOpus5Model('claude-mythos-5')).toBe(false)
69+
})
70+
71+
test('does not match Sonnet 5', () => {
72+
expect(isClaudeOpus5Model('claude-sonnet-5')).toBe(false)
73+
})
74+
75+
test('does not match non-string input', () => {
76+
expect(isClaudeOpus5Model(undefined)).toBe(false)
77+
expect(isClaudeOpus5Model(42)).toBe(false)
78+
expect(isClaudeOpus5Model(null)).toBe(false)
79+
expect(isClaudeOpus5Model({})).toBe(false)
80+
})
81+
})
82+
83+
describe('CLAUDE_OPUS_5_ADAPTIVE_THINKING', () => {
84+
test('matches the shared adaptive+summarized shape (alias of Fable/Mythos)', () => {
85+
expect(CLAUDE_OPUS_5_ADAPTIVE_THINKING).toEqual({
86+
type: 'adaptive',
87+
display: 'summarized',
88+
})
89+
})
90+
})

packages/opencode/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ Both OpenCode and Pi packages can persistently request Anthropic fast mode for s
387387
/claude-fast off
388388
```
389389

390-
When enabled, supported requests add `speed: "fast"` to the Anthropic JSON body and include the `fast-mode-2026-02-01` beta header. Unsupported models are left at standard speed. Anthropic currently documents fast mode for `claude-opus-4-6`, `claude-opus-4-7`, and `claude-opus-4-8`; Claude Fable 5 and Mythos 5 are not fast-mode models.
390+
When enabled, supported requests add `speed: "fast"` to the Anthropic JSON body and include the `fast-mode-2026-02-01` beta header. Unsupported models are left at standard speed. Anthropic currently documents fast mode for `claude-opus-4-6`, `claude-opus-4-7`, `claude-opus-4-8`, and `claude-opus-5`; Claude Fable 5 and Mythos 5 are not fast-mode models.
391391

392392
Fast and standard speeds do not share prompt-cache prefixes, so switching this setting can cause cache misses.
393393

packages/opencode/src/fable-fallback.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { CLAUDE_FABLE_5_MODEL_ID } from '@cortexkit/anthropic-auth-core'
1+
import {
2+
CLAUDE_FABLE_5_MODEL_ID,
3+
isClaudeOpus5Model,
4+
} from '@cortexkit/anthropic-auth-core'
25

36
export const FABLE_FALLBACK_MODEL_ID = 'claude-opus-4-8'
47
export const FABLE_FALLBACK_TURNS = 10
@@ -37,11 +40,21 @@ type FableFallbackState = {
3740
updatedAt: number
3841
}
3942

40-
function isFableModel(model: unknown): model is string {
43+
/**
44+
* Models whose content-filter refusal we recover from by downgrading to Opus 4.8
45+
* for a short window. Fable 5 is the original case; Opus 5 is identical in
46+
* shape (cyber safety classifiers that can return `stop_reason: "refusal"`,
47+
* Anthropic's own fallback default is `claude-opus-4-8`, same pricing). Mythos
48+
* has no classifiers and Sonnet 5 is not flagged — both stay outside this
49+
* gate. The single shared fallback id matches Anthropic's default for both
50+
* source models, so the recovery period is uniform.
51+
*/
52+
function isRecoverableRefusalModel(model: unknown): model is string {
53+
if (typeof model !== 'string') return false
54+
if (isClaudeOpus5Model(model)) return true
4155
return (
42-
typeof model === 'string' &&
43-
(model === CLAUDE_FABLE_5_MODEL_ID ||
44-
model.startsWith(`${CLAUDE_FABLE_5_MODEL_ID}-`))
56+
model === CLAUDE_FABLE_5_MODEL_ID ||
57+
model.startsWith(`${CLAUDE_FABLE_5_MODEL_ID}-`)
4558
)
4659
}
4760

@@ -79,7 +92,7 @@ export class FableFallbackManager {
7992
): FableFallbackPlan | null {
8093
if (!sessionId || typeof bodyText !== 'string') return null
8194
const parsed = parseBody(bodyText)
82-
if (!parsed || !isFableModel(parsed.model)) return null
95+
if (!parsed || !isRecoverableRefusalModel(parsed.model)) return null
8396

8497
this.prune()
8598
const state = this.sessions.get(sessionId)
@@ -135,7 +148,7 @@ export class FableFallbackManager {
135148
}
136149

137150
activate(plan: FableFallbackPlan, cacheAccountId?: string): number {
138-
if (plan.downgraded || !isFableModel(plan.requestedModel)) {
151+
if (plan.downgraded || !isRecoverableRefusalModel(plan.requestedModel)) {
139152
return this.remaining(plan.sessionId)
140153
}
141154
const state: FableFallbackState = {
@@ -179,9 +192,9 @@ export class FableFallbackManager {
179192
state.standbyAnchorSequence = plan.requestSequence
180193
}
181194
state.updatedAt = this.now()
182-
// Retain the zero-remaining state so a later Fable refusal can bridge back
195+
// Retain the zero-remaining state so a later refusal can bridge back
183196
// to this model-specific Opus cache boundary without continuously warming
184-
// Opus while Fable is healthy.
197+
// Opus while the original model is healthy.
185198
this.sessions.delete(plan.sessionId)
186199
this.sessions.set(plan.sessionId, state)
187200
return { counted: true, remaining: state.remaining }

packages/opencode/src/index.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {
6464
isCacheKeepHybridActive,
6565
isCacheKeepPersistentlyEnabled,
6666
isCacheKeepSubagentsEnabled,
67+
isClaudeOpus5Model,
6768
isCostZeroingEnabled,
6869
isDumpPersistentlyEnabled,
6970
isFastModeEnabled,
@@ -631,6 +632,26 @@ const FABLE_SWITCHED_TO_OPUS_NOTICE =
631632
const FABLE_RESTORED_NOTICE =
632633
'Fable recovery window complete. Returning to Fable 5.'
633634

635+
/**
636+
* Compose the recovery-window notice text for the requested model. Fable 5 and
637+
* Opus 5 follow the same Anthropic-recommended fallback (Opus 4.8) for the
638+
* same reason (cyber safety classifiers returning `stop_reason: "refusal"`),
639+
* so the message structure is shared; only the model name moves.
640+
*/
641+
function buildSwitchedToOpusNotice(modelId: string): string {
642+
if (isClaudeOpus5Model(modelId)) {
643+
return 'Opus 5 content filter detected. Switched to Opus 4.8 for a 10-response recovery window while keeping the Opus 5 cache warm.'
644+
}
645+
return FABLE_SWITCHED_TO_OPUS_NOTICE
646+
}
647+
648+
function buildRestoredNotice(modelId: string): string {
649+
if (isClaudeOpus5Model(modelId)) {
650+
return 'Opus 5 recovery window complete. Returning to Opus 5.'
651+
}
652+
return FABLE_RESTORED_NOTICE
653+
}
654+
634655
type AnthropicProviderModel = {
635656
id?: string
636657
name?: string
@@ -1148,6 +1169,9 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
11481169

11491170
function warmFableAfterOpus(context: FableRequestContext) {
11501171
const sessionId = context.plan.sessionId
1172+
const modelLabel = isClaudeOpus5Model(context.plan.requestedModel)
1173+
? 'Opus 5'
1174+
: 'Fable'
11511175
const run = async () => {
11521176
const target = context.warmTarget
11531177
if (!target) {
@@ -1180,20 +1204,20 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
11801204
target.oauthAccountId,
11811205
})
11821206
if (result.ok) {
1183-
logger.debug('fable-fallback', 'Fable cache warmed', {
1207+
logger.debug('fable-fallback', `${modelLabel} cache warmed`, {
11841208
session: sessionId,
11851209
remaining: fableFallbackManager.remaining(sessionId),
11861210
...(result.usage && { usage: result.usage }),
11871211
})
11881212
return
11891213
}
1190-
logger.warn('fable-fallback', 'Fable cache warm skipped', {
1214+
logger.warn('fable-fallback', `${modelLabel} cache warm skipped`, {
11911215
session: sessionId,
11921216
status: result.status,
11931217
reason: result.reason,
11941218
})
11951219
} catch (error) {
1196-
logger.warn('fable-fallback', 'Fable cache warm failed', {
1220+
logger.warn('fable-fallback', `${modelLabel} cache warm failed`, {
11971221
session: sessionId,
11981222
error: error instanceof Error ? error.message : String(error),
11991223
})
@@ -3898,6 +3922,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
38983922
const wrapResponse = (response: Response) =>
38993923
createStrippedStream(response, {
39003924
perf: (stage, data) => trace.mark(stage, data),
3925+
contentFilterModel: fablePlan?.requestedModel,
39013926
...(!fablePlan?.downgraded && fablePlan
39023927
? {
39033928
onContentFilter: () => {
@@ -3916,17 +3941,22 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
39163941
logger.info(
39173942
'fable-fallback',
39183943
'content filter detected; switching session to Opus 4.8',
3919-
{ session: fablePlan.sessionId, remaining },
3944+
{
3945+
session: fablePlan.sessionId,
3946+
requestedModel: fablePlan.requestedModel,
3947+
remaining,
3948+
},
39203949
)
39213950
publishFableRecoveryNotice(
39223951
{
39233952
sessionId: fablePlan.sessionId,
39243953
mode: 'opus',
39253954
remaining,
3955+
requestedModelId: fablePlan.requestedModel,
39263956
},
39273957
storage,
39283958
auth,
3929-
FABLE_SWITCHED_TO_OPUS_NOTICE,
3959+
buildSwitchedToOpusNotice(fablePlan.requestedModel),
39303960
)
39313961
},
39323962
}
@@ -3944,6 +3974,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
39443974
'Opus 4.8 turn completed',
39453975
{
39463976
session: fablePlan.sessionId,
3977+
requestedModel: fablePlan.requestedModel,
39473978
finishReason,
39483979
remaining: completed.remaining,
39493980
},
@@ -3953,6 +3984,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
39533984
sessionId: fablePlan.sessionId,
39543985
mode: 'opus',
39553986
remaining: completed.remaining,
3987+
requestedModelId: fablePlan.requestedModel,
39563988
},
39573989
storage,
39583990
auth,
@@ -3971,10 +4003,11 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
39714003
sessionId: fablePlan.sessionId,
39724004
mode: 'fable',
39734005
remaining: 0,
4006+
requestedModelId: fablePlan.requestedModel,
39744007
},
39754008
storage,
39764009
auth,
3977-
FABLE_RESTORED_NOTICE,
4010+
buildRestoredNotice(fablePlan.requestedModel),
39784011
)
39794012
}
39804013
void warm.then(notifyRestored, notifyRestored)

packages/opencode/src/sidebar-state.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ export interface FableRecoverySidebarState {
4242
mode: 'opus' | 'fable'
4343
remaining: number
4444
changedAt: number
45+
/**
46+
* The model id the user originally requested (e.g. 'claude-fable-5' or
47+
* 'claude-opus-5'). Optional for backward compatibility with pre-Opus-5
48+
* state files; older recoveries default to Fable 5 in the summary.
49+
*/
50+
requestedModelId?: string
4551
}
4652

4753
export interface SidebarState {
@@ -287,6 +293,10 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
287293
mode: recovery.mode,
288294
remaining: Math.max(0, Math.floor(recovery.remaining)),
289295
changedAt: recovery.changedAt,
296+
requestedModelId:
297+
typeof recovery.requestedModelId === 'string'
298+
? recovery.requestedModelId
299+
: undefined,
290300
},
291301
]
292302
})
@@ -677,6 +687,19 @@ export function formatScopedQuotaLabel(title: string) {
677687
return /^fable$/i.test(label) ? 'Fa' : label
678688
}
679689

690+
/**
691+
* Pretty label for a recoverable-refusal source model (Fable 5 or Opus 5).
692+
* Falls back to the raw id for unrecognized ids so the sidebar never goes
693+
* blank on a future model that has not yet been cataloged here.
694+
*/
695+
export function formatFallbackModelLabel(modelId: string | undefined): string {
696+
if (modelId === 'claude-fable-5' || modelId?.startsWith('claude-fable-5-'))
697+
return 'Fable 5'
698+
if (modelId === 'claude-opus-5' || modelId?.startsWith('claude-opus-5-'))
699+
return 'Opus 5'
700+
return modelId ?? 'Fable 5'
701+
}
702+
680703
export function getFableRecoverySummary(
681704
state: SidebarState,
682705
sessionId: string,
@@ -685,7 +708,9 @@ export function getFableRecoverySummary(
685708
(candidate) => candidate.sessionId === sessionId,
686709
)
687710
if (!recovery) return undefined
688-
if (recovery.mode === 'fable') return 'Fable 5 · restored'
711+
if (recovery.mode === 'fable') {
712+
return `${formatFallbackModelLabel(recovery.requestedModelId)} · restored`
713+
}
689714
return `Opus 4.8 · ${recovery.remaining} left`
690715
}
691716

0 commit comments

Comments
 (0)