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