From 527f7690fd5e2369d4043a04b4e53ac4f0b2c465 Mon Sep 17 00:00:00 2001 From: George Liu Date: Mon, 13 Jul 2026 10:45:56 +0800 Subject: [PATCH 1/8] Handle Claude max_tokens one probes --- sdk/api/handlers/claude/code_handlers.go | 77 +++++++++++++++++++ .../claude/code_handlers_probe_test.go | 59 ++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 sdk/api/handlers/claude/code_handlers_probe_test.go diff --git a/sdk/api/handlers/claude/code_handlers.go b/sdk/api/handlers/claude/code_handlers.go index c7226315704..87ecf0c6a8c 100644 --- a/sdk/api/handlers/claude/code_handlers.go +++ b/sdk/api/handlers/claude/code_handlers.go @@ -84,6 +84,10 @@ func (h *ClaudeCodeAPIHandler) ClaudeMessages(c *gin.Context) { // Decode claude-fable-5-dd- model IDs back to the real model name for routing. rawJSON = rewriteClaudeDDModelInBody(rawJSON) + if h.writeClaudeMaxTokensOneProbeResponse(c, rawJSON) { + return + } + // Check if the client requested a streaming response. streamResult := gjson.GetBytes(rawJSON, "stream") if !streamResult.Exists() || streamResult.Type == gjson.False { @@ -149,6 +153,79 @@ func rewriteClaudeDDModelInBody(rawJSON []byte) []byte { return updated } +func (h *ClaudeCodeAPIHandler) writeClaudeMaxTokensOneProbeResponse(c *gin.Context, rawJSON []byte) bool { + if !isClaudeMaxTokensOneProbe(rawJSON) { + return false + } + + streamResult := gjson.GetBytes(rawJSON, "stream") + if streamResult.Exists() && streamResult.Type == gjson.True { + h.writeClaudeMaxTokensOneProbeStream(c, rawJSON) + return true + } + + body := claudeMaxTokensOneProbeBody(rawJSON) + appendClaudeAPIResponse(c, body) + c.Header("Content-Type", "application/json") + c.Status(http.StatusOK) + _, _ = c.Writer.Write(body) + return true +} + +func isClaudeMaxTokensOneProbe(rawJSON []byte) bool { + maxTokens := gjson.GetBytes(rawJSON, "max_tokens") + return maxTokens.Exists() && maxTokens.Type == gjson.Number && maxTokens.Int() == 1 +} + +func claudeMaxTokensOneProbeBody(rawJSON []byte) []byte { + body := []byte(`{"id":"msg_cli_proxy_probe","type":"message","role":"assistant","model":"","content":[{"type":"text","text":"ok"}],"stop_reason":"max_tokens","stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`) + if model := gjson.GetBytes(rawJSON, "model").String(); model != "" { + if updated, err := sjson.SetBytes(body, "model", model); err == nil { + body = updated + } + } + return body +} + +func (h *ClaudeCodeAPIHandler) writeClaudeMaxTokensOneProbeStream(c *gin.Context, rawJSON []byte) { + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("Access-Control-Allow-Origin", "*") + + model := gjson.GetBytes(rawJSON, "model").String() + messageStart := []byte(`{"type":"message_start","message":{"id":"msg_cli_proxy_probe","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}`) + if model != "" { + if updated, err := sjson.SetBytes(messageStart, "message.model", model); err == nil { + messageStart = updated + } + } + + var stream bytes.Buffer + appendClaudeSSEEvent(&stream, "message_start", messageStart) + appendClaudeSSEEvent(&stream, "content_block_start", []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`)) + appendClaudeSSEEvent(&stream, "content_block_delta", []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`)) + appendClaudeSSEEvent(&stream, "content_block_stop", []byte(`{"type":"content_block_stop","index":0}`)) + appendClaudeSSEEvent(&stream, "message_delta", []byte(`{"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)) + appendClaudeSSEEvent(&stream, "message_stop", []byte(`{"type":"message_stop"}`)) + + body := stream.Bytes() + appendClaudeAPIResponse(c, body) + c.Status(http.StatusOK) + _, _ = c.Writer.Write(body) + if flusher, ok := c.Writer.(http.Flusher); ok { + flusher.Flush() + } +} + +func appendClaudeSSEEvent(buf *bytes.Buffer, event string, payload []byte) { + _, _ = buf.WriteString("event: ") + _, _ = buf.WriteString(event) + _, _ = buf.WriteString("\ndata: ") + _, _ = buf.Write(payload) + _, _ = buf.WriteString("\n\n") +} + // ClaudeModels handles the Claude models listing endpoint. // It returns a JSON response containing available Claude models and their specifications. // diff --git a/sdk/api/handlers/claude/code_handlers_probe_test.go b/sdk/api/handlers/claude/code_handlers_probe_test.go new file mode 100644 index 00000000000..4beee33f223 --- /dev/null +++ b/sdk/api/handlers/claude/code_handlers_probe_test.go @@ -0,0 +1,59 @@ +package claude + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" + "github.com/tidwall/gjson" +) + +func TestClaudeMessagesMaxTokensOneProbeReturnsOKContent(t *testing.T) { + gin.SetMode(gin.TestMode) + body := `{"model":"claude-sonnet-4-6","max_tokens":1,"messages":[{"role":"user","content":[{"type":"text","text":"."}]}]}` + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(body)) + handler := NewClaudeCodeAPIHandler(&handlers.BaseAPIHandler{}) + + handler.ClaudeMessages(c) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + respBody := recorder.Body.Bytes() + if got := gjson.GetBytes(respBody, "content.0.type").String(); got != "text" { + t.Fatalf("content.0.type = %q, want text; body=%s", got, respBody) + } + if got := gjson.GetBytes(respBody, "content.0.text").String(); got != "ok" { + t.Fatalf("content.0.text = %q, want ok; body=%s", got, respBody) + } + if got := gjson.GetBytes(respBody, "stop_reason").String(); got != "max_tokens" { + t.Fatalf("stop_reason = %q, want max_tokens; body=%s", got, respBody) + } +} + +func TestClaudeMessagesMaxTokensOneProbeStreamsOKDelta(t *testing.T) { + gin.SetMode(gin.TestMode) + body := `{"model":"claude-opus-4-6","max_tokens":1,"stream":true,"messages":[{"role":"user","content":[{"type":"text","text":"."}]}]}` + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(body)) + handler := NewClaudeCodeAPIHandler(&handlers.BaseAPIHandler{}) + + handler.ClaudeMessages(c) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + respBody := recorder.Body.String() + if !strings.Contains(respBody, `"text":"ok"`) { + t.Fatalf("stream body missing ok delta: %s", respBody) + } + if !strings.Contains(respBody, `"stop_reason":"max_tokens"`) { + t.Fatalf("stream body missing max_tokens stop reason: %s", respBody) + } +} From 1d2f2247fb12c8cb28cb416406e948109b4d25d1 Mon Sep 17 00:00:00 2001 From: George Liu Date: Wed, 16 Sep 2026 14:45:45 +0100 Subject: [PATCH 2/8] feat(codex): enable buffered failover defaults --- config.example.yaml | 23 ++++++----- .../codex_websocket_header_defaults_test.go | 38 +++++++++++++++++++ internal/config/config_defaults.go | 16 ++++++++ internal/config/config_load.go | 10 ++--- internal/config/config_types.go | 8 ++-- internal/config/parse.go | 2 +- internal/config/sdk_config.go | 4 +- test/codex_quota_failover_test.go | 2 +- 8 files changed, 78 insertions(+), 25 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index fb1e0e4b372..92acde05b8b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -207,11 +207,12 @@ claude-code: # When true, return original model IDs in Anthropic model list responses instead of cloaked IDs. disable-cloaking-model-list: false -# disable-image-generation supports: false (default), true, "chat", or "passthrough". +# disable-image-generation supports: false, true, "chat", or "passthrough" (default). +# - false: allow automatic image_generation injection on non-images endpoints. # - true: disable image_generation everywhere (also returns 404 for /v1/images/generations and /v1/images/edits). # - "chat": disable image_generation injection on non-images endpoints, but keep /v1/images/generations and /v1/images/edits enabled. -# - "passthrough": never inject or strip image_generation on non-images endpoints (forward the client payload unchanged); behaves like "chat" on /v1/images/* endpoints. -disable-image-generation: false +# - "passthrough": never inject or strip image_generation on non-images endpoints; clients add it only when needed. Behaves like "chat" on /v1/images/* endpoints. +disable-image-generation: "passthrough" # Base model used by the legacy hosted image_generation tool path when a Codex image request is not proxied directly through the Image API. # Must start with "gpt-" (case-insensitive). If unset or invalid, defaults to "gpt-5.4-mini". @@ -308,17 +309,15 @@ codex: # wait. Neither is made worse by this option - with it off the same stalled line stalls the # conductor instead, which waits on the same context - but a client-side request timeout, not # proxy_read_timeout, is what bounds them. - # Default: false - stream-bootstrap-buffering: false + # Default: true + stream-bootstrap-buffering: true # Optional maximum duration to hold back uncommitted response headers during bootstrap buffering. - # When set (e.g. "20s"), if the time ceiling is reached before the first generated token, + # When set (e.g. "30s"), if the time ceiling is reached before the first generated token, # the stream is released to the client and further in-stream overloads are delivered rather # than failed over. This bounds how long trickled heartbeats or early empty frames can delay # response headers before streaming begins. - # Recommendation: If your CPA is deployed behind a reverse proxy with a strict read timeout - # (e.g. Nginx default proxy_read_timeout 60s or Cloudflare 100s timeout), setting this to "20s" - # is strongly recommended to guarantee headers commit well before proxy timeouts while retaining - # a comfortable 20s window to fail over early scheduling overloads. + # The 30s default commits headers before common 60s or 100s reverse-proxy read timeouts while + # retaining enough time to fail over early scheduling overloads. # Note: The timeout check is evaluated as upstream frames arrive; it never aborts the upstream # connection. Downstream headers are committed as soon as the downstream translator emits chunks # (for formats that render handshake/heartbeats, headers commit immediately on release; for @@ -326,8 +325,8 @@ codex: # commit when that first token arrives). # When unset or set to "0", "0s", "none", "unlimited", "disabled", "off", or "never", no time ceiling # is applied, and buffering relies purely on the 48-frame and 1MB byte budget. - # Default: "0" (unlimited) - stream-bootstrap-timeout: "0" + # Default: "30s" + stream-bootstrap-timeout: "30s" # When true, optimize Codex Desktop, codex-tui, and codex_cli_rs 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 diff --git a/internal/config/codex_websocket_header_defaults_test.go b/internal/config/codex_websocket_header_defaults_test.go index 032d7e248b2..9d77768aac6 100644 --- a/internal/config/codex_websocket_header_defaults_test.go +++ b/internal/config/codex_websocket_header_defaults_test.go @@ -4,8 +4,46 @@ import ( "os" "path/filepath" "testing" + "time" ) +func TestParseConfigBytes_CodexRuntimeDefaults(t *testing.T) { + cfg, errParse := ParseConfigBytes([]byte(`{}`)) + if errParse != nil { + t.Fatalf("ParseConfigBytes() error = %v", errParse) + } + if !cfg.Codex.StreamBootstrapBuffering { + t.Fatal("default StreamBootstrapBuffering = false, want true") + } + if got := cfg.Codex.StreamBootstrapTimeoutDuration(); got != 30*time.Second { + t.Fatalf("default StreamBootstrapTimeoutDuration() = %v, want 30s", got) + } + if got := cfg.DisableImageGeneration; got != DisableImageGenerationPassthrough { + t.Fatalf("default DisableImageGeneration = %v, want passthrough", got) + } +} + +func TestParseConfigBytes_CodexRuntimeDefaultsCanBeOverridden(t *testing.T) { + cfg, errParse := ParseConfigBytes([]byte(` +disable-image-generation: false +codex: + stream-bootstrap-buffering: false + stream-bootstrap-timeout: "0" +`)) + if errParse != nil { + t.Fatalf("ParseConfigBytes() error = %v", errParse) + } + if cfg.Codex.StreamBootstrapBuffering { + t.Fatal("StreamBootstrapBuffering = true, want explicit false") + } + if got := cfg.Codex.StreamBootstrapTimeoutDuration(); got != 0 { + t.Fatalf("StreamBootstrapTimeoutDuration() = %v, want 0", got) + } + if got := cfg.DisableImageGeneration; got != DisableImageGenerationOff { + t.Fatalf("DisableImageGeneration = %v, want false", got) + } +} + func TestLoadConfigOptional_CodexHeaderDefaults(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.yaml") diff --git a/internal/config/config_defaults.go b/internal/config/config_defaults.go index e07e22b822c..dadce93fcbd 100644 --- a/internal/config/config_defaults.go +++ b/internal/config/config_defaults.go @@ -5,4 +5,20 @@ const ( DefaultPprofAddr = "127.0.0.1:8316" DefaultAuthDir = "~/.cli-proxy-api" DefaultDiscoveryServiceType = "_ai-gateway._tcp" + DefaultCodexBootstrapTimeout = "30s" ) + +func applyCodexRuntimeDefaults(cfg *Config) { + if cfg == nil { + return + } + cfg.DisableImageGeneration = DisableImageGenerationPassthrough + cfg.Codex.StreamBootstrapBuffering = true + cfg.Codex.StreamBootstrapTimeout = DefaultCodexBootstrapTimeout +} + +func newOptionalFallbackConfig() *Config { + cfg := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()} + applyCodexRuntimeDefaults(cfg) + return cfg +} diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 595b906f330..aa6ae71a45c 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -36,7 +36,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { if optional { if os.IsNotExist(err) || errors.Is(err, syscall.EISDIR) { // Missing and optional: return empty config (cloud deploy standby). - cfg := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()} + cfg := newOptionalFallbackConfig() cfg.NormalizePluginsConfig() return cfg, nil } @@ -46,14 +46,14 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { // In cloud deploy mode (optional=true), if file is empty or contains only whitespace, return empty config. if optional && len(bytes.TrimSpace(data)) == 0 { - cfg := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()} + cfg := newOptionalFallbackConfig() cfg.NormalizePluginsConfig() return cfg, nil } if errValidate := validateCredentialWeightYAML(data); errValidate != nil { if optional { - cfgOptional := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()} + cfgOptional := newOptionalFallbackConfig() cfgOptional.NormalizePluginsConfig() return cfgOptional, nil } @@ -72,7 +72,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { cfg.DisableCooling = false cfg.SaveCooldownStatus = false cfg.TransientErrorCooldownSeconds = 0 - cfg.DisableImageGeneration = DisableImageGenerationOff + applyCodexRuntimeDefaults(&cfg) cfg.WebsocketAuth = true cfg.Pprof.Enable = false cfg.Pprof.Addr = DefaultPprofAddr @@ -84,7 +84,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { if err = yaml.Unmarshal(data, &cfg); err != nil { if optional { // In cloud deploy mode, if YAML parsing fails, return empty config instead of error. - cfgOptional := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()} + cfgOptional := newOptionalFallbackConfig() cfgOptional.NormalizePluginsConfig() return cfgOptional, nil } diff --git a/internal/config/config_types.go b/internal/config/config_types.go index 746ace5117d..9ac207fac80 100644 --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -193,11 +193,11 @@ type CodexConfig struct { // reasoning phase instead of ending at the first keepalive: a clean end with no terminal event // is request-scoped on SSE and stops there, while a websocket close or a transport error on // either transport is not, so the request may be retried on another credential. - // Default is false. + // Default is true. StreamBootstrapBuffering bool `yaml:"stream-bootstrap-buffering" json:"stream-bootstrap-buffering"` // StreamBootstrapTimeout specifies an optional maximum duration to hold back uncommitted response // headers during bootstrap buffering before releasing the stream to the client. - // Defaults to "0" (unlimited time, relying purely on the 48-frame and 1MB byte bounds). + // Config loaders default this to 30 seconds. A zero-value CodexConfig remains unlimited. // When set (e.g. "20s"), the stream is released once the time ceiling is reached, avoiding // reverse-proxy timeouts (e.g. Nginx 60s proxy_read_timeout). StreamBootstrapTimeout string `yaml:"stream-bootstrap-timeout,omitempty" json:"stream-bootstrap-timeout,omitempty"` @@ -213,13 +213,13 @@ type CodexConfig struct { } // DefaultCodexStreamBootstrapTimeout is the default maximum duration to buffer bootstrap events. -// By default, it is 0 (unlimited time, relying purely on the 48-frame and 1MB byte bounds). +// A loaded runtime config initializes the field to 30 seconds before reaching this fallback. const DefaultCodexStreamBootstrapTimeout = 0 const maxBootstrapTimeoutSeconds = int64(math.MaxInt64 / time.Second) // StreamBootstrapTimeoutDuration returns the maximum duration to buffer bootstrap events. -// Defaults to 0 (unlimited time, relying purely on the 48-frame and 1MB byte bounds). +// Defaults to 0 for a zero-value CodexConfig; loaded runtime configs initialize the field to 30 seconds. // If explicitly set to a positive duration (e.g. "10s", "500ms", "15"), returns that duration. // If set to "0", "0s", "none", "unlimited", "disabled", "off", "never", or invalid strings, returns 0. func (c *CodexConfig) StreamBootstrapTimeoutDuration() time.Duration { diff --git a/internal/config/parse.go b/internal/config/parse.go index 3b3728746a7..46caccd48a8 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -31,7 +31,7 @@ func ParseConfigBytes(data []byte) (*Config, error) { cfg.DisableCooling = false cfg.SaveCooldownStatus = false cfg.TransientErrorCooldownSeconds = 0 - cfg.DisableImageGeneration = DisableImageGenerationOff + applyCodexRuntimeDefaults(&cfg) cfg.WebsocketAuth = true cfg.Pprof.Enable = false cfg.Pprof.Addr = DefaultPprofAddr diff --git a/internal/config/sdk_config.go b/internal/config/sdk_config.go index 1c5a1bb63e8..29835ec9ad4 100644 --- a/internal/config/sdk_config.go +++ b/internal/config/sdk_config.go @@ -12,12 +12,12 @@ type SDKConfig struct { // DisableImageGeneration controls whether the built-in image_generation tool is injected/allowed. // // Supported values: - // - false (default): image_generation is enabled everywhere (normal behavior). + // - false: image_generation is enabled everywhere and may be injected automatically. // - true: image_generation is disabled everywhere. The server stops injecting it, removes it from request payloads, // and returns 404 for /v1/images/generations and /v1/images/edits. // - "chat": disable image_generation injection for all non-images endpoints (e.g. /v1/responses, /v1/chat/completions), // while keeping /v1/images/generations and /v1/images/edits enabled and preserving image_generation there. - // - "passthrough": do not modify the tool list on non-images endpoints — keep image_generation if the client + // - "passthrough" (default): do not modify the tool list on non-images endpoints — keep image_generation if the client // sent it and do not inject it otherwise; on /v1/images/generations and /v1/images/edits behave like "chat". DisableImageGeneration DisableImageGenerationMode `yaml:"disable-image-generation" json:"disable-image-generation"` diff --git a/test/codex_quota_failover_test.go b/test/codex_quota_failover_test.go index 67ccc7ca18d..8729f3ce446 100644 --- a/test/codex_quota_failover_test.go +++ b/test/codex_quota_failover_test.go @@ -205,7 +205,7 @@ func TestCodexModelLevelCoolingPreservesSiblingModel(t *testing.T) { defer server.Close() manager := cliproxyauth.NewManager(nil, &cliproxyauth.RoundRobinSelector{}, nil) manager.SetRetryConfig(0, 0, 0) - cfg, errParse := config.ParseConfigBytes([]byte("codex:\n model-level-cooling: true\n")) + cfg, errParse := config.ParseConfigBytes([]byte("codex:\n model-level-cooling: true\n stream-bootstrap-buffering: false\n")) if errParse != nil { t.Fatalf("parse config: %v", errParse) } From 085596b051da24f305b52588df77c343e8b1afba Mon Sep 17 00:00:00 2001 From: George Liu Date: Sat, 19 Sep 2026 20:53:34 +0100 Subject: [PATCH 3/8] fix(codex): preserve native client identity --- config.example.yaml | 5 ++ internal/config/config_defaults.go | 3 + internal/config/config_types.go | 6 ++ .../executor/codex_executor_request.go | 23 ++++- .../executor/codex_native_fidelity_test.go | 59 +++++++++++- .../codex_websockets_executor_test.go | 90 ++++++++++++------- .../executor/codex_websockets_request.go | 10 ++- .../executor/helps/codex_client_identity.go | 53 +++++++++++ .../helps/codex_client_identity_test.go | 48 ++++++++++ internal/watcher/diff/config_diff.go | 11 +++ 10 files changed, 268 insertions(+), 40 deletions(-) create mode 100644 internal/runtime/executor/helps/codex_client_identity.go create mode 100644 internal/runtime/executor/helps/codex_client_identity_test.go diff --git a/config.example.yaml b/config.example.yaml index 92acde05b8b..ff3199551b0 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -266,6 +266,11 @@ codex: identity-confuse: false # Disable forcing the official Codex User-Agent and Originator headers on HTTP/SSE and WebSocket requests. disable-codex-cloaking: false + # Keep the downstream User-Agent and Originator when the request already presents a coherent + # first-party Codex identity (originator codex_cli_rs / codex-tui / codex_vscode / codex_exec, or + # prefixed "Codex ", together with a matching "/" User-Agent). + # All other requests are still cloaked. Ignored when disable-codex-cloaking is true. + preserve-native-client-identity: true # Hold back the frames that carry nothing the client has seen - the handshake (response.created, # response.in_progress, the websocket metadata frames), keepalive heartbeats, and the *.added # announcements of an item or part that is still empty - until the upstream emits its first diff --git a/internal/config/config_defaults.go b/internal/config/config_defaults.go index dadce93fcbd..a51724e7e51 100644 --- a/internal/config/config_defaults.go +++ b/internal/config/config_defaults.go @@ -15,6 +15,9 @@ func applyCodexRuntimeDefaults(cfg *Config) { cfg.DisableImageGeneration = DisableImageGenerationPassthrough cfg.Codex.StreamBootstrapBuffering = true cfg.Codex.StreamBootstrapTimeout = DefaultCodexBootstrapTimeout + // Preserving a coherent first-party Codex identity is the default behavior. + preserveNativeClientIdentity := true + cfg.Codex.PreserveNativeClientIdentity = &preserveNativeClientIdentity } func newOptionalFallbackConfig() *Config { diff --git a/internal/config/config_types.go b/internal/config/config_types.go index 9ac207fac80..4db06760c1c 100644 --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -176,6 +176,12 @@ type CodexConfig struct { IdentityConfuse bool `yaml:"identity-confuse" json:"identity-confuse"` // DisableCodexCloaking disables forcing the official Codex identity headers on HTTP/SSE and WebSocket requests. DisableCodexCloaking bool `yaml:"disable-codex-cloaking" json:"disable-codex-cloaking"` + // PreserveNativeClientIdentity keeps the downstream User-Agent and Originator untouched when the + // request already presents a coherent first-party Codex identity, instead of overwriting both with + // the built-in Codex identity. Every other request is still cloaked as before. Ignored when + // DisableCodexCloaking is true, because that flag already skips cloaking entirely. + // Default is true; a nil pointer means enabled. + PreserveNativeClientIdentity *bool `yaml:"preserve-native-client-identity,omitempty" json:"preserve-native-client-identity,omitempty"` // StreamBootstrapBuffering holds back the frames that arrive before generation starts, none of // which the client has seen anything from - the handshake (response.created, response.in_progress, // the websocket metadata frames), keepalive heartbeats, and the *.added announcements of an item diff --git a/internal/runtime/executor/codex_executor_request.go b/internal/runtime/executor/codex_executor_request.go index b67eda88c13..c95e724c7e6 100644 --- a/internal/runtime/executor/codex_executor_request.go +++ b/internal/runtime/executor/codex_executor_request.go @@ -172,7 +172,19 @@ func applyCodexIdentityConfuseBody(cfg *config.Config, auth *cliproxyauth.Auth, if turnMetadata := strings.TrimSpace(gjson.GetBytes(rawJSON, "client_metadata.x-codex-turn-metadata").String()); turnMetadata != "" { rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.x-codex-turn-metadata", applyCodexTurnMetadataIdentityConfuse(turnMetadata, &state)) } + if turnID := strings.TrimSpace(gjson.GetBytes(rawJSON, "client_metadata.turn_id").String()); turnID != "" { + rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.turn_id", state.confuseTurnID(turnID)) + } + if rootTurnID := strings.TrimSpace(gjson.GetBytes(rawJSON, "client_metadata.root_turn_id").String()); rootTurnID != "" { + rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.root_turn_id", state.confuseTurnID(rootTurnID)) + } if state.promptCacheKey != "" { + if sessionID := strings.TrimSpace(gjson.GetBytes(rawJSON, "client_metadata.session_id").String()); sessionID != "" { + rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.session_id", state.promptCacheKey) + } + if threadID := strings.TrimSpace(gjson.GetBytes(rawJSON, "client_metadata.thread_id").String()); threadID != "" { + rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.thread_id", state.promptCacheKey) + } if windowID := strings.TrimSpace(gjson.GetBytes(rawJSON, "client_metadata.x-codex-window-id").String()); windowID != "" { rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.x-codex-window-id", state.promptCacheKey+":0") } @@ -299,8 +311,8 @@ func applyModelHeaderOverrides(headers http.Header, modelName string) { for key, value := range overrides { headers.Set(key, value) } - if strings.Contains(headers.Get("User-Agent"), "Mac OS") && codexSessionHeaderValue(headers) == "" { - headers.Set("Session_id", uuid.NewString()) + if helps.IsFirstPartyCodexIdentity(headers.Get("User-Agent"), headers.Get("Originator")) && codexSessionHeaderValue(headers) == "" { + headers.Set("Session-Id", uuid.NewString()) } } @@ -346,7 +358,6 @@ func applyCodexHeadersFromSources(r *http.Request, auth *cliproxyauth.Auth, toke } else { r.Header.Set("Accept", "application/json") } - r.Header.Set("Connection", "Keep-Alive") isAPIKey := codexAuthUsesAPIKey(auth) if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" { @@ -369,10 +380,16 @@ func applyCodexHeadersFromSources(r *http.Request, auth *cliproxyauth.Auth, toke applyCodexCloakingHeaders(r.Header, cfg) } +// applyCodexCloakingHeaders forces the built-in Codex identity headers as a fallback. +// When identity preservation is enabled and the request already presents a coherent first-party +// Codex identity, both User-Agent and Originator are left untouched; anything else is cloaked. func applyCodexCloakingHeaders(headers http.Header, cfg *config.Config) { if headers == nil || cfg == nil || cfg.Codex.DisableCodexCloaking { return } + if helps.PreserveNativeCodexIdentity(cfg) && helps.IsFirstPartyCodexIdentity(headers.Get("User-Agent"), headers.Get("Originator")) { + return + } headers.Set("User-Agent", codexUserAgent) headers.Set("Originator", codexOriginator) } diff --git a/internal/runtime/executor/codex_native_fidelity_test.go b/internal/runtime/executor/codex_native_fidelity_test.go index 7e802fb9536..3ad374ff46b 100644 --- a/internal/runtime/executor/codex_native_fidelity_test.go +++ b/internal/runtime/executor/codex_native_fidelity_test.go @@ -112,8 +112,11 @@ func testCodexNativeStreamFidelity(t *testing.T, source sdktranslator.Format) { } alias := headerValueCaseInsensitive(upstreamHeaders, "session_id") t.Logf("upstream session alias: %q", alias) - if (alias == "") != native { - t.Errorf("session alias = %q, native = %t", alias, native) + if alias != "" { + t.Errorf("unexpected underscore session alias = %q", alias) + } + if got := upstreamHeaders.Get("Session-Id"); got != "session-1" { + t.Errorf("Session-Id = %q, want session-1 (native = %t)", got, native) } } t.Logf("downstream metadata: %q", metadataEvents) @@ -154,3 +157,55 @@ func TestCodexWebsocketLiteHeaderWithoutSessionHeaders(t *testing.T) { } } } + +func TestApplyCodexCloakingHeadersPreservesNativeIdentity(t *testing.T) { + const nativeUA = "codex-tui/0.154.0 (Mac OS 15.7.9; arm64) Apple_Terminal (codex-tui; 0.154.0)" + disabled := false + cases := []struct { + name string + cfg *config.Config + userAgent string + originator string + wantUserAgent string + wantOriginator string + }{ + { + name: "coherent identity preserved by default", + cfg: &config.Config{}, + userAgent: nativeUA, + originator: "codex-tui", + wantUserAgent: nativeUA, + wantOriginator: "codex-tui", + }, + { + name: "preservation disabled falls back to cloaking", + cfg: &config.Config{Codex: config.CodexConfig{PreserveNativeClientIdentity: &disabled}}, + userAgent: nativeUA, + originator: "codex-tui", + wantUserAgent: codexUserAgent, + wantOriginator: codexOriginator, + }, + { + name: "incoherent pair still cloaked", + cfg: &config.Config{}, + userAgent: nativeUA, + originator: "my-proxy", + wantUserAgent: codexUserAgent, + wantOriginator: codexOriginator, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + headers := http.Header{} + headers.Set("User-Agent", tc.userAgent) + headers.Set("Originator", tc.originator) + applyCodexCloakingHeaders(headers, tc.cfg) + if got := headers.Get("User-Agent"); got != tc.wantUserAgent { + t.Errorf("User-Agent = %q, want %q", got, tc.wantUserAgent) + } + if got := headers.Get("Originator"); got != tc.wantOriginator { + t.Errorf("Originator = %q, want %q", got, tc.wantOriginator) + } + }) + } +} diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go index 2dc594d598e..5323764b1a2 100644 --- a/internal/runtime/executor/codex_websockets_executor_test.go +++ b/internal/runtime/executor/codex_websockets_executor_test.go @@ -1275,7 +1275,7 @@ func TestApplyCodexWebsocketHeadersNativeSessionCombinations(t *testing.T) { ctx := contextWithGinHeaders(tt.clientHeaders) initialHeaders := http.Header{} if withCacheAliases { - initialHeaders = http.Header{"session_id": {"cache-alias"}, "Conversation_id": {"cache-alias"}} + initialHeaders = http.Header{"Session-Id": {"cache-alias"}} } got := applyCodexWebsocketHeaders(ctx, initialHeaders, auth, "", cfg, true) @@ -1319,11 +1319,11 @@ func TestApplyCodexWebsocketHeadersCanonicalizesLegacyUnderscoreSessionHeader(t headers := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, "", nil, false) - if got := headers["session_id"]; len(got) != 1 || got[0] != "legacy-underscore-session" { - t.Fatalf("session_id = %#v, want [legacy-underscore-session]", got) + if got := headers["Session-Id"]; len(got) != 1 || got[0] != "legacy-underscore-session" { + t.Fatalf("Session-Id = %#v, want [legacy-underscore-session]", got) } - if got := headers.Get("Session-Id"); got != "" { - t.Fatalf("Session-Id = %s, want empty", got) + if got := headerValueCaseInsensitive(headers, "session_id"); got != "" { + t.Fatalf("session_id = %s, want empty", got) } } @@ -1467,19 +1467,19 @@ func TestApplyCodexWebsocketHeadersUsesCanonicalAccountHeader(t *testing.T) { } } -func TestApplyCodexPromptCacheHeadersSetsSessionIDAndLegacyConversation(t *testing.T) { +func TestApplyCodexPromptCacheHeadersSetsCanonicalSessionID(t *testing.T) { req := cliproxyexecutor.Request{Model: "gpt-5-codex", Payload: []byte(`{"prompt_cache_key":"cache-1"}`)} _, headers := applyCodexPromptCacheHeaders("openai-response", req, []byte(`{"model":"gpt-5-codex"}`)) - if got := headers["session_id"]; len(got) != 1 || got[0] != "cache-1" { - t.Fatalf("session_id = %#v, want [cache-1]", got) + if got := headers["Session-Id"]; len(got) != 1 || got[0] != "cache-1" { + t.Fatalf("Session-Id = %#v, want [cache-1]", got) } - if got := headers.Get("Session-Id"); got != "" { - t.Fatalf("Session-Id = %s, want empty", got) + if got := headerValueCaseInsensitive(headers, "session_id"); got != "" { + t.Fatalf("session_id = %s, want empty", got) } - if got := headers.Get("Conversation_id"); got != "cache-1" { - t.Fatalf("Conversation_id = %s, want cache-1", got) + if got := headers.Get("Conversation_id"); got != "" { + t.Fatalf("Conversation_id = %s, want empty", got) } } @@ -1496,11 +1496,11 @@ func TestApplyCodexPromptCacheHeadersUsesDerivedSessionUUID(t *testing.T) { if _, errParse := uuid.Parse(cacheKey); errParse != nil { t.Fatalf("prompt_cache_key %q is not a UUID: %v", cacheKey, errParse) } - if got := headers["session_id"]; len(got) != 1 || got[0] != cacheKey { - t.Fatalf("session_id = %#v, want [%q]", got, cacheKey) + if got := headers["Session-Id"]; len(got) != 1 || got[0] != cacheKey { + t.Fatalf("Session-Id = %#v, want [%q]", got, cacheKey) } - if got := headers.Get("Conversation_id"); got != cacheKey { - t.Fatalf("Conversation_id = %q, want %q", got, cacheKey) + if got := headers.Get("Conversation_id"); got != "" { + t.Fatalf("Conversation_id = %q, want empty", got) } } @@ -1559,11 +1559,11 @@ func TestApplyCodexPromptCacheHeadersClaudeUsesClaudeCodeSessionID(t *testing.T) if secondKey != firstKey { t.Fatalf("same Claude Code session_id produced different websocket prompt_cache_key: first=%q second=%q", firstKey, secondKey) } - if got := firstHeaders["session_id"]; len(got) != 1 || got[0] != firstKey { - t.Fatalf("first session_id = %#v, want [%q]", got, firstKey) + if got := firstHeaders["Session-Id"]; len(got) != 1 || got[0] != firstKey { + t.Fatalf("first Session-Id = %#v, want [%q]", got, firstKey) } - if got := secondHeaders["session_id"]; len(got) != 1 || got[0] != firstKey { - t.Fatalf("second session_id = %#v, want [%q]", got, firstKey) + if got := secondHeaders["Session-Id"]; len(got) != 1 || got[0] != firstKey { + t.Fatalf("second Session-Id = %#v, want [%q]", got, firstKey) } } @@ -1578,11 +1578,11 @@ func TestApplyCodexPromptCacheHeadersClaudeRejectsBareUserID(t *testing.T) { if got := gjson.GetBytes(body, "prompt_cache_key").String(); got != "" { t.Fatalf("bare metadata.user_id must not create websocket prompt_cache_key, got %q; body=%s", got, string(body)) } - if got := headers["session_id"]; len(got) != 0 { - t.Fatalf("bare metadata.user_id must not create websocket session_id, got %#v", got) + if got := headers["Session-Id"]; len(got) != 0 { + t.Fatalf("bare metadata.user_id must not create websocket Session-Id, got %#v", got) } - if got := headers.Get("Session-Id"); got != "" { - t.Fatalf("bare metadata.user_id must not create websocket Session-Id, got %q", got) + if got := headerValueCaseInsensitive(headers, "session_id"); got != "" { + t.Fatalf("bare metadata.user_id must not create websocket session_id, got %q", got) } if got := headers.Get("Conversation_id"); got != "" { t.Fatalf("bare metadata.user_id must not create websocket Conversation_id, got %q", got) @@ -1614,11 +1614,11 @@ func TestApplyCodexWebsocketHeadersIdentityConfuseRemapsPromptCacheKey(t *testin if gotKey := gjson.GetBytes(body, "prompt_cache_key").String(); gotKey != expectedPromptCacheKey { t.Fatalf("prompt_cache_key = %q, want %q", gotKey, expectedPromptCacheKey) } - if gotSession := headers["session_id"]; len(gotSession) != 1 || gotSession[0] != expectedPromptCacheKey { - t.Fatalf("session_id = %#v, want [%q]", gotSession, expectedPromptCacheKey) + if gotSession := headers["Session-Id"]; len(gotSession) != 1 || gotSession[0] != expectedPromptCacheKey { + t.Fatalf("Session-Id = %#v, want [%q]", gotSession, expectedPromptCacheKey) } - if gotCanonicalSession := headers.Get("Session-Id"); gotCanonicalSession != "" { - t.Fatalf("Session-Id = %q, want empty", gotCanonicalSession) + if gotLegacySession := headerValueCaseInsensitive(headers, "session_id"); gotLegacySession != "" { + t.Fatalf("session_id = %q, want empty", gotLegacySession) } if gotRequestID := headers.Get("X-Client-Request-Id"); gotRequestID != expectedPromptCacheKey { t.Fatalf("X-Client-Request-Id = %q, want %q", gotRequestID, expectedPromptCacheKey) @@ -1626,8 +1626,8 @@ func TestApplyCodexWebsocketHeadersIdentityConfuseRemapsPromptCacheKey(t *testin if gotThreadID := headers.Get("Thread-Id"); gotThreadID != expectedPromptCacheKey { t.Fatalf("Thread-Id = %q, want %q", gotThreadID, expectedPromptCacheKey) } - if gotConversation := headers.Get("Conversation_id"); gotConversation != expectedPromptCacheKey { - t.Fatalf("Conversation_id = %q, want %q", gotConversation, expectedPromptCacheKey) + if gotConversation := headers.Get("Conversation_id"); gotConversation != "" { + t.Fatalf("Conversation_id = %q, want empty", gotConversation) } if gotWindowID := headers.Get("X-Codex-Window-Id"); gotWindowID != expectedPromptCacheKey+":0" { t.Fatalf("X-Codex-Window-Id = %q, want %q", gotWindowID, expectedPromptCacheKey+":0") @@ -1943,7 +1943,13 @@ func TestApplyModelHeaderOverridesFromModelConfig(t *testing.T) { t.Fatalf("User-Agent = %q, want %q", got, wantUA) } if got := codexSessionHeaderValue(req.Header); got == "" { - t.Fatal("expected Session_id to be set for Mac OS User-Agent override") + t.Fatal("expected Session-Id to be set for a first-party Codex User-Agent override") + } + if got := req.Header.Get("Session-Id"); got == "" { + t.Fatal("expected canonical Session-Id spelling") + } + if got := headerValueCaseInsensitive(req.Header, "session_id"); got != "" { + t.Fatalf("session_id = %q, want empty", got) } applyModelHeaderOverrides(req.Header, "gpt-5.4") @@ -1952,6 +1958,28 @@ func TestApplyModelHeaderOverridesFromModelConfig(t *testing.T) { } } +func TestApplyModelHeaderOverridesSkipsSessionForNonCodexUserAgent(t *testing.T) { + reg := registry.GetGlobalRegistry() + clientID := "test-non-codex-ua-model" + reg.RegisterClient(clientID, "codex", []*registry.ModelInfo{{ + ID: "test-non-codex-ua-model", + Config: ®istry.ModelConfig{ + OverrideHeader: map[string]string{ + "user-agent": "Mozilla/5.0 (Macintosh; Mac OS X 10_15_7)", + "originator": "browser", + }, + }, + }}) + t.Cleanup(func() { reg.UnregisterClient(clientID) }) + + headers := http.Header{} + applyModelHeaderOverrides(headers, "test-non-codex-ua-model") + + if got := codexSessionHeaderValue(headers); got != "" { + t.Fatalf("session header = %q, want empty for a non-Codex User-Agent", got) + } +} + func TestApplyModelHeaderOverridesMultipleHeaders(t *testing.T) { reg := registry.GetGlobalRegistry() clientID := "test-model-header-override" diff --git a/internal/runtime/executor/codex_websockets_request.go b/internal/runtime/executor/codex_websockets_request.go index 2c75bff62ea..8cb89256b57 100644 --- a/internal/runtime/executor/codex_websockets_request.go +++ b/internal/runtime/executor/codex_websockets_request.go @@ -57,8 +57,8 @@ func applyCodexPromptCacheHeadersWithContext(ctx context.Context, from sdktransl if cache.ID != "" { rawJSON = helps.SetStringIfDifferent(rawJSON, "prompt_cache_key", cache.ID) - setHeaderCasePreserved(headers, "session_id", cache.ID) - headers.Set("Conversation_id", cache.ID) + // Real Codex clients only carry the canonical Session-Id spelling and never send Conversation_id. + setHeaderCasePreserved(headers, "Session-Id", cache.ID) } return rawJSON, headers, nil @@ -112,6 +112,7 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * } ensureCodexWebsocketSessionHeader(headers, ginHeaders, sessionFallback) if nativeRequest && cfg != nil && cfg.Codex.DisableCodexCloaking { + deleteHeaderCaseInsensitive(headers, "Session-Id") deleteHeaderCaseInsensitive(headers, "session_id") deleteHeaderCaseInsensitive(headers, "conversation_id") for key, values := range ginHeaders { @@ -160,9 +161,10 @@ func ensureCodexWebsocketSessionHeader(target http.Header, source http.Header, f sessionID = strings.TrimSpace(fallbackValue) } if sessionID != "" { - setHeaderCasePreserved(target, "session_id", sessionID) + setHeaderCasePreserved(target, "Session-Id", sessionID) } - deleteHeaderCaseInsensitive(target, "Session-Id") + // The underscore spelling is never produced by a real Codex client. + deleteHeaderCaseInsensitive(target, "session_id") } func codexSessionHeaderValue(headers http.Header) string { diff --git a/internal/runtime/executor/helps/codex_client_identity.go b/internal/runtime/executor/helps/codex_client_identity.go new file mode 100644 index 00000000000..239b10c7881 --- /dev/null +++ b/internal/runtime/executor/helps/codex_client_identity.go @@ -0,0 +1,53 @@ +package helps + +import ( + "regexp" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" +) + +// codexFirstPartyOriginators lists the originator tokens the official Codex clients use. +var codexFirstPartyOriginators = map[string]struct{}{ + "codex_cli_rs": {}, + "codex-tui": {}, + "codex_vscode": {}, + "codex_exec": {}, +} + +// codexClientVersionPattern matches a semver-ish client version token such as 0.154.0 or 1.2.3-beta.1. +var codexClientVersionPattern = regexp.MustCompile(`^[0-9]+(?:\.[0-9]+)*(?:[-+][0-9A-Za-z.-]+)?$`) + +// IsFirstPartyCodexIdentity reports whether the pair of headers forms a coherent first-party Codex +// identity: the originator must be a known first-party token (or carry the "Codex " prefix) and the +// User-Agent must start with that same originator followed by "/" and a version token. +// The originator set follows is_first_party_originator in the Codex CLI, plus codex_exec, which the +// CLI emits in exec mode but omits from that particular whitelist. +func IsFirstPartyCodexIdentity(userAgent, originator string) bool { + ua := strings.TrimSpace(userAgent) + origin := strings.TrimSpace(originator) + if ua == "" || origin == "" { + return false + } + if _, ok := codexFirstPartyOriginators[origin]; !ok && !strings.HasPrefix(origin, "Codex ") { + return false + } + prefix := origin + "/" + if !strings.HasPrefix(ua, prefix) { + return false + } + version := ua[len(prefix):] + if idx := strings.IndexAny(version, " \t"); idx >= 0 { + version = version[:idx] + } + return codexClientVersionPattern.MatchString(version) +} + +// PreserveNativeCodexIdentity reports whether a coherent downstream Codex identity should be kept +// instead of being replaced by the built-in Codex identity. Unset configuration means enabled. +func PreserveNativeCodexIdentity(cfg *config.Config) bool { + if cfg == nil || cfg.Codex.PreserveNativeClientIdentity == nil { + return true + } + return *cfg.Codex.PreserveNativeClientIdentity +} diff --git a/internal/runtime/executor/helps/codex_client_identity_test.go b/internal/runtime/executor/helps/codex_client_identity_test.go new file mode 100644 index 00000000000..dfba509a99c --- /dev/null +++ b/internal/runtime/executor/helps/codex_client_identity_test.go @@ -0,0 +1,48 @@ +package helps + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" +) + +func TestIsFirstPartyCodexIdentity(t *testing.T) { + cases := []struct { + name string + userAgent string + originator string + want bool + }{ + {"real tui", "codex-tui/0.154.0 (Mac OS 15.7.9; arm64) Apple_Terminal (codex-tui; 0.154.0)", "codex-tui", true}, + {"codex prefix", "Codex Desktop/1.2.3 (Mac OS 15.7.9)", "Codex Desktop", true}, + {"vscode", "codex_vscode/1.0.0", "codex_vscode", true}, + {"exec", "codex_exec/0.1", "codex_exec", true}, + {"cli rs", "codex_cli_rs/1.2.3-beta.1 (x)", "codex_cli_rs", true}, + {"mismatched pair", "codex-tui/0.154.0 (Mac OS 15.7.9)", "Codex Desktop", false}, + {"third party", "my-proxy/1.0.0", "my-proxy", false}, + {"no version", "codex-tui/ (x)", "codex-tui", false}, + {"non numeric version", "codex-tui/dev", "codex-tui", false}, + {"empty user agent", "", "codex-tui", false}, + {"empty originator", "codex-tui/0.154.0", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsFirstPartyCodexIdentity(tc.userAgent, tc.originator); got != tc.want { + t.Errorf("IsFirstPartyCodexIdentity(%q, %q) = %t, want %t", tc.userAgent, tc.originator, got, tc.want) + } + }) + } +} + +func TestPreserveNativeCodexIdentity(t *testing.T) { + if !PreserveNativeCodexIdentity(nil) { + t.Error("nil config should preserve") + } + if !PreserveNativeCodexIdentity(&config.Config{}) { + t.Error("unset field should preserve") + } + disabled := false + if PreserveNativeCodexIdentity(&config.Config{Codex: config.CodexConfig{PreserveNativeClientIdentity: &disabled}}) { + t.Error("explicit false should not preserve") + } +} diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index 893a334d12f..826e93a47a7 100644 --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -143,6 +143,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if oldCfg.Codex.DisableCodexCloaking != newCfg.Codex.DisableCodexCloaking { changes = append(changes, fmt.Sprintf("codex.disable-codex-cloaking: %t -> %t", oldCfg.Codex.DisableCodexCloaking, newCfg.Codex.DisableCodexCloaking)) } + if boolPtrValue(oldCfg.Codex.PreserveNativeClientIdentity, true) != boolPtrValue(newCfg.Codex.PreserveNativeClientIdentity, true) { + changes = append(changes, fmt.Sprintf("codex.preserve-native-client-identity: %t -> %t", boolPtrValue(oldCfg.Codex.PreserveNativeClientIdentity, true), boolPtrValue(newCfg.Codex.PreserveNativeClientIdentity, true))) + } if oldCfg.Codex.StreamBootstrapBuffering != newCfg.Codex.StreamBootstrapBuffering { changes = append(changes, fmt.Sprintf("codex.stream-bootstrap-buffering: %t -> %t", oldCfg.Codex.StreamBootstrapBuffering, newCfg.Codex.StreamBootstrapBuffering)) } @@ -660,3 +663,11 @@ func formatURL(raw string) string { } return scheme + "://" + host } + +// boolPtrValue dereferences an optional bool, falling back to def when unset. +func boolPtrValue(v *bool, def bool) bool { + if v == nil { + return def + } + return *v +} From b4cdcadf0602cf8e898bd3e00181fbf56630b6f4 Mon Sep 17 00:00:00 2001 From: George Liu Date: Sat, 19 Sep 2026 21:52:27 +0100 Subject: [PATCH 4/8] fix(codex): prefer native user agent over defaults --- config.example.yaml | 4 +++ .../executor/codex_executor_request.go | 6 +++- .../executor/codex_websockets_request.go | 2 ++ .../executor/helps/codex_client_identity.go | 18 ++++++++++ .../helps/codex_client_identity_test.go | 35 +++++++++++++++++++ 5 files changed, 64 insertions(+), 1 deletion(-) diff --git a/config.example.yaml b/config.example.yaml index ff3199551b0..d2821776bd2 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -269,6 +269,7 @@ codex: # Keep the downstream User-Agent and Originator when the request already presents a coherent # first-party Codex identity (originator codex_cli_rs / codex-tui / codex_vscode / codex_exec, or # prefixed "Codex ", together with a matching "/" User-Agent). + # Such a User-Agent also takes precedence over codex-header-defaults.user-agent. # All other requests are still cloaked. Ignored when disable-codex-cloaking is true. preserve-native-client-identity: true # Hold back the frames that carry nothing the client has seen - the handshake (response.created, @@ -722,6 +723,9 @@ nonstream-keepalive-interval: 0 # These are used only for file-backed/OAuth Codex requests when the client # does not send the header. `user-agent` applies to HTTP and websocket requests; # `beta-features` only applies to websocket requests. They do not apply to codex-api-key entries. +# `user-agent` is also skipped when the client already presents a coherent first-party Codex +# identity and preserve-native-client-identity is enabled, so a genuine Codex client keeps its own +# version instead of being relabelled with the value configured here. # codex-header-defaults: # user-agent: "codex_cli_rs/0.114.0 (Mac OS 14.2.0; x86_64) vscode/1.111.0" # beta-features: "multi_agent" diff --git a/internal/runtime/executor/codex_executor_request.go b/internal/runtime/executor/codex_executor_request.go index c95e724c7e6..c00b57aff18 100644 --- a/internal/runtime/executor/codex_executor_request.go +++ b/internal/runtime/executor/codex_executor_request.go @@ -351,7 +351,11 @@ func applyCodexHeadersFromSources(r *http.Request, auth *cliproxyauth.Auth, toke misc.EnsureHeader(r.Header, ginHeaders, "X-Openai-Internal-Codex-Responses-Lite", "") cfgUserAgent, _ := codexHeaderDefaults(cfg, auth) - ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent) + if nativeUserAgent := helps.NativeCodexUserAgent(cfg, ginHeaders); nativeUserAgent != "" { + r.Header.Set("User-Agent", nativeUserAgent) + } else { + ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent) + } if stream { r.Header.Set("Accept", "text/event-stream") diff --git a/internal/runtime/executor/codex_websockets_request.go b/internal/runtime/executor/codex_websockets_request.go index 8cb89256b57..4f801108a88 100644 --- a/internal/runtime/executor/codex_websockets_request.go +++ b/internal/runtime/executor/codex_websockets_request.go @@ -94,6 +94,8 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * } if isAPIKey { ensureHeaderWithPriority(headers, ginHeaders, "User-Agent", "", "") + } else if nativeUserAgent := helps.NativeCodexUserAgent(cfg, ginHeaders); nativeUserAgent != "" { + headers.Set("User-Agent", nativeUserAgent) } else { ensureHeaderWithConfigPrecedence(headers, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent) } diff --git a/internal/runtime/executor/helps/codex_client_identity.go b/internal/runtime/executor/helps/codex_client_identity.go index 239b10c7881..44636c20ec6 100644 --- a/internal/runtime/executor/helps/codex_client_identity.go +++ b/internal/runtime/executor/helps/codex_client_identity.go @@ -1,6 +1,7 @@ package helps import ( + "net/http" "regexp" "strings" @@ -51,3 +52,20 @@ func PreserveNativeCodexIdentity(cfg *config.Config) bool { } return *cfg.Codex.PreserveNativeClientIdentity } + +// NativeCodexUserAgent returns the downstream User-Agent when identity preservation is enabled and +// the downstream request already presents a coherent first-party Codex identity. Callers keep that +// value instead of the configured default so the User-Agent follows the same downstream-first rule +// as Originator; otherwise a configured default would silently rewrite a genuine client's version +// while the body and x-codex-turn-metadata still carry the real one. An empty result means no +// native identity was presented and the usual config-first precedence applies. +func NativeCodexUserAgent(cfg *config.Config, source http.Header) string { + if source == nil || !PreserveNativeCodexIdentity(cfg) { + return "" + } + userAgent := strings.TrimSpace(source.Get("User-Agent")) + if !IsFirstPartyCodexIdentity(userAgent, source.Get("Originator")) { + return "" + } + return userAgent +} diff --git a/internal/runtime/executor/helps/codex_client_identity_test.go b/internal/runtime/executor/helps/codex_client_identity_test.go index dfba509a99c..610e60cc559 100644 --- a/internal/runtime/executor/helps/codex_client_identity_test.go +++ b/internal/runtime/executor/helps/codex_client_identity_test.go @@ -1,6 +1,7 @@ package helps import ( + "net/http" "testing" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" @@ -46,3 +47,37 @@ func TestPreserveNativeCodexIdentity(t *testing.T) { t.Error("explicit false should not preserve") } } + +func TestNativeCodexUserAgent(t *testing.T) { + const nativeUserAgent = "codex-tui/0.155.1 (Mac OS 15.7.9; arm64) Apple_Terminal/455.1 (codex-tui; 0.155.1)" + + nativeHeaders := func() http.Header { + headers := http.Header{} + headers.Set("User-Agent", nativeUserAgent) + headers.Set("Originator", "codex-tui") + return headers + } + + if got := NativeCodexUserAgent(&config.Config{}, nativeHeaders()); got != nativeUserAgent { + t.Errorf("first-party identity = %q, want the downstream value", got) + } + if got := NativeCodexUserAgent(nil, nativeHeaders()); got != nativeUserAgent { + t.Errorf("nil config = %q, want the downstream value", got) + } + if got := NativeCodexUserAgent(&config.Config{}, nil); got != "" { + t.Errorf("nil headers = %q, want empty", got) + } + + disabled := false + cfgDisabled := &config.Config{Codex: config.CodexConfig{PreserveNativeClientIdentity: &disabled}} + if got := NativeCodexUserAgent(cfgDisabled, nativeHeaders()); got != "" { + t.Errorf("preservation disabled = %q, want empty so the configured default applies", got) + } + + thirdParty := http.Header{} + thirdParty.Set("User-Agent", "my-proxy/1.0.0") + thirdParty.Set("Originator", "my-proxy") + if got := NativeCodexUserAgent(&config.Config{}, thirdParty); got != "" { + t.Errorf("third-party identity = %q, want empty so cloaking applies", got) + } +} From b3abf6079d3df56cb6430f4c51878fe3f219a18d Mon Sep 17 00:00:00 2001 From: George Liu Date: Tue, 29 Sep 2026 00:18:24 +0100 Subject: [PATCH 5/8] feat(registry): expose Claude Sonnet 5.5 --- internal/registry/models/models.json | 32 ++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json index 735526b5416..048f80a82ad 100644 --- a/internal/registry/models/models.json +++ b/internal/registry/models/models.json @@ -222,6 +222,38 @@ "text" ] }, + { + "id": "claude-sonnet-5-5", + "native_capabilities": { + "web_search": true + }, + "object": "model", + "created": 1790640000, + "owned_by": "anthropic", + "type": "claude", + "display_name": "Claude Sonnet 5.5", + "description": "Anthropic's Sonnet 5.5 model for coding, tool use, and enterprise workflows", + "context_length": 1000000, + "max_completion_tokens": 128000, + "thinking": { + "zero_allowed": true, + "dynamic_allowed": true, + "levels": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + "supportedInputModalities": [ + "text", + "image" + ], + "supportedOutputModalities": [ + "text" + ] + }, { "id": "claude-fable-5", "object": "model", From 92f552c0695de6e872695d4d56439518ee1005ab Mon Sep 17 00:00:00 2001 From: George Liu Date: Tue, 29 Sep 2026 00:21:32 +0100 Subject: [PATCH 6/8] Revert "feat(registry): expose Claude Sonnet 5.5" This reverts commit b3abf6079d3df56cb6430f4c51878fe3f219a18d. --- internal/registry/models/models.json | 32 ---------------------------- 1 file changed, 32 deletions(-) diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json index 048f80a82ad..735526b5416 100644 --- a/internal/registry/models/models.json +++ b/internal/registry/models/models.json @@ -222,38 +222,6 @@ "text" ] }, - { - "id": "claude-sonnet-5-5", - "native_capabilities": { - "web_search": true - }, - "object": "model", - "created": 1790640000, - "owned_by": "anthropic", - "type": "claude", - "display_name": "Claude Sonnet 5.5", - "description": "Anthropic's Sonnet 5.5 model for coding, tool use, and enterprise workflows", - "context_length": 1000000, - "max_completion_tokens": 128000, - "thinking": { - "zero_allowed": true, - "dynamic_allowed": true, - "levels": [ - "low", - "medium", - "high", - "xhigh", - "max" - ] - }, - "supportedInputModalities": [ - "text", - "image" - ], - "supportedOutputModalities": [ - "text" - ] - }, { "id": "claude-fable-5", "object": "model", From c8d7892a9a2c068945559497659d7c362bc434b7 Mon Sep 17 00:00:00 2001 From: George Liu Date: Tue, 29 Sep 2026 01:52:13 +0100 Subject: [PATCH 7/8] feat(registry): keep Claude Sonnet 5.5 through remote refresh --- internal/registry/model_updater.go | 20 ++++++++++++++++ internal/registry/model_updater_test.go | 19 +++++++++++++++ internal/registry/models/models.json | 32 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/internal/registry/model_updater.go b/internal/registry/model_updater.go index 5a21f48822a..9383ebe2afd 100644 --- a/internal/registry/model_updater.go +++ b/internal/registry/model_updater.go @@ -125,6 +125,9 @@ func tryRefreshModels(ctx context.Context, label string) { if len(parsed.Meta) == 0 && oldData != nil && len(oldData.Meta) > 0 { parsed.Meta = oldData.Meta } + if oldData != nil { + parsed.Claude = preserveClaudeSonnet55(parsed.Claude, oldData.Claude) + } // Detect changes before updating store. changed := detectChangedProviders(oldData, parsed) @@ -143,6 +146,23 @@ func tryRefreshModels(ctx context.Context, label string) { notifyModelRefresh(changed) } +// preserveClaudeSonnet55 keeps the locally supported model until the remote +// catalog includes it, without overriding newer remote metadata when it does. +func preserveClaudeSonnet55(remote, current []*ModelInfo) []*ModelInfo { + const modelID = "claude-sonnet-5-5" + for _, model := range remote { + if model != nil && model.ID == modelID { + return remote + } + } + for _, model := range current { + if model != nil && model.ID == modelID { + return append(remote, cloneModelInfo(model)) + } + } + return remote +} + // fetchModelsFromRemote tries all remote URLs and returns the parsed model catalog // along with the URL it was fetched from. Returns (nil, "") if all fetches fail. func fetchModelsFromRemote(ctx context.Context) (*staticModelsJSON, string) { diff --git a/internal/registry/model_updater_test.go b/internal/registry/model_updater_test.go index eaab319ccdd..acffa476673 100644 --- a/internal/registry/model_updater_test.go +++ b/internal/registry/model_updater_test.go @@ -4,6 +4,25 @@ import ( "testing" ) +func TestPreserveClaudeSonnet55AcrossRemoteRefresh(t *testing.T) { + current := []*ModelInfo{{ID: "claude-sonnet-5-5", DisplayName: "Local Sonnet 5.5"}} + remote := []*ModelInfo{{ID: "claude-sonnet-5"}} + merged := preserveClaudeSonnet55(remote, current) + if len(merged) != 2 || merged[1].ID != "claude-sonnet-5-5" { + t.Fatalf("missing local Sonnet 5.5 after refresh: %+v", merged) + } + merged[1].DisplayName = "changed" + if current[0].DisplayName != "Local Sonnet 5.5" { + t.Fatal("remote catalog reused the local model pointer") + } + + remote = []*ModelInfo{{ID: "claude-sonnet-5-5", DisplayName: "Remote Sonnet 5.5"}} + merged = preserveClaudeSonnet55(remote, current) + if len(merged) != 1 || merged[0].DisplayName != "Remote Sonnet 5.5" { + t.Fatalf("remote Sonnet 5.5 should take precedence: %+v", merged) + } +} + func TestDetectChangedProviders_CodexConfigurationUpdate(t *testing.T) { oldData := &staticModelsJSON{ CodexFree: []*ModelInfo{{ID: "gpt-6-luna"}}, diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json index 735526b5416..048f80a82ad 100644 --- a/internal/registry/models/models.json +++ b/internal/registry/models/models.json @@ -222,6 +222,38 @@ "text" ] }, + { + "id": "claude-sonnet-5-5", + "native_capabilities": { + "web_search": true + }, + "object": "model", + "created": 1790640000, + "owned_by": "anthropic", + "type": "claude", + "display_name": "Claude Sonnet 5.5", + "description": "Anthropic's Sonnet 5.5 model for coding, tool use, and enterprise workflows", + "context_length": 1000000, + "max_completion_tokens": 128000, + "thinking": { + "zero_allowed": true, + "dynamic_allowed": true, + "levels": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + "supportedInputModalities": [ + "text", + "image" + ], + "supportedOutputModalities": [ + "text" + ] + }, { "id": "claude-fable-5", "object": "model", From dbfc19952ee1befff5add3a7b5468c68ee2e2c59 Mon Sep 17 00:00:00 2001 From: George Liu Date: Wed, 30 Sep 2026 00:00:36 +0100 Subject: [PATCH 8/8] Revert Claude Sonnet 5.5 registry override --- internal/registry/model_updater.go | 20 ---------------- internal/registry/model_updater_test.go | 19 --------------- internal/registry/models/models.json | 32 ------------------------- 3 files changed, 71 deletions(-) diff --git a/internal/registry/model_updater.go b/internal/registry/model_updater.go index 9383ebe2afd..5a21f48822a 100644 --- a/internal/registry/model_updater.go +++ b/internal/registry/model_updater.go @@ -125,9 +125,6 @@ func tryRefreshModels(ctx context.Context, label string) { if len(parsed.Meta) == 0 && oldData != nil && len(oldData.Meta) > 0 { parsed.Meta = oldData.Meta } - if oldData != nil { - parsed.Claude = preserveClaudeSonnet55(parsed.Claude, oldData.Claude) - } // Detect changes before updating store. changed := detectChangedProviders(oldData, parsed) @@ -146,23 +143,6 @@ func tryRefreshModels(ctx context.Context, label string) { notifyModelRefresh(changed) } -// preserveClaudeSonnet55 keeps the locally supported model until the remote -// catalog includes it, without overriding newer remote metadata when it does. -func preserveClaudeSonnet55(remote, current []*ModelInfo) []*ModelInfo { - const modelID = "claude-sonnet-5-5" - for _, model := range remote { - if model != nil && model.ID == modelID { - return remote - } - } - for _, model := range current { - if model != nil && model.ID == modelID { - return append(remote, cloneModelInfo(model)) - } - } - return remote -} - // fetchModelsFromRemote tries all remote URLs and returns the parsed model catalog // along with the URL it was fetched from. Returns (nil, "") if all fetches fail. func fetchModelsFromRemote(ctx context.Context) (*staticModelsJSON, string) { diff --git a/internal/registry/model_updater_test.go b/internal/registry/model_updater_test.go index acffa476673..eaab319ccdd 100644 --- a/internal/registry/model_updater_test.go +++ b/internal/registry/model_updater_test.go @@ -4,25 +4,6 @@ import ( "testing" ) -func TestPreserveClaudeSonnet55AcrossRemoteRefresh(t *testing.T) { - current := []*ModelInfo{{ID: "claude-sonnet-5-5", DisplayName: "Local Sonnet 5.5"}} - remote := []*ModelInfo{{ID: "claude-sonnet-5"}} - merged := preserveClaudeSonnet55(remote, current) - if len(merged) != 2 || merged[1].ID != "claude-sonnet-5-5" { - t.Fatalf("missing local Sonnet 5.5 after refresh: %+v", merged) - } - merged[1].DisplayName = "changed" - if current[0].DisplayName != "Local Sonnet 5.5" { - t.Fatal("remote catalog reused the local model pointer") - } - - remote = []*ModelInfo{{ID: "claude-sonnet-5-5", DisplayName: "Remote Sonnet 5.5"}} - merged = preserveClaudeSonnet55(remote, current) - if len(merged) != 1 || merged[0].DisplayName != "Remote Sonnet 5.5" { - t.Fatalf("remote Sonnet 5.5 should take precedence: %+v", merged) - } -} - func TestDetectChangedProviders_CodexConfigurationUpdate(t *testing.T) { oldData := &staticModelsJSON{ CodexFree: []*ModelInfo{{ID: "gpt-6-luna"}}, diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json index 048f80a82ad..735526b5416 100644 --- a/internal/registry/models/models.json +++ b/internal/registry/models/models.json @@ -222,38 +222,6 @@ "text" ] }, - { - "id": "claude-sonnet-5-5", - "native_capabilities": { - "web_search": true - }, - "object": "model", - "created": 1790640000, - "owned_by": "anthropic", - "type": "claude", - "display_name": "Claude Sonnet 5.5", - "description": "Anthropic's Sonnet 5.5 model for coding, tool use, and enterprise workflows", - "context_length": 1000000, - "max_completion_tokens": 128000, - "thinking": { - "zero_allowed": true, - "dynamic_allowed": true, - "levels": [ - "low", - "medium", - "high", - "xhigh", - "max" - ] - }, - "supportedInputModalities": [ - "text", - "image" - ], - "supportedOutputModalities": [ - "text" - ] - }, { "id": "claude-fable-5", "object": "model",