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
+74 -19
View File
@@ -6450,7 +6450,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "/api/v1/chat/completions 同步执行:stream=true 返回 text/event-stream SSEstream=false 或未传返回兼容 JSON;该接口忽略 X-Async。", "description": "/api/v1/chat/completions 同步执行:除 Gateway 路由字段外透明转发当前及未来 OpenAI 字段与嵌套结构,完整保留 function/custom tools、tool_choice、tool_calls 和旧版 function_callstream=true 返回 text/event-stream SSEstream=false 或未传返回兼容 JSON;该接口忽略 X-Async。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -8566,7 +8566,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "公开 OpenAI-compatible Responses 入口。模型声明 openai_responses 时原生转发,否则使用 Chat Completions 转换;store 缺省为 true。previous_response_id 严格绑定首次成功的平台模型和上游协议,链路不可用时不跨平台续接未提供 previous_response_id 时调用方管理完整状态Gateway 以本轮 input/messages 为准且不追加本地历史。", "description": "公开 OpenAI-compatible Responses 入口。模型声明 openai_responses 时除 Gateway 路由字段外透明转发当前及未来 OpenAI 字段;否则仅对具有等价 Chat Completions 语义的请求执行转换。background、conversation、prompt template、context management、max_tool_calls、自动 truncation、reasoning summary、内置工具、MCP 和 namespace 等原生专属能力不会静默丢弃:路由会排除 Chat 候选,固定 Chat 链路则返回带参数路径的 unsupported_response_parameter。store 缺省为 true。previous_response_id 严格绑定首次成功的平台模型和上游协议,链路不可用时不跨平台续接未提供 previous_response_id 时调用方历史权威Gateway 不追加本地历史。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -8580,7 +8580,7 @@
"summary": "创建 OpenAI Responses", "summary": "创建 OpenAI Responses",
"parameters": [ "parameters": [
{ {
"description": "Responses 请求;Chat 回退支持自定义 function tools", "description": "Responses 请求;Chat 回退支持 function/custom tools 及所有可等价能力",
"name": "input", "name": "input",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -8597,7 +8597,7 @@
} }
}, },
"400": { "400": {
"description": "invalid_previous_response_id / unsupported_response_tool / unsupported_response_parameter", "description": "invalid_previous_response_id / unsupported_response_parameter",
"schema": { "schema": {
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope" "$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
} }
@@ -10810,6 +10810,7 @@
"type": "integer", "type": "integer",
"example": 0 "example": 0
}, },
"logprobs": {},
"message": { "message": {
"$ref": "#/definitions/httpapi.ChatCompletionChoiceMessage" "$ref": "#/definitions/httpapi.ChatCompletionChoiceMessage"
} }
@@ -10818,14 +10819,18 @@
"httpapi.ChatCompletionChoiceMessage": { "httpapi.ChatCompletionChoiceMessage": {
"type": "object", "type": "object",
"properties": { "properties": {
"content": { "annotations": {},
"type": "string", "audio": {},
"example": "Hello" "content": {},
"function_call": {},
"refusal": {
"type": "string"
}, },
"role": { "role": {
"type": "string", "type": "string",
"example": "assistant" "example": "assistant"
} },
"tool_calls": {}
} }
}, },
"httpapi.ChatCompletionCompatibleResponse": { "httpapi.ChatCompletionCompatibleResponse": {
@@ -10853,6 +10858,12 @@
"type": "string", "type": "string",
"example": "chat.completion" "example": "chat.completion"
}, },
"service_tier": {
"type": "string"
},
"system_fingerprint": {
"type": "string"
},
"usage": { "usage": {
"$ref": "#/definitions/httpapi.ChatCompletionUsage" "$ref": "#/definitions/httpapi.ChatCompletionUsage"
} }
@@ -11045,11 +11056,15 @@
"httpapi.ChatMessage": { "httpapi.ChatMessage": {
"type": "object", "type": "object",
"properties": { "properties": {
"audio": {},
"content": {}, "content": {},
"function_call": {}, "function_call": {},
"name": { "name": {
"type": "string" "type": "string"
}, },
"refusal": {
"type": "string"
},
"role": { "role": {
"type": "string", "type": "string",
"example": "user" "example": "user"
@@ -12312,18 +12327,35 @@
"httpapi.ResponsesCompatibleResponse": { "httpapi.ResponsesCompatibleResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
"background": {
"type": "boolean"
},
"completed_at": {
"type": "integer"
},
"conversation": {},
"created_at": { "created_at": {
"type": "integer", "type": "integer",
"example": 1710000000 "example": 1710000000
}, },
"error": {},
"id": { "id": {
"type": "string", "type": "string",
"example": "resp_0123456789abcdef0123456789abcdef" "example": "resp_0123456789abcdef0123456789abcdef"
}, },
"incomplete_details": {},
"instructions": {},
"max_output_tokens": {},
"max_tool_calls": {},
"metadata": {
"type": "object",
"additionalProperties": true
},
"model": { "model": {
"type": "string", "type": "string",
"example": "Doubao Seed 2.0 Pro" "example": "Doubao Seed 2.0 Pro"
}, },
"moderation": {},
"object": { "object": {
"type": "string", "type": "string",
"example": "response" "example": "response"
@@ -12339,18 +12371,47 @@
"type": "string", "type": "string",
"example": "Hello" "example": "Hello"
}, },
"previous_response_id": { "parallel_tool_calls": {
"type": "string", "type": "boolean"
"example": "resp_abcdef0123456789abcdef0123456789"
}, },
"previous_response_id": {},
"prompt": {},
"prompt_cache_key": {},
"prompt_cache_options": {},
"prompt_cache_retention": {},
"reasoning": {},
"safety_identifier": {},
"service_tier": {},
"status": { "status": {
"type": "string", "type": "string",
"enum": [
"queued",
"in_progress",
"completed",
"incomplete",
"failed",
"cancelled"
],
"example": "completed" "example": "completed"
}, },
"temperature": {},
"text": {},
"tool_choice": {},
"tools": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
},
"top_logprobs": {},
"top_p": {},
"truncation": {},
"usage": { "usage": {
"type": "object", "type": "object",
"additionalProperties": true "additionalProperties": true
} },
"user": {}
} }
}, },
"httpapi.ResponsesRequest": { "httpapi.ResponsesRequest": {
@@ -12359,13 +12420,7 @@
"background": { "background": {
"type": "boolean" "type": "boolean"
}, },
"context_management": { "context_management": {},
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
},
"conversation": {}, "conversation": {},
"include": { "include": {
"type": "array", "type": "array",
+69 -18
View File
@@ -154,17 +154,22 @@ definitions:
index: index:
example: 0 example: 0
type: integer type: integer
logprobs: {}
message: message:
$ref: '#/definitions/httpapi.ChatCompletionChoiceMessage' $ref: '#/definitions/httpapi.ChatCompletionChoiceMessage'
type: object type: object
httpapi.ChatCompletionChoiceMessage: httpapi.ChatCompletionChoiceMessage:
properties: properties:
content: annotations: {}
example: Hello audio: {}
content: {}
function_call: {}
refusal:
type: string type: string
role: role:
example: assistant example: assistant
type: string type: string
tool_calls: {}
type: object type: object
httpapi.ChatCompletionCompatibleResponse: httpapi.ChatCompletionCompatibleResponse:
properties: properties:
@@ -184,6 +189,10 @@ definitions:
object: object:
example: chat.completion example: chat.completion
type: string type: string
service_tier:
type: string
system_fingerprint:
type: string
usage: usage:
$ref: '#/definitions/httpapi.ChatCompletionUsage' $ref: '#/definitions/httpapi.ChatCompletionUsage'
type: object type: object
@@ -323,10 +332,13 @@ definitions:
type: object type: object
httpapi.ChatMessage: httpapi.ChatMessage:
properties: properties:
audio: {}
content: {} content: {}
function_call: {} function_call: {}
name: name:
type: string type: string
refusal:
type: string
role: role:
example: user example: user
type: string type: string
@@ -1195,15 +1207,29 @@ definitions:
type: object type: object
httpapi.ResponsesCompatibleResponse: httpapi.ResponsesCompatibleResponse:
properties: properties:
background:
type: boolean
completed_at:
type: integer
conversation: {}
created_at: created_at:
example: 1710000000 example: 1710000000
type: integer type: integer
error: {}
id: id:
example: resp_0123456789abcdef0123456789abcdef example: resp_0123456789abcdef0123456789abcdef
type: string type: string
incomplete_details: {}
instructions: {}
max_output_tokens: {}
max_tool_calls: {}
metadata:
additionalProperties: true
type: object
model: model:
example: Doubao Seed 2.0 Pro example: Doubao Seed 2.0 Pro
type: string type: string
moderation: {}
object: object:
example: response example: response
type: string type: string
@@ -1215,25 +1241,47 @@ definitions:
output_text: output_text:
example: Hello example: Hello
type: string type: string
previous_response_id: parallel_tool_calls:
example: resp_abcdef0123456789abcdef0123456789 type: boolean
type: string previous_response_id: {}
prompt: {}
prompt_cache_key: {}
prompt_cache_options: {}
prompt_cache_retention: {}
reasoning: {}
safety_identifier: {}
service_tier: {}
status: status:
enum:
- queued
- in_progress
- completed
- incomplete
- failed
- cancelled
example: completed example: completed
type: string type: string
temperature: {}
text: {}
tool_choice: {}
tools:
items:
additionalProperties: true
type: object
type: array
top_logprobs: {}
top_p: {}
truncation: {}
usage: usage:
additionalProperties: true additionalProperties: true
type: object type: object
user: {}
type: object type: object
httpapi.ResponsesRequest: httpapi.ResponsesRequest:
properties: properties:
background: background:
type: boolean type: boolean
context_management: context_management: {}
items:
additionalProperties: true
type: object
type: array
conversation: {} conversation: {}
include: include:
items: items:
@@ -8559,8 +8607,9 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: /api/v1/chat/completions 同步执行:stream=true 返回 text/event-stream description: /api/v1/chat/completions 同步执行:除 Gateway 路由字段外透明转发当前及未来 OpenAI 字段与嵌套结构,完整保留
SSEstream=false 或未传返回兼容 JSON;该接口忽略 X-Async。 function/custom tools、tool_choice、tool_calls 和旧版 function_callstream=true
返回 text/event-stream SSEstream=false 或未传返回兼容 JSON;该接口忽略 X-Async。
parameters: parameters:
- description: 该接口忽略此参数 - description: 该接口忽略此参数
in: header in: header
@@ -9926,11 +9975,14 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: 公开 OpenAI-compatible Responses 入口。模型声明 openai_responses 时原生转发,否则使用 description: 公开 OpenAI-compatible Responses 入口。模型声明 openai_responses 时除 Gateway
Chat Completions 转换;store 缺省为 true。previous_response_id 严格绑定首次成功的平台模型和上游协议,链路不可用时不跨平台续接。未提供 路由字段外透明转发当前及未来 OpenAI 字段;否则仅对具有等价 Chat Completions 语义的请求执行转换。background、conversation、prompt
previous_response_id 时由调用方管理完整状态,Gateway 以本轮 input/messages 为准且不追加本地历史。 template、context management、max_tool_calls、自动 truncation、reasoning summary、内置工具、MCP
和 namespace 等原生专属能力不会静默丢弃:路由会排除 Chat 候选,固定 Chat 链路则返回带参数路径的 unsupported_response_parameter。store
缺省为 true。previous_response_id 严格绑定首次成功的平台模型和上游协议,链路不可用时不跨平台续接;未提供 previous_response_id
时调用方历史权威,Gateway 不追加本地历史。
parameters: parameters:
- description: Responses 请求;Chat 回退支持自定义 function tools - description: Responses 请求;Chat 回退支持 function/custom tools 及所有可等价能力
in: body in: body
name: input name: input
required: true required: true
@@ -9945,8 +9997,7 @@ paths:
schema: schema:
$ref: '#/definitions/httpapi.ResponsesCompatibleResponse' $ref: '#/definitions/httpapi.ResponsesCompatibleResponse'
"400": "400":
description: invalid_previous_response_id / unsupported_response_tool / description: invalid_previous_response_id / unsupported_response_parameter
unsupported_response_parameter
schema: schema:
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope' $ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"401": "401":
+81 -4
View File
@@ -50,26 +50,49 @@ func ValidateOpenAIReasoningEffort(value any) error {
} }
func applyOpenAIChatReasoningParams(body map[string]any, candidate store.RuntimeModelCandidate) { func applyOpenAIChatReasoningParams(body map[string]any, candidate store.RuntimeModelCandidate) {
_ = applyOpenAIChatReasoningParamsWithSource(body, candidate, nil)
}
func applyOpenAIChatReasoningParamsWithSource(body map[string]any, candidate store.RuntimeModelCandidate, original map[string]any) error {
effort := normalizedReasoningString(body["reasoning_effort"]) effort := normalizedReasoningString(body["reasoning_effort"])
_, explicitEffort := original["reasoning_effort"]
_, explicitTemperature := original["temperature"]
model := chatReasoningModelName(body, candidate) model := chatReasoningModelName(body, candidate)
if isAliyunBailianOpenAI(candidate) && isAliyunQwen38MaxPreview(model) { if isAliyunBailianOpenAI(candidate) && isAliyunQwen38MaxPreview(model) {
if explicitEffort && qwen38MaxPreviewReasoningEffort(effort) != effort {
return explicitParameterAdaptationError("reasoning_effort", "the selected upstream only supports low, medium, or xhigh without changing the requested reasoning semantics")
}
if temperature, ok := finiteFloatFromAny(body["temperature"]); explicitTemperature && ok && temperature < 0.6 {
return explicitParameterAdaptationError("temperature", "the selected upstream requires temperature >= 0.6")
}
applyAliyunQwen38Reasoning(body, effort) applyAliyunQwen38Reasoning(body, effort)
if temperature, ok := finiteFloatFromAny(body["temperature"]); ok && temperature < 0.6 { if temperature, ok := finiteFloatFromAny(body["temperature"]); ok && temperature < 0.6 {
body["temperature"] = 0.6 body["temperature"] = 0.6
} }
return return nil
} }
if effort == "" || !isOpenAIReasoningEffort(effort) { if effort == "" || !isOpenAIReasoningEffort(effort) {
return return nil
} }
resolved, state := resolveCandidateReasoningEffort(effort, candidate) resolved, state := resolveCandidateReasoningEffort(effort, candidate)
if state == reasoningCapabilityUnsupported { if state == reasoningCapabilityUnsupported {
if explicitEffort {
return explicitParameterAdaptationError("reasoning_effort", "the selected upstream does not support reasoning_effort")
}
delete(body, "reasoning_effort") delete(body, "reasoning_effort")
return return nil
} }
if resolved != "" { if resolved != "" {
if explicitEffort && resolved != effort {
return explicitParameterAdaptationError("reasoning_effort", "the selected upstream does not support the requested reasoning effort exactly")
}
effort = resolved effort = resolved
} }
if explicitEffort {
if err := validateExplicitProviderReasoningEffort(effort, candidate, model); err != nil {
return err
}
}
body["reasoning_effort"] = effort body["reasoning_effort"] = effort
switch { switch {
@@ -82,9 +105,42 @@ func applyOpenAIChatReasoningParams(body map[string]any, candidate store.Runtime
case isVolcesOpenAI(candidate): case isVolcesOpenAI(candidate):
applyVolcesReasoning(body, candidate, effort) applyVolcesReasoning(body, candidate, effort)
} }
return nil
}
func validateExplicitProviderReasoningEffort(effort string, candidate store.RuntimeModelCandidate, model string) error {
if effort == "none" {
return nil
}
unsupported := func() error {
return explicitParameterAdaptationError("reasoning_effort", "the selected upstream cannot preserve the requested reasoning effort exactly")
}
switch {
case isAliyunBailianOpenAI(candidate):
if !isAliyunHighMaxReasoningModel(model) || highMaxReasoningEffort(effort) != effort {
return unsupported()
}
case isDeepSeekOpenAI(candidate):
if highMaxReasoningEffort(effort) != effort {
return unsupported()
}
case isZhipuOpenAI(candidate):
if !isZhipuReasoningEffortModel(model) || zhipuReasoningEffort(effort) != effort {
return unsupported()
}
case isVolcesOpenAI(candidate):
if !isVolcesReasoningEffortModel(model) || volcesChatReasoningEffort(effort) != effort {
return unsupported()
}
}
return nil
} }
func applyOpenAIResponsesReasoningParams(body map[string]any, candidate store.RuntimeModelCandidate) { func applyOpenAIResponsesReasoningParams(body map[string]any, candidate store.RuntimeModelCandidate) {
_ = applyOpenAIResponsesReasoningParamsWithSource(body, candidate, nil)
}
func applyOpenAIResponsesReasoningParamsWithSource(body map[string]any, candidate store.RuntimeModelCandidate, original map[string]any) error {
reasoning, _ := body["reasoning"].(map[string]any) reasoning, _ := body["reasoning"].(map[string]any)
if reasoning != nil { if reasoning != nil {
reasoning = cloneBody(reasoning) reasoning = cloneBody(reasoning)
@@ -93,6 +149,9 @@ func applyOpenAIResponsesReasoningParams(body map[string]any, candidate store.Ru
if reasoning != nil { if reasoning != nil {
effort = normalizedReasoningString(reasoning["effort"]) effort = normalizedReasoningString(reasoning["effort"])
} }
originalReasoning, _ := original["reasoning"].(map[string]any)
_, explicitEffort := originalReasoning["effort"]
_, explicitTemperature := original["temperature"]
model := chatReasoningModelName(body, candidate) model := chatReasoningModelName(body, candidate)
qwen38 := isAliyunBailianOpenAI(candidate) && isAliyunQwen38MaxPreview(model) qwen38 := isAliyunBailianOpenAI(candidate) && isAliyunQwen38MaxPreview(model)
if qwen38 { if qwen38 {
@@ -101,13 +160,23 @@ func applyOpenAIResponsesReasoningParams(body map[string]any, candidate store.Ru
effort = "low" effort = "low"
} }
} else { } else {
effort = qwen38MaxPreviewReasoningEffort(effort) mapped := qwen38MaxPreviewReasoningEffort(effort)
if explicitEffort && mapped != effort {
return explicitParameterAdaptationError("reasoning.effort", "the selected upstream only supports low, medium, or xhigh without changing the requested reasoning semantics")
}
effort = mapped
} }
} else if effort != "" && isOpenAIReasoningEffort(effort) { } else if effort != "" && isOpenAIReasoningEffort(effort) {
resolved, state := resolveCandidateReasoningEffort(effort, candidate) resolved, state := resolveCandidateReasoningEffort(effort, candidate)
if state == reasoningCapabilityUnsupported { if state == reasoningCapabilityUnsupported {
if explicitEffort {
return explicitParameterAdaptationError("reasoning.effort", "the selected upstream does not support reasoning.effort")
}
effort = "" effort = ""
} else if resolved != "" { } else if resolved != "" {
if explicitEffort && resolved != effort {
return explicitParameterAdaptationError("reasoning.effort", "the selected upstream does not support the requested reasoning effort exactly")
}
effort = resolved effort = resolved
} }
} }
@@ -129,9 +198,17 @@ func applyOpenAIResponsesReasoningParams(body map[string]any, candidate store.Ru
delete(body, "enable_thinking") delete(body, "enable_thinking")
if qwen38 { if qwen38 {
if temperature, ok := finiteFloatFromAny(body["temperature"]); ok && temperature < 0.6 { if temperature, ok := finiteFloatFromAny(body["temperature"]); ok && temperature < 0.6 {
if explicitTemperature {
return explicitParameterAdaptationError("temperature", "the selected upstream requires temperature >= 0.6")
}
body["temperature"] = 0.6 body["temperature"] = 0.6
} }
} }
return nil
}
func explicitParameterAdaptationError(param string, message string) error {
return &ClientError{Code: "invalid_parameter", Message: message, Param: param, StatusCode: 400, Retryable: false}
} }
func applyAliyunQwen38Reasoning(body map[string]any, effort string) { func applyAliyunQwen38Reasoning(body map[string]any, effort string) {
+17 -14
View File
@@ -1012,13 +1012,11 @@ func TestOpenAIClientChatRequestNormalizesToolContext(t *testing.T) {
} }
assistant, _ := messages[0].(map[string]any) assistant, _ := messages[0].(map[string]any)
if _, ok := assistant["functionCall"]; ok { if _, ok := assistant["functionCall"]; ok {
t.Fatalf("functionCall should be converted away: %+v", assistant) t.Fatalf("functionCall alias should be converted away: %+v", assistant)
} }
toolCalls, _ := assistant["tool_calls"].([]any) function, _ := assistant["function_call"].(map[string]any)
toolCall, _ := toolCalls[0].(map[string]any)
function, _ := toolCall["function"].(map[string]any)
if function["name"] != "lookup" || function["arguments"] != `{"q":"weather"}` { if function["name"] != "lookup" || function["arguments"] != `{"q":"weather"}` {
t.Fatalf("unexpected normalized tool call: %+v", assistant) t.Fatalf("unexpected normalized legacy function call: %+v", assistant)
} }
toolMessage, _ := messages[1].(map[string]any) toolMessage, _ := messages[1].(map[string]any)
if toolMessage["tool_call_id"] != "call_0" || toolMessage["toolCallId"] != nil { if toolMessage["tool_call_id"] != "call_0" || toolMessage["toolCallId"] != nil {
@@ -1116,14 +1114,18 @@ func TestOpenAIClientChatResponseNormalizesToolCallFormats(t *testing.T) {
if message["content"] != "calling tools" { if message["content"] != "calling tools" {
t.Fatalf("tool_use block should be removed from content: %+v", message) t.Fatalf("tool_use block should be removed from content: %+v", message)
} }
for _, key := range []string{"toolCalls", "function_call"} { for _, key := range []string{"toolCalls"} {
if _, ok := message[key]; ok { if _, ok := message[key]; ok {
t.Fatalf("%s should be converted away: %+v", key, message) t.Fatalf("%s should be converted away: %+v", key, message)
} }
} }
legacyFunction, _ := message["function_call"].(map[string]any)
if legacyFunction["name"] != "legacy_lookup" || legacyFunction["arguments"] != "{\"city\":\"NYC\"}" {
t.Fatalf("canonical legacy function_call should be preserved: %+v", message)
}
toolCalls, _ := message["tool_calls"].([]any) toolCalls, _ := message["tool_calls"].([]any)
if len(toolCalls) != 3 { if len(toolCalls) != 2 {
t.Fatalf("expected 3 normalized tool calls, got %+v", message) t.Fatalf("expected 2 normalized tool calls plus legacy function_call, got %+v", message)
} }
assertToolCall := func(index int, id string, name string, arguments string) { assertToolCall := func(index int, id string, name string, arguments string) {
t.Helper() t.Helper()
@@ -1134,8 +1136,7 @@ func TestOpenAIClientChatResponseNormalizesToolCallFormats(t *testing.T) {
} }
} }
assertToolCall(0, "call_camel", "camel_lookup", "{\"city\":\"SF\"}") assertToolCall(0, "call_camel", "camel_lookup", "{\"city\":\"SF\"}")
assertToolCall(1, "call_1", "legacy_lookup", "{\"city\":\"NYC\"}") assertToolCall(1, "toolu_1", "anthropic_lookup", "{\"city\":\"Boston\"}")
assertToolCall(2, "toolu_1", "anthropic_lookup", "{\"city\":\"Boston\"}")
} }
func TestOpenAIClientChatStreamContract(t *testing.T) { func TestOpenAIClientChatStreamContract(t *testing.T) {
@@ -1393,19 +1394,21 @@ func TestOpenAIClientChatStreamNormalizesToolCallFormats(t *testing.T) {
if len(captured) != 3 { if len(captured) != 3 {
t.Fatalf("unexpected captured events: %+v", captured) t.Fatalf("unexpected captured events: %+v", captured)
} }
for _, event := range captured { for index, event := range captured {
choices, _ := event.Event["choices"].([]any) choices, _ := event.Event["choices"].([]any)
choice, _ := choices[0].(map[string]any) choice, _ := choices[0].(map[string]any)
delta, _ := choice["delta"].(map[string]any) delta, _ := choice["delta"].(map[string]any)
if _, ok := delta["function_call"]; ok {
t.Fatalf("function_call should be converted away: %+v", event.Event)
}
if _, ok := delta["functionCall"]; ok { if _, ok := delta["functionCall"]; ok {
t.Fatalf("functionCall should be converted away: %+v", event.Event) t.Fatalf("functionCall should be converted away: %+v", event.Event)
} }
if _, ok := delta["toolCall"]; ok { if _, ok := delta["toolCall"]; ok {
t.Fatalf("toolCall should be converted away: %+v", event.Event) t.Fatalf("toolCall should be converted away: %+v", event.Event)
} }
if index < 2 {
if _, ok := delta["function_call"]; !ok {
t.Fatalf("canonical legacy function_call should be preserved: %+v", event.Event)
}
}
} }
choices, _ := response.Result["choices"].([]any) choices, _ := response.Result["choices"].([]any)
choice, _ := choices[0].(map[string]any) choice, _ := choices[0].(map[string]any)
+187 -36
View File
@@ -202,6 +202,9 @@ func decodeOpenAIStreamReader(reader io.Reader, onDelta StreamDelta) (map[string
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
rawLines := make([]string, 0) rawLines := make([]string, 0)
parts := make([]string, 0) parts := make([]string, 0)
refusalParts := make([]string, 0)
streamLogprobs := make([]any, 0)
streamAnnotations := make([]any, 0)
reasoningParts := make([]string, 0) reasoningParts := make([]string, 0)
var last map[string]any var last map[string]any
var usage Usage var usage Usage
@@ -232,6 +235,11 @@ func decodeOpenAIStreamReader(reader io.Reader, onDelta StreamDelta) (map[string
if reasoningText != "" { if reasoningText != "" {
reasoningParts = append(reasoningParts, reasoningText) reasoningParts = append(reasoningParts, reasoningText)
} }
if refusal := streamEventRefusal(event); refusal != "" {
refusalParts = append(refusalParts, refusal)
}
streamLogprobs = append(streamLogprobs, streamEventLogprobs(event)...)
streamAnnotations = append(streamAnnotations, streamEventAnnotations(event)...)
aggregateStreamToolCalls(event, toolCalls) aggregateStreamToolCalls(event, toolCalls)
if reason := streamEventFinishReason(event); reason != "" { if reason := streamEventFinishReason(event); reason != "" {
finishReason = reason finishReason = reason
@@ -259,7 +267,7 @@ func decodeOpenAIStreamReader(reader io.Reader, onDelta StreamDelta) (map[string
} }
return out, true, nil return out, true, nil
} }
return buildOpenAIStreamResult(last, parts, reasoningParts, toolCalls, finishReason, usage), true, nil return buildOpenAIStreamResult(last, parts, refusalParts, streamLogprobs, streamAnnotations, reasoningParts, toolCalls, finishReason, usage), true, nil
} }
func decodeOpenAIStream(raw []byte) (map[string]any, bool) { func decodeOpenAIStream(raw []byte) (map[string]any, bool) {
@@ -270,8 +278,8 @@ func decodeOpenAIStream(raw []byte) (map[string]any, bool) {
return result, ok && err == nil return result, ok && err == nil
} }
func buildOpenAIStreamResult(last map[string]any, parts []string, reasoningParts []string, toolCalls map[int]map[string]any, finishReason string, usage Usage) map[string]any { func buildOpenAIStreamResult(last map[string]any, parts []string, refusalParts []string, streamLogprobs []any, streamAnnotations []any, reasoningParts []string, toolCalls map[int]map[string]any, finishReason string, usage Usage) map[string]any {
if len(parts) == 0 && len(reasoningParts) == 0 && len(toolCalls) == 0 { if len(parts) == 0 && len(refusalParts) == 0 && len(streamLogprobs) == 0 && len(streamAnnotations) == 0 && len(reasoningParts) == 0 && len(toolCalls) == 0 {
return last return last
} }
message := map[string]any{ message := map[string]any{
@@ -281,6 +289,12 @@ func buildOpenAIStreamResult(last map[string]any, parts []string, reasoningParts
if len(reasoningParts) > 0 { if len(reasoningParts) > 0 {
message["reasoning_content"] = strings.Join(reasoningParts, "") message["reasoning_content"] = strings.Join(reasoningParts, "")
} }
if len(refusalParts) > 0 {
message["refusal"] = strings.Join(refusalParts, "")
}
if len(streamAnnotations) > 0 {
message["annotations"] = streamAnnotations
}
if len(toolCalls) > 0 { if len(toolCalls) > 0 {
message["tool_calls"] = sortedStreamToolCalls(toolCalls) message["tool_calls"] = sortedStreamToolCalls(toolCalls)
} }
@@ -288,15 +302,17 @@ func buildOpenAIStreamResult(last map[string]any, parts []string, reasoningParts
finishReason = "stop" finishReason = "stop"
} }
var out map[string]any var out map[string]any
choice := map[string]any{
"index": 0, "message": message, "finish_reason": finishReason,
}
if len(streamLogprobs) > 0 {
choice["logprobs"] = map[string]any{"content": streamLogprobs, "refusal": nil}
}
out = map[string]any{ out = map[string]any{
"id": stringFromAny(firstPresent(last["id"], "chatcmpl-stream")), "id": stringFromAny(firstPresent(last["id"], "chatcmpl-stream")),
"object": "chat.completion", "object": "chat.completion",
"model": stringFromAny(last["model"]), "model": stringFromAny(last["model"]),
"choices": []any{map[string]any{ "choices": []any{choice},
"index": 0,
"message": message,
"finish_reason": finishReason,
}},
} }
if usage.TotalTokens > 0 { if usage.TotalTokens > 0 {
usageMap := map[string]any{ usageMap := map[string]any{
@@ -314,6 +330,42 @@ func buildOpenAIStreamResult(last map[string]any, parts []string, reasoningParts
return out return out
} }
func streamEventRefusal(event map[string]any) string {
choices, _ := event["choices"].([]any)
for _, rawChoice := range choices {
choice, _ := rawChoice.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if refusal := stringFromAny(delta["refusal"]); refusal != "" {
return refusal
}
}
return ""
}
func streamEventLogprobs(event map[string]any) []any {
choices, _ := event["choices"].([]any)
out := make([]any, 0)
for _, rawChoice := range choices {
choice, _ := rawChoice.(map[string]any)
logprobs, _ := choice["logprobs"].(map[string]any)
content, _ := logprobs["content"].([]any)
out = append(out, content...)
}
return out
}
func streamEventAnnotations(event map[string]any) []any {
choices, _ := event["choices"].([]any)
out := make([]any, 0)
for _, rawChoice := range choices {
choice, _ := rawChoice.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
annotations, _ := delta["annotations"].([]any)
out = append(out, annotations...)
}
return out
}
// NormalizeChatCompletionRequestBody 将后续请求里的工具调用上下文还原为 // NormalizeChatCompletionRequestBody 将后续请求里的工具调用上下文还原为
// OpenAI Chat Completions 标准格式,便于再次发送给 OpenAI-compatible 上游。 // OpenAI Chat Completions 标准格式,便于再次发送给 OpenAI-compatible 上游。
func NormalizeChatCompletionRequestBody(body map[string]any) map[string]any { func NormalizeChatCompletionRequestBody(body map[string]any) map[string]any {
@@ -333,7 +385,7 @@ func NormalizeChatCompletionRequestBody(body map[string]any) map[string]any {
continue continue
} }
copied := cloneMapAny(message) copied := cloneMapAny(message)
normalizeToolCallsContainer(copied, false) normalizeRequestToolCallsContainer(copied)
normalizeToolMessageFields(copied) normalizeToolMessageFields(copied)
toolMessages, cleanContent, changed := toolResultMessagesFromContent(copied["content"]) toolMessages, cleanContent, changed := toolResultMessagesFromContent(copied["content"])
if changed { if changed {
@@ -355,6 +407,67 @@ func NormalizeChatCompletionRequestBody(body map[string]any) map[string]any {
return out return out
} }
// normalizeRequestToolCallsContainer preserves canonical OpenAI Chat
// structures. Only known provider aliases are normalized. Legacy
// function_call and standard custom tool calls must remain intact.
func normalizeRequestToolCallsContainer(container map[string]any) {
if container == nil {
return
}
toolCalls := canonicalToolCalls(container["tool_calls"], false)
for _, key := range []string{"tool_call", "toolCall", "toolCalls"} {
if raw, ok := container[key]; ok {
for _, normalized := range normalizeRawToolCalls(raw, len(toolCalls), false) {
toolCalls = append(toolCalls, normalized)
}
delete(container, key)
}
}
if contentToolCalls, cleanContent, changed := toolCallsFromContent(container["content"], len(toolCalls), false); changed {
toolCalls = append(toolCalls, contentToolCalls...)
setNormalizedContent(container, cleanContent, false)
}
if partToolCalls := toolCallsFromParts(container["parts"], len(toolCalls), false); len(partToolCalls) > 0 {
toolCalls = append(toolCalls, partToolCalls...)
delete(container, "parts")
}
if len(toolCalls) > 0 {
container["tool_calls"] = toolCalls
}
if functionCall, ok := container["functionCall"]; ok {
if _, canonical := container["function_call"]; !canonical {
if normalized := normalizeToolCall(functionCall, 0, false); normalized != nil {
container["function_call"] = normalized["function"]
}
}
delete(container, "functionCall")
}
}
func canonicalToolCalls(value any, stream bool) []any {
items, ok := value.([]any)
if !ok {
if value == nil {
return nil
}
items = []any{value}
}
out := make([]any, 0, len(items))
for index, raw := range items {
toolCall, _ := raw.(map[string]any)
typeName := stringFromAny(toolCall["type"])
if (typeName == "custom" && len(mapFromAny(toolCall["custom"])) > 0) ||
((typeName == "" || typeName == "function") && len(mapFromAny(toolCall["function"])) > 0) {
out = append(out, cloneMapAny(toolCall))
continue
}
if normalized := normalizeToolCall(raw, index, stream); normalized != nil {
out = append(out, normalized)
}
}
return out
}
func cloneMapAny(source map[string]any) map[string]any { func cloneMapAny(source map[string]any) map[string]any {
if source == nil { if source == nil {
return nil return nil
@@ -446,14 +559,27 @@ func normalizeToolCallsContainer(container map[string]any, stream bool) {
if container == nil { if container == nil {
return return
} }
toolCalls := make([]any, 0) toolCalls := canonicalToolCalls(container["tool_calls"], stream)
for _, rawToolCall := range rawToolCallValues(container) { for _, key := range []string{"tool_call", "toolCall", "toolCalls"} {
for _, normalized := range normalizeRawToolCalls(rawToolCall, len(toolCalls), stream) { if rawToolCall, ok := container[key]; ok {
toolCalls = append(toolCalls, normalized) for _, normalized := range normalizeRawToolCalls(rawToolCall, len(toolCalls), stream) {
toolCalls = append(toolCalls, normalized)
}
delete(container, key)
} }
} }
if functionCall, ok := container["functionCall"]; ok {
if _, canonical := container["function_call"]; !canonical {
if normalized := normalizeToolCall(functionCall, 0, stream); normalized != nil {
container["function_call"] = normalized["function"]
}
}
delete(container, "functionCall")
}
if contentToolCalls, cleanContent, changed := toolCallsFromContent(container["content"], len(toolCalls), stream); changed { if contentToolCalls, cleanContent, changed := toolCallsFromContent(container["content"], len(toolCalls), stream); changed {
toolCalls = append(toolCalls, contentToolCalls...) for _, normalized := range contentToolCalls {
toolCalls = append(toolCalls, normalized)
}
setNormalizedContent(container, cleanContent, stream) setNormalizedContent(container, cleanContent, stream)
} }
if partToolCalls := toolCallsFromParts(container["parts"], len(toolCalls), stream); len(partToolCalls) > 0 { if partToolCalls := toolCallsFromParts(container["parts"], len(toolCalls), stream); len(partToolCalls) > 0 {
@@ -463,9 +589,6 @@ func normalizeToolCallsContainer(container map[string]any, stream bool) {
if len(toolCalls) > 0 { if len(toolCalls) > 0 {
container["tool_calls"] = toolCalls container["tool_calls"] = toolCalls
} }
for _, key := range []string{"tool_call", "toolCall", "toolCalls", "function_call", "functionCall"} {
delete(container, key)
}
} }
func normalizeToolMessageFields(message map[string]any) { func normalizeToolMessageFields(message map[string]any) {
@@ -557,6 +680,20 @@ func normalizeToolCall(value any, index int, stream bool) map[string]any {
if len(source) == 0 { if len(source) == 0 {
return nil return nil
} }
if stringFromAny(source["type"]) == "custom" {
customSource := mapFromAny(source["custom"])
if len(customSource) == 0 {
return nil
}
toolCall := cloneMapAny(source)
toolCall["custom"] = cloneMapAny(customSource)
if stream {
if _, ok := toolCall["index"]; !ok {
toolCall["index"] = index
}
}
return toolCall
}
functionSource := mapFromAny(source["function"]) functionSource := mapFromAny(source["function"])
if len(functionSource) == 0 { if len(functionSource) == 0 {
functionSource = mapFromAny(firstPresent(source["function_call"], source["functionCall"])) functionSource = mapFromAny(firstPresent(source["function_call"], source["functionCall"]))
@@ -976,7 +1113,7 @@ func aggregateStreamToolCalls(event map[string]any, toolCalls map[int]map[string
for _, rawChoice := range choices { for _, rawChoice := range choices {
choice, _ := rawChoice.(map[string]any) choice, _ := rawChoice.(map[string]any)
delta, _ := choice["delta"].(map[string]any) delta, _ := choice["delta"].(map[string]any)
rawToolCalls, _ := delta["tool_calls"].([]any) rawToolCalls := streamToolCallsFromDelta(delta)
for _, rawToolCall := range rawToolCalls { for _, rawToolCall := range rawToolCalls {
incoming, _ := rawToolCall.(map[string]any) incoming, _ := rawToolCall.(map[string]any)
index := intFromAny(incoming["index"]) index := intFromAny(incoming["index"])
@@ -990,25 +1127,39 @@ func aggregateStreamToolCalls(event map[string]any, toolCalls map[int]map[string
current[key] = value current[key] = value
} }
} }
incomingFn, _ := incoming["function"].(map[string]any) aggregateStreamToolPayload(current, incoming, "function", "arguments")
if len(incomingFn) == 0 { aggregateStreamToolPayload(current, incoming, "custom", "input")
continue
}
currentFn, _ := current["function"].(map[string]any)
if currentFn == nil {
currentFn = map[string]any{}
current["function"] = currentFn
}
if name, ok := incomingFn["name"].(string); ok && name != "" {
currentFn["name"] = stringFromAny(currentFn["name"]) + name
}
if arguments, ok := incomingFn["arguments"].(string); ok && arguments != "" {
currentFn["arguments"] = stringFromAny(currentFn["arguments"]) + arguments
}
} }
} }
} }
func streamToolCallsFromDelta(delta map[string]any) []any {
rawToolCalls, _ := delta["tool_calls"].([]any)
out := append([]any(nil), rawToolCalls...)
if functionCall, ok := delta["function_call"].(map[string]any); ok {
out = append(out, map[string]any{"index": 0, "type": "function", "function": functionCall})
}
return out
}
func aggregateStreamToolPayload(current map[string]any, incoming map[string]any, containerKey string, payloadKey string) {
incomingPayload, _ := incoming[containerKey].(map[string]any)
if len(incomingPayload) == 0 {
return
}
currentPayload, _ := current[containerKey].(map[string]any)
if currentPayload == nil {
currentPayload = map[string]any{}
current[containerKey] = currentPayload
}
if name, ok := incomingPayload["name"].(string); ok && name != "" {
currentPayload["name"] = stringFromAny(currentPayload["name"]) + name
}
if payload, ok := incomingPayload[payloadKey].(string); ok && payload != "" {
currentPayload[payloadKey] = stringFromAny(currentPayload[payloadKey]) + payload
}
}
func sortedStreamToolCalls(toolCalls map[int]map[string]any) []any { func sortedStreamToolCalls(toolCalls map[int]map[string]any) []any {
indices := make([]int, 0, len(toolCalls)) indices := make([]int, 0, len(toolCalls))
for index := range toolCalls { for index := range toolCalls {
+32 -7
View File
@@ -54,10 +54,14 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
return Response{}, normalizeErr return Response{}, normalizeErr
} }
body = normalizedBody body = normalizedBody
applyOpenAIChatReasoningParams(body, request.Candidate) if err := applyOpenAIChatReasoningParamsWithSource(body, request.Candidate, openAIAdaptationSource(request, endpointKind)); err != nil {
return Response{}, err
}
body = FilterOpenAIChatRequestBody(body) body = FilterOpenAIChatRequestBody(body)
} else if request.Kind == "responses" { } else if request.Kind == "responses" {
applyOpenAIResponsesReasoningParams(body, request.Candidate) if err := applyOpenAIResponsesReasoningParamsWithSource(body, request.Candidate, request.OriginalBody); err != nil {
return Response{}, err
}
body = FilterOpenAIResponsesRequestBody(body) body = FilterOpenAIResponsesRequestBody(body)
if _, hasInput := body["input"]; !hasInput { if _, hasInput := body["input"]; !hasInput {
if messages, hasMessages := request.Body["messages"]; hasMessages { if messages, hasMessages := request.Body["messages"]; hasMessages {
@@ -79,8 +83,9 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
responseStartedAt := time.Now() responseStartedAt := time.Now()
correctionScope := newParameterCorrectionScope(request, endpointKind) correctionScope := newParameterCorrectionScope(request, endpointKind)
correctionEnabled := endpointKind == "chat.completions" || endpointKind == "responses" correctionEnabled := endpointKind == "chat.completions" || endpointKind == "responses"
protectedCorrections := callerProtectedCorrectionParameters(request, endpointKind)
if correctionEnabled { if correctionEnabled {
c.Corrections.apply(correctionScope, body) c.Corrections.apply(correctionScope, body, protectedCorrections)
} }
provisionalRules := make([]parameterCorrectionRule, 0, 2) provisionalRules := make([]parameterCorrectionRule, 0, 2)
seenCorrectionErrors := make(map[string]struct{}) seenCorrectionErrors := make(map[string]struct{})
@@ -96,6 +101,7 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
} }
req.Header.Set("Content-Type", contentType) req.Header.Set("Content-Type", contentType)
req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Authorization", "Bearer "+apiKey)
applyUpstreamIdempotency(req, request)
if err := notifySubmissionStarted(request); err != nil { if err := notifySubmissionStarted(request); err != nil {
return Response{}, err return Response{}, err
} }
@@ -119,7 +125,7 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
return Response{}, annotateResponseError(upstreamErr, requestIDFromParameterError(upstreamErr), responseStartedAt, time.Now()) return Response{}, annotateResponseError(upstreamErr, requestIDFromParameterError(upstreamErr), responseStartedAt, time.Now())
} }
seenCorrectionErrors[fingerprint] = struct{}{} seenCorrectionErrors[fingerprint] = struct{}{}
rule, safe := deriveParameterCorrection(upstreamErr, body, request.Candidate) rule, safe := deriveParameterCorrection(upstreamErr, body, request.Candidate, protectedCorrections)
if !safe || !applyParameterCorrectionRule(body, rule) { if !safe || !applyParameterCorrectionRule(body, rule) {
return Response{}, annotateResponseError(upstreamErr, requestIDFromParameterError(upstreamErr), responseStartedAt, time.Now()) return Response{}, annotateResponseError(upstreamErr, requestIDFromParameterError(upstreamErr), responseStartedAt, time.Now())
} }
@@ -142,7 +148,7 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
var streamDelta StreamDelta = nativeStreamDelta var streamDelta StreamDelta = nativeStreamDelta
var adapter *chatResponsesStreamAdapter var adapter *chatResponsesStreamAdapter
if request.Kind == "responses" && protocol == ProtocolOpenAIChatCompletions && stream { if request.Kind == "responses" && protocol == ProtocolOpenAIChatCompletions && stream {
adapter = newChatResponsesStreamAdapter(request.PublicResponseID, request.Model) adapter = newChatResponsesStreamAdapter(request.PublicResponseID, request.Model, request.Body)
streamDelta = func(event StreamDeltaEvent) error { return adapter.delta(event, request.StreamDelta) } streamDelta = func(event StreamDeltaEvent) error { return adapter.delta(event, request.StreamDelta) }
} }
if stream { if stream {
@@ -212,6 +218,19 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
}, nil }, nil
} }
func openAIAdaptationSource(request Request, endpointKind string) map[string]any {
if request.OriginalBody == nil || request.Kind != "responses" || endpointKind != "chat.completions" {
return request.OriginalBody
}
out := cloneBody(request.OriginalBody)
if reasoning, ok := request.OriginalBody["reasoning"].(map[string]any); ok {
if effort, explicit := reasoning["effort"]; explicit {
out["reasoning_effort"] = effort
}
}
return out
}
func normalizeOpenAIImageRequestBody(endpointKind string, body map[string]any, originalBody map[string]any) { func normalizeOpenAIImageRequestBody(endpointKind string, body map[string]any, originalBody map[string]any) {
if endpointKind != "images.generations" && endpointKind != "images.edits" { if endpointKind != "images.generations" && endpointKind != "images.edits" {
return return
@@ -541,12 +560,18 @@ func ensureOpenAIStreamUsage(body map[string]any, kind string, stream bool) {
return return
} }
streamOptions := map[string]any{} streamOptions := map[string]any{}
if existing, ok := body["stream_options"].(map[string]any); ok { if raw, explicit := body["stream_options"]; explicit {
existing, ok := raw.(map[string]any)
if !ok {
return
}
for key, value := range existing { for key, value := range existing {
streamOptions[key] = value streamOptions[key] = value
} }
} }
streamOptions["include_usage"] = true if _, explicit := streamOptions["include_usage"]; !explicit {
streamOptions["include_usage"] = true
}
body["stream_options"] = streamOptions body["stream_options"] = streamOptions
} }
@@ -0,0 +1,154 @@
package clients
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestOpenAIChatNativeDeepPassthroughPreservesOfficialAndFutureFields(t *testing.T) {
requestBody := completeChatPassthroughBody()
var captured map[string]any
var idempotencyKey string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idempotencyKey = r.Header.Get("Idempotency-Key")
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
t.Fatal(err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "chatcmpl-native", "object": "chat.completion", "model": "provider-chat",
"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "ok"}}},
})
}))
defer server.Close()
_, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
Kind: "chat.completions", Model: "public-chat", Body: requestBody, OriginalBody: requestBody,
UpstreamIdempotencyKey: "gateway-task-id",
Candidate: store.RuntimeModelCandidate{
Provider: "openai", BaseURL: server.URL, ProviderModelName: "provider-chat",
Credentials: map[string]any{"apiKey": "test-key"},
},
})
if err != nil {
t.Fatal(err)
}
want := jsonRoundTripMap(t, requestBody)
delete(want, "request_id")
want["model"] = "provider-chat"
if !reflect.DeepEqual(captured, want) {
t.Fatalf("native Chat request changed\n got: %#v\nwant: %#v", captured, want)
}
if idempotencyKey != "gateway-task-id" {
t.Fatalf("missing Gateway task idempotency key: %q", idempotencyKey)
}
}
func TestOpenAIResponsesNativeDeepPassthroughPreservesOfficialAndFutureFields(t *testing.T) {
requestBody := completeResponsesPassthroughBody()
var captured map[string]any
var idempotencyKey string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idempotencyKey = r.Header.Get("Idempotency-Key")
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
t.Fatal(err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "resp-native", "object": "response", "status": "completed", "output": []any{},
})
}))
defer server.Close()
_, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
Kind: "responses", Model: "public-responses", Body: requestBody, OriginalBody: requestBody,
UpstreamProtocol: ProtocolOpenAIResponses, UpstreamPreviousResponseID: "resp_upstream_parent",
UpstreamIdempotencyKey: "gateway-task-id",
Candidate: store.RuntimeModelCandidate{
Provider: "openai", BaseURL: server.URL, ProviderModelName: "provider-responses",
Credentials: map[string]any{"apiKey": "test-key"},
},
})
if err != nil {
t.Fatal(err)
}
want := jsonRoundTripMap(t, requestBody)
delete(want, "request_id")
want["model"] = "provider-responses"
want["previous_response_id"] = "resp_upstream_parent"
if !reflect.DeepEqual(captured, want) {
t.Fatalf("native Responses request changed\n got: %#v\nwant: %#v", captured, want)
}
if idempotencyKey != "gateway-task-id" {
t.Fatalf("missing Gateway task idempotency key: %q", idempotencyKey)
}
}
func completeChatPassthroughBody() map[string]any {
return map[string]any{
"model": "caller-chat", "messages": []any{
map[string]any{"role": "assistant", "content": nil, "function_call": map[string]any{"name": "legacy", "arguments": "{\"x\":1}"}},
map[string]any{"role": "assistant", "content": nil, "tool_calls": []any{
map[string]any{"id": "call_function", "type": "function", "function": map[string]any{"name": "lookup", "arguments": "{\"q\":1}"}},
map[string]any{"id": "call_custom", "type": "custom", "custom": map[string]any{"name": "shell", "input": "pwd"}},
}},
},
"audio": map[string]any{"format": "wav", "voice": "alloy"}, "frequency_penalty": 0.1,
"function_call": map[string]any{"name": "legacy"}, "functions": []any{map[string]any{"name": "legacy", "parameters": map[string]any{"type": "object"}}},
"logit_bias": map[string]any{"1": 2}, "logprobs": true, "max_completion_tokens": 101, "max_tokens": 102,
"metadata": map[string]any{"trace": "1"}, "modalities": []any{"text"}, "moderation": map[string]any{"type": "auto"}, "n": 1,
"parallel_tool_calls": true, "prediction": map[string]any{"type": "content", "content": "answer"}, "presence_penalty": 0.2,
"prompt_cache_key": "cache", "prompt_cache_options": map[string]any{"type": "ephemeral"}, "prompt_cache_retention": "in_memory",
"reasoning_effort": "low", "response_format": map[string]any{"type": "json_object"}, "safety_identifier": "safe", "seed": 7,
"service_tier": "default", "stop": []any{"END"}, "store": false, "stream": false,
"stream_options": map[string]any{"include_usage": true}, "temperature": 0.7,
"tool_choice": map[string]any{"type": "custom", "custom": map[string]any{"name": "shell"}},
"tools": []any{
map[string]any{"type": "function", "function": map[string]any{"name": "lookup", "parameters": map[string]any{"type": "object"}}},
map[string]any{"type": "custom", "custom": map[string]any{"name": "shell", "format": map[string]any{"type": "text"}}},
},
"top_logprobs": 2, "top_p": 0.9, "user": "user-1", "verbosity": "low",
"web_search_options": map[string]any{"search_context_size": "low"},
"future_official_field": map[string]any{"nested": []any{map[string]any{"keep": true}}},
"request_id": "gateway-only",
}
}
func completeResponsesPassthroughBody() map[string]any {
return map[string]any{
"background": false, "context_management": map[string]any{"type": "compaction", "compact_threshold": 2000},
"conversation": "conv_1", "include": []any{"message.output_text.logprobs"},
"input": []any{map[string]any{"type": "message", "role": "user", "content": []any{
map[string]any{"type": "input_text", "text": "hello", "prompt_cache_breakpoint": map[string]any{"type": "ephemeral"}},
}}},
"instructions": "be concise", "max_output_tokens": 200, "max_tool_calls": 3, "metadata": map[string]any{"trace": "1"},
"model": "caller-responses", "moderation": map[string]any{"type": "auto"}, "parallel_tool_calls": true,
"previous_response_id": "resp_caller_parent", "prompt": map[string]any{"id": "pmpt_1", "variables": map[string]any{"x": "y"}},
"prompt_cache_key": "cache", "prompt_cache_options": map[string]any{"type": "ephemeral"}, "prompt_cache_retention": "24h",
"reasoning": map[string]any{"effort": "low", "summary": "auto"}, "safety_identifier": "safe", "service_tier": "default",
"store": true, "stream": false, "stream_options": map[string]any{"include_obfuscation": true}, "temperature": 0.7,
"text": map[string]any{"format": map[string]any{"type": "json_schema", "name": "answer", "schema": map[string]any{"type": "object"}}, "verbosity": "low"},
"tool_choice": map[string]any{"type": "custom", "name": "shell"},
"tools": []any{map[string]any{"type": "custom", "name": "shell", "description": "run", "format": map[string]any{"type": "text"}}},
"top_logprobs": 2, "top_p": 0.9, "truncation": "auto", "user": "user-1",
"future_official_field": map[string]any{"nested": []any{map[string]any{"keep": true}}},
"request_id": "gateway-only",
}
}
func jsonRoundTripMap(t *testing.T, value map[string]any) map[string]any {
t.Helper()
raw, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatal(err)
}
return out
}
@@ -1,15 +1,9 @@
package clients package clients
import (
"fmt"
"net/http"
"sort"
)
// Keep these lists aligned with openai-node 6.47.0 and the public OpenAI API // Keep these lists aligned with openai-node 6.47.0 and the public OpenAI API
// reference. The Gateway accepts a small, explicit set of routing extensions at // reference. They document and test the currently known surface, but are not an
// ingress, but only protocol fields (plus controlled provider adaptations) are // upstream allowlist: unknown fields may be introduced by OpenAI after a
// allowed across the upstream boundary. // Gateway release and must remain transparent.
var openAIChatRequestParameters = stringSet( var openAIChatRequestParameters = stringSet(
"messages", "model", "audio", "frequency_penalty", "function_call", "functions", "messages", "model", "audio", "frequency_penalty", "function_call", "functions",
"logit_bias", "logprobs", "max_completion_tokens", "max_tokens", "metadata", "logit_bias", "logprobs", "max_completion_tokens", "max_tokens", "metadata",
@@ -50,56 +44,36 @@ var controlledOpenAIChatProviderParameters = stringSet(
var controlledOpenAIResponsesProviderParameters = stringSet("presence_penalty", "frequency_penalty") var controlledOpenAIResponsesProviderParameters = stringSet("presence_penalty", "frequency_penalty")
func ValidateOpenAIRequestParameters(kind string, body map[string]any) error { func ValidateOpenAIRequestParameters(kind string, body map[string]any) error {
allowed := openAIChatRequestParameters // OpenAI-compatible public endpoints intentionally accept future official
if kind == "responses" { // fields. Provider validation remains authoritative for fields the selected
allowed = openAIResponsesRequestParameters // upstream does not support.
} _ = kind
unknown := make([]string, 0) _ = body
for key := range body { return nil
if _, ok := allowed[key]; ok {
continue
}
if _, ok := gatewayOpenAIRequestExtensions[key]; ok {
continue
}
if kind == "responses" {
if _, ok := gatewayResponsesRequestExtensions[key]; ok {
continue
}
}
unknown = append(unknown, key)
}
if len(unknown) == 0 {
return nil
}
sort.Strings(unknown)
return &ClientError{
Code: "invalid_parameter",
Message: fmt.Sprintf("Unknown parameter: %s", unknown[0]),
Param: unknown[0],
StatusCode: http.StatusBadRequest,
Retryable: false,
}
} }
func FilterOpenAIChatRequestBody(body map[string]any) map[string]any { func FilterOpenAIChatRequestBody(body map[string]any) map[string]any {
return filterOpenAIRequestBody(body, openAIChatRequestParameters, controlledOpenAIChatProviderParameters) return filterOpenAIRequestBody(body, controlledOpenAIChatProviderParameters, nil)
} }
func FilterOpenAIResponsesRequestBody(body map[string]any) map[string]any { func FilterOpenAIResponsesRequestBody(body map[string]any) map[string]any {
return filterOpenAIRequestBody(body, openAIResponsesRequestParameters, controlledOpenAIResponsesProviderParameters) return filterOpenAIRequestBody(body, controlledOpenAIResponsesProviderParameters, gatewayResponsesRequestExtensions)
} }
func filterOpenAIRequestBody(body map[string]any, allowed map[string]struct{}, extensions map[string]struct{}) map[string]any { func filterOpenAIRequestBody(body map[string]any, controlled map[string]struct{}, protocolInternal map[string]struct{}) map[string]any {
out := make(map[string]any, len(body)) out := make(map[string]any, len(body))
for key, value := range body { for key, value := range body {
if _, ok := allowed[key]; ok { if _, ok := controlled[key]; ok {
out[key] = value out[key] = value
continue continue
} }
if _, ok := extensions[key]; ok { if _, internal := gatewayOpenAIRequestExtensions[key]; internal {
out[key] = value continue
} }
if _, internal := protocolInternal[key]; internal {
continue
}
out[key] = value
} }
return out return out
} }
@@ -1,9 +1,6 @@
package clients package clients
import ( import "testing"
"strings"
"testing"
)
func TestOpenAIChatOfficialParametersSurviveBoundary(t *testing.T) { func TestOpenAIChatOfficialParametersSurviveBoundary(t *testing.T) {
body := map[string]any{} body := map[string]any{}
@@ -11,7 +8,7 @@ func TestOpenAIChatOfficialParametersSurviveBoundary(t *testing.T) {
body[key] = "sentinel-" + key body[key] = "sentinel-" + key
} }
body["conversationId"] = "internal" body["conversationId"] = "internal"
body["unknown"] = "must-not-leak" body["future_official_field"] = map[string]any{"nested": []any{"must-survive"}}
filtered := FilterOpenAIChatRequestBody(body) filtered := FilterOpenAIChatRequestBody(body)
for key := range openAIChatRequestParameters { for key := range openAIChatRequestParameters {
@@ -19,7 +16,10 @@ func TestOpenAIChatOfficialParametersSurviveBoundary(t *testing.T) {
t.Fatalf("official Chat parameter %q was removed", key) t.Fatalf("official Chat parameter %q was removed", key)
} }
} }
for _, key := range []string{"conversationId", "unknown"} { if _, ok := filtered["future_official_field"]; !ok {
t.Fatal("future Chat field was removed at the upstream boundary")
}
for _, key := range []string{"conversationId"} {
if _, ok := filtered[key]; ok { if _, ok := filtered[key]; ok {
t.Fatalf("internal/unknown parameter %q leaked upstream", key) t.Fatalf("internal/unknown parameter %q leaked upstream", key)
} }
@@ -32,7 +32,7 @@ func TestOpenAIResponsesOfficialParametersSurviveBoundary(t *testing.T) {
body[key] = "sentinel-" + key body[key] = "sentinel-" + key
} }
body["request_id"] = "internal" body["request_id"] = "internal"
body["unknown"] = "must-not-leak" body["future_official_field"] = map[string]any{"nested": []any{"must-survive"}}
filtered := FilterOpenAIResponsesRequestBody(body) filtered := FilterOpenAIResponsesRequestBody(body)
for key := range openAIResponsesRequestParameters { for key := range openAIResponsesRequestParameters {
@@ -40,20 +40,19 @@ func TestOpenAIResponsesOfficialParametersSurviveBoundary(t *testing.T) {
t.Fatalf("official Responses parameter %q was removed", key) t.Fatalf("official Responses parameter %q was removed", key)
} }
} }
for _, key := range []string{"request_id", "unknown"} { if _, ok := filtered["future_official_field"]; !ok {
t.Fatal("future Responses field was removed at the upstream boundary")
}
for _, key := range []string{"request_id"} {
if _, ok := filtered[key]; ok { if _, ok := filtered[key]; ok {
t.Fatalf("internal/unknown parameter %q leaked upstream", key) t.Fatalf("internal/unknown parameter %q leaked upstream", key)
} }
} }
} }
func TestValidateOpenAIRequestParametersRejectsUnknownTopLevelField(t *testing.T) { func TestValidateOpenAIRequestParametersAcceptsFutureTopLevelField(t *testing.T) {
err := ValidateOpenAIRequestParameters("responses", map[string]any{"model": "demo", "input": "hello", "rogue": true}) if err := ValidateOpenAIRequestParameters("responses", map[string]any{"model": "demo", "input": "hello", "future_official_field": true}); err != nil {
if err == nil || ErrorCode(err) != "invalid_parameter" || !strings.Contains(err.Error(), "rogue") { t.Fatalf("future Responses fields must remain forward compatible, got %v", err)
t.Fatalf("expected OpenAI-style invalid_parameter for rogue field, got %v", err)
}
if ErrorParam(err) != "rogue" {
t.Fatalf("expected rogue parameter attribution, got %q", ErrorParam(err))
} }
if err := ValidateOpenAIRequestParameters("responses", map[string]any{"model": "demo", "input": "hello", "messages": []any{}, "request_id": "internal"}); err != nil { if err := ValidateOpenAIRequestParameters("responses", map[string]any{"model": "demo", "input": "hello", "messages": []any{}, "request_id": "internal"}); err != nil {
t.Fatalf("expected controlled Responses extensions to remain accepted, got %v", err) t.Fatalf("expected controlled Responses extensions to remain accepted, got %v", err)
@@ -104,3 +103,34 @@ func TestResponsesFallbackMapsEquivalentCurrentParameters(t *testing.T) {
} }
} }
} }
func TestNormalizeChatRequestPreservesCustomAndLegacyFunctionCall(t *testing.T) {
legacy := map[string]any{"name": "legacy", "arguments": "{\"x\":1}"}
custom := map[string]any{
"id": "call_custom", "type": "custom",
"custom": map[string]any{"name": "shell", "input": "pwd"},
}
body := NormalizeChatCompletionRequestBody(map[string]any{"messages": []any{map[string]any{
"role": "assistant", "content": nil, "function_call": legacy, "tool_calls": []any{custom},
}}})
messages, _ := body["messages"].([]any)
message, _ := messages[0].(map[string]any)
if got, ok := message["function_call"].(map[string]any); !ok || got["name"] != "legacy" || got["arguments"] != "{\"x\":1}" {
t.Fatalf("legacy function_call changed: %+v", message)
}
toolCalls, _ := message["tool_calls"].([]any)
got, _ := toolCalls[0].(map[string]any)
gotCustom, _ := got["custom"].(map[string]any)
if got["type"] != "custom" || got["id"] != "call_custom" || gotCustom["name"] != "shell" || gotCustom["input"] != "pwd" || got["function"] != nil {
t.Fatalf("standard custom tool call changed: %+v", got)
}
}
func TestEnsureOpenAIStreamUsageDoesNotOverrideCallerChoice(t *testing.T) {
body := map[string]any{"stream_options": map[string]any{"include_usage": false, "include_obfuscation": false}}
ensureOpenAIStreamUsage(body, "chat.completions", true)
options, _ := body["stream_options"].(map[string]any)
if options["include_usage"] != false || options["include_obfuscation"] != false {
t.Fatalf("caller stream options were changed: %+v", options)
}
}
@@ -108,7 +108,7 @@ func NewParameterCorrectionCache() *ParameterCorrectionCache {
} }
} }
func (cache *ParameterCorrectionCache) apply(scope parameterCorrectionScope, body map[string]any) []string { func (cache *ParameterCorrectionCache) apply(scope parameterCorrectionScope, body map[string]any, protected ...map[string]struct{}) []string {
if cache == nil { if cache == nil {
return nil return nil
} }
@@ -127,6 +127,9 @@ func (cache *ParameterCorrectionCache) apply(scope parameterCorrectionScope, bod
applied := make([]string, 0, len(elements)) applied := make([]string, 0, len(elements))
for _, element := range elements { for _, element := range elements {
rule := element.Value.(parameterCorrectionCacheEntry).rule rule := element.Value.(parameterCorrectionCacheEntry).rule
if correctionParamProtected(rule.Param, protected...) {
continue
}
if applyParameterCorrectionRule(body, rule) { if applyParameterCorrectionRule(body, rule) {
applied = append(applied, rule.Param) applied = append(applied, rule.Param)
cache.lru.MoveToFront(element) cache.lru.MoveToFront(element)
@@ -183,7 +186,7 @@ func (cache *ParameterCorrectionCache) size() int {
return cache.lru.Len() return cache.lru.Len()
} }
func deriveParameterCorrection(err error, body map[string]any, candidate store.RuntimeModelCandidate) (parameterCorrectionRule, bool) { func deriveParameterCorrection(err error, body map[string]any, candidate store.RuntimeModelCandidate, protected map[string]struct{}) (parameterCorrectionRule, bool) {
var clientErr *ClientError var clientErr *ClientError
if !errors.As(err, &clientErr) || (clientErr.StatusCode != 400 && clientErr.StatusCode != 422) { if !errors.As(err, &clientErr) || (clientErr.StatusCode != 400 && clientErr.StatusCode != 422) {
return parameterCorrectionRule{}, false return parameterCorrectionRule{}, false
@@ -196,9 +199,15 @@ func deriveParameterCorrection(err error, body map[string]any, candidate store.R
if !isSafeCorrectionParam(param) { if !isSafeCorrectionParam(param) {
return parameterCorrectionRule{}, false return parameterCorrectionRule{}, false
} }
if correctionParamProtected(param, protected) {
return parameterCorrectionRule{}, false
}
lowerMessage := strings.ToLower(message) lowerMessage := strings.ToLower(message)
lowerCode := strings.ToLower(code) lowerCode := strings.ToLower(code)
if conflictRule, ok := deriveConflictCorrection(param, lowerMessage); ok { if conflictRule, ok := deriveConflictCorrection(param, lowerMessage); ok {
if correctionParamProtected(conflictRule.Param, protected) {
return parameterCorrectionRule{}, false
}
return conflictRule, true return conflictRule, true
} }
if strings.Contains(lowerMessage, "unknown parameter") || if strings.Contains(lowerMessage, "unknown parameter") ||
@@ -228,6 +237,76 @@ func deriveParameterCorrection(err error, body map[string]any, candidate store.R
return parameterCorrectionRule{}, false return parameterCorrectionRule{}, false
} }
func correctionParamProtected(param string, protected ...map[string]struct{}) bool {
for _, values := range protected {
if _, ok := values[normalizeCorrectionParam(param)]; ok {
return true
}
}
return false
}
func callerProtectedCorrectionParameters(request Request, endpointKind string) map[string]struct{} {
if request.OriginalBody == nil {
return nil
}
protected := make(map[string]struct{})
protect := func(param string) {
param = normalizeCorrectionParam(param)
if isSafeCorrectionParam(param) {
protected[param] = struct{}{}
}
}
for key := range request.OriginalBody {
protect(key)
}
if reasoning, ok := request.OriginalBody["reasoning"].(map[string]any); ok {
if _, explicit := reasoning["effort"]; explicit {
if endpointKind == "chat.completions" {
protect("reasoning_effort")
} else {
protect("reasoning.effort")
}
}
}
if request.Kind == "responses" && endpointKind == "chat.completions" {
if _, explicit := request.OriginalBody["max_output_tokens"]; explicit {
protect("max_completion_tokens")
}
if _, explicit := request.OriginalBody["top_logprobs"]; explicit {
protect("top_logprobs")
protect("logprobs")
}
if responseIncludeContains(request.OriginalBody["include"], "message.output_text.logprobs") {
protect("logprobs")
}
if text, ok := request.OriginalBody["text"].(map[string]any); ok {
if _, explicit := text["verbosity"]; explicit {
protect("verbosity")
}
}
}
return protected
}
func responseIncludeContains(value any, target string) bool {
switch values := value.(type) {
case []any:
for _, value := range values {
if stringFromAny(value) == target {
return true
}
}
case []string:
for _, value := range values {
if value == target {
return true
}
}
}
return false
}
func requestIDFromParameterError(err error) string { func requestIDFromParameterError(err error) string {
var clientErr *ClientError var clientErr *ClientError
if errors.As(err, &clientErr) { if errors.As(err, &clientErr) {
@@ -296,3 +296,149 @@ func TestParameterCorrectionCacheIsConcurrentAndBounded(t *testing.T) {
t.Fatalf("cache exceeded capacity: %d", cache.size()) t.Fatalf("cache exceeded capacity: %d", cache.size())
} }
} }
func TestOpenAIParameterCorrectionNeverChangesCallerExplicitParameterColdOrHot(t *testing.T) {
t.Run("cold", func(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
writeParameterError(w, "n", "Parameter n must be set to 1.")
}))
defer server.Close()
body := map[string]any{"messages": []any{}, "n": 2}
request := correctionTestRequest(server, body)
request.OriginalBody = body
cache := NewParameterCorrectionCache()
_, err := (OpenAIClient{HTTPClient: server.Client(), Corrections: cache}).Run(context.Background(), request)
if err == nil || requests.Load() != 1 || cache.size() != 0 {
t.Fatalf("explicit cold parameter was corrected: requests=%d cache=%d err=%v", requests.Load(), cache.size(), err)
}
})
t.Run("hot", func(t *testing.T) {
var captured float64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
captured, _ = body["n"].(float64)
writeCorrectionSuccess(w, body["model"])
}))
defer server.Close()
body := map[string]any{"messages": []any{}, "n": 2}
request := correctionTestRequest(server, body)
request.OriginalBody = body
cache := NewParameterCorrectionCache()
cache.commit(newParameterCorrectionScope(request, "chat.completions"), []parameterCorrectionRule{{Param: "n", Action: parameterCorrectionSet, Value: 1}})
if _, err := (OpenAIClient{HTTPClient: server.Client(), Corrections: cache}).Run(context.Background(), request); err != nil {
t.Fatal(err)
}
if captured != 2 {
t.Fatalf("cached correction changed explicit n: %v", captured)
}
})
}
func TestOpenAIParameterCorrectionMayChangeGatewayInjectedParameter(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if body["n"] != float64(1) {
writeParameterError(w, "n", "Parameter n must be set to 1.")
return
}
writeCorrectionSuccess(w, body["model"])
}))
defer server.Close()
request := correctionTestRequest(server, map[string]any{"messages": []any{}, "n": 2})
request.OriginalBody = map[string]any{"messages": []any{}}
if _, err := (OpenAIClient{HTTPClient: server.Client(), Corrections: NewParameterCorrectionCache()}).Run(context.Background(), request); err != nil {
t.Fatal(err)
}
if requests.Load() != 2 {
t.Fatalf("Gateway-injected parameter was not corrected: requests=%d", requests.Load())
}
}
func TestOpenAIProviderAdaptationRejectsExplicitSemanticChange(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("semantically incompatible explicit parameter must fail before upstream")
}))
defer server.Close()
body := map[string]any{"messages": []any{}, "reasoning_effort": "none", "temperature": 0.2}
request := correctionTestRequest(server, body)
request.OriginalBody = body
_, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), request)
if ErrorCode(err) != "invalid_parameter" || ErrorParam(err) != "reasoning_effort" {
t.Fatalf("expected exact reasoning incompatibility, got %v param=%q", err, ErrorParam(err))
}
}
func TestOpenAIProviderAdaptationRejectsInexactDeepSeekEffort(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("inexact DeepSeek effort must fail before upstream")
}))
defer server.Close()
body := map[string]any{"messages": []any{}, "reasoning_effort": "low"}
request := Request{
Kind: "chat.completions", Model: "deepseek-v4-pro", Body: body, OriginalBody: body,
Candidate: store.RuntimeModelCandidate{
Provider: "deepseek-openai", BaseURL: server.URL, ProviderModelName: "deepseek-v4-pro", Credentials: map[string]any{"apiKey": "test-key"},
},
}
_, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), request)
if ErrorCode(err) != "invalid_parameter" || ErrorParam(err) != "reasoning_effort" {
t.Fatalf("expected exact DeepSeek reasoning incompatibility, got %v param=%q", err, ErrorParam(err))
}
}
func TestResponsesFallbackProtectsExplicitMappedOutputLimitFromCorrection(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if body["max_completion_tokens"] != float64(128) || body["max_tokens"] != nil {
t.Fatalf("Responses output limit mapped incorrectly: %+v", body)
}
writeParameterError(w, "max_completion_tokens", "Unsupported parameter: max_completion_tokens")
}))
defer server.Close()
body := map[string]any{"input": "hello", "max_output_tokens": 128}
request := Request{
Kind: "responses", Model: "demo", Body: body, OriginalBody: body, UpstreamProtocol: ProtocolOpenAIChatCompletions,
Candidate: store.RuntimeModelCandidate{
Provider: "openai", BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "test-key"},
},
}
_, err := (OpenAIClient{HTTPClient: server.Client(), Corrections: NewParameterCorrectionCache()}).Run(context.Background(), request)
if err == nil || requests.Load() != 1 {
t.Fatalf("explicit mapped output limit was silently corrected: requests=%d err=%v", requests.Load(), err)
}
}
func TestResponsesFallbackProtectsIncludeMappedLogprobsFromCorrection(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if body["logprobs"] != true {
t.Fatalf("Responses include did not enable Chat logprobs: %+v", body)
}
writeParameterError(w, "logprobs", "Unsupported parameter: logprobs")
}))
defer server.Close()
body := map[string]any{"input": "hello", "include": []any{"message.output_text.logprobs"}}
request := Request{
Kind: "responses", Model: "demo", Body: body, OriginalBody: body, UpstreamProtocol: ProtocolOpenAIChatCompletions,
Candidate: store.RuntimeModelCandidate{
Provider: "openai", BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "test-key"},
},
}
_, err := (OpenAIClient{HTTPClient: server.Client(), Corrections: NewParameterCorrectionCache()}).Run(context.Background(), request)
if err == nil || requests.Load() != 1 {
t.Fatalf("explicit include logprobs was silently corrected: requests=%d err=%v", requests.Load(), err)
}
}
File diff suppressed because it is too large Load Diff
@@ -74,7 +74,7 @@ func TestOpenAIResponsesChatFallbackPreservesHistoryToolsUsageAndReasoningIntern
if len(messages) != 4 { if len(messages) != 4 {
t.Fatalf("expected prior user/assistant plus current system/user/tool messages, got %+v", messages) t.Fatalf("expected prior user/assistant plus current system/user/tool messages, got %+v", messages)
} }
if body["max_tokens"] != float64(128) || body["reasoning_effort"] != "high" { if body["max_completion_tokens"] != float64(128) || body["reasoning_effort"] != "high" {
t.Fatalf("expected mapped max/reasoning fields: %+v", body) t.Fatalf("expected mapped max/reasoning fields: %+v", body)
} }
tools, _ := body["tools"].([]any) tools, _ := body["tools"].([]any)
@@ -165,6 +165,27 @@ func TestOpenAIResponsesNativeStreamForwardsEventsWithoutChatAggregation(t *test
} }
} }
func TestOpenAIResponsesNativeStreamAcceptsIncompleteTerminalEvent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("event: response.incomplete\ndata: {\"type\":\"response.incomplete\",\"response\":{\"id\":\"resp_incomplete\",\"object\":\"response\",\"status\":\"incomplete\",\"output\":[],\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n"))
}))
defer server.Close()
events := make([]StreamDeltaEvent, 0, 1)
response, err := (OpenAIClient{}).Run(context.Background(), Request{
Kind: "responses", Model: "Demo", Body: map[string]any{"input": "hello", "stream": true}, Stream: true,
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
UpstreamProtocol: ProtocolOpenAIResponses,
StreamDelta: func(event StreamDeltaEvent) error { events = append(events, event); return nil },
})
if err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].Event["type"] != "response.incomplete" || response.Result["status"] != "incomplete" || response.UpstreamResponseID != "resp_incomplete" {
t.Fatalf("native incomplete terminal event was not preserved: response=%+v events=%+v", response, events)
}
}
func TestOpenAIResponsesChatFallbackStreamsFunctionArgumentFragments(t *testing.T) { func TestOpenAIResponsesChatFallbackStreamsFunctionArgumentFragments(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" { if r.URL.Path != "/chat/completions" {
@@ -220,6 +241,54 @@ func TestOpenAIResponsesChatFallbackStreamsFunctionArgumentFragments(t *testing.
} }
} }
func TestOpenAIResponsesChatFallbackStreamsCustomRefusalAndLogprobsEndToEnd(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-custom\",\"object\":\"chat.completion.chunk\",\"model\":\"demo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"hi\",\"refusal\":\"no\",\"annotations\":[{\"type\":\"url_citation\"}],\"tool_calls\":[{\"index\":0,\"id\":\"call_custom\",\"type\":\"custom\",\"custom\":{\"name\":\"shell\",\"input\":\"pw\"}}]},\"logprobs\":{\"content\":[{\"token\":\"hi\",\"logprob\":-0.1}]},\"finish_reason\":null}]}\n\n"))
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-custom\",\"object\":\"chat.completion.chunk\",\"model\":\"demo\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"type\":\"custom\",\"custom\":{\"input\":\"d\"}}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":3,\"total_tokens\":5}}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer server.Close()
events := make([]StreamDeltaEvent, 0)
response, err := (OpenAIClient{}).Run(context.Background(), Request{
Kind: "responses", Model: "Demo", Body: map[string]any{
"input": "call it", "stream": true, "include": []any{"message.output_text.logprobs"},
"tools": []any{map[string]any{"type": "custom", "name": "shell", "format": map[string]any{"type": "text"}}},
}, Stream: true,
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
UpstreamProtocol: ProtocolOpenAIChatCompletions, PublicResponseID: "resp_12345678901234567890123456789012",
StreamDelta: func(event StreamDeltaEvent) error { events = append(events, event); return nil },
})
if err != nil {
t.Fatal(err)
}
output, _ := response.Result["output"].([]any)
if len(output) != 2 {
t.Fatalf("expected message and custom tool call: %+v", response.Result)
}
message, _ := output[0].(map[string]any)
parts, _ := message["content"].([]any)
text, _ := parts[0].(map[string]any)
refusal, _ := parts[1].(map[string]any)
custom, _ := output[1].(map[string]any)
if custom["type"] != "custom_tool_call" || custom["call_id"] != "call_custom" || custom["input"] != "pwd" {
t.Fatalf("custom tool stream was not aggregated: %+v", output)
}
if refusal["refusal"] != "no" || len(text["logprobs"].([]any)) != 1 || len(text["annotations"].([]any)) != 1 {
t.Fatalf("refusal/logprobs were not aggregated: %+v", message)
}
types := make([]string, 0, len(events))
for _, event := range events {
types = append(types, stringFromAny(event.Event["type"]))
}
for _, required := range []string{"response.custom_tool_call_input.delta", "response.custom_tool_call_input.done", "response.refusal.delta", "response.refusal.done", "response.completed"} {
if !containsTestString(types, required) {
t.Fatalf("missing %s in converted stream: %v", required, types)
}
}
}
func TestChatResultToResponseMapsIncompleteFinishReason(t *testing.T) { func TestChatResultToResponseMapsIncompleteFinishReason(t *testing.T) {
response := ChatResultToResponse(map[string]any{ response := ChatResultToResponse(map[string]any{
"choices": []any{map[string]any{"finish_reason": "length", "message": map[string]any{"role": "assistant", "content": "partial"}}}, "choices": []any{map[string]any{"finish_reason": "length", "message": map[string]any{"role": "assistant", "content": "partial"}}},
@@ -233,6 +302,23 @@ func TestChatResultToResponseMapsIncompleteFinishReason(t *testing.T) {
} }
} }
func TestChatResultToResponseConvertsLegacyFunctionCall(t *testing.T) {
response := ChatResultToResponse(map[string]any{
"choices": []any{map[string]any{"finish_reason": "function_call", "message": map[string]any{
"role": "assistant", "content": nil,
"function_call": map[string]any{"name": "legacy_lookup", "arguments": "{\"city\":\"Paris\"}"},
}}},
}, "resp_12345678901234567890123456789012", "demo", map[string]any{})
output, _ := response["output"].([]any)
if len(output) != 1 {
t.Fatalf("legacy function_call was lost: %+v", response)
}
call, _ := output[0].(map[string]any)
if call["type"] != "function_call" || call["name"] != "legacy_lookup" || call["arguments"] != "{\"city\":\"Paris\"}" {
t.Fatalf("legacy function_call was not converted: %+v", call)
}
}
func TestNativeResponsesStreamSupportsMultilineDataFrames(t *testing.T) { func TestNativeResponsesStreamSupportsMultilineDataFrames(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
@@ -277,6 +363,7 @@ func TestChatResponsesStreamAdapterEmitsTextItemLifecycle(t *testing.T) {
want := []string{ want := []string{
"response.created", "response.in_progress", "response.output_item.added", "response.content_part.added", "response.created", "response.in_progress", "response.output_item.added", "response.content_part.added",
"response.output_text.delta", "response.output_text.done", "response.content_part.done", "response.output_item.done", "response.output_text.delta", "response.output_text.done", "response.content_part.done", "response.output_item.done",
"response.completed",
} }
if strings.Join(events, ",") != strings.Join(want, ",") { if strings.Join(events, ",") != strings.Join(want, ",") {
t.Fatalf("unexpected text event lifecycle got=%v want=%v", events, want) t.Fatalf("unexpected text event lifecycle got=%v want=%v", events, want)
@@ -285,8 +372,8 @@ func TestChatResponsesStreamAdapterEmitsTextItemLifecycle(t *testing.T) {
func TestResponsesChatFallbackRejectsBuiltInToolsAndUnknownParameters(t *testing.T) { func TestResponsesChatFallbackRejectsBuiltInToolsAndUnknownParameters(t *testing.T) {
_, err := ResponsesRequestToChat(map[string]any{"input": "hello", "tools": []any{map[string]any{"type": "web_search_preview"}}}, nil) _, err := ResponsesRequestToChat(map[string]any{"input": "hello", "tools": []any{map[string]any{"type": "web_search_preview"}}}, nil)
if ErrorCode(err) != "unsupported_response_tool" { if ErrorCode(err) != "unsupported_response_parameter" || ErrorParam(err) != "tools[0].type" {
t.Fatalf("expected unsupported_response_tool, got %v", err) t.Fatalf("expected precise unsupported_response_parameter, got %v param=%q", err, ErrorParam(err))
} }
_, err = ResponsesRequestToChat(map[string]any{"input": "hello", "conversation": "conv_1"}, nil) _, err = ResponsesRequestToChat(map[string]any{"input": "hello", "conversation": "conv_1"}, nil)
if ErrorCode(err) != "unsupported_response_parameter" { if ErrorCode(err) != "unsupported_response_parameter" {
@@ -317,6 +404,223 @@ func TestResponsesChatFallbackKeepsClientManagedStateAuthoritative(t *testing.T)
} }
} }
func TestResponsesChatFallbackMapsMultimodalCustomToolsAndCallHistory(t *testing.T) {
body, err := ResponsesRequestToChat(map[string]any{
"input": []any{
map[string]any{"type": "message", "role": "user", "content": []any{
map[string]any{"type": "input_text", "text": "hello", "prompt_cache_breakpoint": map[string]any{"type": "ephemeral"}},
map[string]any{"type": "input_image", "image_url": "https://example.com/a.png", "detail": "high"},
map[string]any{"type": "input_file", "file_id": "file_1", "filename": "a.pdf"},
map[string]any{"type": "input_audio", "input_audio": map[string]any{"data": "AAAA", "format": "wav"}},
}},
map[string]any{"type": "function_call", "call_id": "call_fn", "name": "lookup", "arguments": "{\"x\":1}"},
map[string]any{"type": "function_call_output", "call_id": "call_fn", "output": map[string]any{"ok": true}},
map[string]any{"type": "custom_tool_call", "call_id": "call_custom", "name": "shell", "input": "pwd"},
map[string]any{"type": "custom_tool_call_output", "call_id": "call_custom", "output": "done"},
},
"tools": []any{
map[string]any{"type": "function", "name": "lookup", "parameters": map[string]any{"type": "object"}, "strict": true},
map[string]any{"type": "custom", "name": "shell", "description": "run", "format": map[string]any{"type": "text"}},
},
"tool_choice": map[string]any{"type": "custom", "name": "shell"},
"include": []any{"message.output_text.logprobs"}, "max_output_tokens": 256,
}, nil)
if err != nil {
t.Fatal(err)
}
if body["max_completion_tokens"] != 256 || body["logprobs"] != true {
t.Fatalf("token/logprobs mapping is incomplete: %+v", body)
}
messages, _ := body["messages"].([]any)
if len(messages) != 5 {
t.Fatalf("call history conversion changed item ownership: %+v", messages)
}
first, _ := messages[0].(map[string]any)
parts, _ := first["content"].([]any)
if len(parts) != 4 {
t.Fatalf("multimodal content was dropped: %+v", parts)
}
text, _ := parts[0].(map[string]any)
image, _ := parts[1].(map[string]any)
imageURL, _ := image["image_url"].(map[string]any)
file, _ := parts[2].(map[string]any)
fileValue, _ := file["file"].(map[string]any)
if text["prompt_cache_breakpoint"] == nil || imageURL["detail"] != "high" || fileValue["file_id"] != "file_1" || fileValue["filename"] != "a.pdf" {
t.Fatalf("multimodal nested fields changed: %+v", parts)
}
customMessage, _ := messages[3].(map[string]any)
customCalls, _ := customMessage["tool_calls"].([]any)
customCall, _ := customCalls[0].(map[string]any)
custom, _ := customCall["custom"].(map[string]any)
if customCall["type"] != "custom" || custom["name"] != "shell" || custom["input"] != "pwd" {
t.Fatalf("custom call history changed: %+v", customMessage)
}
tools, _ := body["tools"].([]any)
customTool, _ := tools[1].(map[string]any)
if customTool["type"] != "custom" || customTool["custom"] == nil {
t.Fatalf("custom tool definition changed: %+v", tools)
}
choice, _ := body["tool_choice"].(map[string]any)
if choice["type"] != "custom" || choice["custom"] == nil {
t.Fatalf("custom tool choice changed: %+v", choice)
}
}
func TestResponsesChatFallbackMapsTextToolOutputAndRejectsNativeOnlyToolOutputContent(t *testing.T) {
body, err := ResponsesRequestToChat(map[string]any{"input": []any{map[string]any{
"type": "function_call_output", "call_id": "call_1", "output": []any{map[string]any{
"type": "input_text", "text": "done", "prompt_cache_breakpoint": map[string]any{"mode": "explicit"},
}},
}}}, nil)
if err != nil {
t.Fatal(err)
}
messages, _ := body["messages"].([]any)
message, _ := messages[0].(map[string]any)
parts, _ := message["content"].([]any)
part, _ := parts[0].(map[string]any)
if part["type"] != "text" || part["text"] != "done" || part["prompt_cache_breakpoint"] == nil {
t.Fatalf("text tool output was not preserved: %+v", message)
}
err = ValidateResponsesChatFallback(map[string]any{"input": []any{map[string]any{
"type": "function_call_output", "call_id": "call_1", "output": []any{map[string]any{
"type": "input_image", "image_url": "https://example.com/a.png", "detail": "high",
}},
}}})
if ErrorCode(err) != "unsupported_response_parameter" || ErrorParam(err) != "input[0].output[0].type" {
t.Fatalf("native-only tool output must be rejected precisely, got %v param=%q", err, ErrorParam(err))
}
}
func TestResponsesChatFallbackAcceptsOnlyNoopNativeFields(t *testing.T) {
if err := ValidateResponsesChatFallback(map[string]any{
"input": "hello", "background": false, "context_management": map[string]any{}, "conversation": "",
"prompt": nil, "truncation": "disabled", "reasoning": map[string]any{"summary": nil},
}); err != nil {
t.Fatalf("no-op native fields should be accepted: %v", err)
}
for param, value := range map[string]any{
"background": true, "conversation": "conv_1", "max_tool_calls": 2, "prompt": map[string]any{"id": "pmpt_1"}, "truncation": "auto",
} {
err := ValidateResponsesChatFallback(map[string]any{"input": "hello", param: value})
if ErrorCode(err) != "unsupported_response_parameter" || ErrorParam(err) != param {
t.Fatalf("expected precise rejection for %s, got %v param=%q", param, err, ErrorParam(err))
}
}
err := ValidateResponsesChatFallback(map[string]any{"input": []any{map[string]any{
"type": "custom_tool_call", "call_id": "call_1", "name": "tool", "input": "x", "namespace": "native-only",
}}})
if ErrorCode(err) != "unsupported_response_parameter" || ErrorParam(err) != "input[0].namespace" {
t.Fatalf("custom call namespace must be rejected precisely, got %v param=%q", err, ErrorParam(err))
}
}
func TestChatResultToResponseBuildsCompleteResponseWithRefusalLogprobsAndCustomTool(t *testing.T) {
request := map[string]any{
"background": false, "conversation": nil, "instructions": "be concise", "max_output_tokens": 300,
"max_tool_calls": nil, "metadata": map[string]any{"trace": "1"}, "moderation": map[string]any{"type": "auto"},
"parallel_tool_calls": true, "previous_response_id": "resp_parent", "prompt": nil, "prompt_cache_key": "cache",
"prompt_cache_options": map[string]any{"type": "ephemeral"}, "prompt_cache_retention": "in_memory",
"reasoning": map[string]any{"effort": "low"}, "safety_identifier": "safe", "service_tier": "default",
"temperature": 0.4, "text": map[string]any{"verbosity": "low"}, "tool_choice": "auto", "tools": []any{},
"top_logprobs": 2, "top_p": 0.9, "truncation": "disabled", "user": "user-1",
}
response := ChatResultToResponse(map[string]any{
"created": 1710000000, "service_tier": "priority", "moderation": map[string]any{"flagged": false},
"choices": []any{map[string]any{
"finish_reason": "stop", "logprobs": map[string]any{"content": []any{map[string]any{"token": "hello", "logprob": -0.1}}},
"message": map[string]any{
"role": "assistant", "content": "hello", "refusal": "cannot continue", "annotations": []any{map[string]any{"type": "url_citation"}},
"tool_calls": []any{
map[string]any{"id": "call_fn", "type": "function", "function": map[string]any{"name": "lookup", "arguments": "{\"x\":1}"}},
map[string]any{"id": "call_custom", "type": "custom", "custom": map[string]any{"name": "shell", "input": "pwd"}},
},
},
}},
"usage": map[string]any{"prompt_tokens": 2, "completion_tokens": 3, "total_tokens": 5},
}, "resp_12345678901234567890123456789012", "demo", request)
for _, key := range []string{
"id", "object", "created_at", "status", "completed_at", "error", "incomplete_details", "instructions", "metadata", "model", "output",
"parallel_tool_calls", "temperature", "tool_choice", "tools", "top_p", "background", "conversation", "max_output_tokens", "max_tool_calls",
"moderation", "output_text", "previous_response_id", "prompt", "prompt_cache_key", "prompt_cache_options", "prompt_cache_retention", "reasoning",
"safety_identifier", "service_tier", "text", "top_logprobs", "truncation", "usage", "user",
} {
if _, ok := response[key]; !ok {
t.Fatalf("complete fallback Response omitted %q: %+v", key, response)
}
}
output, _ := response["output"].([]any)
if len(output) != 3 {
t.Fatalf("expected message, function, and custom output items: %+v", output)
}
message, _ := output[0].(map[string]any)
parts, _ := message["content"].([]any)
text, _ := parts[0].(map[string]any)
refusal, _ := parts[1].(map[string]any)
custom, _ := output[2].(map[string]any)
if len(text["logprobs"].([]any)) != 1 || len(text["annotations"].([]any)) != 1 || refusal["type"] != "refusal" || custom["type"] != "custom_tool_call" || custom["input"] != "pwd" {
t.Fatalf("Response content/tool details changed: %+v", output)
}
if response["service_tier"] != "priority" {
t.Fatalf("upstream response service tier was not preserved: %+v", response)
}
}
func TestChatResponsesStreamAdapterEmitsRefusalLogprobsAndCustomToolLifecycle(t *testing.T) {
adapter := newChatResponsesStreamAdapter("resp_12345678901234567890123456789012", "demo")
events := make([]map[string]any, 0)
onDelta := func(event StreamDeltaEvent) error {
events = append(events, event.Event)
return nil
}
if err := adapter.delta(StreamDeltaEvent{Event: map[string]any{"choices": []any{map[string]any{
"logprobs": map[string]any{"content": []any{map[string]any{"token": "hi", "logprob": -0.1}}},
"delta": map[string]any{
"content": "hi", "refusal": "no",
"tool_calls": []any{map[string]any{"index": 0, "id": "call_custom", "type": "custom", "custom": map[string]any{"name": "shell", "input": "pw"}}},
},
}}}}, onDelta); err != nil {
t.Fatal(err)
}
if err := adapter.delta(StreamDeltaEvent{Event: map[string]any{"choices": []any{map[string]any{
"delta": map[string]any{"tool_calls": []any{map[string]any{"index": 0, "type": "custom", "custom": map[string]any{"input": "d"}}}},
}}}}, onDelta); err != nil {
t.Fatal(err)
}
result := ChatResultToResponse(map[string]any{"choices": []any{map[string]any{
"message": map[string]any{"role": "assistant", "content": "hi", "refusal": "no", "tool_calls": []any{
map[string]any{"id": "call_custom", "type": "custom", "custom": map[string]any{"name": "shell", "input": "pwd"}},
}},
}}}, adapter.publicID, "demo", map[string]any{})
if err := adapter.done(result, onDelta); err != nil {
t.Fatal(err)
}
types := make([]string, 0, len(events))
var sawLogprobs bool
for index, event := range events {
types = append(types, stringFromAny(event["type"]))
if intFromAny(event["sequence_number"]) != index {
t.Fatalf("non-contiguous sequence at %d: %+v", index, event)
}
if event["type"] == "response.output_text.delta" {
logprobs, _ := event["logprobs"].([]any)
sawLogprobs = len(logprobs) == 1
}
}
for _, required := range []string{
"response.refusal.delta", "response.refusal.done", "response.custom_tool_call_input.delta",
"response.custom_tool_call_input.done", "response.output_item.done", "response.completed",
} {
if !containsTestString(types, required) {
t.Fatalf("missing %s lifecycle event: %v", required, types)
}
}
if !sawLogprobs {
t.Fatalf("streamed output_text delta lost logprobs: %+v", events)
}
}
func mustJSON(t *testing.T, value any) []byte { func mustJSON(t *testing.T, value any) []byte {
t.Helper() t.Helper()
data, err := json.Marshal(value) data, err := json.Marshal(value)
@@ -294,7 +294,7 @@ func TestWriteCompatibleTaskResponseStreamsStructuredToolAndReasoningDeltas(t *t
if roleIndex < 0 || reasoningIndex < 0 || roleIndex > reasoningIndex { if roleIndex < 0 || reasoningIndex < 0 || roleIndex > reasoningIndex {
t.Fatalf("assistant role should be emitted before structured deltas: %s", body) t.Fatalf("assistant role should be emitted before structured deltas: %s", body)
} }
for _, want := range []string{`"system_fingerprint":"fp-test"`, `"created":1710000000`, `"reasoning_content":"tagged"`, `"content":"answer"`, `"tool_calls":[{"function":{"arguments":"{\"city\":\"Boston\"}","name":"legacy_lookup"}`, `"tool_calls":[{"function":{"arguments":"{\"q\":"`, `"finish_reason":"tool_calls"`, `"choices":[],"created":1710000000`, `"usage":{"completion_tokens":5,"prompt_tokens":4,"total_tokens":9}`, "data: [DONE]"} { for _, want := range []string{`"system_fingerprint":"fp-test"`, `"created":1710000000`, `"reasoning_content":"tagged"`, `"content":"answer"`, `"function_call":{"arguments":"{\"city\":\"Boston\"}","name":"legacy_lookup"}`, `"tool_calls":[{"function":{"arguments":"{\"q\":"`, `"finish_reason":"tool_calls"`, `"choices":[],"created":1710000000`, `"usage":{"completion_tokens":5,"prompt_tokens":4,"total_tokens":9}`, "data: [DONE]"} {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
t.Fatalf("SSE body missing %s: %s", want, body) t.Fatalf("SSE body missing %s: %s", want, body)
} }
+5 -5
View File
@@ -1327,7 +1327,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
// createAPIV1ChatCompletions godoc // createAPIV1ChatCompletions godoc
// @Summary 创建 Chat Completions // @Summary 创建 Chat Completions
// @Description /api/v1/chat/completions 同步执行:stream=true 返回 text/event-stream SSEstream=false 或未传返回兼容 JSON;该接口忽略 X-Async。 // @Description /api/v1/chat/completions 同步执行:除 Gateway 路由字段外透明转发当前及未来 OpenAI 字段与嵌套结构,完整保留 function/custom tools、tool_choice、tool_calls 和旧版 function_callstream=true 返回 text/event-stream SSEstream=false 或未传返回兼容 JSON;该接口忽略 X-Async。
// @Tags tasks // @Tags tasks
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -1350,7 +1350,7 @@ func (s *Server) createAPIV1ChatCompletions() http.Handler {
// openAIChatCompletionsDoc godoc // openAIChatCompletionsDoc godoc
// @Summary 创建 OpenAI Chat Completions // @Summary 创建 OpenAI Chat Completions
// @Description OpenAI-compatible Chat Completions 入口;仅接受官方字段及文档声明的 EasyAI 路由扩展,未知顶层字段返回 400 invalid_parameter // @Description OpenAI-compatible Chat Completions 入口。除 Gateway 路由字段外,原生请求会透明转发当前及未来 OpenAI 字段和嵌套结构;Gateway 仅覆盖上游模型名等受控字段。function/custom tools、tool_choice、tool_calls 与旧版 function_call 均保持标准语义
// @Tags chat // @Tags chat
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -1368,15 +1368,15 @@ func openAIChatCompletionsDoc() {}
// openAIResponsesDoc godoc // openAIResponsesDoc godoc
// @Summary 创建 OpenAI Responses // @Summary 创建 OpenAI Responses
// @Description 公开 OpenAI-compatible Responses 入口。模型声明 openai_responses 时原生转发,否则使用 Chat Completions 转换;store 缺省为 true。previous_response_id 严格绑定首次成功的平台模型和上游协议,链路不可用时不跨平台续接未提供 previous_response_id 时调用方管理完整状态Gateway 以本轮 input/messages 为准且不追加本地历史。 // @Description 公开 OpenAI-compatible Responses 入口。模型声明 openai_responses 时除 Gateway 路由字段外透明转发当前及未来 OpenAI 字段;否则仅对具有等价 Chat Completions 语义的请求执行转换。background、conversation、prompt template、context management、max_tool_calls、自动 truncation、reasoning summary、内置工具、MCP 和 namespace 等原生专属能力不会静默丢弃:路由会排除 Chat 候选,固定 Chat 链路则返回带参数路径的 unsupported_response_parameter。store 缺省为 true。previous_response_id 严格绑定首次成功的平台模型和上游协议,链路不可用时不跨平台续接未提供 previous_response_id 时调用方历史权威Gateway 不追加本地历史。
// @Tags responses // @Tags responses
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Produce text/event-stream // @Produce text/event-stream
// @Security BearerAuth // @Security BearerAuth
// @Param input body ResponsesRequest true "Responses 请求;Chat 回退支持自定义 function tools" // @Param input body ResponsesRequest true "Responses 请求;Chat 回退支持 function/custom tools 及所有可等价能力"
// @Success 200 {object} ResponsesCompatibleResponse // @Success 200 {object} ResponsesCompatibleResponse
// @Failure 400 {object} OpenAIErrorEnvelope "invalid_previous_response_id / unsupported_response_tool / unsupported_response_parameter" // @Failure 400 {object} OpenAIErrorEnvelope "invalid_previous_response_id / unsupported_response_parameter"
// @Failure 401 {object} OpenAIErrorEnvelope // @Failure 401 {object} OpenAIErrorEnvelope
// @Failure 402 {object} OpenAIErrorEnvelope // @Failure 402 {object} OpenAIErrorEnvelope
// @Failure 403 {object} OpenAIErrorEnvelope // @Failure 403 {object} OpenAIErrorEnvelope
+54 -18
View File
@@ -470,12 +470,14 @@ type ChatMessage struct {
ToolCallID string `json:"tool_call_id,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"`
ToolCalls interface{} `json:"tool_calls,omitempty"` ToolCalls interface{} `json:"tool_calls,omitempty"`
FunctionCall interface{} `json:"function_call,omitempty"` FunctionCall interface{} `json:"function_call,omitempty"`
Audio interface{} `json:"audio,omitempty"`
Refusal string `json:"refusal,omitempty"`
} }
type ResponsesRequest struct { type ResponsesRequest struct {
Model string `json:"model" example:"Doubao Seed 2.0 Pro"` Model string `json:"model" example:"Doubao Seed 2.0 Pro"`
Background *bool `json:"background,omitempty"` Background *bool `json:"background,omitempty"`
ContextManagement []map[string]interface{} `json:"context_management,omitempty"` ContextManagement interface{} `json:"context_management,omitempty"`
Conversation interface{} `json:"conversation,omitempty"` Conversation interface{} `json:"conversation,omitempty"`
Include []string `json:"include,omitempty"` Include []string `json:"include,omitempty"`
Input interface{} `json:"input"` Input interface{} `json:"input"`
@@ -507,15 +509,41 @@ type ResponsesRequest struct {
} }
type ResponsesCompatibleResponse struct { type ResponsesCompatibleResponse struct {
ID string `json:"id" example:"resp_0123456789abcdef0123456789abcdef"` ID string `json:"id" example:"resp_0123456789abcdef0123456789abcdef"`
Object string `json:"object" example:"response"` Object string `json:"object" example:"response"`
CreatedAt int64 `json:"created_at" example:"1710000000"` CreatedAt int64 `json:"created_at" example:"1710000000"`
Status string `json:"status" example:"completed"` Status string `json:"status" example:"completed" enums:"queued,in_progress,completed,incomplete,failed,cancelled"`
Model string `json:"model" example:"Doubao Seed 2.0 Pro"` CompletedAt *int64 `json:"completed_at"`
PreviousResponseID string `json:"previous_response_id,omitempty" example:"resp_abcdef0123456789abcdef0123456789"` Error interface{} `json:"error"`
Output []map[string]interface{} `json:"output"` IncompleteDetails interface{} `json:"incomplete_details"`
OutputText string `json:"output_text,omitempty" example:"Hello"` Instructions interface{} `json:"instructions"`
Usage map[string]interface{} `json:"usage,omitempty"` Metadata map[string]interface{} `json:"metadata"`
Model string `json:"model" example:"Doubao Seed 2.0 Pro"`
Output []map[string]interface{} `json:"output"`
ParallelToolCalls bool `json:"parallel_tool_calls"`
Temperature interface{} `json:"temperature"`
ToolChoice interface{} `json:"tool_choice"`
Tools []map[string]interface{} `json:"tools"`
TopP interface{} `json:"top_p"`
Background bool `json:"background"`
Conversation interface{} `json:"conversation"`
MaxOutputTokens interface{} `json:"max_output_tokens"`
MaxToolCalls interface{} `json:"max_tool_calls"`
Moderation interface{} `json:"moderation"`
OutputText string `json:"output_text" example:"Hello"`
PreviousResponseID interface{} `json:"previous_response_id"`
Prompt interface{} `json:"prompt"`
PromptCacheKey interface{} `json:"prompt_cache_key"`
PromptCacheOptions interface{} `json:"prompt_cache_options"`
PromptCacheRetention interface{} `json:"prompt_cache_retention"`
Reasoning interface{} `json:"reasoning"`
SafetyIdentifier interface{} `json:"safety_identifier"`
ServiceTier interface{} `json:"service_tier"`
Text interface{} `json:"text"`
TopLogprobs interface{} `json:"top_logprobs"`
Truncation interface{} `json:"truncation"`
Usage map[string]interface{} `json:"usage"`
User interface{} `json:"user"`
} }
type ImageGenerationRequest struct { type ImageGenerationRequest struct {
@@ -586,23 +614,31 @@ type CompatibleResponse struct {
} }
type ChatCompletionCompatibleResponse struct { type ChatCompletionCompatibleResponse struct {
ID string `json:"id" example:"chatcmpl-123"` ID string `json:"id" example:"chatcmpl-123"`
Object string `json:"object" example:"chat.completion"` Object string `json:"object" example:"chat.completion"`
Created int64 `json:"created,omitempty" example:"1710000000"` Created int64 `json:"created,omitempty" example:"1710000000"`
Model string `json:"model" example:"gpt-4o-mini"` Model string `json:"model" example:"gpt-4o-mini"`
Choices []ChatCompletionChoice `json:"choices"` Choices []ChatCompletionChoice `json:"choices"`
Usage *ChatCompletionUsage `json:"usage,omitempty"` Usage *ChatCompletionUsage `json:"usage,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
SystemFingerprint string `json:"system_fingerprint,omitempty"`
} }
type ChatCompletionChoice struct { type ChatCompletionChoice struct {
Index int `json:"index" example:"0"` Index int `json:"index" example:"0"`
Message ChatCompletionChoiceMessage `json:"message"` Message ChatCompletionChoiceMessage `json:"message"`
FinishReason string `json:"finish_reason,omitempty" example:"stop"` FinishReason string `json:"finish_reason,omitempty" example:"stop"`
Logprobs interface{} `json:"logprobs,omitempty"`
} }
type ChatCompletionChoiceMessage struct { type ChatCompletionChoiceMessage struct {
Role string `json:"role" example:"assistant"` Role string `json:"role" example:"assistant"`
Content string `json:"content" example:"Hello"` Content interface{} `json:"content"`
Refusal string `json:"refusal,omitempty"`
Audio interface{} `json:"audio,omitempty"`
Annotations interface{} `json:"annotations,omitempty"`
ToolCalls interface{} `json:"tool_calls,omitempty"`
FunctionCall interface{} `json:"function_call,omitempty"`
} }
type ChatCompletionUsage struct { type ChatCompletionUsage struct {
@@ -34,3 +34,16 @@ func TestResponsesStreamWriterForwardsStandardEventsAndNeverWritesDoneMarker(t *
t.Fatalf("Responses stream duplicated response.completed: %s", body) t.Fatalf("Responses stream duplicated response.completed: %s", body)
} }
} }
func TestResponsesStreamWriterUsesIncompleteTerminalWithoutAppendingCompleted(t *testing.T) {
recorder := httptest.NewRecorder()
writer := newCompatibleStreamWriter("responses", "demo", false)
writer.writeDelta(recorder, clients.StreamDeltaEvent{Event: map[string]any{
"type": "response.incomplete", "sequence_number": 3, "response": map[string]any{"id": "resp_123", "status": "incomplete"},
}})
writer.writeDone(recorder, map[string]any{"id": "resp_123", "status": "incomplete"})
body := recorder.Body.String()
if strings.Count(body, "event: response.incomplete") != 1 || strings.Contains(body, "event: response.completed") {
t.Fatalf("incomplete terminal event was duplicated or rewritten: %s", body)
}
}
+25 -2
View File
@@ -49,7 +49,7 @@ func (s *compatibleStreamWriter) writeDelta(w http.ResponseWriter, event clients
if sequence := intFromStreamValue(event.Event["sequence_number"]); sequence >= s.responseSequence { if sequence := intFromStreamValue(event.Event["sequence_number"]); sequence >= s.responseSequence {
s.responseSequence = sequence + 1 s.responseSequence = sequence + 1
} }
if eventType == "response.completed" { if isResponsesTerminalEvent(eventType) {
s.sentResponseDone = true s.sentResponseDone = true
} }
sendSSE(w, eventType, event.Event) sendSSE(w, eventType, event.Event)
@@ -84,7 +84,8 @@ func (s *compatibleStreamWriter) writeDone(w http.ResponseWriter, output map[str
if s.sentResponseDone { if s.sentResponseDone {
return return
} }
sendSSE(w, "response.completed", map[string]any{"type": "response.completed", "sequence_number": s.responseSequence, "response": output}) eventType := responsesTerminalEvent(output)
sendSSE(w, eventType, map[string]any{"type": eventType, "sequence_number": s.responseSequence, "response": output})
s.sentResponseDone = true s.sentResponseDone = true
return return
} }
@@ -106,6 +107,28 @@ func (s *compatibleStreamWriter) writeDone(w http.ResponseWriter, output map[str
s.writeDoneMarker(w) s.writeDoneMarker(w)
} }
func isResponsesTerminalEvent(eventType string) bool {
switch eventType {
case "response.completed", "response.incomplete", "response.failed", "response.cancelled":
return true
default:
return false
}
}
func responsesTerminalEvent(output map[string]any) string {
switch status, _ := output["status"].(string); status {
case "incomplete":
return "response.incomplete"
case "failed":
return "response.failed"
case "cancelled":
return "response.cancelled"
default:
return "response.completed"
}
}
func intFromStreamValue(value any) int { func intFromStreamValue(value any) int {
switch typed := value.(type) { switch typed := value.(type) {
case int: case int:
+14 -1
View File
@@ -83,7 +83,8 @@ func (s *Service) prepareResponseExecution(ctx context.Context, task store.Gatew
return result, nil return result, nil
} }
func prepareResponseCandidates(candidates []store.RuntimeModelCandidate, execution responseExecutionContext) ([]store.RuntimeModelCandidate, error) { func prepareResponseCandidates(candidates []store.RuntimeModelCandidate, execution responseExecutionContext, body map[string]any) ([]store.RuntimeModelCandidate, error) {
chatCompatibilityErr := clients.ValidateResponsesChatFallback(body)
if execution.PreviousChain != nil { if execution.PreviousChain != nil {
for _, candidate := range candidates { for _, candidate := range candidates {
if candidate.PlatformModelID != execution.PreviousChain.PlatformModelID { if candidate.PlatformModelID != execution.PreviousChain.PlatformModelID {
@@ -92,6 +93,9 @@ func prepareResponseCandidates(candidates []store.RuntimeModelCandidate, executi
if !candidateSupportsProtocol(candidate, execution.PreviousChain.UpstreamProtocol) { if !candidateSupportsProtocol(candidate, execution.PreviousChain.UpstreamProtocol) {
break break
} }
if execution.PreviousChain.UpstreamProtocol == clients.ProtocolOpenAIChatCompletions && chatCompatibilityErr != nil {
return nil, chatCompatibilityErr
}
candidate.ResponseProtocol = execution.PreviousChain.UpstreamProtocol candidate.ResponseProtocol = execution.PreviousChain.UpstreamProtocol
return []store.RuntimeModelCandidate{candidate}, nil return []store.RuntimeModelCandidate{candidate}, nil
} }
@@ -106,6 +110,7 @@ func prepareResponseCandidates(candidates []store.RuntimeModelCandidate, executi
items := make([]indexedCandidate, 0, len(candidates)) items := make([]indexedCandidate, 0, len(candidates))
hasAnthropicOnly := false hasAnthropicOnly := false
hasDeclaredUnsupported := false hasDeclaredUnsupported := false
hasChatCandidate := false
for index, candidate := range candidates { for index, candidate := range candidates {
protocols := candidateSupportedProtocols(candidate) protocols := candidateSupportedProtocols(candidate)
group := 1 group := 1
@@ -119,11 +124,19 @@ func prepareResponseCandidates(candidates []store.RuntimeModelCandidate, executi
hasAnthropicOnly = true hasAnthropicOnly = true
} }
continue continue
} else {
hasChatCandidate = true
if chatCompatibilityErr != nil {
continue
}
} }
candidate.ResponseProtocol = protocol candidate.ResponseProtocol = protocol
items = append(items, indexedCandidate{candidate: candidate, index: index, group: group}) items = append(items, indexedCandidate{candidate: candidate, index: index, group: group})
} }
if len(items) == 0 { if len(items) == 0 {
if hasChatCandidate && chatCompatibilityErr != nil {
return nil, chatCompatibilityErr
}
if hasAnthropicOnly { if hasAnthropicOnly {
return nil, &clients.ClientError{Code: "unsupported_model_protocol", Message: "the selected model only supports Anthropic Messages; the public Anthropic adapter is not available", StatusCode: http.StatusBadRequest} return nil, &clients.ClientError{Code: "unsupported_model_protocol", Message: "the selected model only supports Anthropic Messages; the public Anthropic adapter is not available", StatusCode: http.StatusBadRequest}
} }
+44 -5
View File
@@ -24,7 +24,7 @@ func TestPrepareResponseCandidatesPrioritizesNativeAndKeepsGroupOrder(t *testing
{PlatformModelID: "native-2", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIResponses)}, {PlatformModelID: "native-2", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIResponses)},
{PlatformModelID: "chat-2", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions)}, {PlatformModelID: "chat-2", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions)},
} }
prepared, err := prepareResponseCandidates(candidates, responseExecutionContext{}) prepared, err := prepareResponseCandidates(candidates, responseExecutionContext{}, map[string]any{"input": "hello"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -46,7 +46,7 @@ func TestPrepareResponseCandidatesPinsPreviousPlatformModelAndProtocol(t *testin
{PlatformModelID: "other", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions)}, {PlatformModelID: "other", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions)},
{PlatformModelID: "pinned", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions, clients.ProtocolOpenAIResponses)}, {PlatformModelID: "pinned", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions, clients.ProtocolOpenAIResponses)},
} }
prepared, err := prepareResponseCandidates(candidates, responseExecutionContext{PreviousChain: &chain}) prepared, err := prepareResponseCandidates(candidates, responseExecutionContext{PreviousChain: &chain}, map[string]any{"input": "hello"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -54,14 +54,53 @@ func TestPrepareResponseCandidatesPinsPreviousPlatformModelAndProtocol(t *testin
t.Fatalf("previous chain was not pinned: %+v", prepared) t.Fatalf("previous chain was not pinned: %+v", prepared)
} }
_, err = prepareResponseCandidates(candidates[:1], responseExecutionContext{PreviousChain: &chain}) _, err = prepareResponseCandidates(candidates[:1], responseExecutionContext{PreviousChain: &chain}, map[string]any{"input": "hello"})
if clients.ErrorCode(err) != "response_chain_unavailable" { if clients.ErrorCode(err) != "response_chain_unavailable" {
t.Fatalf("expected response_chain_unavailable, got %v", err) t.Fatalf("expected response_chain_unavailable, got %v", err)
} }
} }
func TestPrepareResponseCandidatesExcludesChatForNativeOnlyParameters(t *testing.T) {
candidates := []store.RuntimeModelCandidate{
{PlatformModelID: "chat", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions)},
{PlatformModelID: "native", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIResponses)},
}
prepared, err := prepareResponseCandidates(candidates, responseExecutionContext{}, map[string]any{"input": "hello", "background": true})
if err != nil {
t.Fatal(err)
}
if len(prepared) != 1 || prepared[0].PlatformModelID != "native" || prepared[0].ResponseProtocol != clients.ProtocolOpenAIResponses {
t.Fatalf("native-only request reached Chat candidates: %+v", prepared)
}
_, err = prepareResponseCandidates(candidates[:1], responseExecutionContext{}, map[string]any{"input": "hello", "prompt": map[string]any{"id": "pmpt_1"}})
if clients.ErrorCode(err) != "unsupported_response_parameter" || clients.ErrorParam(err) != "prompt" {
t.Fatalf("Chat-only routing must return precise incompatibility, got %v param=%q", err, clients.ErrorParam(err))
}
_, err = prepareResponseCandidates(candidates[:1], responseExecutionContext{}, map[string]any{
"input": []any{map[string]any{"type": "mcp_call", "name": "remote"}},
})
if clients.ErrorCode(err) != "unsupported_response_parameter" || clients.ErrorParam(err) != "input[0].type" {
t.Fatalf("native-only input item must exclude Chat with a precise path, got %v param=%q", err, clients.ErrorParam(err))
}
}
func TestPrepareResponseCandidatesRejectsNativeOnlyParameterOnPinnedChatChain(t *testing.T) {
chain := store.ResponseChain{PlatformModelID: "pinned", UpstreamProtocol: clients.ProtocolOpenAIChatCompletions}
candidates := []store.RuntimeModelCandidate{{
PlatformModelID: "pinned", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions, clients.ProtocolOpenAIResponses),
}}
_, err := prepareResponseCandidates(candidates, responseExecutionContext{PreviousChain: &chain}, map[string]any{
"input": "hello", "reasoning": map[string]any{"summary": "auto"},
})
if clients.ErrorCode(err) != "unsupported_response_parameter" || clients.ErrorParam(err) != "reasoning.summary" {
t.Fatalf("pinned Chat chain must return precise incompatibility, got %v param=%q", err, clients.ErrorParam(err))
}
}
func TestPrepareResponseCandidatesRejectsAnthropicOnly(t *testing.T) { func TestPrepareResponseCandidatesRejectsAnthropicOnly(t *testing.T) {
_, err := prepareResponseCandidates([]store.RuntimeModelCandidate{{Capabilities: responseProtocolCapability(clients.ProtocolAnthropicMessages)}}, responseExecutionContext{}) _, err := prepareResponseCandidates([]store.RuntimeModelCandidate{{Capabilities: responseProtocolCapability(clients.ProtocolAnthropicMessages)}}, responseExecutionContext{}, map[string]any{"input": "hello"})
if clients.ErrorCode(err) != "unsupported_model_protocol" { if clients.ErrorCode(err) != "unsupported_model_protocol" {
t.Fatalf("expected unsupported_model_protocol, got %v", err) t.Fatalf("expected unsupported_model_protocol, got %v", err)
} }
@@ -107,7 +146,7 @@ func TestCandidateProtocolOverrideAndExplicitEmpty(t *testing.T) {
if protocols := candidateSupportedProtocols(empty); len(protocols) != 0 { if protocols := candidateSupportedProtocols(empty); len(protocols) != 0 {
t.Fatalf("an explicitly empty protocol list must not become legacy Chat: %v", protocols) t.Fatalf("an explicitly empty protocol list must not become legacy Chat: %v", protocols)
} }
if _, err := prepareResponseCandidates([]store.RuntimeModelCandidate{empty}, responseExecutionContext{}); clients.ErrorCode(err) != "unsupported_model_protocol" { if _, err := prepareResponseCandidates([]store.RuntimeModelCandidate{empty}, responseExecutionContext{}, map[string]any{"input": "hello"}); clients.ErrorCode(err) != "unsupported_model_protocol" {
t.Fatalf("expected unsupported_model_protocol, got %v", err) t.Fatalf("expected unsupported_model_protocol, got %v", err)
} }
} }
+1 -1
View File
@@ -481,7 +481,7 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
return Result{Task: failed, Output: failed.Result}, err return Result{Task: failed, Output: failed.Result}, err
} }
if task.Kind == "responses" { if task.Kind == "responses" {
candidates, err = prepareResponseCandidates(candidates, responseExecution) candidates, err = prepareResponseCandidates(candidates, responseExecution, body)
if err != nil { if err != nil {
code, message := responseExecutionFailure(err) code, message := responseExecutionFailure(err)
s.recordFailedAttempt(ctx, failedAttemptRecord{Task: task, Body: body, AttemptNo: task.AttemptCount + 1, Code: code, Cause: err, Simulated: task.RunMode == "simulation", Scope: "response_protocol", Reason: code, ModelType: modelType}) s.recordFailedAttempt(ctx, failedAttemptRecord{Task: task, Body: body, AttemptNo: task.AttemptCount + 1, Code: code, Cause: err, Simulated: task.RunMode == "simulation", Scope: "response_protocol", Reason: code, ModelType: modelType})
+53 -7
View File
@@ -120,6 +120,10 @@ function functionCall(response) {
return (response.output || []).find((item) => item.type === 'function_call'); 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) { async function getTask(token, taskId) {
assert(taskId, 'response is missing X-Gateway-Task-Id'); assert(taskId, 'response is missing X-Gateway-Task-Id');
const response = await fetch(`${baseURL}/api/v1/tasks/${taskId}`, { const response = await fetch(`${baseURL}/api/v1/tasks/${taskId}`, {
@@ -182,8 +186,8 @@ for (const expected of modelCases) {
const toolOutput = `TOOL-${nonce}`; const toolOutput = `TOOL-${nonce}`;
const normalizedModel = expected.model.toLowerCase(); const normalizedModel = expected.model.toLowerCase();
const requiresNonThinkingToolMode = normalizedModel.startsWith('qwen3.7-') || normalizedModel.includes('deepseek-v4-pro'); const requiresNonThinkingToolMode = normalizedModel.startsWith('qwen3.7-') || normalizedModel.includes('deepseek-v4-pro');
const toolReasoning = requiresNonThinkingToolMode ? { reasoning: { effort: 'none' } } : {}; const toolReasoning = requiresNonThinkingToolMode ? { reasoning: { effort: 'none' } } : {};
const forcedToolChoice = 'required'; const forcedToolChoice = 'required';
const toolFirst = await requestStream(token, '/v1/responses', { const toolFirst = await requestStream(token, '/v1/responses', {
model: expected.model, model: expected.model,
input: '调用 lookup_verification_code 获取校验结果。', input: '调用 lookup_verification_code 获取校验结果。',
@@ -194,9 +198,9 @@ for (const expected of modelCases) {
parameters: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'], additionalProperties: false }, parameters: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'], additionalProperties: false },
strict: true, strict: true,
}], }],
tool_choice: forcedToolChoice, tool_choice: forcedToolChoice,
parallel_tool_calls: true, parallel_tool_calls: true,
...toolReasoning, ...toolReasoning,
store: true, store: true,
}); });
const call = functionCall(toolFirst.body); const call = functionCall(toolFirst.body);
@@ -207,25 +211,58 @@ for (const expected of modelCases) {
model: expected.model, model: expected.model,
previous_response_id: toolFirst.body.id, previous_response_id: toolFirst.body.id,
input: [{ type: 'function_call_output', call_id: call.call_id, output: JSON.stringify({ verification: toolOutput }) }], input: [{ type: 'function_call_output', call_id: call.call_id, output: JSON.stringify({ verification: toolOutput }) }],
...toolReasoning, ...toolReasoning,
store: true, store: true,
}); });
assert(responseText(toolSecond.body).includes(toolOutput), `${expected.model} did not consume function_call_output`); 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([ const auditedTasks = await Promise.all([
getTask(token, first.taskId), getTask(token, first.taskId),
getTask(token, second.taskId), getTask(token, second.taskId),
getTask(token, toolFirst.taskId), getTask(token, toolFirst.taskId),
getTask(token, toolSecond.taskId), getTask(token, toolSecond.taskId),
getTask(token, customFirst.taskId),
getTask(token, customSecond.taskId),
]); ]);
const ordinaryFirstAudit = auditSummary(auditedTasks[0], expected); const ordinaryFirstAudit = auditSummary(auditedTasks[0], expected);
const ordinarySecondAudit = auditSummary(auditedTasks[1], expected); const ordinarySecondAudit = auditSummary(auditedTasks[1], expected);
const toolFirstAudit = auditSummary(auditedTasks[2], expected); const toolFirstAudit = auditSummary(auditedTasks[2], expected);
const toolSecondAudit = auditSummary(auditedTasks[3], 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.parentResponseId === first.body.id, `${expected.model} ordinary parent response id mismatch`);
assert(ordinarySecondAudit.chainDepth === 1, `${expected.model} ordinary chain depth 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.parentResponseId === toolFirst.body.id, `${expected.model} tool parent response id mismatch`);
assert(toolSecondAudit.chainDepth === 1, `${expected.model} tool chain depth 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({ results.push({
model: expected.model, model: expected.model,
ordinaryConversation: { ordinaryConversation: {
@@ -238,8 +275,17 @@ for (const expected of modelCases) {
callId: call.call_id, callId: call.call_id,
first: toolFirstAudit, first: toolFirstAudit,
second: toolSecondAudit, second: toolSecondAudit,
firstEventTypes: toolFirst.events.map((event) => event.event), firstEventTypes: toolFirst.events.map((event) => event.event),
secondEventTypes: toolSecond.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),
}, },
}); });
} }