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.

Closes: #5401
This commit is contained in:
Luis Pater
2026-09-03 20:21:12 +08:00
parent 93f5266b7b
commit 291cfb87ef
14 changed files with 901 additions and 4 deletions

View File

@@ -262,6 +262,12 @@ codex:
# normalizes encrypted agent_message content for Codex, and converts agent_message input
# into standard user messages for non-Codex upstream protocols.
optimize-multi-agent-v2: false
# When true, enable opt-in compatibility for orphan Codex delegation outputs.
# Converts orphan function_call_output items from codex_app/create_thread and
# codex_app/send_message_to_thread (which lack a valid call_id or matching function_call)
# into standard user messages across Responses and non-Codex protocols when the request
# header contains X-Openai-Subagent: collab_spawn.
orphan-delegation-compatibility: false
# Terminate and relay Codex Live WebRTC audio and DataChannel traffic in this process.
# This requires inbound UDP reachability. Keep disabled to preserve direct media behavior.
live-media-relay:

View File

@@ -46,6 +46,7 @@ func effectiveSDKConfig(cfg *config.Config) *config.SDKConfig {
}
sdkCfg := cfg.SDKConfig
sdkCfg.CodexOptimizeMultiAgentV2 = cfg.Codex.OptimizeMultiAgentV2
sdkCfg.CodexOrphanDelegationCompatibility = cfg.Codex.OrphanDelegationCompatibility
if cfg.CommercialMode {
sdkCfg.RequestLog = false
}

View File

@@ -14,3 +14,12 @@ func TestEffectiveSDKConfigCopiesCodexOptimizeMultiAgentV2(t *testing.T) {
t.Fatalf("CodexOptimizeMultiAgentV2 = false, want true")
}
}
func TestEffectiveSDKConfigCopiesCodexOrphanDelegationCompatibility(t *testing.T) {
cfg := &config.Config{Codex: config.CodexConfig{OrphanDelegationCompatibility: true}}
sdkCfg := effectiveSDKConfig(cfg)
if sdkCfg == nil || !sdkCfg.CodexOrphanDelegationCompatibility {
t.Fatalf("CodexOrphanDelegationCompatibility = false, want true")
}
}

View File

@@ -71,11 +71,23 @@ func RewriteCodexMultiAgentV2Input(ctx context.Context, headers http.Header, pay
return rewriteCodexAgentMessageInput(payload)
}
// RewriteCodexOrphanDelegationInputForConfig applies RewriteCodexOrphanDelegationInput
// based on cfg.Codex.OrphanDelegationCompatibility and the X-Openai-Subagent header.
func RewriteCodexOrphanDelegationInputForConfig(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config) []byte {
if cfg == nil || !cfg.Codex.OrphanDelegationCompatibility {
return payload
}
return RewriteCodexOrphanDelegationInput(ctx, headers, payload, true)
}
// TranslateRequestWithCodexMultiAgentV2 normalizes official Codex multi-agent
// input before translating it to a non-Codex target protocol.
func TranslateRequestWithCodexMultiAgentV2(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, payload []byte, stream bool) []byte {
if from == sdktranslator.FormatOpenAIResponse && to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse {
payload = RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg)
if from == sdktranslator.FormatOpenAIResponse {
payload = RewriteCodexOrphanDelegationInputForConfig(ctx, headers, payload, cfg)
if to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse {
payload = RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg)
}
}
return sdktranslator.TranslateRequest(from, to, model, payload, stream)
}

View File

@@ -0,0 +1,122 @@
package multiagentv2
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const (
codexAppNamespace = "codex_app"
codexCreateThreadName = "create_thread"
codexSendMessageToThreadName = "send_message_to_thread"
codexAppCreateThreadTool = "codex_app__create_thread"
codexAppSendMessageTool = "codex_app__send_message_to_thread"
codexOpenAISubagentHeader = "X-Openai-Subagent"
codexCollabSpawnSubagent = "collab_spawn"
)
// RewriteCodexOrphanDelegationInput converts orphan Codex delegation outputs into
// standard user messages when orphan delegation compatibility is enabled and the
// request carries the X-Openai-Subagent: collab_spawn header.
func RewriteCodexOrphanDelegationInput(ctx context.Context, headers http.Header, payload []byte, enabled bool) []byte {
if !enabled || len(payload) == 0 || !isCodexCollabSpawnSubagent(ctx, headers) {
return payload
}
input := gjson.GetBytes(payload, "input")
if !input.IsArray() {
return payload
}
inputItems := input.Array()
availableCalls := make(map[string]int)
for _, item := range inputItems {
if item.Get("type").String() == "function_call" {
callID := item.Get("call_id").String()
if strings.TrimSpace(callID) != "" {
availableCalls[callID]++
}
}
}
updated := payload
for itemIndex, item := range inputItems {
if item.Get("type").String() != "function_call_output" {
continue
}
callID := item.Get("call_id").String()
if strings.TrimSpace(callID) != "" && availableCalls[callID] > 0 {
// Paired with a function call in the same request; consume and preserve.
availableCalls[callID]--
continue
}
toolLabel, isTarget := matchCodexDelegationTool(item)
if !isTarget {
continue
}
// Orphan delegation output: downgrade to user message preserving exact output.
itemPath := fmt.Sprintf("input.%d", itemIndex)
userMessage := buildCodexOrphanUserMessage(toolLabel, item.Get("output"))
var errSet error
updated, errSet = sjson.SetRawBytes(updated, itemPath, userMessage)
if errSet != nil {
return payload
}
}
return updated
}
func codexSubagentHeader(ctx context.Context, headers http.Header) string {
if ctx != nil {
if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
return headerValueCaseInsensitive(ginCtx.Request.Header, codexOpenAISubagentHeader)
}
}
return headerValueCaseInsensitive(headers, codexOpenAISubagentHeader)
}
func isCodexCollabSpawnSubagent(ctx context.Context, headers http.Header) bool {
return strings.EqualFold(codexSubagentHeader(ctx, headers), codexCollabSpawnSubagent)
}
func matchCodexDelegationTool(item gjson.Result) (string, bool) {
if item.Get("namespace").String() != codexAppNamespace {
return "", false
}
switch item.Get("name").String() {
case codexCreateThreadName:
return codexAppCreateThreadTool, true
case codexSendMessageToThreadName:
return codexAppSendMessageTool, true
default:
return "", false
}
}
func buildCodexOrphanUserMessage(toolLabel string, output gjson.Result) []byte {
outputText := ""
if output.Exists() {
if output.Type == gjson.String {
outputText = output.String()
} else {
outputText = output.Raw
}
}
fullText := fmt.Sprintf("Tool output from %s:\n%s", toolLabel, outputText)
msg := []byte(`{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}`)
msg, _ = sjson.SetBytes(msg, "content.0.text", fullText)
return msg
}

View File

@@ -0,0 +1,613 @@
package multiagentv2
import (
"context"
"fmt"
"net/http"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"github.com/tidwall/gjson"
)
func testRewriteCodexOrphan(payload []byte, enabled bool) []byte {
headers := http.Header{"X-Openai-Subagent": []string{"collab_spawn"}}
return RewriteCodexOrphanDelegationInput(context.Background(), headers, payload, enabled)
}
func TestRewriteCodexOrphanDelegationInput(t *testing.T) {
t.Run("disabled leaves payload unchanged", func(t *testing.T) {
payload := []byte(`{
"model": "deepseek-v4-pro",
"input": [
{
"type": "function_call_output",
"name": "create_thread",
"namespace": "codex_app",
"output": "<codex_delegation><message>handoff</message></codex_delegation>"
}
]
}`)
got := testRewriteCodexOrphan(payload, false)
if string(got) != string(payload) {
t.Fatalf("expected payload unchanged when disabled, got: %s", string(got))
}
})
t.Run("missing subagent header leaves payload unchanged", func(t *testing.T) {
payload := []byte(`{
"model": "deepseek-v4-pro",
"input": [
{
"type": "function_call_output",
"name": "create_thread",
"namespace": "codex_app",
"output": "<codex_delegation><message>handoff</message></codex_delegation>"
}
]
}`)
got := RewriteCodexOrphanDelegationInput(context.Background(), http.Header{}, payload, true)
if string(got) != string(payload) {
t.Fatalf("expected payload unchanged without header, got: %s", string(got))
}
})
t.Run("different subagent header leaves payload unchanged", func(t *testing.T) {
payload := []byte(`{
"model": "deepseek-v4-pro",
"input": [
{
"type": "function_call_output",
"name": "create_thread",
"namespace": "codex_app",
"output": "<codex_delegation><message>handoff</message></codex_delegation>"
}
]
}`)
headers := http.Header{"X-Openai-Subagent": []string{"other_subagent"}}
got := RewriteCodexOrphanDelegationInput(context.Background(), headers, payload, true)
if string(got) != string(payload) {
t.Fatalf("expected payload unchanged with wrong header, got: %s", string(got))
}
})
t.Run("rewrites orphan create_thread without call_id", func(t *testing.T) {
payload := []byte(`{
"model": "deepseek-v4-pro",
"input": [
{
"type": "function_call_output",
"name": "create_thread",
"namespace": "codex_app",
"output": "<codex_delegation><message>handoff</message></codex_delegation>"
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "please continue"}]
}
]
}`)
got := testRewriteCodexOrphan(payload, true)
parsed := gjson.ParseBytes(got)
item0 := parsed.Get("input.0")
if item0.Get("type").String() != "message" {
t.Fatalf("input.0.type = %q, want %q", item0.Get("type").String(), "message")
}
if item0.Get("role").String() != "user" {
t.Fatalf("input.0.role = %q, want %q", item0.Get("role").String(), "user")
}
wantText := "Tool output from codex_app__create_thread:\n<codex_delegation><message>handoff</message></codex_delegation>"
if text := item0.Get("content.0.text").String(); text != wantText {
t.Fatalf("input.0.content.0.text = %q, want %q", text, wantText)
}
item1 := parsed.Get("input.1")
if item1.Get("type").String() != "message" || item1.Get("content.0.text").String() != "please continue" {
t.Fatalf("input.1 corrupted: %s", item1.Raw)
}
})
t.Run("case-insensitive header key and value works", func(t *testing.T) {
payload := []byte(`{
"model": "deepseek-v4-pro",
"input": [
{
"type": "function_call_output",
"name": "create_thread",
"namespace": "codex_app",
"output": "msg"
}
]
}`)
headers := http.Header{"x-openai-subagent": []string{"COLLAB_SPAWN"}}
got := RewriteCodexOrphanDelegationInput(context.Background(), headers, payload, true)
parsed := gjson.ParseBytes(got)
item0 := parsed.Get("input.0")
if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" {
t.Fatalf("case-insensitive header should rewrite, got: %s", item0.Raw)
}
})
t.Run("rewrites orphan send_message_to_thread with stale call_id", func(t *testing.T) {
payload := []byte(`{
"model": "deepseek-v4-pro",
"input": [
{
"type": "function_call_output",
"call_id": "call_stale_123",
"name": "send_message_to_thread",
"namespace": "codex_app",
"output": "<codex_delegation>msg</codex_delegation>"
}
]
}`)
got := testRewriteCodexOrphan(payload, true)
parsed := gjson.ParseBytes(got)
item0 := parsed.Get("input.0")
if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" {
t.Fatalf("input.0 not rewritten: %s", item0.Raw)
}
wantText := "Tool output from codex_app__send_message_to_thread:\n<codex_delegation>msg</codex_delegation>"
if text := item0.Get("content.0.text").String(); text != wantText {
t.Fatalf("input.0.content.0.text = %q, want %q", text, wantText)
}
})
t.Run("preserves paired create_thread tool call", func(t *testing.T) {
payload := []byte(`{
"model": "deepseek-v4-pro",
"input": [
{
"type": "function_call",
"call_id": "call_active_123",
"name": "create_thread",
"namespace": "codex_app",
"arguments": "{}"
},
{
"type": "function_call_output",
"call_id": "call_active_123",
"name": "create_thread",
"namespace": "codex_app",
"output": "<codex_delegation>valid</codex_delegation>"
}
]
}`)
got := testRewriteCodexOrphan(payload, true)
parsed := gjson.ParseBytes(got)
item1 := parsed.Get("input.1")
if item1.Get("type").String() != "function_call_output" {
t.Fatalf("paired call output was modified: %s", item1.Raw)
}
if item1.Get("call_id").String() != "call_active_123" {
t.Fatalf("call_id changed: %s", item1.Raw)
}
})
t.Run("preserves non-whitelisted tools", func(t *testing.T) {
payload := []byte(`{
"model": "deepseek-v4-pro",
"input": [
{
"type": "function_call_output",
"name": "automation_update",
"namespace": "codex_app",
"output": "ignored"
},
{
"type": "function_call_output",
"name": "create_thread",
"namespace": "other_namespace",
"output": "ignored"
}
]
}`)
got := testRewriteCodexOrphan(payload, true)
parsed := gjson.ParseBytes(got)
if parsed.Get("input.0.type").String() != "function_call_output" {
t.Fatalf("automation_update should not be rewritten: %s", parsed.Get("input.0").Raw)
}
if parsed.Get("input.1.type").String() != "function_call_output" {
t.Fatalf("other_namespace should not be rewritten: %s", parsed.Get("input.1").Raw)
}
})
t.Run("handles empty output", func(t *testing.T) {
payload := []byte(`{
"model": "deepseek-v4-pro",
"input": [
{
"type": "function_call_output",
"name": "create_thread",
"namespace": "codex_app",
"output": ""
}
]
}`)
got := testRewriteCodexOrphan(payload, true)
parsed := gjson.ParseBytes(got)
item0 := parsed.Get("input.0")
if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" {
t.Fatalf("input.0 not rewritten: %s", item0.Raw)
}
wantText := "Tool output from codex_app__create_thread:\n"
if text := item0.Get("content.0.text").String(); text != wantText {
t.Fatalf("input.0.content.0.text = %q, want %q", text, wantText)
}
})
t.Run("call_id whitespace difference is not paired", func(t *testing.T) {
payload := []byte(`{
"model": "deepseek-v4-pro",
"input": [
{
"type": "function_call",
"call_id": "call_1",
"name": "create_thread",
"namespace": "codex_app",
"arguments": "{}"
},
{
"type": "function_call_output",
"call_id": " call_1 ",
"name": "create_thread",
"namespace": "codex_app",
"output": "mismatch"
}
]
}`)
got := testRewriteCodexOrphan(payload, true)
parsed := gjson.ParseBytes(got)
if parsed.Get("input.1.type").String() != "message" {
t.Fatalf("call_id with whitespace mismatch should be treated as orphan: %s", parsed.Get("input.1").Raw)
}
})
t.Run("preserves structured image output in delegation as exact text", func(t *testing.T) {
rawOutput := `[{"type":"input_text","text":"diagram"},{"type":"input_image","image_url":"https://example.com/img.png"}]`
payload := []byte(`{
"model": "deepseek-v4-pro",
"input": [
{
"type": "function_call_output",
"name": "create_thread",
"namespace": "codex_app",
"output": ` + rawOutput + `
}
]
}`)
got := testRewriteCodexOrphan(payload, true)
parsed := gjson.ParseBytes(got)
item0 := parsed.Get("input.0")
if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" {
t.Fatalf("input.0 not rewritten: %s", item0.Raw)
}
wantText := "Tool output from codex_app__create_thread:\n" + rawOutput
if text := item0.Get("content.0.text").String(); text != wantText {
t.Fatalf("input.0.content.0.text = %q, want %q", text, wantText)
}
})
t.Run("call and output in same request are paired regardless of order", func(t *testing.T) {
payload := []byte(`{
"model": "deepseek-v4-pro",
"input": [
{
"type": "function_call_output",
"call_id": "call_future_1",
"name": "create_thread",
"namespace": "codex_app",
"output": "early"
},
{
"type": "function_call",
"call_id": "call_future_1",
"name": "create_thread",
"namespace": "codex_app",
"arguments": "{}"
}
]
}`)
got := testRewriteCodexOrphan(payload, true)
parsed := gjson.ParseBytes(got)
if parsed.Get("input.0.type").String() != "function_call_output" {
t.Fatalf("output should remain paired function_call_output: %s", parsed.Get("input.0").Raw)
}
if parsed.Get("input.1.type").String() != "function_call" {
t.Fatalf("call should remain function_call: %s", parsed.Get("input.1").Raw)
}
})
t.Run("duplicate output consumes call once; second output is orphan", func(t *testing.T) {
payload := []byte(`{
"model": "deepseek-v4-pro",
"input": [
{
"type": "function_call",
"call_id": "call_once",
"name": "create_thread",
"namespace": "codex_app",
"arguments": "{}"
},
{
"type": "function_call_output",
"call_id": "call_once",
"name": "create_thread",
"namespace": "codex_app",
"output": "first"
},
{
"type": "function_call_output",
"call_id": "call_once",
"name": "create_thread",
"namespace": "codex_app",
"output": "second"
}
]
}`)
got := testRewriteCodexOrphan(payload, true)
parsed := gjson.ParseBytes(got)
if parsed.Get("input.1.type").String() != "function_call_output" {
t.Fatalf("first output should pair with call: %s", parsed.Get("input.1").Raw)
}
if parsed.Get("input.2.type").String() != "message" {
t.Fatalf("second output should be orphan: %s", parsed.Get("input.2").Raw)
}
})
t.Run("custom tool call does not pair with function call output", func(t *testing.T) {
payload := []byte(`{
"model": "deepseek-v4-pro",
"input": [
{
"type": "custom_tool_call",
"call_id": "call_custom",
"name": "create_thread",
"input": "{}"
},
{
"type": "function_call_output",
"call_id": "call_custom",
"name": "create_thread",
"namespace": "codex_app",
"output": "orphan"
}
]
}`)
got := testRewriteCodexOrphan(payload, true)
parsed := gjson.ParseBytes(got)
if parsed.Get("input.1.type").String() != "message" {
t.Fatalf("function_call_output should not pair with custom_tool_call: %s", parsed.Get("input.1").Raw)
}
})
t.Run("assistant message tool_calls does not pair with Responses function_call_output", func(t *testing.T) {
payload := []byte(`{
"model": "deepseek-v4-pro",
"input": [
{
"type": "message",
"role": "assistant",
"tool_calls": [{"id": "call_in_msg", "type": "function", "function": {"name": "create_thread"}}]
},
{
"type": "function_call_output",
"call_id": "call_in_msg",
"name": "create_thread",
"namespace": "codex_app",
"output": "orphan"
}
]
}`)
got := testRewriteCodexOrphan(payload, true)
parsed := gjson.ParseBytes(got)
if parsed.Get("input.1.type").String() != "message" {
t.Fatalf("function_call_output should not pair with assistant message tool_calls: %s", parsed.Get("input.1").Raw)
}
})
t.Run("exact namespace and name required", func(t *testing.T) {
payload := []byte(`{
"model": "deepseek-v4-pro",
"input": [
{
"type": "function_call_output",
"name": "codex_app__create_thread",
"output": "orphan"
},
{
"type": "function_call_output",
"name": "create_thread",
"namespace": " codex_app ",
"output": "orphan"
},
{
"type": "function_call_output",
"name": "other_tool",
"namespace": "codex_app",
"output": "orphan"
}
]
}`)
got := testRewriteCodexOrphan(payload, true)
parsed := gjson.ParseBytes(got)
for i := 0; i < 3; i++ {
if itemType := parsed.Get(fmt.Sprintf("input.%d.type", i)).String(); itemType != "function_call_output" {
t.Fatalf("input.%d should not be rewritten: %s", i, parsed.Get(fmt.Sprintf("input.%d", i)).Raw)
}
}
})
}
func TestTranslateRequestWithCodexMultiAgentV2OrphanDelegation(t *testing.T) {
payload := []byte(`{
"model": "test-model",
"stream": false,
"input": [
{
"type": "function_call_output",
"name": "create_thread",
"namespace": "codex_app",
"output": "<codex_delegation><message>handoff</message></codex_delegation>"
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "please continue"}]
}
]
}`)
collabHeaders := http.Header{"X-Openai-Subagent": []string{"collab_spawn"}}
t.Run("enabled but missing X-Openai-Subagent header does not rewrite", func(t *testing.T) {
cfg := &config.Config{
Codex: config.CodexConfig{
OrphanDelegationCompatibility: true,
},
}
got := TranslateRequestWithCodexMultiAgentV2(context.Background(), http.Header{}, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", payload, false)
parsed := gjson.ParseBytes(got)
item0 := parsed.Get("input.0")
if item0.Get("type").String() != "function_call_output" {
t.Fatalf("input.0 = %s, want function_call_output when X-Openai-Subagent header is missing", item0.Raw)
}
})
t.Run("enabled with wrong X-Openai-Subagent header value does not rewrite", func(t *testing.T) {
cfg := &config.Config{
Codex: config.CodexConfig{
OrphanDelegationCompatibility: true,
},
}
headers := http.Header{"X-Openai-Subagent": []string{"other_agent"}}
got := TranslateRequestWithCodexMultiAgentV2(context.Background(), headers, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", payload, false)
parsed := gjson.ParseBytes(got)
item0 := parsed.Get("input.0")
if item0.Get("type").String() != "function_call_output" {
t.Fatalf("input.0 = %s, want function_call_output when X-Openai-Subagent header is wrong", item0.Raw)
}
})
t.Run("enabled translates orphan delegation to user message for responses target", func(t *testing.T) {
cfg := &config.Config{
Codex: config.CodexConfig{
OrphanDelegationCompatibility: true,
},
}
got := TranslateRequestWithCodexMultiAgentV2(context.Background(), collabHeaders, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", payload, false)
parsed := gjson.ParseBytes(got)
item0 := parsed.Get("input.0")
if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" {
t.Fatalf("input.0 = %s, want message/user", item0.Raw)
}
wantText := "Tool output from codex_app__create_thread:\n<codex_delegation><message>handoff</message></codex_delegation>"
if text := item0.Get("content.0.text").String(); text != wantText {
t.Fatalf("input.0.content.0.text = %q, want %q", text, wantText)
}
})
t.Run("enabled translates orphan delegation to user message for chat target without empty tool_call_id", func(t *testing.T) {
cfg := &config.Config{
Codex: config.CodexConfig{
OrphanDelegationCompatibility: true,
},
}
got := TranslateRequestWithCodexMultiAgentV2(context.Background(), collabHeaders, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, "test-model", payload, false)
parsed := gjson.ParseBytes(got)
messages := parsed.Get("messages").Array()
if len(messages) < 2 {
t.Fatalf("expected at least 2 messages, got %d: %s", len(messages), string(got))
}
msg0 := messages[0]
if msg0.Get("role").String() != "user" {
t.Fatalf("messages.0.role = %q, want user (should not be tool message with empty id)", msg0.Get("role").String())
}
wantText := "Tool output from codex_app__create_thread:\n<codex_delegation><message>handoff</message></codex_delegation>"
var text string
if msg0.Get("content").IsArray() {
text = msg0.Get("content.0.text").String()
} else {
text = msg0.Get("content").String()
}
if text != wantText {
t.Fatalf("messages.0.content = %q, want %q", text, wantText)
}
})
t.Run("disabled leaves orphan delegation untranslated for responses target", func(t *testing.T) {
cfg := &config.Config{
Codex: config.CodexConfig{
OrphanDelegationCompatibility: false,
},
}
got := TranslateRequestWithCodexMultiAgentV2(context.Background(), collabHeaders, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", payload, false)
parsed := gjson.ParseBytes(got)
item0 := parsed.Get("input.0")
if item0.Get("type").String() != "function_call_output" {
t.Fatalf("input.0 = %s, want function_call_output when disabled", item0.Raw)
}
})
t.Run("interleaved valid tool pair and orphan delegation retains order and pairing", func(t *testing.T) {
interleaved := []byte(`{
"model": "test-model",
"input": [
{
"type": "function_call",
"call_id": "call_1",
"name": "lookup",
"arguments": "{\"q\":\"test\"}"
},
{
"type": "function_call_output",
"call_id": "call_1",
"name": "lookup",
"output": "result1"
},
{
"type": "function_call_output",
"name": "create_thread",
"namespace": "codex_app",
"output": "delegated"
}
]
}`)
cfg := &config.Config{
Codex: config.CodexConfig{
OrphanDelegationCompatibility: true,
},
}
got := TranslateRequestWithCodexMultiAgentV2(context.Background(), collabHeaders, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", interleaved, false)
parsed := gjson.ParseBytes(got)
if parsed.Get("input.0.type").String() != "function_call" {
t.Fatalf("input.0 should remain function_call")
}
if parsed.Get("input.1.type").String() != "function_call_output" || parsed.Get("input.1.call_id").String() != "call_1" {
t.Fatalf("input.1 should remain paired function_call_output")
}
if parsed.Get("input.2.type").String() != "message" || parsed.Get("input.2.role").String() != "user" {
t.Fatalf("input.2 should be downgraded to message/user: %s", parsed.Get("input.2").Raw)
}
})
}

View File

@@ -158,6 +158,8 @@ type CodexConfig struct {
StreamBootstrapBuffering bool `yaml:"stream-bootstrap-buffering" json:"stream-bootstrap-buffering"`
// OptimizeMultiAgentV2 optimizes official Codex multi-agent requests.
OptimizeMultiAgentV2 bool `yaml:"optimize-multi-agent-v2" json:"optimize-multi-agent-v2"`
// OrphanDelegationCompatibility enables opt-in compatibility for orphan Codex delegation outputs.
OrphanDelegationCompatibility bool `yaml:"orphan-delegation-compatibility" json:"orphan-delegation-compatibility"`
// LiveMediaRelay terminates and relays Codex Live WebRTC media in this process.
LiveMediaRelay CodexLiveMediaRelayConfig `yaml:"live-media-relay" json:"live-media-relay"`
}

View File

@@ -45,6 +45,9 @@ type SDKConfig struct {
// CodexOptimizeMultiAgentV2 mirrors the provider-wide runtime setting for API handlers.
CodexOptimizeMultiAgentV2 bool `yaml:"-" json:"-"`
// CodexOrphanDelegationCompatibility mirrors the provider-wide runtime setting for API handlers.
CodexOrphanDelegationCompatibility bool `yaml:"-" json:"-"`
// ClaudeCode configures Claude Code compatibility behavior.
ClaudeCode ClaudeCodeConfig `yaml:"claude-code" json:"claude-code"`

View File

@@ -29,6 +29,13 @@ func RewriteCodexMultiAgentV2Input(ctx context.Context, headers http.Header, pay
return multiagentv2.RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg)
}
// RewriteCodexOrphanDelegationInput converts orphan Codex delegation outputs into
// standard user messages when orphan delegation compatibility is enabled and the
// request carries the X-Openai-Subagent: collab_spawn header.
func RewriteCodexOrphanDelegationInput(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config) []byte {
return multiagentv2.RewriteCodexOrphanDelegationInputForConfig(ctx, headers, payload, cfg)
}
// TranslateRequestWithCodexMultiAgentV2 normalizes official Codex multi-agent
// input before translating it to a non-Codex target protocol.
func TranslateRequestWithCodexMultiAgentV2(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, payload []byte, stream bool) []byte {
@@ -71,8 +78,11 @@ func TranslateRequestWithAPIKeyModelCompatibility(ctx context.Context, headers h
if !isCompat {
return TranslateRequestWithCodexMultiAgentV2(ctx, headers, cfg, from, to, model, payload, stream)
}
if from == sdktranslator.FormatOpenAIResponse && to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse {
payload = multiagentv2.RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg)
if from == sdktranslator.FormatOpenAIResponse {
payload = RewriteCodexOrphanDelegationInput(ctx, headers, payload, cfg)
if to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse {
payload = multiagentv2.RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg)
}
}
var translated []byte

View File

@@ -121,6 +121,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
if oldCfg.Codex.OptimizeMultiAgentV2 != newCfg.Codex.OptimizeMultiAgentV2 {
changes = append(changes, fmt.Sprintf("codex.optimize-multi-agent-v2: %t -> %t", oldCfg.Codex.OptimizeMultiAgentV2, newCfg.Codex.OptimizeMultiAgentV2))
}
if oldCfg.Codex.OrphanDelegationCompatibility != newCfg.Codex.OrphanDelegationCompatibility {
changes = append(changes, fmt.Sprintf("codex.orphan-delegation-compatibility: %t -> %t", oldCfg.Codex.OrphanDelegationCompatibility, newCfg.Codex.OrphanDelegationCompatibility))
}
if oldCfg.XAI.InjectXSearch != newCfg.XAI.InjectXSearch {
changes = append(changes, fmt.Sprintf("xai.inject-x-search: %t -> %t", oldCfg.XAI.InjectXSearch, newCfg.XAI.InjectXSearch))
}

View File

@@ -204,6 +204,14 @@ func TestBuildConfigChangeDetails_CodexAlphaSearch(t *testing.T) {
expectContains(t, changes, "codex[0].alpha-search: false -> true")
}
func TestBuildConfigChangeDetails_CodexOrphanDelegationCompatibility(t *testing.T) {
oldCfg := &config.Config{Codex: config.CodexConfig{OrphanDelegationCompatibility: false}}
newCfg := &config.Config{Codex: config.CodexConfig{OrphanDelegationCompatibility: true}}
changes := BuildConfigChangeDetails(oldCfg, newCfg)
expectContains(t, changes, "codex.orphan-delegation-compatibility: false -> true")
}
func TestBuildConfigChangeDetails_XAIKeys(t *testing.T) {
oldRetry := 1
newRetry := 0

View File

@@ -511,6 +511,20 @@ func (h *OpenAIResponsesAPIHandler) prepareCodexMultiAgentV2Tools(c *gin.Context
return updated
}
func (h *OpenAIResponsesAPIHandler) prepareCodexOrphanDelegation(c *gin.Context, payload []byte) []byte {
if h == nil || h.Cfg == nil || !h.Cfg.CodexOrphanDelegationCompatibility {
return payload
}
requestCtx := context.Background()
var requestHeaders http.Header
if c != nil && c.Request != nil {
requestCtx = c.Request.Context()
requestHeaders = c.Request.Header
}
requestCtx = context.WithValue(requestCtx, "gin", c)
return multiagentv2.RewriteCodexOrphanDelegationInput(requestCtx, requestHeaders, payload, true)
}
// Responses handles the /v1/responses endpoint.
// It determines whether the request is for a streaming or non-streaming response
// and calls the appropriate handler based on the model provider.
@@ -531,6 +545,7 @@ func (h *OpenAIResponsesAPIHandler) Responses(c *gin.Context) {
}
rawJSON = h.prepareCodexMultiAgentV2Tools(c, rawJSON)
rawJSON = h.prepareCodexOrphanDelegation(c, rawJSON)
// Check if the client requested a streaming response.
streamResult := gjson.GetBytes(rawJSON, "stream")
@@ -554,6 +569,8 @@ func (h *OpenAIResponsesAPIHandler) Compact(c *gin.Context) {
return
}
rawJSON = h.prepareCodexOrphanDelegation(c, rawJSON)
streamResult := gjson.GetBytes(rawJSON, "stream")
if streamResult.Type == gjson.True {
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{

View File

@@ -197,3 +197,93 @@ func TestPrepareCodexMultiAgentV2ToolsAtResponsesBoundarySkipsOtherClients(t *te
t.Fatal("other client unexpectedly received prepared marker")
}
}
func newResponsesOrphanDelegationTestHandler(t *testing.T, executor *responsesMultiAgentCaptureExecutor) (*OpenAIResponsesAPIHandler, string) {
t.Helper()
modelID := "responses-orphan-test-model"
authID := "responses-orphan-test-auth"
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth := &coreauth.Auth{ID: authID, Provider: "codex", Status: coreauth.StatusActive, ProxyURL: "direct"}
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
t.Fatalf("Register auth: %v", errRegister)
}
registry.GetGlobalRegistry().RegisterClient(authID, auth.Provider, []*registry.ModelInfo{{ID: modelID}})
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(authID)
})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{CodexOrphanDelegationCompatibility: true}, manager)
return NewOpenAIResponsesAPIHandler(base), modelID
}
func TestResponsesOrphanCodexDelegationCompatibility(t *testing.T) {
t.Parallel()
executor := &responsesMultiAgentCaptureExecutor{}
handler, modelID := newResponsesOrphanDelegationTestHandler(t, executor)
router := gin.New()
router.POST("/v1/responses", handler.Responses)
payload := fmt.Sprintf(`{
"model": %q,
"stream": false,
"input": [
{
"type": "function_call_output",
"name": "create_thread",
"namespace": "codex_app",
"output": "<codex_delegation>msg</codex_delegation>"
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "continue"}]
}
]
}`, modelID)
request := httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewBufferString(payload))
request.Header.Set("X-Openai-Subagent", "collab_spawn")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", recorder.Code, recorder.Body.String())
}
payloads := executor.Payloads()
if len(payloads) != 1 {
t.Fatalf("captured payload count = %d, want 1", len(payloads))
}
captured := payloads[0]
parsed := gjson.ParseBytes(captured)
if itemType := parsed.Get("input.0.type").String(); itemType != "message" {
t.Fatalf("input.0.type = %q, want message; captured=%s", itemType, captured)
}
if role := parsed.Get("input.0.role").String(); role != "user" {
t.Fatalf("input.0.role = %q, want user", role)
}
wantText := "Tool output from codex_app__create_thread:\n<codex_delegation>msg</codex_delegation>"
if text := parsed.Get("input.0.content.0.text").String(); text != wantText {
t.Fatalf("input.0.content.0.text = %q, want %q", text, wantText)
}
// Without X-Openai-Subagent header, payload should remain function_call_output
requestNoHeader := httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewBufferString(payload))
recorderNoHeader := httptest.NewRecorder()
router.ServeHTTP(recorderNoHeader, requestNoHeader)
if recorderNoHeader.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", recorderNoHeader.Code, recorderNoHeader.Body.String())
}
payloads = executor.Payloads()
if len(payloads) != 2 {
t.Fatalf("captured payload count = %d, want 2", len(payloads))
}
capturedNoHeader := payloads[1]
parsedNoHeader := gjson.ParseBytes(capturedNoHeader)
if itemType := parsedNoHeader.Get("input.0.type").String(); itemType != "function_call_output" {
t.Fatalf("input.0.type = %q, want function_call_output without header; captured=%s", itemType, capturedNoHeader)
}
}

View File

@@ -522,6 +522,7 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
}
requestJSON = h.prepareCodexMultiAgentV2Tools(c, requestJSON)
requestJSON = h.prepareCodexOrphanDelegation(c, requestJSON)
if !useUpstreamWebsocketPassthrough && shouldHandleResponsesWebsocketPrewarmLocally(payload, lastRequest, false) {
if updated, errDelete := sjson.DeleteBytes(requestJSON, "generate"); errDelete == nil {