From 23bb09d240f673241c4fffaa4431cc870f594616 Mon Sep 17 00:00:00 2001 From: unraid Date: Wed, 22 Apr 2026 22:38:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20model/provider=20?= =?UTF-8?q?=E5=B1=82=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../__tests__/openaiConvertMessages.test.ts | 336 ++++++++++-------- .../__tests__/openaiConvertTools.test.ts | 47 +-- .../src/shared/openaiConvertMessages.ts | 52 +-- .../src/shared/openaiConvertTools.ts | 43 ++- .../src/shared/openaiStreamAdapter.ts | 8 +- src/components/ModelPicker.tsx | 291 +++++++-------- .../__tests__/getDefaultOpusModel.test.ts | 148 ++++++++ src/utils/model/configs.ts | 11 + src/utils/model/model.ts | 74 ++-- src/utils/model/modelCapabilities.ts | 5 +- src/utils/model/modelOptions.ts | 142 ++++---- src/utils/model/modelSupportOverrides.ts | 1 + src/utils/model/validateModel.ts | 3 + 13 files changed, 689 insertions(+), 472 deletions(-) create mode 100644 src/utils/model/__tests__/getDefaultOpusModel.test.ts diff --git a/packages/@ant/model-provider/src/shared/__tests__/openaiConvertMessages.test.ts b/packages/@ant/model-provider/src/shared/__tests__/openaiConvertMessages.test.ts index 6de81d8a4..27c792a5d 100644 --- a/packages/@ant/model-provider/src/shared/__tests__/openaiConvertMessages.test.ts +++ b/packages/@ant/model-provider/src/shared/__tests__/openaiConvertMessages.test.ts @@ -21,26 +21,22 @@ function makeAssistantMsg(content: string | any[]): AssistantMessage { describe('anthropicMessagesToOpenAI', () => { test('converts system prompt to system message', () => { - const result = anthropicMessagesToOpenAI( - [makeUserMsg('hello')], - ['You are helpful.'] as any, - ) + const result = anthropicMessagesToOpenAI([makeUserMsg('hello')], [ + 'You are helpful.', + ] as any) expect(result[0]).toEqual({ role: 'system', content: 'You are helpful.' }) }) test('joins multiple system prompt strings', () => { - const result = anthropicMessagesToOpenAI( - [makeUserMsg('hi')], - ['Part 1', 'Part 2'] as any, - ) + const result = anthropicMessagesToOpenAI([makeUserMsg('hi')], [ + 'Part 1', + 'Part 2', + ] as any) expect(result[0]).toEqual({ role: 'system', content: 'Part 1\n\nPart 2' }) }) test('skips empty system prompt', () => { - const result = anthropicMessagesToOpenAI( - [makeUserMsg('hi')], - [] as any, - ) + const result = anthropicMessagesToOpenAI([makeUserMsg('hi')], [] as any) expect(result[0].role).toBe('user') }) @@ -54,10 +50,12 @@ describe('anthropicMessagesToOpenAI', () => { test('converts user message with content array', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg([ - { type: 'text', text: 'line 1' }, - { type: 'text', text: 'line 2' }, - ])], + [ + makeUserMsg([ + { type: 'text', text: 'line 1' }, + { type: 'text', text: 'line 2' }, + ]), + ], [] as any, ) expect(result).toEqual([{ role: 'user', content: 'line 1\nline 2' }]) @@ -73,52 +71,64 @@ describe('anthropicMessagesToOpenAI', () => { test('converts assistant message with tool_use', () => { const result = anthropicMessagesToOpenAI( - [makeAssistantMsg([ - { type: 'text', text: 'Let me help.' }, - { - type: 'tool_use' as const, - id: 'toolu_123', - name: 'bash', - input: { command: 'ls' }, - }, - ])], + [ + makeAssistantMsg([ + { type: 'text', text: 'Let me help.' }, + { + type: 'tool_use' as const, + id: 'toolu_123', + name: 'bash', + input: { command: 'ls' }, + }, + ]), + ], [] as any, ) - expect(result).toEqual([{ - role: 'assistant', - content: 'Let me help.', - tool_calls: [{ - id: 'toolu_123', - type: 'function', - function: { name: 'bash', arguments: '{"command":"ls"}' }, - }], - }]) + expect(result).toEqual([ + { + role: 'assistant', + content: 'Let me help.', + tool_calls: [ + { + id: 'toolu_123', + type: 'function', + function: { name: 'bash', arguments: '{"command":"ls"}' }, + }, + ], + }, + ]) }) test('converts tool_result to tool message', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg([ - { - type: 'tool_result' as const, - tool_use_id: 'toolu_123', - content: 'file1.txt\nfile2.txt', - }, - ])], + [ + makeUserMsg([ + { + type: 'tool_result' as const, + tool_use_id: 'toolu_123', + content: 'file1.txt\nfile2.txt', + }, + ]), + ], [] as any, ) - expect(result).toEqual([{ - role: 'tool', - tool_call_id: 'toolu_123', - content: 'file1.txt\nfile2.txt', - }]) + expect(result).toEqual([ + { + role: 'tool', + tool_call_id: 'toolu_123', + content: 'file1.txt\nfile2.txt', + }, + ]) }) test('strips thinking blocks', () => { const result = anthropicMessagesToOpenAI( - [makeAssistantMsg([ - { type: 'thinking' as const, thinking: 'internal thoughts...' }, - { type: 'text', text: 'visible response' }, - ])], + [ + makeAssistantMsg([ + { type: 'thinking' as const, thinking: 'internal thoughts...' }, + { type: 'text', text: 'visible response' }, + ]), + ], [] as any, ) expect(result).toEqual([{ role: 'assistant', content: 'visible response' }]) @@ -157,91 +167,105 @@ describe('anthropicMessagesToOpenAI', () => { test('converts base64 image to image_url', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg([ - { type: 'text', text: 'what is this?' }, - { - type: 'image' as const, - source: { - type: 'base64', - media_type: 'image/png', - data: 'iVBORw0KGgo=', + [ + makeUserMsg([ + { type: 'text', text: 'what is this?' }, + { + type: 'image' as const, + source: { + type: 'base64', + media_type: 'image/png', + data: 'iVBORw0KGgo=', + }, }, - }, - ])], + ]), + ], [] as any, ) - expect(result).toEqual([{ - role: 'user', - content: [ - { type: 'text', text: 'what is this?' }, - { - type: 'image_url', - image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' }, - }, - ], - }]) + expect(result).toEqual([ + { + role: 'user', + content: [ + { type: 'text', text: 'what is this?' }, + { + type: 'image_url', + image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' }, + }, + ], + }, + ]) }) test('converts url image to image_url', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg([ - { - type: 'image' as const, - source: { - type: 'url', - url: 'https://example.com/img.png', + [ + makeUserMsg([ + { + type: 'image' as const, + source: { + type: 'url', + url: 'https://example.com/img.png', + }, }, - }, - ])], + ]), + ], [] as any, ) - expect(result).toEqual([{ - role: 'user', - content: [ - { - type: 'image_url', - image_url: { url: 'https://example.com/img.png' }, - }, - ], - }]) + expect(result).toEqual([ + { + role: 'user', + content: [ + { + type: 'image_url', + image_url: { url: 'https://example.com/img.png' }, + }, + ], + }, + ]) }) test('converts image-only message without text', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg([ - { - type: 'image' as const, - source: { - type: 'base64', - media_type: 'image/jpeg', - data: '/9j/4AAQ', + [ + makeUserMsg([ + { + type: 'image' as const, + source: { + type: 'base64', + media_type: 'image/jpeg', + data: '/9j/4AAQ', + }, }, - }, - ])], + ]), + ], [] as any, ) - expect(result).toEqual([{ - role: 'user', - content: [ - { - type: 'image_url', - image_url: { url: 'data:image/jpeg;base64,/9j/4AAQ' }, - }, - ], - }]) + expect(result).toEqual([ + { + role: 'user', + content: [ + { + type: 'image_url', + image_url: { url: 'data:image/jpeg;base64,/9j/4AAQ' }, + }, + ], + }, + ]) }) test('defaults to image/png when media_type is missing', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg([ - { - type: 'image' as const, - source: { - type: 'base64', - data: 'ABC123', + [ + makeUserMsg([ + { + type: 'image' as const, + source: { + type: 'base64', + data: 'ABC123', + }, }, - }, - ])], + ]), + ], [] as any, ) expect((result[0].content as any[])[0].image_url.url).toBe( @@ -253,10 +277,16 @@ describe('anthropicMessagesToOpenAI', () => { describe('DeepSeek thinking mode (enableThinking)', () => { test('preserves thinking block as reasoning_content when enabled', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg('question'), makeAssistantMsg([ - { type: 'thinking' as const, thinking: 'Let me reason about this...' }, - { type: 'text', text: 'The answer is 42.' }, - ])], + [ + makeUserMsg('question'), + makeAssistantMsg([ + { + type: 'thinking' as const, + thinking: 'Let me reason about this...', + }, + { type: 'text', text: 'The answer is 42.' }, + ]), + ], [] as any, { enableThinking: true }, ) @@ -271,10 +301,12 @@ describe('DeepSeek thinking mode (enableThinking)', () => { test('drops thinking block when enableThinking is false (default)', () => { const result = anthropicMessagesToOpenAI( - [makeAssistantMsg([ - { type: 'thinking' as const, thinking: 'internal thoughts...' }, - { type: 'text', text: 'visible response' }, - ])], + [ + makeAssistantMsg([ + { type: 'thinking' as const, thinking: 'internal thoughts...' }, + { type: 'text', text: 'visible response' }, + ]), + ], [] as any, ) const assistant = result[0] as any @@ -287,7 +319,10 @@ describe('DeepSeek thinking mode (enableThinking)', () => { [ makeUserMsg('what is the weather?'), makeAssistantMsg([ - { type: 'thinking' as const, thinking: 'I need to call the weather tool.' }, + { + type: 'thinking' as const, + thinking: 'I need to call the weather tool.', + }, { type: 'text', text: '' }, { type: 'tool_use' as const, @@ -399,18 +434,27 @@ describe('DeepSeek thinking mode (enableThinking)', () => { const assistants = result.filter(m => m.role === 'assistant') expect(assistants.length).toBe(3) // All iterations within the same turn preserve reasoning - expect((assistants[0] as any).reasoning_content).toBe('I need the date first.') - expect((assistants[1] as any).reasoning_content).toBe('Now I can get the weather.') - expect((assistants[2] as any).reasoning_content).toBe('I have the info now.') + expect((assistants[0] as any).reasoning_content).toBe( + 'I need the date first.', + ) + expect((assistants[1] as any).reasoning_content).toBe( + 'Now I can get the weather.', + ) + expect((assistants[2] as any).reasoning_content).toBe( + 'I have the info now.', + ) }) test('handles multiple thinking blocks in single assistant message', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg('question'), makeAssistantMsg([ - { type: 'thinking' as const, thinking: 'First thought.' }, - { type: 'thinking' as const, thinking: 'Second thought.' }, - { type: 'text', text: 'Final answer.' }, - ])], + [ + makeUserMsg('question'), + makeAssistantMsg([ + { type: 'thinking' as const, thinking: 'First thought.' }, + { type: 'thinking' as const, thinking: 'Second thought.' }, + { type: 'text', text: 'Final answer.' }, + ]), + ], [] as any, { enableThinking: true }, ) @@ -420,10 +464,13 @@ describe('DeepSeek thinking mode (enableThinking)', () => { test('skips empty thinking blocks', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg('question'), makeAssistantMsg([ - { type: 'thinking' as const, thinking: '' }, - { type: 'text', text: 'Answer.' }, - ])], + [ + makeUserMsg('question'), + makeAssistantMsg([ + { type: 'thinking' as const, thinking: '' }, + { type: 'text', text: 'Answer.' }, + ]), + ], [] as any, { enableThinking: true }, ) @@ -481,15 +528,18 @@ describe('DeepSeek thinking mode (enableThinking)', () => { test('sets content to null when only thinking and tool_calls present', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg('question'), makeAssistantMsg([ - { type: 'thinking' as const, thinking: 'Reasoning only.' }, - { - type: 'tool_use' as const, - id: 'toolu_001', - name: 'bash', - input: { command: 'ls' }, - }, - ])], + [ + makeUserMsg('question'), + makeAssistantMsg([ + { type: 'thinking' as const, thinking: 'Reasoning only.' }, + { + type: 'tool_use' as const, + id: 'toolu_001', + name: 'bash', + input: { command: 'ls' }, + }, + ]), + ], [] as any, { enableThinking: true }, ) diff --git a/packages/@ant/model-provider/src/shared/__tests__/openaiConvertTools.test.ts b/packages/@ant/model-provider/src/shared/__tests__/openaiConvertTools.test.ts index 5bb98fdd8..dbe6455e1 100644 --- a/packages/@ant/model-provider/src/shared/__tests__/openaiConvertTools.test.ts +++ b/packages/@ant/model-provider/src/shared/__tests__/openaiConvertTools.test.ts @@ -18,25 +18,29 @@ describe('anthropicToolsToOpenAI', () => { const result = anthropicToolsToOpenAI(tools as any) - expect(result).toEqual([{ - type: 'function', - function: { - name: 'bash', - description: 'Run a bash command', - parameters: { - type: 'object', - properties: { command: { type: 'string' } }, - required: ['command'], + expect(result).toEqual([ + { + type: 'function', + function: { + name: 'bash', + description: 'Run a bash command', + parameters: { + type: 'object', + properties: { command: { type: 'string' } }, + required: ['command'], + }, }, }, - }]) + ]) }) test('uses empty schema when input_schema missing', () => { const tools = [{ type: 'custom', name: 'noop', description: 'no-op' }] const result = anthropicToolsToOpenAI(tools as any) - expect((result[0] as { function: { parameters: unknown } }).function.parameters).toEqual({ type: 'object', properties: {} }) + expect( + (result[0] as { function: { parameters: unknown } }).function.parameters, + ).toEqual({ type: 'object', properties: {} }) }) test('strips Anthropic-specific fields', () => { @@ -76,7 +80,8 @@ describe('anthropicToolsToOpenAI', () => { }, ] const result = anthropicToolsToOpenAI(tools as any) - const props = (result[0] as { function: { parameters: any } }).function.parameters as any + const props = (result[0] as { function: { parameters: any } }).function + .parameters as any expect(props.properties.mode).toEqual({ enum: ['read'] }) expect(props.properties.mode.const).toBeUndefined() expect(props.properties.name).toEqual({ type: 'string' }) @@ -110,8 +115,11 @@ describe('anthropicToolsToOpenAI', () => { }, ] const result = anthropicToolsToOpenAI(tools as any) - const params = (result[0] as { function: { parameters: any } }).function.parameters as any - expect(params.properties.outer.properties.inner).toEqual({ enum: ['fixed'] }) + const params = (result[0] as { function: { parameters: any } }).function + .parameters as any + expect(params.properties.outer.properties.inner).toEqual({ + enum: ['fixed'], + }) expect(params.definitions.MyType.properties.field).toEqual({ enum: [42] }) }) @@ -125,18 +133,17 @@ describe('anthropicToolsToOpenAI', () => { type: 'object', properties: { val: { - anyOf: [ - { const: 'a' }, - { const: 'b' }, - { type: 'string' }, - ], + anyOf: [{ const: 'a' }, { const: 'b' }, { type: 'string' }], }, }, }, }, ] const result = anthropicToolsToOpenAI(tools as any) - const anyOf = ((result[0] as { function: { parameters: any } }).function.parameters as any).properties.val.anyOf + const anyOf = ( + (result[0] as { function: { parameters: any } }).function + .parameters as any + ).properties.val.anyOf expect(anyOf[0]).toEqual({ enum: ['a'] }) expect(anyOf[1]).toEqual({ enum: ['b'] }) expect(anyOf[2]).toEqual({ type: 'string' }) diff --git a/packages/@ant/model-provider/src/shared/openaiConvertMessages.ts b/packages/@ant/model-provider/src/shared/openaiConvertMessages.ts index 4d2553653..2d7cf62ba 100644 --- a/packages/@ant/model-provider/src/shared/openaiConvertMessages.ts +++ b/packages/@ant/model-provider/src/shared/openaiConvertMessages.ts @@ -62,16 +62,18 @@ export function anthropicMessagesToOpenAI( // A user message starts a new turn if it contains any non-tool_result content // (text, image, or other media). Tool results alone do NOT start a new turn // because they are continuations of the previous assistant tool call. - const startsNewUserTurn = typeof content === 'string' - ? content.length > 0 - : Array.isArray(content) && content.some( - (b: any) => - typeof b === 'string' || - (b && - typeof b === 'object' && - 'type' in b && - b.type !== 'tool_result'), - ) + const startsNewUserTurn = + typeof content === 'string' + ? content.length > 0 + : Array.isArray(content) && + content.some( + (b: any) => + typeof b === 'string' || + (b && + typeof b === 'object' && + 'type' in b && + b.type !== 'tool_result'), + ) if (startsNewUserTurn) { turnBoundaries.add(i) } @@ -88,7 +90,8 @@ export function anthropicMessagesToOpenAI( case 'assistant': // Preserve reasoning_content unless we're before a turn boundary // (i.e., from a previous user Q&A round) - const preserveReasoning = enableThinking && !isBeforeAnyTurnBoundary(i, turnBoundaries) + const preserveReasoning = + enableThinking && !isBeforeAnyTurnBoundary(i, turnBoundaries) result.push(...convertInternalAssistantMessage(msg, preserveReasoning)) break default: @@ -101,9 +104,7 @@ export function anthropicMessagesToOpenAI( function systemPromptToText(systemPrompt: SystemPrompt): string { if (!systemPrompt || systemPrompt.length === 0) return '' - return systemPrompt - .filter(Boolean) - .join('\n\n') + return systemPrompt.filter(Boolean).join('\n\n') } /** @@ -131,7 +132,8 @@ function convertInternalUserMessage( } else if (Array.isArray(content)) { const textParts: string[] = [] const toolResults: BetaToolResultBlockParam[] = [] - const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> = [] + const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> = + [] for (const block of content) { if (typeof block === 'string') { @@ -141,7 +143,9 @@ function convertInternalUserMessage( } else if (block.type === 'tool_result') { toolResults.push(block as BetaToolResultBlockParam) } else if (block.type === 'image') { - const imagePart = convertImageBlockToOpenAI(block as unknown as Record) + const imagePart = convertImageBlockToOpenAI( + block as unknown as Record, + ) if (imagePart) { imageParts.push(imagePart) } @@ -158,7 +162,10 @@ function convertInternalUserMessage( // 如果有图片,构建多模态 content 数组 if (imageParts.length > 0) { - const multiContent: Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }> = [] + const multiContent: Array< + | { type: 'text'; text: string } + | { type: 'image_url'; image_url: { url: string } } + > = [] if (textParts.length > 0) { multiContent.push({ type: 'text', text: textParts.join('\n') }) } @@ -229,7 +236,9 @@ function convertInternalAssistantMessage( } const textParts: string[] = [] - const toolCalls: NonNullable = [] + const toolCalls: NonNullable< + ChatCompletionAssistantMessageParam['tool_calls'] + > = [] const reasoningParts: string[] = [] for (const block of content) { @@ -250,7 +259,8 @@ function convertInternalAssistantMessage( }) } else if (block.type === 'thinking' && preserveReasoning) { // DeepSeek thinking mode: preserve reasoning_content for tool call iterations - const thinkingText = (block as unknown as Record).thinking + const thinkingText = (block as unknown as Record) + .thinking if (typeof thinkingText === 'string' && thinkingText) { reasoningParts.push(thinkingText) } @@ -262,7 +272,9 @@ function convertInternalAssistantMessage( role: 'assistant', content: textParts.length > 0 ? textParts.join('\n') : null, ...(toolCalls.length > 0 && { tool_calls: toolCalls }), - ...(reasoningParts.length > 0 && { reasoning_content: reasoningParts.join('\n') }), + ...(reasoningParts.length > 0 && { + reasoning_content: reasoningParts.join('\n'), + }), } return [result] diff --git a/packages/@ant/model-provider/src/shared/openaiConvertTools.ts b/packages/@ant/model-provider/src/shared/openaiConvertTools.ts index bace8208b..fbf53009d 100644 --- a/packages/@ant/model-provider/src/shared/openaiConvertTools.ts +++ b/packages/@ant/model-provider/src/shared/openaiConvertTools.ts @@ -16,21 +16,27 @@ export function anthropicToolsToOpenAI( .filter(tool => { // Only convert standard tools (skip server tools like computer_use, etc.) const toolType = (tool as unknown as { type?: string }).type - return tool.type === 'custom' || !('type' in tool) || toolType !== 'server' + return ( + tool.type === 'custom' || !('type' in tool) || toolType !== 'server' + ) }) .map(tool => { // Handle the various tool shapes from Anthropic SDK const anyTool = tool as unknown as Record const name = (anyTool.name as string) || '' const description = (anyTool.description as string) || '' - const inputSchema = anyTool.input_schema as Record | undefined + const inputSchema = anyTool.input_schema as + | Record + | undefined return { type: 'function' as const, function: { name, description, - parameters: sanitizeJsonSchema(inputSchema || { type: 'object', properties: {} }), + parameters: sanitizeJsonSchema( + inputSchema || { type: 'object', properties: {} }, + ), }, } satisfies ChatCompletionTool }) @@ -43,7 +49,9 @@ export function anthropicToolsToOpenAI( * support the `const` keyword in JSON Schema. Convert it to `enum` with a * single-element array, which is semantically equivalent. */ -function sanitizeJsonSchema(schema: Record): Record { +function sanitizeJsonSchema( + schema: Record, +): Record { if (!schema || typeof schema !== 'object') return schema const result = { ...schema } @@ -55,20 +63,37 @@ function sanitizeJsonSchema(schema: Record): Record = {} for (const [k, v] of Object.entries(nested as Record)) { - sanitized[k] = v && typeof v === 'object' ? sanitizeJsonSchema(v as Record) : v + sanitized[k] = + v && typeof v === 'object' + ? sanitizeJsonSchema(v as Record) + : v } result[key] = sanitized } } // Recursively process single-schema keys - const singleKeys = ['items', 'additionalProperties', 'not', 'if', 'then', 'else', 'contains', 'propertyNames'] as const + const singleKeys = [ + 'items', + 'additionalProperties', + 'not', + 'if', + 'then', + 'else', + 'contains', + 'propertyNames', + ] as const for (const key of singleKeys) { const nested = result[key] if (nested && typeof nested === 'object' && !Array.isArray(nested)) { @@ -82,7 +107,9 @@ function sanitizeJsonSchema(schema: Record): Record - item && typeof item === 'object' ? sanitizeJsonSchema(item as Record) : item + item && typeof item === 'object' + ? sanitizeJsonSchema(item as Record) + : item, ) } } diff --git a/packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts b/packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts index 9776ca319..1e7df4ea9 100644 --- a/packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts +++ b/packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts @@ -42,7 +42,10 @@ export async function* adaptOpenAIStreamToAnthropic( let currentContentIndex = -1 // Track tool_use blocks: tool_calls index → { contentIndex, id, name, arguments } - const toolBlocks = new Map() + const toolBlocks = new Map< + number, + { contentIndex: number; id: string; name: string; arguments: string } + >() // Track thinking block state let thinkingBlockOpen = false @@ -197,7 +200,8 @@ export async function* adaptOpenAIStreamToAnthropic( // Start new tool_use block currentContentIndex++ - const toolId = tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}` + const toolId = + tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}` const toolName = tc.function?.name || '' toolBlocks.set(tcIndex, { diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx index b9f5155bc..2443f6f57 100644 --- a/src/components/ModelPicker.tsx +++ b/src/components/ModelPicker.tsx @@ -1,21 +1,21 @@ -import capitalize from 'lodash-es/capitalize.js' -import * as React from 'react' -import { useCallback, useMemo, useState } from 'react' -import { has1mContext } from '../utils/context.js' -import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js' +import capitalize from 'lodash-es/capitalize.js'; +import * as React from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { has1mContext } from '../utils/context.js'; +import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' +} from 'src/services/analytics/index.js'; import { FAST_MODE_MODEL_DISPLAY, isFastModeAvailable, isFastModeCooldown, isFastModeEnabled, -} from 'src/utils/fastMode.js' -import { Box, Text } from '@anthropic/ink' -import { useKeybindings } from '../keybindings/useKeybinding.js' -import { useAppState, useSetAppState } from '../state/AppState.js' +} from 'src/utils/fastMode.js'; +import { Box, Text } from '@anthropic/ink'; +import { useKeybindings } from '../keybindings/useKeybinding.js'; +import { useAppState, useSetAppState } from '../state/AppState.js'; import { convertEffortValueToLevel, type EffortLevel, @@ -24,42 +24,39 @@ import { modelSupportsMaxEffort, resolvePickerEffortPersistence, toPersistableEffort, -} from '../utils/effort.js' +} from '../utils/effort.js'; import { getDefaultMainLoopModel, type ModelSetting, modelDisplayString, parseUserSpecifiedModel, -} from '../utils/model/model.js' -import { getModelOptions } from '../utils/model/modelOptions.js' -import { - getSettingsForSource, - updateSettingsForSource, -} from '../utils/settings/settings.js' -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' -import { Select } from './CustomSelect/index.js' -import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink' -import { effortLevelToSymbol } from './EffortIndicator.js' +} from '../utils/model/model.js'; +import { getModelOptions } from '../utils/model/modelOptions.js'; +import { getSettingsForSource, updateSettingsForSource } from '../utils/settings/settings.js'; +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; +import { Select } from './CustomSelect/index.js'; +import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink'; +import { effortLevelToSymbol } from './EffortIndicator.js'; export type Props = { - initial: string | null - sessionModel?: ModelSetting - onSelect: (model: string | null, effort: EffortLevel | undefined) => void - onCancel?: () => void - isStandaloneCommand?: boolean - showFastModeNotice?: boolean + initial: string | null; + sessionModel?: ModelSetting; + onSelect: (model: string | null, effort: EffortLevel | undefined) => void; + onCancel?: () => void; + isStandaloneCommand?: boolean; + showFastModeNotice?: boolean; /** Overrides the dim header line below "Select model". */ - headerText?: string + headerText?: string; /** * When true, skip writing effortLevel to userSettings on selection. * Used by the assistant installer wizard where the model choice is * project-scoped (written to the assistant's .claude/settings.json via * install.ts) and should not leak to the user's global ~/.claude/settings. */ - skipSettingsWrite?: boolean -} + skipSettingsWrite?: boolean; +}; -const NO_PREFERENCE = '__NO_PREFERENCE__' +const NO_PREFERENCE = '__NO_PREFERENCE__'; export function ModelPicker({ initial, @@ -71,49 +68,44 @@ export function ModelPicker({ headerText, skipSettingsWrite, }: Props): React.ReactNode { - const setAppState = useSetAppState() - const exitState = useExitOnCtrlCDWithKeybindings() - const maxVisible = 10 + const setAppState = useSetAppState(); + const exitState = useExitOnCtrlCDWithKeybindings(); + const maxVisible = 10; - const initialValue = initial === null ? NO_PREFERENCE : initial - const [focusedValue, setFocusedValue] = useState( - initialValue, - ) + const initialValue = initial === null ? NO_PREFERENCE : initial; + const [focusedValue, setFocusedValue] = useState(initialValue); - const isFastMode = useAppState(s => - isFastModeEnabled() ? s.fastMode : false, - ) + const isFastMode = useAppState(s => (isFastModeEnabled() ? s.fastMode : false)); const [marked1MValues, setMarked1MValues] = useState>( - () => new Set(has1mContext(initialValue) ? [initialValue.replace(/\[1m\]/i, '')] : []) - ) + () => new Set(has1mContext(initialValue) ? [initialValue.replace(/\[1m\]/i, '')] : []), + ); const handleToggle1M = useCallback(() => { - if (!focusedValue || focusedValue === NO_PREFERENCE) return + if (!focusedValue || focusedValue === NO_PREFERENCE) return; + // Key on the base value so lookups in handleSelect / is1MMarked match the + // initializer — predefined 1M options arrive with a `[1m]` suffix in + // `focusedValue`, which would diverge from the base-value key set. + const baseKey = focusedValue.replace(/\[1m\]/i, ''); setMarked1MValues(prev => { - const next = new Set(prev) - if (next.has(focusedValue)) { - next.delete(focusedValue) + const next = new Set(prev); + if (next.has(baseKey)) { + next.delete(baseKey); } else { - next.add(focusedValue) + next.add(baseKey); } - return next - }) - }, [focusedValue]) + return next; + }); + }, [focusedValue]); - const [hasToggledEffort, setHasToggledEffort] = useState(false) - const effortValue = useAppState(s => s.effortValue) + const [hasToggledEffort, setHasToggledEffort] = useState(false); + const effortValue = useAppState(s => s.effortValue); const [effort, setEffort] = useState( - effortValue !== undefined - ? convertEffortValueToLevel(effortValue) - : undefined, - ) + effortValue !== undefined ? convertEffortValueToLevel(effortValue) : undefined, + ); // Memoize all derived values to prevent re-renders - const modelOptions = useMemo( - () => getModelOptions(isFastMode ?? false), - [isFastMode], - ) + const modelOptions = useMemo(() => getModelOptions(isFastMode ?? false), [isFastMode]); // Ensure the initial value is in the options list // This handles edge cases where the user's current model (e.g., 'haiku' for 3P users) @@ -127,10 +119,10 @@ export function ModelPicker({ label: modelDisplayString(initial), description: 'Current model', }, - ] + ]; } - return modelOptions - }, [modelOptions, initial]) + return modelOptions; + }, [modelOptions, initial]); const selectOptions = useMemo( () => @@ -139,59 +131,46 @@ export function ModelPicker({ value: opt.value === null ? NO_PREFERENCE : opt.value, })), [optionsWithInitial], - ) + ); const initialFocusValue = useMemo( - () => - selectOptions.some(_ => _.value === initialValue) - ? initialValue - : (selectOptions[0]?.value ?? undefined), + () => (selectOptions.some(_ => _.value === initialValue) ? initialValue : (selectOptions[0]?.value ?? undefined)), [selectOptions, initialValue], - ) - const visibleCount = Math.min(maxVisible, selectOptions.length) - const hiddenCount = Math.max(0, selectOptions.length - visibleCount) + ); + const visibleCount = Math.min(maxVisible, selectOptions.length); + const hiddenCount = Math.max(0, selectOptions.length - visibleCount); - const focusedModelName = selectOptions.find( - opt => opt.value === focusedValue, - )?.label - const focusedModel = resolveOptionModel(focusedValue) - const is1MMarked = focusedValue !== undefined && focusedValue !== NO_PREFERENCE && marked1MValues.has(focusedValue) - const focusedSupportsEffort = focusedModel - ? modelSupportsEffort(focusedModel) - : false - const focusedSupportsMax = focusedModel - ? modelSupportsMaxEffort(focusedModel) - : false - const focusedDefaultEffort = getDefaultEffortLevelForOption(focusedValue) + const focusedModelName = selectOptions.find(opt => opt.value === focusedValue)?.label; + const focusedModel = resolveOptionModel(focusedValue); + const is1MMarked = + focusedValue !== undefined && + focusedValue !== NO_PREFERENCE && + marked1MValues.has(focusedValue.replace(/\[1m\]/i, '')); + const focusedSupportsEffort = focusedModel ? modelSupportsEffort(focusedModel) : false; + const focusedSupportsMax = focusedModel ? modelSupportsMaxEffort(focusedModel) : false; + const focusedDefaultEffort = getDefaultEffortLevelForOption(focusedValue); // Clamp display when 'max' is selected but the focused model doesn't support it. // resolveAppliedEffort() does the same downgrade at API-send time. - const displayEffort = - effort === 'max' && !focusedSupportsMax ? 'high' : effort + const displayEffort = effort === 'max' && !focusedSupportsMax ? 'high' : effort; const handleFocus = useCallback( (value: string) => { - setFocusedValue(value) + setFocusedValue(value); if (!hasToggledEffort && effortValue === undefined) { - setEffort(getDefaultEffortLevelForOption(value)) + setEffort(getDefaultEffortLevelForOption(value)); } }, [hasToggledEffort, effortValue], - ) + ); // Effort level cycling keybindings const handleCycleEffort = useCallback( (direction: 'left' | 'right') => { - if (!focusedSupportsEffort) return - setEffort(prev => - cycleEffortLevel( - prev ?? focusedDefaultEffort, - direction, - focusedSupportsMax, - ), - ) - setHasToggledEffort(true) + if (!focusedSupportsEffort) return; + setEffort(prev => cycleEffortLevel(prev ?? focusedDefaultEffort, direction, focusedSupportsMax)); + setHasToggledEffort(true); }, [focusedSupportsEffort, focusedSupportsMax, focusedDefaultEffort], - ) + ); useKeybindings( { @@ -200,13 +179,12 @@ export function ModelPicker({ 'modelPicker:toggle1M': () => handleToggle1M(), }, { context: 'ModelPicker' }, - ) + ); function handleSelect(value: string): void { logEvent('tengu_model_command_menu_effort', { - effort: - effort as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + effort: effort as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); if (!skipSettingsWrite) { // Prior comes from userSettings on disk — NOT merged settings (which // includes project/policy layers that must not leak into the user's @@ -218,28 +196,28 @@ export function ModelPicker({ getDefaultEffortLevelForOption(value), getSettingsForSource('userSettings')?.effortLevel, hasToggledEffort, - ) - const persistable = toPersistableEffort(effortLevel) + ); + const persistable = toPersistableEffort(effortLevel); if (persistable !== undefined) { - updateSettingsForSource('userSettings', { effortLevel: persistable }) + updateSettingsForSource('userSettings', { effortLevel: persistable }); } - setAppState(prev => ({ ...prev, effortValue: effortLevel })) + setAppState(prev => ({ ...prev, effortValue: effortLevel })); } - const selectedModel = resolveOptionModel(value) - const selectedEffort = - hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel) - ? effort - : undefined + const selectedModel = resolveOptionModel(value); + const selectedEffort = hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel) ? effort : undefined; if (value === NO_PREFERENCE) { - onSelect(null, selectedEffort) - return + onSelect(null, selectedEffort); + return; } - // Apply or strip [1m] suffix based on user toggle - const wants1M = marked1MValues.has(value) - const baseValue = value.replace(/\[1m\]/i, '') - const finalValue = wants1M ? `${baseValue}[1m]` : baseValue - onSelect(finalValue, selectedEffort) + // Apply or strip [1m] suffix based on user toggle. marked1MValues is keyed + // on the base value (see initializer + handleToggle1M), so look up with the + // base form — not `value`, which may carry a `[1m]` suffix from predefined + // 1M options and would never match. + const baseValue = value.replace(/\[1m\]/i, ''); + const wants1M = marked1MValues.has(baseValue); + const finalValue = wants1M ? `${baseValue}[1m]` : baseValue; + onSelect(finalValue, selectedEffort); } const content = ( @@ -255,8 +233,8 @@ export function ModelPicker({ {sessionModel && ( - Currently using {modelDisplayString(sessionModel)} for this - session (set by plan mode). Selecting a model will undo this. + Currently using {modelDisplayString(sessionModel)} for this session (set by plan mode). Selecting a model + will undo this. )} @@ -283,10 +261,8 @@ export function ModelPicker({ {focusedSupportsEffort ? ( - {' '} - {capitalize(displayEffort)} effort - {displayEffort === focusedDefaultEffort ? ` (default)` : ``}{' '} - ← → to adjust + {capitalize(displayEffort)} effort + {displayEffort === focusedDefaultEffort ? ` (default)` : ``} ← → to adjust ) : ( @@ -311,16 +287,14 @@ export function ModelPicker({ showFastModeNotice ? ( - Fast mode is ON and available with{' '} - {FAST_MODE_MODEL_DISPLAY} only (/fast). Switching to other - models turn off fast mode. + Fast mode is ON and available with {FAST_MODE_MODEL_DISPLAY} only (/fast). Switching + to other models turn off fast mode. ) : isFastModeAvailable() && !isFastModeCooldown() ? ( - Use /fast to turn on Fast mode ( - {FAST_MODE_MODEL_DISPLAY} only). + Use /fast to turn on Fast mode ({FAST_MODE_MODEL_DISPLAY} only). ) : null @@ -334,68 +308,45 @@ export function ModelPicker({ ) : ( - + )} )} - ) + ); if (!isStandaloneCommand) { - return content + return content; } - return {content} + return {content}; } function resolveOptionModel(value?: string): string | undefined { - if (!value) return undefined - return value === NO_PREFERENCE - ? getDefaultMainLoopModel() - : parseUserSpecifiedModel(value) + if (!value) return undefined; + return value === NO_PREFERENCE ? getDefaultMainLoopModel() : parseUserSpecifiedModel(value); } -function EffortLevelIndicator({ - effort, -}: { - effort?: EffortLevel -}): React.ReactNode { - return ( - - {effortLevelToSymbol(effort ?? 'low')} - - ) +function EffortLevelIndicator({ effort }: { effort?: EffortLevel }): React.ReactNode { + return {effortLevelToSymbol(effort ?? 'low')}; } -function cycleEffortLevel( - current: EffortLevel, - direction: 'left' | 'right', - includeMax: boolean, -): EffortLevel { - const levels: EffortLevel[] = includeMax - ? ['low', 'medium', 'high', 'max'] - : ['low', 'medium', 'high'] +function cycleEffortLevel(current: EffortLevel, direction: 'left' | 'right', includeMax: boolean): EffortLevel { + const levels: EffortLevel[] = includeMax ? ['low', 'medium', 'high', 'max'] : ['low', 'medium', 'high']; // If the current level isn't in the cycle (e.g. 'max' after switching to a // non-Opus model), clamp to 'high'. - const idx = levels.indexOf(current) - const currentIndex = idx !== -1 ? idx : levels.indexOf('high') + const idx = levels.indexOf(current); + const currentIndex = idx !== -1 ? idx : levels.indexOf('high'); if (direction === 'right') { - return levels[(currentIndex + 1) % levels.length]! + return levels[(currentIndex + 1) % levels.length]!; } else { - return levels[(currentIndex - 1 + levels.length) % levels.length]! + return levels[(currentIndex - 1 + levels.length) % levels.length]!; } } function getDefaultEffortLevelForOption(value?: string): EffortLevel { - const resolved = resolveOptionModel(value) ?? getDefaultMainLoopModel() - const defaultValue = getDefaultEffortForModel(resolved) - return defaultValue !== undefined - ? convertEffortValueToLevel(defaultValue) - : 'high' + const resolved = resolveOptionModel(value) ?? getDefaultMainLoopModel(); + const defaultValue = getDefaultEffortForModel(resolved); + return defaultValue !== undefined ? convertEffortValueToLevel(defaultValue) : 'high'; } diff --git a/src/utils/model/__tests__/getDefaultOpusModel.test.ts b/src/utils/model/__tests__/getDefaultOpusModel.test.ts new file mode 100644 index 000000000..35462e3fe --- /dev/null +++ b/src/utils/model/__tests__/getDefaultOpusModel.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { resetModelStringsForTestingOnly } from 'src/bootstrap/state.js' +import { + resetSettingsCache, + setSessionSettingsCache, +} from 'src/utils/settings/settingsCache.js' +import { ALL_MODEL_CONFIGS } from '../configs.js' +import { getDefaultOpusModel } from '../model.js' +import { getOpus46Option } from '../modelOptions.js' +import { getModelStrings } from '../modelStrings.js' + +/** + * Verifies getDefaultOpusModel() returns Opus 4.7 across all providers + * (firstParty + Bedrock/Vertex/Foundry). This is the Gap #2 assertion: + * as of 2026-04-17 all 3P vendors have published Opus 4.7, so the fork + * must not fall back to Opus 4.6 on 3P. + * + * Authoritative sources for 3P availability: + * - AWS Bedrock: docs.aws.amazon.com/bedrock/.../model-card-anthropic-claude-opus-4-7.html + * - Google Vertex AI: docs.cloud.google.com/vertex-ai/.../claude/opus-4-7 + * - Microsoft Foundry: ai.azure.com/catalog/models/claude-opus-4-7 + */ + +const envKeys = [ + 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_OPENAI', + 'CLAUDE_CODE_USE_GROK', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'OPENAI_DEFAULT_OPUS_MODEL', + 'GEMINI_DEFAULT_OPUS_MODEL', +] as const + +const savedEnv: Record = {} + +function resetProviderState(): void { + resetSettingsCache() + setSessionSettingsCache({ settings: {}, errors: [] }) + resetModelStringsForTestingOnly() +} + +describe('getDefaultOpusModel', () => { + beforeEach(() => { + for (const key of envKeys) { + savedEnv[key] = process.env[key] + delete process.env[key] + } + resetProviderState() + }) + + afterEach(() => { + for (const key of envKeys) { + if (savedEnv[key] !== undefined) { + process.env[key] = savedEnv[key] + } else { + delete process.env[key] + } + } + resetProviderState() + }) + + test('returns Opus 4.7 for firstParty', () => { + expect(getDefaultOpusModel()).toBe(ALL_MODEL_CONFIGS.opus47.firstParty) + }) + + test('returns Opus 4.7 for bedrock (3P no longer lags)', () => { + process.env.CLAUDE_CODE_USE_BEDROCK = '1' + expect(getDefaultOpusModel()).toBe(ALL_MODEL_CONFIGS.opus47.bedrock) + }) + + test('returns Opus 4.7 for vertex (3P no longer lags)', () => { + process.env.CLAUDE_CODE_USE_VERTEX = '1' + expect(getDefaultOpusModel()).toBe(ALL_MODEL_CONFIGS.opus47.vertex) + }) + + test('returns Opus 4.7 for foundry (3P no longer lags)', () => { + process.env.CLAUDE_CODE_USE_FOUNDRY = '1' + expect(getDefaultOpusModel()).toBe(ALL_MODEL_CONFIGS.opus47.foundry) + }) + + test('honors ANTHROPIC_DEFAULT_OPUS_MODEL env override (any provider)', () => { + process.env.CLAUDE_CODE_USE_BEDROCK = '1' + process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'claude-opus-4-1-custom' + expect(getDefaultOpusModel()).toBe('claude-opus-4-1-custom') + }) + + test('honors OPENAI_DEFAULT_OPUS_MODEL for openai provider', () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_DEFAULT_OPUS_MODEL = 'gpt-5-turbo' + expect(getDefaultOpusModel()).toBe('gpt-5-turbo') + }) +}) + +/** + * Gap #3 addition — "Opus 4.6" must appear as an explicit opt-in option in + * the /model picker across all non-ANT user tiers. The option's value MUST + * be the canonical 4.6 model string, NOT the 'opus' alias (which would + * resolve via getDefaultOpusModel back to 4.7 on firstParty, silently + * defeating the user's explicit choice). + */ +describe('getOpus46Option', () => { + beforeEach(() => { + for (const key of envKeys) { + savedEnv[key] = process.env[key] + delete process.env[key] + } + resetProviderState() + }) + + afterEach(() => { + for (const key of envKeys) { + if (savedEnv[key] !== undefined) { + process.env[key] = savedEnv[key] + } else { + delete process.env[key] + } + } + resetProviderState() + }) + + test('firstParty: value is canonical opus46 string, NOT opus alias', () => { + const opt = getOpus46Option(false) + expect(opt.value).toBe(getModelStrings().opus46) + expect(opt.value).not.toBe('opus') + expect(opt.label).toBe('Opus 4.6') + }) + + test('firstParty: description says "Previous generation", not "Legacy"', () => { + const opt = getOpus46Option(false) + expect(opt.description).toContain('Previous generation') + expect(opt.description).not.toContain('Legacy') + }) + + test('bedrock: value is canonical opus46 string (unchanged behavior)', () => { + process.env.CLAUDE_CODE_USE_BEDROCK = '1' + const opt = getOpus46Option(false) + expect(opt.value).toBe(getModelStrings().opus46) + expect(opt.value).toBe(ALL_MODEL_CONFIGS.opus46.bedrock) + }) + + test('option has descriptionForModel that mentions Opus 4.6', () => { + const opt = getOpus46Option(false) + expect(opt.descriptionForModel).toBeDefined() + expect(opt.descriptionForModel).toContain('Opus 4.6') + }) +}) diff --git a/src/utils/model/configs.ts b/src/utils/model/configs.ts index d3fac9b07..58d157d9c 100644 --- a/src/utils/model/configs.ts +++ b/src/utils/model/configs.ts @@ -106,6 +106,16 @@ export const CLAUDE_OPUS_4_6_CONFIG = { grok: 'claude-opus-4-6', } as const satisfies ModelConfig +export const CLAUDE_OPUS_4_7_CONFIG = { + firstParty: 'claude-opus-4-7', + bedrock: 'us.anthropic.claude-opus-4-7-v1', + vertex: 'claude-opus-4-7', + foundry: 'claude-opus-4-7', + openai: 'claude-opus-4-7', + gemini: 'claude-opus-4-7', + grok: 'claude-opus-4-7', +} as const satisfies ModelConfig + export const CLAUDE_SONNET_4_6_CONFIG = { firstParty: 'claude-sonnet-4-6', bedrock: 'us.anthropic.claude-sonnet-4-6', @@ -129,6 +139,7 @@ export const ALL_MODEL_CONFIGS = { opus41: CLAUDE_OPUS_4_1_CONFIG, opus45: CLAUDE_OPUS_4_5_CONFIG, opus46: CLAUDE_OPUS_4_6_CONFIG, + opus47: CLAUDE_OPUS_4_7_CONFIG, } as const satisfies Record export type ModelKey = keyof typeof ALL_MODEL_CONFIGS diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index 7bf8b3939..0328257b2 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -28,18 +28,6 @@ import { getAPIProvider } from './providers.js' import { LIGHTNING_BOLT } from '../../constants/figures.js' import { isModelAllowed } from './modelAllowlist.js' import { type ModelAlias, isModelAlias } from './aliases.js' - -/** - * Returns true if the value is a model alias or a model alias with a suffix - * like [1m] (e.g. "opus", "opus[1m]", "sonnet", "haiku[1m]"). - * Used to guard against infinite recursion when getDefault*Model() falls back - * to the user-specified setting — an alias like "opus[1m]" would cause - * parseUserSpecifiedModel → getDefaultOpusModel → parseUserSpecifiedModel loop. - */ -function isAliasOrAliasWithSuffix(value: string): boolean { - const base = value.replace(/\[1m\]$/i, '').trim() - return isModelAlias(base) -} import { capitalize } from '../stringUtils.js' export type ModelShortName = string @@ -64,7 +52,8 @@ export function isNonCustomOpusModel(model: ModelName): boolean { model === getModelStrings().opus40 || model === getModelStrings().opus41 || model === getModelStrings().opus45 || - model === getModelStrings().opus46 + model === getModelStrings().opus46 || + model === getModelStrings().opus47 ) } @@ -138,21 +127,14 @@ export function getDefaultOpusModel(): ModelName { if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) { return process.env.ANTHROPIC_DEFAULT_OPUS_MODEL } - // Fall back to user's configured model — custom providers may not - // recognize hardcoded Anthropic model IDs. - // Skip if the user setting is a model alias (e.g. "opus", "opus[1m]") to - // avoid infinite recursion: parseUserSpecifiedModel(alias) → getDefaultOpusModel(). - const userSpecifiedOpus = getUserSpecifiedModelSetting() - if (userSpecifiedOpus && !isAliasOrAliasWithSuffix(userSpecifiedOpus)) { - return parseUserSpecifiedModel(userSpecifiedOpus) - } - // 3P providers (Bedrock, Vertex, Foundry) — kept as a separate branch - // even when values match, since 3P availability lags firstParty and - // these will diverge again at the next model launch. + // 3P providers (Bedrock, Vertex, Foundry) all publish Opus 4.7 in sync + // with firstParty as of 2026-04-17 (AWS Bedrock, Google Vertex AI, and + // Microsoft Foundry announcements and model catalogs all confirm). The + // branch is kept as a structural hook in case a future launch lags on 3P. if (provider !== 'firstParty') { - return getModelStrings().opus46 + return getModelStrings().opus47 } - return getModelStrings().opus46 + return getModelStrings().opus47 } // @[MODEL LAUNCH]: Update the default Sonnet model (3P providers may lag so keep defaults unchanged). @@ -173,14 +155,6 @@ export function getDefaultSonnetModel(): ModelName { if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) { return process.env.ANTHROPIC_DEFAULT_SONNET_MODEL } - // Fall back to user's configured model (ANTHROPIC_MODEL / settings) — - // custom providers (proxies, national clouds) may not recognize the - // hardcoded Anthropic model IDs. - // Skip if the user setting is a model alias to avoid infinite recursion. - const userSpecified = getUserSpecifiedModelSetting() - if (userSpecified && !isAliasOrAliasWithSuffix(userSpecified)) { - return parseUserSpecifiedModel(userSpecified) - } // Default to Sonnet 4.5 for 3P since they may not have 4.6 yet if (provider !== 'firstParty') { return getModelStrings().sonnet45 @@ -203,13 +177,6 @@ export function getDefaultHaikuModel(): ModelName { if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) { return process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL } - // Fall back to user's configured model — custom providers may not - // recognize hardcoded Anthropic model IDs. - // Skip if the user setting is a model alias to avoid infinite recursion. - const userSpecifiedHaiku = getUserSpecifiedModelSetting() - if (userSpecifiedHaiku && !isAliasOrAliasWithSuffix(userSpecifiedHaiku)) { - return parseUserSpecifiedModel(userSpecifiedHaiku) - } // Haiku 4.5 is available on all platforms (first-party, Foundry, Bedrock, Vertex) return getModelStrings().haiku45 @@ -296,6 +263,9 @@ export function firstPartyNameToCanonical(name: ModelName): ModelShortName { name = name.toLowerCase() // Special cases for Claude 4+ models to differentiate versions // Order matters: check more specific versions first (4-5 before 4) + if (name.includes('claude-opus-4-7')) { + return 'claude-opus-4-7' + } if (name.includes('claude-opus-4-6')) { return 'claude-opus-4-6' } @@ -366,9 +336,9 @@ export function getClaudeAiUserDefaultModelDescription( ): string { if (isMaxSubscriber() || isTeamPremiumSubscriber()) { if (isOpus1mMergeEnabled()) { - return `Opus 4.6 with 1M context · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}` + return `Opus 4.7 with 1M context · Most capable for complex work${fastMode ? getOpusPricingSuffix(true) : ''}` } - return `Opus 4.6 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}` + return `Opus 4.7 · Most capable for complex work${fastMode ? getOpusPricingSuffix(true) : ''}` } return 'Sonnet 4.6 · Best for everyday tasks' } @@ -377,12 +347,12 @@ export function renderDefaultModelSetting( setting: ModelName | ModelAlias, ): string { if (setting === 'opusplan') { - return 'Opus 4.6 in plan mode, else Sonnet 4.6' + return 'Opus 4.7 in plan mode, else Sonnet 4.6' } return renderModelName(parseUserSpecifiedModel(setting)) } -export function getOpus46PricingSuffix(fastMode: boolean): string { +export function getOpusPricingSuffix(fastMode: boolean): string { if (getAPIProvider() !== 'firstParty') return '' const pricing = formatModelPricing(getOpus46CostTier(fastMode)) const fastModeIndicator = fastMode ? ` (${LIGHTNING_BOLT})` : '' @@ -426,6 +396,10 @@ export function renderModelSetting(setting: ModelName | ModelAlias): string { */ export function getPublicModelDisplayName(model: ModelName): string | null { switch (model) { + case getModelStrings().opus47: + return 'Opus 4.7' + case getModelStrings().opus47 + '[1m]': + return 'Opus 4.7 (1M context)' case getModelStrings().opus46: return 'Opus 4.6' case getModelStrings().opus46 + '[1m]': @@ -549,9 +523,10 @@ export function parseUserSpecifiedModel( // Opus 4/4.1 are no longer available on the first-party API (same as // Claude.ai) — silently remap to the current Opus default. The 'opus' - // alias already resolves to 4.6, so the only users on these explicit - // strings pinned them in settings/env/--model/SDK before 4.5 launched. - // 3P providers may not yet have 4.6 capacity, so pass through unchanged. + // alias resolves to the current default Opus (4.7), so the only users + // on these explicit strings pinned them in settings/env/--model/SDK + // before 4.5 launched. 3P providers may not yet have 4.6/4.7 capacity, + // so pass through unchanged. if ( getAPIProvider() === 'firstParty' && isLegacyOpusFirstParty(modelString) && @@ -654,6 +629,9 @@ export function getMarketingNameForModel(modelId: string): string | undefined { const has1m = modelId.toLowerCase().includes('[1m]') const canonical = getCanonicalName(modelId) + if (canonical.includes('claude-opus-4-7')) { + return has1m ? 'Opus 4.7 (with 1M context)' : 'Opus 4.7' + } if (canonical.includes('claude-opus-4-6')) { return has1m ? 'Opus 4.6 (with 1M context)' : 'Opus 4.6' } diff --git a/src/utils/model/modelCapabilities.ts b/src/utils/model/modelCapabilities.ts index 817f9e8c1..ee6d002e5 100644 --- a/src/utils/model/modelCapabilities.ts +++ b/src/utils/model/modelCapabilities.ts @@ -44,7 +44,10 @@ function getCachePath(): string { } function isModelCapabilitiesEligible(): boolean { - if (process.env.USER_TYPE !== 'ant') return false + // Upstream gates this to ant-only, but the /v1/models API is available + // to all firstParty users (API key and OAuth). Enabling for everyone + // lets model capabilities (max_input_tokens, max_tokens) be fetched + // dynamically instead of relying on hardcoded values in context.ts. if (getAPIProvider() !== 'firstParty') return false if (!isFirstPartyAnthropicBaseUrl()) return false return true diff --git a/src/utils/model/modelOptions.ts b/src/utils/model/modelOptions.ts index 6d84a187f..754963955 100644 --- a/src/utils/model/modelOptions.ts +++ b/src/utils/model/modelOptions.ts @@ -27,7 +27,7 @@ import { getMarketingNameForModel, getUserSpecifiedModelSetting, isOpus1mMergeEnabled, - getOpus46PricingSuffix, + getOpusPricingSuffix, renderDefaultModelSetting, type ModelSetting, } from './model.js' @@ -82,8 +82,8 @@ function getCustomSonnetOption(): ModelOption | undefined { provider === 'openai' ? process.env.OPENAI_DEFAULT_SONNET_MODEL : provider === 'gemini' - ? process.env.GEMINI_DEFAULT_SONNET_MODEL - : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL + ? process.env.GEMINI_DEFAULT_SONNET_MODEL + : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL // When a 3P user has a custom sonnet model string, show it directly if (is3P && customSonnetModel) { const is1m = has1mContext(customSonnetModel) @@ -92,14 +92,14 @@ function getCustomSonnetOption(): ModelOption | undefined { provider === 'openai' ? process.env.OPENAI_DEFAULT_SONNET_MODEL_NAME : provider === 'gemini' - ? process.env.GEMINI_DEFAULT_SONNET_MODEL_NAME - : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME + ? process.env.GEMINI_DEFAULT_SONNET_MODEL_NAME + : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME const descEnv = provider === 'openai' ? process.env.OPENAI_DEFAULT_SONNET_MODEL_DESCRIPTION : provider === 'gemini' - ? process.env.GEMINI_DEFAULT_SONNET_MODEL_DESCRIPTION - : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION + ? process.env.GEMINI_DEFAULT_SONNET_MODEL_DESCRIPTION + : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION return { value: 'sonnet', label: nameEnv ?? customSonnetModel, @@ -131,8 +131,8 @@ function getCustomOpusOption(): ModelOption | undefined { provider === 'openai' ? process.env.OPENAI_DEFAULT_OPUS_MODEL : provider === 'gemini' - ? process.env.GEMINI_DEFAULT_OPUS_MODEL - : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL + ? process.env.GEMINI_DEFAULT_OPUS_MODEL + : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL // When a 3P user has a custom opus model string, show it directly if (is3P && customOpusModel) { const is1m = has1mContext(customOpusModel) @@ -141,14 +141,14 @@ function getCustomOpusOption(): ModelOption | undefined { provider === 'openai' ? process.env.OPENAI_DEFAULT_OPUS_MODEL_NAME : provider === 'gemini' - ? process.env.GEMINI_DEFAULT_OPUS_MODEL_NAME - : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME + ? process.env.GEMINI_DEFAULT_OPUS_MODEL_NAME + : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME const descEnv = provider === 'openai' ? process.env.OPENAI_DEFAULT_OPUS_MODEL_DESCRIPTION : provider === 'gemini' - ? process.env.GEMINI_DEFAULT_OPUS_MODEL_DESCRIPTION - : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION + ? process.env.GEMINI_DEFAULT_OPUS_MODEL_DESCRIPTION + : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION return { value: 'opus', label: nameEnv ?? customOpusModel, @@ -167,13 +167,27 @@ function getOpus41Option(): ModelOption { } } -function getOpus46Option(fastMode = false): ModelOption { +function getOpus47Option(fastMode = false): ModelOption { const is3P = getAPIProvider() !== 'firstParty' return { - value: is3P ? getModelStrings().opus46 : 'opus', - label: 'Opus', - description: `Opus 4.6 · Most capable for complex work${getOpus46PricingSuffix(fastMode)}`, - descriptionForModel: 'Opus 4.6 - most capable for complex work', + value: is3P ? getModelStrings().opus47 : 'opus', + label: 'Opus 4.7', + description: `Opus 4.7 · Most capable for complex work${getOpusPricingSuffix(fastMode)}`, + descriptionForModel: 'Opus 4.7 - most capable for complex work', + } +} + +export function getOpus46Option(fastMode = false): ModelOption { + // Always use the canonical 4.6 model string (not the 'opus' alias, which + // resolves via getDefaultOpusModel() to opus47 on firstParty). Users + // selecting "Opus 4.6" must get 4.6 actually dispatched, not alias-routed + // to 4.7. The same string is correct for 3P (getModelStrings maps per + // provider). + return { + value: getModelStrings().opus46, + label: 'Opus 4.6', + description: `Opus 4.6 · Previous generation Opus${getOpusPricingSuffix(fastMode)}`, + descriptionForModel: 'Opus 4.6 - previous generation Opus model', } } @@ -188,12 +202,22 @@ export function getSonnet46_1MOption(): ModelOption { } } -export function getOpus46_1MOption(fastMode = false): ModelOption { +export function getOpus47_1MOption(fastMode = false): ModelOption { const is3P = getAPIProvider() !== 'firstParty' return { - value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]', - label: 'Opus (1M context)', - description: `Opus 4.6 for long sessions${getOpus46PricingSuffix(fastMode)}`, + value: is3P ? getModelStrings().opus47 + '[1m]' : 'opus[1m]', + label: 'Opus 4.7 (1M context)', + description: `Opus 4.7 with 1M context${getOpusPricingSuffix(fastMode)}`, + descriptionForModel: + 'Opus 4.7 with 1M context window - for long sessions with large codebases', + } +} + +export function getOpus46_1MOption(fastMode = false): ModelOption { + return { + value: getModelStrings().opus46 + '[1m]', + label: 'Opus 4.6 (1M context)', + description: `Opus 4.6 with 1M context${getOpusPricingSuffix(fastMode)}`, descriptionForModel: 'Opus 4.6 with 1M context window - for long sessions with large codebases', } @@ -207,8 +231,8 @@ function getCustomHaikuOption(): ModelOption | undefined { provider === 'openai' ? process.env.OPENAI_DEFAULT_HAIKU_MODEL : provider === 'gemini' - ? process.env.GEMINI_DEFAULT_HAIKU_MODEL - : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL + ? process.env.GEMINI_DEFAULT_HAIKU_MODEL + : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL // When a 3P user has a custom haiku model string, show it directly if (is3P && customHaikuModel) { // Use appropriate NAME/DESCRIPTION env vars based on provider @@ -216,14 +240,14 @@ function getCustomHaikuOption(): ModelOption | undefined { provider === 'openai' ? process.env.OPENAI_DEFAULT_HAIKU_MODEL_NAME : provider === 'gemini' - ? process.env.GEMINI_DEFAULT_HAIKU_MODEL_NAME - : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME + ? process.env.GEMINI_DEFAULT_HAIKU_MODEL_NAME + : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME const descEnv = provider === 'openai' ? process.env.OPENAI_DEFAULT_HAIKU_MODEL_DESCRIPTION : provider === 'gemini' - ? process.env.GEMINI_DEFAULT_HAIKU_MODEL_DESCRIPTION - : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION + ? process.env.GEMINI_DEFAULT_HAIKU_MODEL_DESCRIPTION + : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION return { value: 'haiku', label: nameEnv ?? customHaikuModel, @@ -266,8 +290,8 @@ function getHaikuOption(): ModelOption { function getMaxOpusOption(fastMode = false): ModelOption { return { value: 'opus', - label: 'Opus', - description: `Opus 4.6 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`, + label: 'Opus 4.7', + description: `Opus 4.7 · Most capable for complex work${fastMode ? getOpusPricingSuffix(true) : ''}`, } } @@ -281,23 +305,23 @@ export function getMaxSonnet46_1MOption(): ModelOption { } } -export function getMaxOpus46_1MOption(fastMode = false): ModelOption { +export function getMaxOpus47_1MOption(fastMode = false): ModelOption { const billingInfo = isClaudeAISubscriber() ? ' · Billed as extra usage' : '' return { value: 'opus[1m]', - label: 'Opus (1M context)', - description: `Opus 4.6 with 1M context${billingInfo}${getOpus46PricingSuffix(fastMode)}`, + label: 'Opus 4.7 (1M context)', + description: `Opus 4.7 with 1M context${billingInfo}${getOpusPricingSuffix(fastMode)}`, } } function getMergedOpus1MOption(fastMode = false): ModelOption { const is3P = getAPIProvider() !== 'firstParty' return { - value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]', - label: 'Opus (1M context)', - description: `Opus 4.6 with 1M context · Most capable for complex work${!is3P && fastMode ? getOpus46PricingSuffix(fastMode) : ''}`, + value: is3P ? getModelStrings().opus47 + '[1m]' : 'opus[1m]', + label: 'Opus 4.7 (1M context)', + description: `Opus 4.7 with 1M context · Most capable for complex work${!is3P && fastMode ? getOpusPricingSuffix(fastMode) : ''}`, descriptionForModel: - 'Opus 4.6 with 1M context - most capable for complex work', + 'Opus 4.7 with 1M context - most capable for complex work', } } @@ -317,7 +341,7 @@ function getOpusPlanOption(): ModelOption { return { value: 'opusplan', label: 'Opus Plan Mode', - description: 'Use Opus 4.6 in plan mode, Sonnet 4.6 otherwise', + description: 'Use Opus 4.7 in plan mode, Sonnet 4.6 otherwise', } } @@ -344,11 +368,9 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { if (isClaudeAISubscriber()) { if (isMaxSubscriber() || isTeamPremiumSubscriber()) { - // Max and Team Premium users: Opus is default, show Sonnet as alternative + // Max and Team Premium users: Default = Opus 4.7 1M (merged), plus Opus 4.6 1M const premiumOptions = [getDefaultOptionForUser(fastMode)] - if (!isOpus1mMergeEnabled() && checkOpus1mAccess()) { - premiumOptions.push(getMaxOpus46_1MOption(fastMode)) - } + premiumOptions.push(getOpus46_1MOption(fastMode)) premiumOptions.push(MaxSonnet46Option) if (checkSonnet1mAccess()) { @@ -359,44 +381,47 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { return premiumOptions } - // Pro/Team Standard/Enterprise users: Sonnet is default, show Opus as alternative + // Pro/Team Standard/Enterprise users: Sonnet is default, show Opus 4.7 1M + Opus 4.6 1M const standardOptions = [getDefaultOptionForUser(fastMode)] - if (checkSonnet1mAccess()) { - standardOptions.push(getMaxSonnet46_1MOption()) - } if (isOpus1mMergeEnabled()) { standardOptions.push(getMergedOpus1MOption(fastMode)) } else { standardOptions.push(getMaxOpusOption(fastMode)) if (checkOpus1mAccess()) { - standardOptions.push(getMaxOpus46_1MOption(fastMode)) + standardOptions.push(getMaxOpus47_1MOption(fastMode)) } } + standardOptions.push(getOpus46_1MOption(fastMode)) + + if (checkSonnet1mAccess()) { + standardOptions.push(getMaxSonnet46_1MOption()) + } standardOptions.push(MaxHaiku45Option) return standardOptions } - // PAYG 1P API: Default (Sonnet) + Sonnet 1M + Opus 4.6 + Opus 1M + Haiku + // PAYG 1P API: Default (Sonnet) + Opus 4.7 1M + Opus 4.6 1M + Sonnet 1M + Haiku if (getAPIProvider() === 'firstParty') { const payg1POptions = [getDefaultOptionForUser(fastMode)] - if (checkSonnet1mAccess()) { - payg1POptions.push(getSonnet46_1MOption()) - } if (isOpus1mMergeEnabled()) { payg1POptions.push(getMergedOpus1MOption(fastMode)) } else { - payg1POptions.push(getOpus46Option(fastMode)) + payg1POptions.push(getOpus47Option(fastMode)) if (checkOpus1mAccess()) { - payg1POptions.push(getOpus46_1MOption(fastMode)) + payg1POptions.push(getOpus47_1MOption(fastMode)) } } + payg1POptions.push(getOpus46_1MOption(fastMode)) + if (checkSonnet1mAccess()) { + payg1POptions.push(getSonnet46_1MOption()) + } payg1POptions.push(getHaiku45Option()) return payg1POptions } - // PAYG 3P: Default (Sonnet 4.5) + Sonnet (3P custom) or Sonnet 4.6/1M + Opus (3P custom) or Opus 4.1/Opus 4.6/Opus1M + Haiku + Opus 4.1 + // PAYG 3P: Default (Sonnet 4.5) + Sonnet (3P custom) or Sonnet 4.6/1M + Opus (3P custom) or Opus 4.7/Opus 4.6 Legacy/Opus 4.7 1M + Haiku const payg3pOptions = [getDefaultOptionForUser(fastMode)] const customSonnet = getCustomSonnetOption() @@ -414,12 +439,9 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { if (customOpus !== undefined) { payg3pOptions.push(customOpus) } else { - // Add Opus 4.1, Opus 4.6 and Opus 4.6 1M - payg3pOptions.push(getOpus41Option()) // This is the default opus - payg3pOptions.push(getOpus46Option(fastMode)) - if (checkOpus1mAccess()) { - payg3pOptions.push(getOpus46_1MOption(fastMode)) - } + // Add Opus 4.7 1M + Opus 4.6 1M (no redundant non-1M entries) + payg3pOptions.push(getOpus47_1MOption(fastMode)) + payg3pOptions.push(getOpus46_1MOption(fastMode)) } const customHaiku = getCustomHaikuOption() if (customHaiku !== undefined) { diff --git a/src/utils/model/modelSupportOverrides.ts b/src/utils/model/modelSupportOverrides.ts index 14ea0de0a..7e842a1ca 100644 --- a/src/utils/model/modelSupportOverrides.ts +++ b/src/utils/model/modelSupportOverrides.ts @@ -4,6 +4,7 @@ import { getAPIProvider } from './providers.js' export type ModelCapabilityOverride = | 'effort' | 'max_effort' + | 'xhigh_effort' | 'thinking' | 'adaptive_thinking' | 'interleaved_thinking' diff --git a/src/utils/model/validateModel.ts b/src/utils/model/validateModel.ts index 14b816756..a36299428 100644 --- a/src/utils/model/validateModel.ts +++ b/src/utils/model/validateModel.ts @@ -146,6 +146,9 @@ function get3PFallbackSuggestion(model: string): string | undefined { return undefined } const lowerModel = model.toLowerCase() + if (lowerModel.includes('opus-4-7') || lowerModel.includes('opus_4_7')) { + return getModelStrings().opus46 + } if (lowerModel.includes('opus-4-6') || lowerModel.includes('opus_4_6')) { return getModelStrings().opus41 }