From ca0c58384193fa96f96d62d609d59a65c99510b4 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: Thu, 15 Oct 2026 16:59:09 +0800 Subject: [PATCH 1/2] fix(models): keep adaptive thinking for Claude 5 and default Haiku to 5.5 Claude 5.x and Opus 4.7/4.8 reject budget_tokens with a 400, so CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING no longer falls back to a manual budget for them, and provider capability lists that omit required_thinking keep adaptive-only models adaptive. Non-Claude Anthropic-protocol models are unchanged. Add Haiku 5.5: canonical name, 1M context, 128K output, all effort levels (medium default), adaptive thinking with the disabled-thinking effort cap, prompt-length tiered pricing in both cost tables, and make it the first-party Haiku default (third-party providers keep Haiku 4.5). --- desktop/src/constants/modelCatalog.ts | 8 + src/constants/prompts.ts | 8 +- .../__tests__/e2e/business-flow.test.ts | 3 +- src/server/__tests__/e2e/full-flow.test.ts | 2 +- src/server/__tests__/settings.test.ts | 9 + src/server/api/models.ts | 8 + src/server/services/claudeOfficialRuntime.ts | 6 +- .../services/localIndex/sessionProjector.ts | 3 +- src/services/api/claude.ts | 11 +- .../api/claudeRequiredThinking.test.ts | 109 +++++++++++ src/utils/betas.ts | 2 + src/utils/commitAttribution.ts | 1 + src/utils/context.ts | 4 +- src/utils/effort.ts | 13 +- src/utils/model/agent.test.ts | 2 +- src/utils/model/configs.ts | 9 + src/utils/model/fable.test.ts | 5 +- src/utils/model/haiku55.test.ts | 175 ++++++++++++++++++ src/utils/model/model.ts | 17 +- src/utils/model/modelContextWindows.ts | 1 + src/utils/model/modelOptions.ts | 13 ++ src/utils/modelCost.ts | 40 +++- src/utils/modelCostParity.test.ts | 1 + src/utils/thinking.ts | 22 ++- src/utils/usageAccounting.ts | 11 +- 25 files changed, 457 insertions(+), 26 deletions(-) create mode 100644 src/utils/model/haiku55.test.ts diff --git a/desktop/src/constants/modelCatalog.ts b/desktop/src/constants/modelCatalog.ts index d39cffdc78..03ba27f478 100644 --- a/desktop/src/constants/modelCatalog.ts +++ b/desktop/src/constants/modelCatalog.ts @@ -57,6 +57,14 @@ export const OFFICIAL_MODELS: ModelInfo[] = [ defaultReasoningEffort: 'high', supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], }, + { + id: 'claude-haiku-5-5', + name: 'Haiku 5.5', + description: 'Fastest for high-volume, latency-sensitive tasks', + context: '1m', + defaultReasoningEffort: 'medium', + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], + }, { id: 'claude-haiku-4-5', name: 'Haiku 4.5', diff --git a/src/constants/prompts.ts b/src/constants/prompts.ts index a51bd73aa2..e44c13b839 100644 --- a/src/constants/prompts.ts +++ b/src/constants/prompts.ts @@ -116,12 +116,11 @@ export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = // @[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-1', opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', - haiku: 'claude-haiku-4-5-20251001', + haiku: 'claude-haiku-5-5', } function getHooksSection(): string { @@ -693,7 +692,7 @@ export async function computeSimpleEnvInfo( knowledgeCutoffMessage, process.env.USER_TYPE === 'ant' && isUndercover() ? null - : `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.`, + : `The most recent Claude models are the Claude 5 family. 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 5.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).`, @@ -717,7 +716,8 @@ function getKnowledgeCutoff(modelId: string): string | null { if ( canonical.includes('claude-fable-5-1') || canonical.includes('claude-opus-5-5') || - canonical.includes('claude-sonnet-5-5') + canonical.includes('claude-sonnet-5-5') || + canonical.includes('claude-haiku-5-5') ) { return 'June 2026' } else if (canonical.includes('claude-opus-5')) { diff --git a/src/server/__tests__/e2e/business-flow.test.ts b/src/server/__tests__/e2e/business-flow.test.ts index 3a654190e8..f7264e4977 100644 --- a/src/server/__tests__/e2e/business-flow.test.ts +++ b/src/server/__tests__/e2e/business-flow.test.ts @@ -375,7 +375,7 @@ describe('Business Flow: Models & Effort', () => { it('should return available fallback models', async () => { const { data } = await api('GET', '/api/models') - expect(data.models.length).toBe(8) + expect(data.models.length).toBe(9) const names = data.models.map((m: any) => m.name) expect(names).toContain('Fable 5.1') expect(names).toContain('Fable 5') @@ -384,6 +384,7 @@ describe('Business Flow: Models & Effort', () => { expect(names).toContain('Opus 4.8') expect(names).toContain('Sonnet 5.5') expect(names).toContain('Sonnet 5') + expect(names).toContain('Haiku 5.5') expect(names).toContain('Haiku 4.5') }) diff --git a/src/server/__tests__/e2e/full-flow.test.ts b/src/server/__tests__/e2e/full-flow.test.ts index a476d617c0..d7aace60cb 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(8) + expect(data.models.length).toBe(9) 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 7bd91f570f..3f0ad2f214 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -1036,6 +1036,14 @@ describe('Models API', () => { defaultReasoningEffort: 'high', supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], }, + { + id: 'claude-haiku-5-5', + name: 'Haiku 5.5', + description: 'Fastest for high-volume, latency-sensitive tasks', + context: '1m', + defaultReasoningEffort: 'medium', + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], + }, { id: 'claude-haiku-4-5', name: 'Haiku 4.5', @@ -1281,6 +1289,7 @@ describe('Models API', () => { 'claude-opus-4-8', 'claude-sonnet-5-5', 'claude-sonnet-5', + 'claude-haiku-5-5', 'claude-haiku-4-5', ]) }) diff --git a/src/server/api/models.ts b/src/server/api/models.ts index fc3296c0c7..47971dd705 100644 --- a/src/server/api/models.ts +++ b/src/server/api/models.ts @@ -117,6 +117,14 @@ const DEFAULT_MODELS = [ defaultReasoningEffort: 'high', supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], }, + { + id: 'claude-haiku-5-5', + name: 'Haiku 5.5', + description: 'Fastest for high-volume, latency-sensitive tasks', + context: '1m', + defaultReasoningEffort: 'medium', + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], + }, { id: 'claude-haiku-4-5', name: 'Haiku 4.5', diff --git a/src/server/services/claudeOfficialRuntime.ts b/src/server/services/claudeOfficialRuntime.ts index 3cf9f7e79f..863a34444d 100644 --- a/src/server/services/claudeOfficialRuntime.ts +++ b/src/server/services/claudeOfficialRuntime.ts @@ -4,11 +4,12 @@ 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-5' +export const CLAUDE_OFFICIAL_HAIKU_MODEL_ID = 'claude-haiku-5-5' export const CLAUDE_OFFICIAL_DEFAULT_MODELS: ModelMapping = { main: CLAUDE_OFFICIAL_OPUS_MODEL_ID, fable: 'claude-fable-5-1', - haiku: 'claude-haiku-4-5', + haiku: CLAUDE_OFFICIAL_HAIKU_MODEL_ID, sonnet: CLAUDE_OFFICIAL_SONNET_MODEL_ID, opus: CLAUDE_OFFICIAL_OPUS_MODEL_ID, } @@ -17,10 +18,11 @@ const CURRENT_MODEL_ALIASES = new Map([ ['fable', 'claude-fable-5'], ['opus', CLAUDE_OFFICIAL_OPUS_MODEL_ID], ['sonnet', CLAUDE_OFFICIAL_SONNET_MODEL_ID], - ['haiku', 'claude-haiku-4-5'], + ['haiku', CLAUDE_OFFICIAL_HAIKU_MODEL_ID], ['claude-fable-5', 'claude-fable-5'], [CLAUDE_OFFICIAL_OPUS_MODEL_ID, CLAUDE_OFFICIAL_OPUS_MODEL_ID], [CLAUDE_OFFICIAL_SONNET_MODEL_ID, CLAUDE_OFFICIAL_SONNET_MODEL_ID], + [CLAUDE_OFFICIAL_HAIKU_MODEL_ID, CLAUDE_OFFICIAL_HAIKU_MODEL_ID], ['claude-haiku-4-5', 'claude-haiku-4-5'], ]) diff --git a/src/server/services/localIndex/sessionProjector.ts b/src/server/services/localIndex/sessionProjector.ts index 48b6c0ffa3..70ab936a15 100644 --- a/src/server/services/localIndex/sessionProjector.ts +++ b/src/server/services/localIndex/sessionProjector.ts @@ -44,7 +44,8 @@ import type { // 9: usage cost rates were corrected (Sonnet 5, Sonnet 5.5, Opus 5.5, fast mode); rebuild the // persisted per-model dollars. // 10: project the explicit per-session ChatGPT OAuth Fast selection. -export const SESSION_SUMMARY_PARSER_VERSION = 10 +// 11: Haiku 5.5 gained (prompt-length tiered) pricing; rebuild the persisted per-model dollars. +export const SESSION_SUMMARY_PARSER_VERSION = 11 export type SessionSourceCandidate = { path: string diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index a615a61c29..51c47131d2 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -195,6 +195,7 @@ import { endQueryProfile, queryCheckpoint } from "src/utils/queryProfiler.js"; import { modelSupportsAdaptiveThinking, modelUsesBoundThinking, + modelRejectsManualThinking, modelRequiresThinking, modelSupportsThinking, resolveModelThinkingEnabled, @@ -1741,8 +1742,11 @@ async function* queryModel( retryContext?.maxTokensOverride || options.maxOutputTokensOverride || getMaxOutputTokensForModel(options.model) + // CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING only selects a manual budget, so it + // cannot apply to models that reject budget_tokens. const usesAdaptiveThinking = (modelRequiresThinking(options.model) || + modelRejectsManualThinking(options.model) || !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING)) && modelSupportsAdaptiveThinking(options.model) // Manual thinking needs at least 1024 tokens and room for the answer. @@ -1778,11 +1782,12 @@ async function* queryModel( // The installed SDK's effort type predates xhigh, which configureEffortParams supports. const configuredEffort = outputConfig.effort as string | null | undefined - // Opus 5 rejects disabled thinking above high effort. Preserve the user's - // explicit thinking choice and use the highest compatible effort instead. + // Opus 5 and Haiku 5.5 reject disabled thinking above high effort. Preserve + // the user's explicit thinking choice and use the highest compatible effort. + const modelId = options.model.toLowerCase() if ( sendsExplicitDisabledThinking && - options.model.toLowerCase().includes('claude-opus-5') && + (modelId.includes('claude-opus-5') || modelId.includes('claude-haiku-5-5')) && (configuredEffort === 'xhigh' || configuredEffort === 'max') ) { outputConfig.effort = 'high' diff --git a/src/services/api/claudeRequiredThinking.test.ts b/src/services/api/claudeRequiredThinking.test.ts index 17939073ee..9b699b7831 100644 --- a/src/services/api/claudeRequiredThinking.test.ts +++ b/src/services/api/claudeRequiredThinking.test.ts @@ -951,6 +951,21 @@ for (const effortValue of ['low', 'high', 'xhigh', 'max'] as const) { }, 10_000) } +for (const effortValue of ['medium', 'high', 'xhigh', 'max'] as const) { + test(`Haiku 5.5 caps ${effortValue} effort when thinking is disabled`, async () => { + const { requests } = await captureQueryRequest({ + model: 'claude-haiku-5-5', + capabilities: 'thinking,effort,adaptive_thinking,xhigh_effort,max_effort', + effortValue, + }) + expect(requests).toHaveLength(1) + expect(requests[0]?.thinking).toEqual({ type: 'disabled' }) + expect(requests[0]?.output_config).toEqual({ + effort: effortValue === 'xhigh' || effortValue === 'max' ? 'high' : effortValue, + }) + }, 10_000) +} + test('Opus 5 preserves max effort with adaptive thinking enabled', async () => { const { requests } = await captureQueryRequest({ model: 'claude-opus-5', @@ -1014,6 +1029,100 @@ for (const disableAdaptive of [false, true]) { }, 10_000) } +test('Opus 5.5 keeps adaptive thinking on a provider when optional adaptive thinking is disabled', async () => { + // Generic provider capabilities omit required_thinking; a user-level + // CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING must not turn that into budget thinking. + const provider = { + id: 'provider-modelflare', + presetId: 'custom', + name: 'Modelflare', + apiKey: 'loopback-test-key', + authStrategy: 'auth_token', + baseUrl: 'https://provider.example.invalid', + apiFormat: 'anthropic', + runtimeKind: 'anthropic_compatible', + models: { + main: 'claude-opus-5-5[1m]', + haiku: 'claude-haiku-5-5[1m]', + sonnet: 'claude-sonnet-5-5[1m]', + opus: 'claude-opus-5-5[1m]', + }, + } satisfies SavedProvider + + const { content, requests } = await captureQueryRequest({ + model: 'claude-opus-5-5[1m]', + provider, + configureCapabilityOverrides: false, + thinkingBudget: 2048, + env: { CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: '1' }, + responseFactory: (model, body) => { + if ((body.thinking as { type?: unknown } | undefined)?.type !== 'adaptive') { + return Response.json({ + type: 'error', + error: { + type: 'invalid_request_error', + message: 'claude-opus-5-5 requires adaptive thinking; omit thinking or use thinking.type=adaptive and output_config.effort', + }, + }, { status: 400 }) + } + return new Response(successfulResponse(model), { + headers: { 'content-type': 'text/event-stream' }, + }) + }, + }) + + expect(content).toEqual([{ type: 'text', text: 'OK' }]) + expect(requests).toHaveLength(1) + expect(requests[0]?.thinking).toEqual({ type: 'adaptive' }) +}, 10_000) + +// Optional adaptive opt-out on a provider-managed environment. Claude models the +// API rejects budget_tokens for keep adaptive; every other model keeps the +// requested manual budget exactly as before. +const MANUAL_BUDGET = { type: 'enabled', budget_tokens: 2048 } +for (const [model, expectedThinking] of [ + ['claude-opus-5-5', { type: 'adaptive' }], + ['claude-sonnet-5-5', { type: 'adaptive' }], + ['claude-haiku-5-5', { type: 'adaptive' }], + ['claude-fable-5-1', { type: 'adaptive' }], + ['claude-opus-5', { type: 'adaptive' }], + ['claude-sonnet-5', { type: 'adaptive' }], + ['claude-opus-4-8', { type: 'adaptive' }], + ['claude-opus-4-7', { type: 'adaptive' }], + ['claude-opus-4-6', MANUAL_BUDGET], + ['claude-sonnet-4-6', MANUAL_BUDGET], + ['deepseek-v4-pro', MANUAL_BUDGET], + ['minimax-m3', MANUAL_BUDGET], + ['glm-5.3', MANUAL_BUDGET], + ['kimi-k3', MANUAL_BUDGET], + ['doubao-seed-2-0-pro', MANUAL_BUDGET], + ['hunyuan-t1-latest', MANUAL_BUDGET], +] as const) { + test(`${model} thinking with optional adaptive disabled on a provider`, async () => { + const provider = { + id: 'provider-anthropic-compatible', + presetId: 'custom', + name: 'Anthropic compatible', + apiKey: 'loopback-test-key', + authStrategy: 'auth_token', + baseUrl: 'https://provider.example.invalid', + apiFormat: 'anthropic', + runtimeKind: 'anthropic_compatible', + models: { main: model, haiku: model, sonnet: model, opus: model }, + } satisfies SavedProvider + + const { requests } = await captureQueryRequest({ + model, + provider, + configureCapabilityOverrides: false, + thinkingBudget: 2048, + env: { CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: '1' }, + }) + expect(requests).toHaveLength(1) + expect(requests[0]?.thinking).toMatchObject(expectedThinking) + }, 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/utils/betas.ts b/src/utils/betas.ts index bd45469391..100b0ab64c 100644 --- a/src/utils/betas.ts +++ b/src/utils/betas.ts @@ -150,6 +150,7 @@ export function modelSupportsContextManagement(model: string): boolean { canonical.includes('claude-sonnet-5') || canonical.includes('claude-opus-4') || canonical.includes('claude-sonnet-4') || + canonical.includes('claude-haiku-5') || canonical.includes('claude-haiku-4') ) } @@ -175,6 +176,7 @@ export function modelSupportsStructuredOutputs(model: string): boolean { canonical.includes('claude-opus-5') || canonical.includes('claude-opus-4-8') || canonical.includes('claude-opus-4-7') || + canonical.includes('claude-haiku-5-5') || canonical.includes('claude-haiku-4-5') ) } diff --git a/src/utils/commitAttribution.ts b/src/utils/commitAttribution.ts index f4f9eea896..2984121065 100644 --- a/src/utils/commitAttribution.ts +++ b/src/utils/commitAttribution.ts @@ -168,6 +168,7 @@ export function sanitizeModelName(shortName: string): string { if (shortName.includes('sonnet-4-5')) return 'claude-sonnet-4-5' if (shortName.includes('sonnet-4')) return 'claude-sonnet-4' if (shortName.includes('sonnet-3-7')) return 'claude-sonnet-3-7' + if (shortName.includes('haiku-5-5')) return 'claude-haiku-5-5' if (shortName.includes('haiku-4-5')) return 'claude-haiku-4-5' if (shortName.includes('haiku-3-5')) return 'claude-haiku-3-5' // Unknown models get a generic name diff --git a/src/utils/context.ts b/src/utils/context.ts index b23bdfa0d1..0ce74f1c46 100644 --- a/src/utils/context.ts +++ b/src/utils/context.ts @@ -58,6 +58,7 @@ export function modelSupports1M(model: string): boolean { return ( canonical.includes('claude-fable-5') || canonical.includes('claude-sonnet-5') || + canonical.includes('claude-haiku-5') || canonical.includes('claude-opus-5') || canonical.includes('claude-opus-4-8') || canonical.includes('claude-opus-4-7') || @@ -302,7 +303,8 @@ export function getModelMaxOutputTokens(model: string): { m.includes('opus-4-8') || m.includes('opus-4-7') || m.includes('opus-4-6') || - m.includes('sonnet-5') + m.includes('sonnet-5') || + m.includes('haiku-5') ) { defaultTokens = 64_000 upperLimit = 128_000 diff --git a/src/utils/effort.ts b/src/utils/effort.ts index ed2f75f8c4..f34de7e103 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -79,6 +79,7 @@ export function modelSupportsEffort(model: string): boolean { m.includes('opus-4-5') || m.includes('sonnet-4-6') || m.includes('sonnet-5') || + m.includes('haiku-5') || m.includes('fable-5') || m.includes('mythos-5') || m.includes('mythos-preview') @@ -123,6 +124,7 @@ export function modelSupportsXHighEffort(model: string): boolean { m.includes('opus-5') || m.includes('opus-4-8') || m.includes('sonnet-5') || + m.includes('haiku-5') || m.includes('fable-5') || m.includes('mythos-5') ) @@ -150,6 +152,7 @@ export function modelSupportsMaxEffort(model: string): boolean { m.includes('opus-4-8') || m.includes('sonnet-4-6') || m.includes('sonnet-5') || + m.includes('haiku-5') || m.includes('fable-5') || m.includes('mythos-5') || m.includes('mythos-preview') @@ -435,10 +438,14 @@ export function getDefaultEffortForModel( // the model launch DRI and research. Default effort is a sensitive setting // that can greatly affect model quality and bashing. - // 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. + // Claude Code runs Opus 5.5, Sonnet 5.5 and Haiku 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') { + if ( + canonicalModel === 'claude-opus-5-5' || + canonicalModel === 'claude-sonnet-5-5' || + canonicalModel === 'claude-haiku-5-5' + ) { return 'medium' } diff --git a/src/utils/model/agent.test.ts b/src/utils/model/agent.test.ts index 5a3ef9a1f2..9402e08a69 100644 --- a/src/utils/model/agent.test.ts +++ b/src/utils/model/agent.test.ts @@ -17,7 +17,7 @@ describe('getAgentModel', () => { expect( getAgentModel('haiku', 'claude-sonnet-4-6', undefined, 'default'), - ).toBe('claude-haiku-4-5-20251001') + ).toBe('claude-haiku-5-5') expect( getAgentModel('inherit', 'claude-sonnet-4-6', undefined, 'default'), ).toBe('claude-sonnet-4-6') diff --git a/src/utils/model/configs.ts b/src/utils/model/configs.ts index 9948c9d78c..11fa15d9ec 100644 --- a/src/utils/model/configs.ts +++ b/src/utils/model/configs.ts @@ -38,6 +38,14 @@ export const CLAUDE_HAIKU_4_5_CONFIG = { azureOpenAI: 'claude-haiku-4-5-20251001', } as const satisfies ModelConfig +export const CLAUDE_HAIKU_5_5_CONFIG = { + firstParty: 'claude-haiku-5-5', + bedrock: 'us.anthropic.claude-haiku-5-5', + vertex: 'claude-haiku-5-5', + foundry: 'claude-haiku-5-5', + azureOpenAI: 'claude-haiku-5-5', +} as const satisfies ModelConfig + export const CLAUDE_FABLE_5_CONFIG = { firstParty: 'claude-fable-5', bedrock: 'anthropic.claude-fable-5', @@ -180,6 +188,7 @@ export const ALL_MODEL_CONFIGS = { fable51: CLAUDE_FABLE_5_1_CONFIG, haiku35: CLAUDE_3_5_HAIKU_CONFIG, haiku45: CLAUDE_HAIKU_4_5_CONFIG, + haiku55: CLAUDE_HAIKU_5_5_CONFIG, sonnet35: CLAUDE_3_5_V2_SONNET_CONFIG, sonnet37: CLAUDE_3_7_SONNET_CONFIG, sonnet40: CLAUDE_SONNET_4_CONFIG, diff --git a/src/utils/model/fable.test.ts b/src/utils/model/fable.test.ts index d3c3eb7173..88c5e5d492 100644 --- a/src/utils/model/fable.test.ts +++ b/src/utils/model/fable.test.ts @@ -169,11 +169,11 @@ describe('Fable model configuration', () => { 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('The most recent Claude models are the Claude 5 family and Haiku 4.5.') + expect(info).toContain('The most recent Claude models are the Claude 5 family.') 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'") + expect(info).toContain("Haiku 5.5: 'claude-haiku-5-5'") // 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') @@ -193,6 +193,7 @@ describe('Fable model configuration', () => { ['claude-sonnet-5', 'January 2026'], ['claude-sonnet-4-6', 'August 2025'], ['claude-opus-4-5', 'May 2025'], + ['claude-haiku-5-5', 'June 2026'], ['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}.`) diff --git a/src/utils/model/haiku55.test.ts b/src/utils/model/haiku55.test.ts new file mode 100644 index 0000000000..ab905b5727 --- /dev/null +++ b/src/utils/model/haiku55.test.ts @@ -0,0 +1,175 @@ +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 { 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 } from '../modelCost.js' +import { resetSettingsCache } from '../settings/settingsCache.js' +import { + modelRejectsManualThinking, + modelRequiresThinking, + modelSupportsAdaptiveThinking, + modelSupportsThinking, +} from '../thinking.js' +import { estimateCostUSD } from '../usageAccounting.js' +import { ALL_MODEL_CONFIGS } from './configs.js' +import { + firstPartyNameToCanonical, + getDefaultHaikuModel, + getMarketingNameForModel, + getPublicModelDisplayName, + parseUserSpecifiedModel, +} from './model.js' +import { get3PModelCapabilityOverride } from './modelSupportOverrides.js' + +const envKeys = [ + 'HOME', + 'CLAUDE_CONFIG_DIR', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_DISABLE_1M_CONTEXT', +] 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(), 'haiku55-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 HAIKU_55_VARIANTS = [ + 'claude-haiku-5-5', + 'claude-haiku-5-5[1m]', + 'us.anthropic.claude-haiku-5-5', + 'anthropic.claude-haiku-5-5', +] + +describe('Haiku 5.5 identity', () => { + test('keeps its own canonical name instead of collapsing to a bare family', () => { + expect(ALL_MODEL_CONFIGS.haiku55.firstParty).toBe('claude-haiku-5-5') + for (const model of HAIKU_55_VARIANTS) { + expect(firstPartyNameToCanonical(model)).toBe('claude-haiku-5-5') + expect(sanitizeModelName(model)).toBe('claude-haiku-5-5') + } + expect(firstPartyNameToCanonical('claude-haiku-4-5-20251001')).toBe('claude-haiku-4-5') + }) + + test('renders public and marketing names', () => { + expect(getPublicModelDisplayName('claude-haiku-5-5')).toBe('Haiku 5.5') + expect(getPublicModelDisplayName('claude-haiku-5-5[1m]')).toBe('Haiku 5.5 (1M context)') + expect(getMarketingNameForModel('claude-haiku-5-5')).toBe('Haiku 5.5') + expect(getMarketingNameForModel('claude-haiku-5-5[1m]')).toBe('Haiku 5.5 (with 1M context)') + }) +}) + +// https://platform.claude.com/docs/en/models/overview (checked 2026-10-15). +describe('Haiku 5.5 runtime limits, effort and thinking', () => { + test('exposes the native 1M window and the 128K output limit', () => { + for (const model of HAIKU_55_VARIANTS) { + expect(modelSupports1M(model)).toBe(true) + } + expect(getContextWindowForModel('claude-haiku-5-5')).toBe(1_000_000) + expect(getModelMaxOutputTokens('claude-haiku-5-5').upperLimit).toBe(128_000) + expect(getModelMaxOutputTokens('claude-haiku-4-5').upperLimit).toBe(64_000) + }) + + test('supports every effort level and defaults to medium', () => { + expect(modelSupportsEffort('claude-haiku-5-5')).toBe(true) + expect(modelSupportsXHighEffort('claude-haiku-5-5')).toBe(true) + expect(modelSupportsMaxEffort('claude-haiku-5-5')).toBe(true) + expect(getDefaultEffortForModel('claude-haiku-5-5')).toBe('medium') + expect(modelSupportsEffort('claude-haiku-4-5')).toBe(false) + }) + + test('uses adaptive thinking, rejects budgets, but may still be disabled', () => { + expect(modelSupportsThinking('claude-haiku-5-5')).toBe(true) + expect(modelSupportsAdaptiveThinking('claude-haiku-5-5')).toBe(true) + expect(modelRejectsManualThinking('claude-haiku-5-5')).toBe(true) + expect(modelRequiresThinking('claude-haiku-5-5')).toBe(false) + expect(modelRejectsManualThinking('claude-haiku-4-5')).toBe(false) + }) +}) + +// https://platform.claude.com/docs/en/about-claude/pricing (checked 2026-10-15), $ per MTok. +describe('Haiku 5.5 prompt-length pricing', () => { + const million = (prompt: { input: number, read?: number, write?: number }) => ({ + inputTokens: prompt.input, + outputTokens: 1_000_000, + cacheReadInputTokens: prompt.read ?? 0, + cacheCreationInputTokens: prompt.write ?? 0, + }) + + test.each([ + ['prompt at the threshold', million({ input: 100_000 }), 0.1 * 0.1 + 0.5], + ['prompt over the threshold', million({ input: 100_001 }), 0.100001 * 0.5 + 2.5], + // Cache reads and writes count toward prompt length too. + [ + 'cache pushing the prompt over', + million({ input: 1, read: 60_000, write: 40_000 }), + 0.000001 * 0.5 + 0.06 * 0.05 + 0.04 * 0.625 + 2.5, + ], + ])('prices a %s identically in both cost tables', (_label, tokens, expected) => { + expect(calculateCostFromTokens('claude-haiku-5-5', tokens)).toBeCloseTo(expected, 10) + expect(estimateCostUSD('claude-haiku-5-5', tokens)).toBeCloseTo(expected, 10) + }) + + test('keeps flat pricing for other models', () => { + const tokens = million({ input: 500_000 }) + expect(estimateCostUSD('claude-haiku-4-5', tokens)).toBeCloseTo(0.5 + 5, 10) + }) +}) + +describe('Haiku alias resolution', () => { + test('resolves the haiku alias to Haiku 5.5 on first party', () => { + expect(getDefaultHaikuModel()).toBe('claude-haiku-5-5') + expect(parseUserSpecifiedModel('haiku')).toBe('claude-haiku-5-5') + expect(parseUserSpecifiedModel('claude-haiku-4-5')).toBe('claude-haiku-4-5') + }) + + test('keeps the lag-safe Haiku 4.5 default on third-party providers', () => { + process.env.ANTHROPIC_BASE_URL = 'https://provider.example.invalid' + process.env.ANTHROPIC_API_KEY = 'fixture-key' + expect(getDefaultHaikuModel()).toBe('claude-haiku-4-5-20251001') + }) +}) diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index fed3d9c96d..6314ea8f6f 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -175,8 +175,11 @@ export function getDefaultHaikuModel(): ModelName { return process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL } - // Haiku 4.5 is available on all platforms (first-party, Foundry, Bedrock, Vertex) - return getModelStrings().haiku45 + // Default to Haiku 4.5 for 3P since they may not have 5.5 yet + if (shouldUseThirdPartyAnthropicModelDefaults()) { + return getModelStrings().haiku45 + } + return getModelStrings().haiku55 } /** @@ -315,6 +318,9 @@ export function firstPartyNameToCanonical(name: ModelName): ModelShortName { if (name.includes('claude-sonnet-4')) { return 'claude-sonnet-4' } + if (name.includes('claude-haiku-5-5')) { + return 'claude-haiku-5-5' + } if (name.includes('claude-haiku-4-5')) { return 'claude-haiku-4-5' } @@ -493,6 +499,10 @@ export function getPublicModelDisplayName(model: ModelName): string | null { return 'Sonnet 3.7' case getModelStrings().sonnet35: return 'Sonnet 3.5' + case getModelStrings().haiku55 + '[1m]': + return 'Haiku 5.5 (1M context)' + case getModelStrings().haiku55: + return 'Haiku 5.5' case getModelStrings().haiku45: return 'Haiku 4.5' case getModelStrings().haiku35: @@ -749,6 +759,9 @@ export function getMarketingNameForModel(modelId: string): string | undefined { if (canonical.includes('claude-3-5-sonnet')) { return 'Claude 3.5 Sonnet' } + if (canonical.includes('claude-haiku-5-5')) { + return has1m ? 'Haiku 5.5 (with 1M context)' : 'Haiku 5.5' + } if (canonical.includes('claude-haiku-4-5')) { return 'Haiku 4.5' } diff --git a/src/utils/model/modelContextWindows.ts b/src/utils/model/modelContextWindows.ts index 3118d2ad6b..8e5f5ae4c4 100644 --- a/src/utils/model/modelContextWindows.ts +++ b/src/utils/model/modelContextWindows.ts @@ -12,6 +12,7 @@ const DIRECT_MODEL_CONTEXT_WINDOWS: Record = { 'claude-sonnet-5-5': 1_000_000, 'claude-sonnet-5': 1_000_000, 'claude-sonnet-4-6': 200_000, + 'claude-haiku-5-5': 1_000_000, 'claude-haiku-4-5': 200_000, 'deepseek-v4-pro': 1_000_000, 'deepseek-ai/deepseek-v4-pro': 1_000_000, diff --git a/src/utils/model/modelOptions.ts b/src/utils/model/modelOptions.ts index 48d199ae17..6816e7d181 100644 --- a/src/utils/model/modelOptions.ts +++ b/src/utils/model/modelOptions.ts @@ -13,6 +13,7 @@ import { COST_TIER_2_10, COST_HAIKU_35, COST_HAIKU_45, + COST_HAIKU_55, formatModelPricing, } from '../modelCost.js' import { getSettings_DEPRECATED } from '../settings/settings.js' @@ -279,6 +280,17 @@ function getHaiku45Option(): ModelOption { } } +function getHaiku55Option(): ModelOption { + const is3P = shouldUseThirdPartyAnthropicOptions() + return { + value: 'haiku', + label: 'Haiku', + description: `Haiku 5.5 · Fastest for quick answers${is3P ? '' : ` · ${formatModelPricing(COST_HAIKU_55)}`}`, + descriptionForModel: + 'Haiku 5.5 - fastest for quick answers. Lower cost but less capable than Sonnet 5.5.', + } +} + function getHaiku35Option(): ModelOption { const is3P = shouldUseThirdPartyAnthropicOptions() return { @@ -293,6 +305,7 @@ function getHaiku35Option(): ModelOption { function getHaikuOption(): ModelOption { // Return correct Haiku option based on provider const haikuModel = getDefaultHaikuModel() + if (haikuModel === getModelStrings().haiku55) return getHaiku55Option() return haikuModel === getModelStrings().haiku45 ? getHaiku45Option() : getHaiku35Option() diff --git a/src/utils/modelCost.ts b/src/utils/modelCost.ts index 2f3f25c8d6..f3ee503015 100644 --- a/src/utils/modelCost.ts +++ b/src/utils/modelCost.ts @@ -10,6 +10,7 @@ import { CLAUDE_FABLE_5_CONFIG, CLAUDE_FABLE_5_1_CONFIG, CLAUDE_HAIKU_4_5_CONFIG, + CLAUDE_HAIKU_5_5_CONFIG, CLAUDE_OPUS_4_1_CONFIG, CLAUDE_OPUS_4_5_CONFIG, CLAUDE_OPUS_4_6_CONFIG, @@ -138,6 +139,36 @@ export const COST_HAIKU_45 = { webSearchRequests: 0.01, } as const satisfies ModelCosts +// Haiku 5.5 is priced by prompt length: a request whose prompt (input, cache +// reads and cache writes) is over 100K tokens pays the higher rates throughout. +export const HAIKU_55_LONG_PROMPT_THRESHOLD = 100_000 + +export const COST_HAIKU_55 = { + inputTokens: 0.1, + outputTokens: 0.5, + promptCacheWriteTokens: 0.125, + promptCacheReadTokens: 0.01, + webSearchRequests: 0.01, +} as const satisfies ModelCosts + +export const COST_HAIKU_55_LONG_PROMPT = { + inputTokens: 0.5, + outputTokens: 2.5, + promptCacheWriteTokens: 0.625, + promptCacheReadTokens: 0.05, + webSearchRequests: 0.01, +} as const satisfies ModelCosts + +/** Rates for one request whose prompt spans `promptTokens`. */ +export function getPromptLengthCosts( + costs: ModelCosts, + promptTokens: number, +): ModelCosts { + return costs === COST_HAIKU_55 && promptTokens > HAIKU_55_LONG_PROMPT_THRESHOLD + ? COST_HAIKU_55_LONG_PROMPT + : costs +} + const DEFAULT_UNKNOWN_MODEL_COST = COST_TIER_5_25 /** @@ -162,6 +193,8 @@ export const MODEL_COSTS: Record = { COST_TIER_10_50, [firstPartyNameToCanonical(CLAUDE_3_5_HAIKU_CONFIG.firstParty)]: COST_HAIKU_35, + [firstPartyNameToCanonical(CLAUDE_HAIKU_5_5_CONFIG.firstParty)]: + COST_HAIKU_55, [firstPartyNameToCanonical(CLAUDE_HAIKU_4_5_CONFIG.firstParty)]: COST_HAIKU_45, [firstPartyNameToCanonical(CLAUDE_3_5_V2_SONNET_CONFIG.firstParty)]: @@ -224,7 +257,12 @@ export function getModelCosts(model: string, usage: Usage): ModelCosts { DEFAULT_UNKNOWN_MODEL_COST ) } - return costs + return getPromptLengthCosts( + costs, + usage.input_tokens + + (usage.cache_read_input_tokens ?? 0) + + (usage.cache_creation_input_tokens ?? 0), + ) } function trackUnknownModelCost(model: string, shortName: ModelShortName): void { diff --git a/src/utils/modelCostParity.test.ts b/src/utils/modelCostParity.test.ts index 7ddb119ed4..b29cde7c43 100644 --- a/src/utils/modelCostParity.test.ts +++ b/src/utils/modelCostParity.test.ts @@ -29,6 +29,7 @@ describe('Claude model pricing tables', () => { ['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-5-5', 0.1, 0.5, 0.01], ['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)]) { diff --git a/src/utils/thinking.ts b/src/utils/thinking.ts index 74b112a806..c1cb5c85be 100644 --- a/src/utils/thinking.ts +++ b/src/utils/thinking.ts @@ -132,11 +132,28 @@ export function modelSupportsThinking(model: string): boolean { } export function modelRequiresThinking(model: string): boolean { + const adaptiveOnly = isAdaptiveOnlyModel(getCanonicalName(model)) const required3P = get3PModelCapabilityOverride(model, 'required_thinking') if (required3P !== undefined) { - return required3P + // Generic provider capabilities omit required_thinking. An adaptive-only + // model keeps the requirement while its route still declares adaptive + // thinking; only dropping adaptive_thinking opts out. + return required3P || (adaptiveOnly && modelSupportsAdaptiveThinking(model)) } - return isAdaptiveOnlyModel(getCanonicalName(model)) + return adaptiveOnly +} + +/** + * Claude models whose API rejects manual `budget_tokens` thinking with a 400 + * (platform.claude.com/docs/en/build-with-claude/thinking, per-model table). + * Opting out of adaptive thinking cannot fall back to a budget on these. + */ +export function modelRejectsManualThinking(model: string): boolean { + const canonical = getCanonicalName(model) + return ( + /claude-(opus|sonnet|haiku|fable|mythos)-5(?!\d)/.test(canonical) || + /claude-opus-4-[78](?!\d)/.test(canonical) + ) } /** Fable 5.1 binds replayed thinking to the preceding system, tools and history. */ @@ -179,6 +196,7 @@ export function modelSupportsAdaptiveThinking(model: string): boolean { // Supported by current Claude Code flagship models. if ( canonical.includes('sonnet-5') || + canonical.includes('haiku-5') || canonical.includes('opus-5') || canonical.includes('opus-4-8') || canonical.includes('opus-4-7') || diff --git a/src/utils/usageAccounting.ts b/src/utils/usageAccounting.ts index 529e8d280b..10d78d2e80 100644 --- a/src/utils/usageAccounting.ts +++ b/src/utils/usageAccounting.ts @@ -2,6 +2,7 @@ import { COST_FABLE_51, COST_HAIKU_35, COST_HAIKU_45, + COST_HAIKU_55, COST_OPUS_55, COST_OPUS_55_FAST, COST_TIER_2_10, @@ -10,6 +11,7 @@ import { COST_TIER_10_50, COST_TIER_15_75, COST_TIER_30_150, + getPromptLengthCosts, type ModelCosts, } from './modelCost.js' @@ -65,6 +67,7 @@ const MODEL_TIERS: ReadonlyArray = ['claude-sonnet-4', COST_TIER_3_15], ['claude-3-7-sonnet', COST_TIER_3_15], ['claude-3-5-sonnet', COST_TIER_3_15], + ['claude-haiku-5-5', COST_HAIKU_55], ['claude-haiku-4-5', COST_HAIKU_45], ['claude-3-5-haiku', COST_HAIKU_35], ['claude-3-haiku', COST_HAIKU_35], @@ -217,8 +220,12 @@ export function estimateCostUSD( tokens: PricedTokens, speed?: string, ): number | null { - const costs = resolveModelCosts(model, speed) - if (!costs) return null + const tierCosts = resolveModelCosts(model, speed) + if (!tierCosts) return null + const costs = getPromptLengthCosts( + tierCosts, + tokens.inputTokens + tokens.cacheReadInputTokens + tokens.cacheCreationInputTokens, + ) return ( (tokens.inputTokens / 1_000_000) * costs.inputTokens + (tokens.outputTokens / 1_000_000) * costs.outputTokens + From 072c014b2ab38a3850d2d2ab467edeecd7c44525 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: Thu, 15 Oct 2026 17:08:39 +0800 Subject: [PATCH 2/2] test(desktop): cover Haiku 5.5 in the official model catalog --- desktop/src/constants/modelCatalog.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/desktop/src/constants/modelCatalog.test.ts b/desktop/src/constants/modelCatalog.test.ts index 4321e8a289..88d4dd4172 100644 --- a/desktop/src/constants/modelCatalog.test.ts +++ b/desktop/src/constants/modelCatalog.test.ts @@ -27,9 +27,20 @@ describe('Claude official model catalog', () => { expect(ids.indexOf('claude-sonnet-5-5')).toBeLessThan(ids.indexOf('claude-sonnet-5')) }) + it('offers Haiku 5.5 with its default effort and context ahead of Haiku 4.5', () => { + expect(OFFICIAL_MODELS.find(model => model.id === 'claude-haiku-5-5')).toMatchObject({ + name: 'Haiku 5.5', + context: '1m', + defaultReasoningEffort: 'medium', + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], + }) + const ids = OFFICIAL_MODELS.map(model => model.id) + expect(ids.indexOf('claude-haiku-5-5')).toBeLessThan(ids.indexOf('claude-haiku-4-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', + 'claude-opus-5', 'claude-opus-4-8', 'claude-fable-5-1', 'claude-sonnet-5', 'claude-haiku-4-5', ])) }) })