Skip to content

Commit 23bb09d

Browse files
unraidclaude
andcommitted
feat: 添加 model/provider 层改进
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d208855 commit 23bb09d

13 files changed

Lines changed: 689 additions & 472 deletions

packages/@ant/model-provider/src/shared/__tests__/openaiConvertMessages.test.ts

Lines changed: 193 additions & 143 deletions
Large diffs are not rendered by default.

packages/@ant/model-provider/src/shared/__tests__/openaiConvertTools.test.ts

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,25 +18,29 @@ describe('anthropicToolsToOpenAI', () => {
1818

1919
const result = anthropicToolsToOpenAI(tools as any)
2020

21-
expect(result).toEqual([{
22-
type: 'function',
23-
function: {
24-
name: 'bash',
25-
description: 'Run a bash command',
26-
parameters: {
27-
type: 'object',
28-
properties: { command: { type: 'string' } },
29-
required: ['command'],
21+
expect(result).toEqual([
22+
{
23+
type: 'function',
24+
function: {
25+
name: 'bash',
26+
description: 'Run a bash command',
27+
parameters: {
28+
type: 'object',
29+
properties: { command: { type: 'string' } },
30+
required: ['command'],
31+
},
3032
},
3133
},
32-
}])
34+
])
3335
})
3436

3537
test('uses empty schema when input_schema missing', () => {
3638
const tools = [{ type: 'custom', name: 'noop', description: 'no-op' }]
3739
const result = anthropicToolsToOpenAI(tools as any)
3840

39-
expect((result[0] as { function: { parameters: unknown } }).function.parameters).toEqual({ type: 'object', properties: {} })
41+
expect(
42+
(result[0] as { function: { parameters: unknown } }).function.parameters,
43+
).toEqual({ type: 'object', properties: {} })
4044
})
4145

4246
test('strips Anthropic-specific fields', () => {
@@ -76,7 +80,8 @@ describe('anthropicToolsToOpenAI', () => {
7680
},
7781
]
7882
const result = anthropicToolsToOpenAI(tools as any)
79-
const props = (result[0] as { function: { parameters: any } }).function.parameters as any
83+
const props = (result[0] as { function: { parameters: any } }).function
84+
.parameters as any
8085
expect(props.properties.mode).toEqual({ enum: ['read'] })
8186
expect(props.properties.mode.const).toBeUndefined()
8287
expect(props.properties.name).toEqual({ type: 'string' })
@@ -110,8 +115,11 @@ describe('anthropicToolsToOpenAI', () => {
110115
},
111116
]
112117
const result = anthropicToolsToOpenAI(tools as any)
113-
const params = (result[0] as { function: { parameters: any } }).function.parameters as any
114-
expect(params.properties.outer.properties.inner).toEqual({ enum: ['fixed'] })
118+
const params = (result[0] as { function: { parameters: any } }).function
119+
.parameters as any
120+
expect(params.properties.outer.properties.inner).toEqual({
121+
enum: ['fixed'],
122+
})
115123
expect(params.definitions.MyType.properties.field).toEqual({ enum: [42] })
116124
})
117125

@@ -125,18 +133,17 @@ describe('anthropicToolsToOpenAI', () => {
125133
type: 'object',
126134
properties: {
127135
val: {
128-
anyOf: [
129-
{ const: 'a' },
130-
{ const: 'b' },
131-
{ type: 'string' },
132-
],
136+
anyOf: [{ const: 'a' }, { const: 'b' }, { type: 'string' }],
133137
},
134138
},
135139
},
136140
},
137141
]
138142
const result = anthropicToolsToOpenAI(tools as any)
139-
const anyOf = ((result[0] as { function: { parameters: any } }).function.parameters as any).properties.val.anyOf
143+
const anyOf = (
144+
(result[0] as { function: { parameters: any } }).function
145+
.parameters as any
146+
).properties.val.anyOf
140147
expect(anyOf[0]).toEqual({ enum: ['a'] })
141148
expect(anyOf[1]).toEqual({ enum: ['b'] })
142149
expect(anyOf[2]).toEqual({ type: 'string' })

packages/@ant/model-provider/src/shared/openaiConvertMessages.ts

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,18 @@ export function anthropicMessagesToOpenAI(
6262
// A user message starts a new turn if it contains any non-tool_result content
6363
// (text, image, or other media). Tool results alone do NOT start a new turn
6464
// because they are continuations of the previous assistant tool call.
65-
const startsNewUserTurn = typeof content === 'string'
66-
? content.length > 0
67-
: Array.isArray(content) && content.some(
68-
(b: any) =>
69-
typeof b === 'string' ||
70-
(b &&
71-
typeof b === 'object' &&
72-
'type' in b &&
73-
b.type !== 'tool_result'),
74-
)
65+
const startsNewUserTurn =
66+
typeof content === 'string'
67+
? content.length > 0
68+
: Array.isArray(content) &&
69+
content.some(
70+
(b: any) =>
71+
typeof b === 'string' ||
72+
(b &&
73+
typeof b === 'object' &&
74+
'type' in b &&
75+
b.type !== 'tool_result'),
76+
)
7577
if (startsNewUserTurn) {
7678
turnBoundaries.add(i)
7779
}
@@ -88,7 +90,8 @@ export function anthropicMessagesToOpenAI(
8890
case 'assistant':
8991
// Preserve reasoning_content unless we're before a turn boundary
9092
// (i.e., from a previous user Q&A round)
91-
const preserveReasoning = enableThinking && !isBeforeAnyTurnBoundary(i, turnBoundaries)
93+
const preserveReasoning =
94+
enableThinking && !isBeforeAnyTurnBoundary(i, turnBoundaries)
9295
result.push(...convertInternalAssistantMessage(msg, preserveReasoning))
9396
break
9497
default:
@@ -101,9 +104,7 @@ export function anthropicMessagesToOpenAI(
101104

102105
function systemPromptToText(systemPrompt: SystemPrompt): string {
103106
if (!systemPrompt || systemPrompt.length === 0) return ''
104-
return systemPrompt
105-
.filter(Boolean)
106-
.join('\n\n')
107+
return systemPrompt.filter(Boolean).join('\n\n')
107108
}
108109

109110
/**
@@ -131,7 +132,8 @@ function convertInternalUserMessage(
131132
} else if (Array.isArray(content)) {
132133
const textParts: string[] = []
133134
const toolResults: BetaToolResultBlockParam[] = []
134-
const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> = []
135+
const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> =
136+
[]
135137

136138
for (const block of content) {
137139
if (typeof block === 'string') {
@@ -141,7 +143,9 @@ function convertInternalUserMessage(
141143
} else if (block.type === 'tool_result') {
142144
toolResults.push(block as BetaToolResultBlockParam)
143145
} else if (block.type === 'image') {
144-
const imagePart = convertImageBlockToOpenAI(block as unknown as Record<string, unknown>)
146+
const imagePart = convertImageBlockToOpenAI(
147+
block as unknown as Record<string, unknown>,
148+
)
145149
if (imagePart) {
146150
imageParts.push(imagePart)
147151
}
@@ -158,7 +162,10 @@ function convertInternalUserMessage(
158162

159163
// 如果有图片,构建多模态 content 数组
160164
if (imageParts.length > 0) {
161-
const multiContent: Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }> = []
165+
const multiContent: Array<
166+
| { type: 'text'; text: string }
167+
| { type: 'image_url'; image_url: { url: string } }
168+
> = []
162169
if (textParts.length > 0) {
163170
multiContent.push({ type: 'text', text: textParts.join('\n') })
164171
}
@@ -229,7 +236,9 @@ function convertInternalAssistantMessage(
229236
}
230237

231238
const textParts: string[] = []
232-
const toolCalls: NonNullable<ChatCompletionAssistantMessageParam['tool_calls']> = []
239+
const toolCalls: NonNullable<
240+
ChatCompletionAssistantMessageParam['tool_calls']
241+
> = []
233242
const reasoningParts: string[] = []
234243

235244
for (const block of content) {
@@ -250,7 +259,8 @@ function convertInternalAssistantMessage(
250259
})
251260
} else if (block.type === 'thinking' && preserveReasoning) {
252261
// DeepSeek thinking mode: preserve reasoning_content for tool call iterations
253-
const thinkingText = (block as unknown as Record<string, unknown>).thinking
262+
const thinkingText = (block as unknown as Record<string, unknown>)
263+
.thinking
254264
if (typeof thinkingText === 'string' && thinkingText) {
255265
reasoningParts.push(thinkingText)
256266
}
@@ -262,7 +272,9 @@ function convertInternalAssistantMessage(
262272
role: 'assistant',
263273
content: textParts.length > 0 ? textParts.join('\n') : null,
264274
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
265-
...(reasoningParts.length > 0 && { reasoning_content: reasoningParts.join('\n') }),
275+
...(reasoningParts.length > 0 && {
276+
reasoning_content: reasoningParts.join('\n'),
277+
}),
266278
}
267279

268280
return [result]

packages/@ant/model-provider/src/shared/openaiConvertTools.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,27 @@ export function anthropicToolsToOpenAI(
1616
.filter(tool => {
1717
// Only convert standard tools (skip server tools like computer_use, etc.)
1818
const toolType = (tool as unknown as { type?: string }).type
19-
return tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
19+
return (
20+
tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
21+
)
2022
})
2123
.map(tool => {
2224
// Handle the various tool shapes from Anthropic SDK
2325
const anyTool = tool as unknown as Record<string, unknown>
2426
const name = (anyTool.name as string) || ''
2527
const description = (anyTool.description as string) || ''
26-
const inputSchema = anyTool.input_schema as Record<string, unknown> | undefined
28+
const inputSchema = anyTool.input_schema as
29+
| Record<string, unknown>
30+
| undefined
2731

2832
return {
2933
type: 'function' as const,
3034
function: {
3135
name,
3236
description,
33-
parameters: sanitizeJsonSchema(inputSchema || { type: 'object', properties: {} }),
37+
parameters: sanitizeJsonSchema(
38+
inputSchema || { type: 'object', properties: {} },
39+
),
3440
},
3541
} satisfies ChatCompletionTool
3642
})
@@ -43,7 +49,9 @@ export function anthropicToolsToOpenAI(
4349
* support the `const` keyword in JSON Schema. Convert it to `enum` with a
4450
* single-element array, which is semantically equivalent.
4551
*/
46-
function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
52+
function sanitizeJsonSchema(
53+
schema: Record<string, unknown>,
54+
): Record<string, unknown> {
4755
if (!schema || typeof schema !== 'object') return schema
4856

4957
const result = { ...schema }
@@ -55,20 +63,37 @@ function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unk
5563
}
5664

5765
// Recursively process nested schemas
58-
const objectKeys = ['properties', 'definitions', '$defs', 'patternProperties'] as const
66+
const objectKeys = [
67+
'properties',
68+
'definitions',
69+
'$defs',
70+
'patternProperties',
71+
] as const
5972
for (const key of objectKeys) {
6073
const nested = result[key]
6174
if (nested && typeof nested === 'object') {
6275
const sanitized: Record<string, unknown> = {}
6376
for (const [k, v] of Object.entries(nested as Record<string, unknown>)) {
64-
sanitized[k] = v && typeof v === 'object' ? sanitizeJsonSchema(v as Record<string, unknown>) : v
77+
sanitized[k] =
78+
v && typeof v === 'object'
79+
? sanitizeJsonSchema(v as Record<string, unknown>)
80+
: v
6581
}
6682
result[key] = sanitized
6783
}
6884
}
6985

7086
// Recursively process single-schema keys
71-
const singleKeys = ['items', 'additionalProperties', 'not', 'if', 'then', 'else', 'contains', 'propertyNames'] as const
87+
const singleKeys = [
88+
'items',
89+
'additionalProperties',
90+
'not',
91+
'if',
92+
'then',
93+
'else',
94+
'contains',
95+
'propertyNames',
96+
] as const
7297
for (const key of singleKeys) {
7398
const nested = result[key]
7499
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
@@ -82,7 +107,9 @@ function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unk
82107
const nested = result[key]
83108
if (Array.isArray(nested)) {
84109
result[key] = nested.map(item =>
85-
item && typeof item === 'object' ? sanitizeJsonSchema(item as Record<string, unknown>) : item
110+
item && typeof item === 'object'
111+
? sanitizeJsonSchema(item as Record<string, unknown>)
112+
: item,
86113
)
87114
}
88115
}

packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ export async function* adaptOpenAIStreamToAnthropic(
4242
let currentContentIndex = -1
4343

4444
// Track tool_use blocks: tool_calls index → { contentIndex, id, name, arguments }
45-
const toolBlocks = new Map<number, { contentIndex: number; id: string; name: string; arguments: string }>()
45+
const toolBlocks = new Map<
46+
number,
47+
{ contentIndex: number; id: string; name: string; arguments: string }
48+
>()
4649

4750
// Track thinking block state
4851
let thinkingBlockOpen = false
@@ -197,7 +200,8 @@ export async function* adaptOpenAIStreamToAnthropic(
197200

198201
// Start new tool_use block
199202
currentContentIndex++
200-
const toolId = tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
203+
const toolId =
204+
tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
201205
const toolName = tc.function?.name || ''
202206

203207
toolBlocks.set(tcIndex, {

0 commit comments

Comments
 (0)