hkfires commited on
Commit
e6bdaef
·
1 Parent(s): 4864a6f

feat(config): support HTTP headers across providers

Browse files
config.example.yaml CHANGED
@@ -49,10 +49,10 @@ ws-auth: false
49
  # Gemini API keys (preferred)
50
  #gemini-api-key:
51
  # - api-key: "AIzaSy...01"
52
- # # base-url: "https://generativelanguage.googleapis.com"
53
- # # headers:
54
- # # X-Custom-Header: "custom-value"
55
- # # proxy-url: "socks5://proxy.example.com:1080"
56
  # - api-key: "AIzaSy...02"
57
 
58
  # API keys for official Generative Language API (legacy compatibility)
@@ -64,6 +64,8 @@ ws-auth: false
64
  #codex-api-key:
65
  # - api-key: "sk-atSM..."
66
  # base-url: "https://www.example.com" # use the custom codex API endpoint
 
 
67
  # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
68
 
69
  # Claude API keys
@@ -71,6 +73,8 @@ ws-auth: false
71
  # - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
72
  # - api-key: "sk-atSM..."
73
  # base-url: "https://www.example.com" # use the custom claude API endpoint
 
 
74
  # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
75
  # models:
76
  # - name: "claude-3-5-sonnet-20241022" # upstream model name
@@ -80,6 +84,8 @@ ws-auth: false
80
  #openai-compatibility:
81
  # - name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
82
  # base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
 
 
83
  # # New format with per-key proxy support (recommended):
84
  # api-key-entries:
85
  # - api-key: "sk-or-v1-...b780"
 
49
  # Gemini API keys (preferred)
50
  #gemini-api-key:
51
  # - api-key: "AIzaSy...01"
52
+ # base-url: "https://generativelanguage.googleapis.com"
53
+ # headers:
54
+ # X-Custom-Header: "custom-value"
55
+ # proxy-url: "socks5://proxy.example.com:1080"
56
  # - api-key: "AIzaSy...02"
57
 
58
  # API keys for official Generative Language API (legacy compatibility)
 
64
  #codex-api-key:
65
  # - api-key: "sk-atSM..."
66
  # base-url: "https://www.example.com" # use the custom codex API endpoint
67
+ # headers:
68
+ # X-Custom-Header: "custom-value"
69
  # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
70
 
71
  # Claude API keys
 
73
  # - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
74
  # - api-key: "sk-atSM..."
75
  # base-url: "https://www.example.com" # use the custom claude API endpoint
76
+ # headers:
77
+ # X-Custom-Header: "custom-value"
78
  # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
79
  # models:
80
  # - name: "claude-3-5-sonnet-20241022" # upstream model name
 
84
  #openai-compatibility:
85
  # - name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
86
  # base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
87
+ # headers:
88
+ # X-Custom-Header: "custom-value"
89
  # # New format with per-key proxy support (recommended):
90
  # api-key-entries:
91
  # - api-key: "sk-or-v1-...b780"
internal/config/config.go CHANGED
@@ -100,6 +100,9 @@ type ClaudeKey struct {
100
 
101
  // Models defines upstream model names and aliases for request routing.
102
  Models []ClaudeModel `yaml:"models" json:"models"`
 
 
 
103
  }
104
 
105
  // ClaudeModel describes a mapping between an alias and the actual upstream model name.
@@ -123,6 +126,9 @@ type CodexKey struct {
123
 
124
  // ProxyURL overrides the global proxy setting for this API key if provided.
125
  ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
 
 
 
126
  }
127
 
128
  // GeminiKey represents the configuration for a Gemini API key,
@@ -159,6 +165,9 @@ type OpenAICompatibility struct {
159
 
160
  // Models defines the model configurations including aliases for routing.
161
  Models []OpenAICompatibilityModel `yaml:"models" json:"models"`
 
 
 
162
  }
163
 
164
  // OpenAICompatibilityAPIKey represents an API key configuration with optional proxy setting.
@@ -255,6 +264,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
255
  // Sanitize Codex keys: drop entries without base-url
256
  sanitizeCodexKeys(&cfg)
257
 
 
 
 
258
  // Return the populated configuration struct.
259
  return &cfg, nil
260
  }
@@ -271,6 +283,7 @@ func sanitizeOpenAICompatibility(cfg *Config) {
271
  e := cfg.OpenAICompatibility[i]
272
  e.Name = strings.TrimSpace(e.Name)
273
  e.BaseURL = strings.TrimSpace(e.BaseURL)
 
274
  if e.BaseURL == "" {
275
  // Skip providers with no base-url; treated as removed
276
  continue
@@ -290,6 +303,7 @@ func sanitizeCodexKeys(cfg *Config) {
290
  for i := range cfg.CodexKey {
291
  e := cfg.CodexKey[i]
292
  e.BaseURL = strings.TrimSpace(e.BaseURL)
 
293
  if e.BaseURL == "" {
294
  continue
295
  }
@@ -298,6 +312,16 @@ func sanitizeCodexKeys(cfg *Config) {
298
  cfg.CodexKey = out
299
  }
300
 
 
 
 
 
 
 
 
 
 
 
301
  func (cfg *Config) SyncGeminiKeys() {
302
  if cfg == nil {
303
  return
@@ -313,7 +337,7 @@ func (cfg *Config) SyncGeminiKeys() {
313
  }
314
  entry.BaseURL = strings.TrimSpace(entry.BaseURL)
315
  entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
316
- entry.Headers = normalizeGeminiHeaders(entry.Headers)
317
  if _, exists := seen[entry.APIKey]; exists {
318
  continue
319
  }
@@ -356,7 +380,7 @@ func looksLikeBcrypt(s string) bool {
356
  return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$")
357
  }
358
 
359
- func normalizeGeminiHeaders(headers map[string]string) map[string]string {
360
  if len(headers) == 0 {
361
  return nil
362
  }
 
100
 
101
  // Models defines upstream model names and aliases for request routing.
102
  Models []ClaudeModel `yaml:"models" json:"models"`
103
+
104
+ // Headers optionally adds extra HTTP headers for requests sent with this key.
105
+ Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
106
  }
107
 
108
  // ClaudeModel describes a mapping between an alias and the actual upstream model name.
 
126
 
127
  // ProxyURL overrides the global proxy setting for this API key if provided.
128
  ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
129
+
130
+ // Headers optionally adds extra HTTP headers for requests sent with this key.
131
+ Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
132
  }
133
 
134
  // GeminiKey represents the configuration for a Gemini API key,
 
165
 
166
  // Models defines the model configurations including aliases for routing.
167
  Models []OpenAICompatibilityModel `yaml:"models" json:"models"`
168
+
169
+ // Headers optionally adds extra HTTP headers for requests sent to this provider.
170
+ Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
171
  }
172
 
173
  // OpenAICompatibilityAPIKey represents an API key configuration with optional proxy setting.
 
264
  // Sanitize Codex keys: drop entries without base-url
265
  sanitizeCodexKeys(&cfg)
266
 
267
+ // Normalize Claude key headers
268
+ normalizeClaudeKeys(&cfg)
269
+
270
  // Return the populated configuration struct.
271
  return &cfg, nil
272
  }
 
283
  e := cfg.OpenAICompatibility[i]
284
  e.Name = strings.TrimSpace(e.Name)
285
  e.BaseURL = strings.TrimSpace(e.BaseURL)
286
+ e.Headers = normalizeHeaders(e.Headers)
287
  if e.BaseURL == "" {
288
  // Skip providers with no base-url; treated as removed
289
  continue
 
303
  for i := range cfg.CodexKey {
304
  e := cfg.CodexKey[i]
305
  e.BaseURL = strings.TrimSpace(e.BaseURL)
306
+ e.Headers = normalizeHeaders(e.Headers)
307
  if e.BaseURL == "" {
308
  continue
309
  }
 
312
  cfg.CodexKey = out
313
  }
314
 
315
+ func normalizeClaudeKeys(cfg *Config) {
316
+ if cfg == nil || len(cfg.ClaudeKey) == 0 {
317
+ return
318
+ }
319
+ for i := range cfg.ClaudeKey {
320
+ entry := &cfg.ClaudeKey[i]
321
+ entry.Headers = normalizeHeaders(entry.Headers)
322
+ }
323
+ }
324
+
325
  func (cfg *Config) SyncGeminiKeys() {
326
  if cfg == nil {
327
  return
 
337
  }
338
  entry.BaseURL = strings.TrimSpace(entry.BaseURL)
339
  entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
340
+ entry.Headers = normalizeHeaders(entry.Headers)
341
  if _, exists := seen[entry.APIKey]; exists {
342
  continue
343
  }
 
380
  return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$")
381
  }
382
 
383
+ func normalizeHeaders(headers map[string]string) map[string]string {
384
  if len(headers) == 0 {
385
  return nil
386
  }
internal/runtime/executor/claude_executor.go CHANGED
@@ -17,6 +17,7 @@ import (
17
  claudeauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
18
  "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
19
  "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
 
20
  cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
21
  cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
22
  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
67
  if err != nil {
68
  return resp, err
69
  }
70
- applyClaudeHeaders(httpReq, apiKey, false)
71
  var authID, authLabel, authType, authValue string
72
  if auth != nil {
73
  authID = auth.ID
@@ -159,7 +160,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
159
  if err != nil {
160
  return nil, err
161
  }
162
- applyClaudeHeaders(httpReq, apiKey, true)
163
  var authID, authLabel, authType, authValue string
164
  if auth != nil {
165
  authID = auth.ID
@@ -290,7 +291,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
290
  if err != nil {
291
  return cliproxyexecutor.Response{}, err
292
  }
293
- applyClaudeHeaders(httpReq, apiKey, false)
294
  var authID, authLabel, authType, authValue string
295
  if auth != nil {
296
  authID = auth.ID
@@ -529,7 +530,7 @@ func decodeResponseBody(body io.ReadCloser, contentEncoding string) (io.ReadClos
529
  return body, nil
530
  }
531
 
532
- func applyClaudeHeaders(r *http.Request, apiKey string, stream bool) {
533
  r.Header.Set("Authorization", "Bearer "+apiKey)
534
  r.Header.Set("Content-Type", "application/json")
535
 
@@ -564,9 +565,14 @@ func applyClaudeHeaders(r *http.Request, apiKey string, stream bool) {
564
  r.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
565
  if stream {
566
  r.Header.Set("Accept", "text/event-stream")
567
- return
 
 
 
 
 
568
  }
569
- r.Header.Set("Accept", "application/json")
570
  }
571
 
572
  func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
 
17
  claudeauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
18
  "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
19
  "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
20
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
21
  cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
22
  cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
23
  sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
 
68
  if err != nil {
69
  return resp, err
70
  }
71
+ applyClaudeHeaders(httpReq, auth, apiKey, false)
72
  var authID, authLabel, authType, authValue string
73
  if auth != nil {
74
  authID = auth.ID
 
160
  if err != nil {
161
  return nil, err
162
  }
163
+ applyClaudeHeaders(httpReq, auth, apiKey, true)
164
  var authID, authLabel, authType, authValue string
165
  if auth != nil {
166
  authID = auth.ID
 
291
  if err != nil {
292
  return cliproxyexecutor.Response{}, err
293
  }
294
+ applyClaudeHeaders(httpReq, auth, apiKey, false)
295
  var authID, authLabel, authType, authValue string
296
  if auth != nil {
297
  authID = auth.ID
 
530
  return body, nil
531
  }
532
 
533
+ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, stream bool) {
534
  r.Header.Set("Authorization", "Bearer "+apiKey)
535
  r.Header.Set("Content-Type", "application/json")
536
 
 
565
  r.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
566
  if stream {
567
  r.Header.Set("Accept", "text/event-stream")
568
+ } else {
569
+ r.Header.Set("Accept", "application/json")
570
+ }
571
+ var attrs map[string]string
572
+ if auth != nil {
573
+ attrs = auth.Attributes
574
  }
575
+ util.ApplyCustomHeadersFromAttrs(r, attrs)
576
  }
577
 
578
  func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
internal/runtime/executor/codex_executor.go CHANGED
@@ -585,6 +585,11 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string) {
585
  }
586
  }
587
  }
 
 
 
 
 
588
  }
589
 
590
  func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
 
585
  }
586
  }
587
  }
588
+ var attrs map[string]string
589
+ if auth != nil {
590
+ attrs = auth.Attributes
591
+ }
592
+ util.ApplyCustomHeadersFromAttrs(r, attrs)
593
  }
594
 
595
  func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
internal/runtime/executor/gemini_executor.go CHANGED
@@ -495,44 +495,11 @@ func resolveGeminiBaseURL(auth *cliproxyauth.Auth) string {
495
  }
496
 
497
  func applyGeminiHeaders(req *http.Request, auth *cliproxyauth.Auth) {
498
- if req == nil {
499
- return
500
- }
501
- headers := geminiCustomHeaders(auth)
502
- if len(headers) == 0 {
503
- return
504
- }
505
- for k, v := range headers {
506
- if k == "" || v == "" {
507
- continue
508
- }
509
- req.Header.Set(k, v)
510
- }
511
- }
512
-
513
- func geminiCustomHeaders(auth *cliproxyauth.Auth) map[string]string {
514
- if auth == nil || auth.Attributes == nil {
515
- return nil
516
- }
517
- headers := make(map[string]string, len(auth.Attributes))
518
- for k, v := range auth.Attributes {
519
- if !strings.HasPrefix(k, "header:") {
520
- continue
521
- }
522
- name := strings.TrimSpace(strings.TrimPrefix(k, "header:"))
523
- if name == "" {
524
- continue
525
- }
526
- val := strings.TrimSpace(v)
527
- if val == "" {
528
- continue
529
- }
530
- headers[name] = val
531
- }
532
- if len(headers) == 0 {
533
- return nil
534
  }
535
- return headers
536
  }
537
 
538
  func fixGeminiImageAspectRatio(modelName string, rawJSON []byte) []byte {
 
495
  }
496
 
497
  func applyGeminiHeaders(req *http.Request, auth *cliproxyauth.Auth) {
498
+ var attrs map[string]string
499
+ if auth != nil {
500
+ attrs = auth.Attributes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  }
502
+ util.ApplyCustomHeadersFromAttrs(req, attrs)
503
  }
504
 
505
  func fixGeminiImageAspectRatio(modelName string, rawJSON []byte) []byte {
internal/runtime/executor/openai_compat_executor.go CHANGED
@@ -10,6 +10,7 @@ import (
10
  "strings"
11
 
12
  "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
 
13
  cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
14
  cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
15
  sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
@@ -66,6 +67,11 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
66
  httpReq.Header.Set("Authorization", "Bearer "+apiKey)
67
  }
68
  httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat")
 
 
 
 
 
69
  var authID, authLabel, authType, authValue string
70
  if auth != nil {
71
  authID = auth.ID
@@ -143,6 +149,11 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
143
  httpReq.Header.Set("Authorization", "Bearer "+apiKey)
144
  }
145
  httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat")
 
 
 
 
 
146
  httpReq.Header.Set("Accept", "text/event-stream")
147
  httpReq.Header.Set("Cache-Control", "no-cache")
148
  var authID, authLabel, authType, authValue string
 
10
  "strings"
11
 
12
  "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
13
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
14
  cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
15
  cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
16
  sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
 
67
  httpReq.Header.Set("Authorization", "Bearer "+apiKey)
68
  }
69
  httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat")
70
+ var attrs map[string]string
71
+ if auth != nil {
72
+ attrs = auth.Attributes
73
+ }
74
+ util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
75
  var authID, authLabel, authType, authValue string
76
  if auth != nil {
77
  authID = auth.ID
 
149
  httpReq.Header.Set("Authorization", "Bearer "+apiKey)
150
  }
151
  httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat")
152
+ var attrs map[string]string
153
+ if auth != nil {
154
+ attrs = auth.Attributes
155
+ }
156
+ util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
157
  httpReq.Header.Set("Accept", "text/event-stream")
158
  httpReq.Header.Set("Cache-Control", "no-cache")
159
  var authID, authLabel, authType, authValue string
internal/util/header_helpers.go ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package util
2
+
3
+ import (
4
+ "net/http"
5
+ "strings"
6
+ )
7
+
8
+ // ApplyCustomHeadersFromAttrs applies user-defined headers stored in the provided attributes map.
9
+ // Custom headers override built-in defaults when conflicts occur.
10
+ func ApplyCustomHeadersFromAttrs(r *http.Request, attrs map[string]string) {
11
+ if r == nil {
12
+ return
13
+ }
14
+ applyCustomHeaders(r, extractCustomHeaders(attrs))
15
+ }
16
+
17
+ func extractCustomHeaders(attrs map[string]string) map[string]string {
18
+ if len(attrs) == 0 {
19
+ return nil
20
+ }
21
+ headers := make(map[string]string)
22
+ for k, v := range attrs {
23
+ if !strings.HasPrefix(k, "header:") {
24
+ continue
25
+ }
26
+ name := strings.TrimSpace(strings.TrimPrefix(k, "header:"))
27
+ if name == "" {
28
+ continue
29
+ }
30
+ val := strings.TrimSpace(v)
31
+ if val == "" {
32
+ continue
33
+ }
34
+ headers[name] = val
35
+ }
36
+ if len(headers) == 0 {
37
+ return nil
38
+ }
39
+ return headers
40
+ }
41
+
42
+ func applyCustomHeaders(r *http.Request, headers map[string]string) {
43
+ if r == nil || len(headers) == 0 {
44
+ return
45
+ }
46
+ for k, v := range headers {
47
+ if k == "" || v == "" {
48
+ continue
49
+ }
50
+ r.Header.Set(k, v)
51
+ }
52
+ }
internal/watcher/watcher.go CHANGED
@@ -762,16 +762,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
762
  if base != "" {
763
  attrs["base_url"] = base
764
  }
765
- if len(entry.Headers) > 0 {
766
- for hk, hv := range entry.Headers {
767
- key := strings.TrimSpace(hk)
768
- val := strings.TrimSpace(hv)
769
- if key == "" || val == "" {
770
- continue
771
- }
772
- attrs["header:"+key] = val
773
- }
774
- }
775
  a := &coreauth.Auth{
776
  ID: id,
777
  Provider: "gemini",
@@ -803,6 +794,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
803
  if hash := computeClaudeModelsHash(ck.Models); hash != "" {
804
  attrs["models_hash"] = hash
805
  }
 
806
  proxyURL := strings.TrimSpace(ck.ProxyURL)
807
  a := &coreauth.Auth{
808
  ID: id,
@@ -831,6 +823,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
831
  if ck.BaseURL != "" {
832
  attrs["base_url"] = ck.BaseURL
833
  }
 
834
  proxyURL := strings.TrimSpace(ck.ProxyURL)
835
  a := &coreauth.Auth{
836
  ID: id,
@@ -873,6 +866,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
873
  if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" {
874
  attrs["models_hash"] = hash
875
  }
 
876
  a := &coreauth.Auth{
877
  ID: id,
878
  Provider: providerName,
@@ -905,6 +899,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
905
  if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" {
906
  attrs["models_hash"] = hash
907
  }
 
908
  a := &coreauth.Auth{
909
  ID: id,
910
  Provider: providerName,
@@ -930,6 +925,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
930
  if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" {
931
  attrs["models_hash"] = hash
932
  }
 
933
  a := &coreauth.Auth{
934
  ID: id,
935
  Provider: providerName,
@@ -1131,13 +1127,16 @@ func describeOpenAICompatibilityUpdate(oldEntry, newEntry config.OpenAICompatibi
1131
  newKeyCount := countAPIKeys(newEntry)
1132
  oldModelCount := countOpenAIModels(oldEntry.Models)
1133
  newModelCount := countOpenAIModels(newEntry.Models)
1134
- details := make([]string, 0, 2)
1135
  if oldKeyCount != newKeyCount {
1136
  details = append(details, fmt.Sprintf("api-keys %d -> %d", oldKeyCount, newKeyCount))
1137
  }
1138
  if oldModelCount != newModelCount {
1139
  details = append(details, fmt.Sprintf("models %d -> %d", oldModelCount, newModelCount))
1140
  }
 
 
 
1141
  if len(details) == 0 {
1142
  return ""
1143
  }
@@ -1303,6 +1302,9 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
1303
  if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
1304
  changes = append(changes, fmt.Sprintf("claude[%d].api-key: updated", i))
1305
  }
 
 
 
1306
  }
1307
  }
1308
 
@@ -1325,6 +1327,9 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
1325
  if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
1326
  changes = append(changes, fmt.Sprintf("codex[%d].api-key: updated", i))
1327
  }
 
 
 
1328
  }
1329
  }
1330
 
@@ -1357,6 +1362,20 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
1357
  return changes
1358
  }
1359
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1360
  func trimStrings(in []string) []string {
1361
  out := make([]string, len(in))
1362
  for i := range in {
 
762
  if base != "" {
763
  attrs["base_url"] = base
764
  }
765
+ addConfigHeadersToAttrs(entry.Headers, attrs)
 
 
 
 
 
 
 
 
 
766
  a := &coreauth.Auth{
767
  ID: id,
768
  Provider: "gemini",
 
794
  if hash := computeClaudeModelsHash(ck.Models); hash != "" {
795
  attrs["models_hash"] = hash
796
  }
797
+ addConfigHeadersToAttrs(ck.Headers, attrs)
798
  proxyURL := strings.TrimSpace(ck.ProxyURL)
799
  a := &coreauth.Auth{
800
  ID: id,
 
823
  if ck.BaseURL != "" {
824
  attrs["base_url"] = ck.BaseURL
825
  }
826
+ addConfigHeadersToAttrs(ck.Headers, attrs)
827
  proxyURL := strings.TrimSpace(ck.ProxyURL)
828
  a := &coreauth.Auth{
829
  ID: id,
 
866
  if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" {
867
  attrs["models_hash"] = hash
868
  }
869
+ addConfigHeadersToAttrs(compat.Headers, attrs)
870
  a := &coreauth.Auth{
871
  ID: id,
872
  Provider: providerName,
 
899
  if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" {
900
  attrs["models_hash"] = hash
901
  }
902
+ addConfigHeadersToAttrs(compat.Headers, attrs)
903
  a := &coreauth.Auth{
904
  ID: id,
905
  Provider: providerName,
 
925
  if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" {
926
  attrs["models_hash"] = hash
927
  }
928
+ addConfigHeadersToAttrs(compat.Headers, attrs)
929
  a := &coreauth.Auth{
930
  ID: id,
931
  Provider: providerName,
 
1127
  newKeyCount := countAPIKeys(newEntry)
1128
  oldModelCount := countOpenAIModels(oldEntry.Models)
1129
  newModelCount := countOpenAIModels(newEntry.Models)
1130
+ details := make([]string, 0, 3)
1131
  if oldKeyCount != newKeyCount {
1132
  details = append(details, fmt.Sprintf("api-keys %d -> %d", oldKeyCount, newKeyCount))
1133
  }
1134
  if oldModelCount != newModelCount {
1135
  details = append(details, fmt.Sprintf("models %d -> %d", oldModelCount, newModelCount))
1136
  }
1137
+ if !equalStringMap(oldEntry.Headers, newEntry.Headers) {
1138
+ details = append(details, "headers updated")
1139
+ }
1140
  if len(details) == 0 {
1141
  return ""
1142
  }
 
1302
  if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
1303
  changes = append(changes, fmt.Sprintf("claude[%d].api-key: updated", i))
1304
  }
1305
+ if !equalStringMap(o.Headers, n.Headers) {
1306
+ changes = append(changes, fmt.Sprintf("claude[%d].headers: updated", i))
1307
+ }
1308
  }
1309
  }
1310
 
 
1327
  if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
1328
  changes = append(changes, fmt.Sprintf("codex[%d].api-key: updated", i))
1329
  }
1330
+ if !equalStringMap(o.Headers, n.Headers) {
1331
+ changes = append(changes, fmt.Sprintf("codex[%d].headers: updated", i))
1332
+ }
1333
  }
1334
  }
1335
 
 
1362
  return changes
1363
  }
1364
 
1365
+ func addConfigHeadersToAttrs(headers map[string]string, attrs map[string]string) {
1366
+ if len(headers) == 0 || attrs == nil {
1367
+ return
1368
+ }
1369
+ for hk, hv := range headers {
1370
+ key := strings.TrimSpace(hk)
1371
+ val := strings.TrimSpace(hv)
1372
+ if key == "" || val == "" {
1373
+ continue
1374
+ }
1375
+ attrs["header:"+key] = val
1376
+ }
1377
+ }
1378
+
1379
  func trimStrings(in []string) []string {
1380
  out := make([]string, len(in))
1381
  for i := range in {