diff --git a/README.md b/README.md index 0115b67..e96fe6d 100644 --- a/README.md +++ b/README.md @@ -723,7 +723,7 @@ Metapi 完全自托管,所有数据(账号、令牌、路由、日志)均 ## ⭐ Star History -[![Star History Chart](https://api.star-history.com/svg?repos=cita-777/metapi&type=date&legend=top-left&v=3)](https://www.star-history.com/#cita-777/metapi&type=date&legend=top-left) +[![Star History Chart](https://api.star-history.com/svg?repos=cita-777/metapi&type=date&legend=top-left&v=4)](https://www.star-history.com/#cita-777/metapi&type=date&legend=top-left) ---
diff --git a/src/server/routes/proxy/chat.stream.test.ts b/src/server/routes/proxy/chat.stream.test.ts index 3a00403..b9e5253 100644 --- a/src/server/routes/proxy/chat.stream.test.ts +++ b/src/server/routes/proxy/chat.stream.test.ts @@ -796,6 +796,171 @@ describe('chat proxy stream behavior', () => { expect(options.headers.accept).toBe('text/event-stream'); }); + it('preserves function_call/function_call_output when /v1/responses falls back to /v1/chat/completions', async () => { + fetchModelPricingCatalogMock.mockResolvedValue({ + models: [ + { + modelName: 'upstream-gpt', + supportedEndpointTypes: ['/v1/chat/completions'], + }, + ], + groupRatio: {}, + }); + + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'chatcmpl_responses_fallback_chat', + object: 'chat.completion', + created: 1_706_000_111, + model: 'upstream-gpt', + choices: [{ + index: 0, + message: { role: 'assistant', content: 'ok' }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 9, completion_tokens: 3, total_tokens: 12 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.2', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'find readme' }], + }, + { + type: 'function_call', + call_id: 'call_abc', + name: 'Glob', + arguments: '{"pattern":"README*"}', + }, + { + type: 'function_call_output', + call_id: 'call_abc', + output: '{"matches":1}', + }, + ], + tools: [{ + type: 'function', + name: 'Glob', + description: 'Search files', + parameters: { + type: 'object', + properties: { pattern: { type: 'string' } }, + required: ['pattern'], + }, + strict: true, + }], + tool_choice: { + type: 'function', + name: 'Glob', + }, + }, + }); + + expect(response.statusCode).toBe(200); + + const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; + expect(targetUrl).toContain('/v1/chat/completions'); + + const forwarded = JSON.parse(options.body); + expect(Array.isArray(forwarded.messages)).toBe(true); + + const assistantWithToolCall = forwarded.messages.find((item: any) => + item?.role === 'assistant' + && Array.isArray(item?.tool_calls) + && item.tool_calls.length > 0, + ); + expect(assistantWithToolCall).toBeTruthy(); + expect(assistantWithToolCall.tool_calls[0].id).toBe('call_abc'); + expect(assistantWithToolCall.tool_calls[0].function?.name).toBe('Glob'); + expect(assistantWithToolCall.tool_calls[0].function?.arguments).toContain('README*'); + + const toolMessage = forwarded.messages.find((item: any) => item?.role === 'tool'); + expect(toolMessage).toBeTruthy(); + expect(toolMessage.tool_call_id).toBe('call_abc'); + expect(toolMessage.content).toContain('matches'); + + expect(forwarded.tools?.[0]?.function?.name).toBe('Glob'); + expect(forwarded.tool_choice?.function?.name).toBe('Glob'); + }); + + it('preserves function_call/function_call_output when /v1/responses falls back to /v1/messages', async () => { + fetchModelPricingCatalogMock.mockResolvedValue({ + models: [ + { + modelName: 'upstream-gpt', + supportedEndpointTypes: ['anthropic'], + }, + ], + groupRatio: {}, + }); + + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'msg_responses_fallback_messages', + type: 'message', + model: 'upstream-gpt', + content: [{ type: 'text', text: 'ok' }], + stop_reason: 'end_turn', + usage: { input_tokens: 8, output_tokens: 2, total_tokens: 10 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.2', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'find readme' }], + }, + { + type: 'function_call', + call_id: 'call_abc', + name: 'Glob', + arguments: '{"pattern":"README*"}', + }, + { + type: 'function_call_output', + call_id: 'call_abc', + output: '{"matches":1}', + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + + const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; + expect(targetUrl).toContain('/v1/messages'); + + const forwarded = JSON.parse(options.body); + expect(Array.isArray(forwarded.messages)).toBe(true); + + const assistantMessage = forwarded.messages.find((item: any) => item?.role === 'assistant'); + expect(Array.isArray(assistantMessage?.content)).toBe(true); + expect(assistantMessage.content.some((part: any) => part?.type === 'tool_use')).toBe(true); + + const userToolResultMessage = forwarded.messages.find((item: any) => + item?.role === 'user' + && Array.isArray(item?.content) + && item.content.some((part: any) => part?.type === 'tool_result'), + ); + expect(userToolResultMessage).toBeTruthy(); + expect(userToolResultMessage.content[0].tool_use_id).toBe('call_abc'); + }); + it('routes /v1/responses to /v1/messages when upstream catalog is anthropic-only', async () => { fetchModelPricingCatalogMock.mockResolvedValue({ models: [ @@ -1196,6 +1361,95 @@ describe('chat proxy stream behavior', () => { expect(response.body).toContain('\\"pattern\\":\\"README*\\"'); }); + it('uses response.function_call_arguments.done when upstream omits delta on /v1/chat/completions', async () => { + fetchModelPricingCatalogMock.mockResolvedValue({ + models: [ + { + modelName: 'upstream-gpt', + supportedEndpointTypes: ['/v1/responses'], + }, + ], + groupRatio: {}, + }); + + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('event: response.created\ndata: {"type":"response.created","response":{"id":"resp_done_only","model":"upstream-gpt","created_at":1706000000,"status":"in_progress","output":[]}}\n\n')); + controller.enqueue(encoder.encode('event: response.output_item.added\ndata: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_done_only","name":"Glob"}}\n\n')); + controller.enqueue(encoder.encode('event: response.function_call_arguments.done\ndata: {"type":"response.function_call_arguments.done","output_index":0,"call_id":"call_done_only","arguments":"{\\"pattern\\":\\"README*\\"}"}\n\n')); + controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_done_only","model":"upstream-gpt","status":"completed","usage":{"input_tokens":5,"output_tokens":3,"total_tokens":8}}}\n\n')); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); + + fetchMock.mockResolvedValue(new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'claude-haiku-4-5-20251001', + stream: true, + messages: [{ role: 'user', content: 'find readme' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('"tool_calls"'); + expect(response.body).toContain('"id":"call_done_only"'); + expect(response.body).toContain('"name":"Glob"'); + expect(response.body).toContain('\\"pattern\\":\\"README*\\"'); + }); + + it('does not duplicate tool arguments when upstream sends both delta and done on /v1/chat/completions', async () => { + fetchModelPricingCatalogMock.mockResolvedValue({ + models: [ + { + modelName: 'upstream-gpt', + supportedEndpointTypes: ['/v1/responses'], + }, + ], + groupRatio: {}, + }); + + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('event: response.created\ndata: {"type":"response.created","response":{"id":"resp_delta_done","model":"upstream-gpt","created_at":1706000000,"status":"in_progress","output":[]}}\n\n')); + controller.enqueue(encoder.encode('event: response.output_item.added\ndata: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_delta_done","name":"Glob"}}\n\n')); + controller.enqueue(encoder.encode('event: response.function_call_arguments.delta\ndata: {"type":"response.function_call_arguments.delta","output_index":0,"call_id":"call_delta_done","delta":"{\\"pattern\\":\\"README*\\"}"}\n\n')); + controller.enqueue(encoder.encode('event: response.function_call_arguments.done\ndata: {"type":"response.function_call_arguments.done","output_index":0,"call_id":"call_delta_done","arguments":"{\\"pattern\\":\\"README*\\"}"}\n\n')); + controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_delta_done","model":"upstream-gpt","status":"completed","usage":{"input_tokens":5,"output_tokens":3,"total_tokens":8}}}\n\n')); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); + + fetchMock.mockResolvedValue(new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'claude-haiku-4-5-20251001', + stream: true, + messages: [{ role: 'user', content: 'find readme' }], + }, + }); + + expect(response.statusCode).toBe(200); + const matches = response.body.match(/\\"pattern\\":\\"README\*\\"/g) || []; + expect(matches.length).toBe(1); + }); + it('preserves non-stream function_call output when /v1/chat/completions falls back to /v1/responses', async () => { fetchModelPricingCatalogMock.mockResolvedValue({ models: [ diff --git a/src/server/routes/proxy/chatFormats.ts b/src/server/routes/proxy/chatFormats.ts index ce6c10a..dddfce5 100644 --- a/src/server/routes/proxy/chatFormats.ts +++ b/src/server/routes/proxy/chatFormats.ts @@ -11,7 +11,7 @@ export type StreamTransformContext = { created: number; roleSent: boolean; doneSent: boolean; - toolCalls: Record; + toolCalls: Record; }; export type ClaudeDownstreamContext = { @@ -167,13 +167,15 @@ export function normalizeStopReason(raw: unknown): string | null { || value === 'end' || value === 'eos' || value === 'finished' + || value === 'completed' || value === 'stop_sequence' ) { return 'stop'; } if ( - value === 'max_tokens' + value === 'incomplete' + || value === 'max_tokens' || value === 'length' || value === 'max_output_tokens' || value === 'max_tokens_exceeded' @@ -835,11 +837,33 @@ export function normalizeUpstreamStreamEvent( : (isNonEmptyString((payload as any).item_id) ? (payload as any).item_id : undefined) ); const toolName = isNonEmptyString((payload as any).name) ? (payload as any).name : undefined; - const argumentsDelta = ( - typeof payload.delta === 'string' - ? payload.delta - : (typeof (payload as any).arguments === 'string' ? (payload as any).arguments : undefined) + const rawArguments = ( + type === 'response.function_call_arguments.done' + ? (typeof (payload as any).arguments === 'string' ? (payload as any).arguments : undefined) + : ( + typeof payload.delta === 'string' + ? payload.delta + : (typeof (payload as any).arguments === 'string' ? (payload as any).arguments : undefined) + ) ); + let argumentsDelta = rawArguments; + if (type === 'response.function_call_arguments.done' && typeof rawArguments === 'string') { + const existingArguments = context.toolCalls[outputIndex]?.arguments || ''; + if (existingArguments && rawArguments.startsWith(existingArguments)) { + const missingSuffix = rawArguments.slice(existingArguments.length); + argumentsDelta = missingSuffix.length > 0 ? missingSuffix : undefined; + } else if (existingArguments === rawArguments) { + argumentsDelta = undefined; + } + } + + const knownTool = context.toolCalls[outputIndex] || {}; + const shouldBackfillId = !!toolCallId && !knownTool.id; + const shouldBackfillName = !!toolName && !knownTool.name; + if (argumentsDelta === undefined && !shouldBackfillId && !shouldBackfillName) { + return {}; + } + return { toolCallDeltas: [{ index: outputIndex, @@ -1019,9 +1043,11 @@ function buildOpenAiStreamChunk( const existing = context.toolCalls[index] || {}; const id = toolDelta.id || existing.id || `call_meta_${index}`; const name = toolDelta.name || existing.name || ''; + const nextArguments = `${existing.arguments || ''}${toolDelta.argumentsDelta ?? ''}`; context.toolCalls[index] = { id, name: name || existing.name, + arguments: nextArguments, }; const fn: Record = {}; diff --git a/src/server/routes/proxy/responses.ts b/src/server/routes/proxy/responses.ts index 520f283..5acb88e 100644 --- a/src/server/routes/proxy/responses.ts +++ b/src/server/routes/proxy/responses.ts @@ -35,6 +35,10 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object'; } +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + function normalizeText(value: unknown): string { if (typeof value === 'string') return value; if (Array.isArray(value)) { @@ -53,31 +57,303 @@ function normalizeText(value: unknown): string { return ''; } +function stringifyJson(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return ''; + } +} + +function normalizeToolArguments(value: unknown): string { + if (typeof value === 'string') return value; + if (value === undefined || value === null) return ''; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value) || isRecord(value)) return stringifyJson(value); + return ''; +} + +function normalizeToolOutput(value: unknown): string { + const text = normalizeText(value).trim(); + if (text) return text; + if (typeof value === 'string') return value; + if (value === undefined || value === null) return ''; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value) || isRecord(value)) return stringifyJson(value); + return ''; +} + +type OpenAiToolCall = { + id: string; + type: 'function'; + function: { + name: string; + arguments: string; + }; +}; + +function toOpenAiToolCall(item: Record, fallbackIndex: number): OpenAiToolCall | null { + const callId = ( + asTrimmedString(item.call_id) + || asTrimmedString(item.id) + || `call_${Date.now()}_${fallbackIndex}` + ); + const name = asTrimmedString(item.name); + if (!name) return null; + + return { + id: callId, + type: 'function', + function: { + name, + arguments: normalizeToolArguments(item.arguments), + }, + }; +} + +function extractTextAndToolsFromResponsesMessage( + rawContent: unknown, + fallbackPrefix: string, +): { + text: string; + toolCalls: OpenAiToolCall[]; + toolResults: Array<{ id: string; content: string }>; +} { + const contentItems = Array.isArray(rawContent) ? rawContent : [rawContent]; + const textParts: string[] = []; + const toolCalls: OpenAiToolCall[] = []; + const toolResults: Array<{ id: string; content: string }> = []; + + for (let index = 0; index < contentItems.length; index += 1) { + const part = contentItems[index]; + if (!isRecord(part)) { + const text = normalizeText(part).trim(); + if (text) textParts.push(text); + continue; + } + + const partType = asTrimmedString(part.type).toLowerCase(); + if (partType === 'tool_use') { + const callId = asTrimmedString(part.id) || `call_${fallbackPrefix}_${index}`; + const name = asTrimmedString(part.name); + if (!name) continue; + + toolCalls.push({ + id: callId, + type: 'function', + function: { + name, + arguments: normalizeToolArguments(part.input), + }, + }); + continue; + } + + if (partType === 'tool_result') { + const id = asTrimmedString(part.tool_use_id) || asTrimmedString(part.id); + if (!id) continue; + toolResults.push({ + id, + content: normalizeToolOutput(part.content), + }); + continue; + } + + const text = normalizeText(part).trim(); + if (text) textParts.push(text); + } + + return { + text: textParts.join('\n').trim(), + toolCalls, + toolResults, + }; +} + +function convertResponsesToolsToOpenAi(rawTools: unknown): unknown { + if (!Array.isArray(rawTools)) return rawTools; + + const converted = rawTools + .map((item) => { + if (!isRecord(item)) return item; + const type = asTrimmedString(item.type).toLowerCase(); + + if (type !== 'function') return item; + + if (isRecord(item.function) && asTrimmedString(item.function.name)) { + return item; + } + + const name = asTrimmedString(item.name); + if (!name) return item; + + const fn: Record = { name }; + const description = asTrimmedString(item.description); + if (description) fn.description = description; + if (item.parameters !== undefined) fn.parameters = item.parameters; + if (item.strict !== undefined) fn.strict = item.strict; + + return { + type: 'function', + function: fn, + }; + }); + + return converted; +} + +function convertResponsesToolChoiceToOpenAi(rawToolChoice: unknown): unknown { + if (rawToolChoice === undefined) return undefined; + if (typeof rawToolChoice === 'string') return rawToolChoice; + if (!isRecord(rawToolChoice)) return rawToolChoice; + + const type = asTrimmedString(rawToolChoice.type).toLowerCase(); + if (type === 'function') { + if (isRecord(rawToolChoice.function) && asTrimmedString(rawToolChoice.function.name)) { + return rawToolChoice; + } + + const name = asTrimmedString(rawToolChoice.name); + if (!name) return 'required'; + return { + type: 'function', + function: { name }, + }; + } + + if (type === 'auto' || type === 'none' || type === 'required') { + return type; + } + + return rawToolChoice; +} + function convertResponsesBodyToOpenAiBody( body: Record, modelName: string, stream: boolean, ): Record { - const messages: Array<{ role: string; content: string }> = []; + const messages: Array> = []; const input = body.input; + let functionCallIndex = 0; + let pendingToolCalls: OpenAiToolCall[] = []; + + const flushPendingToolCalls = () => { + if (pendingToolCalls.length <= 0) return; + messages.push({ + role: 'assistant', + content: '', + tool_calls: pendingToolCalls, + }); + pendingToolCalls = []; + }; + + const pushToolOutputMessage = (callIdRaw: unknown, outputRaw: unknown) => { + const toolCallId = asTrimmedString(callIdRaw); + if (!toolCallId) return; + const content = normalizeToolOutput(outputRaw); + messages.push({ + role: 'tool', + tool_call_id: toolCallId, + content, + }); + }; + + const pushUserMessage = (content: string) => { + if (!content) return; + messages.push({ role: 'user', content }); + }; + + const processInputItem = (item: unknown) => { + if (typeof item === 'string') { + flushPendingToolCalls(); + const text = item.trim(); + if (text) pushUserMessage(text); + return; + } + + if (!isRecord(item)) return; + + const itemType = asTrimmedString(item.type).toLowerCase(); + if (itemType === 'function_call') { + const toolCall = toOpenAiToolCall(item, functionCallIndex); + functionCallIndex += 1; + if (!toolCall) return; + pendingToolCalls.push(toolCall); + return; + } + + if (itemType === 'function_call_output') { + flushPendingToolCalls(); + pushToolOutputMessage(item.call_id ?? item.id, item.output ?? item.content); + return; + } + + flushPendingToolCalls(); + + const role = asTrimmedString(item.role).toLowerCase() || 'user'; + const messageContent = item.content ?? item.input ?? item; + const parsed = extractTextAndToolsFromResponsesMessage( + messageContent, + `${Date.now()}_${messages.length}`, + ); + + if (role === 'assistant') { + if (!parsed.text && parsed.toolCalls.length <= 0) return; + + const assistantMessage: Record = { + role: 'assistant', + content: parsed.text, + }; + if (parsed.toolCalls.length > 0) { + assistantMessage.tool_calls = parsed.toolCalls; + } + messages.push(assistantMessage); + return; + } + + const normalizedRole = role === 'system' || role === 'developer' + ? 'system' + : role === 'tool' + ? 'tool' + : 'user'; + + if (normalizedRole === 'tool') { + pushToolOutputMessage(item.tool_call_id ?? item.call_id ?? item.id, messageContent); + return; + } + + if (parsed.text) { + messages.push({ + role: normalizedRole, + content: parsed.text, + }); + } + + for (const toolResult of parsed.toolResults) { + messages.push({ + role: 'tool', + tool_call_id: toolResult.id, + content: toolResult.content, + }); + } + }; if (typeof input === 'string') { const text = input.trim(); - if (text) messages.push({ role: 'user', content: text }); + if (text) { + messages.push({ role: 'user', content: text }); + } } else if (Array.isArray(input)) { for (const item of input) { - if (!isRecord(item)) continue; - const role = typeof item.role === 'string' ? item.role : 'user'; - const text = normalizeText(item.content ?? item).trim(); - if (!text) continue; - messages.push({ role: role === 'assistant' ? 'assistant' : (role === 'system' ? 'system' : 'user'), content: text }); + processInputItem(item); } } else if (isRecord(input)) { - const text = normalizeText(input).trim(); - if (text) messages.push({ role: 'user', content: text }); + processInputItem(input); } + flushPendingToolCalls(); - const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : ''; + const instructions = asTrimmedString(body.instructions); if (instructions) { messages.unshift({ role: 'system', content: instructions }); } @@ -97,8 +373,9 @@ function convertResponsesBodyToOpenAiBody( if (typeof body.max_output_tokens === 'number' && Number.isFinite(body.max_output_tokens)) { payload.max_tokens = body.max_output_tokens; } - if (body.tools !== undefined) payload.tools = body.tools; - if (body.tool_choice !== undefined) payload.tool_choice = body.tool_choice; + if (body.parallel_tool_calls !== undefined) payload.parallel_tool_calls = body.parallel_tool_calls; + if (body.tools !== undefined) payload.tools = convertResponsesToolsToOpenAi(body.tools); + if (body.tool_choice !== undefined) payload.tool_choice = convertResponsesToolChoiceToOpenAi(body.tool_choice); return payload; }