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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,7 @@ Both OpenCode and Pi packages can persistently request Anthropic fast mode for s
/claude-fast off
```

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

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

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ export function isFastModeSupportedModel(model: unknown) {
typeof model === 'string' &&
(model.startsWith('claude-opus-4-6') ||
model.startsWith('claude-opus-4-7') ||
model.startsWith('claude-opus-4-8'))
model.startsWith('claude-opus-4-8') ||
model.startsWith('claude-opus-5'))
)
}

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/fast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function buildFastModeStatusSummary(input?: { enabled?: boolean }) {
`- Enabled: ${enabled ? 'enabled' : 'disabled'}`,
'- Persisted: ~/.config/opencode/anthropic-auth.json',
'- Scope: adds Anthropic fast mode to supported Opus requests',
'- Supported models: claude-opus-4-6, claude-opus-4-7, and claude-opus-4-8',
'- Supported models: claude-opus-4-6, claude-opus-4-7, claude-opus-4-8, and claude-opus-5',
'- Request changes: adds `speed: "fast"` and the `fast-mode-2026-02-01` beta header',
'- Note: fast and standard speeds do not share prompt-cache prefixes',
].join('\n')
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,27 @@ export function isClaudeSonnet5Model(model: unknown) {
)
}

export const CLAUDE_OPUS_5_MODEL_ID = 'claude-opus-5'

/**
* Opus 5 has the same adaptive-thinking-by-default + `display: "omitted"`
* shape as Sonnet 5, so the injected thinking must also be summarized to be
* visible. Kept as its own constant per the PR #100 lesson — hook callers
* should reference the per-family name (`CLAUDE_OPUS_5_ADAPTIVE_THINKING`,
* not the Fable/Mythos alias) so a future divergence between the families
* only changes this re-export, not every call site.
*/
export const CLAUDE_OPUS_5_ADAPTIVE_THINKING =
CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING

export function isClaudeOpus5Model(model: unknown) {
return (
typeof model === 'string' &&
(model === CLAUDE_OPUS_5_MODEL_ID ||
model.startsWith(`${CLAUDE_OPUS_5_MODEL_ID}-`))
)
}

export function isOpenAIReasoningSignature(value: unknown): boolean {
if (typeof value !== 'string') return false
if (value.startsWith('gAAAA')) return true
Expand Down
62 changes: 61 additions & 1 deletion packages/core/src/tests/models.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, test } from 'bun:test'
import { isClaudeSonnet5Model } from '../models'
import {
CLAUDE_OPUS_5_ADAPTIVE_THINKING,
CLAUDE_OPUS_5_MODEL_ID,
isClaudeOpus5Model,
isClaudeSonnet5Model,
} from '../models'

describe('isClaudeSonnet5Model', () => {
test('matches the bare claude-sonnet-5 id', () => {
Expand Down Expand Up @@ -28,3 +33,58 @@ describe('isClaudeSonnet5Model', () => {
expect(isClaudeSonnet5Model(42)).toBe(false)
})
})

describe('isClaudeOpus5Model', () => {
test('exposes the bare id constant', () => {
expect(CLAUDE_OPUS_5_MODEL_ID).toBe('claude-opus-5')
})

test('matches the bare claude-opus-5 id', () => {
expect(isClaudeOpus5Model('claude-opus-5')).toBe(true)
})

test('matches the catalog claude-opus-5-fast variant', () => {
expect(isClaudeOpus5Model('claude-opus-5-fast')).toBe(true)
})

test('matches a dated claude-opus-5 snapshot', () => {
expect(isClaudeOpus5Model('claude-opus-5-20260701')).toBe(true)
})

test('matches the dated fast snapshot', () => {
expect(isClaudeOpus5Model('claude-opus-5-fast-20260701')).toBe(true)
})

test('does not match claude-opus-4-8', () => {
expect(isClaudeOpus5Model('claude-opus-4-8')).toBe(false)
})

test('does not match a non-dash prefix collision', () => {
expect(isClaudeOpus5Model('claude-opus-5x')).toBe(false)
})

test('does not match the Fable/Mythos ids', () => {
expect(isClaudeOpus5Model('claude-fable-5')).toBe(false)
expect(isClaudeOpus5Model('claude-mythos-5')).toBe(false)
})

test('does not match Sonnet 5', () => {
expect(isClaudeOpus5Model('claude-sonnet-5')).toBe(false)
})

test('does not match non-string input', () => {
expect(isClaudeOpus5Model(undefined)).toBe(false)
expect(isClaudeOpus5Model(42)).toBe(false)
expect(isClaudeOpus5Model(null)).toBe(false)
expect(isClaudeOpus5Model({})).toBe(false)
})
})

describe('CLAUDE_OPUS_5_ADAPTIVE_THINKING', () => {
test('matches the shared adaptive+summarized shape (alias of Fable/Mythos)', () => {
expect(CLAUDE_OPUS_5_ADAPTIVE_THINKING).toEqual({
type: 'adaptive',
display: 'summarized',
})
})
})
2 changes: 1 addition & 1 deletion packages/opencode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ Both OpenCode and Pi packages can persistently request Anthropic fast mode for s
/claude-fast off
```

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

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

Expand Down
31 changes: 22 additions & 9 deletions packages/opencode/src/fable-fallback.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { CLAUDE_FABLE_5_MODEL_ID } from '@cortexkit/anthropic-auth-core'
import {
CLAUDE_FABLE_5_MODEL_ID,
isClaudeOpus5Model,
} from '@cortexkit/anthropic-auth-core'

export const FABLE_FALLBACK_MODEL_ID = 'claude-opus-4-8'
export const FABLE_FALLBACK_TURNS = 10
Expand Down Expand Up @@ -37,11 +40,21 @@ type FableFallbackState = {
updatedAt: number
}

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

Expand Down Expand Up @@ -79,7 +92,7 @@ export class FableFallbackManager {
): FableFallbackPlan | null {
if (!sessionId || typeof bodyText !== 'string') return null
const parsed = parseBody(bodyText)
if (!parsed || !isFableModel(parsed.model)) return null
if (!parsed || !isRecoverableRefusalModel(parsed.model)) return null

this.prune()
const state = this.sessions.get(sessionId)
Expand Down Expand Up @@ -135,7 +148,7 @@ export class FableFallbackManager {
}

activate(plan: FableFallbackPlan, cacheAccountId?: string): number {
if (plan.downgraded || !isFableModel(plan.requestedModel)) {
if (plan.downgraded || !isRecoverableRefusalModel(plan.requestedModel)) {
return this.remaining(plan.sessionId)
}
const state: FableFallbackState = {
Expand Down Expand Up @@ -179,9 +192,9 @@ export class FableFallbackManager {
state.standbyAnchorSequence = plan.requestSequence
}
state.updatedAt = this.now()
// Retain the zero-remaining state so a later Fable refusal can bridge back
// Retain the zero-remaining state so a later refusal can bridge back
// to this model-specific Opus cache boundary without continuously warming
// Opus while Fable is healthy.
// Opus while the original model is healthy.
this.sessions.delete(plan.sessionId)
this.sessions.set(plan.sessionId, state)
return { counted: true, remaining: state.remaining }
Expand Down
45 changes: 39 additions & 6 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
isCacheKeepHybridActive,
isCacheKeepPersistentlyEnabled,
isCacheKeepSubagentsEnabled,
isClaudeOpus5Model,
isCostZeroingEnabled,
isDumpPersistentlyEnabled,
isFastModeEnabled,
Expand Down Expand Up @@ -631,6 +632,26 @@ const FABLE_SWITCHED_TO_OPUS_NOTICE =
const FABLE_RESTORED_NOTICE =
'Fable recovery window complete. Returning to Fable 5.'

/**
* Compose the recovery-window notice text for the requested model. Fable 5 and
* Opus 5 follow the same Anthropic-recommended fallback (Opus 4.8) for the
* same reason (cyber safety classifiers returning `stop_reason: "refusal"`),
* so the message structure is shared; only the model name moves.
*/
function buildSwitchedToOpusNotice(modelId: string): string {
if (isClaudeOpus5Model(modelId)) {
return 'Opus 5 content filter detected. Switched to Opus 4.8 for a 10-response recovery window while keeping the Opus 5 cache warm.'
}
return FABLE_SWITCHED_TO_OPUS_NOTICE
}

function buildRestoredNotice(modelId: string): string {
if (isClaudeOpus5Model(modelId)) {
return 'Opus 5 recovery window complete. Returning to Opus 5.'
}
return FABLE_RESTORED_NOTICE
}

type AnthropicProviderModel = {
id?: string
name?: string
Expand Down Expand Up @@ -1148,6 +1169,9 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {

function warmFableAfterOpus(context: FableRequestContext) {
const sessionId = context.plan.sessionId
const modelLabel = isClaudeOpus5Model(context.plan.requestedModel)
? 'Opus 5'
: 'Fable'
const run = async () => {
const target = context.warmTarget
if (!target) {
Expand Down Expand Up @@ -1180,20 +1204,20 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
target.oauthAccountId,
})
if (result.ok) {
logger.debug('fable-fallback', 'Fable cache warmed', {
logger.debug('fable-fallback', `${modelLabel} cache warmed`, {
session: sessionId,
remaining: fableFallbackManager.remaining(sessionId),
...(result.usage && { usage: result.usage }),
})
return
}
logger.warn('fable-fallback', 'Fable cache warm skipped', {
logger.warn('fable-fallback', `${modelLabel} cache warm skipped`, {
session: sessionId,
status: result.status,
reason: result.reason,
})
} catch (error) {
logger.warn('fable-fallback', 'Fable cache warm failed', {
logger.warn('fable-fallback', `${modelLabel} cache warm failed`, {
session: sessionId,
error: error instanceof Error ? error.message : String(error),
})
Expand Down Expand Up @@ -3898,6 +3922,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
const wrapResponse = (response: Response) =>
createStrippedStream(response, {
perf: (stage, data) => trace.mark(stage, data),
contentFilterModel: fablePlan?.requestedModel,
...(!fablePlan?.downgraded && fablePlan
? {
onContentFilter: () => {
Expand All @@ -3916,17 +3941,22 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
logger.info(
'fable-fallback',
'content filter detected; switching session to Opus 4.8',
{ session: fablePlan.sessionId, remaining },
{
session: fablePlan.sessionId,
requestedModel: fablePlan.requestedModel,
remaining,
},
)
publishFableRecoveryNotice(
{
sessionId: fablePlan.sessionId,
mode: 'opus',
remaining,
requestedModelId: fablePlan.requestedModel,
},
storage,
auth,
FABLE_SWITCHED_TO_OPUS_NOTICE,
buildSwitchedToOpusNotice(fablePlan.requestedModel),
)
},
}
Expand All @@ -3944,6 +3974,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
'Opus 4.8 turn completed',
{
session: fablePlan.sessionId,
requestedModel: fablePlan.requestedModel,
finishReason,
remaining: completed.remaining,
},
Expand All @@ -3953,6 +3984,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
sessionId: fablePlan.sessionId,
mode: 'opus',
remaining: completed.remaining,
requestedModelId: fablePlan.requestedModel,
},
storage,
auth,
Expand All @@ -3971,10 +4003,11 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
sessionId: fablePlan.sessionId,
mode: 'fable',
remaining: 0,
requestedModelId: fablePlan.requestedModel,
},
storage,
auth,
FABLE_RESTORED_NOTICE,
buildRestoredNotice(fablePlan.requestedModel),
)
}
void warm.then(notifyRestored, notifyRestored)
Expand Down
27 changes: 26 additions & 1 deletion packages/opencode/src/sidebar-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ export interface FableRecoverySidebarState {
mode: 'opus' | 'fable'
remaining: number
changedAt: number
/**
* The model id the user originally requested (e.g. 'claude-fable-5' or
* 'claude-opus-5'). Optional for backward compatibility with pre-Opus-5
* state files; older recoveries default to Fable 5 in the summary.
*/
requestedModelId?: string
}

export interface SidebarState {
Expand Down Expand Up @@ -287,6 +293,10 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
mode: recovery.mode,
remaining: Math.max(0, Math.floor(recovery.remaining)),
changedAt: recovery.changedAt,
requestedModelId:
typeof recovery.requestedModelId === 'string'
? recovery.requestedModelId
: undefined,
},
]
})
Expand Down Expand Up @@ -677,6 +687,19 @@ export function formatScopedQuotaLabel(title: string) {
return /^fable$/i.test(label) ? 'Fa' : label
}

/**
* Pretty label for a recoverable-refusal source model (Fable 5 or Opus 5).
* Falls back to the raw id for unrecognized ids so the sidebar never goes
* blank on a future model that has not yet been cataloged here.
*/
export function formatFallbackModelLabel(modelId: string | undefined): string {
if (modelId === 'claude-fable-5' || modelId?.startsWith('claude-fable-5-'))
return 'Fable 5'
if (modelId === 'claude-opus-5' || modelId?.startsWith('claude-opus-5-'))
return 'Opus 5'
return modelId ?? 'Fable 5'
}

export function getFableRecoverySummary(
state: SidebarState,
sessionId: string,
Expand All @@ -685,7 +708,9 @@ export function getFableRecoverySummary(
(candidate) => candidate.sessionId === sessionId,
)
if (!recovery) return undefined
if (recovery.mode === 'fable') return 'Fable 5 · restored'
if (recovery.mode === 'fable') {
return `${formatFallbackModelLabel(recovery.requestedModelId)} · restored`
}
return `Opus 4.8 · ${recovery.remaining} left`
}

Expand Down
Loading