- Add `codex.orphan-delegation-compatibility` configuration option and mirror it to SDK configuration. - Convert orphan Codex delegation outputs into standard user messages for requests with `X-Openai-Subagent: collab_spawn`. - Integrate orphan delegation rewriting into OpenAI responses request handling pipeline.
Unified · Split
| @@ -262,6 +262,12 @@ codex: · 261 unmodified lines | ||
| 262 | 262 | # normalizes encrypted agent_message content for Codex, and converts agent_message input |
| 263 | 263 | # into standard user messages for non-Codex upstream protocols. |
| 264 | 264 | optimize-multi-agent-v2: false |
| 265 | + # When true, enable opt-in compatibility for orphan Codex delegation outputs. | |
| 266 | + # Converts orphan function_call_output items from codex_app/create_thread and | |
| 267 | + # codex_app/send_message_to_thread (which lack a valid call_id or matching function_call) | |
| 268 | + # into standard user messages across Responses and non-Codex protocols when the request | |
| 269 | + # header contains X-Openai-Subagent: collab_spawn. | |
| 270 | + orphan-delegation-compatibility: false | |
| 265 | 271 | # Terminate and relay Codex Live WebRTC audio and DataChannel traffic in this process. |
| 266 | 272 | # This requires inbound UDP reachability. Keep disabled to preserve direct media behavior. |
| 267 | 273 | live-media-relay: |
| @@ -46,6 +46,7 @@ func effectiveSDKConfig(cfg *config.Config) *config.SDKConfig { · 45 unmodified lines | ||
| 46 | 46 | } |
| 47 | 47 | sdkCfg := cfg.SDKConfig |
| 48 | 48 | sdkCfg.CodexOptimizeMultiAgentV2 = cfg.Codex.OptimizeMultiAgentV2 |
| 49 | + sdkCfg.CodexOrphanDelegationCompatibility = cfg.Codex.OrphanDelegationCompatibility | |
| 49 | 50 | if cfg.CommercialMode { |
| 50 | 51 | sdkCfg.RequestLog = false |
| 51 | 52 | } |
| @@ -14,3 +14,12 @@ func TestEffectiveSDKConfigCopiesCodexOptimizeMultiAgentV2(t *testing.T) { · 13 unmodified lines | ||
| 14 | 14 | t.Fatalf("CodexOptimizeMultiAgentV2 = false, want true") |
| 15 | 15 | } |
| 16 | 16 | } |
| 17 | + | |
| 18 | +func TestEffectiveSDKConfigCopiesCodexOrphanDelegationCompatibility(t *testing.T) { | |
| 19 | + cfg := &config.Config{Codex: config.CodexConfig{OrphanDelegationCompatibility: true}} | |
| 20 | + | |
| 21 | + sdkCfg := effectiveSDKConfig(cfg) | |
| 22 | + if sdkCfg == nil || !sdkCfg.CodexOrphanDelegationCompatibility { | |
| 23 | + t.Fatalf("CodexOrphanDelegationCompatibility = false, want true") | |
| 24 | + } | |
| 25 | +} | |
| @@ -71,11 +71,23 @@ func RewriteCodexMultiAgentV2Input(ctx context.Context, headers http.Header, pay · 70 unmodified lines | ||
| 71 | 71 | return rewriteCodexAgentMessageInput(payload) |
| 72 | 72 | } |
| 73 | 73 | |
| 74 | +// RewriteCodexOrphanDelegationInputForConfig applies RewriteCodexOrphanDelegationInput | |
| 75 | +// based on cfg.Codex.OrphanDelegationCompatibility and the X-Openai-Subagent header. | |
| 76 | +func RewriteCodexOrphanDelegationInputForConfig(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config) []byte { | |
| 77 | + if cfg == nil || !cfg.Codex.OrphanDelegationCompatibility { | |
| 78 | + return payload | |
| 79 | + } | |
| 80 | + return RewriteCodexOrphanDelegationInput(ctx, headers, payload, true) | |
| 81 | +} | |
| 82 | + | |
| 74 | 83 | // TranslateRequestWithCodexMultiAgentV2 normalizes official Codex multi-agent |
| 75 | 84 | // input before translating it to a non-Codex target protocol. |
| 76 | 85 | func TranslateRequestWithCodexMultiAgentV2(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, payload []byte, stream bool) []byte { |
| 77 | − if from == sdktranslator.FormatOpenAIResponse && to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse { | |
| 78 | − payload = RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg) | |
| 86 | + if from == sdktranslator.FormatOpenAIResponse { | |
| 87 | + payload = RewriteCodexOrphanDelegationInputForConfig(ctx, headers, payload, cfg) | |
| 88 | + if to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse { | |
| 89 | + payload = RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg) | |
| 90 | + } | |
| 79 | 91 | } |
| 80 | 92 | return sdktranslator.TranslateRequest(from, to, model, payload, stream) |
| 81 | 93 | } |
| @@ -0,0 +1,122 @@ | ||
| 1 | +package multiagentv2 | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "net/http" | |
| 7 | + "strings" | |
| 8 | + | |
| 9 | + "github.com/gin-gonic/gin" | |
| 10 | + "github.com/tidwall/gjson" | |
| 11 | + "github.com/tidwall/sjson" | |
| 12 | +) | |
| 13 | + | |
| 14 | +const ( | |
| 15 | + codexAppNamespace = "codex_app" | |
| 16 | + codexCreateThreadName = "create_thread" | |
| 17 | + codexSendMessageToThreadName = "send_message_to_thread" | |
| 18 | + codexAppCreateThreadTool = "codex_app__create_thread" | |
| 19 | + codexAppSendMessageTool = "codex_app__send_message_to_thread" | |
| 20 | + codexOpenAISubagentHeader = "X-Openai-Subagent" | |
| 21 | + codexCollabSpawnSubagent = "collab_spawn" | |
| 22 | +) | |
| 23 | + | |
| 24 | +// RewriteCodexOrphanDelegationInput converts orphan Codex delegation outputs into | |
| 25 | +// standard user messages when orphan delegation compatibility is enabled and the | |
| 26 | +// request carries the X-Openai-Subagent: collab_spawn header. | |
| 27 | +func RewriteCodexOrphanDelegationInput(ctx context.Context, headers http.Header, payload []byte, enabled bool) []byte { | |
| 28 | + if !enabled || len(payload) == 0 || !isCodexCollabSpawnSubagent(ctx, headers) { | |
| 29 | + return payload | |
| 30 | + } | |
| 31 | + | |
| 32 | + input := gjson.GetBytes(payload, "input") | |
| 33 | + if !input.IsArray() { | |
| 34 | + return payload | |
| 35 | + } | |
| 36 | + | |
| 37 | + inputItems := input.Array() | |
| 38 | + availableCalls := make(map[string]int) | |
| 39 | + for _, item := range inputItems { | |
| 40 | + if item.Get("type").String() == "function_call" { | |
| 41 | + callID := item.Get("call_id").String() | |
| 42 | + if strings.TrimSpace(callID) != "" { | |
| 43 | + availableCalls[callID]++ | |
| 44 | + } | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + updated := payload | |
| 49 | + for itemIndex, item := range inputItems { | |
| 50 | + if item.Get("type").String() != "function_call_output" { | |
| 51 | + continue | |
| 52 | + } | |
| 53 | + | |
| 54 | + callID := item.Get("call_id").String() | |
| 55 | + if strings.TrimSpace(callID) != "" && availableCalls[callID] > 0 { | |
| 56 | + // Paired with a function call in the same request; consume and preserve. | |
| 57 | + availableCalls[callID]-- | |
| 58 | + continue | |
| 59 | + } | |
| 60 | + | |
| 61 | + toolLabel, isTarget := matchCodexDelegationTool(item) | |
| 62 | + if !isTarget { | |
| 63 | + continue | |
| 64 | + } | |
| 65 | + | |
| 66 | + // Orphan delegation output: downgrade to user message preserving exact output. | |
| 67 | + itemPath := fmt.Sprintf("input.%d", itemIndex) | |
| 68 | + userMessage := buildCodexOrphanUserMessage(toolLabel, item.Get("output")) | |
| 69 | + var errSet error | |
| 70 | + updated, errSet = sjson.SetRawBytes(updated, itemPath, userMessage) | |
| 71 | + if errSet != nil { | |
| 72 | + return payload | |
| 73 | + } | |
| 74 | + } | |
| 75 | + | |
| 76 | + return updated | |
| 77 | +} | |
| 78 | + | |
| 79 | +func codexSubagentHeader(ctx context.Context, headers http.Header) string { | |
| 80 | + if ctx != nil { | |
| 81 | + if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { | |
| 82 | + return headerValueCaseInsensitive(ginCtx.Request.Header, codexOpenAISubagentHeader) | |
| 83 | + } | |
| 84 | + } | |
| 85 | + return headerValueCaseInsensitive(headers, codexOpenAISubagentHeader) | |
| 86 | +} | |
| 87 | + | |
| 88 | +func isCodexCollabSpawnSubagent(ctx context.Context, headers http.Header) bool { | |
| 89 | + return strings.EqualFold(codexSubagentHeader(ctx, headers), codexCollabSpawnSubagent) | |
| 90 | +} | |
| 91 | + | |
| 92 | +func matchCodexDelegationTool(item gjson.Result) (string, bool) { | |
| 93 | + if item.Get("namespace").String() != codexAppNamespace { | |
| 94 | + return "", false | |
| 95 | + } | |
| 96 | + | |
| 97 | + switch item.Get("name").String() { | |
| 98 | + case codexCreateThreadName: | |
| 99 | + return codexAppCreateThreadTool, true | |
| 100 | + case codexSendMessageToThreadName: | |
| 101 | + return codexAppSendMessageTool, true | |
| 102 | + default: | |
| 103 | + return "", false | |
| 104 | + } | |
| 105 | +} | |
| 106 | + | |
| 107 | +func buildCodexOrphanUserMessage(toolLabel string, output gjson.Result) []byte { | |
| 108 | + outputText := "" | |
| 109 | + if output.Exists() { | |
| 110 | + if output.Type == gjson.String { | |
| 111 | + outputText = output.String() | |
| 112 | + } else { | |
| 113 | + outputText = output.Raw | |
| 114 | + } | |
| 115 | + } | |
| 116 | + | |
| 117 | + fullText := fmt.Sprintf("Tool output from %s:\n%s", toolLabel, outputText) | |
| 118 | + | |
| 119 | + msg := []byte(`{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}`) | |
| 120 | + msg, _ = sjson.SetBytes(msg, "content.0.text", fullText) | |
| 121 | + return msg | |
| 122 | +} | |
| @@ -0,0 +1,613 @@ | ||
| 1 | +package multiagentv2 | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "net/http" | |
| 7 | + "testing" | |
| 8 | + | |
| 9 | + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" | |
| 10 | + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" | |
| 11 | + "github.com/tidwall/gjson" | |
| 12 | +) | |
| 13 | + | |
| 14 | +func testRewriteCodexOrphan(payload []byte, enabled bool) []byte { | |
| 15 | + headers := http.Header{"X-Openai-Subagent": []string{"collab_spawn"}} | |
| 16 | + return RewriteCodexOrphanDelegationInput(context.Background(), headers, payload, enabled) | |
| 17 | +} | |
| 18 | + | |
| 19 | +func TestRewriteCodexOrphanDelegationInput(t *testing.T) { | |
| 20 | + t.Run("disabled leaves payload unchanged", func(t *testing.T) { | |
| 21 | + payload := []byte(`{ | |
| 22 | + "model": "deepseek-v4-pro", | |
| 23 | + "input": [ | |
| 24 | + { | |
| 25 | + "type": "function_call_output", | |
| 26 | + "name": "create_thread", | |
| 27 | + "namespace": "codex_app", | |
| 28 | + "output": "<codex_delegation><message>handoff</message></codex_delegation>" | |
| 29 | + } | |
| 30 | + ] | |
| 31 | + }`) | |
| 32 | + got := testRewriteCodexOrphan(payload, false) | |
| 33 | + if string(got) != string(payload) { | |
| 34 | + t.Fatalf("expected payload unchanged when disabled, got: %s", string(got)) | |
| 35 | + } | |
| 36 | + }) | |
| 37 | + | |
| 38 | + t.Run("missing subagent header leaves payload unchanged", func(t *testing.T) { | |
| 39 | + payload := []byte(`{ | |
| 40 | + "model": "deepseek-v4-pro", | |
| 41 | + "input": [ | |
| 42 | + { | |
| 43 | + "type": "function_call_output", | |
| 44 | + "name": "create_thread", | |
| 45 | + "namespace": "codex_app", | |
| 46 | + "output": "<codex_delegation><message>handoff</message></codex_delegation>" | |
| 47 | + } | |
| 48 | + ] | |
| 49 | + }`) | |
| 50 | + got := RewriteCodexOrphanDelegationInput(context.Background(), http.Header{}, payload, true) | |
| 51 | + if string(got) != string(payload) { | |
| 52 | + t.Fatalf("expected payload unchanged without header, got: %s", string(got)) | |
| 53 | + } | |
| 54 | + }) | |
| 55 | + | |
| 56 | + t.Run("different subagent header leaves payload unchanged", func(t *testing.T) { | |
| 57 | + payload := []byte(`{ | |
| 58 | + "model": "deepseek-v4-pro", | |
| 59 | + "input": [ | |
| 60 | + { | |
| 61 | + "type": "function_call_output", | |
| 62 | + "name": "create_thread", | |
| 63 | + "namespace": "codex_app", | |
| 64 | + "output": "<codex_delegation><message>handoff</message></codex_delegation>" | |
| 65 | + } | |
| 66 | + ] | |
| 67 | + }`) | |
| 68 | + headers := http.Header{"X-Openai-Subagent": []string{"other_subagent"}} | |
| 69 | + got := RewriteCodexOrphanDelegationInput(context.Background(), headers, payload, true) | |
| 70 | + if string(got) != string(payload) { | |
| 71 | + t.Fatalf("expected payload unchanged with wrong header, got: %s", string(got)) | |
| 72 | + } | |
| 73 | + }) | |
| 74 | + | |
| 75 | + t.Run("rewrites orphan create_thread without call_id", func(t *testing.T) { | |
| 76 | + payload := []byte(`{ | |
| 77 | + "model": "deepseek-v4-pro", | |
| 78 | + "input": [ | |
| 79 | + { | |
| 80 | + "type": "function_call_output", | |
| 81 | + "name": "create_thread", | |
| 82 | + "namespace": "codex_app", | |
| 83 | + "output": "<codex_delegation><message>handoff</message></codex_delegation>" | |
| 84 | + }, | |
| 85 | + { | |
| 86 | + "type": "message", | |
| 87 | + "role": "user", | |
| 88 | + "content": [{"type": "input_text", "text": "please continue"}] | |
| 89 | + } | |
| 90 | + ] | |
| 91 | + }`) | |
| 92 | + got := testRewriteCodexOrphan(payload, true) | |
| 93 | + parsed := gjson.ParseBytes(got) | |
| 94 | + | |
| 95 | + item0 := parsed.Get("input.0") | |
| 96 | + if item0.Get("type").String() != "message" { | |
| 97 | + t.Fatalf("input.0.type = %q, want %q", item0.Get("type").String(), "message") | |
| 98 | + } | |
| 99 | + if item0.Get("role").String() != "user" { | |
| 100 | + t.Fatalf("input.0.role = %q, want %q", item0.Get("role").String(), "user") | |
| 101 | + } | |
| 102 | + wantText := "Tool output from codex_app__create_thread:\n<codex_delegation><message>handoff</message></codex_delegation>" | |
| 103 | + if text := item0.Get("content.0.text").String(); text != wantText { | |
| 104 | + t.Fatalf("input.0.content.0.text = %q, want %q", text, wantText) | |
| 105 | + } | |
| 106 | + | |
| 107 | + item1 := parsed.Get("input.1") | |
| 108 | + if item1.Get("type").String() != "message" || item1.Get("content.0.text").String() != "please continue" { | |
| 109 | + t.Fatalf("input.1 corrupted: %s", item1.Raw) | |
| 110 | + } | |
| 111 | + }) | |
| 112 | + | |
| 113 | + t.Run("case-insensitive header key and value works", func(t *testing.T) { | |
| 114 | + payload := []byte(`{ | |
| 115 | + "model": "deepseek-v4-pro", | |
| 116 | + "input": [ | |
| 117 | + { | |
| 118 | + "type": "function_call_output", | |
| 119 | + "name": "create_thread", | |
| 120 | + "namespace": "codex_app", | |
| 121 | + "output": "msg" | |
| 122 | + } | |
| 123 | + ] | |
| 124 | + }`) | |
| 125 | + headers := http.Header{"x-openai-subagent": []string{"COLLAB_SPAWN"}} | |
| 126 | + got := RewriteCodexOrphanDelegationInput(context.Background(), headers, payload, true) | |
| 127 | + parsed := gjson.ParseBytes(got) | |
| 128 | + | |
| 129 | + item0 := parsed.Get("input.0") | |
| 130 | + if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" { | |
| 131 | + t.Fatalf("case-insensitive header should rewrite, got: %s", item0.Raw) | |
| 132 | + } | |
| 133 | + }) | |
| 134 | + | |
| 135 | + t.Run("rewrites orphan send_message_to_thread with stale call_id", func(t *testing.T) { | |
| 136 | + payload := []byte(`{ | |
| 137 | + "model": "deepseek-v4-pro", | |
| 138 | + "input": [ | |
| 139 | + { | |
| 140 | + "type": "function_call_output", | |
| 141 | + "call_id": "call_stale_123", | |
| 142 | + "name": "send_message_to_thread", | |
| 143 | + "namespace": "codex_app", | |
| 144 | + "output": "<codex_delegation>msg</codex_delegation>" | |
| 145 | + } | |
| 146 | + ] | |
| 147 | + }`) | |
| 148 | + got := testRewriteCodexOrphan(payload, true) | |
| 149 | + parsed := gjson.ParseBytes(got) | |
| 150 | + | |
| 151 | + item0 := parsed.Get("input.0") | |
| 152 | + if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" { | |
| 153 | + t.Fatalf("input.0 not rewritten: %s", item0.Raw) | |
| 154 | + } | |
| 155 | + wantText := "Tool output from codex_app__send_message_to_thread:\n<codex_delegation>msg</codex_delegation>" | |
| 156 | + if text := item0.Get("content.0.text").String(); text != wantText { | |
| 157 | + t.Fatalf("input.0.content.0.text = %q, want %q", text, wantText) | |
| 158 | + } | |
| 159 | + }) | |
| 160 | + | |
| 161 | + t.Run("preserves paired create_thread tool call", func(t *testing.T) { | |
| 162 | + payload := []byte(`{ | |
| 163 | + "model": "deepseek-v4-pro", | |
| 164 | + "input": [ | |
| 165 | + { | |
| 166 | + "type": "function_call", | |
| 167 | + "call_id": "call_active_123", | |
| 168 | + "name": "create_thread", | |
| 169 | + "namespace": "codex_app", | |
| 170 | + "arguments": "{}" | |
| 171 | + }, | |
| 172 | + { | |
| 173 | + "type": "function_call_output", | |
| 174 | + "call_id": "call_active_123", | |
| 175 | + "name": "create_thread", | |
| 176 | + "namespace": "codex_app", | |
| 177 | + "output": "<codex_delegation>valid</codex_delegation>" | |
| 178 | + } | |
| 179 | + ] | |
| 180 | + }`) | |
| 181 | + got := testRewriteCodexOrphan(payload, true) | |
| 182 | + parsed := gjson.ParseBytes(got) | |
| 183 | + | |
| 184 | + item1 := parsed.Get("input.1") | |
| 185 | + if item1.Get("type").String() != "function_call_output" { | |
| 186 | + t.Fatalf("paired call output was modified: %s", item1.Raw) | |
| 187 | + } | |
| 188 | + if item1.Get("call_id").String() != "call_active_123" { | |
| 189 | + t.Fatalf("call_id changed: %s", item1.Raw) | |
| 190 | + } | |
| 191 | + }) | |
| 192 | + | |
| 193 | + t.Run("preserves non-whitelisted tools", func(t *testing.T) { | |
| 194 | + payload := []byte(`{ | |
| 195 | + "model": "deepseek-v4-pro", | |
| 196 | + "input": [ | |
| 197 | + { | |
| 198 | + "type": "function_call_output", | |
| 199 | + "name": "automation_update", | |
| 200 | + "namespace": "codex_app", | |
| 201 | + "output": "ignored" | |
| 202 | + }, | |
| 203 | + { | |
| 204 | + "type": "function_call_output", | |
| 205 | + "name": "create_thread", | |
| 206 | + "namespace": "other_namespace", | |
| 207 | + "output": "ignored" | |
| 208 | + } | |
| 209 | + ] | |
| 210 | + }`) | |
| 211 | + got := testRewriteCodexOrphan(payload, true) | |
| 212 | + parsed := gjson.ParseBytes(got) | |
| 213 | + | |
| 214 | + if parsed.Get("input.0.type").String() != "function_call_output" { | |
| 215 | + t.Fatalf("automation_update should not be rewritten: %s", parsed.Get("input.0").Raw) | |
| 216 | + } | |
| 217 | + if parsed.Get("input.1.type").String() != "function_call_output" { | |
| 218 | + t.Fatalf("other_namespace should not be rewritten: %s", parsed.Get("input.1").Raw) | |
| 219 | + } | |
| 220 | + }) | |
| 221 | + | |
| 222 | + t.Run("handles empty output", func(t *testing.T) { | |
| 223 | + payload := []byte(`{ | |
| 224 | + "model": "deepseek-v4-pro", | |
| 225 | + "input": [ | |
| 226 | + { | |
| 227 | + "type": "function_call_output", | |
| 228 | + "name": "create_thread", | |
| 229 | + "namespace": "codex_app", | |
| 230 | + "output": "" | |
| 231 | + } | |
| 232 | + ] | |
| 233 | + }`) | |
| 234 | + got := testRewriteCodexOrphan(payload, true) | |
| 235 | + parsed := gjson.ParseBytes(got) | |
| 236 | + | |
| 237 | + item0 := parsed.Get("input.0") | |
| 238 | + if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" { | |
| 239 | + t.Fatalf("input.0 not rewritten: %s", item0.Raw) | |
| 240 | + } | |
| 241 | + wantText := "Tool output from codex_app__create_thread:\n" | |
| 242 | + if text := item0.Get("content.0.text").String(); text != wantText { | |
| 243 | + t.Fatalf("input.0.content.0.text = %q, want %q", text, wantText) | |
| 244 | + } | |
| 245 | + }) | |
| 246 | + | |
| 247 | + t.Run("call_id whitespace difference is not paired", func(t *testing.T) { | |
| 248 | + payload := []byte(`{ | |
| 249 | + "model": "deepseek-v4-pro", | |
| 250 | + "input": [ | |
| 251 | + { | |
| 252 | + "type": "function_call", | |
| 253 | + "call_id": "call_1", | |
| 254 | + "name": "create_thread", | |
| 255 | + "namespace": "codex_app", | |
| 256 | + "arguments": "{}" | |
| 257 | + }, | |
| 258 | + { | |
| 259 | + "type": "function_call_output", | |
| 260 | + "call_id": " call_1 ", | |
| 261 | + "name": "create_thread", | |
| 262 | + "namespace": "codex_app", | |
| 263 | + "output": "mismatch" | |
| 264 | + } | |
| 265 | + ] | |
| 266 | + }`) | |
| 267 | + got := testRewriteCodexOrphan(payload, true) | |
| 268 | + parsed := gjson.ParseBytes(got) | |
| 269 | + | |
| 270 | + if parsed.Get("input.1.type").String() != "message" { | |
| 271 | + t.Fatalf("call_id with whitespace mismatch should be treated as orphan: %s", parsed.Get("input.1").Raw) | |
| 272 | + } | |
| 273 | + }) | |
| 274 | + | |
| 275 | + t.Run("preserves structured image output in delegation as exact text", func(t *testing.T) { | |
| 276 | + rawOutput := `[{"type":"input_text","text":"diagram"},{"type":"input_image","image_url":"https://example.com/img.png"}]` | |
| 277 | + payload := []byte(`{ | |
| 278 | + "model": "deepseek-v4-pro", | |
| 279 | + "input": [ | |
| 280 | + { | |
| 281 | + "type": "function_call_output", | |
| 282 | + "name": "create_thread", | |
| 283 | + "namespace": "codex_app", | |
| 284 | + "output": ` + rawOutput + ` | |
| 285 | + } | |
| 286 | + ] | |
| 287 | + }`) | |
| 288 | + got := testRewriteCodexOrphan(payload, true) | |
| 289 | + parsed := gjson.ParseBytes(got) | |
| 290 | + | |
| 291 | + item0 := parsed.Get("input.0") | |
| 292 | + if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" { | |
| 293 | + t.Fatalf("input.0 not rewritten: %s", item0.Raw) | |
| 294 | + } | |
| 295 | + wantText := "Tool output from codex_app__create_thread:\n" + rawOutput | |
| 296 | + if text := item0.Get("content.0.text").String(); text != wantText { | |
| 297 | + t.Fatalf("input.0.content.0.text = %q, want %q", text, wantText) | |
| 298 | + } | |
| 299 | + }) | |
| 300 | + | |
| 301 | + t.Run("call and output in same request are paired regardless of order", func(t *testing.T) { | |
| 302 | + payload := []byte(`{ | |
| 303 | + "model": "deepseek-v4-pro", | |
| 304 | + "input": [ | |
| 305 | + { | |
| 306 | + "type": "function_call_output", | |
| 307 | + "call_id": "call_future_1", | |
| 308 | + "name": "create_thread", | |
| 309 | + "namespace": "codex_app", | |
| 310 | + "output": "early" | |
| 311 | + }, | |
| 312 | + { | |
| 313 | + "type": "function_call", | |
| 314 | + "call_id": "call_future_1", | |
| 315 | + "name": "create_thread", | |
| 316 | + "namespace": "codex_app", | |
| 317 | + "arguments": "{}" | |
| 318 | + } | |
| 319 | + ] | |
| 320 | + }`) | |
| 321 | + got := testRewriteCodexOrphan(payload, true) | |
| 322 | + parsed := gjson.ParseBytes(got) | |
| 323 | + | |
| 324 | + if parsed.Get("input.0.type").String() != "function_call_output" { | |
| 325 | + t.Fatalf("output should remain paired function_call_output: %s", parsed.Get("input.0").Raw) | |
| 326 | + } | |
| 327 | + if parsed.Get("input.1.type").String() != "function_call" { | |
| 328 | + t.Fatalf("call should remain function_call: %s", parsed.Get("input.1").Raw) | |
| 329 | + } | |
| 330 | + }) | |
| 331 | + | |
| 332 | + t.Run("duplicate output consumes call once; second output is orphan", func(t *testing.T) { | |
| 333 | + payload := []byte(`{ | |
| 334 | + "model": "deepseek-v4-pro", | |
| 335 | + "input": [ | |
| 336 | + { | |
| 337 | + "type": "function_call", | |
| 338 | + "call_id": "call_once", | |
| 339 | + "name": "create_thread", | |
| 340 | + "namespace": "codex_app", | |
| 341 | + "arguments": "{}" | |
| 342 | + }, | |
| 343 | + { | |
| 344 | + "type": "function_call_output", | |
| 345 | + "call_id": "call_once", | |
| 346 | + "name": "create_thread", | |
| 347 | + "namespace": "codex_app", | |
| 348 | + "output": "first" | |
| 349 | + }, | |
| 350 | + { | |
| 351 | + "type": "function_call_output", | |
| 352 | + "call_id": "call_once", | |
| 353 | + "name": "create_thread", | |
| 354 | + "namespace": "codex_app", | |
| 355 | + "output": "second" | |
| 356 | + } | |
| 357 | + ] | |
| 358 | + }`) | |
| 359 | + got := testRewriteCodexOrphan(payload, true) | |
| 360 | + parsed := gjson.ParseBytes(got) | |
| 361 | + | |
| 362 | + if parsed.Get("input.1.type").String() != "function_call_output" { | |
| 363 | + t.Fatalf("first output should pair with call: %s", parsed.Get("input.1").Raw) | |
| 364 | + } | |
| 365 | + if parsed.Get("input.2.type").String() != "message" { | |
| 366 | + t.Fatalf("second output should be orphan: %s", parsed.Get("input.2").Raw) | |
| 367 | + } | |
| 368 | + }) | |
| 369 | + | |
| 370 | + t.Run("custom tool call does not pair with function call output", func(t *testing.T) { | |
| 371 | + payload := []byte(`{ | |
| 372 | + "model": "deepseek-v4-pro", | |
| 373 | + "input": [ | |
| 374 | + { | |
| 375 | + "type": "custom_tool_call", | |
| 376 | + "call_id": "call_custom", | |
| 377 | + "name": "create_thread", | |
| 378 | + "input": "{}" | |
| 379 | + }, | |
| 380 | + { | |
| 381 | + "type": "function_call_output", | |
| 382 | + "call_id": "call_custom", | |
| 383 | + "name": "create_thread", | |
| 384 | + "namespace": "codex_app", | |
| 385 | + "output": "orphan" | |
| 386 | + } | |
| 387 | + ] | |
| 388 | + }`) | |
| 389 | + got := testRewriteCodexOrphan(payload, true) | |
| 390 | + parsed := gjson.ParseBytes(got) | |
| 391 | + | |
| 392 | + if parsed.Get("input.1.type").String() != "message" { | |
| 393 | + t.Fatalf("function_call_output should not pair with custom_tool_call: %s", parsed.Get("input.1").Raw) | |
| 394 | + } | |
| 395 | + }) | |
| 396 | + | |
| 397 | + t.Run("assistant message tool_calls does not pair with Responses function_call_output", func(t *testing.T) { | |
| 398 | + payload := []byte(`{ | |
| 399 | + "model": "deepseek-v4-pro", | |
| 400 | + "input": [ | |
| 401 | + { | |
| 402 | + "type": "message", | |
| 403 | + "role": "assistant", | |
| 404 | + "tool_calls": [{"id": "call_in_msg", "type": "function", "function": {"name": "create_thread"}}] | |
| 405 | + }, | |
| 406 | + { | |
| 407 | + "type": "function_call_output", | |
| 408 | + "call_id": "call_in_msg", | |
| 409 | + "name": "create_thread", | |
| 410 | + "namespace": "codex_app", | |
| 411 | + "output": "orphan" | |
| 412 | + } | |
| 413 | + ] | |
| 414 | + }`) | |
| 415 | + got := testRewriteCodexOrphan(payload, true) | |
| 416 | + parsed := gjson.ParseBytes(got) | |
| 417 | + | |
| 418 | + if parsed.Get("input.1.type").String() != "message" { | |
| 419 | + t.Fatalf("function_call_output should not pair with assistant message tool_calls: %s", parsed.Get("input.1").Raw) | |
| 420 | + } | |
| 421 | + }) | |
| 422 | + | |
| 423 | + t.Run("exact namespace and name required", func(t *testing.T) { | |
| 424 | + payload := []byte(`{ | |
| 425 | + "model": "deepseek-v4-pro", | |
| 426 | + "input": [ | |
| 427 | + { | |
| 428 | + "type": "function_call_output", | |
| 429 | + "name": "codex_app__create_thread", | |
| 430 | + "output": "orphan" | |
| 431 | + }, | |
| 432 | + { | |
| 433 | + "type": "function_call_output", | |
| 434 | + "name": "create_thread", | |
| 435 | + "namespace": " codex_app ", | |
| 436 | + "output": "orphan" | |
| 437 | + }, | |
| 438 | + { | |
| 439 | + "type": "function_call_output", | |
| 440 | + "name": "other_tool", | |
| 441 | + "namespace": "codex_app", | |
| 442 | + "output": "orphan" | |
| 443 | + } | |
| 444 | + ] | |
| 445 | + }`) | |
| 446 | + got := testRewriteCodexOrphan(payload, true) | |
| 447 | + parsed := gjson.ParseBytes(got) | |
| 448 | + | |
| 449 | + for i := 0; i < 3; i++ { | |
| 450 | + if itemType := parsed.Get(fmt.Sprintf("input.%d.type", i)).String(); itemType != "function_call_output" { | |
| 451 | + t.Fatalf("input.%d should not be rewritten: %s", i, parsed.Get(fmt.Sprintf("input.%d", i)).Raw) | |
| 452 | + } | |
| 453 | + } | |
| 454 | + }) | |
| 455 | +} | |
| 456 | + | |
| 457 | +func TestTranslateRequestWithCodexMultiAgentV2OrphanDelegation(t *testing.T) { | |
| 458 | + payload := []byte(`{ | |
| 459 | + "model": "test-model", | |
| 460 | + "stream": false, | |
| 461 | + "input": [ | |
| 462 | + { | |
| 463 | + "type": "function_call_output", | |
| 464 | + "name": "create_thread", | |
| 465 | + "namespace": "codex_app", | |
| 466 | + "output": "<codex_delegation><message>handoff</message></codex_delegation>" | |
| 467 | + }, | |
| 468 | + { | |
| 469 | + "type": "message", | |
| 470 | + "role": "user", | |
| 471 | + "content": [{"type": "input_text", "text": "please continue"}] | |
| 472 | + } | |
| 473 | + ] | |
| 474 | + }`) | |
| 475 | + collabHeaders := http.Header{"X-Openai-Subagent": []string{"collab_spawn"}} | |
| 476 | + | |
| 477 | + t.Run("enabled but missing X-Openai-Subagent header does not rewrite", func(t *testing.T) { | |
| 478 | + cfg := &config.Config{ | |
| 479 | + Codex: config.CodexConfig{ | |
| 480 | + OrphanDelegationCompatibility: true, | |
| 481 | + }, | |
| 482 | + } | |
| 483 | + got := TranslateRequestWithCodexMultiAgentV2(context.Background(), http.Header{}, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", payload, false) | |
| 484 | + parsed := gjson.ParseBytes(got) | |
| 485 | + | |
| 486 | + item0 := parsed.Get("input.0") | |
| 487 | + if item0.Get("type").String() != "function_call_output" { | |
| 488 | + t.Fatalf("input.0 = %s, want function_call_output when X-Openai-Subagent header is missing", item0.Raw) | |
| 489 | + } | |
| 490 | + }) | |
| 491 | + | |
| 492 | + t.Run("enabled with wrong X-Openai-Subagent header value does not rewrite", func(t *testing.T) { | |
| 493 | + cfg := &config.Config{ | |
| 494 | + Codex: config.CodexConfig{ | |
| 495 | + OrphanDelegationCompatibility: true, | |
| 496 | + }, | |
| 497 | + } | |
| 498 | + headers := http.Header{"X-Openai-Subagent": []string{"other_agent"}} | |
| 499 | + got := TranslateRequestWithCodexMultiAgentV2(context.Background(), headers, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", payload, false) | |
| 500 | + parsed := gjson.ParseBytes(got) | |
| 501 | + | |
| 502 | + item0 := parsed.Get("input.0") | |
| 503 | + if item0.Get("type").String() != "function_call_output" { | |
| 504 | + t.Fatalf("input.0 = %s, want function_call_output when X-Openai-Subagent header is wrong", item0.Raw) | |
| 505 | + } | |
| 506 | + }) | |
| 507 | + | |
| 508 | + t.Run("enabled translates orphan delegation to user message for responses target", func(t *testing.T) { | |
| 509 | + cfg := &config.Config{ | |
| 510 | + Codex: config.CodexConfig{ | |
| 511 | + OrphanDelegationCompatibility: true, | |
| 512 | + }, | |
| 513 | + } | |
| 514 | + got := TranslateRequestWithCodexMultiAgentV2(context.Background(), collabHeaders, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", payload, false) | |
| 515 | + parsed := gjson.ParseBytes(got) | |
| 516 | + | |
| 517 | + item0 := parsed.Get("input.0") | |
| 518 | + if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" { | |
| 519 | + t.Fatalf("input.0 = %s, want message/user", item0.Raw) | |
| 520 | + } | |
| 521 | + wantText := "Tool output from codex_app__create_thread:\n<codex_delegation><message>handoff</message></codex_delegation>" | |
| 522 | + if text := item0.Get("content.0.text").String(); text != wantText { | |
| 523 | + t.Fatalf("input.0.content.0.text = %q, want %q", text, wantText) | |
| 524 | + } | |
| 525 | + }) | |
| 526 | + | |
| 527 | + t.Run("enabled translates orphan delegation to user message for chat target without empty tool_call_id", func(t *testing.T) { | |
| 528 | + cfg := &config.Config{ | |
| 529 | + Codex: config.CodexConfig{ | |
| 530 | + OrphanDelegationCompatibility: true, | |
| 531 | + }, | |
| 532 | + } | |
| 533 | + got := TranslateRequestWithCodexMultiAgentV2(context.Background(), collabHeaders, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, "test-model", payload, false) | |
| 534 | + parsed := gjson.ParseBytes(got) | |
| 535 | + | |
| 536 | + messages := parsed.Get("messages").Array() | |
| 537 | + if len(messages) < 2 { | |
| 538 | + t.Fatalf("expected at least 2 messages, got %d: %s", len(messages), string(got)) | |
| 539 | + } | |
| 540 | + msg0 := messages[0] | |
| 541 | + if msg0.Get("role").String() != "user" { | |
| 542 | + t.Fatalf("messages.0.role = %q, want user (should not be tool message with empty id)", msg0.Get("role").String()) | |
| 543 | + } | |
| 544 | + wantText := "Tool output from codex_app__create_thread:\n<codex_delegation><message>handoff</message></codex_delegation>" | |
| 545 | + var text string | |
| 546 | + if msg0.Get("content").IsArray() { | |
| 547 | + text = msg0.Get("content.0.text").String() | |
| 548 | + } else { | |
| 549 | + text = msg0.Get("content").String() | |
| 550 | + } | |
| 551 | + if text != wantText { | |
| 552 | + t.Fatalf("messages.0.content = %q, want %q", text, wantText) | |
| 553 | + } | |
| 554 | + }) | |
| 555 | + | |
| 556 | + t.Run("disabled leaves orphan delegation untranslated for responses target", func(t *testing.T) { | |
| 557 | + cfg := &config.Config{ | |
| 558 | + Codex: config.CodexConfig{ | |
| 559 | + OrphanDelegationCompatibility: false, | |
| 560 | + }, | |
| 561 | + } | |
| 562 | + got := TranslateRequestWithCodexMultiAgentV2(context.Background(), collabHeaders, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", payload, false) | |
| 563 | + parsed := gjson.ParseBytes(got) | |
| 564 | + | |
| 565 | + item0 := parsed.Get("input.0") | |
| 566 | + if item0.Get("type").String() != "function_call_output" { | |
| 567 | + t.Fatalf("input.0 = %s, want function_call_output when disabled", item0.Raw) | |
| 568 | + } | |
| 569 | + }) | |
| 570 | + | |
| 571 | + t.Run("interleaved valid tool pair and orphan delegation retains order and pairing", func(t *testing.T) { | |
| 572 | + interleaved := []byte(`{ | |
| 573 | + "model": "test-model", | |
| 574 | + "input": [ | |
| 575 | + { | |
| 576 | + "type": "function_call", | |
| 577 | + "call_id": "call_1", | |
| 578 | + "name": "lookup", | |
| 579 | + "arguments": "{\"q\":\"test\"}" | |
| 580 | + }, | |
| 581 | + { | |
| 582 | + "type": "function_call_output", | |
| 583 | + "call_id": "call_1", | |
| 584 | + "name": "lookup", | |
| 585 | + "output": "result1" | |
| 586 | + }, | |
| 587 | + { | |
| 588 | + "type": "function_call_output", | |
| 589 | + "name": "create_thread", | |
| 590 | + "namespace": "codex_app", | |
| 591 | + "output": "delegated" | |
| 592 | + } | |
| 593 | + ] | |
| 594 | + }`) | |
| 595 | + cfg := &config.Config{ | |
| 596 | + Codex: config.CodexConfig{ | |
| 597 | + OrphanDelegationCompatibility: true, | |
| 598 | + }, | |
| 599 | + } | |
| 600 | + got := TranslateRequestWithCodexMultiAgentV2(context.Background(), collabHeaders, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", interleaved, false) | |
| 601 | + parsed := gjson.ParseBytes(got) | |
| 602 | + | |
| 603 | + if parsed.Get("input.0.type").String() != "function_call" { | |
| 604 | + t.Fatalf("input.0 should remain function_call") | |
| 605 | + } | |
| 606 | + if parsed.Get("input.1.type").String() != "function_call_output" || parsed.Get("input.1.call_id").String() != "call_1" { | |
| 607 | + t.Fatalf("input.1 should remain paired function_call_output") | |
| 608 | + } | |
| 609 | + if parsed.Get("input.2.type").String() != "message" || parsed.Get("input.2.role").String() != "user" { | |
| 610 | + t.Fatalf("input.2 should be downgraded to message/user: %s", parsed.Get("input.2").Raw) | |
| 611 | + } | |
| 612 | + }) | |
| 613 | +} | |
| @@ -158,6 +158,8 @@ type CodexConfig struct { · 157 unmodified lines | ||
| 158 | 158 | StreamBootstrapBuffering bool `yaml:"stream-bootstrap-buffering" json:"stream-bootstrap-buffering"` |
| 159 | 159 | // OptimizeMultiAgentV2 optimizes official Codex multi-agent requests. |
| 160 | 160 | OptimizeMultiAgentV2 bool `yaml:"optimize-multi-agent-v2" json:"optimize-multi-agent-v2"` |
| 161 | + // OrphanDelegationCompatibility enables opt-in compatibility for orphan Codex delegation outputs. | |
| 162 | + OrphanDelegationCompatibility bool `yaml:"orphan-delegation-compatibility" json:"orphan-delegation-compatibility"` | |
| 161 | 163 | // LiveMediaRelay terminates and relays Codex Live WebRTC media in this process. |
| 162 | 164 | LiveMediaRelay CodexLiveMediaRelayConfig `yaml:"live-media-relay" json:"live-media-relay"` |
| 163 | 165 | } |
| @@ -45,6 +45,9 @@ type SDKConfig struct { · 44 unmodified lines | ||
| 45 | 45 | // CodexOptimizeMultiAgentV2 mirrors the provider-wide runtime setting for API handlers. |
| 46 | 46 | CodexOptimizeMultiAgentV2 bool `yaml:"-" json:"-"` |
| 47 | 47 | |
| 48 | + // CodexOrphanDelegationCompatibility mirrors the provider-wide runtime setting for API handlers. | |
| 49 | + CodexOrphanDelegationCompatibility bool `yaml:"-" json:"-"` | |
| 50 | + | |
| 48 | 51 | // ClaudeCode configures Claude Code compatibility behavior. |
| 49 | 52 | ClaudeCode ClaudeCodeConfig `yaml:"claude-code" json:"claude-code"` |
| 50 | 53 | |
| @@ -29,6 +29,13 @@ func RewriteCodexMultiAgentV2Input(ctx context.Context, headers http.Header, pay · 28 unmodified lines | ||
| 29 | 29 | return multiagentv2.RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg) |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | +// RewriteCodexOrphanDelegationInput converts orphan Codex delegation outputs into | |
| 33 | +// standard user messages when orphan delegation compatibility is enabled and the | |
| 34 | +// request carries the X-Openai-Subagent: collab_spawn header. | |
| 35 | +func RewriteCodexOrphanDelegationInput(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config) []byte { | |
| 36 | + return multiagentv2.RewriteCodexOrphanDelegationInputForConfig(ctx, headers, payload, cfg) | |
| 37 | +} | |
| 38 | + | |
| 32 | 39 | // TranslateRequestWithCodexMultiAgentV2 normalizes official Codex multi-agent |
| 33 | 40 | // input before translating it to a non-Codex target protocol. |
| 34 | 41 | 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 · 36 unmodified lines | ||
| 71 | 78 | if !isCompat { |
| 72 | 79 | return TranslateRequestWithCodexMultiAgentV2(ctx, headers, cfg, from, to, model, payload, stream) |
| 73 | 80 | } |
| 74 | − if from == sdktranslator.FormatOpenAIResponse && to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse { | |
| 75 | − payload = multiagentv2.RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg) | |
| 81 | + if from == sdktranslator.FormatOpenAIResponse { | |
| 82 | + payload = RewriteCodexOrphanDelegationInput(ctx, headers, payload, cfg) | |
| 83 | + if to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse { | |
| 84 | + payload = multiagentv2.RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg) | |
| 85 | + } | |
| 76 | 86 | } |
| 77 | 87 | |
| 78 | 88 | var translated []byte |
| @@ -121,6 +121,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { · 120 unmodified lines | ||
| 121 | 121 | if oldCfg.Codex.OptimizeMultiAgentV2 != newCfg.Codex.OptimizeMultiAgentV2 { |
| 122 | 122 | changes = append(changes, fmt.Sprintf("codex.optimize-multi-agent-v2: %t -> %t", oldCfg.Codex.OptimizeMultiAgentV2, newCfg.Codex.OptimizeMultiAgentV2)) |
| 123 | 123 | } |
| 124 | + if oldCfg.Codex.OrphanDelegationCompatibility != newCfg.Codex.OrphanDelegationCompatibility { | |
| 125 | + changes = append(changes, fmt.Sprintf("codex.orphan-delegation-compatibility: %t -> %t", oldCfg.Codex.OrphanDelegationCompatibility, newCfg.Codex.OrphanDelegationCompatibility)) | |
| 126 | + } | |
| 124 | 127 | if oldCfg.XAI.InjectXSearch != newCfg.XAI.InjectXSearch { |
| 125 | 128 | changes = append(changes, fmt.Sprintf("xai.inject-x-search: %t -> %t", oldCfg.XAI.InjectXSearch, newCfg.XAI.InjectXSearch)) |
| 126 | 129 | } |
| @@ -204,6 +204,14 @@ func TestBuildConfigChangeDetails_CodexAlphaSearch(t *testing.T) { · 203 unmodified lines | ||
| 204 | 204 | expectContains(t, changes, "codex[0].alpha-search: false -> true") |
| 205 | 205 | } |
| 206 | 206 | |
| 207 | +func TestBuildConfigChangeDetails_CodexOrphanDelegationCompatibility(t *testing.T) { | |
| 208 | + oldCfg := &config.Config{Codex: config.CodexConfig{OrphanDelegationCompatibility: false}} | |
| 209 | + newCfg := &config.Config{Codex: config.CodexConfig{OrphanDelegationCompatibility: true}} | |
| 210 | + | |
| 211 | + changes := BuildConfigChangeDetails(oldCfg, newCfg) | |
| 212 | + expectContains(t, changes, "codex.orphan-delegation-compatibility: false -> true") | |
| 213 | +} | |
| 214 | + | |
| 207 | 215 | func TestBuildConfigChangeDetails_XAIKeys(t *testing.T) { |
| 208 | 216 | oldRetry := 1 |
| 209 | 217 | newRetry := 0 |
| @@ -511,6 +511,20 @@ func (h *OpenAIResponsesAPIHandler) prepareCodexMultiAgentV2Tools(c *gin.Context · 510 unmodified lines | ||
| 511 | 511 | return updated |
| 512 | 512 | } |
| 513 | 513 | |
| 514 | +func (h *OpenAIResponsesAPIHandler) prepareCodexOrphanDelegation(c *gin.Context, payload []byte) []byte { | |
| 515 | + if h == nil || h.Cfg == nil || !h.Cfg.CodexOrphanDelegationCompatibility { | |
| 516 | + return payload | |
| 517 | + } | |
| 518 | + requestCtx := context.Background() | |
| 519 | + var requestHeaders http.Header | |
| 520 | + if c != nil && c.Request != nil { | |
| 521 | + requestCtx = c.Request.Context() | |
| 522 | + requestHeaders = c.Request.Header | |
| 523 | + } | |
| 524 | + requestCtx = context.WithValue(requestCtx, "gin", c) | |
| 525 | + return multiagentv2.RewriteCodexOrphanDelegationInput(requestCtx, requestHeaders, payload, true) | |
| 526 | +} | |
| 527 | + | |
| 514 | 528 | // Responses handles the /v1/responses endpoint. |
| 515 | 529 | // It determines whether the request is for a streaming or non-streaming response |
| 516 | 530 | // and calls the appropriate handler based on the model provider. |
| @@ -531,6 +545,7 @@ func (h *OpenAIResponsesAPIHandler) Responses(c *gin.Context) { · 14 unmodified lines | ||
| 531 | 545 | } |
| 532 | 546 | |
| 533 | 547 | rawJSON = h.prepareCodexMultiAgentV2Tools(c, rawJSON) |
| 548 | + rawJSON = h.prepareCodexOrphanDelegation(c, rawJSON) | |
| 534 | 549 | |
| 535 | 550 | // Check if the client requested a streaming response. |
| 536 | 551 | streamResult := gjson.GetBytes(rawJSON, "stream") |
| @@ -554,6 +569,8 @@ func (h *OpenAIResponsesAPIHandler) Compact(c *gin.Context) { · 17 unmodified lines | ||
| 554 | 569 | return |
| 555 | 570 | } |
| 556 | 571 | |
| 572 | + rawJSON = h.prepareCodexOrphanDelegation(c, rawJSON) | |
| 573 | + | |
| 557 | 574 | streamResult := gjson.GetBytes(rawJSON, "stream") |
| 558 | 575 | if streamResult.Type == gjson.True { |
| 559 | 576 | c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ |
| @@ -197,3 +197,93 @@ func TestPrepareCodexMultiAgentV2ToolsAtResponsesBoundarySkipsOtherClients(t *te · 196 unmodified lines | ||
| 197 | 197 | t.Fatal("other client unexpectedly received prepared marker") |
| 198 | 198 | } |
| 199 | 199 | } |
| 200 | + | |
| 201 | +func newResponsesOrphanDelegationTestHandler(t *testing.T, executor *responsesMultiAgentCaptureExecutor) (*OpenAIResponsesAPIHandler, string) { | |
| 202 | + t.Helper() | |
| 203 | + | |
| 204 | + modelID := "responses-orphan-test-model" | |
| 205 | + authID := "responses-orphan-test-auth" | |
| 206 | + manager := coreauth.NewManager(nil, nil, nil) | |
| 207 | + manager.RegisterExecutor(executor) | |
| 208 | + auth := &coreauth.Auth{ID: authID, Provider: "codex", Status: coreauth.StatusActive, ProxyURL: "direct"} | |
| 209 | + if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil { | |
| 210 | + t.Fatalf("Register auth: %v", errRegister) | |
| 211 | + } | |
| 212 | + registry.GetGlobalRegistry().RegisterClient(authID, auth.Provider, []*registry.ModelInfo{{ID: modelID}}) | |
| 213 | + t.Cleanup(func() { | |
| 214 | + registry.GetGlobalRegistry().UnregisterClient(authID) | |
| 215 | + }) | |
| 216 | + | |
| 217 | + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{CodexOrphanDelegationCompatibility: true}, manager) | |
| 218 | + return NewOpenAIResponsesAPIHandler(base), modelID | |
| 219 | +} | |
| 220 | + | |
| 221 | +func TestResponsesOrphanCodexDelegationCompatibility(t *testing.T) { | |
| 222 | + t.Parallel() | |
| 223 | + | |
| 224 | + executor := &responsesMultiAgentCaptureExecutor{} | |
| 225 | + handler, modelID := newResponsesOrphanDelegationTestHandler(t, executor) | |
| 226 | + | |
| 227 | + router := gin.New() | |
| 228 | + router.POST("/v1/responses", handler.Responses) | |
| 229 | + | |
| 230 | + payload := fmt.Sprintf(`{ | |
| 231 | + "model": %q, | |
| 232 | + "stream": false, | |
| 233 | + "input": [ | |
| 234 | + { | |
| 235 | + "type": "function_call_output", | |
| 236 | + "name": "create_thread", | |
| 237 | + "namespace": "codex_app", | |
| 238 | + "output": "<codex_delegation>msg</codex_delegation>" | |
| 239 | + }, | |
| 240 | + { | |
| 241 | + "type": "message", | |
| 242 | + "role": "user", | |
| 243 | + "content": [{"type": "input_text", "text": "continue"}] | |
| 244 | + } | |
| 245 | + ] | |
| 246 | + }`, modelID) | |
| 247 | + | |
| 248 | + request := httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewBufferString(payload)) | |
| 249 | + request.Header.Set("X-Openai-Subagent", "collab_spawn") | |
| 250 | + recorder := httptest.NewRecorder() | |
| 251 | + router.ServeHTTP(recorder, request) | |
| 252 | + if recorder.Code != http.StatusOK { | |
| 253 | + t.Fatalf("status = %d, want 200; body=%s", recorder.Code, recorder.Body.String()) | |
| 254 | + } | |
| 255 | + | |
| 256 | + payloads := executor.Payloads() | |
| 257 | + if len(payloads) != 1 { | |
| 258 | + t.Fatalf("captured payload count = %d, want 1", len(payloads)) | |
| 259 | + } | |
| 260 | + captured := payloads[0] | |
| 261 | + parsed := gjson.ParseBytes(captured) | |
| 262 | + if itemType := parsed.Get("input.0.type").String(); itemType != "message" { | |
| 263 | + t.Fatalf("input.0.type = %q, want message; captured=%s", itemType, captured) | |
| 264 | + } | |
| 265 | + if role := parsed.Get("input.0.role").String(); role != "user" { | |
| 266 | + t.Fatalf("input.0.role = %q, want user", role) | |
| 267 | + } | |
| 268 | + wantText := "Tool output from codex_app__create_thread:\n<codex_delegation>msg</codex_delegation>" | |
| 269 | + if text := parsed.Get("input.0.content.0.text").String(); text != wantText { | |
| 270 | + t.Fatalf("input.0.content.0.text = %q, want %q", text, wantText) | |
| 271 | + } | |
| 272 | + | |
| 273 | + // Without X-Openai-Subagent header, payload should remain function_call_output | |
| 274 | + requestNoHeader := httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewBufferString(payload)) | |
| 275 | + recorderNoHeader := httptest.NewRecorder() | |
| 276 | + router.ServeHTTP(recorderNoHeader, requestNoHeader) | |
| 277 | + if recorderNoHeader.Code != http.StatusOK { | |
| 278 | + t.Fatalf("status = %d, want 200; body=%s", recorderNoHeader.Code, recorderNoHeader.Body.String()) | |
| 279 | + } | |
| 280 | + payloads = executor.Payloads() | |
| 281 | + if len(payloads) != 2 { | |
| 282 | + t.Fatalf("captured payload count = %d, want 2", len(payloads)) | |
| 283 | + } | |
| 284 | + capturedNoHeader := payloads[1] | |
| 285 | + parsedNoHeader := gjson.ParseBytes(capturedNoHeader) | |
| 286 | + if itemType := parsedNoHeader.Get("input.0.type").String(); itemType != "function_call_output" { | |
| 287 | + t.Fatalf("input.0.type = %q, want function_call_output without header; captured=%s", itemType, capturedNoHeader) | |
| 288 | + } | |
| 289 | +} | |
| @@ -522,6 +522,7 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) { · 521 unmodified lines | ||
| 522 | 522 | } |
| 523 | 523 | |
| 524 | 524 | requestJSON = h.prepareCodexMultiAgentV2Tools(c, requestJSON) |
| 525 | + requestJSON = h.prepareCodexOrphanDelegation(c, requestJSON) | |
| 525 | 526 | |
| 526 | 527 | if !useUpstreamWebsocketPassthrough && shouldHandleResponsesWebsocketPrewarmLocally(payload, lastRequest, false) { |
| 527 | 528 | if updated, errDelete := sjson.DeleteBytes(requestJSON, "generate"); errDelete == nil { |