diff --git a/config.example.yaml b/config.example.yaml index 4bba36ac1..50f145099 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -262,6 +262,12 @@ codex: # normalizes encrypted agent_message content for Codex, and converts agent_message input # into standard user messages for non-Codex upstream protocols. optimize-multi-agent-v2: false + # When true, enable opt-in compatibility for orphan Codex delegation outputs. + # Converts orphan function_call_output items from codex_app/create_thread and + # codex_app/send_message_to_thread (which lack a valid call_id or matching function_call) + # into standard user messages across Responses and non-Codex protocols when the request + # header contains X-Openai-Subagent: collab_spawn. + orphan-delegation-compatibility: false # Terminate and relay Codex Live WebRTC audio and DataChannel traffic in this process. # This requires inbound UDP reachability. Keep disabled to preserve direct media behavior. live-media-relay: diff --git a/internal/api/server_options.go b/internal/api/server_options.go index ef254febc..4e190a8d6 100644 --- a/internal/api/server_options.go +++ b/internal/api/server_options.go @@ -46,6 +46,7 @@ func effectiveSDKConfig(cfg *config.Config) *config.SDKConfig { } sdkCfg := cfg.SDKConfig sdkCfg.CodexOptimizeMultiAgentV2 = cfg.Codex.OptimizeMultiAgentV2 + sdkCfg.CodexOrphanDelegationCompatibility = cfg.Codex.OrphanDelegationCompatibility if cfg.CommercialMode { sdkCfg.RequestLog = false } diff --git a/internal/api/server_sdk_config_test.go b/internal/api/server_sdk_config_test.go index 1a58f2597..25bb28c01 100644 --- a/internal/api/server_sdk_config_test.go +++ b/internal/api/server_sdk_config_test.go @@ -14,3 +14,12 @@ func TestEffectiveSDKConfigCopiesCodexOptimizeMultiAgentV2(t *testing.T) { t.Fatalf("CodexOptimizeMultiAgentV2 = false, want true") } } + +func TestEffectiveSDKConfigCopiesCodexOrphanDelegationCompatibility(t *testing.T) { + cfg := &config.Config{Codex: config.CodexConfig{OrphanDelegationCompatibility: true}} + + sdkCfg := effectiveSDKConfig(cfg) + if sdkCfg == nil || !sdkCfg.CodexOrphanDelegationCompatibility { + t.Fatalf("CodexOrphanDelegationCompatibility = false, want true") + } +} diff --git a/internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2.go b/internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2.go index 69046d4e7..d65442356 100644 --- a/internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2.go +++ b/internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2.go @@ -71,11 +71,23 @@ func RewriteCodexMultiAgentV2Input(ctx context.Context, headers http.Header, pay return rewriteCodexAgentMessageInput(payload) } +// RewriteCodexOrphanDelegationInputForConfig applies RewriteCodexOrphanDelegationInput +// based on cfg.Codex.OrphanDelegationCompatibility and the X-Openai-Subagent header. +func RewriteCodexOrphanDelegationInputForConfig(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config) []byte { + if cfg == nil || !cfg.Codex.OrphanDelegationCompatibility { + return payload + } + return RewriteCodexOrphanDelegationInput(ctx, headers, payload, true) +} + // TranslateRequestWithCodexMultiAgentV2 normalizes official Codex multi-agent // input before translating it to a non-Codex target protocol. func TranslateRequestWithCodexMultiAgentV2(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, payload []byte, stream bool) []byte { - if from == sdktranslator.FormatOpenAIResponse && to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse { - payload = RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg) + if from == sdktranslator.FormatOpenAIResponse { + payload = RewriteCodexOrphanDelegationInputForConfig(ctx, headers, payload, cfg) + if to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse { + payload = RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg) + } } return sdktranslator.TranslateRequest(from, to, model, payload, stream) } diff --git a/internal/client/codex/optimize-multi-agent-v2/orphan_delegation.go b/internal/client/codex/optimize-multi-agent-v2/orphan_delegation.go new file mode 100644 index 000000000..8013b6513 --- /dev/null +++ b/internal/client/codex/optimize-multi-agent-v2/orphan_delegation.go @@ -0,0 +1,122 @@ +package multiagentv2 + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const ( + codexAppNamespace = "codex_app" + codexCreateThreadName = "create_thread" + codexSendMessageToThreadName = "send_message_to_thread" + codexAppCreateThreadTool = "codex_app__create_thread" + codexAppSendMessageTool = "codex_app__send_message_to_thread" + codexOpenAISubagentHeader = "X-Openai-Subagent" + codexCollabSpawnSubagent = "collab_spawn" +) + +// RewriteCodexOrphanDelegationInput converts orphan Codex delegation outputs into +// standard user messages when orphan delegation compatibility is enabled and the +// request carries the X-Openai-Subagent: collab_spawn header. +func RewriteCodexOrphanDelegationInput(ctx context.Context, headers http.Header, payload []byte, enabled bool) []byte { + if !enabled || len(payload) == 0 || !isCodexCollabSpawnSubagent(ctx, headers) { + return payload + } + + input := gjson.GetBytes(payload, "input") + if !input.IsArray() { + return payload + } + + inputItems := input.Array() + availableCalls := make(map[string]int) + for _, item := range inputItems { + if item.Get("type").String() == "function_call" { + callID := item.Get("call_id").String() + if strings.TrimSpace(callID) != "" { + availableCalls[callID]++ + } + } + } + + updated := payload + for itemIndex, item := range inputItems { + if item.Get("type").String() != "function_call_output" { + continue + } + + callID := item.Get("call_id").String() + if strings.TrimSpace(callID) != "" && availableCalls[callID] > 0 { + // Paired with a function call in the same request; consume and preserve. + availableCalls[callID]-- + continue + } + + toolLabel, isTarget := matchCodexDelegationTool(item) + if !isTarget { + continue + } + + // Orphan delegation output: downgrade to user message preserving exact output. + itemPath := fmt.Sprintf("input.%d", itemIndex) + userMessage := buildCodexOrphanUserMessage(toolLabel, item.Get("output")) + var errSet error + updated, errSet = sjson.SetRawBytes(updated, itemPath, userMessage) + if errSet != nil { + return payload + } + } + + return updated +} + +func codexSubagentHeader(ctx context.Context, headers http.Header) string { + if ctx != nil { + if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { + return headerValueCaseInsensitive(ginCtx.Request.Header, codexOpenAISubagentHeader) + } + } + return headerValueCaseInsensitive(headers, codexOpenAISubagentHeader) +} + +func isCodexCollabSpawnSubagent(ctx context.Context, headers http.Header) bool { + return strings.EqualFold(codexSubagentHeader(ctx, headers), codexCollabSpawnSubagent) +} + +func matchCodexDelegationTool(item gjson.Result) (string, bool) { + if item.Get("namespace").String() != codexAppNamespace { + return "", false + } + + switch item.Get("name").String() { + case codexCreateThreadName: + return codexAppCreateThreadTool, true + case codexSendMessageToThreadName: + return codexAppSendMessageTool, true + default: + return "", false + } +} + +func buildCodexOrphanUserMessage(toolLabel string, output gjson.Result) []byte { + outputText := "" + if output.Exists() { + if output.Type == gjson.String { + outputText = output.String() + } else { + outputText = output.Raw + } + } + + fullText := fmt.Sprintf("Tool output from %s:\n%s", toolLabel, outputText) + + msg := []byte(`{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}`) + msg, _ = sjson.SetBytes(msg, "content.0.text", fullText) + return msg +} diff --git a/internal/client/codex/optimize-multi-agent-v2/orphan_delegation_test.go b/internal/client/codex/optimize-multi-agent-v2/orphan_delegation_test.go new file mode 100644 index 000000000..d5720cbe3 --- /dev/null +++ b/internal/client/codex/optimize-multi-agent-v2/orphan_delegation_test.go @@ -0,0 +1,613 @@ +package multiagentv2 + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +func testRewriteCodexOrphan(payload []byte, enabled bool) []byte { + headers := http.Header{"X-Openai-Subagent": []string{"collab_spawn"}} + return RewriteCodexOrphanDelegationInput(context.Background(), headers, payload, enabled) +} + +func TestRewriteCodexOrphanDelegationInput(t *testing.T) { + t.Run("disabled leaves payload unchanged", func(t *testing.T) { + payload := []byte(`{ + "model": "deepseek-v4-pro", + "input": [ + { + "type": "function_call_output", + "name": "create_thread", + "namespace": "codex_app", + "output": "handoff" + } + ] + }`) + got := testRewriteCodexOrphan(payload, false) + if string(got) != string(payload) { + t.Fatalf("expected payload unchanged when disabled, got: %s", string(got)) + } + }) + + t.Run("missing subagent header leaves payload unchanged", func(t *testing.T) { + payload := []byte(`{ + "model": "deepseek-v4-pro", + "input": [ + { + "type": "function_call_output", + "name": "create_thread", + "namespace": "codex_app", + "output": "handoff" + } + ] + }`) + got := RewriteCodexOrphanDelegationInput(context.Background(), http.Header{}, payload, true) + if string(got) != string(payload) { + t.Fatalf("expected payload unchanged without header, got: %s", string(got)) + } + }) + + t.Run("different subagent header leaves payload unchanged", func(t *testing.T) { + payload := []byte(`{ + "model": "deepseek-v4-pro", + "input": [ + { + "type": "function_call_output", + "name": "create_thread", + "namespace": "codex_app", + "output": "handoff" + } + ] + }`) + headers := http.Header{"X-Openai-Subagent": []string{"other_subagent"}} + got := RewriteCodexOrphanDelegationInput(context.Background(), headers, payload, true) + if string(got) != string(payload) { + t.Fatalf("expected payload unchanged with wrong header, got: %s", string(got)) + } + }) + + t.Run("rewrites orphan create_thread without call_id", func(t *testing.T) { + payload := []byte(`{ + "model": "deepseek-v4-pro", + "input": [ + { + "type": "function_call_output", + "name": "create_thread", + "namespace": "codex_app", + "output": "handoff" + }, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "please continue"}] + } + ] + }`) + got := testRewriteCodexOrphan(payload, true) + parsed := gjson.ParseBytes(got) + + item0 := parsed.Get("input.0") + if item0.Get("type").String() != "message" { + t.Fatalf("input.0.type = %q, want %q", item0.Get("type").String(), "message") + } + if item0.Get("role").String() != "user" { + t.Fatalf("input.0.role = %q, want %q", item0.Get("role").String(), "user") + } + wantText := "Tool output from codex_app__create_thread:\nhandoff" + if text := item0.Get("content.0.text").String(); text != wantText { + t.Fatalf("input.0.content.0.text = %q, want %q", text, wantText) + } + + item1 := parsed.Get("input.1") + if item1.Get("type").String() != "message" || item1.Get("content.0.text").String() != "please continue" { + t.Fatalf("input.1 corrupted: %s", item1.Raw) + } + }) + + t.Run("case-insensitive header key and value works", func(t *testing.T) { + payload := []byte(`{ + "model": "deepseek-v4-pro", + "input": [ + { + "type": "function_call_output", + "name": "create_thread", + "namespace": "codex_app", + "output": "msg" + } + ] + }`) + headers := http.Header{"x-openai-subagent": []string{"COLLAB_SPAWN"}} + got := RewriteCodexOrphanDelegationInput(context.Background(), headers, payload, true) + parsed := gjson.ParseBytes(got) + + item0 := parsed.Get("input.0") + if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" { + t.Fatalf("case-insensitive header should rewrite, got: %s", item0.Raw) + } + }) + + t.Run("rewrites orphan send_message_to_thread with stale call_id", func(t *testing.T) { + payload := []byte(`{ + "model": "deepseek-v4-pro", + "input": [ + { + "type": "function_call_output", + "call_id": "call_stale_123", + "name": "send_message_to_thread", + "namespace": "codex_app", + "output": "msg" + } + ] + }`) + got := testRewriteCodexOrphan(payload, true) + parsed := gjson.ParseBytes(got) + + item0 := parsed.Get("input.0") + if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" { + t.Fatalf("input.0 not rewritten: %s", item0.Raw) + } + wantText := "Tool output from codex_app__send_message_to_thread:\nmsg" + if text := item0.Get("content.0.text").String(); text != wantText { + t.Fatalf("input.0.content.0.text = %q, want %q", text, wantText) + } + }) + + t.Run("preserves paired create_thread tool call", func(t *testing.T) { + payload := []byte(`{ + "model": "deepseek-v4-pro", + "input": [ + { + "type": "function_call", + "call_id": "call_active_123", + "name": "create_thread", + "namespace": "codex_app", + "arguments": "{}" + }, + { + "type": "function_call_output", + "call_id": "call_active_123", + "name": "create_thread", + "namespace": "codex_app", + "output": "valid" + } + ] + }`) + got := testRewriteCodexOrphan(payload, true) + parsed := gjson.ParseBytes(got) + + item1 := parsed.Get("input.1") + if item1.Get("type").String() != "function_call_output" { + t.Fatalf("paired call output was modified: %s", item1.Raw) + } + if item1.Get("call_id").String() != "call_active_123" { + t.Fatalf("call_id changed: %s", item1.Raw) + } + }) + + t.Run("preserves non-whitelisted tools", func(t *testing.T) { + payload := []byte(`{ + "model": "deepseek-v4-pro", + "input": [ + { + "type": "function_call_output", + "name": "automation_update", + "namespace": "codex_app", + "output": "ignored" + }, + { + "type": "function_call_output", + "name": "create_thread", + "namespace": "other_namespace", + "output": "ignored" + } + ] + }`) + got := testRewriteCodexOrphan(payload, true) + parsed := gjson.ParseBytes(got) + + if parsed.Get("input.0.type").String() != "function_call_output" { + t.Fatalf("automation_update should not be rewritten: %s", parsed.Get("input.0").Raw) + } + if parsed.Get("input.1.type").String() != "function_call_output" { + t.Fatalf("other_namespace should not be rewritten: %s", parsed.Get("input.1").Raw) + } + }) + + t.Run("handles empty output", func(t *testing.T) { + payload := []byte(`{ + "model": "deepseek-v4-pro", + "input": [ + { + "type": "function_call_output", + "name": "create_thread", + "namespace": "codex_app", + "output": "" + } + ] + }`) + got := testRewriteCodexOrphan(payload, true) + parsed := gjson.ParseBytes(got) + + item0 := parsed.Get("input.0") + if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" { + t.Fatalf("input.0 not rewritten: %s", item0.Raw) + } + wantText := "Tool output from codex_app__create_thread:\n" + if text := item0.Get("content.0.text").String(); text != wantText { + t.Fatalf("input.0.content.0.text = %q, want %q", text, wantText) + } + }) + + t.Run("call_id whitespace difference is not paired", func(t *testing.T) { + payload := []byte(`{ + "model": "deepseek-v4-pro", + "input": [ + { + "type": "function_call", + "call_id": "call_1", + "name": "create_thread", + "namespace": "codex_app", + "arguments": "{}" + }, + { + "type": "function_call_output", + "call_id": " call_1 ", + "name": "create_thread", + "namespace": "codex_app", + "output": "mismatch" + } + ] + }`) + got := testRewriteCodexOrphan(payload, true) + parsed := gjson.ParseBytes(got) + + if parsed.Get("input.1.type").String() != "message" { + t.Fatalf("call_id with whitespace mismatch should be treated as orphan: %s", parsed.Get("input.1").Raw) + } + }) + + t.Run("preserves structured image output in delegation as exact text", func(t *testing.T) { + rawOutput := `[{"type":"input_text","text":"diagram"},{"type":"input_image","image_url":"https://example.com/img.png"}]` + payload := []byte(`{ + "model": "deepseek-v4-pro", + "input": [ + { + "type": "function_call_output", + "name": "create_thread", + "namespace": "codex_app", + "output": ` + rawOutput + ` + } + ] + }`) + got := testRewriteCodexOrphan(payload, true) + parsed := gjson.ParseBytes(got) + + item0 := parsed.Get("input.0") + if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" { + t.Fatalf("input.0 not rewritten: %s", item0.Raw) + } + wantText := "Tool output from codex_app__create_thread:\n" + rawOutput + if text := item0.Get("content.0.text").String(); text != wantText { + t.Fatalf("input.0.content.0.text = %q, want %q", text, wantText) + } + }) + + t.Run("call and output in same request are paired regardless of order", func(t *testing.T) { + payload := []byte(`{ + "model": "deepseek-v4-pro", + "input": [ + { + "type": "function_call_output", + "call_id": "call_future_1", + "name": "create_thread", + "namespace": "codex_app", + "output": "early" + }, + { + "type": "function_call", + "call_id": "call_future_1", + "name": "create_thread", + "namespace": "codex_app", + "arguments": "{}" + } + ] + }`) + got := testRewriteCodexOrphan(payload, true) + parsed := gjson.ParseBytes(got) + + if parsed.Get("input.0.type").String() != "function_call_output" { + t.Fatalf("output should remain paired function_call_output: %s", parsed.Get("input.0").Raw) + } + if parsed.Get("input.1.type").String() != "function_call" { + t.Fatalf("call should remain function_call: %s", parsed.Get("input.1").Raw) + } + }) + + t.Run("duplicate output consumes call once; second output is orphan", func(t *testing.T) { + payload := []byte(`{ + "model": "deepseek-v4-pro", + "input": [ + { + "type": "function_call", + "call_id": "call_once", + "name": "create_thread", + "namespace": "codex_app", + "arguments": "{}" + }, + { + "type": "function_call_output", + "call_id": "call_once", + "name": "create_thread", + "namespace": "codex_app", + "output": "first" + }, + { + "type": "function_call_output", + "call_id": "call_once", + "name": "create_thread", + "namespace": "codex_app", + "output": "second" + } + ] + }`) + got := testRewriteCodexOrphan(payload, true) + parsed := gjson.ParseBytes(got) + + if parsed.Get("input.1.type").String() != "function_call_output" { + t.Fatalf("first output should pair with call: %s", parsed.Get("input.1").Raw) + } + if parsed.Get("input.2.type").String() != "message" { + t.Fatalf("second output should be orphan: %s", parsed.Get("input.2").Raw) + } + }) + + t.Run("custom tool call does not pair with function call output", func(t *testing.T) { + payload := []byte(`{ + "model": "deepseek-v4-pro", + "input": [ + { + "type": "custom_tool_call", + "call_id": "call_custom", + "name": "create_thread", + "input": "{}" + }, + { + "type": "function_call_output", + "call_id": "call_custom", + "name": "create_thread", + "namespace": "codex_app", + "output": "orphan" + } + ] + }`) + got := testRewriteCodexOrphan(payload, true) + parsed := gjson.ParseBytes(got) + + if parsed.Get("input.1.type").String() != "message" { + t.Fatalf("function_call_output should not pair with custom_tool_call: %s", parsed.Get("input.1").Raw) + } + }) + + t.Run("assistant message tool_calls does not pair with Responses function_call_output", func(t *testing.T) { + payload := []byte(`{ + "model": "deepseek-v4-pro", + "input": [ + { + "type": "message", + "role": "assistant", + "tool_calls": [{"id": "call_in_msg", "type": "function", "function": {"name": "create_thread"}}] + }, + { + "type": "function_call_output", + "call_id": "call_in_msg", + "name": "create_thread", + "namespace": "codex_app", + "output": "orphan" + } + ] + }`) + got := testRewriteCodexOrphan(payload, true) + parsed := gjson.ParseBytes(got) + + if parsed.Get("input.1.type").String() != "message" { + t.Fatalf("function_call_output should not pair with assistant message tool_calls: %s", parsed.Get("input.1").Raw) + } + }) + + t.Run("exact namespace and name required", func(t *testing.T) { + payload := []byte(`{ + "model": "deepseek-v4-pro", + "input": [ + { + "type": "function_call_output", + "name": "codex_app__create_thread", + "output": "orphan" + }, + { + "type": "function_call_output", + "name": "create_thread", + "namespace": " codex_app ", + "output": "orphan" + }, + { + "type": "function_call_output", + "name": "other_tool", + "namespace": "codex_app", + "output": "orphan" + } + ] + }`) + got := testRewriteCodexOrphan(payload, true) + parsed := gjson.ParseBytes(got) + + for i := 0; i < 3; i++ { + if itemType := parsed.Get(fmt.Sprintf("input.%d.type", i)).String(); itemType != "function_call_output" { + t.Fatalf("input.%d should not be rewritten: %s", i, parsed.Get(fmt.Sprintf("input.%d", i)).Raw) + } + } + }) +} + +func TestTranslateRequestWithCodexMultiAgentV2OrphanDelegation(t *testing.T) { + payload := []byte(`{ + "model": "test-model", + "stream": false, + "input": [ + { + "type": "function_call_output", + "name": "create_thread", + "namespace": "codex_app", + "output": "handoff" + }, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "please continue"}] + } + ] + }`) + collabHeaders := http.Header{"X-Openai-Subagent": []string{"collab_spawn"}} + + t.Run("enabled but missing X-Openai-Subagent header does not rewrite", func(t *testing.T) { + cfg := &config.Config{ + Codex: config.CodexConfig{ + OrphanDelegationCompatibility: true, + }, + } + got := TranslateRequestWithCodexMultiAgentV2(context.Background(), http.Header{}, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", payload, false) + parsed := gjson.ParseBytes(got) + + item0 := parsed.Get("input.0") + if item0.Get("type").String() != "function_call_output" { + t.Fatalf("input.0 = %s, want function_call_output when X-Openai-Subagent header is missing", item0.Raw) + } + }) + + t.Run("enabled with wrong X-Openai-Subagent header value does not rewrite", func(t *testing.T) { + cfg := &config.Config{ + Codex: config.CodexConfig{ + OrphanDelegationCompatibility: true, + }, + } + headers := http.Header{"X-Openai-Subagent": []string{"other_agent"}} + got := TranslateRequestWithCodexMultiAgentV2(context.Background(), headers, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", payload, false) + parsed := gjson.ParseBytes(got) + + item0 := parsed.Get("input.0") + if item0.Get("type").String() != "function_call_output" { + t.Fatalf("input.0 = %s, want function_call_output when X-Openai-Subagent header is wrong", item0.Raw) + } + }) + + t.Run("enabled translates orphan delegation to user message for responses target", func(t *testing.T) { + cfg := &config.Config{ + Codex: config.CodexConfig{ + OrphanDelegationCompatibility: true, + }, + } + got := TranslateRequestWithCodexMultiAgentV2(context.Background(), collabHeaders, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", payload, false) + parsed := gjson.ParseBytes(got) + + item0 := parsed.Get("input.0") + if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" { + t.Fatalf("input.0 = %s, want message/user", item0.Raw) + } + wantText := "Tool output from codex_app__create_thread:\nhandoff" + if text := item0.Get("content.0.text").String(); text != wantText { + t.Fatalf("input.0.content.0.text = %q, want %q", text, wantText) + } + }) + + t.Run("enabled translates orphan delegation to user message for chat target without empty tool_call_id", func(t *testing.T) { + cfg := &config.Config{ + Codex: config.CodexConfig{ + OrphanDelegationCompatibility: true, + }, + } + got := TranslateRequestWithCodexMultiAgentV2(context.Background(), collabHeaders, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, "test-model", payload, false) + parsed := gjson.ParseBytes(got) + + messages := parsed.Get("messages").Array() + if len(messages) < 2 { + t.Fatalf("expected at least 2 messages, got %d: %s", len(messages), string(got)) + } + msg0 := messages[0] + if msg0.Get("role").String() != "user" { + t.Fatalf("messages.0.role = %q, want user (should not be tool message with empty id)", msg0.Get("role").String()) + } + wantText := "Tool output from codex_app__create_thread:\nhandoff" + var text string + if msg0.Get("content").IsArray() { + text = msg0.Get("content.0.text").String() + } else { + text = msg0.Get("content").String() + } + if text != wantText { + t.Fatalf("messages.0.content = %q, want %q", text, wantText) + } + }) + + t.Run("disabled leaves orphan delegation untranslated for responses target", func(t *testing.T) { + cfg := &config.Config{ + Codex: config.CodexConfig{ + OrphanDelegationCompatibility: false, + }, + } + got := TranslateRequestWithCodexMultiAgentV2(context.Background(), collabHeaders, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", payload, false) + parsed := gjson.ParseBytes(got) + + item0 := parsed.Get("input.0") + if item0.Get("type").String() != "function_call_output" { + t.Fatalf("input.0 = %s, want function_call_output when disabled", item0.Raw) + } + }) + + t.Run("interleaved valid tool pair and orphan delegation retains order and pairing", func(t *testing.T) { + interleaved := []byte(`{ + "model": "test-model", + "input": [ + { + "type": "function_call", + "call_id": "call_1", + "name": "lookup", + "arguments": "{\"q\":\"test\"}" + }, + { + "type": "function_call_output", + "call_id": "call_1", + "name": "lookup", + "output": "result1" + }, + { + "type": "function_call_output", + "name": "create_thread", + "namespace": "codex_app", + "output": "delegated" + } + ] + }`) + cfg := &config.Config{ + Codex: config.CodexConfig{ + OrphanDelegationCompatibility: true, + }, + } + got := TranslateRequestWithCodexMultiAgentV2(context.Background(), collabHeaders, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", interleaved, false) + parsed := gjson.ParseBytes(got) + + if parsed.Get("input.0.type").String() != "function_call" { + t.Fatalf("input.0 should remain function_call") + } + if parsed.Get("input.1.type").String() != "function_call_output" || parsed.Get("input.1.call_id").String() != "call_1" { + t.Fatalf("input.1 should remain paired function_call_output") + } + if parsed.Get("input.2.type").String() != "message" || parsed.Get("input.2.role").String() != "user" { + t.Fatalf("input.2 should be downgraded to message/user: %s", parsed.Get("input.2").Raw) + } + }) +} diff --git a/internal/config/config_types.go b/internal/config/config_types.go index 05b21e712..370845826 100644 --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -158,6 +158,8 @@ type CodexConfig struct { StreamBootstrapBuffering bool `yaml:"stream-bootstrap-buffering" json:"stream-bootstrap-buffering"` // OptimizeMultiAgentV2 optimizes official Codex multi-agent requests. OptimizeMultiAgentV2 bool `yaml:"optimize-multi-agent-v2" json:"optimize-multi-agent-v2"` + // OrphanDelegationCompatibility enables opt-in compatibility for orphan Codex delegation outputs. + OrphanDelegationCompatibility bool `yaml:"orphan-delegation-compatibility" json:"orphan-delegation-compatibility"` // LiveMediaRelay terminates and relays Codex Live WebRTC media in this process. LiveMediaRelay CodexLiveMediaRelayConfig `yaml:"live-media-relay" json:"live-media-relay"` } diff --git a/internal/config/sdk_config.go b/internal/config/sdk_config.go index c7a53ffb3..5c971d696 100644 --- a/internal/config/sdk_config.go +++ b/internal/config/sdk_config.go @@ -45,6 +45,9 @@ type SDKConfig struct { // CodexOptimizeMultiAgentV2 mirrors the provider-wide runtime setting for API handlers. CodexOptimizeMultiAgentV2 bool `yaml:"-" json:"-"` + // CodexOrphanDelegationCompatibility mirrors the provider-wide runtime setting for API handlers. + CodexOrphanDelegationCompatibility bool `yaml:"-" json:"-"` + // ClaudeCode configures Claude Code compatibility behavior. ClaudeCode ClaudeCodeConfig `yaml:"claude-code" json:"claude-code"` diff --git a/internal/runtime/executor/helps/codex_multi_agent_v2.go b/internal/runtime/executor/helps/codex_multi_agent_v2.go index 4e2209f86..56b88a7aa 100644 --- a/internal/runtime/executor/helps/codex_multi_agent_v2.go +++ b/internal/runtime/executor/helps/codex_multi_agent_v2.go @@ -29,6 +29,13 @@ func RewriteCodexMultiAgentV2Input(ctx context.Context, headers http.Header, pay return multiagentv2.RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg) } +// RewriteCodexOrphanDelegationInput converts orphan Codex delegation outputs into +// standard user messages when orphan delegation compatibility is enabled and the +// request carries the X-Openai-Subagent: collab_spawn header. +func RewriteCodexOrphanDelegationInput(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config) []byte { + return multiagentv2.RewriteCodexOrphanDelegationInputForConfig(ctx, headers, payload, cfg) +} + // TranslateRequestWithCodexMultiAgentV2 normalizes official Codex multi-agent // input before translating it to a non-Codex target protocol. func TranslateRequestWithCodexMultiAgentV2(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, payload []byte, stream bool) []byte { @@ -71,8 +78,11 @@ func TranslateRequestWithAPIKeyModelCompatibility(ctx context.Context, headers h if !isCompat { return TranslateRequestWithCodexMultiAgentV2(ctx, headers, cfg, from, to, model, payload, stream) } - if from == sdktranslator.FormatOpenAIResponse && to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse { - payload = multiagentv2.RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg) + if from == sdktranslator.FormatOpenAIResponse { + payload = RewriteCodexOrphanDelegationInput(ctx, headers, payload, cfg) + if to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse { + payload = multiagentv2.RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg) + } } var translated []byte diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index b8a79720d..07ec3e8a4 100644 --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -121,6 +121,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if oldCfg.Codex.OptimizeMultiAgentV2 != newCfg.Codex.OptimizeMultiAgentV2 { changes = append(changes, fmt.Sprintf("codex.optimize-multi-agent-v2: %t -> %t", oldCfg.Codex.OptimizeMultiAgentV2, newCfg.Codex.OptimizeMultiAgentV2)) } + if oldCfg.Codex.OrphanDelegationCompatibility != newCfg.Codex.OrphanDelegationCompatibility { + changes = append(changes, fmt.Sprintf("codex.orphan-delegation-compatibility: %t -> %t", oldCfg.Codex.OrphanDelegationCompatibility, newCfg.Codex.OrphanDelegationCompatibility)) + } if oldCfg.XAI.InjectXSearch != newCfg.XAI.InjectXSearch { changes = append(changes, fmt.Sprintf("xai.inject-x-search: %t -> %t", oldCfg.XAI.InjectXSearch, newCfg.XAI.InjectXSearch)) } diff --git a/internal/watcher/diff/config_diff_test.go b/internal/watcher/diff/config_diff_test.go index f355b1ef4..e221419e7 100644 --- a/internal/watcher/diff/config_diff_test.go +++ b/internal/watcher/diff/config_diff_test.go @@ -204,6 +204,14 @@ func TestBuildConfigChangeDetails_CodexAlphaSearch(t *testing.T) { expectContains(t, changes, "codex[0].alpha-search: false -> true") } +func TestBuildConfigChangeDetails_CodexOrphanDelegationCompatibility(t *testing.T) { + oldCfg := &config.Config{Codex: config.CodexConfig{OrphanDelegationCompatibility: false}} + newCfg := &config.Config{Codex: config.CodexConfig{OrphanDelegationCompatibility: true}} + + changes := BuildConfigChangeDetails(oldCfg, newCfg) + expectContains(t, changes, "codex.orphan-delegation-compatibility: false -> true") +} + func TestBuildConfigChangeDetails_XAIKeys(t *testing.T) { oldRetry := 1 newRetry := 0 diff --git a/sdk/api/handlers/openai/openai_responses_handlers.go b/sdk/api/handlers/openai/openai_responses_handlers.go index 4a830afcf..e05ee1363 100644 --- a/sdk/api/handlers/openai/openai_responses_handlers.go +++ b/sdk/api/handlers/openai/openai_responses_handlers.go @@ -511,6 +511,20 @@ func (h *OpenAIResponsesAPIHandler) prepareCodexMultiAgentV2Tools(c *gin.Context return updated } +func (h *OpenAIResponsesAPIHandler) prepareCodexOrphanDelegation(c *gin.Context, payload []byte) []byte { + if h == nil || h.Cfg == nil || !h.Cfg.CodexOrphanDelegationCompatibility { + return payload + } + requestCtx := context.Background() + var requestHeaders http.Header + if c != nil && c.Request != nil { + requestCtx = c.Request.Context() + requestHeaders = c.Request.Header + } + requestCtx = context.WithValue(requestCtx, "gin", c) + return multiagentv2.RewriteCodexOrphanDelegationInput(requestCtx, requestHeaders, payload, true) +} + // Responses handles the /v1/responses endpoint. // It determines whether the request is for a streaming or non-streaming response // and calls the appropriate handler based on the model provider. @@ -531,6 +545,7 @@ func (h *OpenAIResponsesAPIHandler) Responses(c *gin.Context) { } rawJSON = h.prepareCodexMultiAgentV2Tools(c, rawJSON) + rawJSON = h.prepareCodexOrphanDelegation(c, rawJSON) // Check if the client requested a streaming response. streamResult := gjson.GetBytes(rawJSON, "stream") @@ -554,6 +569,8 @@ func (h *OpenAIResponsesAPIHandler) Compact(c *gin.Context) { return } + rawJSON = h.prepareCodexOrphanDelegation(c, rawJSON) + streamResult := gjson.GetBytes(rawJSON, "stream") if streamResult.Type == gjson.True { c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ diff --git a/sdk/api/handlers/openai/openai_responses_multi_agent_test.go b/sdk/api/handlers/openai/openai_responses_multi_agent_test.go index 2e1be926a..2ea9d50f8 100644 --- a/sdk/api/handlers/openai/openai_responses_multi_agent_test.go +++ b/sdk/api/handlers/openai/openai_responses_multi_agent_test.go @@ -197,3 +197,93 @@ func TestPrepareCodexMultiAgentV2ToolsAtResponsesBoundarySkipsOtherClients(t *te t.Fatal("other client unexpectedly received prepared marker") } } + +func newResponsesOrphanDelegationTestHandler(t *testing.T, executor *responsesMultiAgentCaptureExecutor) (*OpenAIResponsesAPIHandler, string) { + t.Helper() + + modelID := "responses-orphan-test-model" + authID := "responses-orphan-test-auth" + manager := coreauth.NewManager(nil, nil, nil) + manager.RegisterExecutor(executor) + auth := &coreauth.Auth{ID: authID, Provider: "codex", Status: coreauth.StatusActive, ProxyURL: "direct"} + if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil { + t.Fatalf("Register auth: %v", errRegister) + } + registry.GetGlobalRegistry().RegisterClient(authID, auth.Provider, []*registry.ModelInfo{{ID: modelID}}) + t.Cleanup(func() { + registry.GetGlobalRegistry().UnregisterClient(authID) + }) + + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{CodexOrphanDelegationCompatibility: true}, manager) + return NewOpenAIResponsesAPIHandler(base), modelID +} + +func TestResponsesOrphanCodexDelegationCompatibility(t *testing.T) { + t.Parallel() + + executor := &responsesMultiAgentCaptureExecutor{} + handler, modelID := newResponsesOrphanDelegationTestHandler(t, executor) + + router := gin.New() + router.POST("/v1/responses", handler.Responses) + + payload := fmt.Sprintf(`{ + "model": %q, + "stream": false, + "input": [ + { + "type": "function_call_output", + "name": "create_thread", + "namespace": "codex_app", + "output": "msg" + }, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "continue"}] + } + ] + }`, modelID) + + request := httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewBufferString(payload)) + request.Header.Set("X-Openai-Subagent", "collab_spawn") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", recorder.Code, recorder.Body.String()) + } + + payloads := executor.Payloads() + if len(payloads) != 1 { + t.Fatalf("captured payload count = %d, want 1", len(payloads)) + } + captured := payloads[0] + parsed := gjson.ParseBytes(captured) + if itemType := parsed.Get("input.0.type").String(); itemType != "message" { + t.Fatalf("input.0.type = %q, want message; captured=%s", itemType, captured) + } + if role := parsed.Get("input.0.role").String(); role != "user" { + t.Fatalf("input.0.role = %q, want user", role) + } + wantText := "Tool output from codex_app__create_thread:\nmsg" + if text := parsed.Get("input.0.content.0.text").String(); text != wantText { + t.Fatalf("input.0.content.0.text = %q, want %q", text, wantText) + } + + // Without X-Openai-Subagent header, payload should remain function_call_output + requestNoHeader := httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewBufferString(payload)) + recorderNoHeader := httptest.NewRecorder() + router.ServeHTTP(recorderNoHeader, requestNoHeader) + if recorderNoHeader.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", recorderNoHeader.Code, recorderNoHeader.Body.String()) + } + payloads = executor.Payloads() + if len(payloads) != 2 { + t.Fatalf("captured payload count = %d, want 2", len(payloads)) + } + capturedNoHeader := payloads[1] + parsedNoHeader := gjson.ParseBytes(capturedNoHeader) + if itemType := parsedNoHeader.Get("input.0.type").String(); itemType != "function_call_output" { + t.Fatalf("input.0.type = %q, want function_call_output without header; captured=%s", itemType, capturedNoHeader) + } +} diff --git a/sdk/api/handlers/openai/openai_responses_websocket.go b/sdk/api/handlers/openai/openai_responses_websocket.go index 8999df0b4..21cfa077b 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket.go +++ b/sdk/api/handlers/openai/openai_responses_websocket.go @@ -522,6 +522,7 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) { } requestJSON = h.prepareCodexMultiAgentV2Tools(c, requestJSON) + requestJSON = h.prepareCodexOrphanDelegation(c, requestJSON) if !useUpstreamWebsocketPassthrough && shouldHandleResponsesWebsocketPrewarmLocally(payload, lastRequest, false) { if updated, errDelete := sjson.DeleteBytes(requestJSON, "generate"); errDelete == nil {