diff --git a/config.example.yaml b/config.example.yaml --- a/config.example.yaml +++ b/config.example.yaml @@ -212,6 +212,11 @@ # Some superstitious users believe request tracking identifiers can be used # as evidence for TOS enforcement bans; this option only satisfies those odd concerns. identity-confuse: false + # When true, optimize Codex Desktop and codex-tui requests for multi-agent v2. + # This refreshes Codex spawn_agent model details, removes message parameter encryption, + # 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 authentication for the WebSocket API (/v1/ws). ws-auth: true diff --git a/internal/api/server.go b/internal/api/server.go --- a/internal/api/server.go +++ b/internal/api/server.go @@ -107,6 +107,7 @@ return nil } sdkCfg := cfg.SDKConfig + sdkCfg.CodexOptimizeMultiAgentV2 = cfg.Codex.OptimizeMultiAgentV2 if cfg.CommercialMode { sdkCfg.RequestLog = false } @@ -1366,7 +1367,7 @@ models = append(models, model) } - c.JSON(http.StatusOK, openai.CodexClientModelsResponse(models)) + c.JSON(http.StatusOK, openai.CodexClientModelsResponseWithMultiAgentV2(models, s.cfg.Codex.OptimizeMultiAgentV2)) } func (s *Server) geminiModelsHandler(geminiHandler *gemini.GeminiAPIHandler) gin.HandlerFunc { diff --git a/internal/api/server_sdk_config_test.go b/internal/api/server_sdk_config_test.go new file mode 100644 --- /dev/null +++ b/internal/api/server_sdk_config_test.go @@ -0,0 +1,16 @@ +package api + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" +) + +func TestEffectiveSDKConfigCopiesCodexOptimizeMultiAgentV2(t *testing.T) { + cfg := &config.Config{Codex: config.CodexConfig{OptimizeMultiAgentV2: true}} + + sdkCfg := effectiveSDKConfig(cfg) + if sdkCfg == nil || !sdkCfg.CodexOptimizeMultiAgentV2 { + t.Fatalf("CodexOptimizeMultiAgentV2 = false, want true") + } +} diff --git a/internal/config/codex_websocket_header_defaults_test.go b/internal/config/codex_websocket_header_defaults_test.go --- a/internal/config/codex_websocket_header_defaults_test.go +++ b/internal/config/codex_websocket_header_defaults_test.go @@ -37,6 +37,7 @@ configYAML := []byte(` codex: identity-confuse: true + optimize-multi-agent-v2: true `) if err := os.WriteFile(configPath, configYAML, 0o600); err != nil { t.Fatalf("failed to write config: %v", err) @@ -49,5 +50,8 @@ if !cfg.Codex.IdentityConfuse { t.Fatalf("IdentityConfuse = false, want true") + } + if !cfg.Codex.OptimizeMultiAgentV2 { + t.Fatalf("OptimizeMultiAgentV2 = false, want true") } } diff --git a/internal/config/config.go b/internal/config/config.go --- a/internal/config/config.go +++ b/internal/config/config.go @@ -291,6 +291,8 @@ // CodexConfig configures provider-wide Codex request behavior. type CodexConfig struct { IdentityConfuse bool `yaml:"identity-confuse" json:"identity-confuse"` + // OptimizeMultiAgentV2 optimizes official Codex multi-agent requests. + OptimizeMultiAgentV2 bool `yaml:"optimize-multi-agent-v2" json:"optimize-multi-agent-v2"` } // TLSConfig holds HTTPS server settings. diff --git a/internal/config/sdk_config.go b/internal/config/sdk_config.go --- a/internal/config/sdk_config.go +++ b/internal/config/sdk_config.go @@ -42,6 +42,9 @@ // RequestLog enables or disables detailed request logging functionality. RequestLog bool `yaml:"request-log" json:"request-log"` + // CodexOptimizeMultiAgentV2 mirrors the provider-wide runtime setting for API handlers. + CodexOptimizeMultiAgentV2 bool `yaml:"-" json:"-"` + // APIKeys is a list of keys for authenticating clients to this proxy server. APIKeys []string `yaml:"api-keys" json:"api-keys"` diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -131,7 +131,7 @@ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth) defer reporter.TrackFailure(ctx, &err) - translatedReq, body, err := e.translateRequest(req, opts, false) + translatedReq, body, err := e.translateRequest(ctx, req, opts, false) if err != nil { return resp, err } @@ -200,7 +200,7 @@ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth) defer reporter.TrackFailure(ctx, &err) - translatedReq, body, err := e.translateRequest(req, opts, true) + translatedReq, body, err := e.translateRequest(ctx, req, opts, true) if err != nil { return nil, err } @@ -381,7 +381,7 @@ // CountTokens counts tokens for the given request using the AI Studio API. func (e *AIStudioExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { baseModel := thinking.ParseSuffix(req.Model).ModelName - _, body, err := e.translateRequest(req, opts, false) + _, body, err := e.translateRequest(ctx, req, opts, false) if err != nil { return cliproxyexecutor.Response{}, err } @@ -449,7 +449,7 @@ toFormat sdktranslator.Format } -func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts cliproxyexecutor.Options, stream bool) ([]byte, translatedPayload, error) { +func (e *AIStudioExecutor) translateRequest(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, stream bool) ([]byte, translatedPayload, error) { baseModel := thinking.ParseSuffix(req.Model).ModelName from := opts.SourceFormat @@ -459,8 +459,8 @@ originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, stream) - payload := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, stream) + originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, stream) + payload := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream) payload, err := thinking.ApplyThinking(payload, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return nil, translatedPayload{}, err diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -653,8 +653,8 @@ if updatedAuth != nil { auth = updatedAuth } - originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false) - translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false) + originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false) + translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { @@ -874,8 +874,8 @@ if updatedAuth != nil { auth = updatedAuth } - originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) - translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true) + originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) + translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { @@ -1344,8 +1344,8 @@ auth = updatedAuth } - originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) - translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true) + originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) + translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { @@ -1680,7 +1680,7 @@ } // Prepare payload once (doesn't depend on baseURL) - payload := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false) + payload := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) payload, err := thinking.ApplyThinking(payload, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -287,8 +287,8 @@ originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, stream) - body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, stream) + originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, stream) + body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream) body = helps.SetStringIfDifferent(body, "model", upstreamModel) body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) @@ -482,8 +482,8 @@ originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) - body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true) + originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) + body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) body = helps.SetStringIfDifferent(body, "model", upstreamModel) body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) @@ -778,7 +778,7 @@ to := sdktranslator.FromString("claude") // Use streaming translation to preserve function calling, except for claude. stream := from != to - body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, stream) + body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream) body = helps.SetStringIfDifferent(body, "model", upstreamModel) if rebuildMidSystemMessageEnabled(e.cfg, auth) { body = rebuildMidSystemMessagesToTopLevel(body) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -1149,6 +1149,7 @@ } body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body) body = normalizeCodexParallelToolCalls(body, opts.Headers) + body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2Request(ctx, opts.Headers, body, e.cfg) body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body) if errReplay != nil { return resp, errReplay @@ -1218,6 +1219,7 @@ } eventData := bytes.TrimSpace(line[5:]) + eventData = helps.RestoreCodexMultiAgentV2Response(eventData, optimizeMultiAgentV2) eventType := gjson.GetBytes(eventData, "type").String() if streamErr, terminalBody, ok := codexTerminalFailureErr(eventData); ok { @@ -1308,6 +1310,7 @@ body = normalizeCodexInstructions(body) body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body) body = normalizeCodexParallelToolCalls(body, opts.Headers) + body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2Request(ctx, opts.Headers, body, e.cfg) reporter.SetTranslatedReasoningEffort(body, to.String()) url := strings.TrimSuffix(baseURL, "/") + "/responses/compact" @@ -1364,6 +1367,7 @@ } upstreamData := applyCodexIdentityConfuseResponsePayload(data, identityState) helps.AppendAPIResponseChunk(ctx, e.cfg, upstreamData) + upstreamData = helps.RestoreCodexMultiAgentV2Response(upstreamData, optimizeMultiAgentV2) reporter.Publish(ctx, helps.ParseOpenAIUsage(upstreamData)) reporter.EnsurePublished(ctx) var param any @@ -1420,6 +1424,7 @@ } body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body) body = normalizeCodexParallelToolCalls(body, opts.Headers) + body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2Request(ctx, opts.Headers, body, e.cfg) body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body) if errReplay != nil { return nil, errReplay @@ -1501,6 +1506,8 @@ if bytes.HasPrefix(line, dataTag) { data := bytes.TrimSpace(line[5:]) + data = helps.RestoreCodexMultiAgentV2Response(data, optimizeMultiAgentV2) + translatedLine = append([]byte("data: "), data...) eventType := gjson.GetBytes(data, "type").String() if streamErr, terminalBody, ok := codexTerminalFailureErr(data); ok { if errClearReplay := clearCodexReasoningReplayOnInvalidSignature(ctx, replayScope, streamErr.StatusCode(), terminalBody); errClearReplay != nil { diff --git a/internal/runtime/executor/codex_executor_spawn_agent_test.go b/internal/runtime/executor/codex_executor_spawn_agent_test.go new file mode 100644 --- /dev/null +++ b/internal/runtime/executor/codex_executor_spawn_agent_test.go @@ -0,0 +1,208 @@ +package executor + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestCodexExecutorOptimizeMultiAgentV2(t *testing.T) { + modelID := "codex-executor-spawn-agent-test-model" + clientID := "codex-executor-spawn-agent-test-client" + modelRegistry := registry.GetGlobalRegistry() + modelRegistry.RegisterClient(clientID, "codex", []*registry.ModelInfo{{ + ID: modelID, + Description: "Executor test model.", + Thinking: ®istry.ThinkingSupport{ + Levels: []string{"low", "medium", "high"}, + }, + }}) + defer modelRegistry.UnregisterClient(clientID) + + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + upstreamBody, _ = io.ReadAll(request.Body) + if request.URL.Path == "/responses/compact" { + w.Header().Set("Content-Type", "application/json") + namespace := gjson.GetBytes(upstreamBody, "input.0.tools.0.name").String() + compact := fmt.Sprintf(`{"id":"resp_1","object":"response.compaction","output":[{"type":"function_call","name":"spawn_agent","namespace":%q,"arguments":"{}","call_id":"call_1"}]}`, namespace) + _, _ = w.Write([]byte(compact)) + return + } + w.Header().Set("Content-Type", "text/event-stream") + namespace := gjson.GetBytes(upstreamBody, "input.0.tools.0.name").String() + completed := fmt.Sprintf(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","output":[{"type":"function_call","name":"spawn_agent","namespace":%q,"arguments":"{}","call_id":"call_1"}]}}`+"\n\n", namespace) + _, _ = w.Write([]byte(completed)) + })) + defer server.Close() + + payload := codexSpawnAgentTestPayload() + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "base_url": server.URL, + "api_key": "test", + }} + + tests := []struct { + name string + enabled bool + mode string + }{ + {name: "execute enabled", enabled: true, mode: "execute"}, + {name: "execute disabled", enabled: false, mode: "execute"}, + {name: "stream enabled", enabled: true, mode: "stream"}, + {name: "stream disabled", enabled: false, mode: "stream"}, + {name: "compact enabled", enabled: true, mode: "compact"}, + {name: "compact disabled", enabled: false, mode: "compact"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstreamBody = nil + executor := NewCodexExecutor(&config.Config{Codex: config.CodexConfig{OptimizeMultiAgentV2: tt.enabled}}) + ctx := codexSpawnAgentTestContext() + headers := http.Header{"User-Agent": []string{"overridden-client/1.0"}} + req := cliproxyexecutor.Request{Model: "gpt-5.4", Payload: payload} + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("openai-response"), Headers: headers} + + var clientPayload []byte + switch tt.mode { + case "stream": + result, errExecute := executor.ExecuteStream(ctx, auth, req, opts) + if errExecute != nil { + t.Fatalf("ExecuteStream() error = %v", errExecute) + } + for chunk := range result.Chunks { + clientPayload = append(clientPayload, chunk.Payload...) + } + case "compact": + opts.Alt = "responses/compact" + response, errExecute := executor.Execute(ctx, auth, req, opts) + if errExecute != nil { + t.Fatalf("compact Execute() error = %v", errExecute) + } + clientPayload = response.Payload + default: + response, errExecute := executor.Execute(ctx, auth, req, opts) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + clientPayload = response.Payload + } + + assertCodexSpawnAgentOptimization(t, upstreamBody, modelID, tt.enabled) + assertCodexSpawnAgentRequestMessage(t, upstreamBody, tt.enabled) + assertCodexSpawnAgentClientNamespace(t, clientPayload) + }) + } +} + +func codexSpawnAgentTestPayload() []byte { + return []byte(`{ + "model":"gpt-5.4", + "input":[{ + "type":"additional_tools", + "role":"developer", + "tools":[{ + "type":"namespace", + "name":"collaboration", + "tools":[{ + "type":"function", + "name":"spawn_agent", + "description":"Available model overrides (optional; inherited parent model is preferred):\n- old-model\nSpawns an agent.", + "parameters":{"type":"object","properties":{"message":{"type":"string","encrypted":true}}} + }] + }] + },{ + "type":"agent_message", + "id":"amsg_1", + "author":"/root", + "recipient":"/root/worker", + "content":[ + {"type":"input_text","text":"Payload:\n"}, + {"type":"encrypted_content","encrypted_content":"delegated task"} + ], + "internal_chat_message_metadata_passthrough":{"turn_id":"turn_1"} + }] + }`) +} + +func codexSpawnAgentTestContext() context.Context { + request := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + request.Header.Set("User-Agent", "codex-tui/0.145.0") + ginCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ginCtx.Request = request + return context.WithValue(context.Background(), "gin", ginCtx) +} + +func assertCodexSpawnAgentClientNamespace(t *testing.T, payload []byte) { + t.Helper() + if strings.Contains(string(payload), "collaboration-optimize") { + t.Fatalf("optimized namespace leaked to client: %s", payload) + } + if !strings.Contains(string(payload), `"namespace":"collaboration"`) { + t.Fatalf("restored collaboration namespace missing from client payload: %s", payload) + } +} + +func assertCodexSpawnAgentRequestMessage(t *testing.T, payload []byte, enabled bool) { + t.Helper() + message := gjson.GetBytes(payload, "input.1") + if message.Get("type").String() != "agent_message" || message.Get("role").Exists() { + t.Fatalf("Codex executor changed outer agent message: %s", payload) + } + if message.Get("author").String() != "/root" || message.Get("recipient").String() != "/root/worker" || message.Get("internal_chat_message_metadata_passthrough.turn_id").String() != "turn_1" { + t.Fatalf("Codex executor changed agent message metadata: %s", payload) + } + if enabled { + if message.Get("content.1.type").String() != "input_text" || message.Get("content.1.text").String() != "delegated task" { + t.Fatalf("Codex executor did not normalize agent message content: %s", payload) + } + if message.Get("content.1.encrypted_content").Exists() { + t.Fatalf("Codex executor preserved encrypted_content: %s", payload) + } + return + } + if message.Get("content.1.type").String() != "encrypted_content" || message.Get("content.1.encrypted_content").String() != "delegated task" { + t.Fatalf("disabled optimization changed agent message content: %s", payload) + } +} + +func assertCodexSpawnAgentOptimization(t *testing.T, payload []byte, modelID string, enabled bool) { + t.Helper() + namespace := gjson.GetBytes(payload, "input.0.tools.0.name").String() + description := gjson.GetBytes(payload, "input.0.tools.0.tools.0.description").String() + encrypted := gjson.GetBytes(payload, "input.0.tools.0.tools.0.parameters.properties.message.encrypted") + if enabled { + if namespace != "collaboration-optimize" { + t.Fatalf("optimized namespace = %q, want collaboration-optimize", namespace) + } + wantModel := "- `" + modelID + "`: Executor test model. Reasoning efforts: low, medium (default), high." + if !strings.Contains(description, wantModel) { + t.Fatalf("description does not contain model metadata: %q", description) + } + if encrypted.Exists() { + t.Fatalf("message encrypted was not removed: %s", encrypted.Raw) + } + return + } + if namespace != "collaboration" { + t.Fatalf("disabled namespace = %q, want collaboration", namespace) + } + if !strings.Contains(description, "- old-model") { + t.Fatalf("disabled optimization changed description: %q", description) + } + if !encrypted.Bool() { + t.Fatalf("disabled optimization removed message encrypted: %s", encrypted.Raw) + } +} diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go --- a/internal/runtime/executor/codex_websockets_executor.go +++ b/internal/runtime/executor/codex_websockets_executor.go @@ -489,6 +489,7 @@ } body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex websockets executor", body) body = normalizeCodexWebsocketParallelToolCalls(body, opts.Headers) + body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2Request(ctx, opts.Headers, body, e.cfg) body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body) if errReplay != nil { return resp, errReplay @@ -703,6 +704,7 @@ reporter.MarkFirstResponseByte() payload = applyCodexIdentityConfuseResponsePayload(payload, identityState) helps.AppendAPIWebsocketResponse(ctx, e.cfg, payload) + payload = helps.RestoreCodexMultiAgentV2Response(payload, optimizeMultiAgentV2) if wsErr, ok := parseCodexWebsocketError(payload); ok { if sess != nil { @@ -788,6 +790,7 @@ } body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex websockets executor", body) body = normalizeCodexWebsocketParallelToolCalls(body, opts.Headers) + body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2Request(ctx, opts.Headers, body, e.cfg) body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body) if errReplay != nil { return nil, errReplay @@ -1062,6 +1065,7 @@ reporter.MarkFirstResponseByte() payload = applyCodexIdentityConfuseResponsePayload(payload, identityState) helps.AppendAPIWebsocketResponse(ctx, e.cfg, payload) + payload = helps.RestoreCodexMultiAgentV2Response(payload, optimizeMultiAgentV2) if wsErr, ok := parseCodexWebsocketError(payload); ok { terminateReason = "upstream_error" diff --git a/internal/runtime/executor/codex_websockets_spawn_agent_test.go b/internal/runtime/executor/codex_websockets_spawn_agent_test.go new file mode 100644 --- /dev/null +++ b/internal/runtime/executor/codex_websockets_spawn_agent_test.go @@ -0,0 +1,98 @@ +package executor + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/websocket" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestCodexWebsocketsExecutorOptimizeMultiAgentV2(t *testing.T) { + modelID := "codex-websocket-spawn-agent-test-model" + clientID := "codex-websocket-spawn-agent-test-client" + modelRegistry := registry.GetGlobalRegistry() + modelRegistry.RegisterClient(clientID, "codex", []*registry.ModelInfo{{ + ID: modelID, + Description: "Executor test model.", + Thinking: ®istry.ThinkingSupport{ + Levels: []string{"low", "medium", "high"}, + }, + }}) + defer modelRegistry.UnregisterClient(clientID) + + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + capturedPayload := make(chan []byte, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + conn, errUpgrade := upgrader.Upgrade(w, request, nil) + if errUpgrade != nil { + t.Errorf("upgrade websocket: %v", errUpgrade) + return + } + defer func() { _ = conn.Close() }() + _, payload, errRead := conn.ReadMessage() + if errRead != nil { + t.Errorf("read websocket request: %v", errRead) + return + } + capturedPayload <- payload + namespace := gjson.GetBytes(payload, "input.0.tools.0.name").String() + completed := []byte(fmt.Sprintf(`{"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","output":[{"type":"function_call","name":"spawn_agent","namespace":%q,"arguments":"{}","call_id":"call_1"}]}}`, namespace)) + if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil { + t.Errorf("write websocket response: %v", errWrite) + } + })) + defer server.Close() + + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "base_url": server.URL, + "api_key": "test", + }} + req := cliproxyexecutor.Request{Model: "gpt-5.4", Payload: codexSpawnAgentTestPayload()} + opts := cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai-response"), + Headers: http.Header{"User-Agent": []string{"overridden-client/1.0"}}, + } + + for _, tt := range []struct { + name string + enabled bool + stream bool + }{ + {name: "execute enabled", enabled: true}, + {name: "execute disabled", enabled: false}, + {name: "stream enabled", enabled: true, stream: true}, + {name: "stream disabled", enabled: false, stream: true}, + } { + t.Run(tt.name, func(t *testing.T) { + executor := NewCodexWebsocketsExecutor(&config.Config{Codex: config.CodexConfig{OptimizeMultiAgentV2: tt.enabled}}) + var clientPayload []byte + if tt.stream { + result, errExecute := executor.ExecuteStream(codexSpawnAgentTestContext(), auth, req, opts) + if errExecute != nil { + t.Fatalf("ExecuteStream() error = %v", errExecute) + } + for chunk := range result.Chunks { + clientPayload = append(clientPayload, chunk.Payload...) + } + } else { + response, errExecute := executor.Execute(codexSpawnAgentTestContext(), auth, req, opts) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + clientPayload = response.Payload + } + upstreamPayload := <-capturedPayload + assertCodexSpawnAgentOptimization(t, upstreamPayload, modelID, tt.enabled) + assertCodexSpawnAgentRequestMessage(t, upstreamPayload, tt.enabled) + assertCodexSpawnAgentClientNamespace(t, clientPayload) + }) + } +} diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -145,8 +145,8 @@ originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false) - body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false) + originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false) + body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { @@ -258,8 +258,8 @@ originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) - body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true) + originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) + body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { @@ -387,7 +387,7 @@ reporter := helps.NewExecutorUsageReporter(ctx, e, targetName, auth) defer reporter.TrackFailure(ctx, &err) - body := translateGeminiInteractionsRequestBody(targetName, req.Payload, opts, false) + body := translateGeminiInteractionsRequestBody(ctx, e.cfg, targetName, req.Payload, opts, false) if gjson.GetBytes(body, "model").Exists() && targetName != "" { body = helps.SetStringIfDifferent(body, "model", targetName) } @@ -398,7 +398,7 @@ requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) fromProtocol := opts.SourceFormat.String() - originalTranslated := geminiInteractionsPayloadConfigSource(targetName, req.Payload, opts, false) + originalTranslated := geminiInteractionsPayloadConfigSource(ctx, e.cfg, targetName, req.Payload, opts, false) body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers) baseURL := resolveGeminiBaseURL(auth) @@ -463,7 +463,7 @@ reporter := helps.NewExecutorUsageReporter(ctx, e, targetName, auth) defer reporter.TrackFailure(ctx, &err) - body := translateGeminiInteractionsRequestBody(targetName, req.Payload, opts, true) + body := translateGeminiInteractionsRequestBody(ctx, e.cfg, targetName, req.Payload, opts, true) if gjson.GetBytes(body, "model").Exists() && targetName != "" { body = helps.SetStringIfDifferent(body, "model", targetName) } @@ -474,7 +474,7 @@ requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) fromProtocol := opts.SourceFormat.String() - originalTranslated := geminiInteractionsPayloadConfigSource(targetName, req.Payload, opts, true) + originalTranslated := geminiInteractionsPayloadConfigSource(ctx, e.cfg, targetName, req.Payload, opts, true) body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = helps.SetBoolIfDifferent(body, "stream", true) baseURL := resolveGeminiBaseURL(auth) @@ -619,7 +619,7 @@ from := opts.SourceFormat responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("gemini") - translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false) + translatedReq := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { @@ -779,19 +779,19 @@ } } -func translateGeminiInteractionsRequestBody(model string, payload []byte, opts cliproxyexecutor.Options, stream bool) []byte { +func translateGeminiInteractionsRequestBody(ctx context.Context, cfg *config.Config, model string, payload []byte, opts cliproxyexecutor.Options, stream bool) []byte { if opts.SourceFormat == "" || opts.SourceFormat == sdktranslator.FormatInteractions { return bytes.Clone(payload) } - return sdktranslator.TranslateRequest(opts.SourceFormat, sdktranslator.FormatInteractions, model, payload, stream) + return helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, cfg, opts.SourceFormat, sdktranslator.FormatInteractions, model, payload, stream) } -func geminiInteractionsPayloadConfigSource(model string, payload []byte, opts cliproxyexecutor.Options, stream bool) []byte { +func geminiInteractionsPayloadConfigSource(ctx context.Context, cfg *config.Config, model string, payload []byte, opts cliproxyexecutor.Options, stream bool) []byte { source := opts.OriginalRequest if len(source) == 0 { source = payload } - return translateGeminiInteractionsRequestBody(model, source, opts, stream) + return translateGeminiInteractionsRequestBody(ctx, cfg, model, source, opts, stream) } func isNativeInteractionsAuth(auth *cliproxyauth.Auth) bool { diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go --- a/internal/runtime/executor/gemini_vertex_executor.go +++ b/internal/runtime/executor/gemini_vertex_executor.go @@ -328,8 +328,8 @@ originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false) - body = sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false) + originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false) + body = helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { @@ -453,8 +453,8 @@ originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false) - body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false) + originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false) + body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { @@ -568,8 +568,8 @@ originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) - body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true) + originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) + body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { @@ -714,8 +714,8 @@ originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) - body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true) + originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) + body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { @@ -852,7 +852,7 @@ responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("gemini") - translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false) + translatedReq := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { @@ -943,7 +943,7 @@ responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("gemini") - translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false) + translatedReq := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -103,8 +103,8 @@ originalPayloadSource = opts.OriginalRequest } originalPayload := bytes.Clone(originalPayloadSource) - originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false) - body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false) + originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false) + body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, bytes.Clone(req.Payload), false) // Strip kimi- prefix and any [1m] suffix for upstream API upstreamModel := normalizeKimiUpstreamModel(baseModel) @@ -212,8 +212,8 @@ originalPayloadSource = opts.OriginalRequest } originalPayload := bytes.Clone(originalPayloadSource) - originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) - body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true) + originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) + body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, bytes.Clone(req.Payload), true) // Strip kimi- prefix and any [1m] suffix for upstream API upstreamModel := normalizeKimiUpstreamModel(baseModel) diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -111,8 +111,8 @@ originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, opts.Stream) - translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, opts.Stream) + originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, opts.Stream) + translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, opts.Stream) translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { @@ -312,8 +312,8 @@ originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) - translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true) + originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) + translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { @@ -583,7 +583,7 @@ from := opts.SourceFormat responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("openai") - translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false) + translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) modelForCounting := baseModel diff --git a/internal/runtime/executor/xai_executor.go b/internal/runtime/executor/xai_executor.go --- a/internal/runtime/executor/xai_executor.go +++ b/internal/runtime/executor/xai_executor.go @@ -1028,6 +1028,7 @@ body, _ = sjson.DeleteBytes(body, "prompt_cache_retention") body, _ = sjson.DeleteBytes(body, "safety_identifier") body, _ = sjson.DeleteBytes(body, "stream_options") + body = helps.RewriteCodexMultiAgentV2Input(ctx, opts.Headers, body, e.cfg) namespaceTools := collectXAINamespaceToolRefs(body) // Collect before normalizeXAITools flattens namespace wrappers so keys match // the post-restore (namespace, short-name) shape used by the response filter. diff --git a/internal/runtime/executor/xai_executor_test.go b/internal/runtime/executor/xai_executor_test.go --- a/internal/runtime/executor/xai_executor_test.go +++ b/internal/runtime/executor/xai_executor_test.go @@ -329,6 +329,56 @@ } } +func TestXAIExecutorPrepareResponsesRequestRewritesCodexAgentMessage(t *testing.T) { + t.Parallel() + + exec := NewXAIExecutor(&config.Config{Codex: config.CodexConfig{OptimizeMultiAgentV2: true}}) + payload := []byte(`{ + "model":"grok-4.5", + "input":[{ + "type":"agent_message", + "id":"amsg_019f92c3-6d77-7880-a6e4-f920867dc6a0", + "author":"/root", + "recipient":"/root/arithmetic_question", + "content":[ + {"type":"input_text","text":"Message Type: NEW_TASK\nTask name: /root/arithmetic_question\nSender: /root\nPayload:\n"}, + {"type":"encrypted_content","encrypted_content":"请出一道四则运算题。只回复题目本身,不要解答;使用中文。"} + ], + "internal_chat_message_metadata_passthrough":{"turn_id":"019f92c3-6772-7213-8aac-8bd154d528f1"} + }] + }`) + prepared, errPrepare := exec.prepareResponsesRequest(context.Background(), cliproxyexecutor.Request{ + Model: "grok-4.5", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatOpenAIResponse, + Headers: http.Header{"User-Agent": []string{"Codex Desktop/0.146.0-alpha.3.1"}}, + }, true) + if errPrepare != nil { + t.Fatalf("prepareResponsesRequest() error = %v", errPrepare) + } + + message := gjson.GetBytes(prepared.body, "input.0") + if message.Get("type").String() != "message" || message.Get("role").String() != "user" { + t.Fatalf("agent message was not rewritten: %s", prepared.body) + } + if message.Get("content.1.type").String() != "input_text" { + t.Fatalf("content[1].type = %q, want input_text; body=%s", message.Get("content.1.type").String(), prepared.body) + } + if text := message.Get("content.1.text").String(); text != "请出一道四则运算题。只回复题目本身,不要解答;使用中文。" { + t.Fatalf("content[1].text = %q; body=%s", text, prepared.body) + } + if message.Get("content.1.encrypted_content").Exists() { + t.Fatalf("encrypted_content was preserved: %s", prepared.body) + } + if message.Get("id").String() != "amsg_019f92c3-6d77-7880-a6e4-f920867dc6a0" || message.Get("author").String() != "/root" || message.Get("recipient").String() != "/root/arithmetic_question" { + t.Fatalf("agent message identity fields changed: %s", prepared.body) + } + if turnID := message.Get("internal_chat_message_metadata_passthrough.turn_id").String(); turnID != "019f92c3-6772-7213-8aac-8bd154d528f1" { + t.Fatalf("turn_id = %q; body=%s", turnID, prepared.body) + } +} + func TestXAIExecutorExecuteRestoresAdditionalToolsNamespaceCalls(t *testing.T) { var gotBody []byte server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -105,6 +105,9 @@ if oldCfg.Codex.IdentityConfuse != newCfg.Codex.IdentityConfuse { changes = append(changes, fmt.Sprintf("codex.identity-confuse: %t -> %t", oldCfg.Codex.IdentityConfuse, newCfg.Codex.IdentityConfuse)) } + 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.Routing.Strategy != newCfg.Routing.Strategy { changes = append(changes, fmt.Sprintf("routing.strategy: %s -> %s", oldCfg.Routing.Strategy, newCfg.Routing.Strategy)) diff --git a/internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2.go b/internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2.go new file mode 100644 --- /dev/null +++ b/internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2.go @@ -0,0 +1,746 @@ +package multiagentv2 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/home" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const ( + codexSpawnAgentDescriptionMarker = "Spawns an agent" + codexSpawnAgentModelsHeading = "Available model overrides (optional; inherited parent model is preferred):" + codexCollaborationNamespace = "collaboration" + codexOptimizedCollaborationNamespace = "collaboration-optimize" + codexOptimizedCollaborationNamePrefix = codexOptimizedCollaborationNamespace + "__" +) + +type codexSpawnAgentModel struct { + id string + description string + reasoningEfforts []string + defaultReasoningEffort string + serviceTiers []string + priority int + displayName string +} + +type codexClientModelsCatalog struct { + Models []map[string]any `json:"models"` +} + +// RewriteCodexSpawnAgentDescription optimizes spawn_agent definitions for +// official Codex clients when multi-agent v2 optimization is enabled. +func RewriteCodexSpawnAgentDescription(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config) []byte { + updated, _ := OptimizeCodexMultiAgentV2Request(ctx, headers, payload, cfg) + return updated +} + +// RewriteCodexMultiAgentV2Input converts official Codex multi-agent input into +// standard Responses API messages when multi-agent v2 optimization is enabled. +func RewriteCodexMultiAgentV2Input(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config) []byte { + if !codexMultiAgentV2Enabled(ctx, headers, cfg) { + return payload + } + return rewriteCodexAgentMessageInput(payload) +} + +// 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) + } + return sdktranslator.TranslateRequest(from, to, model, payload, stream) +} + +// OptimizeCodexMultiAgentV2Request rewrites an eligible spawn_agent request and +// reports whether the collaboration namespace was renamed for upstream use. +func OptimizeCodexMultiAgentV2Request(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config) ([]byte, bool) { + if !codexMultiAgentV2Enabled(ctx, headers, cfg) { + return payload, false + } + updated := rewriteCodexAgentMessageContent(payload) + toolPaths := codexSpawnAgentToolPaths(updated) + if len(toolPaths) == 0 || hasCodexOptimizedCollaborationConflict(updated) { + return updated, false + } + models := codexSpawnAgentModelsForRequest(ctx, headers, cfg.Home.Enabled) + updated = rewriteCodexSpawnAgentTools(updated, toolPaths, models) + return optimizeCodexCollaborationNamespace(updated, toolPaths) +} + +func codexMultiAgentV2Enabled(ctx context.Context, headers http.Header, cfg *config.Config) bool { + return cfg != nil && cfg.Codex.OptimizeMultiAgentV2 && isCodexMultiAgentClient(codexClientUserAgent(ctx, headers)) +} + +func codexClientUserAgent(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, "User-Agent") + } + } + return headerValueCaseInsensitive(headers, "User-Agent") +} + +func headerValueCaseInsensitive(headers http.Header, name string) string { + if headers == nil { + return "" + } + if value := strings.TrimSpace(headers.Get(name)); value != "" { + return value + } + for key, values := range headers { + if !strings.EqualFold(key, name) { + continue + } + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + } + return "" +} + +func isCodexMultiAgentClient(userAgent string) bool { + userAgent = strings.TrimSpace(userAgent) + return strings.HasPrefix(userAgent, "Codex Desktop/") || strings.HasPrefix(userAgent, "codex-tui/") +} + +func codexSpawnAgentModelsForRequest(ctx context.Context, headers http.Header, homeEnabled bool) []codexSpawnAgentModel { + availableModels := registry.GetGlobalRegistry().GetAvailableModels("openai") + if homeEnabled { + availableModels = codexHomeAvailableModels(ctx, headers) + } + return codexSpawnAgentModelsFromSources(availableModels, registry.GetCodexClientModelsJSON(), func(modelID string) *registry.ModelInfo { + return registry.LookupModelInfo(modelID) + }) +} + +func codexHomeAvailableModels(ctx context.Context, headers http.Header) []map[string]any { + client := home.Current() + if client == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + requestHeaders := headers + if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { + requestHeaders = ginCtx.Request.Header + } + query := make(url.Values) + query.Set("client_version", "") + raw, errGet := client.GetModels(ctx, requestHeaders, query) + if errGet != nil { + return nil + } + return decodeCodexHomeAvailableModels(raw) +} + +func decodeCodexHomeAvailableModels(raw []byte) []map[string]any { + var sections map[string][]map[string]any + if err := json.Unmarshal(raw, §ions); err != nil || len(sections) == 0 { + return nil + } + + seen := make(map[string]struct{}) + models := make([]map[string]any, 0, 256) + for _, sectionModels := range sections { + for _, model := range sectionModels { + modelID := mapString(model, "id") + if modelID == "" { + modelID = strings.TrimPrefix(mapString(model, "name"), "models/") + } + if modelID == "" { + continue + } + if _, exists := seen[modelID]; exists { + continue + } + seen[modelID] = struct{}{} + + displayName := mapString(model, "display_name") + if displayName == "" { + displayName = mapString(model, "displayName") + } + entry := map[string]any{"id": modelID} + if displayName != "" { + entry["display_name"] = displayName + entry["description"] = displayName + } + models = append(models, entry) + } + } + sort.Slice(models, func(i, j int) bool { + return mapString(models[i], "id") < mapString(models[j], "id") + }) + return models +} + +func codexSpawnAgentModelsFromSources(availableModels []map[string]any, catalogJSON []byte, lookupModel func(string) *registry.ModelInfo) []codexSpawnAgentModel { + var catalog codexClientModelsCatalog + if err := json.Unmarshal(catalogJSON, &catalog); err != nil || len(catalog.Models) == 0 { + return nil + } + + templates := make(map[string]map[string]any, len(catalog.Models)) + var defaultTemplate map[string]any + for _, model := range catalog.Models { + modelID := mapString(model, "slug") + if modelID == "" { + continue + } + templates[modelID] = model + if modelID == "gpt-5.5" { + defaultTemplate = model + } + } + if defaultTemplate == nil { + return nil + } + + seen := make(map[string]struct{}, len(availableModels)) + templateModels := make([]codexSpawnAgentModel, 0, len(availableModels)) + synthesizedModels := make([]codexSpawnAgentModel, 0, len(availableModels)) + for _, availableModel := range availableModels { + modelID := mapString(availableModel, "id") + if modelID == "" { + continue + } + if _, exists := seen[modelID]; exists { + continue + } + seen[modelID] = struct{}{} + + if template, ok := templates[modelID]; ok { + templateModels = append(templateModels, codexSpawnAgentModelFromMetadata(modelID, template)) + continue + } + + profile := codexSpawnAgentModelFromMetadata(modelID, defaultTemplate) + profile.id = modelID + profile.description = mapString(availableModel, "description") + profile.displayName = mapString(availableModel, "display_name") + if profile.displayName == "" { + profile.displayName = modelID + } + if lookupModel != nil { + if info := lookupModel(modelID); info != nil { + if strings.TrimSpace(info.Description) != "" { + profile.description = strings.TrimSpace(info.Description) + } + applyCodexSpawnAgentThinking(&profile, info.Thinking) + } + } + if profile.description == "" { + profile.description = modelID + } + profile.serviceTiers = nil + synthesizedModels = append(synthesizedModels, profile) + } + + sort.SliceStable(templateModels, func(i, j int) bool { + if templateModels[i].priority == templateModels[j].priority { + return templateModels[i].id < templateModels[j].id + } + return templateModels[i].priority < templateModels[j].priority + }) + sort.SliceStable(synthesizedModels, func(i, j int) bool { + left := strings.ToLower(synthesizedModels[i].displayName) + right := strings.ToLower(synthesizedModels[j].displayName) + if left == right { + return synthesizedModels[i].id < synthesizedModels[j].id + } + return left < right + }) + return append(templateModels, synthesizedModels...) +} + +func codexSpawnAgentModelFromMetadata(modelID string, metadata map[string]any) codexSpawnAgentModel { + profile := codexSpawnAgentModel{ + id: modelID, + description: mapString(metadata, "description"), + displayName: mapString(metadata, "display_name"), + priority: mapInt(metadata, "priority"), + } + profile.reasoningEfforts, profile.defaultReasoningEffort = codexReasoningMetadata(metadata) + profile.serviceTiers = codexServiceTierIDs(metadata) + return profile +} + +func applyCodexSpawnAgentThinking(profile *codexSpawnAgentModel, thinking *registry.ThinkingSupport) { + if profile == nil || thinking == nil || len(thinking.Levels) == 0 { + return + } + + efforts := make([]string, 0, len(thinking.Levels)) + defaultEffort := "" + firstEffort := "" + for _, rawEffort := range thinking.Levels { + effort := normalizeCodexReasoningEffort(rawEffort) + if effort == "" { + continue + } + if firstEffort == "" { + firstEffort = effort + } + if (defaultEffort == "" && effort != "none") || effort == "medium" { + defaultEffort = effort + } + efforts = append(efforts, effort) + } + if len(efforts) == 0 { + return + } + if defaultEffort == "" { + defaultEffort = firstEffort + } + profile.reasoningEfforts = efforts + profile.defaultReasoningEffort = defaultEffort +} + +func codexReasoningMetadata(metadata map[string]any) ([]string, string) { + rawLevels, _ := metadata["supported_reasoning_levels"].([]any) + efforts := make([]string, 0, len(rawLevels)) + allowed := make(map[string]struct{}, len(rawLevels)) + for _, rawLevel := range rawLevels { + level, _ := rawLevel.(map[string]any) + effort := normalizeCodexReasoningEffort(mapString(level, "effort")) + if effort == "" { + continue + } + efforts = append(efforts, effort) + allowed[effort] = struct{}{} + } + if len(efforts) == 0 { + return nil, "" + } + + defaultEffort := normalizeCodexReasoningEffort(mapString(metadata, "default_reasoning_level")) + if _, ok := allowed[defaultEffort]; !ok { + defaultEffort = efforts[0] + } + return efforts, defaultEffort +} + +func normalizeCodexReasoningEffort(effort string) string { + effort = strings.ToLower(strings.TrimSpace(effort)) + switch effort { + case "none", "low", "medium", "high", "xhigh", "max", "ultra": + return effort + default: + return "" + } +} + +func codexServiceTierIDs(metadata map[string]any) []string { + rawTiers, _ := metadata["service_tiers"].([]any) + tiers := make([]string, 0, len(rawTiers)) + seen := make(map[string]struct{}, len(rawTiers)) + for _, rawTier := range rawTiers { + tier, _ := rawTier.(map[string]any) + tierID := mapString(tier, "id") + if tierID == "" { + continue + } + if _, exists := seen[tierID]; exists { + continue + } + seen[tierID] = struct{}{} + tiers = append(tiers, tierID) + } + return tiers +} + +func mapString(values map[string]any, key string) string { + if values == nil { + return "" + } + value, _ := values[key].(string) + return strings.TrimSpace(value) +} + +func mapInt(values map[string]any, key string) int { + if values == nil { + return 0 + } + switch value := values[key].(type) { + case int: + return value + case int64: + return int(value) + case float64: + return int(value) + default: + return 0 + } +} + +func rewriteCodexSpawnAgentDescription(payload []byte, models []codexSpawnAgentModel) []byte { + return rewriteCodexSpawnAgentTools(payload, codexSpawnAgentToolPaths(payload), models) +} + +func rewriteCodexSpawnAgentTools(payload []byte, toolPaths []string, models []codexSpawnAgentModel) []byte { + if len(toolPaths) == 0 { + return payload + } + modelList := formatCodexSpawnAgentModels(models) + updated := payload + for _, toolPath := range toolPaths { + descriptionPath := toolPath + ".description" + description := gjson.GetBytes(updated, descriptionPath) + if description.Type == gjson.String && modelList != "" { + rewritten := replaceCodexSpawnAgentModels(description.String(), modelList) + if rewritten != description.String() { + var errSet error + updated, errSet = sjson.SetBytes(updated, descriptionPath, rewritten) + if errSet != nil { + return payload + } + } + } + + var errDelete error + updated, errDelete = sjson.DeleteBytes(updated, toolPath+".parameters.properties.message.encrypted") + if errDelete != nil { + return payload + } + } + return updated +} + +func hasCodexOptimizedCollaborationConflict(payload []byte) bool { + if codexToolsHaveOptimizedCollaborationConflict(gjson.GetBytes(payload, "tools")) { + return true + } + input := gjson.GetBytes(payload, "input") + if !input.IsArray() { + return false + } + for _, item := range input.Array() { + if strings.TrimSpace(item.Get("type").String()) == "additional_tools" && codexToolsHaveOptimizedCollaborationConflict(item.Get("tools")) { + return true + } + } + return false +} + +func codexToolsHaveOptimizedCollaborationConflict(tools gjson.Result) bool { + if !tools.IsArray() { + return false + } + for _, tool := range tools.Array() { + name := strings.TrimSpace(tool.Get("name").String()) + if name == codexOptimizedCollaborationNamespace || strings.HasPrefix(name, codexOptimizedCollaborationNamePrefix) { + return true + } + if strings.TrimSpace(tool.Get("type").String()) == "namespace" && codexToolsHaveOptimizedCollaborationConflict(tool.Get("tools")) { + return true + } + } + return false +} + +func optimizeCodexCollaborationNamespace(payload []byte, toolPaths []string) ([]byte, bool) { + updated := payload + optimized := false + for _, toolPath := range toolPaths { + separatorIndex := strings.LastIndex(toolPath, ".tools.") + if separatorIndex < 0 { + continue + } + namespacePath := toolPath[:separatorIndex] + namespace := gjson.GetBytes(updated, namespacePath) + if strings.TrimSpace(namespace.Get("type").String()) != "namespace" || strings.TrimSpace(namespace.Get("name").String()) != codexCollaborationNamespace { + continue + } + var errSet error + updated, errSet = sjson.SetBytes(updated, namespacePath+".name", codexOptimizedCollaborationNamespace) + if errSet != nil { + return payload, false + } + optimized = true + } + return updated, optimized +} + +// RestoreCodexMultiAgentV2Response restores optimized collaboration namespace +// values before an upstream response is translated and returned to the client. +func RestoreCodexMultiAgentV2Response(payload []byte, optimized bool) []byte { + if !optimized || len(payload) == 0 || !gjson.ValidBytes(payload) { + return payload + } + + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.UseNumber() + var value any + if errDecode := decoder.Decode(&value); errDecode != nil { + return payload + } + if !restoreCodexCollaborationValue(value) { + return payload + } + restored, errMarshal := json.Marshal(value) + if errMarshal != nil { + return payload + } + return restored +} + +func restoreCodexCollaborationValue(value any) bool { + changed := false + switch typed := value.(type) { + case []any: + for _, item := range typed { + if restoreCodexCollaborationValue(item) { + changed = true + } + } + case map[string]any: + itemType := strings.TrimSpace(mapString(typed, "type")) + isToolCall := itemType == "function_call" || itemType == "custom_tool_call" + if isToolCall { + if namespace, ok := typed["namespace"].(string); ok && namespace == codexOptimizedCollaborationNamespace { + typed["namespace"] = codexCollaborationNamespace + changed = true + } + } + if name, ok := typed["name"].(string); ok { + switch { + case name == codexOptimizedCollaborationNamespace && itemType == "namespace": + typed["name"] = codexCollaborationNamespace + changed = true + case isToolCall && strings.HasPrefix(name, codexOptimizedCollaborationNamePrefix): + typed["name"] = codexCollaborationNamespace + "__" + strings.TrimPrefix(name, codexOptimizedCollaborationNamePrefix) + changed = true + } + } + for key, child := range typed { + if key == "arguments" || key == "input" || key == "output" && (itemType == "function_call_output" || itemType == "custom_tool_call_output") { + continue + } + if restoreCodexCollaborationValue(child) { + changed = true + } + } + } + return changed +} + +func rewriteCodexAgentMessageInput(payload []byte) []byte { + input := gjson.GetBytes(payload, "input") + if !input.IsArray() { + return payload + } + + updated := rewriteCodexAgentMessageContent(payload) + for itemIndex, item := range input.Array() { + if strings.TrimSpace(item.Get("type").String()) != "agent_message" { + continue + } + itemPath := fmt.Sprintf("input.%d", itemIndex) + var errSet error + updated, errSet = sjson.SetBytes(updated, itemPath+".role", "user") + if errSet != nil { + return payload + } + updated, errSet = sjson.SetBytes(updated, itemPath+".type", "message") + if errSet != nil { + return payload + } + } + return updated +} + +func rewriteCodexAgentMessageContent(payload []byte) []byte { + input := gjson.GetBytes(payload, "input") + if !input.IsArray() { + return payload + } + + updated := payload + for itemIndex, item := range input.Array() { + if strings.TrimSpace(item.Get("type").String()) != "agent_message" { + continue + } + content := item.Get("content") + if !content.IsArray() { + continue + } + for partIndex, part := range content.Array() { + if strings.TrimSpace(part.Get("type").String()) != "encrypted_content" { + continue + } + encryptedContent := part.Get("encrypted_content") + if encryptedContent.Type != gjson.String { + continue + } + partPath := fmt.Sprintf("input.%d.content.%d", itemIndex, partIndex) + var errSet error + updated, errSet = sjson.SetBytes(updated, partPath+".type", "input_text") + if errSet != nil { + return payload + } + updated, errSet = sjson.SetBytes(updated, partPath+".text", encryptedContent.String()) + if errSet != nil { + return payload + } + updated, errSet = sjson.DeleteBytes(updated, partPath+".encrypted_content") + if errSet != nil { + return payload + } + } + } + return updated +} + +func codexSpawnAgentToolPaths(payload []byte) []string { + paths := make([]string, 0, 1) + collectCodexSpawnAgentToolPaths(gjson.GetBytes(payload, "tools"), "tools", &paths) + + input := gjson.GetBytes(payload, "input") + if input.IsArray() { + for index, item := range input.Array() { + if strings.TrimSpace(item.Get("type").String()) != "additional_tools" { + continue + } + collectCodexSpawnAgentToolPaths(item.Get("tools"), fmt.Sprintf("input.%d.tools", index), &paths) + } + } + return paths +} + +func collectCodexSpawnAgentToolPaths(tools gjson.Result, path string, paths *[]string) { + if !tools.IsArray() { + return + } + for index, tool := range tools.Array() { + toolPath := fmt.Sprintf("%s.%d", path, index) + toolType := strings.TrimSpace(tool.Get("type").String()) + if toolType == "function" && strings.TrimSpace(tool.Get("name").String()) == "spawn_agent" { + *paths = append(*paths, toolPath) + } + if toolType == "namespace" { + collectCodexSpawnAgentToolPaths(tool.Get("tools"), toolPath+".tools", paths) + } + } +} + +func formatCodexSpawnAgentModels(models []codexSpawnAgentModel) string { + var modelList strings.Builder + for _, model := range models { + modelID := strings.Join(strings.Fields(model.id), " ") + if modelID == "" { + continue + } + modelList.WriteString("- ") + modelList.WriteString(markdownCode(modelID)) + modelList.WriteString(": ") + hasDetails := false + if description := strings.Join(strings.Fields(model.description), " "); description != "" { + writeSentence(&modelList, description) + hasDetails = true + } + if len(model.reasoningEfforts) > 0 { + if hasDetails { + modelList.WriteByte(' ') + } + modelList.WriteString("Reasoning efforts: ") + for index, effort := range model.reasoningEfforts { + if index > 0 { + modelList.WriteString(", ") + } + modelList.WriteString(effort) + if effort == model.defaultReasoningEffort { + modelList.WriteString(" (default)") + } + } + modelList.WriteByte('.') + hasDetails = true + } + if len(model.serviceTiers) > 0 { + if hasDetails { + modelList.WriteByte(' ') + } + modelList.WriteString("Service tiers: ") + modelList.WriteString(strings.Join(model.serviceTiers, ", ")) + modelList.WriteByte('.') + } + modelList.WriteByte('\n') + } + return strings.TrimSuffix(modelList.String(), "\n") +} + +func markdownCode(value string) string { + if strings.Contains(value, "`") { + return "`` " + value + " ``" + } + return "`" + value + "`" +} + +func writeSentence(builder *strings.Builder, value string) { + builder.WriteString(value) + if !strings.ContainsAny(value[len(value)-1:], ".!?") { + builder.WriteByte('.') + } +} + +func replaceCodexSpawnAgentModels(description, modelList string) string { + if modelList == "" { + return description + } + + cleaned, headingIndent := removeCodexSpawnAgentModelSections(description) + section := headingIndent + codexSpawnAgentModelsHeading + "\n" + modelList + "\n" + markerIndex := strings.Index(cleaned, codexSpawnAgentDescriptionMarker) + if markerIndex >= 0 { + markerLineStart := strings.LastIndex(cleaned[:markerIndex], "\n") + 1 + return cleaned[:markerLineStart] + section + cleaned[markerLineStart:] + } + separator := "" + if cleaned != "" && !strings.HasSuffix(cleaned, "\n") { + separator = "\n\n" + } + return cleaned + separator + strings.TrimSuffix(section, "\n") +} + +func removeCodexSpawnAgentModelSections(description string) (string, string) { + lines := strings.SplitAfter(description, "\n") + var cleaned strings.Builder + headingIndent := "" + for index := 0; index < len(lines); { + line := lines[index] + trimmedLine := strings.TrimSpace(line) + if trimmedLine != codexSpawnAgentModelsHeading { + cleaned.WriteString(line) + index++ + continue + } + + if headingIndent == "" { + headingIndex := strings.Index(line, codexSpawnAgentModelsHeading) + if headingIndex > 0 { + headingIndent = line[:headingIndex] + } + } + index++ + for index < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[index]), "- ") { + index++ + } + } + return cleaned.String(), headingIndent +} diff --git a/internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2_test.go b/internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2_test.go new file mode 100644 --- /dev/null +++ b/internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2_test.go @@ -0,0 +1,589 @@ +package multiagentv2 + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestIsCodexMultiAgentClient(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + userAgent string + want bool + }{ + { + name: "Codex Desktop", + userAgent: "Codex Desktop/0.146.0-alpha.3 (Mac OS 26.5.2; arm64) unknown (Codex Desktop; 26.721.30844)", + want: true, + }, + { + name: "codex tui", + userAgent: "codex-tui/0.145.0 (Mac OS 26.5.2; arm64) iTerm.app/3.6.11 (codex-tui; 0.145.0)", + want: true, + }, + { + name: "other client", + userAgent: "curl/8.7.1", + want: false, + }, + { + name: "embedded token", + userAgent: "proxy Codex Desktop/0.146.0", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := isCodexMultiAgentClient(tt.userAgent); got != tt.want { + t.Fatalf("isCodexMultiAgentClient(%q) = %v, want %v", tt.userAgent, got, tt.want) + } + }) + } +} + +func TestCodexSpawnAgentModelsFromSourcesIncludesModelMetadata(t *testing.T) { + t.Parallel() + + catalog := []byte(`{"models":[ + {"slug":"model-template","display_name":"Template","description":"Template model.","default_reasoning_level":"low","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"}],"service_tiers":[{"id":"priority"}],"priority":1}, + {"slug":"gpt-5.5","display_name":"Default","description":"Default model.","default_reasoning_level":"medium","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"}],"service_tiers":[{"id":"priority"}],"priority":2} + ]}`) + available := []map[string]any{ + {"id": "custom-model", "display_name": "Custom", "description": "Registry description."}, + {"id": "model-template"}, + {"id": "custom-model", "description": "duplicate"}, + } + lookup := func(modelID string) *registry.ModelInfo { + if modelID != "custom-model" { + return nil + } + return ®istry.ModelInfo{ + Description: "Dynamic model.", + Thinking: ®istry.ThinkingSupport{ + Levels: []string{"none", "low", "medium", "high"}, + }, + } + } + + models := codexSpawnAgentModelsFromSources(available, catalog, lookup) + if len(models) != 2 { + t.Fatalf("model count = %d, want 2", len(models)) + } + if got := models[0]; got.id != "model-template" || got.description != "Template model." || got.defaultReasoningEffort != "low" { + t.Fatalf("template model = %+v", got) + } + if got := strings.Join(models[0].serviceTiers, ","); got != "priority" { + t.Fatalf("template service tiers = %q, want priority", got) + } + custom := models[1] + if custom.id != "custom-model" || custom.description != "Dynamic model." { + t.Fatalf("custom model = %+v", custom) + } + if got := strings.Join(custom.reasoningEfforts, ","); got != "none,low,medium,high" { + t.Fatalf("custom reasoning efforts = %q", got) + } + if custom.defaultReasoningEffort != "medium" { + t.Fatalf("custom default reasoning effort = %q, want medium", custom.defaultReasoningEffort) + } + if len(custom.serviceTiers) != 0 { + t.Fatalf("custom service tiers = %v, want none", custom.serviceTiers) + } +} + +func TestDecodeCodexHomeAvailableModels(t *testing.T) { + t.Parallel() + + raw := []byte(`{ + "codex":[{"id":"model-b","display_name":"Model B"},{"id":"model-a"}], + "other":[{"name":"models/model-c","displayName":"Model C"},{"id":"model-a","display_name":"duplicate"}] + }`) + models := decodeCodexHomeAvailableModels(raw) + if len(models) != 3 { + t.Fatalf("model count = %d, want 3", len(models)) + } + if got := mapString(models[0], "id"); got != "model-a" { + t.Fatalf("first model ID = %q, want model-a", got) + } + if got := mapString(models[1], "description"); got != "Model B" { + t.Fatalf("model-b description = %q, want Model B", got) + } + if got := mapString(models[2], "id"); got != "model-c" { + t.Fatalf("last model ID = %q, want model-c", got) + } + if got := decodeCodexHomeAvailableModels([]byte(`{"error":{"type":"no_credentials"}}`)); got != nil { + t.Fatalf("error envelope decoded as models: %#v", got) + } +} + +func TestRewriteCodexSpawnAgentDescriptionNormalizesModelList(t *testing.T) { + t.Parallel() + + payload := []byte(`{ + "input":[{ + "type":"additional_tools", + "role":"developer", + "tools":[{ + "type":"namespace", + "name":"collaboration", + "tools":[ + {"type":"function","name":"send_message","description":"unchanged"}, + {"type":"function","name":"spawn_agent","description":"\n Available model overrides (optional; inherited parent model is preferred):\n- old duplicate\n- old duplicate\n Spawns an agent to work on a task.","parameters":{"type":"object","properties":{"message":{"type":"string","encrypted":true}}}} + ] + }] + }] + }`) + models := []codexSpawnAgentModel{ + { + id: "model-alpha", + description: "Alpha model.", + reasoningEfforts: []string{"low", "medium", "high"}, + defaultReasoningEffort: "medium", + serviceTiers: []string{"priority"}, + }, + { + id: "model-beta", + description: "Beta model", + reasoningEfforts: []string{"low", "high"}, + defaultReasoningEffort: "low", + }, + } + + got := rewriteCodexSpawnAgentDescription(payload, models) + description := gjson.GetBytes(got, "input.0.tools.0.tools.1.description").String() + wantAlpha := "- `model-alpha`: Alpha model. Reasoning efforts: low, medium (default), high. Service tiers: priority." + wantBeta := "- `model-beta`: Beta model. Reasoning efforts: low (default), high." + if !strings.Contains(description, wantAlpha) || !strings.Contains(description, wantBeta) { + t.Fatalf("description does not contain model metadata:\n%s", description) + } + if strings.Contains(description, "old duplicate") { + t.Fatalf("stale model list was not replaced: %q", description) + } + for _, modelID := range []string{"model-alpha", "model-beta"} { + if count := strings.Count(description, "`"+modelID+"`"); count != 1 { + t.Fatalf("model %q reference count = %d, want 1", modelID, count) + } + } + if strings.Index(description, "`model-beta`") > strings.Index(description, codexSpawnAgentDescriptionMarker) { + t.Fatalf("model list was not inserted before spawn instructions: %q", description) + } + if gotDescription := gjson.GetBytes(got, "input.0.tools.0.tools.0.description").String(); gotDescription != "unchanged" { + t.Fatalf("non-spawn tool description = %q, want unchanged", gotDescription) + } + if encrypted := gjson.GetBytes(got, "input.0.tools.0.tools.1.parameters.properties.message.encrypted"); encrypted.Exists() { + t.Fatalf("spawn_agent message encrypted was not removed: %s", encrypted.Raw) + } +} + +func TestRewriteCodexSpawnAgentDescriptionTopLevelWithoutMarker(t *testing.T) { + t.Parallel() + + payload := []byte(`{"tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","description":"Create a worker."}]}]}`) + models := []codexSpawnAgentModel{{ + id: "model-a", + description: "Model A.", + reasoningEfforts: []string{"medium"}, + defaultReasoningEffort: "medium", + }} + got := rewriteCodexSpawnAgentDescription(payload, models) + description := gjson.GetBytes(got, "tools.0.tools.0.description").String() + + wantSuffix := codexSpawnAgentModelsHeading + "\n- `model-a`: Model A. Reasoning efforts: medium (default)." + if !strings.HasPrefix(description, "Create a worker.\n\n") || !strings.HasSuffix(description, wantSuffix) { + t.Fatalf("description = %q, want original text followed by model list", description) + } +} + +func TestCodexSpawnAgentToolPathsIgnoreInvalidContainers(t *testing.T) { + t.Parallel() + + payload := []byte(`{ + "input":[{"type":"message","tools":[{"type":"function","name":"spawn_agent","description":"message"}]}], + "tools":[ + {"type":"function","name":"wrapper","tools":[{"type":"function","name":"spawn_agent","description":"child"}]}, + {"type":"custom","name":"spawn_agent","description":"custom"}, + {"type":"namespace","name":"spawn_agent","description":"namespace"} + ] + }`) + if paths := codexSpawnAgentToolPaths(payload); len(paths) != 0 { + t.Fatalf("invalid container paths = %v, want none", paths) + } +} + +func TestOptimizeCodexMultiAgentV2RequestSkipsNamespaceConflict(t *testing.T) { + t.Parallel() + + payload := []byte(`{"tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]},{"type":"namespace","name":"collaboration-optimize","tools":[]}]}`) + headers := http.Header{"User-Agent": []string{"codex-tui/0.145.0"}} + cfg := &config.Config{Codex: config.CodexConfig{OptimizeMultiAgentV2: true}} + got, optimized := OptimizeCodexMultiAgentV2Request(context.Background(), headers, payload, cfg) + if optimized { + t.Fatal("namespace conflict unexpectedly enabled optimization") + } + if string(got) != string(payload) { + t.Fatalf("namespace conflict changed payload: %s", got) + } +} + +func TestOptimizeCodexCollaborationNamespaceWithoutModels(t *testing.T) { + t.Parallel() + + payload := []byte(`{"tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}]}`) + toolPaths := codexSpawnAgentToolPaths(payload) + got, optimized := optimizeCodexCollaborationNamespace(payload, toolPaths) + if !optimized { + t.Fatal("collaboration namespace was not optimized") + } + if namespace := gjson.GetBytes(got, "tools.0.name").String(); namespace != codexOptimizedCollaborationNamespace { + t.Fatalf("namespace = %q, want collaboration-optimize", namespace) + } +} + +func TestRewriteCodexSpawnAgentDescriptionWithoutModelsStillRemovesEncrypted(t *testing.T) { + t.Parallel() + + payload := []byte(`{"tools":[{"type":"function","name":"spawn_agent","description":"unchanged","parameters":{"properties":{"message":{"encrypted":true}}}}]}`) + got := rewriteCodexSpawnAgentDescription(payload, nil) + if description := gjson.GetBytes(got, "tools.0.description").String(); description != "unchanged" { + t.Fatalf("description = %q, want unchanged", description) + } + if encrypted := gjson.GetBytes(got, "tools.0.parameters.properties.message.encrypted"); encrypted.Exists() { + t.Fatalf("message encrypted was not removed: %s", encrypted.Raw) + } +} + +func TestRewriteCodexSpawnAgentDescriptionLeavesPayloadWithoutToolUnchanged(t *testing.T) { + t.Parallel() + + payload := []byte(`{"tools":[{"type":"function","name":"other","description":"unchanged"}]}`) + models := []codexSpawnAgentModel{{id: "model-a", description: "Model A."}} + got := rewriteCodexSpawnAgentDescription(payload, models) + if string(got) != string(payload) { + t.Fatalf("payload changed without spawn_agent tool: %s", got) + } +} + +func TestRewriteCodexSpawnAgentDescriptionEnabledOptimizesTool(t *testing.T) { + modelID := "codex-spawn-agent-test-model" + clientID := "codex-spawn-agent-test-client" + modelRegistry := registry.GetGlobalRegistry() + modelRegistry.RegisterClient(clientID, "codex", []*registry.ModelInfo{{ + ID: modelID, + Description: "Test agent model.", + Thinking: ®istry.ThinkingSupport{ + Levels: []string{"low", "medium", "high"}, + }, + }}) + defer modelRegistry.UnregisterClient(clientID) + + payload := []byte(`{"tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","description":"Spawns an agent.","parameters":{"properties":{"message":{"type":"string","encrypted":true}}}}]}]}`) + headers := http.Header{"User-Agent": []string{"Codex Desktop/0.146.0-alpha.3"}} + cfg := &config.Config{Codex: config.CodexConfig{OptimizeMultiAgentV2: true}} + got, optimized := OptimizeCodexMultiAgentV2Request(context.Background(), headers, payload, cfg) + if !optimized { + t.Fatal("collaboration namespace was not marked optimized") + } + if namespace := gjson.GetBytes(got, "tools.0.name").String(); namespace != codexOptimizedCollaborationNamespace { + t.Fatalf("namespace = %q, want %q", namespace, codexOptimizedCollaborationNamespace) + } + description := gjson.GetBytes(got, "tools.0.tools.0.description").String() + want := "- `" + modelID + "`: Test agent model. Reasoning efforts: low, medium (default), high." + if !strings.Contains(description, want) { + t.Fatalf("description does not contain dynamic model metadata: %q", description) + } + if encrypted := gjson.GetBytes(got, "tools.0.tools.0.parameters.properties.message.encrypted"); encrypted.Exists() { + t.Fatalf("spawn_agent message encrypted was not removed: %s", encrypted.Raw) + } +} + +func TestOptimizeCodexMultiAgentV2RequestNormalizesAgentMessageContentOnly(t *testing.T) { + t.Parallel() + + payload := []byte(`{"input":[{"type":"agent_message","id":"amsg_1","author":"/root","recipient":"/root/worker","content":[{"type":"input_text","text":"Payload:\n"},{"type":"encrypted_content","encrypted_content":"delegated task"}],"internal_chat_message_metadata_passthrough":{"turn_id":"turn_1"}}]}`) + headers := http.Header{"User-Agent": []string{"Codex Desktop/0.146.0-alpha.3"}} + cfg := &config.Config{Codex: config.CodexConfig{OptimizeMultiAgentV2: true}} + got, namespaceOptimized := OptimizeCodexMultiAgentV2Request(context.Background(), headers, payload, cfg) + if namespaceOptimized { + t.Fatal("payload without spawn_agent unexpectedly optimized a namespace") + } + message := gjson.GetBytes(got, "input.0") + if message.Get("type").String() != "agent_message" || message.Get("role").Exists() { + t.Fatalf("outer agent message changed: %s", got) + } + if message.Get("content.1.type").String() != "input_text" || message.Get("content.1.text").String() != "delegated task" { + t.Fatalf("encrypted content was not normalized: %s", got) + } + if message.Get("content.1.encrypted_content").Exists() { + t.Fatalf("encrypted_content was preserved: %s", got) + } + if message.Get("author").String() != "/root" || message.Get("recipient").String() != "/root/worker" || message.Get("internal_chat_message_metadata_passthrough.turn_id").String() != "turn_1" { + t.Fatalf("agent message metadata changed: %s", got) + } + + for _, tt := range []struct { + name string + headers http.Header + cfg *config.Config + }{ + {name: "disabled", headers: headers, cfg: &config.Config{}}, + {name: "unrelated client", headers: http.Header{"User-Agent": []string{"curl/8.7.1"}}, cfg: cfg}, + } { + t.Run(tt.name, func(t *testing.T) { + unchanged, _ := OptimizeCodexMultiAgentV2Request(context.Background(), tt.headers, payload, tt.cfg) + if string(unchanged) != string(payload) { + t.Fatalf("ineligible request changed: %s", unchanged) + } + }) + } +} + +func TestRestoreCodexMultiAgentV2Response(t *testing.T) { + t.Parallel() + + payload := []byte(`{ + "type":"response.completed", + "response":{ + "output":[ + {"type":"function_call","name":"spawn_agent","namespace":"collaboration-optimize","arguments":{"namespace":"collaboration-optimize","name":"collaboration-optimize__opaque"}}, + {"type":"function_call","name":"collaboration-optimize__send_message"}, + {"type":"message","namespace":"collaboration-optimize","name":"collaboration-optimize__plain"} + ], + "tools":[{"type":"namespace","name":"collaboration-optimize"}] + } + }`) + got := RestoreCodexMultiAgentV2Response(payload, true) + if namespace := gjson.GetBytes(got, "response.output.0.namespace").String(); namespace != codexCollaborationNamespace { + t.Fatalf("function namespace = %q, want collaboration", namespace) + } + if name := gjson.GetBytes(got, "response.output.1.name").String(); name != "collaboration__send_message" { + t.Fatalf("qualified function name = %q, want collaboration__send_message", name) + } + if name := gjson.GetBytes(got, "response.tools.0.name").String(); name != codexCollaborationNamespace { + t.Fatalf("namespace tool name = %q, want collaboration", name) + } + if namespace := gjson.GetBytes(got, "response.output.0.arguments.namespace").String(); namespace != codexOptimizedCollaborationNamespace { + t.Fatalf("opaque arguments namespace was unexpectedly rewritten: %q", namespace) + } + if namespace := gjson.GetBytes(got, "response.output.2.namespace").String(); namespace != codexOptimizedCollaborationNamespace { + t.Fatalf("ordinary namespace field was unexpectedly rewritten: %q", namespace) + } + if name := gjson.GetBytes(got, "response.output.2.name").String(); name != "collaboration-optimize__plain" { + t.Fatalf("ordinary name field was unexpectedly rewritten: %q", name) + } + if unchanged := RestoreCodexMultiAgentV2Response(payload, false); string(unchanged) != string(payload) { + t.Fatalf("inactive restore changed payload: %s", unchanged) + } +} + +func TestRewriteCodexMultiAgentV2InputRewritesAgentMessage(t *testing.T) { + t.Parallel() + + payload := []byte(`{"model":"gpt-5.4","input":[{ + "type":"agent_message", + "id":"amsg_019f92ae-84fd-76f0-aa66-5a722dee382e", + "author":"/root", + "recipient":"/root/arithmetic_problem", + "content":[ + {"type":"input_text","text":"Message Type: NEW_TASK\nTask name: /root/arithmetic_problem\nSender: /root\nPayload:\n"}, + {"type":"encrypted_content","encrypted_content":"请出一道四则运算题,并给出答案。全程使用简体中文,题目简洁。"} + ], + "internal_chat_message_metadata_passthrough":{"turn_id":"019f92ae-7eae-7371-957e-8f6f734edddc"} + }]}`) + headers := http.Header{"User-Agent": []string{"Codex Desktop/0.146.0-alpha.3"}} + cfg := &config.Config{Codex: config.CodexConfig{OptimizeMultiAgentV2: true}} + got := RewriteCodexMultiAgentV2Input(context.Background(), headers, payload, cfg) + + if messageType := gjson.GetBytes(got, "input.0.type").String(); messageType != "message" { + t.Fatalf("type = %q, want message; payload=%s", messageType, got) + } + if role := gjson.GetBytes(got, "input.0.role").String(); role != "user" { + t.Fatalf("role = %q, want user; payload=%s", role, got) + } + if partType := gjson.GetBytes(got, "input.0.content.1.type").String(); partType != "input_text" { + t.Fatalf("content[1].type = %q, want input_text; payload=%s", partType, got) + } + if text := gjson.GetBytes(got, "input.0.content.1.text").String(); text != "请出一道四则运算题,并给出答案。全程使用简体中文,题目简洁。" { + t.Fatalf("content[1].text = %q; payload=%s", text, got) + } + if encrypted := gjson.GetBytes(got, "input.0.content.1.encrypted_content"); encrypted.Exists() { + t.Fatalf("content[1].encrypted_content was preserved: %s", got) + } + if author := gjson.GetBytes(got, "input.0.author").String(); author != "/root" { + t.Fatalf("author = %q, want /root", author) + } + if turnID := gjson.GetBytes(got, "input.0.internal_chat_message_metadata_passthrough.turn_id").String(); turnID != "019f92ae-7eae-7371-957e-8f6f734edddc" { + t.Fatalf("turn_id = %q", turnID) + } +} + +func TestRewriteCodexMultiAgentV2InputConditions(t *testing.T) { + t.Parallel() + + payload := []byte(`{"input":[{"type":"agent_message","content":[{"type":"encrypted_content","encrypted_content":"task"}]}]}`) + tests := []struct { + name string + cfg *config.Config + userAgent string + want bool + }{ + { + name: "Codex Desktop enabled", + cfg: &config.Config{Codex: config.CodexConfig{OptimizeMultiAgentV2: true}}, + userAgent: "Codex Desktop/0.146.0-alpha.3", + want: true, + }, + { + name: "codex tui enabled", + cfg: &config.Config{Codex: config.CodexConfig{OptimizeMultiAgentV2: true}}, + userAgent: "codex-tui/0.145.0", + want: true, + }, + { + name: "optimization disabled", + cfg: &config.Config{}, + userAgent: "codex-tui/0.145.0", + }, + { + name: "unrelated client", + cfg: &config.Config{Codex: config.CodexConfig{OptimizeMultiAgentV2: true}}, + userAgent: "curl/8.7.1", + }, + { + name: "nil config", + userAgent: "Codex Desktop/0.146.0-alpha.3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + headers := http.Header{"User-Agent": []string{tt.userAgent}} + got := RewriteCodexMultiAgentV2Input(context.Background(), headers, payload, tt.cfg) + if rewritten := gjson.GetBytes(got, "input.0.type").String() == "message"; rewritten != tt.want { + t.Fatalf("rewritten = %v, want %v; payload=%s", rewritten, tt.want, got) + } + }) + } +} + +func TestTranslateRequestWithCodexMultiAgentV2Conditions(t *testing.T) { + payload := []byte(`{"model":"test-model","input":[{"type":"agent_message","content":[{"type":"encrypted_content","encrypted_content":"task"}]}]}`) + enabledCfg := &config.Config{Codex: config.CodexConfig{OptimizeMultiAgentV2: true}} + eligibleHeaders := http.Header{"User-Agent": []string{"Codex Desktop/0.146.0-alpha.3"}} + + translations := []struct { + name string + to sdktranslator.Format + path string + want string + model string + }{ + {name: "Claude", to: sdktranslator.FormatClaude, path: "messages.0.content", want: "task", model: "claude-sonnet-4-5"}, + {name: "Gemini", to: sdktranslator.FormatGemini, path: "contents.0.parts.0.text", want: "task", model: "gemini-2.5-pro"}, + {name: "Antigravity", to: sdktranslator.FormatAntigravity, path: "request.contents.0.parts.0.text", want: "task", model: "gemini-2.5-pro"}, + {name: "OpenAI", to: sdktranslator.FormatOpenAI, path: "messages.0.content.0.text", want: "task", model: "chat-model"}, + {name: "Interactions", to: sdktranslator.FormatInteractions, path: "input.0.content.0.text", want: "task", model: "interaction-model"}, + } + for _, tt := range translations { + t.Run(tt.name, func(t *testing.T) { + got := TranslateRequestWithCodexMultiAgentV2(context.Background(), eligibleHeaders, enabledCfg, sdktranslator.FormatOpenAIResponse, tt.to, tt.model, payload, false) + if value := gjson.GetBytes(got, tt.path).String(); value != tt.want { + t.Fatalf("%s = %q, want %q; output=%s", tt.path, value, tt.want, got) + } + }) + } + + t.Run("disabled optimization", func(t *testing.T) { + got := TranslateRequestWithCodexMultiAgentV2(context.Background(), eligibleHeaders, &config.Config{}, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, "chat-model", payload, false) + if count := gjson.GetBytes(got, "messages.#").Int(); count != 0 { + t.Fatalf("disabled optimization translated agent_message; output=%s", got) + } + }) + t.Run("unrelated client", func(t *testing.T) { + headers := http.Header{"User-Agent": []string{"curl/8.7.1"}} + got := TranslateRequestWithCodexMultiAgentV2(context.Background(), headers, enabledCfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, "chat-model", payload, false) + if count := gjson.GetBytes(got, "messages.#").Int(); count != 0 { + t.Fatalf("unrelated client agent_message was translated; output=%s", got) + } + }) + t.Run("non-Responses source", func(t *testing.T) { + got := TranslateRequestWithCodexMultiAgentV2(context.Background(), eligibleHeaders, enabledCfg, sdktranslator.FormatOpenAI, sdktranslator.FormatOpenAI, "test-model", payload, false) + if messageType := gjson.GetBytes(got, "input.0.type").String(); messageType != "agent_message" { + t.Fatalf("non-Responses source changed agent_message; output=%s", got) + } + }) + for _, target := range []sdktranslator.Format{sdktranslator.FormatCodex, sdktranslator.FormatOpenAIResponse} { + t.Run("excluded target "+target.String(), func(t *testing.T) { + got := TranslateRequestWithCodexMultiAgentV2(context.Background(), eligibleHeaders, enabledCfg, sdktranslator.FormatOpenAIResponse, target, "test-model", payload, false) + if messageType := gjson.GetBytes(got, "input.0.type").String(); messageType != "agent_message" { + t.Fatalf("target %s changed agent_message; output=%s", target, got) + } + }) + } +} + +func TestRewriteCodexSpawnAgentDescriptionDisabledLeavesPayloadUnchanged(t *testing.T) { + t.Parallel() + + payload := []byte(`{"tools":[{"type":"function","name":"spawn_agent","description":"unchanged","parameters":{"properties":{"message":{"encrypted":true}}}}]}`) + headers := http.Header{"User-Agent": []string{"codex-tui/0.145.0"}} + got := RewriteCodexSpawnAgentDescription(context.Background(), headers, payload, &config.Config{}) + if string(got) != string(payload) { + t.Fatalf("disabled optimization changed payload: %s", got) + } +} + +func TestRewriteCodexSpawnAgentDescriptionIgnoresOtherUserAgent(t *testing.T) { + t.Parallel() + + payload := []byte(`{"tools":[{"type":"function","name":"spawn_agent","description":"unchanged"}]}`) + headers := http.Header{"User-Agent": []string{"curl/8.7.1"}} + cfg := &config.Config{Codex: config.CodexConfig{OptimizeMultiAgentV2: true}} + got := RewriteCodexSpawnAgentDescription(context.Background(), headers, payload, cfg) + if string(got) != string(payload) { + t.Fatalf("payload changed for unrelated User-Agent: %s", got) + } +} + +func TestReplaceCodexSpawnAgentModelsNormalizesSectionsAndPreservesInstructions(t *testing.T) { + t.Parallel() + + description := codexSpawnAgentModelsHeading + "\n- `old-model`: old\nKeep this multi-agent instruction.\nSpawns an agent.\n" + codexSpawnAgentModelsHeading + got := replaceCodexSpawnAgentModels(description, "- `new-model`: New model.") + if strings.Contains(got, "old-model") { + t.Fatalf("old model list was preserved: %q", got) + } + if count := strings.Count(got, codexSpawnAgentModelsHeading); count != 1 { + t.Fatalf("model heading count = %d, want 1: %q", count, got) + } + if !strings.Contains(got, "Keep this multi-agent instruction.") { + t.Fatalf("following instruction was removed: %q", got) + } +} + +func TestCodexClientUserAgentPrefersGinRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + request := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + request.Header.Set("User-Agent", "codex-tui/0.145.0") + ginCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ginCtx.Request = request + ctx := context.WithValue(context.Background(), "gin", ginCtx) + headers := http.Header{"User-Agent": []string{"overridden-client/1.0"}} + + if got := codexClientUserAgent(ctx, headers); got != "codex-tui/0.145.0" { + t.Fatalf("codexClientUserAgent() = %q, want gin request User-Agent", got) + } +} diff --git a/internal/runtime/executor/helps/codex_multi_agent_v2.go b/internal/runtime/executor/helps/codex_multi_agent_v2.go new file mode 100644 --- /dev/null +++ b/internal/runtime/executor/helps/codex_multi_agent_v2.go @@ -0,0 +1,40 @@ +package helps + +import ( + "context" + "net/http" + + multiagentv2 "github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/optimize-multi-agent-v2" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" +) + +// RewriteCodexSpawnAgentDescription optimizes spawn_agent definitions for +// official Codex clients when multi-agent v2 optimization is enabled. +func RewriteCodexSpawnAgentDescription(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config) []byte { + return multiagentv2.RewriteCodexSpawnAgentDescription(ctx, headers, payload, cfg) +} + +// RewriteCodexMultiAgentV2Input converts official Codex multi-agent input into +// standard Responses API messages when multi-agent v2 optimization is enabled. +func RewriteCodexMultiAgentV2Input(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config) []byte { + return multiagentv2.RewriteCodexMultiAgentV2Input(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 { + return multiagentv2.TranslateRequestWithCodexMultiAgentV2(ctx, headers, cfg, from, to, model, payload, stream) +} + +// OptimizeCodexMultiAgentV2Request rewrites an eligible spawn_agent request and +// reports whether the collaboration namespace was renamed for upstream use. +func OptimizeCodexMultiAgentV2Request(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config) ([]byte, bool) { + return multiagentv2.OptimizeCodexMultiAgentV2Request(ctx, headers, payload, cfg) +} + +// RestoreCodexMultiAgentV2Response restores optimized collaboration namespace +// values before an upstream response is translated and returned to the client. +func RestoreCodexMultiAgentV2Response(payload []byte, optimized bool) []byte { + return multiagentv2.RestoreCodexMultiAgentV2Response(payload, optimized) +} diff --git a/sdk/api/handlers/openai/codex_client_models.go b/sdk/api/handlers/openai/codex_client_models.go --- a/sdk/api/handlers/openai/codex_client_models.go +++ b/sdk/api/handlers/openai/codex_client_models.go @@ -35,20 +35,27 @@ } func (h *OpenAIAPIHandler) codexClientModelsResponse() map[string]any { - return codexClientModelsResponse(h.Models(), registry.GetGlobalRegistry().GetModelProviders) + optimizeMultiAgentV2 := h != nil && h.Cfg != nil && h.Cfg.CodexOptimizeMultiAgentV2 + return codexClientModelsResponse(h.Models(), registry.GetGlobalRegistry().GetModelProviders, optimizeMultiAgentV2) } func CodexClientModelsResponse(models []map[string]any) map[string]any { - return codexClientModelsResponse(models, nil) + return codexClientModelsResponse(models, nil, false) } -func codexClientModelsResponse(models []map[string]any, providersForModel codexClientModelProvidersFunc) map[string]any { +// CodexClientModelsResponseWithMultiAgentV2 builds the Codex client model response +// and advertises multi-agent v2 for synthesized models when enabled. +func CodexClientModelsResponseWithMultiAgentV2(models []map[string]any, enabled bool) map[string]any { + return codexClientModelsResponse(models, nil, enabled) +} + +func codexClientModelsResponse(models []map[string]any, providersForModel codexClientModelProvidersFunc, optimizeMultiAgentV2 bool) map[string]any { return map[string]any{ - "models": buildCodexClientModels(models, providersForModel), + "models": buildCodexClientModels(models, providersForModel, optimizeMultiAgentV2), } } -func buildCodexClientModels(models []map[string]any, providersForModel codexClientModelProvidersFunc) []map[string]any { +func buildCodexClientModels(models []map[string]any, providersForModel codexClientModelProvidersFunc, optimizeMultiAgentV2 bool) []map[string]any { templates, defaultTemplate, err := loadCodexClientModelTemplates() if err != nil || defaultTemplate == nil { return nil @@ -72,7 +79,7 @@ } entry := cloneCodexClientModelMap(defaultTemplate) - applyCodexClientModelMetadata(entry, id, model) + applyCodexClientModelMetadata(entry, id, model, optimizeMultiAgentV2) applyCodexClientSearchToolSupport(entry, id, false, providersForModel) sanitizeCodexClientReasoningMetadata(entry) applyCodexClientVisibilityOverride(entry, id) @@ -214,7 +221,7 @@ } } -func applyCodexClientModelMetadata(entry map[string]any, id string, model map[string]any) { +func applyCodexClientModelMetadata(entry map[string]any, id string, model map[string]any, optimizeMultiAgentV2 bool) { info := registry.LookupModelInfo(id) displayName := stringModelValue(model, "display_name") @@ -252,6 +259,9 @@ entry["display_name"] = displayName entry["description"] = description entry["prefer_websockets"] = false + if optimizeMultiAgentV2 { + entry["multi_agent_version"] = "v2" + } entry["service_tiers"] = []any{} delete(entry, "apply_patch_tool_type") delete(entry, "upgrade") diff --git a/sdk/api/handlers/openai/codex_client_models_test.go b/sdk/api/handlers/openai/codex_client_models_test.go --- a/sdk/api/handlers/openai/codex_client_models_test.go +++ b/sdk/api/handlers/openai/codex_client_models_test.go @@ -3,7 +3,9 @@ import ( "testing" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" ) func TestCodexClientModelsResponse_InputModalitiesFromRegistry(t *testing.T) { @@ -189,7 +191,7 @@ {"id": "gpt-5.6-sol"}, }, func(id string) []string { return providers[id] - }) + }, false) models, ok := resp["models"].([]map[string]any) if !ok { t.Fatalf("models type = %T, want []map[string]any", resp["models"]) @@ -291,5 +293,69 @@ } if got := stringModelValue(templates["gpt-5.5"], "display_name"); got != "Second" { t.Fatalf("cached display_name = %q, want Second", got) + } +} + +func TestApplyCodexClientModelMetadataPreservesMultiAgentVersionWhenDisabled(t *testing.T) { + entry := map[string]any{"multi_agent_version": "v1"} + model := map[string]any{"id": "custom-model"} + + applyCodexClientModelMetadata(entry, "custom-model", model, false) + if got := entry["multi_agent_version"]; got != "v1" { + t.Fatalf("disabled multi_agent_version = %#v, want preserved v1", got) + } + + applyCodexClientModelMetadata(entry, "custom-model", model, true) + if got := entry["multi_agent_version"]; got != "v2" { + t.Fatalf("enabled multi_agent_version = %#v, want v2", got) + } +} + +func TestCodexClientModelsResponseMultiAgentV2FollowsConfig(t *testing.T) { + modelID := "codex-client-multi-agent-v2-test" + clientID := "codex-client-multi-agent-v2-test-client" + modelRegistry := registry.GetGlobalRegistry() + modelRegistry.RegisterClient(clientID, "openai-compatibility", []*registry.ModelInfo{{ID: modelID}}) + t.Cleanup(func() { + modelRegistry.UnregisterClient(clientID) + }) + + base := handlers.NewBaseAPIHandlers(&config.SDKConfig{}, nil) + handler := NewOpenAIAPIHandler(base) + for _, tt := range []struct { + name string + enabled bool + }{ + {name: "disabled", enabled: false}, + {name: "enabled", enabled: true}, + } { + t.Run(tt.name, func(t *testing.T) { + base.Cfg.CodexOptimizeMultiAgentV2 = tt.enabled + response := handler.codexClientModelsResponse() + models, ok := response["models"].([]map[string]any) + if !ok { + t.Fatalf("models type = %T, want []map[string]any", response["models"]) + } + var entry map[string]any + for _, model := range models { + if stringModelValue(model, "slug") == modelID { + entry = model + break + } + } + if entry == nil { + t.Fatalf("missing synthesized model %q", modelID) + } + value, exists := entry["multi_agent_version"] + if tt.enabled { + if !exists || value != "v2" { + t.Fatalf("multi_agent_version = %#v, want v2", value) + } + return + } + if !exists || value != nil { + t.Fatalf("multi_agent_version = %#v, want preserved null", value) + } + }) } }