diff --git a/internal/runtime/executor/codex_executor_execute.go b/internal/runtime/executor/codex_executor_execute.go index 9637e5224..dd531b1ed 100644 --- a/internal/runtime/executor/codex_executor_execute.go +++ b/internal/runtime/executor/codex_executor_execute.go @@ -69,7 +69,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(ctx, "codex executor", body, isCompat) body = normalizeCodexParallelToolCalls(body, opts.Headers) body = helps.NormalizeCodexToolSchemas(body) - body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, baseModel) + body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, isCompat) body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body) if errReplay != nil { return resp, errReplay @@ -242,7 +242,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(ctx, "codex executor", body, isCompat) body = normalizeCodexParallelToolCalls(body, opts.Headers) body = helps.NormalizeCodexToolSchemas(body) - body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, baseModel) + body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, isCompat) reporter.SetTranslatedReasoningEffort(body, to.String()) url := strings.TrimSuffix(baseURL, "/") + "/responses/compact" diff --git a/internal/runtime/executor/codex_executor_spawn_agent_test.go b/internal/runtime/executor/codex_executor_spawn_agent_test.go index a5ba3475d..dde0cacb5 100644 --- a/internal/runtime/executor/codex_executor_spawn_agent_test.go +++ b/internal/runtime/executor/codex_executor_spawn_agent_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" "github.com/router-for-me/CLIProxyAPI/v8/internal/config" "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" @@ -202,6 +203,120 @@ func TestCodexExecutorIsCompatConvertsAgentMessage(t *testing.T) { } } +func TestCodexExecutorsMultiAgentV2UsesSelectedHomeModel(t *testing.T) { + capturedPayload := make(chan []byte, 1) + upgrader := websocket.Upgrader{} + completed := []byte(`{"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","output":[]}}`) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if websocket.IsWebSocketUpgrade(request) { + conn, errUpgrade := upgrader.Upgrade(w, request, nil) + if errUpgrade != nil { + t.Errorf("upgrade websocket: %v", errUpgrade) + return + } + defer func() { + if errClose := conn.Close(); errClose != nil { + t.Errorf("close websocket: %v", errClose) + } + }() + _, payload, errRead := conn.ReadMessage() + if errRead != nil { + t.Errorf("read websocket request: %v", errRead) + return + } + capturedPayload <- payload + if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil { + t.Errorf("write websocket response: %v", errWrite) + } + return + } + payload, errRead := io.ReadAll(request.Body) + if errRead != nil { + t.Errorf("read request: %v", errRead) + http.Error(w, errRead.Error(), http.StatusInternalServerError) + return + } + capturedPayload <- payload + if request.URL.Path == "/responses/compact" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"resp_1","object":"response.compaction","output":[]}`)) + return + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: %s\n\n", completed) + })) + t.Cleanup(server.Close) + + for _, tc := range []struct { + name, model string + isCompat bool + }{ + {name: "native alias", model: "gpt-5.4"}, + {name: "compat alias", model: "gpt-5.4", isCompat: true}, + {name: "native suffix", model: "gpt-5.4(high)"}, + {name: "compat suffix", model: "gpt-5.4(high)", isCompat: true}, + } { + for _, mode := range []string{"execute", "stream", "compact", "websocket execute", "websocket stream"} { + t.Run(tc.name+"/"+mode, func(t *testing.T) { + cfg := &config.Config{Home: config.HomeConfig{Enabled: true}, Codex: config.CodexConfig{OptimizeMultiAgentV2: true}} + var executor cliproxyauth.ProviderExecutor = NewCodexExecutor(cfg) + if strings.HasPrefix(mode, "websocket") { + executor = NewCodexWebsocketsExecutor(cfg) + } + auth := &cliproxyauth.Auth{ + ID: t.Name(), Provider: "codex", Prefix: "tenant", + Attributes: map[string]string{ + "base_url": server.URL, "api_key": "test", "auth_kind": cliproxyauth.AuthKindAPIKey, + "home_upstream_model": tc.model, + }, + Metadata: map[string]any{"credential_options": map[string]any{"models": []config.CodexModel{ + {Name: "gpt-5.4", Alias: "other", IsCompat: !tc.isCompat}, + {Name: tc.model, Alias: "chosen", IsCompat: tc.isCompat}, + }}}, + } + req := cliproxyexecutor.Request{ + Model: tc.model, Payload: codexSpawnAgentTestPayload(), + Metadata: map[string]any{"cliproxy.resolved_home_model_info": ®istry.ModelInfo{ + ID: "gpt-5.4", IsCompat: tc.isCompat, Thinking: ®istry.ThinkingSupport{Levels: []string{"high"}}, + }}, + } + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("openai-response")} + ctx := codexSpawnAgentTestContext() + if strings.HasSuffix(mode, "stream") { + result, errStream := executor.ExecuteStream(ctx, auth, req, opts) + if errStream != nil { + t.Fatal(errStream) + } + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatal(chunk.Err) + } + } + } else { + if mode == "compact" { + opts.Alt = "responses/compact" + } + if _, errExecute := executor.Execute(ctx, auth, req, opts); errExecute != nil { + t.Fatal(errExecute) + } + } + upstreamPayload := <-capturedPayload + message := gjson.GetBytes(upstreamPayload, "input.1") + if tc.isCompat { + if message.Get("type").String() != "message" || message.Get("role").String() != "user" { + t.Fatalf("selected compat model must receive a user message: %s", message.Raw) + } + if message.Get("author").Exists() || message.Get("recipient").Exists() || message.Get("internal_chat_message_metadata_passthrough").Exists() { + t.Fatalf("compat message retained internal metadata: %s", message.Raw) + } + } else { + assertCodexSpawnAgentRequestMessage(t, upstreamPayload, true) + } + }) + } + } +} + func TestCodexExecutor_IsCompat_StripsAuthorAndRecipient_Issue6136(t *testing.T) { var upstreamBody []byte server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { diff --git a/internal/runtime/executor/codex_executor_stream.go b/internal/runtime/executor/codex_executor_stream.go index bcacb36f1..2dd99283c 100644 --- a/internal/runtime/executor/codex_executor_stream.go +++ b/internal/runtime/executor/codex_executor_stream.go @@ -77,7 +77,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(ctx, "codex executor", body, isCompat) body = normalizeCodexParallelToolCalls(body, opts.Headers) body = helps.NormalizeCodexToolSchemas(body) - body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, baseModel) + body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, isCompat) body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body) if errReplay != nil { return nil, errReplay diff --git a/internal/runtime/executor/codex_websockets_execute.go b/internal/runtime/executor/codex_websockets_execute.go index 806b31288..b7ce4ca15 100644 --- a/internal/runtime/executor/codex_websockets_execute.go +++ b/internal/runtime/executor/codex_websockets_execute.go @@ -67,7 +67,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut body = normalizeCodexWebsocketParallelToolCalls(body, opts.Headers) body = helps.NormalizeCodexToolSchemas(body) multiAgentV2Conflict := helps.HasCodexMultiAgentV2NamespaceConflict(body) - body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, baseModel) + body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, isCompat) body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body) if errReplay != nil { return resp, errReplay diff --git a/internal/runtime/executor/codex_websockets_stream.go b/internal/runtime/executor/codex_websockets_stream.go index 1c603c780..f723fe681 100644 --- a/internal/runtime/executor/codex_websockets_stream.go +++ b/internal/runtime/executor/codex_websockets_stream.go @@ -786,7 +786,7 @@ func (e *CodexWebsocketsExecutor) prepareCodexWebsocketStream(ctx context.Contex body = normalizeCodexWebsocketParallelToolCalls(body, opts.Headers) body = helps.NormalizeCodexToolSchemas(body) multiAgentV2Conflict := helps.HasCodexMultiAgentV2NamespaceConflict(body) - body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, baseModel) + body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, isCompat) body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body) if errReplay != nil { return nil, errReplay diff --git a/internal/runtime/executor/helps/codex_multi_agent_v2.go b/internal/runtime/executor/helps/codex_multi_agent_v2.go index d2dddaf54..e0cdacd77 100644 --- a/internal/runtime/executor/helps/codex_multi_agent_v2.go +++ b/internal/runtime/executor/helps/codex_multi_agent_v2.go @@ -166,16 +166,16 @@ func OptimizeCodexMultiAgentV2Request(ctx context.Context, headers http.Header, } // OptimizeCodexMultiAgentV2RequestForAuth applies the standard Codex MultiAgentV2 -// request optimization and, when the selected codex-api-key model has is-compat -// enabled, also converts agent_message items into portable message/user input, +// request optimization and uses the execution attempt's resolved compatibility +// flag to convert agent_message items into portable message/user input, // proactively stripping author, recipient, and internal passthrough metadata. -func OptimizeCodexMultiAgentV2RequestForAuth(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config, auth *cliproxyauth.Auth, model string) ([]byte, bool) { +func OptimizeCodexMultiAgentV2RequestForAuth(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config, auth *cliproxyauth.Auth, isCompat bool) ([]byte, bool) { if auth != nil && auth.AuthKind() == cliproxyauth.AuthKindAPIKey { cfg = cfg.ForAPIKey() } payload = multiagentv2.RewriteCodexOrphanDelegationInputForConfig(ctx, headers, payload, cfg) updated, optimized := multiagentv2.OptimizeCodexMultiAgentV2Request(ctx, headers, payload, cfg) - if cliproxyauth.CodexAPIKeyModelIsCompat(cfg, auth, model) { + if isCompat { updated = multiagentv2.RewriteCodexMultiAgentV2Input(ctx, headers, updated, cfg, true) } return updated, optimized