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 {