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:
+74
-19
@@ -6450,7 +6450,7 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "/api/v1/chat/completions 同步执行:stream=true 返回 text/event-stream SSE;stream=false 或未传返回兼容 JSON;该接口忽略 X-Async。",
|
||||
"description": "/api/v1/chat/completions 同步执行:除 Gateway 路由字段外透明转发当前及未来 OpenAI 字段与嵌套结构,完整保留 function/custom tools、tool_choice、tool_calls 和旧版 function_call;stream=true 返回 text/event-stream SSE,stream=false 或未传返回兼容 JSON;该接口忽略 X-Async。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -8566,7 +8566,7 @@
|
||||
"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": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -8580,7 +8580,7 @@
|
||||
"summary": "创建 OpenAI Responses",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Responses 请求;Chat 回退只支持自定义 function tools",
|
||||
"description": "Responses 请求;Chat 回退支持 function/custom tools 及所有可等价能力",
|
||||
"name": "input",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -8597,7 +8597,7 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid_previous_response_id / unsupported_response_tool / unsupported_response_parameter",
|
||||
"description": "invalid_previous_response_id / unsupported_response_parameter",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
|
||||
}
|
||||
@@ -10810,6 +10810,7 @@
|
||||
"type": "integer",
|
||||
"example": 0
|
||||
},
|
||||
"logprobs": {},
|
||||
"message": {
|
||||
"$ref": "#/definitions/httpapi.ChatCompletionChoiceMessage"
|
||||
}
|
||||
@@ -10818,14 +10819,18 @@
|
||||
"httpapi.ChatCompletionChoiceMessage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"example": "Hello"
|
||||
"annotations": {},
|
||||
"audio": {},
|
||||
"content": {},
|
||||
"function_call": {},
|
||||
"refusal": {
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"example": "assistant"
|
||||
}
|
||||
},
|
||||
"tool_calls": {}
|
||||
}
|
||||
},
|
||||
"httpapi.ChatCompletionCompatibleResponse": {
|
||||
@@ -10853,6 +10858,12 @@
|
||||
"type": "string",
|
||||
"example": "chat.completion"
|
||||
},
|
||||
"service_tier": {
|
||||
"type": "string"
|
||||
},
|
||||
"system_fingerprint": {
|
||||
"type": "string"
|
||||
},
|
||||
"usage": {
|
||||
"$ref": "#/definitions/httpapi.ChatCompletionUsage"
|
||||
}
|
||||
@@ -11045,11 +11056,15 @@
|
||||
"httpapi.ChatMessage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"audio": {},
|
||||
"content": {},
|
||||
"function_call": {},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"refusal": {
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"example": "user"
|
||||
@@ -12312,18 +12327,35 @@
|
||||
"httpapi.ResponsesCompatibleResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"background": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"completed_at": {
|
||||
"type": "integer"
|
||||
},
|
||||
"conversation": {},
|
||||
"created_at": {
|
||||
"type": "integer",
|
||||
"example": 1710000000
|
||||
},
|
||||
"error": {},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"example": "resp_0123456789abcdef0123456789abcdef"
|
||||
},
|
||||
"incomplete_details": {},
|
||||
"instructions": {},
|
||||
"max_output_tokens": {},
|
||||
"max_tool_calls": {},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"example": "Doubao Seed 2.0 Pro"
|
||||
},
|
||||
"moderation": {},
|
||||
"object": {
|
||||
"type": "string",
|
||||
"example": "response"
|
||||
@@ -12339,18 +12371,47 @@
|
||||
"type": "string",
|
||||
"example": "Hello"
|
||||
},
|
||||
"previous_response_id": {
|
||||
"type": "string",
|
||||
"example": "resp_abcdef0123456789abcdef0123456789"
|
||||
"parallel_tool_calls": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"previous_response_id": {},
|
||||
"prompt": {},
|
||||
"prompt_cache_key": {},
|
||||
"prompt_cache_options": {},
|
||||
"prompt_cache_retention": {},
|
||||
"reasoning": {},
|
||||
"safety_identifier": {},
|
||||
"service_tier": {},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"queued",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"incomplete",
|
||||
"failed",
|
||||
"cancelled"
|
||||
],
|
||||
"example": "completed"
|
||||
},
|
||||
"temperature": {},
|
||||
"text": {},
|
||||
"tool_choice": {},
|
||||
"tools": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"top_logprobs": {},
|
||||
"top_p": {},
|
||||
"truncation": {},
|
||||
"usage": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"user": {}
|
||||
}
|
||||
},
|
||||
"httpapi.ResponsesRequest": {
|
||||
@@ -12359,13 +12420,7 @@
|
||||
"background": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"context_management": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"context_management": {},
|
||||
"conversation": {},
|
||||
"include": {
|
||||
"type": "array",
|
||||
|
||||
+69
-18
@@ -154,17 +154,22 @@ definitions:
|
||||
index:
|
||||
example: 0
|
||||
type: integer
|
||||
logprobs: {}
|
||||
message:
|
||||
$ref: '#/definitions/httpapi.ChatCompletionChoiceMessage'
|
||||
type: object
|
||||
httpapi.ChatCompletionChoiceMessage:
|
||||
properties:
|
||||
content:
|
||||
example: Hello
|
||||
annotations: {}
|
||||
audio: {}
|
||||
content: {}
|
||||
function_call: {}
|
||||
refusal:
|
||||
type: string
|
||||
role:
|
||||
example: assistant
|
||||
type: string
|
||||
tool_calls: {}
|
||||
type: object
|
||||
httpapi.ChatCompletionCompatibleResponse:
|
||||
properties:
|
||||
@@ -184,6 +189,10 @@ definitions:
|
||||
object:
|
||||
example: chat.completion
|
||||
type: string
|
||||
service_tier:
|
||||
type: string
|
||||
system_fingerprint:
|
||||
type: string
|
||||
usage:
|
||||
$ref: '#/definitions/httpapi.ChatCompletionUsage'
|
||||
type: object
|
||||
@@ -323,10 +332,13 @@ definitions:
|
||||
type: object
|
||||
httpapi.ChatMessage:
|
||||
properties:
|
||||
audio: {}
|
||||
content: {}
|
||||
function_call: {}
|
||||
name:
|
||||
type: string
|
||||
refusal:
|
||||
type: string
|
||||
role:
|
||||
example: user
|
||||
type: string
|
||||
@@ -1195,15 +1207,29 @@ definitions:
|
||||
type: object
|
||||
httpapi.ResponsesCompatibleResponse:
|
||||
properties:
|
||||
background:
|
||||
type: boolean
|
||||
completed_at:
|
||||
type: integer
|
||||
conversation: {}
|
||||
created_at:
|
||||
example: 1710000000
|
||||
type: integer
|
||||
error: {}
|
||||
id:
|
||||
example: resp_0123456789abcdef0123456789abcdef
|
||||
type: string
|
||||
incomplete_details: {}
|
||||
instructions: {}
|
||||
max_output_tokens: {}
|
||||
max_tool_calls: {}
|
||||
metadata:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
model:
|
||||
example: Doubao Seed 2.0 Pro
|
||||
type: string
|
||||
moderation: {}
|
||||
object:
|
||||
example: response
|
||||
type: string
|
||||
@@ -1215,25 +1241,47 @@ definitions:
|
||||
output_text:
|
||||
example: Hello
|
||||
type: string
|
||||
previous_response_id:
|
||||
example: resp_abcdef0123456789abcdef0123456789
|
||||
type: string
|
||||
parallel_tool_calls:
|
||||
type: boolean
|
||||
previous_response_id: {}
|
||||
prompt: {}
|
||||
prompt_cache_key: {}
|
||||
prompt_cache_options: {}
|
||||
prompt_cache_retention: {}
|
||||
reasoning: {}
|
||||
safety_identifier: {}
|
||||
service_tier: {}
|
||||
status:
|
||||
enum:
|
||||
- queued
|
||||
- in_progress
|
||||
- completed
|
||||
- incomplete
|
||||
- failed
|
||||
- cancelled
|
||||
example: completed
|
||||
type: string
|
||||
temperature: {}
|
||||
text: {}
|
||||
tool_choice: {}
|
||||
tools:
|
||||
items:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
type: array
|
||||
top_logprobs: {}
|
||||
top_p: {}
|
||||
truncation: {}
|
||||
usage:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
user: {}
|
||||
type: object
|
||||
httpapi.ResponsesRequest:
|
||||
properties:
|
||||
background:
|
||||
type: boolean
|
||||
context_management:
|
||||
items:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
type: array
|
||||
context_management: {}
|
||||
conversation: {}
|
||||
include:
|
||||
items:
|
||||
@@ -8559,8 +8607,9 @@ paths:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: /api/v1/chat/completions 同步执行:stream=true 返回 text/event-stream
|
||||
SSE;stream=false 或未传返回兼容 JSON;该接口忽略 X-Async。
|
||||
description: /api/v1/chat/completions 同步执行:除 Gateway 路由字段外透明转发当前及未来 OpenAI 字段与嵌套结构,完整保留
|
||||
function/custom tools、tool_choice、tool_calls 和旧版 function_call;stream=true
|
||||
返回 text/event-stream SSE,stream=false 或未传返回兼容 JSON;该接口忽略 X-Async。
|
||||
parameters:
|
||||
- description: 该接口忽略此参数
|
||||
in: header
|
||||
@@ -9926,11 +9975,14 @@ paths:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
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 不追加本地历史。
|
||||
parameters:
|
||||
- description: Responses 请求;Chat 回退只支持自定义 function tools
|
||||
- description: Responses 请求;Chat 回退支持 function/custom tools 及所有可等价能力
|
||||
in: body
|
||||
name: input
|
||||
required: true
|
||||
@@ -9945,8 +9997,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.ResponsesCompatibleResponse'
|
||||
"400":
|
||||
description: invalid_previous_response_id / unsupported_response_tool /
|
||||
unsupported_response_parameter
|
||||
description: invalid_previous_response_id / unsupported_response_parameter
|
||||
schema:
|
||||
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
|
||||
"401":
|
||||
|
||||
@@ -50,26 +50,49 @@ func ValidateOpenAIReasoningEffort(value any) error {
|
||||
}
|
||||
|
||||
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"])
|
||||
_, explicitEffort := original["reasoning_effort"]
|
||||
_, explicitTemperature := original["temperature"]
|
||||
model := chatReasoningModelName(body, candidate)
|
||||
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)
|
||||
if temperature, ok := finiteFloatFromAny(body["temperature"]); ok && temperature < 0.6 {
|
||||
body["temperature"] = 0.6
|
||||
}
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if effort == "" || !isOpenAIReasoningEffort(effort) {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
resolved, state := resolveCandidateReasoningEffort(effort, candidate)
|
||||
if state == reasoningCapabilityUnsupported {
|
||||
if explicitEffort {
|
||||
return explicitParameterAdaptationError("reasoning_effort", "the selected upstream does not support reasoning_effort")
|
||||
}
|
||||
delete(body, "reasoning_effort")
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if resolved != "" {
|
||||
if explicitEffort && resolved != effort {
|
||||
return explicitParameterAdaptationError("reasoning_effort", "the selected upstream does not support the requested reasoning effort exactly")
|
||||
}
|
||||
effort = resolved
|
||||
}
|
||||
if explicitEffort {
|
||||
if err := validateExplicitProviderReasoningEffort(effort, candidate, model); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
body["reasoning_effort"] = effort
|
||||
|
||||
switch {
|
||||
@@ -82,9 +105,42 @@ func applyOpenAIChatReasoningParams(body map[string]any, candidate store.Runtime
|
||||
case isVolcesOpenAI(candidate):
|
||||
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) {
|
||||
_ = applyOpenAIResponsesReasoningParamsWithSource(body, candidate, nil)
|
||||
}
|
||||
|
||||
func applyOpenAIResponsesReasoningParamsWithSource(body map[string]any, candidate store.RuntimeModelCandidate, original map[string]any) error {
|
||||
reasoning, _ := body["reasoning"].(map[string]any)
|
||||
if reasoning != nil {
|
||||
reasoning = cloneBody(reasoning)
|
||||
@@ -93,6 +149,9 @@ func applyOpenAIResponsesReasoningParams(body map[string]any, candidate store.Ru
|
||||
if reasoning != nil {
|
||||
effort = normalizedReasoningString(reasoning["effort"])
|
||||
}
|
||||
originalReasoning, _ := original["reasoning"].(map[string]any)
|
||||
_, explicitEffort := originalReasoning["effort"]
|
||||
_, explicitTemperature := original["temperature"]
|
||||
model := chatReasoningModelName(body, candidate)
|
||||
qwen38 := isAliyunBailianOpenAI(candidate) && isAliyunQwen38MaxPreview(model)
|
||||
if qwen38 {
|
||||
@@ -101,13 +160,23 @@ func applyOpenAIResponsesReasoningParams(body map[string]any, candidate store.Ru
|
||||
effort = "low"
|
||||
}
|
||||
} 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) {
|
||||
resolved, state := resolveCandidateReasoningEffort(effort, candidate)
|
||||
if state == reasoningCapabilityUnsupported {
|
||||
if explicitEffort {
|
||||
return explicitParameterAdaptationError("reasoning.effort", "the selected upstream does not support reasoning.effort")
|
||||
}
|
||||
effort = ""
|
||||
} else if resolved != "" {
|
||||
if explicitEffort && resolved != effort {
|
||||
return explicitParameterAdaptationError("reasoning.effort", "the selected upstream does not support the requested reasoning effort exactly")
|
||||
}
|
||||
effort = resolved
|
||||
}
|
||||
}
|
||||
@@ -129,9 +198,17 @@ func applyOpenAIResponsesReasoningParams(body map[string]any, candidate store.Ru
|
||||
delete(body, "enable_thinking")
|
||||
if qwen38 {
|
||||
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
|
||||
}
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -1012,13 +1012,11 @@ func TestOpenAIClientChatRequestNormalizesToolContext(t *testing.T) {
|
||||
}
|
||||
assistant, _ := messages[0].(map[string]any)
|
||||
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)
|
||||
toolCall, _ := toolCalls[0].(map[string]any)
|
||||
function, _ := toolCall["function"].(map[string]any)
|
||||
function, _ := assistant["function_call"].(map[string]any)
|
||||
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)
|
||||
if toolMessage["tool_call_id"] != "call_0" || toolMessage["toolCallId"] != nil {
|
||||
@@ -1116,14 +1114,18 @@ func TestOpenAIClientChatResponseNormalizesToolCallFormats(t *testing.T) {
|
||||
if message["content"] != "calling tools" {
|
||||
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 {
|
||||
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)
|
||||
if len(toolCalls) != 3 {
|
||||
t.Fatalf("expected 3 normalized tool calls, got %+v", message)
|
||||
if len(toolCalls) != 2 {
|
||||
t.Fatalf("expected 2 normalized tool calls plus legacy function_call, got %+v", message)
|
||||
}
|
||||
assertToolCall := func(index int, id string, name string, arguments string) {
|
||||
t.Helper()
|
||||
@@ -1134,8 +1136,7 @@ func TestOpenAIClientChatResponseNormalizesToolCallFormats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
assertToolCall(0, "call_camel", "camel_lookup", "{\"city\":\"SF\"}")
|
||||
assertToolCall(1, "call_1", "legacy_lookup", "{\"city\":\"NYC\"}")
|
||||
assertToolCall(2, "toolu_1", "anthropic_lookup", "{\"city\":\"Boston\"}")
|
||||
assertToolCall(1, "toolu_1", "anthropic_lookup", "{\"city\":\"Boston\"}")
|
||||
}
|
||||
|
||||
func TestOpenAIClientChatStreamContract(t *testing.T) {
|
||||
@@ -1393,19 +1394,21 @@ func TestOpenAIClientChatStreamNormalizesToolCallFormats(t *testing.T) {
|
||||
if len(captured) != 3 {
|
||||
t.Fatalf("unexpected captured events: %+v", captured)
|
||||
}
|
||||
for _, event := range captured {
|
||||
for index, event := range captured {
|
||||
choices, _ := event.Event["choices"].([]any)
|
||||
choice, _ := choices[0].(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 {
|
||||
t.Fatalf("functionCall should be converted away: %+v", event.Event)
|
||||
}
|
||||
if _, ok := delta["toolCall"]; ok {
|
||||
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)
|
||||
choice, _ := choices[0].(map[string]any)
|
||||
|
||||
@@ -202,6 +202,9 @@ func decodeOpenAIStreamReader(reader io.Reader, onDelta StreamDelta) (map[string
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||
rawLines := make([]string, 0)
|
||||
parts := make([]string, 0)
|
||||
refusalParts := make([]string, 0)
|
||||
streamLogprobs := make([]any, 0)
|
||||
streamAnnotations := make([]any, 0)
|
||||
reasoningParts := make([]string, 0)
|
||||
var last map[string]any
|
||||
var usage Usage
|
||||
@@ -232,6 +235,11 @@ func decodeOpenAIStreamReader(reader io.Reader, onDelta StreamDelta) (map[string
|
||||
if 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)
|
||||
if reason := streamEventFinishReason(event); reason != "" {
|
||||
finishReason = reason
|
||||
@@ -259,7 +267,7 @@ func decodeOpenAIStreamReader(reader io.Reader, onDelta StreamDelta) (map[string
|
||||
}
|
||||
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) {
|
||||
@@ -270,8 +278,8 @@ func decodeOpenAIStream(raw []byte) (map[string]any, bool) {
|
||||
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 {
|
||||
if len(parts) == 0 && len(reasoningParts) == 0 && len(toolCalls) == 0 {
|
||||
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(refusalParts) == 0 && len(streamLogprobs) == 0 && len(streamAnnotations) == 0 && len(reasoningParts) == 0 && len(toolCalls) == 0 {
|
||||
return last
|
||||
}
|
||||
message := map[string]any{
|
||||
@@ -281,6 +289,12 @@ func buildOpenAIStreamResult(last map[string]any, parts []string, reasoningParts
|
||||
if len(reasoningParts) > 0 {
|
||||
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 {
|
||||
message["tool_calls"] = sortedStreamToolCalls(toolCalls)
|
||||
}
|
||||
@@ -288,15 +302,17 @@ func buildOpenAIStreamResult(last map[string]any, parts []string, reasoningParts
|
||||
finishReason = "stop"
|
||||
}
|
||||
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{
|
||||
"id": stringFromAny(firstPresent(last["id"], "chatcmpl-stream")),
|
||||
"object": "chat.completion",
|
||||
"model": stringFromAny(last["model"]),
|
||||
"choices": []any{map[string]any{
|
||||
"index": 0,
|
||||
"message": message,
|
||||
"finish_reason": finishReason,
|
||||
}},
|
||||
"id": stringFromAny(firstPresent(last["id"], "chatcmpl-stream")),
|
||||
"object": "chat.completion",
|
||||
"model": stringFromAny(last["model"]),
|
||||
"choices": []any{choice},
|
||||
}
|
||||
if usage.TotalTokens > 0 {
|
||||
usageMap := map[string]any{
|
||||
@@ -314,6 +330,42 @@ func buildOpenAIStreamResult(last map[string]any, parts []string, reasoningParts
|
||||
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 将后续请求里的工具调用上下文还原为
|
||||
// OpenAI Chat Completions 标准格式,便于再次发送给 OpenAI-compatible 上游。
|
||||
func NormalizeChatCompletionRequestBody(body map[string]any) map[string]any {
|
||||
@@ -333,7 +385,7 @@ func NormalizeChatCompletionRequestBody(body map[string]any) map[string]any {
|
||||
continue
|
||||
}
|
||||
copied := cloneMapAny(message)
|
||||
normalizeToolCallsContainer(copied, false)
|
||||
normalizeRequestToolCallsContainer(copied)
|
||||
normalizeToolMessageFields(copied)
|
||||
toolMessages, cleanContent, changed := toolResultMessagesFromContent(copied["content"])
|
||||
if changed {
|
||||
@@ -355,6 +407,67 @@ func NormalizeChatCompletionRequestBody(body map[string]any) map[string]any {
|
||||
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 {
|
||||
if source == nil {
|
||||
return nil
|
||||
@@ -446,14 +559,27 @@ func normalizeToolCallsContainer(container map[string]any, stream bool) {
|
||||
if container == nil {
|
||||
return
|
||||
}
|
||||
toolCalls := make([]any, 0)
|
||||
for _, rawToolCall := range rawToolCallValues(container) {
|
||||
for _, normalized := range normalizeRawToolCalls(rawToolCall, len(toolCalls), stream) {
|
||||
toolCalls = append(toolCalls, normalized)
|
||||
toolCalls := canonicalToolCalls(container["tool_calls"], stream)
|
||||
for _, key := range []string{"tool_call", "toolCall", "toolCalls"} {
|
||||
if rawToolCall, ok := container[key]; ok {
|
||||
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 {
|
||||
toolCalls = append(toolCalls, contentToolCalls...)
|
||||
for _, normalized := range contentToolCalls {
|
||||
toolCalls = append(toolCalls, normalized)
|
||||
}
|
||||
setNormalizedContent(container, cleanContent, stream)
|
||||
}
|
||||
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 {
|
||||
container["tool_calls"] = toolCalls
|
||||
}
|
||||
for _, key := range []string{"tool_call", "toolCall", "toolCalls", "function_call", "functionCall"} {
|
||||
delete(container, key)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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"])
|
||||
if len(functionSource) == 0 {
|
||||
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 {
|
||||
choice, _ := rawChoice.(map[string]any)
|
||||
delta, _ := choice["delta"].(map[string]any)
|
||||
rawToolCalls, _ := delta["tool_calls"].([]any)
|
||||
rawToolCalls := streamToolCallsFromDelta(delta)
|
||||
for _, rawToolCall := range rawToolCalls {
|
||||
incoming, _ := rawToolCall.(map[string]any)
|
||||
index := intFromAny(incoming["index"])
|
||||
@@ -990,25 +1127,39 @@ func aggregateStreamToolCalls(event map[string]any, toolCalls map[int]map[string
|
||||
current[key] = value
|
||||
}
|
||||
}
|
||||
incomingFn, _ := incoming["function"].(map[string]any)
|
||||
if len(incomingFn) == 0 {
|
||||
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
|
||||
}
|
||||
aggregateStreamToolPayload(current, incoming, "function", "arguments")
|
||||
aggregateStreamToolPayload(current, incoming, "custom", "input")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
indices := make([]int, 0, len(toolCalls))
|
||||
for index := range toolCalls {
|
||||
|
||||
@@ -54,10 +54,14 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
|
||||
return Response{}, normalizeErr
|
||||
}
|
||||
body = normalizedBody
|
||||
applyOpenAIChatReasoningParams(body, request.Candidate)
|
||||
if err := applyOpenAIChatReasoningParamsWithSource(body, request.Candidate, openAIAdaptationSource(request, endpointKind)); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
body = FilterOpenAIChatRequestBody(body)
|
||||
} 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)
|
||||
if _, hasInput := body["input"]; !hasInput {
|
||||
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()
|
||||
correctionScope := newParameterCorrectionScope(request, endpointKind)
|
||||
correctionEnabled := endpointKind == "chat.completions" || endpointKind == "responses"
|
||||
protectedCorrections := callerProtectedCorrectionParameters(request, endpointKind)
|
||||
if correctionEnabled {
|
||||
c.Corrections.apply(correctionScope, body)
|
||||
c.Corrections.apply(correctionScope, body, protectedCorrections)
|
||||
}
|
||||
provisionalRules := make([]parameterCorrectionRule, 0, 2)
|
||||
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("Authorization", "Bearer "+apiKey)
|
||||
applyUpstreamIdempotency(req, request)
|
||||
if err := notifySubmissionStarted(request); err != nil {
|
||||
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())
|
||||
}
|
||||
seenCorrectionErrors[fingerprint] = struct{}{}
|
||||
rule, safe := deriveParameterCorrection(upstreamErr, body, request.Candidate)
|
||||
rule, safe := deriveParameterCorrection(upstreamErr, body, request.Candidate, protectedCorrections)
|
||||
if !safe || !applyParameterCorrectionRule(body, rule) {
|
||||
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 adapter *chatResponsesStreamAdapter
|
||||
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) }
|
||||
}
|
||||
if stream {
|
||||
@@ -212,6 +218,19 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
|
||||
}, 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) {
|
||||
if endpointKind != "images.generations" && endpointKind != "images.edits" {
|
||||
return
|
||||
@@ -541,12 +560,18 @@ func ensureOpenAIStreamUsage(body map[string]any, kind string, stream bool) {
|
||||
return
|
||||
}
|
||||
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 {
|
||||
streamOptions[key] = value
|
||||
}
|
||||
}
|
||||
streamOptions["include_usage"] = true
|
||||
if _, explicit := streamOptions["include_usage"]; !explicit {
|
||||
streamOptions["include_usage"] = true
|
||||
}
|
||||
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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// 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
|
||||
// ingress, but only protocol fields (plus controlled provider adaptations) are
|
||||
// allowed across the upstream boundary.
|
||||
// reference. They document and test the currently known surface, but are not an
|
||||
// upstream allowlist: unknown fields may be introduced by OpenAI after a
|
||||
// Gateway release and must remain transparent.
|
||||
var openAIChatRequestParameters = stringSet(
|
||||
"messages", "model", "audio", "frequency_penalty", "function_call", "functions",
|
||||
"logit_bias", "logprobs", "max_completion_tokens", "max_tokens", "metadata",
|
||||
@@ -50,56 +44,36 @@ var controlledOpenAIChatProviderParameters = stringSet(
|
||||
var controlledOpenAIResponsesProviderParameters = stringSet("presence_penalty", "frequency_penalty")
|
||||
|
||||
func ValidateOpenAIRequestParameters(kind string, body map[string]any) error {
|
||||
allowed := openAIChatRequestParameters
|
||||
if kind == "responses" {
|
||||
allowed = openAIResponsesRequestParameters
|
||||
}
|
||||
unknown := make([]string, 0)
|
||||
for key := range body {
|
||||
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,
|
||||
}
|
||||
// OpenAI-compatible public endpoints intentionally accept future official
|
||||
// fields. Provider validation remains authoritative for fields the selected
|
||||
// upstream does not support.
|
||||
_ = kind
|
||||
_ = body
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
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))
|
||||
for key, value := range body {
|
||||
if _, ok := allowed[key]; ok {
|
||||
if _, ok := controlled[key]; ok {
|
||||
out[key] = value
|
||||
continue
|
||||
}
|
||||
if _, ok := extensions[key]; ok {
|
||||
out[key] = value
|
||||
if _, internal := gatewayOpenAIRequestExtensions[key]; internal {
|
||||
continue
|
||||
}
|
||||
if _, internal := protocolInternal[key]; internal {
|
||||
continue
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestOpenAIChatOfficialParametersSurviveBoundary(t *testing.T) {
|
||||
body := map[string]any{}
|
||||
@@ -11,7 +8,7 @@ func TestOpenAIChatOfficialParametersSurviveBoundary(t *testing.T) {
|
||||
body[key] = "sentinel-" + key
|
||||
}
|
||||
body["conversationId"] = "internal"
|
||||
body["unknown"] = "must-not-leak"
|
||||
body["future_official_field"] = map[string]any{"nested": []any{"must-survive"}}
|
||||
|
||||
filtered := FilterOpenAIChatRequestBody(body)
|
||||
for key := range openAIChatRequestParameters {
|
||||
@@ -19,7 +16,10 @@ func TestOpenAIChatOfficialParametersSurviveBoundary(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("internal/unknown parameter %q leaked upstream", key)
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func TestOpenAIResponsesOfficialParametersSurviveBoundary(t *testing.T) {
|
||||
body[key] = "sentinel-" + key
|
||||
}
|
||||
body["request_id"] = "internal"
|
||||
body["unknown"] = "must-not-leak"
|
||||
body["future_official_field"] = map[string]any{"nested": []any{"must-survive"}}
|
||||
|
||||
filtered := FilterOpenAIResponsesRequestBody(body)
|
||||
for key := range openAIResponsesRequestParameters {
|
||||
@@ -40,20 +40,19 @@ func TestOpenAIResponsesOfficialParametersSurviveBoundary(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("internal/unknown parameter %q leaked upstream", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOpenAIRequestParametersRejectsUnknownTopLevelField(t *testing.T) {
|
||||
err := ValidateOpenAIRequestParameters("responses", map[string]any{"model": "demo", "input": "hello", "rogue": true})
|
||||
if err == nil || ErrorCode(err) != "invalid_parameter" || !strings.Contains(err.Error(), "rogue") {
|
||||
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))
|
||||
func TestValidateOpenAIRequestParametersAcceptsFutureTopLevelField(t *testing.T) {
|
||||
if err := ValidateOpenAIRequestParameters("responses", map[string]any{"model": "demo", "input": "hello", "future_official_field": true}); err != nil {
|
||||
t.Fatalf("future Responses fields must remain forward compatible, got %v", err)
|
||||
}
|
||||
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)
|
||||
@@ -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 {
|
||||
return nil
|
||||
}
|
||||
@@ -127,6 +127,9 @@ func (cache *ParameterCorrectionCache) apply(scope parameterCorrectionScope, bod
|
||||
applied := make([]string, 0, len(elements))
|
||||
for _, element := range elements {
|
||||
rule := element.Value.(parameterCorrectionCacheEntry).rule
|
||||
if correctionParamProtected(rule.Param, protected...) {
|
||||
continue
|
||||
}
|
||||
if applyParameterCorrectionRule(body, rule) {
|
||||
applied = append(applied, rule.Param)
|
||||
cache.lru.MoveToFront(element)
|
||||
@@ -183,7 +186,7 @@ func (cache *ParameterCorrectionCache) size() int {
|
||||
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
|
||||
if !errors.As(err, &clientErr) || (clientErr.StatusCode != 400 && clientErr.StatusCode != 422) {
|
||||
return parameterCorrectionRule{}, false
|
||||
@@ -196,9 +199,15 @@ func deriveParameterCorrection(err error, body map[string]any, candidate store.R
|
||||
if !isSafeCorrectionParam(param) {
|
||||
return parameterCorrectionRule{}, false
|
||||
}
|
||||
if correctionParamProtected(param, protected) {
|
||||
return parameterCorrectionRule{}, false
|
||||
}
|
||||
lowerMessage := strings.ToLower(message)
|
||||
lowerCode := strings.ToLower(code)
|
||||
if conflictRule, ok := deriveConflictCorrection(param, lowerMessage); ok {
|
||||
if correctionParamProtected(conflictRule.Param, protected) {
|
||||
return parameterCorrectionRule{}, false
|
||||
}
|
||||
return conflictRule, true
|
||||
}
|
||||
if strings.Contains(lowerMessage, "unknown parameter") ||
|
||||
@@ -228,6 +237,76 @@ func deriveParameterCorrection(err error, body map[string]any, candidate store.R
|
||||
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 {
|
||||
var clientErr *ClientError
|
||||
if errors.As(err, &clientErr) {
|
||||
|
||||
@@ -296,3 +296,149 @@ func TestParameterCorrectionCacheIsConcurrentAndBounded(t *testing.T) {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
response := ChatResultToResponse(map[string]any{
|
||||
"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) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
@@ -277,6 +363,7 @@ func TestChatResponsesStreamAdapterEmitsTextItemLifecycle(t *testing.T) {
|
||||
want := []string{
|
||||
"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.completed",
|
||||
}
|
||||
if strings.Join(events, ",") != strings.Join(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) {
|
||||
_, err := ResponsesRequestToChat(map[string]any{"input": "hello", "tools": []any{map[string]any{"type": "web_search_preview"}}}, nil)
|
||||
if ErrorCode(err) != "unsupported_response_tool" {
|
||||
t.Fatalf("expected unsupported_response_tool, got %v", err)
|
||||
if ErrorCode(err) != "unsupported_response_parameter" || ErrorParam(err) != "tools[0].type" {
|
||||
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)
|
||||
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 {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
|
||||
@@ -294,7 +294,7 @@ func TestWriteCompatibleTaskResponseStreamsStructuredToolAndReasoningDeltas(t *t
|
||||
if roleIndex < 0 || reasoningIndex < 0 || roleIndex > reasoningIndex {
|
||||
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) {
|
||||
t.Fatalf("SSE body missing %s: %s", want, body)
|
||||
}
|
||||
|
||||
@@ -1327,7 +1327,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
|
||||
// createAPIV1ChatCompletions godoc
|
||||
// @Summary 创建 Chat Completions
|
||||
// @Description /api/v1/chat/completions 同步执行:stream=true 返回 text/event-stream SSE;stream=false 或未传返回兼容 JSON;该接口忽略 X-Async。
|
||||
// @Description /api/v1/chat/completions 同步执行:除 Gateway 路由字段外透明转发当前及未来 OpenAI 字段与嵌套结构,完整保留 function/custom tools、tool_choice、tool_calls 和旧版 function_call;stream=true 返回 text/event-stream SSE,stream=false 或未传返回兼容 JSON;该接口忽略 X-Async。
|
||||
// @Tags tasks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -1350,7 +1350,7 @@ func (s *Server) createAPIV1ChatCompletions() http.Handler {
|
||||
|
||||
// openAIChatCompletionsDoc godoc
|
||||
// @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
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -1368,15 +1368,15 @@ func openAIChatCompletionsDoc() {}
|
||||
|
||||
// openAIResponsesDoc godoc
|
||||
// @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
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Produce text/event-stream
|
||||
// @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
|
||||
// @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 402 {object} OpenAIErrorEnvelope
|
||||
// @Failure 403 {object} OpenAIErrorEnvelope
|
||||
|
||||
@@ -470,12 +470,14 @@ type ChatMessage struct {
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
ToolCalls interface{} `json:"tool_calls,omitempty"`
|
||||
FunctionCall interface{} `json:"function_call,omitempty"`
|
||||
Audio interface{} `json:"audio,omitempty"`
|
||||
Refusal string `json:"refusal,omitempty"`
|
||||
}
|
||||
|
||||
type ResponsesRequest struct {
|
||||
Model string `json:"model" example:"Doubao Seed 2.0 Pro"`
|
||||
Background *bool `json:"background,omitempty"`
|
||||
ContextManagement []map[string]interface{} `json:"context_management,omitempty"`
|
||||
ContextManagement interface{} `json:"context_management,omitempty"`
|
||||
Conversation interface{} `json:"conversation,omitempty"`
|
||||
Include []string `json:"include,omitempty"`
|
||||
Input interface{} `json:"input"`
|
||||
@@ -507,15 +509,41 @@ type ResponsesRequest struct {
|
||||
}
|
||||
|
||||
type ResponsesCompatibleResponse struct {
|
||||
ID string `json:"id" example:"resp_0123456789abcdef0123456789abcdef"`
|
||||
Object string `json:"object" example:"response"`
|
||||
CreatedAt int64 `json:"created_at" example:"1710000000"`
|
||||
Status string `json:"status" example:"completed"`
|
||||
Model string `json:"model" example:"Doubao Seed 2.0 Pro"`
|
||||
PreviousResponseID string `json:"previous_response_id,omitempty" example:"resp_abcdef0123456789abcdef0123456789"`
|
||||
Output []map[string]interface{} `json:"output"`
|
||||
OutputText string `json:"output_text,omitempty" example:"Hello"`
|
||||
Usage map[string]interface{} `json:"usage,omitempty"`
|
||||
ID string `json:"id" example:"resp_0123456789abcdef0123456789abcdef"`
|
||||
Object string `json:"object" example:"response"`
|
||||
CreatedAt int64 `json:"created_at" example:"1710000000"`
|
||||
Status string `json:"status" example:"completed" enums:"queued,in_progress,completed,incomplete,failed,cancelled"`
|
||||
CompletedAt *int64 `json:"completed_at"`
|
||||
Error interface{} `json:"error"`
|
||||
IncompleteDetails interface{} `json:"incomplete_details"`
|
||||
Instructions interface{} `json:"instructions"`
|
||||
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 {
|
||||
@@ -586,23 +614,31 @@ type CompatibleResponse struct {
|
||||
}
|
||||
|
||||
type ChatCompletionCompatibleResponse struct {
|
||||
ID string `json:"id" example:"chatcmpl-123"`
|
||||
Object string `json:"object" example:"chat.completion"`
|
||||
Created int64 `json:"created,omitempty" example:"1710000000"`
|
||||
Model string `json:"model" example:"gpt-4o-mini"`
|
||||
Choices []ChatCompletionChoice `json:"choices"`
|
||||
Usage *ChatCompletionUsage `json:"usage,omitempty"`
|
||||
ID string `json:"id" example:"chatcmpl-123"`
|
||||
Object string `json:"object" example:"chat.completion"`
|
||||
Created int64 `json:"created,omitempty" example:"1710000000"`
|
||||
Model string `json:"model" example:"gpt-4o-mini"`
|
||||
Choices []ChatCompletionChoice `json:"choices"`
|
||||
Usage *ChatCompletionUsage `json:"usage,omitempty"`
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
SystemFingerprint string `json:"system_fingerprint,omitempty"`
|
||||
}
|
||||
|
||||
type ChatCompletionChoice struct {
|
||||
Index int `json:"index" example:"0"`
|
||||
Message ChatCompletionChoiceMessage `json:"message"`
|
||||
FinishReason string `json:"finish_reason,omitempty" example:"stop"`
|
||||
Logprobs interface{} `json:"logprobs,omitempty"`
|
||||
}
|
||||
|
||||
type ChatCompletionChoiceMessage struct {
|
||||
Role string `json:"role" example:"assistant"`
|
||||
Content string `json:"content" example:"Hello"`
|
||||
Role string `json:"role" example:"assistant"`
|
||||
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 {
|
||||
|
||||
@@ -34,3 +34,16 @@ func TestResponsesStreamWriterForwardsStandardEventsAndNeverWritesDoneMarker(t *
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func (s *compatibleStreamWriter) writeDelta(w http.ResponseWriter, event clients
|
||||
if sequence := intFromStreamValue(event.Event["sequence_number"]); sequence >= s.responseSequence {
|
||||
s.responseSequence = sequence + 1
|
||||
}
|
||||
if eventType == "response.completed" {
|
||||
if isResponsesTerminalEvent(eventType) {
|
||||
s.sentResponseDone = true
|
||||
}
|
||||
sendSSE(w, eventType, event.Event)
|
||||
@@ -84,7 +84,8 @@ func (s *compatibleStreamWriter) writeDone(w http.ResponseWriter, output map[str
|
||||
if s.sentResponseDone {
|
||||
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
|
||||
return
|
||||
}
|
||||
@@ -106,6 +107,28 @@ func (s *compatibleStreamWriter) writeDone(w http.ResponseWriter, output map[str
|
||||
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 {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
|
||||
@@ -83,7 +83,8 @@ func (s *Service) prepareResponseExecution(ctx context.Context, task store.Gatew
|
||||
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 {
|
||||
for _, candidate := range candidates {
|
||||
if candidate.PlatformModelID != execution.PreviousChain.PlatformModelID {
|
||||
@@ -92,6 +93,9 @@ func prepareResponseCandidates(candidates []store.RuntimeModelCandidate, executi
|
||||
if !candidateSupportsProtocol(candidate, execution.PreviousChain.UpstreamProtocol) {
|
||||
break
|
||||
}
|
||||
if execution.PreviousChain.UpstreamProtocol == clients.ProtocolOpenAIChatCompletions && chatCompatibilityErr != nil {
|
||||
return nil, chatCompatibilityErr
|
||||
}
|
||||
candidate.ResponseProtocol = execution.PreviousChain.UpstreamProtocol
|
||||
return []store.RuntimeModelCandidate{candidate}, nil
|
||||
}
|
||||
@@ -106,6 +110,7 @@ func prepareResponseCandidates(candidates []store.RuntimeModelCandidate, executi
|
||||
items := make([]indexedCandidate, 0, len(candidates))
|
||||
hasAnthropicOnly := false
|
||||
hasDeclaredUnsupported := false
|
||||
hasChatCandidate := false
|
||||
for index, candidate := range candidates {
|
||||
protocols := candidateSupportedProtocols(candidate)
|
||||
group := 1
|
||||
@@ -119,11 +124,19 @@ func prepareResponseCandidates(candidates []store.RuntimeModelCandidate, executi
|
||||
hasAnthropicOnly = true
|
||||
}
|
||||
continue
|
||||
} else {
|
||||
hasChatCandidate = true
|
||||
if chatCompatibilityErr != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
candidate.ResponseProtocol = protocol
|
||||
items = append(items, indexedCandidate{candidate: candidate, index: index, group: group})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
if hasChatCandidate && chatCompatibilityErr != nil {
|
||||
return nil, chatCompatibilityErr
|
||||
}
|
||||
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}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestPrepareResponseCandidatesPrioritizesNativeAndKeepsGroupOrder(t *testing
|
||||
{PlatformModelID: "native-2", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIResponses)},
|
||||
{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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func TestPrepareResponseCandidatesPinsPreviousPlatformModelAndProtocol(t *testin
|
||||
{PlatformModelID: "other", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions)},
|
||||
{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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -54,14 +54,53 @@ func TestPrepareResponseCandidatesPinsPreviousPlatformModelAndProtocol(t *testin
|
||||
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" {
|
||||
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) {
|
||||
_, 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" {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,7 +481,7 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
|
||||
return Result{Task: failed, Output: failed.Result}, err
|
||||
}
|
||||
if task.Kind == "responses" {
|
||||
candidates, err = prepareResponseCandidates(candidates, responseExecution)
|
||||
candidates, err = prepareResponseCandidates(candidates, responseExecution, body)
|
||||
if err != nil {
|
||||
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})
|
||||
|
||||
@@ -120,6 +120,10 @@ function functionCall(response) {
|
||||
return (response.output || []).find((item) => item.type === 'function_call');
|
||||
}
|
||||
|
||||
function customToolCall(response) {
|
||||
return (response.output || []).find((item) => item.type === 'custom_tool_call');
|
||||
}
|
||||
|
||||
async function getTask(token, taskId) {
|
||||
assert(taskId, 'response is missing X-Gateway-Task-Id');
|
||||
const response = await fetch(`${baseURL}/api/v1/tasks/${taskId}`, {
|
||||
@@ -182,8 +186,8 @@ for (const expected of modelCases) {
|
||||
const toolOutput = `TOOL-${nonce}`;
|
||||
const normalizedModel = expected.model.toLowerCase();
|
||||
const requiresNonThinkingToolMode = normalizedModel.startsWith('qwen3.7-') || normalizedModel.includes('deepseek-v4-pro');
|
||||
const toolReasoning = requiresNonThinkingToolMode ? { reasoning: { effort: 'none' } } : {};
|
||||
const forcedToolChoice = 'required';
|
||||
const toolReasoning = requiresNonThinkingToolMode ? { reasoning: { effort: 'none' } } : {};
|
||||
const forcedToolChoice = 'required';
|
||||
const toolFirst = await requestStream(token, '/v1/responses', {
|
||||
model: expected.model,
|
||||
input: '调用 lookup_verification_code 获取校验结果。',
|
||||
@@ -194,9 +198,9 @@ for (const expected of modelCases) {
|
||||
parameters: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'], additionalProperties: false },
|
||||
strict: true,
|
||||
}],
|
||||
tool_choice: forcedToolChoice,
|
||||
tool_choice: forcedToolChoice,
|
||||
parallel_tool_calls: true,
|
||||
...toolReasoning,
|
||||
...toolReasoning,
|
||||
store: true,
|
||||
});
|
||||
const call = functionCall(toolFirst.body);
|
||||
@@ -207,25 +211,58 @@ for (const expected of modelCases) {
|
||||
model: expected.model,
|
||||
previous_response_id: toolFirst.body.id,
|
||||
input: [{ type: 'function_call_output', call_id: call.call_id, output: JSON.stringify({ verification: toolOutput }) }],
|
||||
...toolReasoning,
|
||||
...toolReasoning,
|
||||
store: true,
|
||||
});
|
||||
assert(responseText(toolSecond.body).includes(toolOutput), `${expected.model} did not consume function_call_output`);
|
||||
|
||||
const customOutput = `CUSTOM-${nonce}`;
|
||||
const customFirst = await requestStream(token, '/v1/responses', {
|
||||
model: expected.model,
|
||||
input: '调用 echo_verification_text,并把当前校验码作为纯文本输入。',
|
||||
tools: [{
|
||||
type: 'custom',
|
||||
name: 'echo_verification_text',
|
||||
description: '接收任意纯文本校验内容',
|
||||
format: { type: 'text' },
|
||||
}],
|
||||
tool_choice: { type: 'custom', name: 'echo_verification_text' },
|
||||
...toolReasoning,
|
||||
store: true,
|
||||
});
|
||||
const customCall = customToolCall(customFirst.body);
|
||||
assert(customCall?.call_id, `${expected.model} custom tool response is missing call_id`);
|
||||
assert(customFirst.events.some((event) => event.event === 'response.custom_tool_call_input.delta'), `${expected.model} stream is missing custom tool input delta`);
|
||||
|
||||
const customSecond = await requestStream(token, '/v1/responses', {
|
||||
model: expected.model,
|
||||
previous_response_id: customFirst.body.id,
|
||||
input: [{ type: 'custom_tool_call_output', call_id: customCall.call_id, output: customOutput }],
|
||||
...toolReasoning,
|
||||
store: true,
|
||||
});
|
||||
assert(responseText(customSecond.body).includes(customOutput), `${expected.model} did not consume custom_tool_call_output`);
|
||||
|
||||
const auditedTasks = await Promise.all([
|
||||
getTask(token, first.taskId),
|
||||
getTask(token, second.taskId),
|
||||
getTask(token, toolFirst.taskId),
|
||||
getTask(token, toolSecond.taskId),
|
||||
getTask(token, customFirst.taskId),
|
||||
getTask(token, customSecond.taskId),
|
||||
]);
|
||||
const ordinaryFirstAudit = auditSummary(auditedTasks[0], expected);
|
||||
const ordinarySecondAudit = auditSummary(auditedTasks[1], expected);
|
||||
const toolFirstAudit = auditSummary(auditedTasks[2], expected);
|
||||
const toolSecondAudit = auditSummary(auditedTasks[3], expected);
|
||||
const customFirstAudit = auditSummary(auditedTasks[4], expected);
|
||||
const customSecondAudit = auditSummary(auditedTasks[5], expected);
|
||||
assert(ordinarySecondAudit.parentResponseId === first.body.id, `${expected.model} ordinary parent response id mismatch`);
|
||||
assert(ordinarySecondAudit.chainDepth === 1, `${expected.model} ordinary chain depth mismatch`);
|
||||
assert(toolSecondAudit.parentResponseId === toolFirst.body.id, `${expected.model} tool parent response id mismatch`);
|
||||
assert(toolSecondAudit.chainDepth === 1, `${expected.model} tool chain depth mismatch`);
|
||||
assert(customSecondAudit.parentResponseId === customFirst.body.id, `${expected.model} custom tool parent response id mismatch`);
|
||||
assert(customSecondAudit.chainDepth === 1, `${expected.model} custom tool chain depth mismatch`);
|
||||
results.push({
|
||||
model: expected.model,
|
||||
ordinaryConversation: {
|
||||
@@ -238,8 +275,17 @@ for (const expected of modelCases) {
|
||||
callId: call.call_id,
|
||||
first: toolFirstAudit,
|
||||
second: toolSecondAudit,
|
||||
firstEventTypes: toolFirst.events.map((event) => event.event),
|
||||
secondEventTypes: toolSecond.events.map((event) => event.event),
|
||||
firstEventTypes: toolFirst.events.map((event) => event.event),
|
||||
secondEventTypes: toolSecond.events.map((event) => event.event),
|
||||
},
|
||||
customToolCalling: {
|
||||
passed: true,
|
||||
callId: customCall.call_id,
|
||||
input: customCall.input,
|
||||
first: customFirstAudit,
|
||||
second: customSecondAudit,
|
||||
firstEventTypes: customFirst.events.map((event) => event.event),
|
||||
secondEventTypes: customSecond.events.map((event) => event.event),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user