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