feat: 添加 model/provider 层改进

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
unraid
2026-04-22 22:38:10 +08:00
parent d208855f07
commit 23bb09d240
13 changed files with 689 additions and 472 deletions

View File

@@ -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 },
)

View File

@@ -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' })

View File

@@ -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<string, unknown>)
const imagePart = convertImageBlockToOpenAI(
block as unknown as Record<string, unknown>,
)
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<ChatCompletionAssistantMessageParam['tool_calls']> = []
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<string, unknown>).thinking
const thinkingText = (block as unknown as Record<string, unknown>)
.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]

View File

@@ -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<string, unknown>
const name = (anyTool.name as string) || ''
const description = (anyTool.description as string) || ''
const inputSchema = anyTool.input_schema as Record<string, unknown> | undefined
const inputSchema = anyTool.input_schema as
| Record<string, unknown>
| 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<string, unknown>): Record<string, unknown> {
function sanitizeJsonSchema(
schema: Record<string, unknown>,
): Record<string, unknown> {
if (!schema || typeof schema !== 'object') return schema
const result = { ...schema }
@@ -55,20 +63,37 @@ function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unk
}
// Recursively process nested schemas
const objectKeys = ['properties', 'definitions', '$defs', 'patternProperties'] as const
const objectKeys = [
'properties',
'definitions',
'$defs',
'patternProperties',
] as const
for (const key of objectKeys) {
const nested = result[key]
if (nested && typeof nested === 'object') {
const sanitized: Record<string, unknown> = {}
for (const [k, v] of Object.entries(nested as Record<string, unknown>)) {
sanitized[k] = v && typeof v === 'object' ? sanitizeJsonSchema(v as Record<string, unknown>) : v
sanitized[k] =
v && typeof v === 'object'
? sanitizeJsonSchema(v as Record<string, unknown>)
: 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<string, unknown>): Record<string, unk
const nested = result[key]
if (Array.isArray(nested)) {
result[key] = nested.map(item =>
item && typeof item === 'object' ? sanitizeJsonSchema(item as Record<string, unknown>) : item
item && typeof item === 'object'
? sanitizeJsonSchema(item as Record<string, unknown>)
: item,
)
}
}

View File

@@ -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<number, { contentIndex: number; id: string; name: string; arguments: string }>()
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, {

View File

@@ -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<string | undefined>(
initialValue,
)
const initialValue = initial === null ? NO_PREFERENCE : initial;
const [focusedValue, setFocusedValue] = useState<string | undefined>(initialValue);
const isFastMode = useAppState(s =>
isFastModeEnabled() ? s.fastMode : false,
)
const isFastMode = useAppState(s => (isFastModeEnabled() ? s.fastMode : false));
const [marked1MValues, setMarked1MValues] = useState<Set<string>>(
() => 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<EffortLevel | undefined>(
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({
</Text>
{sessionModel && (
<Text dimColor>
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.
</Text>
)}
</Box>
@@ -283,10 +261,8 @@ export function ModelPicker({
<Box marginBottom={1} flexDirection="column">
{focusedSupportsEffort ? (
<Text dimColor>
<EffortLevelIndicator effort={displayEffort} />{' '}
{capitalize(displayEffort)} effort
{displayEffort === focusedDefaultEffort ? ` (default)` : ``}{' '}
<Text color="subtle"> to adjust</Text>
<EffortLevelIndicator effort={displayEffort} /> {capitalize(displayEffort)} effort
{displayEffort === focusedDefaultEffort ? ` (default)` : ``} <Text color="subtle"> to adjust</Text>
</Text>
) : (
<Text color="subtle">
@@ -311,16 +287,14 @@ export function ModelPicker({
showFastModeNotice ? (
<Box marginBottom={1}>
<Text dimColor>
Fast mode is <Text bold>ON</Text> and available with{' '}
{FAST_MODE_MODEL_DISPLAY} only (/fast). Switching to other
models turn off fast mode.
Fast mode is <Text bold>ON</Text> and available with {FAST_MODE_MODEL_DISPLAY} only (/fast). Switching
to other models turn off fast mode.
</Text>
</Box>
) : isFastModeAvailable() && !isFastModeCooldown() ? (
<Box marginBottom={1}>
<Text dimColor>
Use <Text bold>/fast</Text> to turn on Fast mode (
{FAST_MODE_MODEL_DISPLAY} only).
Use <Text bold>/fast</Text> to turn on Fast mode ({FAST_MODE_MODEL_DISPLAY} only).
</Text>
</Box>
) : null
@@ -334,68 +308,45 @@ export function ModelPicker({
) : (
<Byline>
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
<ConfigurableShortcutHint
action="select:cancel"
context="Select"
fallback="Esc"
description="exit"
/>
<ConfigurableShortcutHint action="select:cancel" context="Select" fallback="Esc" description="exit" />
</Byline>
)}
</Text>
)}
</Box>
)
);
if (!isStandaloneCommand) {
return content
return content;
}
return <Pane color="permission">{content}</Pane>
return <Pane color="permission">{content}</Pane>;
}
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 (
<Text color={effort ? 'claude' : 'subtle'}>
{effortLevelToSymbol(effort ?? 'low')}
</Text>
)
function EffortLevelIndicator({ effort }: { effort?: EffortLevel }): React.ReactNode {
return <Text color={effort ? 'claude' : 'subtle'}>{effortLevelToSymbol(effort ?? 'low')}</Text>;
}
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';
}

View File

@@ -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<string, string | undefined> = {}
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')
})
})

View File

@@ -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<string, ModelConfig>
export type ModelKey = keyof typeof ALL_MODEL_CONFIGS

View File

@@ -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'
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -4,6 +4,7 @@ import { getAPIProvider } from './providers.js'
export type ModelCapabilityOverride =
| 'effort'
| 'max_effort'
| 'xhigh_effort'
| 'thinking'
| 'adaptive_thinking'
| 'interleaved_thinking'

View File

@@ -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
}