diff --git a/config.example.yaml b/config.example.yaml index 40bb8721..16be5c36 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -173,6 +173,14 @@ nonstream-keepalive-interval: 0 # runtime-version: "v24.3.0" # timeout: "600" +# Default headers for Codex OAuth model requests. +# 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. +# codex-header-defaults: +# user-agent: "my-codex-client/1.0" +# beta-features: "feature-a,feature-b" + # OpenAI compatibility providers # openai-compatibility: # - name: "openrouter" # The name of the provider; it will be used in the user agent and other places. diff --git a/internal/config/codex_websocket_header_defaults_test.go b/internal/config/codex_websocket_header_defaults_test.go new file mode 100644 index 00000000..49947c1c --- /dev/null +++ b/internal/config/codex_websocket_header_defaults_test.go @@ -0,0 +1,32 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfigOptional_CodexHeaderDefaults(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + configYAML := []byte(` +codex-header-defaults: + user-agent: " my-codex-client/1.0 " + beta-features: " feature-a,feature-b " +`) + if err := os.WriteFile(configPath, configYAML, 0o600); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg, err := LoadConfigOptional(configPath, false) + if err != nil { + t.Fatalf("LoadConfigOptional() error = %v", err) + } + + if got := cfg.CodexHeaderDefaults.UserAgent; got != "my-codex-client/1.0" { + t.Fatalf("UserAgent = %q, want %q", got, "my-codex-client/1.0") + } + if got := cfg.CodexHeaderDefaults.BetaFeatures; got != "feature-a,feature-b" { + t.Fatalf("BetaFeatures = %q, want %q", got, "feature-a,feature-b") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 5a6595f7..7bd137e0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -90,6 +90,10 @@ type Config struct { // Codex defines a list of Codex API key configurations as specified in the YAML configuration file. CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"` + // CodexHeaderDefaults configures fallback headers for Codex OAuth model requests. + // These are used only when the client does not send its own headers. + CodexHeaderDefaults CodexHeaderDefaults `yaml:"codex-header-defaults" json:"codex-header-defaults"` + // ClaudeKey defines a list of Claude API key configurations as specified in the YAML configuration file. ClaudeKey []ClaudeKey `yaml:"claude-api-key" json:"claude-api-key"` @@ -133,6 +137,14 @@ type ClaudeHeaderDefaults struct { Timeout string `yaml:"timeout" json:"timeout"` } +// CodexHeaderDefaults configures fallback header values injected into Codex +// model requests for OAuth/file-backed auth when the client omits them. +// UserAgent applies to HTTP and websocket requests; BetaFeatures only applies to websockets. +type CodexHeaderDefaults struct { + UserAgent string `yaml:"user-agent" json:"user-agent"` + BetaFeatures string `yaml:"beta-features" json:"beta-features"` +} + // TLSConfig holds HTTPS server settings. type TLSConfig struct { // Enable toggles HTTPS server mode. @@ -615,6 +627,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { // Sanitize Codex keys: drop entries without base-url cfg.SanitizeCodexKeys() + // Sanitize Codex header defaults. + cfg.SanitizeCodexHeaderDefaults() + // Sanitize Claude key headers cfg.SanitizeClaudeKeys() @@ -704,6 +719,16 @@ func payloadRawString(value any) ([]byte, bool) { } } +// SanitizeCodexHeaderDefaults trims surrounding whitespace from the +// configured Codex header fallback values. +func (cfg *Config) SanitizeCodexHeaderDefaults() { + if cfg == nil { + return + } + cfg.CodexHeaderDefaults.UserAgent = strings.TrimSpace(cfg.CodexHeaderDefaults.UserAgent) + cfg.CodexHeaderDefaults.BetaFeatures = strings.TrimSpace(cfg.CodexHeaderDefaults.BetaFeatures) +} + // SanitizeOAuthModelAlias normalizes and deduplicates global OAuth model name aliases. // It trims whitespace, normalizes channel keys to lower-case, drops empty entries, // allows multiple aliases per upstream name, and ensures aliases are unique within each channel. diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 30092ec7..4fb22919 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -122,7 +122,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re if err != nil { return resp, err } - applyCodexHeaders(httpReq, auth, apiKey, true) + applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -226,7 +226,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A if err != nil { return resp, err } - applyCodexHeaders(httpReq, auth, apiKey, false) + applyCodexHeaders(httpReq, auth, apiKey, false, e.cfg) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -321,7 +321,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au if err != nil { return nil, err } - applyCodexHeaders(httpReq, auth, apiKey, true) + applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -636,7 +636,7 @@ func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Form return httpReq, nil } -func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool) { +func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config) { r.Header.Set("Content-Type", "application/json") r.Header.Set("Authorization", "Bearer "+token) @@ -647,7 +647,8 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s misc.EnsureHeader(r.Header, ginHeaders, "Version", codexClientVersion) misc.EnsureHeader(r.Header, ginHeaders, "Session_id", uuid.NewString()) - misc.EnsureHeader(r.Header, ginHeaders, "User-Agent", codexUserAgent) + cfgUserAgent, _ := codexHeaderDefaults(cfg, auth) + 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_executor.go b/internal/runtime/executor/codex_websockets_executor.go index 1f340050..2a4f4a3f 100644 --- a/internal/runtime/executor/codex_websockets_executor.go +++ b/internal/runtime/executor/codex_websockets_executor.go @@ -190,7 +190,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut } body, wsHeaders := applyCodexPromptCacheHeaders(from, req, body) - wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey) + wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg) var authID, authLabel, authType, authValue string if auth != nil { @@ -385,7 +385,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr } body, wsHeaders := applyCodexPromptCacheHeaders(from, req, body) - wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey) + wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg) var authID, authLabel, authType, authValue string authID = auth.ID @@ -787,7 +787,7 @@ func applyCodexPromptCacheHeaders(from sdktranslator.Format, req cliproxyexecuto return rawJSON, headers } -func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *cliproxyauth.Auth, token string) http.Header { +func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *cliproxyauth.Auth, token string, cfg *config.Config) http.Header { if headers == nil { headers = http.Header{} } @@ -800,7 +800,8 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * ginHeaders = ginCtx.Request.Header } - misc.EnsureHeader(headers, ginHeaders, "x-codex-beta-features", "") + cfgUserAgent, cfgBetaFeatures := codexHeaderDefaults(cfg, auth) + ensureHeaderWithPriority(headers, ginHeaders, "x-codex-beta-features", cfgBetaFeatures, "") misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-state", "") misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-metadata", "") misc.EnsureHeader(headers, ginHeaders, "x-responsesapi-include-timing-metrics", "") @@ -815,7 +816,7 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * } headers.Set("OpenAI-Beta", betaHeader) misc.EnsureHeader(headers, ginHeaders, "Session_id", uuid.NewString()) - misc.EnsureHeader(headers, ginHeaders, "User-Agent", codexUserAgent) + ensureHeaderWithConfigPrecedence(headers, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent) isAPIKey := false if auth != nil && auth.Attributes != nil { @@ -843,6 +844,62 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * return headers } +func codexHeaderDefaults(cfg *config.Config, auth *cliproxyauth.Auth) (string, string) { + if cfg == nil || auth == nil { + return "", "" + } + if auth.Attributes != nil { + if v := strings.TrimSpace(auth.Attributes["api_key"]); v != "" { + return "", "" + } + } + return strings.TrimSpace(cfg.CodexHeaderDefaults.UserAgent), strings.TrimSpace(cfg.CodexHeaderDefaults.BetaFeatures) +} + +func ensureHeaderWithPriority(target http.Header, source http.Header, key, configValue, fallbackValue string) { + if target == nil { + return + } + if strings.TrimSpace(target.Get(key)) != "" { + return + } + if source != nil { + if val := strings.TrimSpace(source.Get(key)); val != "" { + target.Set(key, val) + return + } + } + if val := strings.TrimSpace(configValue); val != "" { + target.Set(key, val) + return + } + if val := strings.TrimSpace(fallbackValue); val != "" { + target.Set(key, val) + } +} + +func ensureHeaderWithConfigPrecedence(target http.Header, source http.Header, key, configValue, fallbackValue string) { + if target == nil { + return + } + if strings.TrimSpace(target.Get(key)) != "" { + return + } + if val := strings.TrimSpace(configValue); val != "" { + target.Set(key, val) + return + } + if source != nil { + if val := strings.TrimSpace(source.Get(key)); val != "" { + target.Set(key, val) + return + } + } + if val := strings.TrimSpace(fallbackValue); val != "" { + target.Set(key, val) + } +} + type statusErrWithHeaders struct { statusErr headers http.Header diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go index 1fd68513..e1335386 100644 --- a/internal/runtime/executor/codex_websockets_executor_test.go +++ b/internal/runtime/executor/codex_websockets_executor_test.go @@ -3,8 +3,12 @@ package executor import ( "context" "net/http" + "net/http/httptest" "testing" + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" "github.com/tidwall/gjson" ) @@ -28,9 +32,158 @@ func TestBuildCodexWebsocketRequestBodyPreservesPreviousResponseID(t *testing.T) } func TestApplyCodexWebsocketHeadersDefaultsToCurrentResponsesBeta(t *testing.T) { - headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, nil, "") + headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, nil, "", nil) if got := headers.Get("OpenAI-Beta"); got != codexResponsesWebsocketBetaHeaderValue { t.Fatalf("OpenAI-Beta = %s, want %s", got, codexResponsesWebsocketBetaHeaderValue) } + if got := headers.Get("User-Agent"); got != codexUserAgent { + t.Fatalf("User-Agent = %s, want %s", got, codexUserAgent) + } + if got := headers.Get("x-codex-beta-features"); got != "" { + t.Fatalf("x-codex-beta-features = %q, want empty", got) + } +} + +func TestApplyCodexWebsocketHeadersUsesConfigDefaultsForOAuth(t *testing.T) { + cfg := &config.Config{ + CodexHeaderDefaults: config.CodexHeaderDefaults{ + UserAgent: "my-codex-client/1.0", + BetaFeatures: "feature-a,feature-b", + }, + } + auth := &cliproxyauth.Auth{ + Provider: "codex", + Metadata: map[string]any{"email": "user@example.com"}, + } + + headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, auth, "", cfg) + + if got := headers.Get("User-Agent"); got != "my-codex-client/1.0" { + t.Fatalf("User-Agent = %s, want %s", got, "my-codex-client/1.0") + } + if got := headers.Get("x-codex-beta-features"); got != "feature-a,feature-b" { + t.Fatalf("x-codex-beta-features = %s, want %s", got, "feature-a,feature-b") + } + if got := headers.Get("OpenAI-Beta"); got != codexResponsesWebsocketBetaHeaderValue { + t.Fatalf("OpenAI-Beta = %s, want %s", got, codexResponsesWebsocketBetaHeaderValue) + } +} + +func TestApplyCodexWebsocketHeadersPrefersExistingHeadersOverClientAndConfig(t *testing.T) { + cfg := &config.Config{ + CodexHeaderDefaults: config.CodexHeaderDefaults{ + UserAgent: "config-ua", + BetaFeatures: "config-beta", + }, + } + auth := &cliproxyauth.Auth{ + Provider: "codex", + Metadata: map[string]any{"email": "user@example.com"}, + } + ctx := contextWithGinHeaders(map[string]string{ + "User-Agent": "client-ua", + "X-Codex-Beta-Features": "client-beta", + }) + headers := http.Header{} + headers.Set("User-Agent", "existing-ua") + headers.Set("X-Codex-Beta-Features", "existing-beta") + + got := applyCodexWebsocketHeaders(ctx, headers, auth, "", cfg) + + if gotVal := got.Get("User-Agent"); gotVal != "existing-ua" { + t.Fatalf("User-Agent = %s, want %s", gotVal, "existing-ua") + } + if gotVal := got.Get("x-codex-beta-features"); gotVal != "existing-beta" { + t.Fatalf("x-codex-beta-features = %s, want %s", gotVal, "existing-beta") + } +} + +func TestApplyCodexWebsocketHeadersConfigUserAgentOverridesClientHeader(t *testing.T) { + cfg := &config.Config{ + CodexHeaderDefaults: config.CodexHeaderDefaults{ + UserAgent: "config-ua", + BetaFeatures: "config-beta", + }, + } + auth := &cliproxyauth.Auth{ + Provider: "codex", + Metadata: map[string]any{"email": "user@example.com"}, + } + ctx := contextWithGinHeaders(map[string]string{ + "User-Agent": "client-ua", + "X-Codex-Beta-Features": "client-beta", + }) + + headers := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, "", cfg) + + if got := headers.Get("User-Agent"); got != "config-ua" { + t.Fatalf("User-Agent = %s, want %s", got, "config-ua") + } + if got := headers.Get("x-codex-beta-features"); got != "client-beta" { + t.Fatalf("x-codex-beta-features = %s, want %s", got, "client-beta") + } +} + +func TestApplyCodexWebsocketHeadersIgnoresConfigForAPIKeyAuth(t *testing.T) { + cfg := &config.Config{ + CodexHeaderDefaults: config.CodexHeaderDefaults{ + UserAgent: "config-ua", + BetaFeatures: "config-beta", + }, + } + auth := &cliproxyauth.Auth{ + Provider: "codex", + Attributes: map[string]string{"api_key": "sk-test"}, + } + + headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, auth, "sk-test", cfg) + + if got := headers.Get("User-Agent"); got != codexUserAgent { + t.Fatalf("User-Agent = %s, want %s", got, codexUserAgent) + } + if got := headers.Get("x-codex-beta-features"); got != "" { + t.Fatalf("x-codex-beta-features = %q, want empty", got) + } +} + +func TestApplyCodexHeadersUsesConfigUserAgentForOAuth(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "https://example.com/responses", nil) + if err != nil { + t.Fatalf("NewRequest() error = %v", err) + } + cfg := &config.Config{ + CodexHeaderDefaults: config.CodexHeaderDefaults{ + UserAgent: "config-ua", + BetaFeatures: "config-beta", + }, + } + auth := &cliproxyauth.Auth{ + Provider: "codex", + Metadata: map[string]any{"email": "user@example.com"}, + } + req = req.WithContext(contextWithGinHeaders(map[string]string{ + "User-Agent": "client-ua", + })) + + applyCodexHeaders(req, auth, "oauth-token", true, cfg) + + if got := req.Header.Get("User-Agent"); got != "config-ua" { + t.Fatalf("User-Agent = %s, want %s", got, "config-ua") + } + if got := req.Header.Get("x-codex-beta-features"); got != "" { + t.Fatalf("x-codex-beta-features = %q, want empty", got) + } +} + +func contextWithGinHeaders(headers map[string]string) context.Context { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequest(http.MethodPost, "/", nil) + ginCtx.Request.Header = make(http.Header, len(headers)) + for key, value := range headers { + ginCtx.Request.Header.Set(key, value) + } + return context.WithValue(context.Background(), "gin", ginCtx) }