fix(openai): keep parallel_tool_calls for Responses Lite additional_tools

normalizeOpenAIParallelToolCallsWithoutTools only looked at the top-level
"tools" array, but normalizeOpenAIResponsesLiteTools moves namespace tools
into an input item of type "additional_tools" and deletes the top-level key.
A Responses Lite request that carries tools therefore looks like it has none,
and the parallel_tool_calls:false that ensureOpenAIResponsesLiteParallelToolCalls
had just pinned gets deleted on the way out.

OpenAI then applies its default of true and rejects the request:

  400 unsupported_value: "X-OpenAI-Internal-Codex-Responses-Lite requires
  `parallel_tool_calls` to be false."

Note the field cannot simply be pinned to false unconditionally: without tools
OpenAI rejects it with "'parallel_tool_calls' is only allowed when 'tools' are
specified", so the two constraints have to be honoured together.

Reuse the same tool-detection standard the Lite path already uses by adding
openAIRequestBodyHasTools, the []byte counterpart of openAIResponsesLiteHasTools,
so both sides of the repo agree on what "has tools" means.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
759502416
2026-08-24 21:15:39 +08:00
co-authored by Claude Opus 5
parent 03e8ab4134
commit 1563db3f82
2 changed files with 46 additions and 2 deletions
@@ -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
@@ -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())
}