mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
feat(models): support Sonnet 5.5 and refresh Claude defaults
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
@@ -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')) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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],
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user