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
19 changes: 19 additions & 0 deletions packages/core/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/tests/models.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
60 changes: 60 additions & 0 deletions packages/opencode/src/tests/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
30 changes: 30 additions & 0 deletions packages/opencode/src/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>,
): { 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<string, unknown>,
options: { enabled: boolean; mode: Cache1hMode },
Expand Down Expand Up @@ -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,
Expand All @@ -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'),
})
Expand Down
11 changes: 10 additions & 1 deletion packages/pi/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }
Comment thread
iceteaSA marked this conversation as resolved.
}

if (options?.reasoning) {
if (isFableOrMythos5) {
if (isFableOrMythos5 || isSonnet5) {
body.output_config = { effort: options.reasoning }
} else {
const budgets: Record<string, number> = {
Expand Down
36 changes: 36 additions & 0 deletions packages/pi/src/tests/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down