From e5ea945ed93dd3bd1439952fc3032b1ee25ac5ec Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 6 Aug 2026 04:49:28 +0800 Subject: [PATCH] feat(codex): add model-level `is-compat` flag to rewrite MultiAgentV2 `agent_message` for Responses-compatible endpoints Closes: #4801 --- config.example.yaml | 5 + internal/config/config_types.go | 7 ++ internal/modelconfig/model_hash.go | 2 +- .../executor/codex_executor_execute.go | 4 +- .../codex_executor_spawn_agent_test.go | 95 +++++++++++++++++++ .../runtime/executor/codex_executor_stream.go | 2 +- .../executor/codex_websockets_execute.go | 2 +- .../executor/codex_websockets_stream.go | 2 +- .../executor/helps/codex_multi_agent_v2.go | 12 +++ internal/watcher/diff/models_summary.go | 6 +- .../auth/api_key_model_capabilities.go | 40 ++++++++ .../auth/api_key_model_capabilities_test.go | 41 ++++++++ 12 files changed, 211 insertions(+), 7 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 17fc3169b..d8fbed3c6 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -351,6 +351,11 @@ nonstream-keepalive-interval: 0 # display-name: "Codex Latest" # optional catalog display name # max-context-length: 1048576 # optional: override Codex client context window metadata # force-mapping: true # optional: rewrite response model fields back to the alias +# # When true and codex.optimize-multi-agent-v2 is also true, convert Codex +# # MultiAgentV2 agent_message items into portable Responses message/user input +# # for third-party Responses-compatible endpoints that reject agent_message. +# # Default false keeps agent_message unchanged for native OpenAI/Codex endpoints. +# is-compat: false # thinking: # optional: exact thinking capability for this configured model # levels: ["xhigh", "high", "medium", "low"] # excluded-models: diff --git a/internal/config/config_types.go b/internal/config/config_types.go index ca71086dd..c2c0a5478 100644 --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -470,6 +470,12 @@ type CodexModel struct { // ForceMapping rewrites upstream response model fields back to Alias. ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"` + // IsCompat converts Codex MultiAgentV2 agent_message items into portable + // Responses message/user input when codex.optimize-multi-agent-v2 is also true. + // Use this for third-party Responses-compatible endpoints that do not accept + // native agent_message items. Default false keeps agent_message unchanged. + IsCompat bool `yaml:"is-compat,omitempty" json:"is-compat,omitempty"` + // Thinking configures the thinking/reasoning capability for this model. Thinking *registry.ThinkingSupport `yaml:"thinking,omitempty" json:"thinking,omitempty"` } @@ -481,6 +487,7 @@ func (m CodexModel) GetAlias() string { return m.Alias } func (m CodexModel) GetDisplayName() string { return m.DisplayName } func (m CodexModel) GetMaxContextLength() int { return m.MaxContextLength } func (m CodexModel) GetForceMapping() bool { return m.ForceMapping } +func (m CodexModel) GetIsCompat() bool { return m.IsCompat } func (m CodexModel) GetThinking() *registry.ThinkingSupport { return m.Thinking } diff --git a/internal/modelconfig/model_hash.go b/internal/modelconfig/model_hash.go index 348204be8..ed5575125 100644 --- a/internal/modelconfig/model_hash.go +++ b/internal/modelconfig/model_hash.go @@ -65,7 +65,7 @@ func ComputeCodexModelsHash(models []config.CodexModel) string { if name == "" && alias == "" { continue } - out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|" + fmt.Sprintf("force-mapping=%t", model.ForceMapping) + thinkingHashSuffix(model.Thinking)) + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|" + fmt.Sprintf("force-mapping=%t", model.ForceMapping) + "|" + fmt.Sprintf("is-compat=%t", model.IsCompat) + thinkingHashSuffix(model.Thinking)) } }) return hashJoined(keys) diff --git a/internal/runtime/executor/codex_executor_execute.go b/internal/runtime/executor/codex_executor_execute.go index a5305ff6a..c6b96edbb 100644 --- a/internal/runtime/executor/codex_executor_execute.go +++ b/internal/runtime/executor/codex_executor_execute.go @@ -66,7 +66,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re } body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body) body = normalizeCodexParallelToolCalls(body, opts.Headers) - body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2Request(ctx, opts.Headers, body, e.cfg) + body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, baseModel) body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body) if errReplay != nil { return resp, errReplay @@ -227,7 +227,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A body = normalizeCodexInstructions(body) body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body) body = normalizeCodexParallelToolCalls(body, opts.Headers) - body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2Request(ctx, opts.Headers, body, e.cfg) + body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, baseModel) 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 a9a1fad4c..40355c6fc 100644 --- a/internal/runtime/executor/codex_executor_spawn_agent_test.go +++ b/internal/runtime/executor/codex_executor_spawn_agent_test.go @@ -107,6 +107,101 @@ func TestCodexExecutorOptimizeMultiAgentV2(t *testing.T) { } } +func TestCodexExecutorIsCompatConvertsAgentMessage(t *testing.T) { + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + upstreamBody, _ = io.ReadAll(request.Body) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","output":[]}}` + "\n\n")) + })) + defer server.Close() + + payload := codexSpawnAgentTestPayload() + baseCfg := config.Config{ + Codex: config.CodexConfig{OptimizeMultiAgentV2: true}, + CodexKey: []config.CodexKey{{ + APIKey: "test", + BaseURL: server.URL, + Models: []config.CodexModel{ + {Name: "deepseek-v4-flash", Alias: "deepseek-alias", IsCompat: true}, + {Name: "gpt-5.4", Alias: "codex-native"}, + }, + }}, + } + auth := &cliproxyauth.Auth{ + Provider: "codex", + Attributes: map[string]string{ + "base_url": server.URL, + "api_key": "test", + }, + } + + tests := []struct { + name string + model string + enabled bool + wantType string + wantRole string + wantRoleExists bool + }{ + { + name: "is-compat converts agent_message", + model: "deepseek-v4-flash", + enabled: true, + wantType: "message", + wantRole: "user", + wantRoleExists: true, + }, + { + name: "native model keeps agent_message", + model: "gpt-5.4", + enabled: true, + wantType: "agent_message", + wantRoleExists: false, + }, + { + name: "optimize disabled keeps agent_message", + model: "deepseek-v4-flash", + enabled: false, + wantType: "agent_message", + wantRoleExists: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstreamBody = nil + cfg := baseCfg + cfg.Codex.OptimizeMultiAgentV2 = tt.enabled + executor := NewCodexExecutor(&cfg) + ctx := codexSpawnAgentTestContext() + req := cliproxyexecutor.Request{Model: tt.model, Payload: payload} + opts := cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai-response"), + Headers: http.Header{"User-Agent": []string{"overridden-client/1.0"}}, + } + if _, errExecute := executor.Execute(ctx, auth, req, opts); errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + message := gjson.GetBytes(upstreamBody, "input.1") + if message.Get("type").String() != tt.wantType { + t.Fatalf("input.1.type = %q, want %q; body=%s", message.Get("type").String(), tt.wantType, upstreamBody) + } + if tt.wantRoleExists { + if message.Get("role").String() != tt.wantRole { + t.Fatalf("input.1.role = %q, want %q; body=%s", message.Get("role").String(), tt.wantRole, upstreamBody) + } + if message.Get("content.1.type").String() != "input_text" || message.Get("content.1.text").String() != "delegated task" { + t.Fatalf("compat conversion did not normalize content: %s", upstreamBody) + } + return + } + if message.Get("role").Exists() { + t.Fatalf("input.1.role unexpectedly present: %s", upstreamBody) + } + }) + } +} + func codexSpawnAgentTestPayload() []byte { return []byte(`{ "model":"gpt-5.4", diff --git a/internal/runtime/executor/codex_executor_stream.go b/internal/runtime/executor/codex_executor_stream.go index ddc1e81e9..70f5dc8e0 100644 --- a/internal/runtime/executor/codex_executor_stream.go +++ b/internal/runtime/executor/codex_executor_stream.go @@ -66,7 +66,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au } body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body) body = normalizeCodexParallelToolCalls(body, opts.Headers) - body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2Request(ctx, opts.Headers, body, e.cfg) + body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, baseModel) 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 8f46812e3..5243bc7ad 100644 --- a/internal/runtime/executor/codex_websockets_execute.go +++ b/internal/runtime/executor/codex_websockets_execute.go @@ -64,7 +64,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut } body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex websockets executor", body) body = normalizeCodexWebsocketParallelToolCalls(body, opts.Headers) - body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2Request(ctx, opts.Headers, body, e.cfg) + body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, baseModel) 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 60f877388..505081f09 100644 --- a/internal/runtime/executor/codex_websockets_stream.go +++ b/internal/runtime/executor/codex_websockets_stream.go @@ -61,7 +61,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr } body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex websockets executor", body) body = normalizeCodexWebsocketParallelToolCalls(body, opts.Headers) - body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2Request(ctx, opts.Headers, body, e.cfg) + body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, baseModel) 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 bf2c0b68c..4b6b2399b 100644 --- a/internal/runtime/executor/helps/codex_multi_agent_v2.go +++ b/internal/runtime/executor/helps/codex_multi_agent_v2.go @@ -6,6 +6,7 @@ import ( multiagentv2 "github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/optimize-multi-agent-v2" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" ) @@ -33,6 +34,17 @@ func OptimizeCodexMultiAgentV2Request(ctx context.Context, headers http.Header, return multiagentv2.OptimizeCodexMultiAgentV2Request(ctx, headers, payload, cfg) } +// 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. +func OptimizeCodexMultiAgentV2RequestForAuth(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config, auth *cliproxyauth.Auth, model string) ([]byte, bool) { + updated, optimized := multiagentv2.OptimizeCodexMultiAgentV2Request(ctx, headers, payload, cfg) + if cliproxyauth.CodexAPIKeyModelIsCompat(cfg, auth, model) { + updated = multiagentv2.RewriteCodexMultiAgentV2Input(ctx, headers, updated, cfg) + } + 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 { diff --git a/internal/watcher/diff/models_summary.go b/internal/watcher/diff/models_summary.go index 40b1fd9ec..67953b83c 100644 --- a/internal/watcher/diff/models_summary.go +++ b/internal/watcher/diff/models_summary.go @@ -87,7 +87,11 @@ func SummarizeCodexModels(models []config.CodexModel) CodexModelsSummary { if model.ForceMapping { forceMapping = "true" } - out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|force-mapping=" + forceMapping + thinkingHashSuffix(model.Thinking)) + isCompat := "false" + if model.IsCompat { + isCompat = "true" + } + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|force-mapping=" + forceMapping + "|is-compat=" + isCompat + thinkingHashSuffix(model.Thinking)) } }) return CodexModelsSummary{ diff --git a/sdk/cliproxy/auth/api_key_model_capabilities.go b/sdk/cliproxy/auth/api_key_model_capabilities.go index c88dcbf26..4a109e9a0 100644 --- a/sdk/cliproxy/auth/api_key_model_capabilities.go +++ b/sdk/cliproxy/auth/api_key_model_capabilities.go @@ -57,6 +57,46 @@ func ResolvedAPIKeyModelInfo(req cliproxyexecutor.Request) (*registry.ModelInfo, return modelInfo, true } +// CodexAPIKeyModelIsCompat reports whether the selected codex-api-key model has +// is-compat enabled. When true and codex.optimize-multi-agent-v2 is also true, +// Codex MultiAgentV2 agent_message items are converted into portable Responses +// message/user input for third-party Responses-compatible endpoints. +func CodexAPIKeyModelIsCompat(cfg *internalconfig.Config, auth *Auth, model string) bool { + if cfg == nil || auth == nil || !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") { + return false + } + entry := resolveCodexAPIKeyConfig(cfg, auth) + if entry == nil || len(entry.Models) == 0 { + return false + } + requested := strings.TrimSpace(model) + if requested == "" { + return false + } + baseModel := strings.TrimSpace(thinking.ParseSuffix(requested).ModelName) + if baseModel == "" { + baseModel = requested + } + for i := range entry.Models { + name := strings.TrimSpace(entry.Models[i].Name) + alias := strings.TrimSpace(entry.Models[i].Alias) + if name == "" { + name = alias + } + if alias == "" { + alias = name + } + if name == "" { + continue + } + if strings.EqualFold(name, requested) || strings.EqualFold(name, baseModel) || + strings.EqualFold(alias, requested) || strings.EqualFold(alias, baseModel) { + return entry.Models[i].IsCompat + } + } + return false +} + func (m *Manager) attachResolvedAPIKeyModelInfo(req cliproxyexecutor.Request, auth *Auth, routeModel, upstreamModel string) cliproxyexecutor.Request { return attachResolvedAPIKeyModelInfo(m.loadAPIKeyModelRouting(), req, auth, routeModel, upstreamModel) } diff --git a/sdk/cliproxy/auth/api_key_model_capabilities_test.go b/sdk/cliproxy/auth/api_key_model_capabilities_test.go index 91d1a6744..27fbd4b1a 100644 --- a/sdk/cliproxy/auth/api_key_model_capabilities_test.go +++ b/sdk/cliproxy/auth/api_key_model_capabilities_test.go @@ -251,3 +251,44 @@ func assertResolvedThinkingLevels(t *testing.T, req cliproxyexecutor.Request, wa } } } + +func TestCodexAPIKeyModelIsCompat(t *testing.T) { + cfg := &internalconfig.Config{CodexKey: []internalconfig.CodexKey{{ + APIKey: "codex-key", + BaseURL: "https://compat.example.com/v1", + Models: []internalconfig.CodexModel{ + {Name: "deepseek-v4-flash", Alias: "deepseek-alias", IsCompat: true}, + {Name: "gpt-5.4", Alias: "codex-native"}, + }, + }}} + auth := &Auth{ + Provider: "codex", + Attributes: map[string]string{ + AttributeAuthKind: AuthKindAPIKey, + AttributeAPIKey: "codex-key", + "base_url": "https://compat.example.com/v1", + }, + } + + if !CodexAPIKeyModelIsCompat(cfg, auth, "deepseek-v4-flash") { + t.Fatal("upstream name IsCompat = false, want true") + } + if !CodexAPIKeyModelIsCompat(cfg, auth, "deepseek-alias") { + t.Fatal("alias IsCompat = false, want true") + } + if !CodexAPIKeyModelIsCompat(cfg, auth, "deepseek-v4-flash(high)") { + t.Fatal("suffix model IsCompat = false, want true") + } + if CodexAPIKeyModelIsCompat(cfg, auth, "gpt-5.4") { + t.Fatal("native model IsCompat = true, want false") + } + if CodexAPIKeyModelIsCompat(cfg, auth, "missing-model") { + t.Fatal("missing model IsCompat = true, want false") + } + if CodexAPIKeyModelIsCompat(cfg, &Auth{Provider: "claude", Attributes: auth.Attributes}, "deepseek-v4-flash") { + t.Fatal("non-codex provider IsCompat = true, want false") + } + if CodexAPIKeyModelIsCompat(nil, auth, "deepseek-v4-flash") { + t.Fatal("nil config IsCompat = true, want false") + } +}