From ad8afc8a2e984118f25aa8aa269de536c760ca57 Mon Sep 17 00:00:00 2001 From: CHOS1N Date: Thu, 9 Jul 2026 22:52:35 +0800 Subject: [PATCH] Add parallel_tool_calls compatibility mapping Preserve Chat Completions parallel_tool_calls when converting requests to the Responses API, and map Responses parallel_tool_calls back when falling back to Chat Completions upstreams. Cover both true and explicit false values so clients can disable parallel tool calls without the field being dropped by omitempty. --- .../chatcompletions_responses_bridge.go | 1 + .../chatcompletions_responses_bridge_test.go | 20 +++++++++++++++++++ .../chatcompletions_responses_test.go | 19 ++++++++++++++++++ .../apicompat/chatcompletions_to_responses.go | 13 ++++++------ backend/internal/pkg/apicompat/types.go | 1 + 5 files changed, 48 insertions(+), 6 deletions(-) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index eeeedd29aa..2043246de0 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -28,6 +28,7 @@ func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsR TopP: req.TopP, Stream: req.Stream, ServiceTier: req.ServiceTier, + ParallelToolCalls: req.ParallelToolCalls, } if req.Reasoning != nil { out.ReasoningEffort = req.Reasoning.Effort diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go index b194d88141..2e770b4158 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go @@ -127,6 +127,26 @@ func TestResponsesToChatCompletionsRequest_TextFormatJsonSchema(t *testing.T) { }`, string(out.ResponseFormat)) } +func TestResponsesToChatCompletionsRequest_ParallelToolCalls(t *testing.T) { + parallel := false + req := &ResponsesRequest{ + Model: "gpt-4o", + Input: json.RawMessage(`[ + {"role":"user","content":"Use tools"} + ]`), + ParallelToolCalls: ¶llel, + } + + out, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + require.NotNil(t, out.ParallelToolCalls) + assert.False(t, *out.ParallelToolCalls) + + payload, err := json.Marshal(out) + require.NoError(t, err) + assert.Contains(t, string(payload), `"parallel_tool_calls":false`) +} + func chatMessageRoles(messages []ChatMessage) []string { roles := make([]string, 0, len(messages)) for _, message := range messages { diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index b30330863c..8772ec0f4e 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -531,6 +531,25 @@ func TestChatCompletionsToResponses_ServiceTier(t *testing.T) { assert.Equal(t, "flex", resp.ServiceTier) } +func TestChatCompletionsToResponses_ParallelToolCalls(t *testing.T) { + for _, value := range []bool{false, true} { + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + ParallelToolCalls: &value, + Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}}, + } + + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + require.NotNil(t, resp.ParallelToolCalls) + assert.Equal(t, value, *resp.ParallelToolCalls) + + payload, err := json.Marshal(resp) + require.NoError(t, err) + assert.Contains(t, string(payload), `"parallel_tool_calls":`+string(mustMarshalJSON(t, value))) + } +} + // --------------------------------------------------------------------------- // temperature / top_p stripping for reasoning models // --------------------------------------------------------------------------- diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index 07c557ab3b..0f65d217e8 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -27,12 +27,13 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, } out := &ResponsesRequest{ - Model: req.Model, - Instructions: req.Instructions, - Input: inputJSON, - Stream: true, // upstream always streams - Include: []string{"reasoning.encrypted_content"}, - ServiceTier: req.ServiceTier, + Model: req.Model, + Instructions: req.Instructions, + Input: inputJSON, + Stream: true, // upstream always streams + Include: []string{"reasoning.encrypted_content"}, + ServiceTier: req.ServiceTier, + ParallelToolCalls: req.ParallelToolCalls, } // Reasoning models (gpt-5.x) do not accept sampling parameters. diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index a0fd07a0d1..f7955c70c0 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -435,6 +435,7 @@ type ChatCompletionsRequest struct { Stream bool `json:"stream,omitempty"` StreamOptions *ChatStreamOptions `json:"stream_options,omitempty"` Tools []ChatTool `json:"tools,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` ToolChoice json.RawMessage `json:"tool_choice,omitempty"` ReasoningEffort string `json:"reasoning_effort,omitempty"` // "low" | "medium" | "high" | "xhigh" ServiceTier string `json:"service_tier,omitempty"`