feat(models): support Sonnet 5.5 and refresh Claude defaults

This commit is contained in:
程序员阿江(Relakkes)
2026-09-29 16:25:20 +08:00
parent 2ec845d084
commit 6d9a3a9364
31 changed files with 842 additions and 102 deletions
@@ -389,7 +389,8 @@ describe('plan mode permission UI', () => {
await waitFor(() => {
expect(screen.getByTestId('model-selector-dropdown')).toBeTruthy()
})
fireEvent.click(screen.getByRole('button', { name: /Sonnet 5/ }))
// Anchored so the official catalog's "Sonnet 5.5" is not also matched.
fireEvent.click(screen.getByRole('button', { name: /^Sonnet 5(?![.\d])/ }))
await waitFor(() => {
expect(screen.getByTestId('plan-execution-model').textContent).toContain('applies on approve')
@@ -445,7 +446,7 @@ describe('plan mode permission UI', () => {
await waitFor(() => {
expect(screen.getByTestId('model-selector-dropdown')).toBeTruthy()
})
fireEvent.click(screen.getByRole('button', { name: /Sonnet 5/ }))
fireEvent.click(screen.getByRole('button', { name: /^Sonnet 5(?![.\d])/ }))
await waitFor(() => {
expect(screen.getByTestId('plan-execution-model').textContent).toContain('applies on approve')
})
@@ -194,7 +194,7 @@ describe('ModelSelector', () => {
},
)
it('keeps the current Claude Official catalog visible when the API returns legacy settings models', async () => {
function renderClaudeOfficialWithLegacyModels() {
const legacyModels: ModelInfo[] = [
{ id: 'claude-opus-4-7', name: 'Opus 4.7', description: 'Legacy Opus', context: '1m' },
{ id: 'claude-sonnet-4-6', name: 'Sonnet 4.6', description: 'Legacy Sonnet', context: '200k' },
@@ -224,6 +224,11 @@ describe('ModelSelector', () => {
const onRuntimeChange = vi.fn()
render(<ModelSelector runtimeKey="session-claude-legacy" onRuntimeSelectionChange={onRuntimeChange} />)
return onRuntimeChange
}
it('keeps the current Claude Official catalog visible when the API returns legacy settings models', async () => {
const onRuntimeChange = renderClaudeOfficialWithLegacyModels()
await clickByRole(/Opus 4\.7/i)
@@ -231,7 +236,8 @@ describe('ModelSelector', () => {
expect(screen.getByRole('button', { name: /Opus 5\.5/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^Opus 5 / })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Opus 4\.8/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Sonnet 5/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Sonnet 5\.5/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^Sonnet 5 / })).toBeInTheDocument()
expect(screen.getAllByRole('button', { name: /Opus 4\.7/ }).length).toBeGreaterThan(0)
await clickByRole(/Opus 5\.5/)
expect(onRuntimeChange).toHaveBeenCalledWith(expect.objectContaining({
@@ -239,6 +245,20 @@ describe('ModelSelector', () => {
}))
})
it('selects Sonnet 5.5 from the Claude Official catalog instead of the Sonnet 5 it supersedes', async () => {
const onRuntimeChange = renderClaudeOfficialWithLegacyModels()
await clickByRole(/Opus 4\.7/i)
await clickByRole(/Sonnet 5\.5/)
expect(onRuntimeChange).toHaveBeenCalledWith(expect.objectContaining({
providerId: null, modelId: 'claude-sonnet-5-5',
}))
expect(onRuntimeChange).not.toHaveBeenCalledWith(expect.objectContaining({
modelId: 'claude-sonnet-5',
}))
})
it('does not query official OAuth status when mounted', () => {
const fetchClaudeStatus = vi.fn(async () => {})
const fetchOpenAIStatus = vi.fn(async () => {})
@@ -12,6 +12,21 @@ describe('Claude official model catalog', () => {
})
})
it('offers Sonnet 5.5 with the Claude Code default effort and context for OAuth selection', () => {
expect(OFFICIAL_MODELS.find(model => model.id === 'claude-sonnet-5-5')).toMatchObject({
name: 'Sonnet 5.5',
context: '1m',
defaultReasoningEffort: 'medium',
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
})
})
it('lists Sonnet 5.5 ahead of the Sonnet 5 it supersedes', () => {
const ids = OFFICIAL_MODELS.map(model => model.id)
expect(ids.indexOf('claude-sonnet-5-5')).toBeGreaterThanOrEqual(0)
expect(ids.indexOf('claude-sonnet-5-5')).toBeLessThan(ids.indexOf('claude-sonnet-5'))
})
it('keeps older explicit model selections available', () => {
expect(OFFICIAL_MODELS.map(model => model.id)).toEqual(expect.arrayContaining([
'claude-opus-5', 'claude-opus-4-8', 'claude-fable-5-1', 'claude-sonnet-5',
+8
View File
@@ -41,6 +41,14 @@ export const OFFICIAL_MODELS: ModelInfo[] = [
defaultReasoningEffort: 'high',
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
},
{
id: 'claude-sonnet-5-5',
name: 'Sonnet 5.5',
description: 'Best combination of speed and intelligence',
context: '1m',
defaultReasoningEffort: 'medium',
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
},
{
id: 'claude-sonnet-5',
name: 'Sonnet 5',
+20 -12
View File
@@ -114,15 +114,14 @@ export const CLAUDE_CODE_DOCS_MAP_URL =
export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY =
'__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'
// @[MODEL LAUNCH]: Update the latest frontier model.
const FRONTIER_MODEL_NAME = 'Claude Opus 4.8'
// @[MODEL LAUNCH]: Update the model family IDs below to the latest in each tier.
// @[MODEL LAUNCH]: Update the model family IDs below to the latest in each tier, and the
// matching names in the "most recent Claude models" sentence in computeSimpleEnvInfo.
// The Haiku ID is the pinned snapshot, as in the official Claude Code prompt.
const LATEST_CLAUDE_MODEL_IDS = {
fable: 'claude-fable-5',
opus: 'claude-opus-4-8',
sonnet: 'claude-sonnet-5',
haiku: 'claude-haiku-4-5',
fable: 'claude-fable-5-1',
opus: 'claude-opus-5-5',
sonnet: 'claude-sonnet-5-5',
haiku: 'claude-haiku-4-5-20251001',
}
function getHooksSection(): string {
@@ -694,13 +693,13 @@ export async function computeSimpleEnvInfo(
knowledgeCutoffMessage,
process.env.USER_TYPE === 'ant' && isUndercover()
? null
: `The most recent Claude flagship models are Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5, and Claude Haiku 4.5. Model IDs — Fable 5: '${LATEST_CLAUDE_MODEL_IDS.fable}', Opus 4.8: '${LATEST_CLAUDE_MODEL_IDS.opus}', Sonnet 5: '${LATEST_CLAUDE_MODEL_IDS.sonnet}', Haiku 4.5: '${LATEST_CLAUDE_MODEL_IDS.haiku}'. When building AI applications, default to the latest and most capable Claude models.`,
: `The most recent Claude models are the Claude 5 family and Haiku 4.5. Model IDs — Fable 5.1: '${LATEST_CLAUDE_MODEL_IDS.fable}', Opus 5.5: '${LATEST_CLAUDE_MODEL_IDS.opus}', Sonnet 5.5: '${LATEST_CLAUDE_MODEL_IDS.sonnet}', Haiku 4.5: '${LATEST_CLAUDE_MODEL_IDS.haiku}'. When building AI applications, default to the latest and most capable Claude models.`,
process.env.USER_TYPE === 'ant' && isUndercover()
? null
: `Claude Code is available as a CLI in the terminal, desktop app (Mac/Windows), web app (claude.ai/code), and IDE extensions (VS Code, JetBrains).`,
process.env.USER_TYPE === 'ant' && isUndercover()
? null
: `Fast mode for Claude Code uses the same ${FRONTIER_MODEL_NAME} model with faster output. It does NOT switch to a different model. It can be toggled with /fast.`,
: `Fast mode for Claude Code uses Claude Opus with faster output (it does not downgrade to a smaller model). It can be toggled with /fast.`,
].filter(item => item !== null)
return [
@@ -713,16 +712,25 @@ export async function computeSimpleEnvInfo(
// @[MODEL LAUNCH]: Add a knowledge cutoff date for the new model.
function getKnowledgeCutoff(modelId: string): string | null {
const canonical = getCanonicalName(modelId)
// Order matters: a point release (`-5-5`, `-5-1`) contains its whole-number id, so it is
// matched first. Values follow Anthropic's model pages and the official Claude Code catalog.
if (
canonical.includes('claude-fable-5-1') ||
canonical.includes('claude-opus-5-5') ||
canonical.includes('claude-sonnet-5-5')
) {
return 'June 2026'
} else if (canonical.includes('claude-opus-5')) {
return 'May 2026'
} else if (
canonical.includes('claude-fable-5') ||
canonical.includes('claude-opus-4-8') ||
canonical.includes('claude-opus-4-7') ||
canonical.includes('claude-sonnet-5')
) {
return 'January 2026'
} else if (canonical.includes('claude-sonnet-4-6')) {
return 'August 2025'
} else if (canonical.includes('claude-opus-4-7')) {
return 'May 2025'
} else if (canonical.includes('claude-opus-4-5')) {
return 'May 2025'
} else if (canonical.includes('claude-haiku-4')) {
+2 -2
View File
@@ -3570,12 +3570,12 @@ describe('WebSocket Chat Integration', () => {
sessionId,
options: {
providerId: null,
model: 'claude-sonnet-5',
model: 'claude-sonnet-5-5',
},
})
await expect(sessionService.getSessionLaunchInfo(sessionId)).resolves.toMatchObject({
runtimeProviderId: null,
runtimeModelId: 'claude-sonnet-5',
runtimeModelId: 'claude-sonnet-5-5',
})
} finally {
conversationService.startSession = originalStartSession
@@ -374,20 +374,21 @@ describe('Business Flow: Models & Effort', () => {
it('should return available fallback models', async () => {
const { data } = await api('GET', '/api/models')
expect(data.models.length).toBe(7)
expect(data.models.length).toBe(8)
const names = data.models.map((m: any) => m.name)
expect(names).toContain('Fable 5.1')
expect(names).toContain('Fable 5')
expect(names).toContain('Opus 5.5')
expect(names).toContain('Opus 5')
expect(names).toContain('Opus 4.8')
expect(names).toContain('Sonnet 5.5')
expect(names).toContain('Sonnet 5')
expect(names).toContain('Haiku 4.5')
})
it('should default to Opus model', async () => {
const { data } = await api('GET', '/api/models/current')
expect(data.model.id).toBe('claude-opus-5')
expect(data.model.id).toBe('claude-opus-5-5')
})
it('should switch to Opus 4.8', async () => {
+1 -1
View File
@@ -216,7 +216,7 @@ describe('E2E: Full Flow', () => {
it('should list available models', async () => {
const { data } = await api('GET', '/api/models')
expect(data.models.length).toBe(7)
expect(data.models.length).toBe(8)
expect(data.models[0].name).toBe('Fable 5.1')
})
+23 -11
View File
@@ -968,6 +968,14 @@ describe('Models API', () => {
defaultReasoningEffort: 'high',
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
},
{
id: 'claude-sonnet-5-5',
name: 'Sonnet 5.5',
description: 'Best combination of speed and intelligence',
context: '1m',
defaultReasoningEffort: 'medium',
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
},
{
id: 'claude-sonnet-5',
name: 'Sonnet 5',
@@ -1177,7 +1185,8 @@ describe('Models API', () => {
expect(res.status).toBe(200)
const body = await res.json()
expect(body.model.id).toBe('claude-opus-5')
// The CLI launches without an explicit model here and its `opus` alias is Opus 5.5.
expect(body.model).toMatchObject({ id: 'claude-opus-5-5', name: 'Opus 5.5' })
})
it('GET /api/models/current should replace the legacy opus[1m] default with the Claude OAuth Pro default', async () => {
@@ -1200,8 +1209,9 @@ describe('Models API', () => {
const currentBody = await currentResponse.json()
expect(currentBody.model).toMatchObject({
id: 'claude-sonnet-5',
name: 'Sonnet 5',
id: 'claude-sonnet-5-5',
name: 'Sonnet 5.5',
defaultReasoningEffort: 'medium',
})
const listRequest = makeRequest('GET', '/api/models')
@@ -1217,6 +1227,7 @@ describe('Models API', () => {
'claude-opus-5-5',
'claude-opus-5',
'claude-opus-4-8',
'claude-sonnet-5-5',
'claude-sonnet-5',
'claude-haiku-4-5',
])
@@ -1574,16 +1585,17 @@ describe('Model Options', () => {
beforeEach(setup)
afterEach(teardown)
it('defaults Anthropic API users to Opus 5 and exposes the current official options once', () => {
it('defaults Anthropic API users to Opus 5.5 and exposes the current official options once', () => {
process.env.ANTHROPIC_API_KEY = 'test-api-key'
expect(getDefaultMainLoopModelSetting()).toBe('claude-opus-5')
expect(getDefaultMainLoopModelSetting()).toBe('claude-opus-5-5')
const options = getModelOptions()
const values = options.map(option => option.value)
expect(options[0]?.description).toContain('Opus 5')
expect(options[0]?.description).toContain('$5')
expect(options[0]?.description).toContain('Opus 5.5')
// Opus 5.5 is $4/$20, not the $5/$25 of the Opus 5 it replaces as the default.
expect(options[0]?.description).toContain('$4/$20 per Mtok')
expect(values).toContain('fable')
expect(values).toContain('sonnet')
expect(values).not.toContain('opus')
@@ -1623,10 +1635,10 @@ describe('Model Options', () => {
it('labels extended-context options with current first-party and conservative third-party names', () => {
process.env.ANTHROPIC_API_KEY = 'test-api-key'
expect(getSonnet46_1MOption().description).toContain('Sonnet 5')
expect(getOpus46_1MOption().description).toContain('Opus 5')
expect(getMaxSonnet46_1MOption().description).toContain('Sonnet 5')
expect(getMaxOpus46_1MOption().description).toContain('Opus 5')
expect(getSonnet46_1MOption().description).toContain('Sonnet 5.5')
expect(getOpus46_1MOption().description).toContain('Opus 5.5')
expect(getMaxSonnet46_1MOption().description).toContain('Sonnet 5.5')
expect(getMaxOpus46_1MOption().description).toContain('Opus 5.5')
process.env.ANTHROPIC_BASE_URL = 'https://api.deepseek.com/anthropic'
+12 -1
View File
@@ -89,6 +89,14 @@ const DEFAULT_MODELS = [
defaultReasoningEffort: 'high',
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
},
{
id: 'claude-sonnet-5-5',
name: 'Sonnet 5.5',
description: 'Best combination of speed and intelligence',
context: '1m',
defaultReasoningEffort: 'medium',
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
},
{
id: 'claude-sonnet-5',
name: 'Sonnet 5',
@@ -107,7 +115,10 @@ const DEFAULT_MODELS = [
const EFFORT_LEVELS = MODEL_REASONING_EFFORTS
const DEFAULT_MODEL = 'claude-opus-5'
// Standalone sessions (no provider, no Claude OAuth) start the CLI without an explicit model, so the
// CLI's own default runs. Keep this in step with what the `opus` alias resolves to there
// (getDefaultOpusModel) so the UI names the model that actually runs.
const DEFAULT_MODEL = 'claude-opus-5-5'
const DEFAULT_EFFORT = 'max'
const settingsService = new SettingsService()
@@ -23,10 +23,33 @@ describe('Claude official runtime model selection', () => {
})
}
test('selects Opus 5.5 for Max and preserves the Sonnet default for other subscriptions', () => {
function useProSubscription() {
tokenSpy.mockResolvedValue({
accessToken: 'fake-access-token',
refreshToken: null,
expiresAt: null,
scopes: [],
subscriptionType: 'pro',
})
}
test('selects Opus 5.5 for Max and Sonnet 5.5 for other subscriptions', () => {
expect(getClaudeOfficialDefaultModelId('max')).toBe('claude-opus-5-5')
expect(getClaudeOfficialDefaultModelId('pro')).toBe('claude-sonnet-5')
expect(getClaudeOfficialDefaultModelId(null)).toBe('claude-sonnet-5')
expect(getClaudeOfficialDefaultModelId('pro')).toBe('claude-sonnet-5-5')
expect(getClaudeOfficialDefaultModelId(null)).toBe('claude-sonnet-5-5')
})
test('resolves Sonnet aliases and the unset default to Sonnet 5.5', async () => {
useProSubscription()
for (const model of [undefined, 'sonnet', 'sonnet[1m]', 'SONNET:1m', 'claude-sonnet-5-5[1m]']) {
expect(await resolveClaudeOfficialRuntimeModel(model)).toBe('claude-sonnet-5-5')
}
})
test('preserves an explicitly selected Sonnet 5 instead of rewriting it to Sonnet 5.5', async () => {
useProSubscription()
expect(await resolveClaudeOfficialRuntimeModel('claude-sonnet-5')).toBe('claude-sonnet-5')
expect(await resolveClaudeOfficialRuntimeModel('claude-sonnet-5[1m]')).toBe('claude-sonnet-5')
})
test('resolves Opus aliases and legacy defaults to Opus 5.5', async () => {
+1 -1
View File
@@ -3,7 +3,7 @@ import type { ModelMapping } from '../types/provider.js'
import { hahaOAuthService } from './hahaOAuthService.js'
export const CLAUDE_OFFICIAL_OPUS_MODEL_ID = 'claude-opus-5-5'
export const CLAUDE_OFFICIAL_SONNET_MODEL_ID = 'claude-sonnet-5'
export const CLAUDE_OFFICIAL_SONNET_MODEL_ID = 'claude-sonnet-5-5'
export const CLAUDE_OFFICIAL_DEFAULT_MODELS: ModelMapping = {
main: CLAUDE_OFFICIAL_OPUS_MODEL_ID,
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { Database } from 'bun:sqlite'
import { getEventListeners } from 'node:events'
import { appendFile, chmod, mkdir, mkdtemp, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -777,6 +778,100 @@ describe('local index coordinator', () => {
}
})
it('replaces persisted per-model dollars with the current rates after a parser upgrade', async () => {
// Version 9 corrected the Sonnet 5 / Sonnet 5.5 / Opus 5.5 rates. Dollars are persisted per model,
// so without a parser bump every already-indexed transcript would keep its old price forever.
expect(SESSION_SUMMARY_PARSER_VERSION).toBeGreaterThanOrEqual(9)
const root = await createTempDir('coordinator-activity-cost-upgrade')
const configDir = join(root, 'config')
const databasePath = join(configDir, 'cc-haha', 'db', 'index-v1.sqlite')
const transcriptPath = join(configDir, 'projects', '-repo', 'cost-upgrade.jsonl')
await mkdir(dirname(transcriptPath), { recursive: true })
await writeFile(transcriptPath, [
{
type: 'user',
message: { role: 'user', content: 'Price this' },
timestamp: '2026-01-01T00:00:00.000Z',
},
{
type: 'assistant',
requestId: 'req_cost_upgrade',
message: {
id: 'msg_cost_upgrade',
role: 'assistant',
model: 'claude-sonnet-5',
content: [{ type: 'text', text: 'Priced' }],
usage: { input_tokens: 1_000_000, output_tokens: 1_000_000 },
},
timestamp: '2026-01-01T00:00:01.000Z',
},
].map(entry => JSON.stringify(entry)).join('\n') + '\n')
const createIdleWatcher = (): ReconciliationWatcher => ({
async start() {},
async stop() {},
queueTranscriptPath() {},
queueFullSweep() {},
getMetrics: () => ({
queuedPaths: 0,
maxBatchSize: 0,
yielded: 0,
fullSweeps: 0,
watchFailures: 0,
}),
})
const previousCoordinator = createLocalIndexCoordinator({
resolveMode: () => ({ mode: 'on', warningCode: null }),
resolveScope: () => configDir,
resolveDatabasePath: () => databasePath,
createProjector: options => createSessionProjector({
...options,
parserVersion: SESSION_SUMMARY_PARSER_VERSION - 1,
}),
createWatcher: createIdleWatcher,
})
await previousCoordinator.start()
await waitFor(() => previousCoordinator.isActivityScopeReady())
await previousCoordinator.stop()
// Leave behind what the previous release persisted: Sonnet 5 at the old $3/$15 rate.
const staleDatabase = new Database(databasePath)
try {
staleDatabase.run(
"UPDATE activity_daily_models SET cost_usd = 18 WHERE model = 'claude-sonnet-5'",
)
expect(staleDatabase.query<{ cost_usd: number }, []>(
"SELECT cost_usd FROM activity_daily_models WHERE model = 'claude-sonnet-5'",
).get()?.cost_usd).toBe(18)
} finally {
staleDatabase.close()
}
let runScheduledDiscovery: (() => void) | undefined
const upgradedCoordinator = createLocalIndexCoordinator({
resolveMode: () => ({ mode: 'on', warningCode: null }),
resolveScope: () => configDir,
resolveDatabasePath: () => databasePath,
schedule: operation => { runScheduledDiscovery = operation },
createWatcher: createIdleWatcher,
})
try {
await upgradedCoordinator.start()
// The stale dollars are withheld until the rebuild has replaced them.
expect(upgradedCoordinator.getActivityStats('all')).toBeNull()
runScheduledDiscovery?.()
await waitFor(() => upgradedCoordinator.isActivityScopeReady())
// 1M input at $2 + 1M output at $10.
expect(upgradedCoordinator.getActivityStats('all')?.modelUsage['claude-sonnet-5']?.costUSD)
.toBeCloseTo(12, 6)
} finally {
await upgradedCoordinator.stop()
}
})
it('keeps retained rows degraded after watch failure and ignores late batches after stop', async () => {
const index = createFakeIndex([candidate(1)])
let watcherOptions!: ReconciliationWatcherOptions
@@ -38,7 +38,9 @@ import type {
// 6: protocol enforcement was removed; rebuild v5 summaries without protocol restrictions.
// 7: independent desktop team workers remain addressable but leave sidebar listings.
// 8: complete runtime selections clear the previous effort when no override is saved.
export const SESSION_SUMMARY_PARSER_VERSION = 8
// 9: usage cost rates were corrected (Sonnet 5, Sonnet 5.5, Opus 5.5, fast mode); rebuild the
// persisted per-model dollars.
export const SESSION_SUMMARY_PARSER_VERSION = 9
export type SessionSourceCandidate = {
path: string
@@ -855,6 +855,26 @@ for (const disableAdaptive of [false, true]) {
}, 10_000)
}
for (const disableAdaptive of [false, true]) {
test(`Sonnet 5.5 never sends disabled thinking, with optional adaptive disabled=${disableAdaptive}`, async () => {
const { content, requests } = await captureQueryRequest({
model: 'claude-sonnet-5-5',
configureCapabilityOverrides: false,
globalThinkingEnabled: false,
env: {
CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: disableAdaptive ? '1' : undefined,
CLAUDE_CODE_ALWAYS_ENABLE_EFFORT: '1',
},
})
expect(content).toContainEqual({ type: 'text', text: 'OK' })
expect(requests).toHaveLength(1)
expect(requests[0]?.model).toBe('claude-sonnet-5-5')
expect(requests[0]?.thinking).toEqual({ type: 'adaptive' })
expect(requests[0]?.max_tokens).toBe(128_000)
expect(requests[0]?.output_config).toEqual({ effort: 'medium' })
}, 10_000)
}
test('Opus 5.5 respects an explicit third-party capability opt-out', async () => {
const { requests } = await captureQueryRequest({
model: 'claude-opus-5-5',
+5 -5
View File
@@ -34,14 +34,14 @@ import typescriptClaudeApiToolUse from './claude-api/typescript/claude-api/tool-
// - claude-api/SKILL.md (Current Models pricing table)
// - claude-api/shared/models.md (full model catalog with legacy versions and alias mappings)
export const SKILL_MODEL_VARS = {
OPUS_ID: 'claude-opus-4-8',
OPUS_NAME: 'Claude Opus 4.8',
SONNET_ID: 'claude-sonnet-5',
SONNET_NAME: 'Claude Sonnet 5',
OPUS_ID: 'claude-opus-5-5',
OPUS_NAME: 'Claude Opus 5.5',
SONNET_ID: 'claude-sonnet-5-5',
SONNET_NAME: 'Claude Sonnet 5.5',
HAIKU_ID: 'claude-haiku-4-5',
HAIKU_NAME: 'Claude Haiku 4.5',
// Previous Sonnet ID — used in "do not append date suffixes" example in SKILL.md.
PREV_SONNET_ID: 'claude-sonnet-4-5',
PREV_SONNET_ID: 'claude-sonnet-5',
} satisfies Record<string, string>
export const SKILL_PROMPT: string = skillPrompt
+1
View File
@@ -162,6 +162,7 @@ export function sanitizeModelName(shortName: string): string {
if (shortName.includes('opus-4-5')) return 'claude-opus-4-5'
if (shortName.includes('opus-4-1')) return 'claude-opus-4-1'
if (shortName.includes('opus-4')) return 'claude-opus-4'
if (shortName.includes('sonnet-5-5')) return 'claude-sonnet-5-5'
if (shortName.includes('sonnet-5')) return 'claude-sonnet-5'
if (shortName.includes('sonnet-4-6')) return 'claude-sonnet-4-6'
if (shortName.includes('sonnet-4-5')) return 'claude-sonnet-4-5'
+1 -1
View File
@@ -269,7 +269,7 @@ export function getModelMaxOutputTokens(model: string): {
const m = getCanonicalName(model)
if (m === 'claude-opus-5-5') {
if (m === 'claude-opus-5-5' || m === 'claude-sonnet-5-5') {
defaultTokens = 128_000
upperLimit = 128_000
} else if (
+4 -1
View File
@@ -435,7 +435,10 @@ export function getDefaultEffortForModel(
// the model launch DRI and research. Default effort is a sensitive setting
// that can greatly affect model quality and bashing.
if (getCanonicalName(model) === 'claude-opus-5-5') {
// Claude Code runs Opus 5.5 and Sonnet 5.5 at medium; the API default for
// Sonnet 5.5 is high, so it must be sent explicitly.
const canonicalModel = getCanonicalName(model)
if (canonicalModel === 'claude-opus-5-5' || canonicalModel === 'claude-sonnet-5-5') {
return 'medium'
}
+9
View File
@@ -62,6 +62,14 @@ export const CLAUDE_SONNET_5_CONFIG = {
azureOpenAI: 'claude-sonnet-5',
} as const satisfies ModelConfig
export const CLAUDE_SONNET_5_5_CONFIG = {
firstParty: 'claude-sonnet-5-5',
bedrock: 'us.anthropic.claude-sonnet-5-5',
vertex: 'claude-sonnet-5-5',
foundry: 'claude-sonnet-5-5',
azureOpenAI: 'claude-sonnet-5-5',
} as const satisfies ModelConfig
export const CLAUDE_SONNET_4_CONFIG = {
firstParty: 'claude-sonnet-4-20250514',
bedrock: 'us.anthropic.claude-sonnet-4-20250514-v1:0',
@@ -178,6 +186,7 @@ export const ALL_MODEL_CONFIGS = {
sonnet45: CLAUDE_SONNET_4_5_CONFIG,
sonnet46: CLAUDE_SONNET_4_6_CONFIG,
sonnet50: CLAUDE_SONNET_5_CONFIG,
sonnet55: CLAUDE_SONNET_5_5_CONFIG,
opus40: CLAUDE_OPUS_4_CONFIG,
opus41: CLAUDE_OPUS_4_1_CONFIG,
opus45: CLAUDE_OPUS_4_5_CONFIG,
+36 -12
View File
@@ -129,7 +129,7 @@ describe('Fable model configuration', () => {
expect(getMarketingNameForModel('claude-opus-4-8')).toBe('Opus 4.8')
expect(getMarketingNameForModel('claude-sonnet-5')).toBe('Sonnet 5')
expect(renderDefaultModelSetting('opusplan')).toBe(
'Opus 5 in plan mode, else Sonnet 5',
'Opus 5.5 in plan mode, else Sonnet 5.5',
)
})
@@ -158,24 +158,48 @@ describe('Fable model configuration', () => {
})
})
test('publishes current model IDs and knowledge cutoffs in environment context', async () => {
test('publishes the current model lineup in environment context', async () => {
expect(SKILL_MODEL_VARS).toMatchObject({
OPUS_ID: 'claude-opus-4-8',
OPUS_NAME: 'Claude Opus 4.8',
SONNET_ID: 'claude-sonnet-5',
SONNET_NAME: 'Claude Sonnet 5',
OPUS_ID: 'claude-opus-5-5',
OPUS_NAME: 'Claude Opus 5.5',
SONNET_ID: 'claude-sonnet-5-5',
SONNET_NAME: 'Claude Sonnet 5.5',
PREV_SONNET_ID: 'claude-sonnet-5',
})
for (const model of ['claude-fable-5', 'claude-opus-4-8', 'claude-sonnet-5']) {
for (const model of ['claude-fable-5', 'claude-opus-4-8', 'claude-sonnet-5', 'claude-sonnet-5-5']) {
const info = await computeSimpleEnvInfo(model)
expect(info).toContain('Assistant knowledge cutoff is January 2026.')
expect(info).toContain("Fable 5: 'claude-fable-5'")
expect(info).toContain("Opus 4.8: 'claude-opus-4-8'")
expect(info).toContain("Sonnet 5: 'claude-sonnet-5'")
expect(info).toContain('same Claude Opus 4.8 model')
expect(info).toContain('The most recent Claude models are the Claude 5 family and Haiku 4.5.')
expect(info).toContain("Fable 5.1: 'claude-fable-5-1'")
expect(info).toContain("Opus 5.5: 'claude-opus-5-5'")
expect(info).toContain("Sonnet 5.5: 'claude-sonnet-5-5'")
expect(info).toContain("Haiku 4.5: 'claude-haiku-4-5-20251001'")
// The fast-mode note no longer names an Opus generation that has since been superseded.
expect(info).toContain('Fast mode for Claude Code uses Claude Opus with faster output')
expect(info).not.toContain('same Claude Opus 4.8 model')
}
})
// Reliable knowledge cutoffs: https://platform.claude.com/docs/en/models/overview and each
// model's page, which match the official Claude Code model catalog.
test.each([
['claude-fable-5-1', 'June 2026'],
['claude-opus-5-5', 'June 2026'],
['claude-sonnet-5-5', 'June 2026'],
['claude-opus-5', 'May 2026'],
['claude-fable-5', 'January 2026'],
['claude-opus-4-8', 'January 2026'],
['claude-opus-4-7', 'January 2026'],
['claude-sonnet-5', 'January 2026'],
['claude-sonnet-4-6', 'August 2025'],
['claude-opus-4-5', 'May 2025'],
['claude-haiku-4-5', 'February 2025'],
])('tells %s its knowledge cutoff is %s', async (model, cutoff) => {
expect(await computeSimpleEnvInfo(model)).toContain(`Assistant knowledge cutoff is ${cutoff}.`)
// A context-window marker does not change what the model knows.
expect(await computeSimpleEnvInfo(`${model}[1m]`)).toContain(`Assistant knowledge cutoff is ${cutoff}.`)
})
test('sanitizes new model trailers and keeps teammate fallbacks provider-safe', () => {
expect(sanitizeModelName('claude-fable-5-1-experimental')).toBe('claude-fable-5-1')
expect(sanitizeModelName('claude-fable-5-experimental')).toBe('claude-fable-5')
+26 -7
View File
@@ -22,7 +22,11 @@ import {
} from '../context.js'
import { isEnvTruthy } from '../envUtils.js'
import { getModelStrings, resolveOverriddenModel } from './modelStrings.js'
import { formatModelPricing, getOpus46CostTier } from '../modelCost.js'
import {
formatModelPricing,
getModelPricingString,
getOpus46CostTier,
} from '../modelCost.js'
import { getSettings_DEPRECATED } from '../settings/settings.js'
import type { PermissionMode } from '../permissions/PermissionMode.js'
import {
@@ -144,7 +148,7 @@ export function getDefaultOpusModel(): ModelName {
if (shouldUseThirdPartyAnthropicModelDefaults()) {
return getModelStrings().opus46
}
return getModelStrings().opus50
return getModelStrings().opus55
}
// @[MODEL LAUNCH]: Update the default Sonnet model (3P providers may lag so keep defaults unchanged).
@@ -159,7 +163,7 @@ export function getDefaultSonnetModel(): ModelName {
if (shouldUseThirdPartyAnthropicModelDefaults()) {
return getModelStrings().sonnet45
}
return getModelStrings().sonnet50
return getModelStrings().sonnet55
}
// @[MODEL LAUNCH]: Update the default Haiku model (3P providers may lag so keep defaults unchanged).
@@ -296,6 +300,9 @@ export function firstPartyNameToCanonical(name: ModelName): ModelShortName {
if (name.includes('claude-opus-4')) {
return 'claude-opus-4'
}
if (name.includes('claude-sonnet-5-5')) {
return 'claude-sonnet-5-5'
}
if (name.includes('claude-sonnet-5')) {
return 'claude-sonnet-5'
}
@@ -363,11 +370,11 @@ export function getClaudeAiUserDefaultModelDescription(
}
if (isMaxSubscriber() || isTeamPremiumSubscriber()) {
if (isOpus1mMergeEnabled()) {
return `Opus 5 with 1M context · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`
return `Opus 5.5 with 1M context · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`
}
return `Opus 5 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`
return `Opus 5.5 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`
}
return 'Sonnet 5 · Best for everyday tasks'
return 'Sonnet 5.5 · Best for everyday tasks'
}
export function renderDefaultModelSetting(
@@ -381,7 +388,12 @@ export function renderDefaultModelSetting(
export function getOpus46PricingSuffix(fastMode: boolean): string {
if (getAPIProvider() !== 'firstParty') return ''
const pricing = formatModelPricing(getOpus46CostTier(fastMode))
// Standard rates follow whichever Opus the `opus` alias resolves to; only the fast-mode
// premium is still the tier published for Opus 4.7.
const pricing = fastMode
? formatModelPricing(getOpus46CostTier(true))
: (getModelPricingString(getDefaultOpusModel()) ??
formatModelPricing(getOpus46CostTier(false)))
const fastModeIndicator = fastMode ? ` (${LIGHTNING_BOLT})` : ''
return ` ·${fastModeIndicator} ${pricing}`
}
@@ -461,6 +473,10 @@ export function getPublicModelDisplayName(model: ModelName): string | null {
return 'Sonnet 4.6 (1M context)'
case getModelStrings().sonnet46:
return 'Sonnet 4.6'
case getModelStrings().sonnet55 + '[1m]':
return 'Sonnet 5.5 (1M context)'
case getModelStrings().sonnet55:
return 'Sonnet 5.5'
case getModelStrings().sonnet50 + '[1m]':
return 'Sonnet 5 (1M context)'
case getModelStrings().sonnet50:
@@ -712,6 +728,9 @@ export function getMarketingNameForModel(modelId: string): string | undefined {
if (canonical.includes('claude-opus-4')) {
return 'Opus 4'
}
if (canonical.includes('claude-sonnet-5-5')) {
return has1m ? 'Sonnet 5.5 (with 1M context)' : 'Sonnet 5.5'
}
if (canonical.includes('claude-sonnet-5')) {
return has1m ? 'Sonnet 5 (with 1M context)' : 'Sonnet 5'
}
+1
View File
@@ -9,6 +9,7 @@ const DIRECT_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
'claude-opus-5': 1_000_000,
'claude-opus-4-8': 1_000_000,
'claude-opus-4-7': 1_000_000,
'claude-sonnet-5-5': 1_000_000,
'claude-sonnet-5': 1_000_000,
'claude-sonnet-4-6': 200_000,
'claude-haiku-4-5': 200_000,
+24 -19
View File
@@ -10,7 +10,7 @@ import {
import { getModelStrings } from './modelStrings.js'
import {
COST_TIER_10_50,
COST_TIER_3_15,
COST_TIER_2_10,
COST_HAIKU_35,
COST_HAIKU_45,
formatModelPricing,
@@ -122,7 +122,7 @@ export function getDefaultOptionForUser(fastMode = false): ModelOption {
? ''
: currentModel.includes('Opus')
? getOpus46PricingSuffix(fastMode)
: ` · ${formatModelPricing(COST_TIER_3_15)}`
: ` · ${formatModelPricing(COST_TIER_2_10)}`
return {
value: null,
label: 'Default (recommended)',
@@ -182,11 +182,11 @@ function getFable5Option(): ModelOption | undefined {
function getSonnet46Option(): ModelOption {
const is3P = shouldUseThirdPartyAnthropicOptions()
const modelName = is3P ? 'Sonnet 4.6' : 'Sonnet 5'
const modelName = is3P ? 'Sonnet 4.6' : 'Sonnet 5.5'
return {
value: is3P ? getModelStrings().sonnet46 : 'sonnet',
label: 'Sonnet',
description: `${modelName} · Best for everyday tasks${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`,
description: `${modelName} · Best for everyday tasks${is3P ? '' : ` · ${formatModelPricing(COST_TIER_2_10)}`}`,
descriptionForModel:
`${modelName} - best for everyday tasks. Generally recommended for most coding tasks`,
}
@@ -223,18 +223,18 @@ function getOpus46Option(fastMode = false): ModelOption {
return {
value: is3P ? getModelStrings().opus46 : 'opus',
label: 'Opus',
description: `${is3P ? 'Opus 4.7' : 'Opus 5'} · Most capable for complex work${getOpus46PricingSuffix(fastMode)}`,
descriptionForModel: `${is3P ? 'Opus 4.7' : 'Opus 5'} - most capable for complex work`,
description: `${is3P ? 'Opus 4.7' : 'Opus 5.5'} · Most capable for complex work${getOpus46PricingSuffix(fastMode)}`,
descriptionForModel: `${is3P ? 'Opus 4.7' : 'Opus 5.5'} - most capable for complex work`,
}
}
export function getSonnet46_1MOption(): ModelOption {
const is3P = shouldUseThirdPartyAnthropicOptions()
const modelName = is3P ? 'Sonnet 4.6' : 'Sonnet 5'
const modelName = is3P ? 'Sonnet 4.6' : 'Sonnet 5.5'
return {
value: is3P ? getModelStrings().sonnet46 + '[1m]' : 'sonnet[1m]',
label: 'Sonnet (1M context)',
description: `${modelName} for long sessions${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`,
description: `${modelName} for long sessions${is3P ? '' : ` · ${formatModelPricing(COST_TIER_2_10)}`}`,
descriptionForModel:
`${modelName} with 1M context window - for long sessions with large codebases`,
}
@@ -242,7 +242,7 @@ export function getSonnet46_1MOption(): ModelOption {
export function getOpus46_1MOption(fastMode = false): ModelOption {
const is3P = shouldUseThirdPartyAnthropicOptions()
const modelName = is3P ? 'Opus 4.7' : 'Opus 5'
const modelName = is3P ? 'Opus 4.7' : 'Opus 5.5'
return {
value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]',
label: 'Opus (1M context)',
@@ -302,7 +302,7 @@ function getMaxOpusOption(fastMode = false): ModelOption {
return {
value: 'opus',
label: 'Opus',
description: `Opus 5 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`,
description: `Opus 5.5 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`,
}
}
@@ -312,7 +312,7 @@ export function getMaxSonnet46_1MOption(): ModelOption {
return {
value: 'sonnet[1m]',
label: 'Sonnet (1M context)',
description: `Sonnet 5 with 1M context${billingInfo}${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`,
description: `Sonnet 5.5 with 1M context${billingInfo}${is3P ? '' : ` · ${formatModelPricing(COST_TIER_2_10)}`}`,
}
}
@@ -321,7 +321,7 @@ export function getMaxOpus46_1MOption(fastMode = false): ModelOption {
return {
value: 'opus[1m]',
label: 'Opus (1M context)',
description: `Opus 5 with 1M context${billingInfo}${getOpus46PricingSuffix(fastMode)}`,
description: `Opus 5.5 with 1M context${billingInfo}${getOpus46PricingSuffix(fastMode)}`,
}
}
@@ -330,16 +330,16 @@ function getMergedOpus1MOption(fastMode = false): ModelOption {
return {
value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]',
label: 'Opus (1M context)',
description: `${is3P ? 'Opus 4.7' : 'Opus 5'} with 1M context · Most capable for complex work${!is3P && fastMode ? getOpus46PricingSuffix(fastMode) : ''}`,
description: `${is3P ? 'Opus 4.7' : 'Opus 5.5'} with 1M context · Most capable for complex work${!is3P && fastMode ? getOpus46PricingSuffix(fastMode) : ''}`,
descriptionForModel:
`${is3P ? 'Opus 4.7' : 'Opus 5'} with 1M context - most capable for complex work`,
`${is3P ? 'Opus 4.7' : 'Opus 5.5'} with 1M context - most capable for complex work`,
}
}
const MaxSonnet46Option: ModelOption = {
value: 'sonnet',
label: 'Sonnet',
description: 'Sonnet 5 · Best for everyday tasks',
description: 'Sonnet 5.5 · Best for everyday tasks',
}
const MaxHaiku45Option: ModelOption = {
@@ -476,8 +476,8 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
return standardOptions
}
// PAYG 1P API: Default (Opus 5) + Fable + Sonnet + Haiku.
// Opus 5 and Sonnet 5 already have native 1M context windows.
// PAYG 1P API: Default (Opus 5.5) + Fable + Sonnet + Haiku.
// Opus 5.x and Sonnet 5.x already have native 1M context windows.
if (!shouldUseThirdPartyAnthropicOptions()) {
const payg1POptions = [getDefaultOptionForUser(fastMode)]
pushUniqueOption(payg1POptions, getFable5Option())
@@ -569,8 +569,13 @@ function getModelFamilyInfo(
}
}
// Opus family
if (canonical.includes('claude-opus-4')) {
// Opus family. A pinned Opus 5 only has a newer alias target where the alias has moved
// on to Opus 5.5; third-party defaults lag on Opus 4.7, which is not an upgrade.
if (
canonical.includes('claude-opus-4') ||
(canonical === 'claude-opus-5' &&
getCanonicalName(getDefaultOpusModel()) === 'claude-opus-5-5')
) {
const currentName = getMarketingNameForModel(getDefaultOpusModel())
if (currentName) {
return { alias: 'Opus', currentVersionName: currentName }
+55 -8
View File
@@ -6,11 +6,16 @@ import { clearOAuthTokenCache } from '../auth.js'
import { resetSettingsCache } from '../settings/settingsCache.js'
import { sanitizeModelName } from '../commitAttribution.js'
import { ALL_MODEL_CONFIGS } from './configs.js'
import { getOpus46_1MOption, getMaxOpus46_1MOption } from './modelOptions.js'
import {
getModelOptions,
getOpus46_1MOption,
getMaxOpus46_1MOption,
} from './modelOptions.js'
import {
firstPartyNameToCanonical,
getDefaultOpusModel,
getMarketingNameForModel,
getOpus46PricingSuffix,
getPublicModelDisplayName,
isNonCustomOpusModel,
parseUserSpecifiedModel,
@@ -22,6 +27,7 @@ const envKeys = [
'CLAUDE_CONFIG_DIR',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_API_KEY',
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
@@ -52,13 +58,15 @@ afterEach(() => {
})
describe('Opus 5 runtime model identity', () => {
test('registers and resolves the latest official Opus without rewriting pinned models', () => {
test('registers Opus 5 and leaves a pinned Opus 5 alone now that the opus alias tracks Opus 5.5', () => {
expect(Object.values(ALL_MODEL_CONFIGS).some(config => config.firstParty === 'claude-opus-5')).toBe(true)
expect(getDefaultOpusModel()).toBe('claude-opus-5')
expect(parseUserSpecifiedModel('opus')).toBe('claude-opus-5')
expect(parseUserSpecifiedModel('opus[1m]')).toBe('claude-opus-5[1m]')
expect(getDefaultOpusModel()).toBe('claude-opus-5-5')
expect(parseUserSpecifiedModel('opus')).toBe('claude-opus-5-5')
expect(parseUserSpecifiedModel('opus[1m]')).toBe('claude-opus-5-5[1m]')
expect(parseUserSpecifiedModel('claude-opus-5')).toBe('claude-opus-5')
expect(parseUserSpecifiedModel('claude-opus-5[1m]')).toBe('claude-opus-5[1m]')
expect(parseUserSpecifiedModel('claude-opus-4-8')).toBe('claude-opus-4-8')
expect(renderDefaultModelSetting('opusplan')).toBe('Opus 5 in plan mode, else Sonnet 5')
expect(renderDefaultModelSetting('opusplan')).toBe('Opus 5.5 in plan mode, else Sonnet 5.5')
})
test('preserves explicit provider overrides and third-party defaults', () => {
@@ -70,13 +78,52 @@ describe('Opus 5 runtime model identity', () => {
})
test('describes the Opus alias with its actual resolved generation', () => {
expect(getOpus46_1MOption().description).toContain('Opus 5')
expect(getMaxOpus46_1MOption().description).toContain('Opus 5')
expect(getOpus46_1MOption().description).toContain('Opus 5.5')
expect(getMaxOpus46_1MOption().description).toContain('Opus 5.5')
process.env.ANTHROPIC_BASE_URL = 'https://provider.example.invalid'
process.env.ANTHROPIC_API_KEY = 'fixture-key'
expect(getOpus46_1MOption().description).toContain('Opus 4.7')
})
test('quotes the price of the Opus the alias resolves to', () => {
// Opus 5.5 is $4/$20; quoting the $5/$25 of Opus 5 or 4.7 would overstate it.
expect(getOpus46PricingSuffix(false)).toBe(' · $4/$20 per Mtok')
process.env.ANTHROPIC_BASE_URL = 'https://provider.example.invalid'
process.env.ANTHROPIC_API_KEY = 'fixture-key'
// The lag-safe third-party default is Opus 4.7 ($5/$25).
expect(getOpus46PricingSuffix(false)).toBe(' · $5/$25 per Mtok')
process.env.CLAUDE_CODE_USE_BEDROCK = '1'
expect(getOpus46PricingSuffix(false)).toBe('')
})
test('hints that a pinned Opus 5 has a newer alias target only where the alias moved to Opus 5.5', () => {
process.env.ANTHROPIC_API_KEY = 'fixture-key'
process.env.ANTHROPIC_MODEL = 'claude-opus-5'
expect(getModelOptions()).toContainEqual({
value: 'claude-opus-5',
label: 'Opus 5',
description: 'Newer version available · select Opus for Opus 5.5',
})
process.env.ANTHROPIC_MODEL = 'claude-opus-5-5'
expect(getModelOptions()).toContainEqual({
value: 'claude-opus-5-5',
label: 'Opus 5.5',
description: 'claude-opus-5-5',
})
// Third-party defaults lag on Opus 4.7, which is not an upgrade over a pinned Opus 5.
process.env.ANTHROPIC_BASE_URL = 'https://provider.example.invalid'
process.env.ANTHROPIC_MODEL = 'claude-opus-5'
expect(getModelOptions()).toContainEqual({
value: 'claude-opus-5',
label: 'Opus 5',
description: 'claude-opus-5',
})
})
test('recognizes Opus 5 throughout canonicalization, public display, and attribution', () => {
expect(firstPartyNameToCanonical('anthropic.claude-opus-5')).toBe('claude-opus-5')
expect(isNonCustomOpusModel('claude-opus-5')).toBe(true)
+256
View File
@@ -0,0 +1,256 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { computeSimpleEnvInfo } from '../../constants/prompts.js'
import { clearOAuthTokenCache } from '../auth.js'
import { sanitizeModelName } from '../commitAttribution.js'
import {
getContextWindowForModel,
getModelMaxOutputTokens,
modelSupports1M,
} from '../context.js'
import {
getDefaultEffortForModel,
modelSupportsEffort,
modelSupportsMaxEffort,
modelSupportsXHighEffort,
} from '../effort.js'
import { calculateCostFromTokens, getModelPricingString } from '../modelCost.js'
import { resetSettingsCache } from '../settings/settingsCache.js'
import { resolveSideQueryThinkingConfig } from '../sideQuery.js'
import {
modelRequiresThinking,
modelSupportsAdaptiveThinking,
modelSupportsThinking,
modelUsesBoundThinking,
} from '../thinking.js'
import { resolveModelCosts } from '../usageAccounting.js'
import { ALL_MODEL_CONFIGS } from './configs.js'
import {
firstPartyNameToCanonical,
getClaudeAiUserDefaultModelDescription,
getDefaultSonnetModel,
getMarketingNameForModel,
getPublicModelDisplayName,
parseUserSpecifiedModel,
renderDefaultModelSetting,
} from './model.js'
import { getMaxSonnet46_1MOption, getSonnet46_1MOption } from './modelOptions.js'
import { get3PModelCapabilityOverride } from './modelSupportOverrides.js'
const envKeys = [
'HOME',
'CLAUDE_CONFIG_DIR',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_API_KEY',
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_FOUNDRY',
'CLAUDE_CODE_DISABLE_1M_CONTEXT',
'CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING',
'CC_HAHA_SEND_DISABLED_THINKING',
] as const
let savedEnv: (string | undefined)[]
let temporaryHome: string
function clearCapabilityCache() {
;(get3PModelCapabilityOverride as typeof get3PModelCapabilityOverride & {
cache?: { clear?: () => void }
}).cache?.clear?.()
}
beforeEach(() => {
savedEnv = envKeys.map(key => process.env[key])
for (const key of envKeys) delete process.env[key]
temporaryHome = mkdtempSync(join(tmpdir(), 'sonnet55-model-test-'))
process.env.HOME = temporaryHome
process.env.CLAUDE_CONFIG_DIR = join(temporaryHome, 'config')
resetSettingsCache()
clearOAuthTokenCache()
clearCapabilityCache()
})
afterEach(() => {
resetSettingsCache()
clearOAuthTokenCache()
clearCapabilityCache()
rmSync(temporaryHome, { recursive: true, force: true })
envKeys.forEach((key, index) => {
const value = savedEnv[index]
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
})
const SONNET_55_VARIANTS = [
'claude-sonnet-5-5',
'claude-sonnet-5-5[1m]',
'us.anthropic.claude-sonnet-5-5',
'anthropic.claude-sonnet-5-5',
]
describe('Sonnet 5.5 identity', () => {
test('registers the official IDs without absorbing the Sonnet 5 it supersedes', () => {
expect(ALL_MODEL_CONFIGS.sonnet55).toMatchObject({
firstParty: 'claude-sonnet-5-5',
vertex: 'claude-sonnet-5-5',
foundry: 'claude-sonnet-5-5',
})
expect(ALL_MODEL_CONFIGS.sonnet55.bedrock).toMatch(/anthropic\.claude-sonnet-5-5$/)
for (const model of SONNET_55_VARIANTS) {
expect(firstPartyNameToCanonical(model)).toBe('claude-sonnet-5-5')
expect(sanitizeModelName(model)).toBe('claude-sonnet-5-5')
}
// `claude-sonnet-5` is a prefix of `claude-sonnet-5-5`; each keeps its own identity.
for (const model of ['claude-sonnet-5', 'anthropic.claude-sonnet-5']) {
expect(firstPartyNameToCanonical(model)).toBe('claude-sonnet-5')
expect(sanitizeModelName(model)).toBe('claude-sonnet-5')
}
})
test('renders distinct public and marketing names', () => {
expect(getPublicModelDisplayName('claude-sonnet-5-5')).toBe('Sonnet 5.5')
expect(getPublicModelDisplayName('claude-sonnet-5-5[1m]')).toBe('Sonnet 5.5 (1M context)')
expect(getMarketingNameForModel('claude-sonnet-5-5')).toBe('Sonnet 5.5')
expect(getMarketingNameForModel('claude-sonnet-5-5[1m]')).toBe('Sonnet 5.5 (with 1M context)')
expect(getPublicModelDisplayName('claude-sonnet-5')).toBe('Sonnet 5')
expect(getMarketingNameForModel('claude-sonnet-5')).toBe('Sonnet 5')
})
test('tells the model its own name, ID and June 2026 knowledge cutoff', async () => {
const info = await computeSimpleEnvInfo('claude-sonnet-5-5')
expect(info).toContain(
'You are powered by the model named Sonnet 5.5. The exact model ID is claude-sonnet-5-5.',
)
expect(info).toContain('Assistant knowledge cutoff is June 2026.')
})
})
describe('Sonnet alias resolution', () => {
test('resolves the sonnet alias to Sonnet 5.5 and keeps explicit pins untouched', () => {
expect(getDefaultSonnetModel()).toBe('claude-sonnet-5-5')
expect(parseUserSpecifiedModel('sonnet')).toBe('claude-sonnet-5-5')
expect(parseUserSpecifiedModel('sonnet[1m]')).toBe('claude-sonnet-5-5[1m]')
expect(parseUserSpecifiedModel('claude-sonnet-5')).toBe('claude-sonnet-5')
expect(parseUserSpecifiedModel('claude-sonnet-5-5')).toBe('claude-sonnet-5-5')
expect(renderDefaultModelSetting('opusplan')).toBe('Opus 5.5 in plan mode, else Sonnet 5.5')
})
test('keeps lag-safe third-party defaults and honors provider overrides', () => {
process.env.ANTHROPIC_BASE_URL = 'https://provider.example.invalid'
process.env.ANTHROPIC_API_KEY = 'fixture-key'
expect(getDefaultSonnetModel()).toBe('claude-sonnet-4-5-20250929')
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'provider-custom-sonnet'
expect(parseUserSpecifiedModel('sonnet')).toBe('provider-custom-sonnet')
})
test('describes the alias with its actual resolved generation and price', () => {
expect(getClaudeAiUserDefaultModelDescription()).toBe('Sonnet 5.5 · Best for everyday tasks')
expect(getSonnet46_1MOption().description).toBe('Sonnet 5.5 for long sessions · $2/$10 per Mtok')
expect(getMaxSonnet46_1MOption().description).toBe('Sonnet 5.5 with 1M context · $2/$10 per Mtok')
process.env.ANTHROPIC_BASE_URL = 'https://provider.example.invalid'
process.env.ANTHROPIC_API_KEY = 'fixture-key'
expect(getSonnet46_1MOption().description).toBe('Sonnet 4.6 for long sessions')
})
})
describe('Sonnet 5.5 runtime limits and thinking', () => {
test('exposes the native 1M window and the 128K output limit', () => {
for (const model of SONNET_55_VARIANTS) {
expect(modelSupports1M(model)).toBe(true)
}
expect(getContextWindowForModel('claude-sonnet-5-5')).toBe(1_000_000)
expect(getContextWindowForModel('claude-sonnet-5-5[1m]')).toBe(1_000_000)
expect(getModelMaxOutputTokens('claude-sonnet-5-5')).toEqual({
default: 128_000,
upperLimit: 128_000,
})
expect(getModelMaxOutputTokens('claude-sonnet-5')).toEqual({
default: 64_000,
upperLimit: 128_000,
})
})
test('always runs adaptive thinking because the API rejects thinking: disabled', () => {
for (const model of SONNET_55_VARIANTS) {
expect(modelSupportsThinking(model)).toBe(true)
expect(modelSupportsAdaptiveThinking(model)).toBe(true)
expect(modelRequiresThinking(model)).toBe(true)
}
expect(modelUsesBoundThinking('claude-sonnet-5-5')).toBe(false)
// Sonnet 5 still accepts an explicit disable.
expect(modelRequiresThinking('claude-sonnet-5')).toBe(false)
})
test('never lets a side query send disabled thinking to Sonnet 5.5', () => {
expect(resolveSideQueryThinkingConfig(false, 1024, 'claude-sonnet-5-5')).toEqual({
type: 'adaptive',
})
expect(resolveSideQueryThinkingConfig(undefined, 1024, 'claude-sonnet-5-5[1m]')).toEqual({
type: 'adaptive',
})
expect(resolveSideQueryThinkingConfig(false, 1024, 'claude-sonnet-5')).toEqual({
type: 'disabled',
})
})
test('respects an explicit third-party capability opt-out', () => {
process.env.ANTHROPIC_BASE_URL = 'https://provider.example.invalid'
process.env.ANTHROPIC_API_KEY = 'fixture-key'
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'claude-sonnet-5-5'
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES = ''
clearCapabilityCache()
expect(modelRequiresThinking('claude-sonnet-5-5')).toBe(false)
expect(modelSupportsAdaptiveThinking('claude-sonnet-5-5')).toBe(false)
// A provider that declares the capabilities keeps the required adaptive mode.
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES =
'thinking,required_thinking,adaptive_thinking'
clearCapabilityCache()
expect(modelRequiresThinking('claude-sonnet-5-5')).toBe(true)
expect(modelSupportsAdaptiveThinking('claude-sonnet-5-5')).toBe(true)
})
test('defaults to medium effort while supporting every effort level', () => {
expect(getDefaultEffortForModel('claude-sonnet-5-5')).toBe('medium')
expect(getDefaultEffortForModel('claude-sonnet-5-5[1m]')).toBe('medium')
expect(modelSupportsEffort('claude-sonnet-5-5')).toBe(true)
expect(modelSupportsXHighEffort('claude-sonnet-5-5')).toBe(true)
expect(modelSupportsMaxEffort('claude-sonnet-5-5')).toBe(true)
})
})
describe('Sonnet 5.5 pricing', () => {
test('uses the published $2/$10 pricing, $2.50 cache writes and $0.20 cached reads', () => {
expect(getModelPricingString('claude-sonnet-5-5')).toBe('$2/$10 per Mtok')
for (const model of SONNET_55_VARIANTS) {
expect(
calculateCostFromTokens(model, {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadInputTokens: 1_000_000,
cacheCreationInputTokens: 1_000_000,
}),
).toBeCloseTo(14.7, 10)
}
})
test('prices activity stats at the Sonnet 5.5 rate instead of the Sonnet 5 prefix rate', () => {
const expected = {
inputTokens: 2,
outputTokens: 10,
promptCacheWriteTokens: 2.5,
promptCacheReadTokens: 0.2,
webSearchRequests: 0.01,
}
for (const model of ['claude-sonnet-5-5', 'anthropic/claude-sonnet-5-5', 'claude-sonnet-5-5-20260928']) {
expect(resolveModelCosts(model)).toEqual(expected)
}
})
})
+28 -2
View File
@@ -16,9 +16,11 @@ import {
CLAUDE_OPUS_4_8_CONFIG,
CLAUDE_OPUS_4_CONFIG,
CLAUDE_OPUS_5_5_CONFIG,
CLAUDE_OPUS_5_CONFIG,
CLAUDE_SONNET_4_5_CONFIG,
CLAUDE_SONNET_4_6_CONFIG,
CLAUDE_SONNET_4_CONFIG,
CLAUDE_SONNET_5_5_CONFIG,
CLAUDE_SONNET_5_CONFIG,
} from './model/configs.js'
import {
@@ -37,7 +39,7 @@ export type ModelCosts = {
webSearchRequests: number
}
// Standard pricing tier for Sonnet models: $3 input / $15 output per Mtok
// Pricing tier for Sonnet 4.x and earlier: $3 input / $15 output per Mtok
export const COST_TIER_3_15 = {
inputTokens: 3,
outputTokens: 15,
@@ -46,6 +48,17 @@ export const COST_TIER_3_15 = {
webSearchRequests: 0.01,
} as const satisfies ModelCosts
// Pricing tier for Sonnet 5 and Sonnet 5.5: $2 input / $10 output per Mtok.
// Sonnet 5 launched at this price as introductory pricing; Anthropic made it the
// standard price and cancelled the announced $3/$15 increase, so do not restore it.
export const COST_TIER_2_10 = {
inputTokens: 2,
outputTokens: 10,
promptCacheWriteTokens: 2.5,
promptCacheReadTokens: 0.2,
webSearchRequests: 0.01,
} as const satisfies ModelCosts
// Pricing tier for Opus 4/4.1: $15 input / $75 output per Mtok
export const COST_TIER_15_75 = {
inputTokens: 15,
@@ -88,6 +101,16 @@ export const COST_OPUS_55 = {
webSearchRequests: 0.01,
} as const satisfies ModelCosts
// Opus 5.5 fast mode (research preview): $8 input / $40 output per Mtok, with the cache
// multipliers stacked on top (1.25x writes, 0.05x reads on Opus 5.5).
export const COST_OPUS_55_FAST = {
inputTokens: 8,
outputTokens: 40,
promptCacheWriteTokens: 10,
promptCacheReadTokens: 0.4,
webSearchRequests: 0.01,
} as const satisfies ModelCosts
// Fast mode pricing for Opus 4.7: $30 input / $150 output per Mtok
export const COST_TIER_30_150 = {
inputTokens: 30,
@@ -132,6 +155,7 @@ export function getOpus46CostTier(fastMode: boolean): ModelCosts {
// Web search cost: $10 per 1000 requests = $0.01 per request
export const MODEL_COSTS: Record<ModelShortName, ModelCosts> = {
[firstPartyNameToCanonical(CLAUDE_OPUS_5_5_CONFIG.firstParty)]: COST_OPUS_55,
[firstPartyNameToCanonical(CLAUDE_OPUS_5_CONFIG.firstParty)]: COST_TIER_5_25,
[firstPartyNameToCanonical(CLAUDE_FABLE_5_1_CONFIG.firstParty)]:
COST_FABLE_51,
[firstPartyNameToCanonical(CLAUDE_FABLE_5_CONFIG.firstParty)]:
@@ -150,8 +174,10 @@ export const MODEL_COSTS: Record<ModelShortName, ModelCosts> = {
COST_TIER_3_15,
[firstPartyNameToCanonical(CLAUDE_SONNET_4_6_CONFIG.firstParty)]:
COST_TIER_3_15,
[firstPartyNameToCanonical(CLAUDE_SONNET_5_5_CONFIG.firstParty)]:
COST_TIER_2_10,
[firstPartyNameToCanonical(CLAUDE_SONNET_5_CONFIG.firstParty)]:
COST_TIER_3_15,
COST_TIER_2_10,
[firstPartyNameToCanonical(CLAUDE_OPUS_4_CONFIG.firstParty)]: COST_TIER_15_75,
[firstPartyNameToCanonical(CLAUDE_OPUS_4_1_CONFIG.firstParty)]:
COST_TIER_15_75,
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'bun:test'
import { ALL_MODEL_CONFIGS } from './model/configs.js'
import { firstPartyNameToCanonical } from './model/model.js'
import { MODEL_COSTS } from './modelCost.js'
import { resolveModelCosts } from './usageAccounting.js'
// Two independent tables price Claude models: `MODEL_COSTS` (canonical-name lookup, used for the
// CLI's /cost) and the indexer's prefix table in usageAccounting (activity stats). A model added to
// only one, or a shorter prefix such as `claude-sonnet-5` shadowing `claude-sonnet-5-5`, misprices
// it without any error, so the two must agree for every model the app registers.
const claudeModels = Object.entries(ALL_MODEL_CONFIGS).filter(([, config]) =>
config.firstParty.startsWith('claude-'),
)
describe('Claude model pricing tables', () => {
it.each(claudeModels)('%s is priced by both tables, identically', (_key, config) => {
const canonical = firstPartyNameToCanonical(config.firstParty)
expect(MODEL_COSTS[canonical]).toBeDefined()
expect(resolveModelCosts(config.firstParty)).toEqual(MODEL_COSTS[canonical]!)
})
// https://platform.claude.com/docs/en/about-claude/pricing (checked 2026-09-29), $ per MTok.
it.each([
['claude-fable-5-1', 10, 50, 0.25],
['claude-fable-5', 10, 50, 1],
['claude-opus-5-5', 4, 20, 0.2],
['claude-opus-5', 5, 25, 0.5],
['claude-opus-4-8', 5, 25, 0.5],
['claude-sonnet-5-5', 2, 10, 0.2],
['claude-sonnet-5', 2, 10, 0.2],
['claude-sonnet-4-6', 3, 15, 0.3],
['claude-haiku-4-5', 1, 5, 0.1],
])('%s matches the published input/output/cache-read prices', (model, input, output, cacheRead) => {
for (const costs of [MODEL_COSTS[firstPartyNameToCanonical(model)], resolveModelCosts(model)]) {
expect(costs).toMatchObject({
inputTokens: input,
outputTokens: output,
promptCacheReadTokens: cacheRead,
})
}
})
})
+15 -6
View File
@@ -86,6 +86,16 @@ export function getRainbowColor(
return colors[charIndex % colors.length]!
}
// Fable, Opus 5.5 and Sonnet 5.5 reject `thinking: disabled` and manual budgets;
// they always run adaptive thinking.
function isAdaptiveOnlyModel(canonical: string): boolean {
return (
canonical.includes('claude-fable-5') ||
canonical === 'claude-opus-5-5' ||
canonical === 'claude-sonnet-5-5'
)
}
// TODO(inigo): add support for probing unknown models via API error detection
// Provider-aware thinking support detection (aligns with modelSupportsISP in betas.ts)
export function modelSupportsThinking(model: string): boolean {
@@ -101,9 +111,9 @@ export function modelSupportsThinking(model: string): boolean {
// IMPORTANT: Do not change thinking support without notifying the model
// launch DRI and research. This can greatly affect model quality and bashing.
const canonical = getCanonicalName(model)
// Fable and Opus 5.5 use always-on adaptive thinking. Keep this after the provider
// Adaptive-only models use always-on adaptive thinking. Keep this after the provider
// capability override so an explicitly incompatible 3P route can opt out.
if (canonical.includes('claude-fable-5') || canonical === 'claude-opus-5-5') {
if (isAdaptiveOnlyModel(canonical)) {
return true
}
const provider = getAPIProvider()
@@ -126,8 +136,7 @@ export function modelRequiresThinking(model: string): boolean {
if (required3P !== undefined) {
return required3P
}
const canonical = getCanonicalName(model)
return canonical.includes('claude-fable-5') || canonical === 'claude-opus-5-5'
return isAdaptiveOnlyModel(getCanonicalName(model))
}
/** Fable 5.1 binds replayed thinking to the preceding system, tools and history. */
@@ -154,9 +163,9 @@ export function modelSupportsAdaptiveThinking(model: string): boolean {
return supported3P
}
const canonical = getCanonicalName(model)
// Fable and Opus 5.5 reject disabled/manual thinking and require adaptive thinking.
// Adaptive-only models reject disabled/manual thinking and require adaptive thinking.
// Explicit 3P capability declarations above remain authoritative.
if (canonical.includes('claude-fable-5') || canonical === 'claude-opus-5-5') {
if (isAdaptiveOnlyModel(canonical)) {
return true
}
const provider = getAPIProvider()
+77 -2
View File
@@ -28,10 +28,33 @@ describe('resolveModelCosts', () => {
expect(resolveModelCosts('claude-opus-4-8')?.inputTokens).toBe(5)
expect(resolveModelCosts('claude-opus-4-1')?.inputTokens).toBe(15)
expect(resolveModelCosts('claude-fable-5')?.outputTokens).toBe(50)
expect(resolveModelCosts('claude-sonnet-5')?.outputTokens).toBe(15)
expect(resolveModelCosts('claude-haiku-4-5')?.outputTokens).toBe(5)
})
it('prices Sonnet 5 at its published $2/$10, which replaced the announced $3/$15', () => {
expect(resolveModelCosts('claude-sonnet-5')).toEqual({
inputTokens: 2,
outputTokens: 10,
promptCacheWriteTokens: 2.5,
promptCacheReadTokens: 0.2,
webSearchRequests: 0.01,
})
// Sonnet 4.x keeps the older Sonnet rate.
expect(resolveModelCosts('claude-sonnet-4-6')).toMatchObject({ inputTokens: 3, outputTokens: 15 })
})
it('prices Opus 5.5 below Opus 5 rather than inheriting the shorter prefix', () => {
expect(resolveModelCosts('claude-opus-5-5')).toEqual({
inputTokens: 4,
outputTokens: 20,
promptCacheWriteTokens: 5,
promptCacheReadTokens: 0.2,
webSearchRequests: 0.01,
})
expect(resolveModelCosts('anthropic/claude-opus-5-5')).toEqual(resolveModelCosts('claude-opus-5-5')!)
expect(resolveModelCosts('claude-opus-5')).toMatchObject({ inputTokens: 5, outputTokens: 25 })
})
it('sees through the decorations gateways and dated snapshots add', () => {
const opus = resolveModelCosts('claude-opus-4-8')
expect(resolveModelCosts('claude-opus-4-8-r')).toEqual(opus!)
@@ -44,6 +67,11 @@ describe('resolveModelCosts', () => {
// `claude-opus-4-1` bills at the old Opus tier; a bare `claude-opus-4` must not swallow it.
expect(resolveModelCosts('claude-opus-4-1')?.inputTokens).toBe(15)
expect(resolveModelCosts('claude-opus-4-5')?.inputTokens).toBe(5)
// The same holds when a point release extends a whole-number model id.
expect(resolveModelCosts('claude-sonnet-5')?.inputTokens).toBe(2)
expect(resolveModelCosts('claude-sonnet-5-5')?.inputTokens).toBe(2)
expect(resolveModelCosts('claude-fable-5')?.promptCacheReadTokens).toBe(1)
expect(resolveModelCosts('claude-fable-5-1')?.promptCacheReadTokens).toBe(0.25)
})
it('returns null for third-party models instead of guessing Claude rates', () => {
@@ -69,7 +97,23 @@ describe('resolveModelCosts', () => {
expect(resolveModelCosts('claude-opus-5', 'fast')?.inputTokens).toBe(10)
expect(resolveModelCosts('claude-opus-5', 'standard')?.inputTokens).toBe(5)
// Sonnet has no fast mode — a stray `speed` must not change what it costs.
expect(resolveModelCosts('claude-sonnet-5', 'fast')?.inputTokens).toBe(3)
expect(resolveModelCosts('claude-sonnet-5', 'fast')?.inputTokens).toBe(2)
expect(resolveModelCosts('claude-sonnet-5-5', 'fast')).toEqual(resolveModelCosts('claude-sonnet-5-5')!)
})
it('bills Opus 5.5 and Opus 4.8 fast mode at their published premiums', () => {
// https://platform.claude.com/docs/en/about-claude/pricing#fast-mode-pricing
expect(resolveModelCosts('claude-opus-5-5', 'fast')).toEqual({
inputTokens: 8,
outputTokens: 40,
promptCacheWriteTokens: 10,
promptCacheReadTokens: 0.4,
webSearchRequests: 0.01,
})
// Opus 5.5 must not inherit Opus 5's $10/$50 fast rate through the shorter prefix.
expect(resolveModelCosts('claude-opus-5', 'fast')).toMatchObject({ inputTokens: 10, outputTokens: 50 })
expect(resolveModelCosts('claude-opus-4-8', 'fast')).toMatchObject({ inputTokens: 10, outputTokens: 50 })
expect(resolveModelCosts('claude-opus-4-8', 'standard')).toMatchObject({ inputTokens: 5, outputTokens: 25 })
})
})
@@ -119,6 +163,37 @@ describe('estimateCostUSD', () => {
expect(cost).toBeCloseTo(36.75, 10)
})
it('bills Sonnet 5 and the newer Sonnet 5.5 identically at $2/$10', () => {
const million = tokens({
inputTokens: ONE_MILLION,
outputTokens: ONE_MILLION,
cacheReadInputTokens: ONE_MILLION,
cacheCreationInputTokens: ONE_MILLION,
})
// 2 input + 10 output + 0.20 cache read + 2.50 cache write
expect(estimateCostUSD('claude-sonnet-5', million)).toBeCloseTo(14.7, 10)
expect(estimateCostUSD('claude-sonnet-5-5', million)).toBeCloseTo(14.7, 10)
})
it('bills Opus 5.5 at $4/$20 with the 5%-of-input cache read rate', () => {
// 4 input + 20 output + 0.20 cache read + 5 cache write
expect(estimateCostUSD('claude-opus-5-5', tokens({
inputTokens: ONE_MILLION,
outputTokens: ONE_MILLION,
cacheReadInputTokens: ONE_MILLION,
cacheCreationInputTokens: ONE_MILLION,
}))).toBeCloseTo(29.2, 10)
expect(estimateCostUSD('claude-opus-5-5', tokens({ cacheReadInputTokens: ONE_MILLION })))
.toBeCloseTo(0.2, 10)
})
it('bills fast-mode usage at the fast rate and standard usage at the standard rate', () => {
const million = tokens({ inputTokens: ONE_MILLION, outputTokens: ONE_MILLION })
expect(estimateCostUSD('claude-opus-5-5', million, 'fast')).toBeCloseTo(48, 10)
expect(estimateCostUSD('claude-opus-5-5', million, 'standard')).toBeCloseTo(24, 10)
expect(estimateCostUSD('claude-opus-4-8', million, 'fast')).toBeCloseTo(60, 10)
})
it('prices cache reads at a tenth of input, which is why token totals overstate spend', () => {
const cacheRead = estimateCostUSD('claude-opus-5', tokens({ cacheReadInputTokens: ONE_MILLION }))
const input = estimateCostUSD('claude-opus-5', tokens({ inputTokens: ONE_MILLION }))
+8 -1
View File
@@ -2,6 +2,9 @@ import {
COST_FABLE_51,
COST_HAIKU_35,
COST_HAIKU_45,
COST_OPUS_55,
COST_OPUS_55_FAST,
COST_TIER_2_10,
COST_TIER_3_15,
COST_TIER_5_25,
COST_TIER_10_50,
@@ -44,6 +47,7 @@ export type PricedTokens = {
// @[MODEL LAUNCH]: add the new model's canonical prefix here alongside its entry in MODEL_COSTS.
// Longest match wins, so a bare family name may sit next to its versioned variants.
const MODEL_TIERS: ReadonlyArray<readonly [prefix: string, costs: ModelCosts]> = [
['claude-opus-5-5', COST_OPUS_55],
['claude-opus-5', COST_TIER_5_25],
['claude-opus-4-8', COST_TIER_5_25],
['claude-opus-4-7', COST_TIER_5_25],
@@ -54,7 +58,8 @@ const MODEL_TIERS: ReadonlyArray<readonly [prefix: string, costs: ModelCosts]> =
['claude-fable-5', COST_TIER_10_50],
['claude-fable-5-1', COST_FABLE_51],
['claude-mythos-5', COST_TIER_10_50],
['claude-sonnet-5', COST_TIER_3_15],
['claude-sonnet-5-5', COST_TIER_2_10],
['claude-sonnet-5', COST_TIER_2_10],
['claude-sonnet-4-6', COST_TIER_3_15],
['claude-sonnet-4-5', COST_TIER_3_15],
['claude-sonnet-4', COST_TIER_3_15],
@@ -68,7 +73,9 @@ const MODEL_TIERS: ReadonlyArray<readonly [prefix: string, costs: ModelCosts]> =
// Fast mode bills at its own rate on the models that offer it; everything else ignores `speed`.
const FAST_MODE_TIERS: ReadonlyArray<readonly [prefix: string, costs: ModelCosts]> = [
['claude-opus-5-5', COST_OPUS_55_FAST],
['claude-opus-5', COST_TIER_10_50],
['claude-opus-4-8', COST_TIER_10_50],
['claude-opus-4-7', COST_TIER_30_150],
]