From 6d9a3a9364c4c846ef0093f6633f9b35c38b3a39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 29 Sep 2026 16:25:20 +0800 Subject: [PATCH] feat(models): support Sonnet 5.5 and refresh Claude defaults --- .../chat/PlanModePermissionDialog.test.tsx | 5 +- .../controls/ModelSelector.test.tsx | 24 +- desktop/src/constants/modelCatalog.test.ts | 15 + desktop/src/constants/modelCatalog.ts | 8 + src/constants/prompts.ts | 32 ++- src/server/__tests__/conversations.test.ts | 4 +- .../__tests__/e2e/business-flow.test.ts | 5 +- src/server/__tests__/e2e/full-flow.test.ts | 2 +- src/server/__tests__/settings.test.ts | 34 ++- src/server/api/models.ts | 13 +- .../services/claudeOfficialRuntime.test.ts | 29 +- src/server/services/claudeOfficialRuntime.ts | 2 +- .../services/localIndex/coordinator.test.ts | 95 +++++++ .../services/localIndex/sessionProjector.ts | 4 +- .../api/claudeRequiredThinking.test.ts | 20 ++ src/skills/bundled/claudeApiContent.ts | 10 +- src/utils/commitAttribution.ts | 1 + src/utils/context.ts | 2 +- src/utils/effort.ts | 5 +- src/utils/model/configs.ts | 9 + src/utils/model/fable.test.ts | 48 +++- src/utils/model/model.ts | 33 ++- src/utils/model/modelContextWindows.ts | 1 + src/utils/model/modelOptions.ts | 43 +-- src/utils/model/opus5.test.ts | 63 ++++- src/utils/model/sonnet55.test.ts | 256 ++++++++++++++++++ src/utils/modelCost.ts | 30 +- src/utils/modelCostParity.test.ts | 42 +++ src/utils/thinking.ts | 21 +- src/utils/usageAccounting.test.ts | 79 +++++- src/utils/usageAccounting.ts | 9 +- 31 files changed, 842 insertions(+), 102 deletions(-) create mode 100644 src/utils/model/sonnet55.test.ts create mode 100644 src/utils/modelCostParity.test.ts diff --git a/desktop/src/components/chat/PlanModePermissionDialog.test.tsx b/desktop/src/components/chat/PlanModePermissionDialog.test.tsx index c61fb119..c5c78225 100644 --- a/desktop/src/components/chat/PlanModePermissionDialog.test.tsx +++ b/desktop/src/components/chat/PlanModePermissionDialog.test.tsx @@ -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') }) diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx index 2b85c717..6dc35d7d 100644 --- a/desktop/src/components/controls/ModelSelector.test.tsx +++ b/desktop/src/components/controls/ModelSelector.test.tsx @@ -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() + 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 () => {}) diff --git a/desktop/src/constants/modelCatalog.test.ts b/desktop/src/constants/modelCatalog.test.ts index f876f9c5..4321e8a2 100644 --- a/desktop/src/constants/modelCatalog.test.ts +++ b/desktop/src/constants/modelCatalog.test.ts @@ -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', diff --git a/desktop/src/constants/modelCatalog.ts b/desktop/src/constants/modelCatalog.ts index 49339f52..d39cffdc 100644 --- a/desktop/src/constants/modelCatalog.ts +++ b/desktop/src/constants/modelCatalog.ts @@ -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', diff --git a/src/constants/prompts.ts b/src/constants/prompts.ts index 23e62bac..a51bd73a 100644 --- a/src/constants/prompts.ts +++ b/src/constants/prompts.ts @@ -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')) { diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 0b5099b7..5f58c185 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -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 diff --git a/src/server/__tests__/e2e/business-flow.test.ts b/src/server/__tests__/e2e/business-flow.test.ts index 2ddffeef..58645edb 100644 --- a/src/server/__tests__/e2e/business-flow.test.ts +++ b/src/server/__tests__/e2e/business-flow.test.ts @@ -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 () => { diff --git a/src/server/__tests__/e2e/full-flow.test.ts b/src/server/__tests__/e2e/full-flow.test.ts index 7c7ebd64..2a5268f0 100644 --- a/src/server/__tests__/e2e/full-flow.test.ts +++ b/src/server/__tests__/e2e/full-flow.test.ts @@ -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') }) diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts index 0ee5930b..5f8b466c 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -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' diff --git a/src/server/api/models.ts b/src/server/api/models.ts index aef44ff2..b9af8ae5 100644 --- a/src/server/api/models.ts +++ b/src/server/api/models.ts @@ -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() diff --git a/src/server/services/claudeOfficialRuntime.test.ts b/src/server/services/claudeOfficialRuntime.test.ts index dfd29b4f..e86ecc76 100644 --- a/src/server/services/claudeOfficialRuntime.test.ts +++ b/src/server/services/claudeOfficialRuntime.test.ts @@ -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 () => { diff --git a/src/server/services/claudeOfficialRuntime.ts b/src/server/services/claudeOfficialRuntime.ts index 4cff2724..3cf9f7e7 100644 --- a/src/server/services/claudeOfficialRuntime.ts +++ b/src/server/services/claudeOfficialRuntime.ts @@ -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, diff --git a/src/server/services/localIndex/coordinator.test.ts b/src/server/services/localIndex/coordinator.test.ts index 4edff93f..142a9058 100644 --- a/src/server/services/localIndex/coordinator.test.ts +++ b/src/server/services/localIndex/coordinator.test.ts @@ -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 diff --git a/src/server/services/localIndex/sessionProjector.ts b/src/server/services/localIndex/sessionProjector.ts index c9848816..df5f77c5 100644 --- a/src/server/services/localIndex/sessionProjector.ts +++ b/src/server/services/localIndex/sessionProjector.ts @@ -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 diff --git a/src/services/api/claudeRequiredThinking.test.ts b/src/services/api/claudeRequiredThinking.test.ts index ec337def..7966f359 100644 --- a/src/services/api/claudeRequiredThinking.test.ts +++ b/src/services/api/claudeRequiredThinking.test.ts @@ -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', diff --git a/src/skills/bundled/claudeApiContent.ts b/src/skills/bundled/claudeApiContent.ts index 901cf3c8..3ade8abb 100644 --- a/src/skills/bundled/claudeApiContent.ts +++ b/src/skills/bundled/claudeApiContent.ts @@ -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 export const SKILL_PROMPT: string = skillPrompt diff --git a/src/utils/commitAttribution.ts b/src/utils/commitAttribution.ts index 4857074f..f4f9eea8 100644 --- a/src/utils/commitAttribution.ts +++ b/src/utils/commitAttribution.ts @@ -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' diff --git a/src/utils/context.ts b/src/utils/context.ts index c34e0732..7698396d 100644 --- a/src/utils/context.ts +++ b/src/utils/context.ts @@ -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 ( diff --git a/src/utils/effort.ts b/src/utils/effort.ts index 0b92abde..ed2f75f8 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -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' } diff --git a/src/utils/model/configs.ts b/src/utils/model/configs.ts index 0895d68c..9948c9d7 100644 --- a/src/utils/model/configs.ts +++ b/src/utils/model/configs.ts @@ -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, diff --git a/src/utils/model/fable.test.ts b/src/utils/model/fable.test.ts index 8c10711a..d3c3eb71 100644 --- a/src/utils/model/fable.test.ts +++ b/src/utils/model/fable.test.ts @@ -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') diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index 90c8ffb4..fed3d9c9 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -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' } diff --git a/src/utils/model/modelContextWindows.ts b/src/utils/model/modelContextWindows.ts index 69d888b5..10938e56 100644 --- a/src/utils/model/modelContextWindows.ts +++ b/src/utils/model/modelContextWindows.ts @@ -9,6 +9,7 @@ const DIRECT_MODEL_CONTEXT_WINDOWS: Record = { '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, diff --git a/src/utils/model/modelOptions.ts b/src/utils/model/modelOptions.ts index b7b6490f..48d199ae 100644 --- a/src/utils/model/modelOptions.ts +++ b/src/utils/model/modelOptions.ts @@ -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 } diff --git a/src/utils/model/opus5.test.ts b/src/utils/model/opus5.test.ts index 80d033e2..f80539d7 100644 --- a/src/utils/model/opus5.test.ts +++ b/src/utils/model/opus5.test.ts @@ -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) diff --git a/src/utils/model/sonnet55.test.ts b/src/utils/model/sonnet55.test.ts new file mode 100644 index 00000000..a6ecaf8e --- /dev/null +++ b/src/utils/model/sonnet55.test.ts @@ -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) + } + }) +}) diff --git a/src/utils/modelCost.ts b/src/utils/modelCost.ts index 282e8827..2f3f25c8 100644 --- a/src/utils/modelCost.ts +++ b/src/utils/modelCost.ts @@ -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 = { [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 = { 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, diff --git a/src/utils/modelCostParity.test.ts b/src/utils/modelCostParity.test.ts new file mode 100644 index 00000000..7ddb119e --- /dev/null +++ b/src/utils/modelCostParity.test.ts @@ -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, + }) + } + }) +}) diff --git a/src/utils/thinking.ts b/src/utils/thinking.ts index fca8f4ae..74b112a8 100644 --- a/src/utils/thinking.ts +++ b/src/utils/thinking.ts @@ -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() diff --git a/src/utils/usageAccounting.test.ts b/src/utils/usageAccounting.test.ts index 0eea2cb8..81329e28 100644 --- a/src/utils/usageAccounting.test.ts +++ b/src/utils/usageAccounting.test.ts @@ -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 })) diff --git a/src/utils/usageAccounting.ts b/src/utils/usageAccounting.ts index 0ddf5b84..529e8d28 100644 --- a/src/utils/usageAccounting.ts +++ b/src/utils/usageAccounting.ts @@ -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 = [ + ['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 = ['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 = // Fast mode bills at its own rate on the models that offer it; everything else ignores `speed`. const FAST_MODE_TIERS: ReadonlyArray = [ + ['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], ]