mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat: 添加 model/provider 层改进
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
148
src/utils/model/__tests__/getDefaultOpusModel.test.ts
Normal file
148
src/utils/model/__tests__/getDefaultOpusModel.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getAPIProvider } from './providers.js'
|
||||
export type ModelCapabilityOverride =
|
||||
| 'effort'
|
||||
| 'max_effort'
|
||||
| 'xhigh_effort'
|
||||
| 'thinking'
|
||||
| 'adaptive_thinking'
|
||||
| 'interleaved_thinking'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user