diff --git a/config.example.yaml b/config.example.yaml index cb4d4e9d5c8..587d98da583 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -348,8 +348,8 @@ upstream: # 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, # the stream is released to the client and further in-stream overloads are delivered rather @@ -366,8 +366,8 @@ upstream: # 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, enable opt-in compatibility for orphan Codex delegation outputs. # Converts orphan function_call_output items from codex_app/create_thread and # codex_app/send_message_to_thread (which lack a valid call_id or matching function_call) @@ -999,6 +999,9 @@ oauth: # Codex provider behavior. codex: + # Preserve a coherent first-party Codex client's User-Agent and Originator. + # Other requests still use the configured Codex identity headers. + preserve-native-client-identity: true # Terminate and relay Codex Live WebRTC audio and DataChannel traffic in this process. # This requires inbound UDP reachability. Keep disabled to preserve direct media behavior. live-media-relay: @@ -1030,6 +1033,7 @@ oauth: # 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 api-keys.codex entries. + # A native Codex User-Agent takes precedence when preserve-native-client-identity is enabled. # 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/config/codex_websocket_header_defaults_test.go b/internal/config/codex_websocket_header_defaults_test.go index c86fc6c93e1..f4919ae0766 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..a51724e7e51 100644 --- a/internal/config/config_defaults.go +++ b/internal/config/config_defaults.go @@ -5,4 +5,23 @@ 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 + // Preserving a coherent first-party Codex identity is the default behavior. + preserveNativeClientIdentity := true + cfg.Codex.PreserveNativeClientIdentity = &preserveNativeClientIdentity +} + +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 20780461ffc..1919925599b 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 c3584aa11c1..2f67cbb3f1f 100644 --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -182,6 +182,12 @@ type AntigravityConnectionPoolConfig struct { type CodexConfig struct { // 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 @@ -199,11 +205,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"` @@ -220,13 +226,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 9172a2314d7..27553712ac7 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 ccac615f73b..bf527212ad5 100644 --- a/internal/config/sdk_config.go +++ b/internal/config/sdk_config.go @@ -23,12 +23,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/internal/runtime/executor/codex_executor_request.go b/internal/runtime/executor/codex_executor_request.go index af8790c2f54..6fbb51f1db3 100644 --- a/internal/runtime/executor/codex_executor_request.go +++ b/internal/runtime/executor/codex_executor_request.go @@ -168,8 +168,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()) } } @@ -208,14 +208,17 @@ 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") } else { r.Header.Set("Accept", "application/json") } - r.Header.Set("Connection", "Keep-Alive") isAPIKey := codexAuthUsesAPIKey(auth) if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" { @@ -310,10 +313,16 @@ func isCodexCloakingDisabled(cfg *config.Config, auth *cliproxyauth.Auth) bool { return false } +// 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, auth *cliproxyauth.Auth) { if headers == nil || cfg == nil || isCodexCloakingDisabled(cfg, auth) { 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 8e84638297b..a3de8f027d0 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, nil) + 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 47e957f3f75..65f29fd420c 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) @@ -1829,7 +1829,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") @@ -1838,6 +1844,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 cd7bd5f3b37..45ea7e539b4 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 @@ -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) } @@ -112,6 +114,7 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * } ensureCodexWebsocketSessionHeader(headers, ginHeaders, sessionFallback) if nativeRequest && isCodexCloakingDisabled(cfg, auth) { + deleteHeaderCaseInsensitive(headers, "Session-Id") deleteHeaderCaseInsensitive(headers, "session_id") deleteHeaderCaseInsensitive(headers, "conversation_id") for key, values := range ginHeaders { @@ -160,9 +163,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..0e09450bee3 --- /dev/null +++ b/internal/runtime/executor/helps/codex_client_identity.go @@ -0,0 +1,71 @@ +package helps + +import ( + "net/http" + "regexp" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v8/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 +} + +// 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 new file mode 100644 index 00000000000..6328381b22b --- /dev/null +++ b/internal/runtime/executor/helps/codex_client_identity_test.go @@ -0,0 +1,83 @@ +package helps + +import ( + "net/http" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v8/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") + } +} + +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) + } +} diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index b08cc6d9431..d19aecbcca0 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)) } @@ -669,3 +672,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 +} diff --git a/sdk/api/handlers/claude/code_handlers.go b/sdk/api/handlers/claude/code_handlers.go index 08d37bfe800..71aac793b36 100644 --- a/sdk/api/handlers/claude/code_handlers.go +++ b/sdk/api/handlers/claude/code_handlers.go @@ -86,6 +86,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 { @@ -151,6 +155,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..597f61ce5b9 --- /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/v8/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) + } +} diff --git a/test/codex_quota_failover_test.go b/test/codex_quota_failover_test.go index 1b4a293f674..a72114d2d25 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) }