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:
2026-08-04 19:26:48 +08:00
parent b2c9b4f6d9
commit fe8dcb40ca
22 changed files with 2098 additions and 309 deletions
+53 -7
View File
@@ -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),
},
});
}