- 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 | # normalizes encrypted agent_message content for Codex, and converts agent_message input | 262 | # normalizes encrypted agent_message content for Codex, and converts agent_message input |
| 263 | # into standard user messages for non-Codex upstream protocols. | 263 | # into standard user messages for non-Codex upstream protocols. |
| 264 | optimize-multi-agent-v2: false | 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 | # Terminate and relay Codex Live WebRTC audio and DataChannel traffic in this process. | 271 | # Terminate and relay Codex Live WebRTC audio and DataChannel traffic in this process. |
| 266 | # This requires inbound UDP reachability. Keep disabled to preserve direct media behavior. | 272 | # This requires inbound UDP reachability. Keep disabled to preserve direct media behavior. |
| 267 | live-media-relay: | 273 | live-media-relay: |
| @@ -46,6 +46,7 @@ func effectiveSDKConfig(cfg *config.Config) *config.SDKConfig { · 45 unmodified lines | |||
| 46 | } | 46 | } |
| 47 | sdkCfg := cfg.SDKConfig | 47 | sdkCfg := cfg.SDKConfig |
| 48 | sdkCfg.CodexOptimizeMultiAgentV2 = cfg.Codex.OptimizeMultiAgentV2 | 48 | sdkCfg.CodexOptimizeMultiAgentV2 = cfg.Codex.OptimizeMultiAgentV2 |
| 49 | sdkCfg.CodexOrphanDelegationCompatibility = cfg.Codex.OrphanDelegationCompatibility | ||
| 49 | if cfg.CommercialMode { | 50 | if cfg.CommercialMode { |
| 50 | sdkCfg.RequestLog = false | 51 | sdkCfg.RequestLog = false |
| 51 | } | 52 | } |
| @@ -14,3 +14,12 @@ func TestEffectiveSDKConfigCopiesCodexOptimizeMultiAgentV2(t *testing.T) { · 13 unmodified lines | |||
| 14 | t.Fatalf("CodexOptimizeMultiAgentV2 = false, want true") | 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 | return rewriteCodexAgentMessageInput(payload) | 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 | // TranslateRequestWithCodexMultiAgentV2 normalizes official Codex multi-agent | 83 | // TranslateRequestWithCodexMultiAgentV2 normalizes official Codex multi-agent |
| 75 | // input before translating it to a non-Codex target protocol. | 84 | // input before translating it to a non-Codex target protocol. |
| 76 | func TranslateRequestWithCodexMultiAgentV2(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, payload []byte, stream bool) []byte { | 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 { | 86 | if from == sdktranslator.FormatOpenAIResponse { |
| 78 | payload = RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg) | 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 | return sdktranslator.TranslateRequest(from, to, model, payload, stream) | 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 | StreamBootstrapBuffering bool `yaml:"stream-bootstrap-buffering" json:"stream-bootstrap-buffering"` | 158 | StreamBootstrapBuffering bool `yaml:"stream-bootstrap-buffering" json:"stream-bootstrap-buffering"` |
| 159 | // OptimizeMultiAgentV2 optimizes official Codex multi-agent requests. | 159 | // OptimizeMultiAgentV2 optimizes official Codex multi-agent requests. |
| 160 | OptimizeMultiAgentV2 bool `yaml:"optimize-multi-agent-v2" json:"optimize-multi-agent-v2"` | 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 | // LiveMediaRelay terminates and relays Codex Live WebRTC media in this process. | 163 | // LiveMediaRelay terminates and relays Codex Live WebRTC media in this process. |
| 162 | LiveMediaRelay CodexLiveMediaRelayConfig `yaml:"live-media-relay" json:"live-media-relay"` | 164 | LiveMediaRelay CodexLiveMediaRelayConfig `yaml:"live-media-relay" json:"live-media-relay"` |
| 163 | } | 165 | } |
| @@ -45,6 +45,9 @@ type SDKConfig struct { · 44 unmodified lines | |||
| 45 | // CodexOptimizeMultiAgentV2 mirrors the provider-wide runtime setting for API handlers. | 45 | // CodexOptimizeMultiAgentV2 mirrors the provider-wide runtime setting for API handlers. |
| 46 | CodexOptimizeMultiAgentV2 bool `yaml:"-" json:"-"` | 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 | // ClaudeCode configures Claude Code compatibility behavior. | 51 | // ClaudeCode configures Claude Code compatibility behavior. |
| 49 | ClaudeCode ClaudeCodeConfig `yaml:"claude-code" json:"claude-code"` | 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 | return multiagentv2.RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg) | 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 | // TranslateRequestWithCodexMultiAgentV2 normalizes official Codex multi-agent | 39 | // TranslateRequestWithCodexMultiAgentV2 normalizes official Codex multi-agent |
| 33 | // input before translating it to a non-Codex target protocol. | 40 | // input before translating it to a non-Codex target protocol. |
| 34 | func TranslateRequestWithCodexMultiAgentV2(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, payload []byte, stream bool) []byte { | 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 | if !isCompat { | 78 | if !isCompat { |
| 72 | return TranslateRequestWithCodexMultiAgentV2(ctx, headers, cfg, from, to, model, payload, stream) | 79 | return TranslateRequestWithCodexMultiAgentV2(ctx, headers, cfg, from, to, model, payload, stream) |
| 73 | } | 80 | } |
| 74 | if from == sdktranslator.FormatOpenAIResponse && to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse { | 81 | if from == sdktranslator.FormatOpenAIResponse { |
| 75 | payload = multiagentv2.RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg) | 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 | var translated []byte | 88 | var translated []byte |
| @@ -121,6 +121,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { · 120 unmodified lines | |||
| 121 | if oldCfg.Codex.OptimizeMultiAgentV2 != newCfg.Codex.OptimizeMultiAgentV2 { | 121 | if oldCfg.Codex.OptimizeMultiAgentV2 != newCfg.Codex.OptimizeMultiAgentV2 { |
| 122 | changes = append(changes, fmt.Sprintf("codex.optimize-multi-agent-v2: %t -> %t", oldCfg.Codex.OptimizeMultiAgentV2, newCfg.Codex.OptimizeMultiAgentV2)) | 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 | if oldCfg.XAI.InjectXSearch != newCfg.XAI.InjectXSearch { | 127 | if oldCfg.XAI.InjectXSearch != newCfg.XAI.InjectXSearch { |
| 125 | changes = append(changes, fmt.Sprintf("xai.inject-x-search: %t -> %t", oldCfg.XAI.InjectXSearch, newCfg.XAI.InjectXSearch)) | 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 | expectContains(t, changes, "codex[0].alpha-search: false -> true") | 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 | func TestBuildConfigChangeDetails_XAIKeys(t *testing.T) { | 215 | func TestBuildConfigChangeDetails_XAIKeys(t *testing.T) { |
| 208 | oldRetry := 1 | 216 | oldRetry := 1 |
| 209 | newRetry := 0 | 217 | newRetry := 0 |
| @@ -511,6 +511,20 @@ func (h *OpenAIResponsesAPIHandler) prepareCodexMultiAgentV2Tools(c *gin.Context · 510 unmodified lines | |||
| 511 | return updated | 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 | // Responses handles the /v1/responses endpoint. | 528 | // Responses handles the /v1/responses endpoint. |
| 515 | // It determines whether the request is for a streaming or non-streaming response | 529 | // It determines whether the request is for a streaming or non-streaming response |
| 516 | // and calls the appropriate handler based on the model provider. | 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 | rawJSON = h.prepareCodexMultiAgentV2Tools(c, rawJSON) | 547 | rawJSON = h.prepareCodexMultiAgentV2Tools(c, rawJSON) |
| 548 | rawJSON = h.prepareCodexOrphanDelegation(c, rawJSON) | ||
| 534 | 549 | ||
| 535 | // Check if the client requested a streaming response. | 550 | // Check if the client requested a streaming response. |
| 536 | streamResult := gjson.GetBytes(rawJSON, "stream") | 551 | streamResult := gjson.GetBytes(rawJSON, "stream") |
| @@ -554,6 +569,8 @@ func (h *OpenAIResponsesAPIHandler) Compact(c *gin.Context) { · 17 unmodified lines | |||
| 554 | return | 569 | return |
| 555 | } | 570 | } |
| 556 | 571 | ||
| 572 | rawJSON = h.prepareCodexOrphanDelegation(c, rawJSON) | ||
| 573 | |||
| 557 | streamResult := gjson.GetBytes(rawJSON, "stream") | 574 | streamResult := gjson.GetBytes(rawJSON, "stream") |
| 558 | if streamResult.Type == gjson.True { | 575 | if streamResult.Type == gjson.True { |
| 559 | c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ | 576 | c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ |
| @@ -197,3 +197,93 @@ func TestPrepareCodexMultiAgentV2ToolsAtResponsesBoundarySkipsOtherClients(t *te · 196 unmodified lines | |||
| 197 | t.Fatal("other client unexpectedly received prepared marker") | 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 | requestJSON = h.prepareCodexMultiAgentV2Tools(c, requestJSON) | 524 | requestJSON = h.prepareCodexMultiAgentV2Tools(c, requestJSON) |
| 525 | requestJSON = h.prepareCodexOrphanDelegation(c, requestJSON) | ||
| 525 | 526 | ||
| 526 | if !useUpstreamWebsocketPassthrough && shouldHandleResponsesWebsocketPrewarmLocally(payload, lastRequest, false) { | 527 | if !useUpstreamWebsocketPassthrough && shouldHandleResponsesWebsocketPrewarmLocally(payload, lastRequest, false) { |
| 527 | if updated, errDelete := sjson.DeleteBytes(requestJSON, "generate"); errDelete == nil { | 528 | if updated, errDelete := sjson.DeleteBytes(requestJSON, "generate"); errDelete == nil { |