feat: 增强测试用例以支持函数调用和输出,优化工具参数处理逻辑

This commit is contained in:
ciat-777
2026-03-02 23:11:38 +08:00
parent 555f6e15ab
commit fed7ea7023
4 changed files with 576 additions and 19 deletions
+1 -1
View File
@@ -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)
---
<div align="center">
+254
View File
@@ -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<Uint8Array>({
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<Uint8Array>({
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: [
+29 -3
View File
@@ -11,7 +11,7 @@ export type StreamTransformContext = {
created: number;
roleSent: boolean;
doneSent: boolean;
toolCalls: Record<number, { id?: string; name?: string }>;
toolCalls: Record<number, { id?: string; name?: string; arguments?: string }>;
};
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 = (
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<string, unknown> = {};
+289 -12
View File
@@ -35,6 +35,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>, 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<string, unknown> = { 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<string, unknown>,
modelName: string,
stream: boolean,
): Record<string, unknown> {
const messages: Array<{ role: string; content: string }> = [];
const messages: Array<Record<string, unknown>> = [];
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<string, unknown> = {
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;
}