diff --git a/README.md b/README.md index a94461f3..75d7a26e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index ee6bc43c..3f1b8577 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -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')) ) } diff --git a/packages/core/src/fast.ts b/packages/core/src/fast.ts index c742a06d..4f10cc92 100644 --- a/packages/core/src/fast.ts +++ b/packages/core/src/fast.ts @@ -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') diff --git a/packages/core/src/models.ts b/packages/core/src/models.ts index d5b22566..06e7dbcd 100644 --- a/packages/core/src/models.ts +++ b/packages/core/src/models.ts @@ -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 diff --git a/packages/core/src/tests/models.test.ts b/packages/core/src/tests/models.test.ts index c648216a..4797bc97 100644 --- a/packages/core/src/tests/models.test.ts +++ b/packages/core/src/tests/models.test.ts @@ -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', () => { @@ -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', + }) + }) +}) diff --git a/packages/opencode/README.md b/packages/opencode/README.md index 50a5e8bb..2911343e 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -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. diff --git a/packages/opencode/src/fable-fallback.ts b/packages/opencode/src/fable-fallback.ts index 4057b8c2..b57402fe 100644 --- a/packages/opencode/src/fable-fallback.ts +++ b/packages/opencode/src/fable-fallback.ts @@ -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 @@ -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}-`) ) } @@ -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) @@ -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 = { @@ -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 } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index dbe867b1..b64e34b7 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -64,6 +64,7 @@ import { isCacheKeepHybridActive, isCacheKeepPersistentlyEnabled, isCacheKeepSubagentsEnabled, + isClaudeOpus5Model, isCostZeroingEnabled, isDumpPersistentlyEnabled, isFastModeEnabled, @@ -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 @@ -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) { @@ -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), }) @@ -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: () => { @@ -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), ) }, } @@ -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, }, @@ -3953,6 +3984,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { sessionId: fablePlan.sessionId, mode: 'opus', remaining: completed.remaining, + requestedModelId: fablePlan.requestedModel, }, storage, auth, @@ -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) diff --git a/packages/opencode/src/sidebar-state.ts b/packages/opencode/src/sidebar-state.ts index 171f9dea..f9d1107f 100644 --- a/packages/opencode/src/sidebar-state.ts +++ b/packages/opencode/src/sidebar-state.ts @@ -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 { @@ -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, }, ] }) @@ -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, @@ -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` } diff --git a/packages/opencode/src/tests/fable-fallback.test.ts b/packages/opencode/src/tests/fable-fallback.test.ts index c8a58ed5..9b043983 100644 --- a/packages/opencode/src/tests/fable-fallback.test.ts +++ b/packages/opencode/src/tests/fable-fallback.test.ts @@ -164,4 +164,158 @@ describe('FableFallbackManager', () => { expect(manager.complete(oldOpus)).toEqual({ counted: false, remaining: 10 }) }) + + test('routes dated Fable snapshot to Opus 4.8 after filter activation', () => { + const manager = new FableFallbackManager() + const datedModel = 'claude-fable-5-20260608' + const filtered = manager.plan('session-a', body(datedModel))! + expect(manager.activate(filtered, 'fable-account')).toBe( + FABLE_FALLBACK_TURNS, + ) + + const plan = manager.plan('session-a', body(datedModel))! + expect(plan.downgraded).toBe(true) + expect(plan.requestedModel).toBe(datedModel) + expect(plan.effectiveModel).toBe(FABLE_FALLBACK_MODEL_ID) + expect(JSON.parse(plan.bodyText).model).toBe(FABLE_FALLBACK_MODEL_ID) + }) +}) + +describe('FableFallbackManager — Opus 5 recovery parity', () => { + test('leaves Opus 5 unchanged until its content filter activates a session', () => { + const manager = new FableFallbackManager() + const plan = manager.plan('session-a', body('claude-opus-5')) + + expect(plan).toMatchObject({ + requestedModel: 'claude-opus-5', + effectiveModel: 'claude-opus-5', + downgraded: false, + }) + expect(JSON.parse(plan!.bodyText).model).toBe('claude-opus-5') + }) + + test('matches the claude-opus-5-fast catalog variant', () => { + const manager = new FableFallbackManager() + const plan = manager.plan('session-a', body('claude-opus-5-fast')) + + expect(plan).toMatchObject({ + requestedModel: 'claude-opus-5-fast', + effectiveModel: 'claude-opus-5-fast', + downgraded: false, + }) + }) + + test('matches the dated claude-opus-5-20260701 snapshot', () => { + const manager = new FableFallbackManager() + const plan = manager.plan('session-a', body('claude-opus-5-20260701')) + + expect(plan).toMatchObject({ + requestedModel: 'claude-opus-5-20260701', + effectiveModel: 'claude-opus-5-20260701', + downgraded: false, + }) + }) + + test('routes the next ten successful Opus 5 requests to Opus 4.8', () => { + const manager = new FableFallbackManager() + const filtered = manager.plan('session-a', body('claude-opus-5'))! + expect(manager.activate(filtered, 'opus5-account')).toBe( + FABLE_FALLBACK_TURNS, + ) + + for ( + let remaining = FABLE_FALLBACK_TURNS - 1; + remaining >= 0; + remaining-- + ) { + const plan = manager.plan('session-a', body('claude-opus-5'))! + expect(plan.downgraded).toBe(true) + expect(plan.requestedModel).toBe('claude-opus-5') + expect(plan.effectiveModel).toBe(FABLE_FALLBACK_MODEL_ID) + expect(plan.cacheAccountId).toBe('opus5-account') + expect(JSON.parse(plan.bodyText).model).toBe(FABLE_FALLBACK_MODEL_ID) + expect(manager.complete(plan)).toEqual({ counted: true, remaining }) + } + + const restored = manager.plan('session-a', body('claude-opus-5'))! + expect(restored.downgraded).toBe(false) + expect(restored.requestedModel).toBe('claude-opus-5') + expect(JSON.parse(restored.bodyText).model).toBe('claude-opus-5') + }) + + test('keeps Opus 5 downgrade state isolated by session and ignores other models', () => { + const manager = new FableFallbackManager() + manager.activate(manager.plan('session-a', body('claude-opus-5'))!) + + expect(manager.plan('session-a', body('claude-opus-5'))?.downgraded).toBe( + true, + ) + expect(manager.plan('session-b', body('claude-opus-5'))?.downgraded).toBe( + false, + ) + expect(manager.plan('session-a', body('claude-opus-4-8'))).toBeNull() + expect(manager.plan('session-a', body('claude-mythos-5'))).toBeNull() + expect(manager.plan('session-a', body('claude-sonnet-5'))).toBeNull() + expect(manager.plan(undefined, body('claude-opus-5'))).toBeNull() + }) + + test('rebinds an active Opus 5 recovery cycle when sticky routing migrates accounts', () => { + const manager = new FableFallbackManager() + manager.activate(manager.plan('session-a', body('claude-opus-5'))!, 'old') + const lateOldRoute = manager.plan('session-a', body('claude-opus-5'))! + const migrated = manager.plan('session-a', body('claude-opus-5'))! + + expect(manager.bindRecoveryAccount(migrated, 'new')).toBe(true) + expect(migrated.cacheAccountId).toBe('new') + expect(manager.recoveryAccount(lateOldRoute)).toBe('new') + }) + + test('retains the newest Opus 5 cache anchor across a restored period', () => { + const manager = new FableFallbackManager() + manager.activate(manager.plan('session-a', body('claude-opus-5'))!) + + for (let index = 0; index < FABLE_FALLBACK_TURNS; index++) { + const plan = manager.plan('session-a', body('claude-opus-5'))! + expect( + manager.complete(plan, { + fingerprint: `anchor-${index}`, + messageIndex: 10 + index * 2, + messageCount: 11 + index * 2, + oauthAccountId: 'fallback-account', + }).counted, + ).toBe(true) + } + + const restored = manager.plan('session-a', body('claude-opus-5'))! + expect(restored).toMatchObject({ + downgraded: false, + requestedModel: 'claude-opus-5', + standbyCacheAnchor: { + fingerprint: 'anchor-9', + messageIndex: 28, + messageCount: 29, + oauthAccountId: 'fallback-account', + }, + }) + + manager.activate(restored, 'opus5-account') + const nextCycle = manager.plan('session-a', body('claude-opus-5'))! + expect(nextCycle).toMatchObject({ + downgraded: true, + cacheAccountId: 'opus5-account', + standbyCacheAnchor: restored.standbyCacheAnchor, + }) + }) + + test('Opus 5 recovery does not touch unrelated sessions', () => { + const manager = new FableFallbackManager() + manager.activate(manager.plan('session-a', body('claude-opus-5'))!) + + const otherSession = manager.plan('session-b', body('claude-opus-5')) + expect(otherSession).toMatchObject({ + requestedModel: 'claude-opus-5', + effectiveModel: 'claude-opus-5', + downgraded: false, + }) + }) }) diff --git a/packages/opencode/src/tests/fast.test.ts b/packages/opencode/src/tests/fast.test.ts index 26462de9..f07f8566 100644 --- a/packages/opencode/src/tests/fast.test.ts +++ b/packages/opencode/src/tests/fast.test.ts @@ -26,7 +26,7 @@ describe('claude-fast command state', () => { expect(summary).toContain('## Claude Fast Mode Status') expect(parseEnabled(summary)).toBe(false) expect(summary).toContain( - 'claude-opus-4-6, claude-opus-4-7, and claude-opus-4-8', + 'claude-opus-4-6, claude-opus-4-7, claude-opus-4-8, and claude-opus-5', ) }) diff --git a/packages/opencode/src/tests/sidebar-state.test.ts b/packages/opencode/src/tests/sidebar-state.test.ts index 6deb7b20..5507956a 100644 --- a/packages/opencode/src/tests/sidebar-state.test.ts +++ b/packages/opencode/src/tests/sidebar-state.test.ts @@ -19,6 +19,7 @@ import { DEFAULT_SIDEBAR_STATE, drainSidebarWrites, FIVE_HOUR_MS, + formatFallbackModelLabel, getCollapsedQuotaSummary, getFableRecoverySummary, getSidebarState, @@ -187,12 +188,14 @@ describe('getFableRecoverySummary', () => { mode: 'opus', remaining: 7, changedAt: 123, + requestedModelId: 'claude-fable-5', }, { sessionId: 'ses_other', mode: 'fable', remaining: 0, changedAt: 124, + requestedModelId: 'claude-fable-5', }, ], }) @@ -214,6 +217,7 @@ describe('getFableRecoverySummary', () => { mode: 'fable', remaining: 0, changedAt: 456, + requestedModelId: 'claude-fable-5', }, ], }) @@ -222,6 +226,73 @@ describe('getFableRecoverySummary', () => { 'Fable 5 · restored', ) }) + + test('names the originally-requested model when an Opus 5 session is restored', () => { + const state = make({ + fableRecoveries: [ + { + sessionId: 'ses_opus5', + mode: 'opus', + remaining: 4, + changedAt: 100, + requestedModelId: 'claude-opus-5', + }, + { + sessionId: 'ses_opus5_fast', + mode: 'fable', + remaining: 0, + changedAt: 200, + requestedModelId: 'claude-opus-5-fast', + }, + ], + }) + + expect(getFableRecoverySummary(state, 'ses_opus5')).toBe( + 'Opus 4.8 · 4 left', + ) + expect(getFableRecoverySummary(state, 'ses_opus5_fast')).toBe( + 'Opus 5 · restored', + ) + }) + + test('defaults to Fable 5 when an older recovery has no requestedModelId', () => { + const state = make({ + fableRecoveries: [ + { + sessionId: 'ses_legacy', + mode: 'fable', + remaining: 0, + changedAt: 1, + }, + ], + }) + + expect(getFableRecoverySummary(state, 'ses_legacy')).toBe( + 'Fable 5 · restored', + ) + }) +}) + +describe('formatFallbackModelLabel', () => { + test('formats Fable 5 ids', () => { + expect(formatFallbackModelLabel('claude-fable-5')).toBe('Fable 5') + expect(formatFallbackModelLabel('claude-fable-5-20260608')).toBe('Fable 5') + }) + + test('formats Opus 5 ids', () => { + expect(formatFallbackModelLabel('claude-opus-5')).toBe('Opus 5') + expect(formatFallbackModelLabel('claude-opus-5-fast')).toBe('Opus 5') + expect(formatFallbackModelLabel('claude-opus-5-20260701')).toBe('Opus 5') + }) + + test('falls back to the raw id for unrecognized models', () => { + expect(formatFallbackModelLabel('claude-fable-99')).toBe('claude-fable-99') + expect(formatFallbackModelLabel('claude-opus-4-8')).toBe('claude-opus-4-8') + }) + + test('defaults to Fable 5 when the id is missing', () => { + expect(formatFallbackModelLabel(undefined)).toBe('Fable 5') + }) }) describe('getCollapsedQuotaSummary', () => { diff --git a/packages/opencode/src/tests/transform.test.ts b/packages/opencode/src/tests/transform.test.ts index f37f86d5..cf56cbf4 100644 --- a/packages/opencode/src/tests/transform.test.ts +++ b/packages/opencode/src/tests/transform.test.ts @@ -679,6 +679,70 @@ describe('createStrippedStream', () => { expect(completed).toBe(0) }) + test('refusal error message names Opus 5 when contentFilterModel is set', async () => { + const encoder = new TextEncoder() + const chunks = [ + 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1"}}\n\n', + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_', + 'reason":"refusal"},"usage":{"output_tokens":0}}\n\n', + ] + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + controller.close() + }, + }) + const response = createStrippedStream(new Response(stream), { + onContentFilter: () => true, + contentFilterModel: 'claude-opus-5', + }) + const reader = response.body!.getReader() + expect((await reader.read()).done).toBe(false) + + let caught: unknown + try { + while (!(await reader.read()).done) {} + } catch (error) { + caught = error + } + + expect((caught as { code?: string }).code).toBe('ECONNRESET') + expect((caught as { providerErrorType?: string }).providerErrorType).toBe( + 'refusal', + ) + expect((caught as Error).message).toContain('Opus 5') + expect((caught as Error).message).not.toContain('Fable') + }) + + test('refusal error message uses Fable when contentFilterModel not set', async () => { + const encoder = new TextEncoder() + const chunks = [ + 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1"}}\n\n', + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_', + 'reason":"refusal"},"usage":{"output_tokens":0}}\n\n', + ] + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + controller.close() + }, + }) + const response = createStrippedStream(new Response(stream), { + onContentFilter: () => true, + }) + const reader = response.body!.getReader() + expect((await reader.read()).done).toBe(false) + + let caught: unknown + try { + while (!(await reader.read()).done) {} + } catch (error) { + caught = error + } + + expect((caught as Error).message).toContain('Fable') + }) + test('signals successful Anthropic finishes exactly once', async () => { const finishes: string[] = [] const response = createStrippedStream( @@ -780,6 +844,25 @@ describe('prepareFableCacheWarmSource', () => { expect(body.output_config).toEqual({ effort: 'xhigh' }) expect(body.messages).toEqual([{ role: 'user', content: 'same input' }]) }) + + test('restores an Opus 5 request to claude-opus-5 when explicitly requested', () => { + const source = prepareFableCacheWarmSource( + JSON.stringify({ + model: 'claude-opus-4-8', + speed: 'fast', + thinking: { type: 'enabled', budget_tokens: 4096 }, + output_config: { effort: 'xhigh' }, + messages: [{ role: 'user', content: 'same input' }], + }), + 'claude-opus-5', + ) + + expect(source.ok).toBe(true) + if (!source.ok) throw new Error(source.reason) + const body = JSON.parse(source.bodyText) + expect(body.model).toBe('claude-opus-5') + expect(body.thinking).toEqual({ type: 'adaptive', display: 'summarized' }) + }) }) describe('sanitizeSystemText', () => { @@ -1014,6 +1097,19 @@ describe('rewriteRequestBody', () => { expect(result.speed).toBe('fast') }) + test('sets fast speed for Opus 5 when fast mode is enabled', async () => { + const body = JSON.stringify({ + model: 'claude-opus-5', + messages: [{ role: 'user', content: 'hi' }], + }) + + const result = JSON.parse( + await rewriteRequestBody(body, { fastModeEnabled: true }), + ) + + expect(result.speed).toBe('fast') + }) + test('does not set fast speed for unsupported models', async () => { for (const model of ['claude-sonnet-4-5', 'claude-fable-5']) { const body = JSON.stringify({ @@ -1103,6 +1199,173 @@ describe('rewriteRequestBody', () => { expect(result.thinking).toEqual({ type: 'enabled', budget_tokens: 5_000 }) }) + test('requests summarized adaptive thinking for Opus 5 without thinking', async () => { + const body = JSON.stringify({ + model: 'claude-opus-5', + messages: [{ role: 'user', content: 'hi' }], + }) + + const result = JSON.parse(await rewriteRequestBody(body)) + + expect(result.thinking).toEqual({ type: 'adaptive', display: 'summarized' }) + }) + + test('replaces manual enabled thinking with adaptive summarized for Opus 5', async () => { + const body = JSON.stringify({ + model: 'claude-opus-5-20260701', + messages: [{ role: 'user', content: 'hi' }], + thinking: { type: 'enabled', budget_tokens: 10_000 }, + }) + + const result = JSON.parse(await rewriteRequestBody(body)) + + expect(result.thinking).toEqual({ type: 'adaptive', display: 'summarized' }) + expect(result.thinking.budget_tokens).toBeUndefined() + }) + + test('preserves explicitly disabled thinking for Opus 5', async () => { + const body = JSON.stringify({ + model: 'claude-opus-5', + messages: [{ role: 'user', content: 'hi' }], + thinking: { type: 'disabled' }, + }) + + const result = JSON.parse(await rewriteRequestBody(body)) + + expect(result.thinking).toEqual({ type: 'disabled' }) + }) + + test('strips display from disabled thinking for Opus 5', async () => { + const body = JSON.stringify({ + model: 'claude-opus-5', + messages: [{ role: 'user', content: 'hi' }], + thinking: { type: 'disabled', display: 'summarized' }, + }) + + const result = JSON.parse(await rewriteRequestBody(body)) + + expect(result.thinking).toEqual({ type: 'disabled' }) + }) + + test('lowers Opus 5 disabled thinking + effort xhigh to effort high', async () => { + const body = JSON.stringify({ + model: 'claude-opus-5', + messages: [{ role: 'user', content: 'hi' }], + thinking: { type: 'disabled' }, + output_config: { effort: 'xhigh' }, + }) + + const result = JSON.parse(await rewriteRequestBody(body)) + + expect(result.thinking).toEqual({ type: 'disabled' }) + expect(result.output_config).toEqual({ effort: 'high' }) + }) + + test('lowers Opus 5 disabled thinking + effort max to effort high', async () => { + const body = JSON.stringify({ + model: 'claude-opus-5', + messages: [{ role: 'user', content: 'hi' }], + thinking: { type: 'disabled' }, + output_config: { effort: 'max' }, + }) + + const result = JSON.parse(await rewriteRequestBody(body)) + + expect(result.thinking).toEqual({ type: 'disabled' }) + expect(result.output_config).toEqual({ effort: 'high' }) + }) + + test('leaves Opus 5 disabled thinking effort untouched at high or below', async () => { + for (const effort of ['high', 'medium', 'low']) { + const body = JSON.stringify({ + model: 'claude-opus-5', + messages: [{ role: 'user', content: 'hi' }], + thinking: { type: 'disabled' }, + output_config: { effort }, + }) + + const result = JSON.parse(await rewriteRequestBody(body)) + + expect(result.thinking).toEqual({ type: 'disabled' }) + expect(result.output_config).toEqual({ effort }) + } + }) + + test('preserves Opus 5 adaptive thinking effort at xhigh (no lower applied)', async () => { + const body = JSON.stringify({ + model: 'claude-opus-5', + messages: [{ role: 'user', content: 'hi' }], + output_config: { effort: 'xhigh' }, + }) + + const result = JSON.parse(await rewriteRequestBody(body)) + + expect(result.thinking).toEqual({ type: 'adaptive', display: 'summarized' }) + expect(result.output_config).toEqual({ effort: 'xhigh' }) + }) + + test('does not touch thinking for non-Opus5 models', async () => { + const body = JSON.stringify({ + model: 'claude-fable-5', + messages: [{ role: 'user', content: 'hi' }], + thinking: { type: 'enabled', budget_tokens: 5_000 }, + }) + + const result = JSON.parse(await rewriteRequestBody(body)) + + expect(result.thinking).toEqual({ type: 'adaptive', display: 'summarized' }) + }) + + test('downgraded Opus 5 recovery body keeps Opus 4.8 thinking semantics (no Opus 5 injection)', async () => { + // After fable-fallback rewrites the body to claude-opus-4-8, the Opus 5 + // normalizer must NOT fire — the predicted 4.8 body should preserve the + // user's manual thinking settings (they would have been valid for 4.8 + // before the recovery started; only the model id changed). + const body = JSON.stringify({ + model: 'claude-opus-4-8', + messages: [{ role: 'user', content: 'hi' }], + thinking: { type: 'enabled', budget_tokens: 8_000 }, + }) + + const result = JSON.parse(await rewriteRequestBody(body)) + + expect(result.thinking).toEqual({ type: 'enabled', budget_tokens: 8_000 }) + }) + + test('downgraded Opus 5 recovery with disabled+xhigh preserves thinking and effort (normalizer bypass)', async () => { + // After recovery rewrites model→claude-opus-4-8, the Opus 5 normalizer + // must NOT fire. A disabled+xhigh pair is invalid for Opus 5 (400) but + // perfectly valid for 4.8 (where thinking is not adaptive-by-default + // and no effort constraint exists). The normalizer must only match + // actual Opus 5 bodies — not recovered 4.8 bodies that carry the + // user's original settings. + const body = JSON.stringify({ + model: 'claude-opus-4-8', + messages: [{ role: 'user', content: 'hi' }], + thinking: { type: 'disabled' }, + output_config: { effort: 'xhigh' }, + }) + + const result = JSON.parse(await rewriteRequestBody(body)) + + expect(result.thinking).toEqual({ type: 'disabled' }) + expect(result.output_config).toEqual({ effort: 'xhigh' }) + }) + + test('downgraded Opus 5 recovery with disabled+max preserves thinking and effort (normalizer bypass)', async () => { + const body = JSON.stringify({ + model: 'claude-opus-4-8', + messages: [{ role: 'user', content: 'hi' }], + thinking: { type: 'disabled' }, + output_config: { effort: 'max' }, + }) + + const result = JSON.parse(await rewriteRequestBody(body)) + + expect(result.thinking).toEqual({ type: 'disabled' }) + expect(result.output_config).toEqual({ effort: 'max' }) + }) + test('strips OpenAI encrypted reasoning blocks before sending to Anthropic', async () => { const body = JSON.stringify({ model: 'claude-opus-4-8', diff --git a/packages/opencode/src/transform.ts b/packages/opencode/src/transform.ts index fff6755e..0517cc6f 100644 --- a/packages/opencode/src/transform.ts +++ b/packages/opencode/src/transform.ts @@ -8,10 +8,12 @@ import { CLAUDE_CODE_IDENTITY, CLAUDE_FABLE_5_MODEL_ID, CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING, + CLAUDE_OPUS_5_ADAPTIVE_THINKING, CLAUDE_SONNET_5_ADAPTIVE_THINKING, type ClaudeCodeIdentity, FAST_MODE_BETA, isClaudeFableOrMythos5Model, + isClaudeOpus5Model, isClaudeSonnet5Model, isFastModeSupportedModel, isOpenAIReasoningSignature, @@ -969,6 +971,37 @@ function normalizeSonnet5Request( return { replacedExisting: hadThinking, display: 'summarized' } } +/** + * Opus 5 mirrors Sonnet 5: adaptive thinking on by default with + * `display: "omitted"`, so the synthesized request must inject a summarized + * shape to be visible. Unlike Fable/Mythos (which reject a disable), Opus 5 + * accepts `{type:"disabled"}` — but only at effort `high` or below; pairing + * `disabled` with `output_config.effort` of `xhigh` or `max` 400s server-side + * per request. The user's intent to disable thinking wins over their effort + * setting, so when both are present we rewrite `effort` down to `high` to + * satisfy the API contract. When thinking is enabled (or absent), the effort + * value is left untouched — only the disable pair is constrained. + */ +function normalizeOpus5Request( + parsed: Record, +): { replacedExisting: boolean; display: 'summarized' | 'disabled' } | null { + if (!isClaudeOpus5Model(parsed.model)) return null + const hadThinking = Object.hasOwn(parsed, 'thinking') + const thinking = parsed.thinking + if (isRecord(thinking) && thinking.type === 'disabled') { + parsed.thinking = { type: 'disabled' } + const outputConfig = parsed.output_config + if (isRecord(outputConfig) && outputConfig.effort === 'xhigh') { + outputConfig.effort = 'high' + } else if (isRecord(outputConfig) && outputConfig.effort === 'max') { + outputConfig.effort = 'high' + } + return { replacedExisting: hadThinking, display: 'disabled' } + } + parsed.thinking = { ...CLAUDE_OPUS_5_ADAPTIVE_THINKING } + return { replacedExisting: hadThinking, display: 'summarized' } +} + export function prepareFableCacheWarmSource( bodyText: string, fableModel = CLAUDE_FABLE_5_MODEL_ID, @@ -978,6 +1011,7 @@ export function prepareFableCacheWarmSource( body.model = fableModel delete body.speed normalizeFableMythosRequest(body) + normalizeOpus5Request(body) return { ok: true, bodyText: JSON.stringify(body) } } catch { return { ok: false, reason: 'body is not valid JSON' } @@ -1116,6 +1150,7 @@ export async function rewriteRequestBody( const removedNonAnthropicThinking = stripNonAnthropicThinkingBlocks(parsed) const fableMythosThinking = normalizeFableMythosRequest(parsed) const sonnet5Thinking = normalizeSonnet5Request(parsed) + const opus5Thinking = normalizeOpus5Request(parsed) options.perf?.('model_normalize', { ms: rewriteRoundMs(rewriteNowMs() - modelNormalizeStart), model: typeof parsed.model === 'string' ? parsed.model : undefined, @@ -1126,6 +1161,8 @@ export async function rewriteRequestBody( fableMythosThinking?.replacedExisting ?? false, sonnet5ThinkingDisplay: sonnet5Thinking?.display, replacedSonnet5Thinking: sonnet5Thinking?.replacedExisting ?? false, + opus5ThinkingDisplay: opus5Thinking?.display, + replacedOpus5Thinking: opus5Thinking?.replacedExisting ?? false, removedNonAnthropicThinking, hasOutputConfig: Object.hasOwn(parsed, 'output_config'), }) @@ -1486,9 +1523,12 @@ function retryableAnthropicStreamError( return error } -function retryableFableContentFilterError(): RetryableAnthropicStreamError { +function retryableFableContentFilterError( + model: unknown, +): RetryableAnthropicStreamError { + const label = isClaudeOpus5Model(model) ? 'Opus 5' : 'Fable' const error = new Error( - 'Fable response was blocked by the provider content filter; retrying with Opus 4.8', + `${label} response was blocked by the provider content filter; retrying with Opus 4.8`, ) as RetryableAnthropicStreamError error.code = 'ECONNRESET' error.syscall = 'anthropic-sse' @@ -1589,6 +1629,7 @@ export function createStrippedStream( perf?: RewritePerfCallback onContentFilter?: () => boolean | undefined onComplete?: (finishReason: string) => void + contentFilterModel?: unknown } = {}, ): Response { if (!response.body) return response @@ -1618,7 +1659,9 @@ export function createStrippedStream( const update = updateSseFinishState(sseFinish, text) if (update?.type === 'content-filter' && options.onContentFilter) { const handled = options.onContentFilter() - return handled === false ? null : retryableFableContentFilterError() + return handled === false + ? null + : retryableFableContentFilterError(options.contentFilterModel) } if (update?.type === 'complete') options.onComplete?.(update.finishReason) return null diff --git a/packages/pi/README.md b/packages/pi/README.md index 5840233d..81c7e3e9 100644 --- a/packages/pi/README.md +++ b/packages/pi/README.md @@ -76,7 +76,7 @@ The sidecar uses the same JSON shape as the OpenCode package, including `routing /claude-quota ``` -`/claude-quota` reports sidecar OAuth fallback quota state from `~/.pi/agent/anthropic-auth.json`. `/claude-routing fallback-first` prefers usable OAuth fallback accounts before the main account; `/claude-routing main-first` restores the default. `/claude-routing sticky-balanced` assigns each Pi session to an OAuth account according to current 5-hour, 7-day, and matching model-scoped quota headroom, then persists that assignment across transient failures and process restarts. `/claude-routing reset` clears the current Pi session's assignment. Direct Opus sessions prefer usable accounts whose Fable quota is exhausted. API-key routes use the same sidecar shape as OpenCode and are sent directly to their configured Anthropic-compatible base URL, such as Kie's `https://api.kie.ai/claude`, but Pi only uses them after the main OAuth model response reports HTTP 429 or a streaming rate-limit error and a live quota check confirms 0% remaining. `/claude-cachekeep always` keeps active hybrid caches warm while Pi remains open; `/claude-cachekeep HH-HH` limits prewarms to a local time window. Both send `max_tokens: 0` pre-warm requests about five minutes before the 1-hour TTL expires. Running `/claude-cachekeep` without arguments lists live tracked sessions across Pi processes through a temporary lease registry that stores only session IDs and cache timing. `/claude-fast on` adds Anthropic `speed: "fast"` plus the `fast-mode-2026-02-01` beta header for supported Opus models (`claude-opus-4-6`, `claude-opus-4-7`, and `claude-opus-4-8`). +`/claude-quota` reports sidecar OAuth fallback quota state from `~/.pi/agent/anthropic-auth.json`. `/claude-routing fallback-first` prefers usable OAuth fallback accounts before the main account; `/claude-routing main-first` restores the default. `/claude-routing sticky-balanced` assigns each Pi session to an OAuth account according to current 5-hour, 7-day, and matching model-scoped quota headroom, then persists that assignment across transient failures and process restarts. `/claude-routing reset` clears the current Pi session's assignment. Direct Opus sessions prefer usable accounts whose Fable quota is exhausted. API-key routes use the same sidecar shape as OpenCode and are sent directly to their configured Anthropic-compatible base URL, such as Kie's `https://api.kie.ai/claude`, but Pi only uses them after the main OAuth model response reports HTTP 429 or a streaming rate-limit error and a live quota check confirms 0% remaining. `/claude-cachekeep always` keeps active hybrid caches warm while Pi remains open; `/claude-cachekeep HH-HH` limits prewarms to a local time window. Both send `max_tokens: 0` pre-warm requests about five minutes before the 1-hour TTL expires. Running `/claude-cachekeep` without arguments lists live tracked sessions across Pi processes through a temporary lease registry that stores only session IDs and cache timing. `/claude-fast on` adds Anthropic `speed: "fast"` plus the `fast-mode-2026-02-01` beta header for supported Opus models (`claude-opus-4-6`, `claude-opus-4-7`, `claude-opus-4-8`, and `claude-opus-5`). ## Relay diff --git a/packages/pi/src/convert.ts b/packages/pi/src/convert.ts index 60fc17d7..f7a9fdd1 100644 --- a/packages/pi/src/convert.ts +++ b/packages/pi/src/convert.ts @@ -5,9 +5,11 @@ import { CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_IDENTITY, CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING, + CLAUDE_OPUS_5_ADAPTIVE_THINKING, CLAUDE_SONNET_5_ADAPTIVE_THINKING, type ClaudeCodeIdentity, isClaudeFableOrMythos5Model, + isClaudeOpus5Model, isClaudeSonnet5Model, isFastModeSupportedModel, isOpenAIReasoningSignature, @@ -409,18 +411,24 @@ export async function buildAnthropicRequest( const isFableOrMythos5 = isClaudeFableOrMythos5Model(modelId) const isSonnet5 = isClaudeSonnet5Model(modelId) - // Sonnet 5 shares Fable/Mythos's adaptive-summarized contract: make adaptive - // thinking visible (display defaults to "omitted") and map reasoning to - // output_config effort. Pi's typed options cannot express thinking-disabled, - // so there is no disable case here (see transform.ts for the raw-body path). + const isOpus5 = isClaudeOpus5Model(modelId) + // Sonnet 5 and Opus 5 share Fable/Mythos's adaptive-summarized contract: make + // adaptive thinking visible (display defaults to "omitted") and map reasoning + // to output_config effort. Pi's typed options cannot express + // thinking-disabled, so there is no disable case here (see transform.ts for + // the raw-body path). Each model refs its own per-family constant — + // families can diverge, so the constant rather than a shared alias keeps + // the call sites explicit. if (isFableOrMythos5) { body.thinking = { ...CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING } } else if (isSonnet5) { body.thinking = { ...CLAUDE_SONNET_5_ADAPTIVE_THINKING } + } else if (isOpus5) { + body.thinking = { ...CLAUDE_OPUS_5_ADAPTIVE_THINKING } } if (options?.reasoning) { - if (isFableOrMythos5 || isSonnet5) { + if (isFableOrMythos5 || isSonnet5 || isOpus5) { body.output_config = { effort: options.reasoning } } else { const budgets: Record = { diff --git a/packages/pi/src/tests/convert.test.ts b/packages/pi/src/tests/convert.test.ts index 0a86de9d..148731c7 100644 --- a/packages/pi/src/tests/convert.test.ts +++ b/packages/pi/src/tests/convert.test.ts @@ -400,6 +400,55 @@ describe('buildAnthropicRequest — Sonnet 5 thinking', () => { }) }) +describe('buildAnthropicRequest — Opus 5 thinking', () => { + test('requests summarized adaptive thinking for Opus 5 without reasoning', async () => { + const { body } = await buildAnthropicRequest( + 'claude-opus-5', + { messages: [userMsg('hello')], systemPrompt: 'test', tools: [] } as any, + {} as any, + defaultCache, + ) + + expect(body.thinking).toEqual({ type: 'adaptive', display: 'summarized' }) + expect(body.output_config).toBeUndefined() + }) + + test('maps reasoning to output_config effort for Opus 5', async () => { + const { body } = await buildAnthropicRequest( + 'claude-opus-5-20260701', + { messages: [userMsg('hello')], systemPrompt: 'test', tools: [] } as any, + { reasoning: 'high' } as any, + defaultCache, + ) + + expect(body.thinking).toEqual({ type: 'adaptive', display: 'summarized' }) + expect(body.output_config).toEqual({ effort: 'high' }) + }) + + test('sets display summarized (not omitted) so Opus 5 thinking is visible', async () => { + const { body } = await buildAnthropicRequest( + 'claude-opus-5', + { messages: [userMsg('hello')], systemPrompt: 'test', tools: [] } as any, + {} as any, + defaultCache, + ) + + expect((body.thinking as { display?: string }).display).toBe('summarized') + }) + + test('keeps manual thinking budgets for non-Opus5 models', async () => { + const { body } = await buildAnthropicRequest( + 'claude-opus-4-8', + { messages: [userMsg('hello')], systemPrompt: 'test', tools: [] } as any, + { reasoning: 'high' } as any, + defaultCache, + ) + + expect(body.output_config).toBeUndefined() + expect(body.thinking).toEqual({ type: 'enabled', budget_tokens: 20_480 }) + }) +}) + describe('convertMessages — empty error tool_result guard', () => { test('injects Error placeholder when is_error=true and content is empty', async () => { const messages = await buildMessages([