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:
co-authored by
Claude Opus 5
parent
03e8ab4134
commit
1563db3f82
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user