diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index c9a61d7465..8aa4364777 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -366,8 +366,7 @@ func normalizeOpenAIParallelToolCallsWithoutTools(body []byte) ([]byte, bool, er if !parallel.Exists() { return body, false, nil } - tools := gjson.GetBytes(body, "tools") - if tools.IsArray() && len(tools.Array()) > 0 { + if openAIRequestBodyHasTools(body) { return body, false, nil } normalized, err := sjson.DeleteBytes(body, "parallel_tool_calls") @@ -377,6 +376,31 @@ func normalizeOpenAIParallelToolCallsWithoutTools(body []byte) ([]byte, bool, er return normalized, true, nil } +// openAIRequestBodyHasTools is the []byte counterpart of openAIResponsesLiteHasTools: +// besides the top-level "tools" array it also recognizes the Responses Lite carrier. +// normalizeOpenAIResponsesLiteTools moves namespace tools into an input item of type +// "additional_tools" and drops the top-level "tools" key; the request still carries +// tools at that point. Looking only at the top level therefore misreads such a body as +// "no tools" and deletes the parallel_tool_calls:false that +// ensureOpenAIResponsesLiteParallelToolCalls had just pinned, and OpenAI falls back to +// its default of true and rejects the request with +// 400 unsupported_value: "X-OpenAI-Internal-Codex-Responses-Lite requires +// `parallel_tool_calls` to be false." +func openAIRequestBodyHasTools(body []byte) bool { + if tools := gjson.GetBytes(body, "tools"); tools.IsArray() && len(tools.Array()) > 0 { + return true + } + for _, item := range gjson.GetBytes(body, "input").Array() { + if strings.TrimSpace(item.Get("type").String()) != "additional_tools" { + continue + } + if tools := item.Get("tools"); tools.IsArray() && len(tools.Array()) > 0 { + return true + } + } + return false +} + func normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body []byte, knownStoreFalse bool) ([]byte, bool, error) { if !knownStoreFalse && gjson.GetBytes(body, "store").Type != gjson.False { return body, false, nil diff --git a/backend/internal/service/openai_gateway_request_body_reasoning_test.go b/backend/internal/service/openai_gateway_request_body_reasoning_test.go index f14a75018b..03397e751f 100644 --- a/backend/internal/service/openai_gateway_request_body_reasoning_test.go +++ b/backend/internal/service/openai_gateway_request_body_reasoning_test.go @@ -270,3 +270,23 @@ func TestNormalizeOpenAIParallelToolCallsWithoutTools(t *testing.T) { require.True(t, changed) require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists()) } + +// A Responses Lite body that has already been through normalizeOpenAIResponsesLiteTools +// carries its tools in an input item of type "additional_tools" and no longer has a +// top-level "tools" key. It still has tools, so the parallel_tool_calls:false that +// ensureOpenAIResponsesLiteParallelToolCalls pinned must survive this normalization — +// otherwise OpenAI applies its default of true and rejects the request. +func TestNormalizeOpenAIParallelToolCallsWithoutTools_KeepsResponsesLiteAdditionalTools(t *testing.T) { + liteBody := []byte(`{"input":[{"type":"message","role":"user","content":"hi"},{"type":"additional_tools","tools":[{"type":"function","name":"spawn_agent"}]}],"parallel_tool_calls":false}`) + normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(liteBody) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, gjson.False, gjson.GetBytes(normalized, "parallel_tool_calls").Type) + + // An empty additional_tools item carries no tools, so the field is still dropped. + emptyLiteBody := []byte(`{"input":[{"type":"additional_tools","tools":[]}],"parallel_tool_calls":true}`) + normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(emptyLiteBody) + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists()) +}