feat(executor): normalize parallel_tool_calls based on tools presence

- Added `normalizeCodexParallelToolCallsForTools` to conditionally remove `parallel_tool_calls` when `tools` are missing or empty.
- Integrated normalization into Codex executor workflows for improved request handling.
- Introduced unit tests to validate behavior across different tool scenarios.

Closes: #3903
This commit is contained in:
Luis Pater
2026-06-20 09:47:45 +08:00
parent 4c78e40da4
commit 75fa62653f
2 changed files with 58 additions and 0 deletions
@@ -829,6 +829,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
body = ensureImageGenerationTool(body, baseModel, auth)
}
body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body)
body = normalizeCodexParallelToolCallsForTools(body)
body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body)
if errReplay != nil {
return resp, errReplay
@@ -1004,6 +1005,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
body = ensureImageGenerationTool(body, baseModel, auth)
}
body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body)
body = normalizeCodexParallelToolCallsForTools(body)
reporter.SetTranslatedReasoningEffort(body, to.String())
url := strings.TrimSuffix(baseURL, "/") + "/responses/compact"
@@ -1113,6 +1115,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
body = ensureImageGenerationTool(body, baseModel, auth)
}
body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body)
body = normalizeCodexParallelToolCallsForTools(body)
body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body)
if errReplay != nil {
return nil, errReplay
@@ -1775,6 +1778,21 @@ func ensureImageGenerationTool(body []byte, baseModel string, auth *cliproxyauth
return body
}
func normalizeCodexParallelToolCallsForTools(body []byte) []byte {
if !gjson.GetBytes(body, "parallel_tool_calls").Exists() {
return body
}
tools := gjson.GetBytes(body, "tools")
hasTools := tools.Exists() && tools.IsArray() && len(tools.Array()) > 0
if hasTools {
return body
}
body, _ = sjson.DeleteBytes(body, "parallel_tool_calls")
return body
}
func publishCodexImageToolUsage(ctx context.Context, reporter *helps.UsageReporter, body []byte, completedData []byte) {
detail, ok := helps.ParseCodexImageToolUsage(completedData)
if !ok {
@@ -0,0 +1,40 @@
package executor
import (
"testing"
"github.com/tidwall/gjson"
)
func TestNormalizeCodexParallelToolCallsForTools_DropsWhenToolsMissing(t *testing.T) {
body := []byte(`{"model":"gpt-5.4","parallel_tool_calls":true,"input":"hi"}`)
out := normalizeCodexParallelToolCallsForTools(body)
if gjson.GetBytes(out, "parallel_tool_calls").Exists() {
t.Fatalf("parallel_tool_calls should be removed when tools are missing: %s", string(out))
}
}
func TestNormalizeCodexParallelToolCallsForTools_DropsWhenToolsEmpty(t *testing.T) {
body := []byte(`{"model":"gpt-5.4","tools":[],"parallel_tool_calls":false,"input":"hi"}`)
out := normalizeCodexParallelToolCallsForTools(body)
if gjson.GetBytes(out, "parallel_tool_calls").Exists() {
t.Fatalf("parallel_tool_calls should be removed when tools are empty: %s", string(out))
}
if !gjson.GetBytes(out, "tools").Exists() {
t.Fatalf("tools should be preserved: %s", string(out))
}
}
func TestNormalizeCodexParallelToolCallsForTools_PreservesWhenToolsPresent(t *testing.T) {
body := []byte(`{"model":"gpt-5.4","tools":[{"type":"function","name":"lookup"}],"parallel_tool_calls":true,"input":"hi"}`)
out := normalizeCodexParallelToolCallsForTools(body)
if !gjson.GetBytes(out, "parallel_tool_calls").Bool() {
t.Fatalf("parallel_tool_calls should be preserved when tools are present: %s", string(out))
}
}