feat(openai): 完善 Chat 与 Responses 参数转发
原生 Chat/Responses 改为透明转发,保留标准工具结构并保护调用方显式参数。补齐 Responses 到 Chat 的兼容转换、协议路由边界、完整响应和流式事件,并同步更新 Swagger、回归测试与真实验收脚本。 验证: - cd apps/api && env -u AI_GATEWAY_TEST_DATABASE_URL go test ./... -count=1 - pnpm openapi - pnpm lint - pnpm test - pnpm build - gofmt -l 无输出 - git diff --check 通过 风险: - Chat 回退无法等价表达的 Responses 原生能力现在会返回 unsupported_response_parameter - 真实供应商 E2E 因本地没有已启用的平台模型候选而未完成
This commit is contained in:
@@ -120,6 +120,10 @@ function functionCall(response) {
|
||||
return (response.output || []).find((item) => item.type === 'function_call');
|
||||
}
|
||||
|
||||
function customToolCall(response) {
|
||||
return (response.output || []).find((item) => item.type === 'custom_tool_call');
|
||||
}
|
||||
|
||||
async function getTask(token, taskId) {
|
||||
assert(taskId, 'response is missing X-Gateway-Task-Id');
|
||||
const response = await fetch(`${baseURL}/api/v1/tasks/${taskId}`, {
|
||||
@@ -182,8 +186,8 @@ for (const expected of modelCases) {
|
||||
const toolOutput = `TOOL-${nonce}`;
|
||||
const normalizedModel = expected.model.toLowerCase();
|
||||
const requiresNonThinkingToolMode = normalizedModel.startsWith('qwen3.7-') || normalizedModel.includes('deepseek-v4-pro');
|
||||
const toolReasoning = requiresNonThinkingToolMode ? { reasoning: { effort: 'none' } } : {};
|
||||
const forcedToolChoice = 'required';
|
||||
const toolReasoning = requiresNonThinkingToolMode ? { reasoning: { effort: 'none' } } : {};
|
||||
const forcedToolChoice = 'required';
|
||||
const toolFirst = await requestStream(token, '/v1/responses', {
|
||||
model: expected.model,
|
||||
input: '调用 lookup_verification_code 获取校验结果。',
|
||||
@@ -194,9 +198,9 @@ for (const expected of modelCases) {
|
||||
parameters: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'], additionalProperties: false },
|
||||
strict: true,
|
||||
}],
|
||||
tool_choice: forcedToolChoice,
|
||||
tool_choice: forcedToolChoice,
|
||||
parallel_tool_calls: true,
|
||||
...toolReasoning,
|
||||
...toolReasoning,
|
||||
store: true,
|
||||
});
|
||||
const call = functionCall(toolFirst.body);
|
||||
@@ -207,25 +211,58 @@ for (const expected of modelCases) {
|
||||
model: expected.model,
|
||||
previous_response_id: toolFirst.body.id,
|
||||
input: [{ type: 'function_call_output', call_id: call.call_id, output: JSON.stringify({ verification: toolOutput }) }],
|
||||
...toolReasoning,
|
||||
...toolReasoning,
|
||||
store: true,
|
||||
});
|
||||
assert(responseText(toolSecond.body).includes(toolOutput), `${expected.model} did not consume function_call_output`);
|
||||
|
||||
const customOutput = `CUSTOM-${nonce}`;
|
||||
const customFirst = await requestStream(token, '/v1/responses', {
|
||||
model: expected.model,
|
||||
input: '调用 echo_verification_text,并把当前校验码作为纯文本输入。',
|
||||
tools: [{
|
||||
type: 'custom',
|
||||
name: 'echo_verification_text',
|
||||
description: '接收任意纯文本校验内容',
|
||||
format: { type: 'text' },
|
||||
}],
|
||||
tool_choice: { type: 'custom', name: 'echo_verification_text' },
|
||||
...toolReasoning,
|
||||
store: true,
|
||||
});
|
||||
const customCall = customToolCall(customFirst.body);
|
||||
assert(customCall?.call_id, `${expected.model} custom tool response is missing call_id`);
|
||||
assert(customFirst.events.some((event) => event.event === 'response.custom_tool_call_input.delta'), `${expected.model} stream is missing custom tool input delta`);
|
||||
|
||||
const customSecond = await requestStream(token, '/v1/responses', {
|
||||
model: expected.model,
|
||||
previous_response_id: customFirst.body.id,
|
||||
input: [{ type: 'custom_tool_call_output', call_id: customCall.call_id, output: customOutput }],
|
||||
...toolReasoning,
|
||||
store: true,
|
||||
});
|
||||
assert(responseText(customSecond.body).includes(customOutput), `${expected.model} did not consume custom_tool_call_output`);
|
||||
|
||||
const auditedTasks = await Promise.all([
|
||||
getTask(token, first.taskId),
|
||||
getTask(token, second.taskId),
|
||||
getTask(token, toolFirst.taskId),
|
||||
getTask(token, toolSecond.taskId),
|
||||
getTask(token, customFirst.taskId),
|
||||
getTask(token, customSecond.taskId),
|
||||
]);
|
||||
const ordinaryFirstAudit = auditSummary(auditedTasks[0], expected);
|
||||
const ordinarySecondAudit = auditSummary(auditedTasks[1], expected);
|
||||
const toolFirstAudit = auditSummary(auditedTasks[2], expected);
|
||||
const toolSecondAudit = auditSummary(auditedTasks[3], expected);
|
||||
const customFirstAudit = auditSummary(auditedTasks[4], expected);
|
||||
const customSecondAudit = auditSummary(auditedTasks[5], expected);
|
||||
assert(ordinarySecondAudit.parentResponseId === first.body.id, `${expected.model} ordinary parent response id mismatch`);
|
||||
assert(ordinarySecondAudit.chainDepth === 1, `${expected.model} ordinary chain depth mismatch`);
|
||||
assert(toolSecondAudit.parentResponseId === toolFirst.body.id, `${expected.model} tool parent response id mismatch`);
|
||||
assert(toolSecondAudit.chainDepth === 1, `${expected.model} tool chain depth mismatch`);
|
||||
assert(customSecondAudit.parentResponseId === customFirst.body.id, `${expected.model} custom tool parent response id mismatch`);
|
||||
assert(customSecondAudit.chainDepth === 1, `${expected.model} custom tool chain depth mismatch`);
|
||||
results.push({
|
||||
model: expected.model,
|
||||
ordinaryConversation: {
|
||||
@@ -238,8 +275,17 @@ for (const expected of modelCases) {
|
||||
callId: call.call_id,
|
||||
first: toolFirstAudit,
|
||||
second: toolSecondAudit,
|
||||
firstEventTypes: toolFirst.events.map((event) => event.event),
|
||||
secondEventTypes: toolSecond.events.map((event) => event.event),
|
||||
firstEventTypes: toolFirst.events.map((event) => event.event),
|
||||
secondEventTypes: toolSecond.events.map((event) => event.event),
|
||||
},
|
||||
customToolCalling: {
|
||||
passed: true,
|
||||
callId: customCall.call_id,
|
||||
input: customCall.input,
|
||||
first: customFirstAudit,
|
||||
second: customSecondAudit,
|
||||
firstEventTypes: customFirst.events.map((event) => event.event),
|
||||
secondEventTypes: customSecond.events.map((event) => event.event),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user