History

feat(codex): support orphan delegation compatibility via orphan-delegation-compatibility

- 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.

Luis Pater committed 2 months ago

Commit 291cfb81 parent 93f526614 files · +901 −4Browse files

Unified · Split

14 files
  1. config.example.yaml +6 −0
  2. internal/api/server_options.go +1 −0
  3. internal/api/server_sdk_config_test.go +9 −0
  4. internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2.go +14 −2
  5. internal/client/codex/optimize-multi-agent-v2/orphan_delegation.go +122 −0
  6. internal/client/codex/optimize-multi-agent-v2/orphan_delegation_test.go +613 −0
  7. internal/config/config_types.go +2 −0
  8. internal/config/sdk_config.go +3 −0
  9. internal/runtime/executor/helps/codex_multi_agent_v2.go +12 −2
  10. internal/watcher/diff/config_diff.go +3 −0
  11. internal/watcher/diff/config_diff_test.go +8 −0
  12. sdk/api/handlers/openai/openai_responses_handlers.go +17 −0
  13. sdk/api/handlers/openai/openai_responses_multi_agent_test.go +90 −0
  14. sdk/api/handlers/openai/openai_responses_websocket.go +1 −0
config.example.yaml+6−0
@@ -262,6 +262,12 @@ codex: · 261 unmodified lines
262262 # normalizes encrypted agent_message content for Codex, and converts agent_message input
263263 # into standard user messages for non-Codex upstream protocols.
264264 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
265271 # Terminate and relay Codex Live WebRTC audio and DataChannel traffic in this process.
266272 # This requires inbound UDP reachability. Keep disabled to preserve direct media behavior.
267273 live-media-relay:
internal/api/server_options.go+1−0
@@ -46,6 +46,7 @@ func effectiveSDKConfig(cfg *config.Config) *config.SDKConfig { · 45 unmodified lines
4646 }
4747 sdkCfg := cfg.SDKConfig
4848 sdkCfg.CodexOptimizeMultiAgentV2 = cfg.Codex.OptimizeMultiAgentV2
49+ sdkCfg.CodexOrphanDelegationCompatibility = cfg.Codex.OrphanDelegationCompatibility
4950 if cfg.CommercialMode {
5051 sdkCfg.RequestLog = false
5152 }
internal/api/server_sdk_config_test.go+9−0
@@ -14,3 +14,12 @@ func TestEffectiveSDKConfigCopiesCodexOptimizeMultiAgentV2(t *testing.T) { · 13 unmodified lines
1414 t.Fatalf("CodexOptimizeMultiAgentV2 = false, want true")
1515 }
1616 }
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+}
internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2.go+14−2
@@ -71,11 +71,23 @@ func RewriteCodexMultiAgentV2Input(ctx context.Context, headers http.Header, pay · 70 unmodified lines
7171 return rewriteCodexAgentMessageInput(payload)
7272 }
7373
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+
7483 // TranslateRequestWithCodexMultiAgentV2 normalizes official Codex multi-agent
7584 // input before translating it to a non-Codex target protocol.
7685 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+ }
7991 }
8092 return sdktranslator.TranslateRequest(from, to, model, payload, stream)
8193 }
internal/client/codex/optimize-multi-agent-v2/orphan_delegation.go+122−0
@@ -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+}
internal/client/codex/optimize-multi-agent-v2/orphan_delegation_test.go+613−0
@@ -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+}
internal/config/config_types.go+2−0
@@ -158,6 +158,8 @@ type CodexConfig struct { · 157 unmodified lines
158158 StreamBootstrapBuffering bool `yaml:"stream-bootstrap-buffering" json:"stream-bootstrap-buffering"`
159159 // OptimizeMultiAgentV2 optimizes official Codex multi-agent requests.
160160 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"`
161163 // LiveMediaRelay terminates and relays Codex Live WebRTC media in this process.
162164 LiveMediaRelay CodexLiveMediaRelayConfig `yaml:"live-media-relay" json:"live-media-relay"`
163165 }
internal/config/sdk_config.go+3−0
@@ -45,6 +45,9 @@ type SDKConfig struct { · 44 unmodified lines
4545 // CodexOptimizeMultiAgentV2 mirrors the provider-wide runtime setting for API handlers.
4646 CodexOptimizeMultiAgentV2 bool `yaml:"-" json:"-"`
4747
48+ // CodexOrphanDelegationCompatibility mirrors the provider-wide runtime setting for API handlers.
49+ CodexOrphanDelegationCompatibility bool `yaml:"-" json:"-"`
50+
4851 // ClaudeCode configures Claude Code compatibility behavior.
4952 ClaudeCode ClaudeCodeConfig `yaml:"claude-code" json:"claude-code"`
5053
internal/runtime/executor/helps/codex_multi_agent_v2.go+12−2
@@ -29,6 +29,13 @@ func RewriteCodexMultiAgentV2Input(ctx context.Context, headers http.Header, pay · 28 unmodified lines
2929 return multiagentv2.RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg)
3030 }
3131
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+
3239 // TranslateRequestWithCodexMultiAgentV2 normalizes official Codex multi-agent
3340 // input before translating it to a non-Codex target protocol.
3441 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
7178 if !isCompat {
7279 return TranslateRequestWithCodexMultiAgentV2(ctx, headers, cfg, from, to, model, payload, stream)
7380 }
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+ }
7686 }
7787
7888 var translated []byte
internal/watcher/diff/config_diff.go+3−0
@@ -121,6 +121,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { · 120 unmodified lines
121121 if oldCfg.Codex.OptimizeMultiAgentV2 != newCfg.Codex.OptimizeMultiAgentV2 {
122122 changes = append(changes, fmt.Sprintf("codex.optimize-multi-agent-v2: %t -> %t", oldCfg.Codex.OptimizeMultiAgentV2, newCfg.Codex.OptimizeMultiAgentV2))
123123 }
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+ }
124127 if oldCfg.XAI.InjectXSearch != newCfg.XAI.InjectXSearch {
125128 changes = append(changes, fmt.Sprintf("xai.inject-x-search: %t -> %t", oldCfg.XAI.InjectXSearch, newCfg.XAI.InjectXSearch))
126129 }
internal/watcher/diff/config_diff_test.go+8−0
@@ -204,6 +204,14 @@ func TestBuildConfigChangeDetails_CodexAlphaSearch(t *testing.T) { · 203 unmodified lines
204204 expectContains(t, changes, "codex[0].alpha-search: false -> true")
205205 }
206206
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+
207215 func TestBuildConfigChangeDetails_XAIKeys(t *testing.T) {
208216 oldRetry := 1
209217 newRetry := 0
sdk/api/handlers/openai/openai_responses_handlers.go+17−0
@@ -511,6 +511,20 @@ func (h *OpenAIResponsesAPIHandler) prepareCodexMultiAgentV2Tools(c *gin.Context · 510 unmodified lines
511511 return updated
512512 }
513513
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+
514528 // Responses handles the /v1/responses endpoint.
515529 // It determines whether the request is for a streaming or non-streaming response
516530 // and calls the appropriate handler based on the model provider.
@@ -531,6 +545,7 @@ func (h *OpenAIResponsesAPIHandler) Responses(c *gin.Context) { · 14 unmodified lines
531545 }
532546
533547 rawJSON = h.prepareCodexMultiAgentV2Tools(c, rawJSON)
548+ rawJSON = h.prepareCodexOrphanDelegation(c, rawJSON)
534549
535550 // Check if the client requested a streaming response.
536551 streamResult := gjson.GetBytes(rawJSON, "stream")
@@ -554,6 +569,8 @@ func (h *OpenAIResponsesAPIHandler) Compact(c *gin.Context) { · 17 unmodified lines
554569 return
555570 }
556571
572+ rawJSON = h.prepareCodexOrphanDelegation(c, rawJSON)
573+
557574 streamResult := gjson.GetBytes(rawJSON, "stream")
558575 if streamResult.Type == gjson.True {
559576 c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
sdk/api/handlers/openai/openai_responses_multi_agent_test.go+90−0
@@ -197,3 +197,93 @@ func TestPrepareCodexMultiAgentV2ToolsAtResponsesBoundarySkipsOtherClients(t *te · 196 unmodified lines
197197 t.Fatal("other client unexpectedly received prepared marker")
198198 }
199199 }
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+}
sdk/api/handlers/openai/openai_responses_websocket.go+1−0
@@ -522,6 +522,7 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) { · 521 unmodified lines
522522 }
523523
524524 requestJSON = h.prepareCodexMultiAgentV2Tools(c, requestJSON)
525+ requestJSON = h.prepareCodexOrphanDelegation(c, requestJSON)
525526
526527 if !useUpstreamWebsocketPassthrough && shouldHandleResponsesWebsocketPrewarmLocally(payload, lastRequest, false) {
527528 if updated, errDelete := sjson.DeleteBytes(requestJSON, "generate"); errDelete == nil {