diff --git a/packages/core/src/models.ts b/packages/core/src/models.ts index 1efeb68..d5b2256 100644 --- a/packages/core/src/models.ts +++ b/packages/core/src/models.ts @@ -50,6 +50,25 @@ export function isClaudeFableOrMythos5Model(model: unknown) { ) } +export const CLAUDE_SONNET_5_MODEL_ID = 'claude-sonnet-5' + +/** + * Sonnet 5 enables adaptive thinking by default but ships `display: "omitted"`, + * so the `thinking` field returns empty and the user sees nothing. Injecting + * this makes the adaptive thinking summary visible. Reuses the Fable/Mythos + * shape because the API contract is identical. + */ +export const CLAUDE_SONNET_5_ADAPTIVE_THINKING = + CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING + +export function isClaudeSonnet5Model(model: unknown) { + return ( + typeof model === 'string' && + (model === CLAUDE_SONNET_5_MODEL_ID || + model.startsWith(`${CLAUDE_SONNET_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 new file mode 100644 index 0000000..c648216 --- /dev/null +++ b/packages/core/src/tests/models.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'bun:test' +import { isClaudeSonnet5Model } from '../models' + +describe('isClaudeSonnet5Model', () => { + test('matches the bare claude-sonnet-5 id', () => { + expect(isClaudeSonnet5Model('claude-sonnet-5')).toBe(true) + }) + + test('matches a dated claude-sonnet-5 snapshot', () => { + expect(isClaudeSonnet5Model('claude-sonnet-5-20260630')).toBe(true) + }) + + test('does not match claude-sonnet-4-6', () => { + expect(isClaudeSonnet5Model('claude-sonnet-4-6')).toBe(false) + }) + + test('does not match the Fable/Mythos ids', () => { + expect(isClaudeSonnet5Model('claude-fable-5')).toBe(false) + expect(isClaudeSonnet5Model('claude-mythos-5')).toBe(false) + }) + + test('does not match a non-dash prefix collision', () => { + expect(isClaudeSonnet5Model('claude-sonnet-5x')).toBe(false) + }) + + test('does not match non-string input', () => { + expect(isClaudeSonnet5Model(undefined)).toBe(false) + expect(isClaudeSonnet5Model(42)).toBe(false) + }) +}) diff --git a/packages/opencode/src/tests/transform.test.ts b/packages/opencode/src/tests/transform.test.ts index d1517e9..7321ef2 100644 --- a/packages/opencode/src/tests/transform.test.ts +++ b/packages/opencode/src/tests/transform.test.ts @@ -938,6 +938,66 @@ describe('rewriteRequestBody', () => { expect(result.output_config).toEqual({ effort: 'xhigh' }) }) + test('requests summarized adaptive thinking for Sonnet 5 without thinking', async () => { + const body = JSON.stringify({ + model: 'claude-sonnet-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 Sonnet 5', async () => { + const body = JSON.stringify({ + model: 'claude-sonnet-5-20260630', + 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 Sonnet 5', async () => { + const body = JSON.stringify({ + model: 'claude-sonnet-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 Sonnet 5', async () => { + const body = JSON.stringify({ + model: 'claude-sonnet-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('does not touch thinking for non-Sonnet5 models', async () => { + const body = JSON.stringify({ + model: 'claude-sonnet-4-6', + messages: [{ role: 'user', content: 'hi' }], + thinking: { type: 'enabled', budget_tokens: 5_000 }, + }) + + const result = JSON.parse(await rewriteRequestBody(body)) + + expect(result.thinking).toEqual({ type: 'enabled', budget_tokens: 5_000 }) + }) + 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 5dc088e..5b45cfd 100644 --- a/packages/opencode/src/transform.ts +++ b/packages/opencode/src/transform.ts @@ -6,9 +6,11 @@ import { CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_IDENTITY, CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING, + CLAUDE_SONNET_5_ADAPTIVE_THINKING, type ClaudeCodeIdentity, FAST_MODE_BETA, isClaudeFableOrMythos5Model, + isClaudeSonnet5Model, isFastModeSupportedModel, isOpenAIReasoningSignature, mergeAnthropicBetas, @@ -776,6 +778,31 @@ function normalizeFableMythosRequest( return { replacedExisting: hadThinking } } +/** + * Sonnet 5 has adaptive thinking on by default but defaults `display` to + * "omitted", so the thinking field returns empty. Inject + * `{type:"adaptive",display:"summarized"}` to make it visible — UNLESS the + * caller explicitly disabled thinking. Unlike Fable/Mythos (which reject a + * disable), Sonnet 5 accepts `{type:"disabled"}`, so an intentional opt-out is + * preserved rather than force-enabled. A manual `{type:"enabled",budget_tokens}` + * would 400 on Sonnet 5, so it is overwritten with the adaptive form. + */ +function normalizeSonnet5Request( + parsed: Record, +): { replacedExisting: boolean; display: 'summarized' | 'disabled' } | null { + if (!isClaudeSonnet5Model(parsed.model)) return null + const hadThinking = Object.hasOwn(parsed, 'thinking') + const thinking = parsed.thinking + if (isRecord(thinking) && thinking.type === 'disabled') { + // `display` is invalid alongside `type:"disabled"` (nothing to display) and + // can 400; canonicalize to a bare disabled object while keeping thinking off. + parsed.thinking = { type: 'disabled' } + return { replacedExisting: hadThinking, display: 'disabled' } + } + parsed.thinking = { ...CLAUDE_SONNET_5_ADAPTIVE_THINKING } + return { replacedExisting: hadThinking, display: 'summarized' } +} + function applyCache1hStrategy( parsed: Record, options: { enabled: boolean; mode: Cache1hMode }, @@ -902,6 +929,7 @@ export async function rewriteRequestBody( const modelNormalizeStart = rewriteNowMs() const removedNonAnthropicThinking = stripNonAnthropicThinkingBlocks(parsed) const fableMythosThinking = normalizeFableMythosRequest(parsed) + const sonnet5Thinking = normalizeSonnet5Request(parsed) options.perf?.('model_normalize', { ms: rewriteRoundMs(rewriteNowMs() - modelNormalizeStart), model: typeof parsed.model === 'string' ? parsed.model : undefined, @@ -910,6 +938,8 @@ export async function rewriteRequestBody( : undefined, replacedFableMythosThinking: fableMythosThinking?.replacedExisting ?? false, + sonnet5ThinkingDisplay: sonnet5Thinking?.display, + replacedSonnet5Thinking: sonnet5Thinking?.replacedExisting ?? false, removedNonAnthropicThinking, hasOutputConfig: Object.hasOwn(parsed, 'output_config'), }) diff --git a/packages/pi/src/convert.ts b/packages/pi/src/convert.ts index 9dcb1c9..60fc17d 100644 --- a/packages/pi/src/convert.ts +++ b/packages/pi/src/convert.ts @@ -5,8 +5,10 @@ import { CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_IDENTITY, CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING, + CLAUDE_SONNET_5_ADAPTIVE_THINKING, type ClaudeCodeIdentity, isClaudeFableOrMythos5Model, + isClaudeSonnet5Model, isFastModeSupportedModel, isOpenAIReasoningSignature, orderClaudeCodeBody, @@ -406,12 +408,19 @@ 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). if (isFableOrMythos5) { body.thinking = { ...CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING } + } else if (isSonnet5) { + body.thinking = { ...CLAUDE_SONNET_5_ADAPTIVE_THINKING } } if (options?.reasoning) { - if (isFableOrMythos5) { + if (isFableOrMythos5 || isSonnet5) { 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 e12ddcf..0a86de9 100644 --- a/packages/pi/src/tests/convert.test.ts +++ b/packages/pi/src/tests/convert.test.ts @@ -364,6 +364,42 @@ describe('buildAnthropicRequest — Fable/Mythos thinking', () => { }) }) +describe('buildAnthropicRequest — Sonnet 5 thinking', () => { + test('requests summarized adaptive thinking for Sonnet 5 without reasoning', async () => { + const { body } = await buildAnthropicRequest( + 'claude-sonnet-5', + { messages: [userMsg('hello')], systemPrompt: 'test', tools: [] } as any, + {} as any, + defaultCache, + ) + + expect(body.thinking).toEqual({ type: 'adaptive', display: 'summarized' }) + }) + + test('replaces manual reasoning budget with adaptive summarized for Sonnet 5', async () => { + const { body } = await buildAnthropicRequest( + 'claude-sonnet-5-20260630', + { 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 Sonnet 5 thinking is visible', async () => { + const { body } = await buildAnthropicRequest( + 'claude-sonnet-5', + { messages: [userMsg('hello')], systemPrompt: 'test', tools: [] } as any, + {} as any, + defaultCache, + ) + + expect((body.thinking as { display?: string }).display).toBe('summarized') + }) +}) + describe('convertMessages — empty error tool_result guard', () => { test('injects Error placeholder when is_error=true and content is empty', async () => { const messages = await buildMessages([