From cfb9cb8951ad5cea6451518ddc4faf36dcb1e4df Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sat, 8 Nov 2025 20:52:05 +0800 Subject: [PATCH] feat(config): support HTTP headers across providers --- config.example.yaml | 14 +++-- internal/config/config.go | 28 +++++++++- internal/runtime/executor/claude_executor.go | 18 ++++--- internal/runtime/executor/codex_executor.go | 5 ++ internal/runtime/executor/gemini_executor.go | 41 ++------------- .../executor/openai_compat_executor.go | 11 ++++ internal/util/header_helpers.go | 52 +++++++++++++++++++ internal/watcher/watcher.go | 41 +++++++++++---- 8 files changed, 150 insertions(+), 60 deletions(-) create mode 100644 internal/util/header_helpers.go diff --git a/config.example.yaml b/config.example.yaml index 27c11c25..ec16fb1c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -49,10 +49,10 @@ ws-auth: false # Gemini API keys (preferred) #gemini-api-key: # - api-key: "AIzaSy...01" -# # base-url: "https://generativelanguage.googleapis.com" -# # headers: -# # X-Custom-Header: "custom-value" -# # proxy-url: "socks5://proxy.example.com:1080" +# base-url: "https://generativelanguage.googleapis.com" +# headers: +# X-Custom-Header: "custom-value" +# proxy-url: "socks5://proxy.example.com:1080" # - api-key: "AIzaSy...02" # API keys for official Generative Language API (legacy compatibility) @@ -64,6 +64,8 @@ ws-auth: false #codex-api-key: # - api-key: "sk-atSM..." # base-url: "https://www.example.com" # use the custom codex API endpoint +# headers: +# X-Custom-Header: "custom-value" # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override # Claude API keys @@ -71,6 +73,8 @@ ws-auth: false # - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url # - api-key: "sk-atSM..." # base-url: "https://www.example.com" # use the custom claude API endpoint +# headers: +# X-Custom-Header: "custom-value" # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override # models: # - name: "claude-3-5-sonnet-20241022" # upstream model name @@ -80,6 +84,8 @@ ws-auth: false #openai-compatibility: # - name: "openrouter" # The name of the provider; it will be used in the user agent and other places. # base-url: "https://openrouter.ai/api/v1" # The base URL of the provider. +# headers: +# X-Custom-Header: "custom-value" # # New format with per-key proxy support (recommended): # api-key-entries: # - api-key: "sk-or-v1-...b780" diff --git a/internal/config/config.go b/internal/config/config.go index baed1a54..6aac82a9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -100,6 +100,9 @@ type ClaudeKey struct { // Models defines upstream model names and aliases for request routing. Models []ClaudeModel `yaml:"models" json:"models"` + + // Headers optionally adds extra HTTP headers for requests sent with this key. + Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` } // ClaudeModel describes a mapping between an alias and the actual upstream model name. @@ -123,6 +126,9 @@ type CodexKey struct { // ProxyURL overrides the global proxy setting for this API key if provided. ProxyURL string `yaml:"proxy-url" json:"proxy-url"` + + // Headers optionally adds extra HTTP headers for requests sent with this key. + Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` } // GeminiKey represents the configuration for a Gemini API key, @@ -159,6 +165,9 @@ type OpenAICompatibility struct { // Models defines the model configurations including aliases for routing. Models []OpenAICompatibilityModel `yaml:"models" json:"models"` + + // Headers optionally adds extra HTTP headers for requests sent to this provider. + Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` } // OpenAICompatibilityAPIKey represents an API key configuration with optional proxy setting. @@ -255,6 +264,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { // Sanitize Codex keys: drop entries without base-url sanitizeCodexKeys(&cfg) + // Normalize Claude key headers + normalizeClaudeKeys(&cfg) + // Return the populated configuration struct. return &cfg, nil } @@ -271,6 +283,7 @@ func sanitizeOpenAICompatibility(cfg *Config) { e := cfg.OpenAICompatibility[i] e.Name = strings.TrimSpace(e.Name) e.BaseURL = strings.TrimSpace(e.BaseURL) + e.Headers = normalizeHeaders(e.Headers) if e.BaseURL == "" { // Skip providers with no base-url; treated as removed continue @@ -290,6 +303,7 @@ func sanitizeCodexKeys(cfg *Config) { for i := range cfg.CodexKey { e := cfg.CodexKey[i] e.BaseURL = strings.TrimSpace(e.BaseURL) + e.Headers = normalizeHeaders(e.Headers) if e.BaseURL == "" { continue } @@ -298,6 +312,16 @@ func sanitizeCodexKeys(cfg *Config) { cfg.CodexKey = out } +func normalizeClaudeKeys(cfg *Config) { + if cfg == nil || len(cfg.ClaudeKey) == 0 { + return + } + for i := range cfg.ClaudeKey { + entry := &cfg.ClaudeKey[i] + entry.Headers = normalizeHeaders(entry.Headers) + } +} + func (cfg *Config) SyncGeminiKeys() { if cfg == nil { return @@ -313,7 +337,7 @@ func (cfg *Config) SyncGeminiKeys() { } entry.BaseURL = strings.TrimSpace(entry.BaseURL) entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) - entry.Headers = normalizeGeminiHeaders(entry.Headers) + entry.Headers = normalizeHeaders(entry.Headers) if _, exists := seen[entry.APIKey]; exists { continue } @@ -356,7 +380,7 @@ func looksLikeBcrypt(s string) bool { return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$") } -func normalizeGeminiHeaders(headers map[string]string) map[string]string { +func normalizeHeaders(headers map[string]string) map[string]string { if len(headers) == 0 { return nil } diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 3ced56d4..fe7cfe93 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -17,6 +17,7 @@ import ( claudeauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" @@ -67,7 +68,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if err != nil { return resp, err } - applyClaudeHeaders(httpReq, apiKey, false) + applyClaudeHeaders(httpReq, auth, apiKey, false) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -159,7 +160,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if err != nil { return nil, err } - applyClaudeHeaders(httpReq, apiKey, true) + applyClaudeHeaders(httpReq, auth, apiKey, true) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -290,7 +291,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut if err != nil { return cliproxyexecutor.Response{}, err } - applyClaudeHeaders(httpReq, apiKey, false) + applyClaudeHeaders(httpReq, auth, apiKey, false) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -529,7 +530,7 @@ func decodeResponseBody(body io.ReadCloser, contentEncoding string) (io.ReadClos return body, nil } -func applyClaudeHeaders(r *http.Request, apiKey string, stream bool) { +func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, stream bool) { r.Header.Set("Authorization", "Bearer "+apiKey) r.Header.Set("Content-Type", "application/json") @@ -564,9 +565,14 @@ func applyClaudeHeaders(r *http.Request, apiKey string, stream bool) { r.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") if stream { r.Header.Set("Accept", "text/event-stream") - return + } else { + r.Header.Set("Accept", "application/json") } - r.Header.Set("Accept", "application/json") + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(r, attrs) } func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 98d24fe9..947bff88 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -585,6 +585,11 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string) { } } } + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(r, attrs) } func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index 1db96eb8..364eed29 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -495,44 +495,11 @@ func resolveGeminiBaseURL(auth *cliproxyauth.Auth) string { } func applyGeminiHeaders(req *http.Request, auth *cliproxyauth.Auth) { - if req == nil { - return + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes } - headers := geminiCustomHeaders(auth) - if len(headers) == 0 { - return - } - for k, v := range headers { - if k == "" || v == "" { - continue - } - req.Header.Set(k, v) - } -} - -func geminiCustomHeaders(auth *cliproxyauth.Auth) map[string]string { - if auth == nil || auth.Attributes == nil { - return nil - } - headers := make(map[string]string, len(auth.Attributes)) - for k, v := range auth.Attributes { - if !strings.HasPrefix(k, "header:") { - continue - } - name := strings.TrimSpace(strings.TrimPrefix(k, "header:")) - if name == "" { - continue - } - val := strings.TrimSpace(v) - if val == "" { - continue - } - headers[name] = val - } - if len(headers) == 0 { - return nil - } - return headers + util.ApplyCustomHeadersFromAttrs(req, attrs) } func fixGeminiImageAspectRatio(modelName string, rawJSON []byte) []byte { diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index 16d5b500..e08196db 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" @@ -66,6 +67,11 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A httpReq.Header.Set("Authorization", "Bearer "+apiKey) } httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat") + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(httpReq, attrs) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -143,6 +149,11 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy httpReq.Header.Set("Authorization", "Bearer "+apiKey) } httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat") + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(httpReq, attrs) httpReq.Header.Set("Accept", "text/event-stream") httpReq.Header.Set("Cache-Control", "no-cache") var authID, authLabel, authType, authValue string diff --git a/internal/util/header_helpers.go b/internal/util/header_helpers.go new file mode 100644 index 00000000..c53c291f --- /dev/null +++ b/internal/util/header_helpers.go @@ -0,0 +1,52 @@ +package util + +import ( + "net/http" + "strings" +) + +// ApplyCustomHeadersFromAttrs applies user-defined headers stored in the provided attributes map. +// Custom headers override built-in defaults when conflicts occur. +func ApplyCustomHeadersFromAttrs(r *http.Request, attrs map[string]string) { + if r == nil { + return + } + applyCustomHeaders(r, extractCustomHeaders(attrs)) +} + +func extractCustomHeaders(attrs map[string]string) map[string]string { + if len(attrs) == 0 { + return nil + } + headers := make(map[string]string) + for k, v := range attrs { + if !strings.HasPrefix(k, "header:") { + continue + } + name := strings.TrimSpace(strings.TrimPrefix(k, "header:")) + if name == "" { + continue + } + val := strings.TrimSpace(v) + if val == "" { + continue + } + headers[name] = val + } + if len(headers) == 0 { + return nil + } + return headers +} + +func applyCustomHeaders(r *http.Request, headers map[string]string) { + if r == nil || len(headers) == 0 { + return + } + for k, v := range headers { + if k == "" || v == "" { + continue + } + r.Header.Set(k, v) + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index e2cb4e5e..d7d1d57e 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -762,16 +762,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { if base != "" { attrs["base_url"] = base } - if len(entry.Headers) > 0 { - for hk, hv := range entry.Headers { - key := strings.TrimSpace(hk) - val := strings.TrimSpace(hv) - if key == "" || val == "" { - continue - } - attrs["header:"+key] = val - } - } + addConfigHeadersToAttrs(entry.Headers, attrs) a := &coreauth.Auth{ ID: id, Provider: "gemini", @@ -803,6 +794,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { if hash := computeClaudeModelsHash(ck.Models); hash != "" { attrs["models_hash"] = hash } + addConfigHeadersToAttrs(ck.Headers, attrs) proxyURL := strings.TrimSpace(ck.ProxyURL) a := &coreauth.Auth{ ID: id, @@ -831,6 +823,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { if ck.BaseURL != "" { attrs["base_url"] = ck.BaseURL } + addConfigHeadersToAttrs(ck.Headers, attrs) proxyURL := strings.TrimSpace(ck.ProxyURL) a := &coreauth.Auth{ ID: id, @@ -873,6 +866,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" { attrs["models_hash"] = hash } + addConfigHeadersToAttrs(compat.Headers, attrs) a := &coreauth.Auth{ ID: id, Provider: providerName, @@ -905,6 +899,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" { attrs["models_hash"] = hash } + addConfigHeadersToAttrs(compat.Headers, attrs) a := &coreauth.Auth{ ID: id, Provider: providerName, @@ -930,6 +925,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" { attrs["models_hash"] = hash } + addConfigHeadersToAttrs(compat.Headers, attrs) a := &coreauth.Auth{ ID: id, Provider: providerName, @@ -1131,13 +1127,16 @@ func describeOpenAICompatibilityUpdate(oldEntry, newEntry config.OpenAICompatibi newKeyCount := countAPIKeys(newEntry) oldModelCount := countOpenAIModels(oldEntry.Models) newModelCount := countOpenAIModels(newEntry.Models) - details := make([]string, 0, 2) + details := make([]string, 0, 3) if oldKeyCount != newKeyCount { details = append(details, fmt.Sprintf("api-keys %d -> %d", oldKeyCount, newKeyCount)) } if oldModelCount != newModelCount { details = append(details, fmt.Sprintf("models %d -> %d", oldModelCount, newModelCount)) } + if !equalStringMap(oldEntry.Headers, newEntry.Headers) { + details = append(details, "headers updated") + } if len(details) == 0 { return "" } @@ -1303,6 +1302,9 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) { changes = append(changes, fmt.Sprintf("claude[%d].api-key: updated", i)) } + if !equalStringMap(o.Headers, n.Headers) { + changes = append(changes, fmt.Sprintf("claude[%d].headers: updated", i)) + } } } @@ -1325,6 +1327,9 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) { changes = append(changes, fmt.Sprintf("codex[%d].api-key: updated", i)) } + if !equalStringMap(o.Headers, n.Headers) { + changes = append(changes, fmt.Sprintf("codex[%d].headers: updated", i)) + } } } @@ -1357,6 +1362,20 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { return changes } +func addConfigHeadersToAttrs(headers map[string]string, attrs map[string]string) { + if len(headers) == 0 || attrs == nil { + return + } + for hk, hv := range headers { + key := strings.TrimSpace(hk) + val := strings.TrimSpace(hv) + if key == "" || val == "" { + continue + } + attrs["header:"+key] = val + } +} + func trimStrings(in []string) []string { out := make([]string, len(in)) for i := range in {