Compare commits

..

9 Commits

Author SHA1 Message Date
Luis Pater ef7e8206d3 fix(executor): ensure usage reporting for upstream responses lacking usage data
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
Add `ensurePublished` to guarantee request counting even when usage fields (e.g., tokens) are absent in OpenAI-compatible executor responses, particularly for streaming paths.
2025-11-09 17:24:47 +08:00
Luis Pater 87291c0d75 Merge pull request #227 from router-for-me/api
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
add headers support for api
2025-11-09 14:00:37 +08:00
hkfires 51d2766d5c fix(management): sanitize keys and normalize headers 2025-11-09 12:13:02 +08:00
hkfires a00ba77604 refactor(config): rename SyncGeminiKeys; use Sanitize* methods 2025-11-09 08:29:47 +08:00
Luis Pater 3264605c2d Merge pull request #226 from router-for-me/headers
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
feat(config): support HTTP headers across providers
2025-11-08 21:41:31 +08:00
hkfires cfb9cb8951 feat(config): support HTTP headers across providers 2025-11-08 20:52:05 +08:00
Luis Pater bb00436509 fix(service): skip disabled auth entries during executor binding
Prevent disabled auth entries from overriding active provider executors, addressing lingering configs during reloads (e.g., removed OpenAI-compat entries).
2025-11-08 18:19:34 +08:00
Luis Pater 1afbc4dd96 fix(translator): separate tool calls from content in OpenAI Claude requests
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
2025-11-08 17:57:46 +08:00
Luis Pater d745f07044 fix(registry): replace Gemini model list with updated stable and preview versions
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
2025-11-08 15:51:57 +08:00
13 changed files with 303 additions and 124 deletions
+10 -4
View File
@@ -49,10 +49,10 @@ ws-auth: false
# Gemini API keys (preferred) # Gemini API keys (preferred)
#gemini-api-key: #gemini-api-key:
# - api-key: "AIzaSy...01" # - api-key: "AIzaSy...01"
# # base-url: "https://generativelanguage.googleapis.com" # base-url: "https://generativelanguage.googleapis.com"
# # headers: # headers:
# # X-Custom-Header: "custom-value" # X-Custom-Header: "custom-value"
# # proxy-url: "socks5://proxy.example.com:1080" # proxy-url: "socks5://proxy.example.com:1080"
# - api-key: "AIzaSy...02" # - api-key: "AIzaSy...02"
# API keys for official Generative Language API (legacy compatibility) # API keys for official Generative Language API (legacy compatibility)
@@ -64,6 +64,8 @@ ws-auth: false
#codex-api-key: #codex-api-key:
# - api-key: "sk-atSM..." # - api-key: "sk-atSM..."
# base-url: "https://www.example.com" # use the custom codex API endpoint # 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 # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
# Claude API keys # 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..." # use the official claude API key, no need to set the base url
# - api-key: "sk-atSM..." # - api-key: "sk-atSM..."
# base-url: "https://www.example.com" # use the custom claude API endpoint # 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 # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
# models: # models:
# - name: "claude-3-5-sonnet-20241022" # upstream model name # - name: "claude-3-5-sonnet-20241022" # upstream model name
@@ -80,6 +84,8 @@ ws-auth: false
#openai-compatibility: #openai-compatibility:
# - name: "openrouter" # The name of the provider; it will be used in the user agent and other places. # - 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. # 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): # # New format with per-key proxy support (recommended):
# api-key-entries: # api-key-entries:
# - api-key: "sk-or-v1-...b780" # - api-key: "sk-or-v1-...b780"
@@ -148,7 +148,7 @@ func (h *Handler) applyLegacyKeys(keys []string) {
} }
h.cfg.GeminiKey = newList h.cfg.GeminiKey = newList
h.cfg.GlAPIKey = sanitized h.cfg.GlAPIKey = sanitized
h.cfg.SyncGeminiKeys() h.cfg.SanitizeGeminiKeys()
} }
// api-keys // api-keys
@@ -206,7 +206,7 @@ func (h *Handler) PutGeminiKeys(c *gin.Context) {
arr = obj.Items arr = obj.Items
} }
h.cfg.GeminiKey = append([]config.GeminiKey(nil), arr...) h.cfg.GeminiKey = append([]config.GeminiKey(nil), arr...)
h.cfg.SyncGeminiKeys() h.cfg.SanitizeGeminiKeys()
h.persist(c) h.persist(c)
} }
func (h *Handler) PatchGeminiKey(c *gin.Context) { func (h *Handler) PatchGeminiKey(c *gin.Context) {
@@ -227,7 +227,7 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) {
// Treat empty API key as delete. // Treat empty API key as delete.
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) { if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) {
h.cfg.GeminiKey = append(h.cfg.GeminiKey[:*body.Index], h.cfg.GeminiKey[*body.Index+1:]...) h.cfg.GeminiKey = append(h.cfg.GeminiKey[:*body.Index], h.cfg.GeminiKey[*body.Index+1:]...)
h.cfg.SyncGeminiKeys() h.cfg.SanitizeGeminiKeys()
h.persist(c) h.persist(c)
return return
} }
@@ -245,7 +245,7 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) {
} }
if removed { if removed {
h.cfg.GeminiKey = out h.cfg.GeminiKey = out
h.cfg.SyncGeminiKeys() h.cfg.SanitizeGeminiKeys()
h.persist(c) h.persist(c)
return return
} }
@@ -257,7 +257,7 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) {
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) { if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) {
h.cfg.GeminiKey[*body.Index] = value h.cfg.GeminiKey[*body.Index] = value
h.cfg.SyncGeminiKeys() h.cfg.SanitizeGeminiKeys()
h.persist(c) h.persist(c)
return return
} }
@@ -266,7 +266,7 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) {
for i := range h.cfg.GeminiKey { for i := range h.cfg.GeminiKey {
if h.cfg.GeminiKey[i].APIKey == match { if h.cfg.GeminiKey[i].APIKey == match {
h.cfg.GeminiKey[i] = value h.cfg.GeminiKey[i] = value
h.cfg.SyncGeminiKeys() h.cfg.SanitizeGeminiKeys()
h.persist(c) h.persist(c)
return return
} }
@@ -284,7 +284,7 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) {
} }
if len(out) != len(h.cfg.GeminiKey) { if len(out) != len(h.cfg.GeminiKey) {
h.cfg.GeminiKey = out h.cfg.GeminiKey = out
h.cfg.SyncGeminiKeys() h.cfg.SanitizeGeminiKeys()
h.persist(c) h.persist(c)
} else { } else {
c.JSON(404, gin.H{"error": "item not found"}) c.JSON(404, gin.H{"error": "item not found"})
@@ -295,7 +295,7 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) {
var idx int var idx int
if _, err := fmt.Sscanf(idxStr, "%d", &idx); err == nil && idx >= 0 && idx < len(h.cfg.GeminiKey) { if _, err := fmt.Sscanf(idxStr, "%d", &idx); err == nil && idx >= 0 && idx < len(h.cfg.GeminiKey) {
h.cfg.GeminiKey = append(h.cfg.GeminiKey[:idx], h.cfg.GeminiKey[idx+1:]...) h.cfg.GeminiKey = append(h.cfg.GeminiKey[:idx], h.cfg.GeminiKey[idx+1:]...)
h.cfg.SyncGeminiKeys() h.cfg.SanitizeGeminiKeys()
h.persist(c) h.persist(c)
return return
} }
@@ -328,6 +328,7 @@ func (h *Handler) PutClaudeKeys(c *gin.Context) {
normalizeClaudeKey(&arr[i]) normalizeClaudeKey(&arr[i])
} }
h.cfg.ClaudeKey = arr h.cfg.ClaudeKey = arr
h.cfg.SanitizeClaudeKeys()
h.persist(c) h.persist(c)
} }
func (h *Handler) PatchClaudeKey(c *gin.Context) { func (h *Handler) PatchClaudeKey(c *gin.Context) {
@@ -340,16 +341,19 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) {
c.JSON(400, gin.H{"error": "invalid body"}) c.JSON(400, gin.H{"error": "invalid body"})
return return
} }
normalizeClaudeKey(body.Value) value := *body.Value
normalizeClaudeKey(&value)
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.ClaudeKey) { if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.ClaudeKey) {
h.cfg.ClaudeKey[*body.Index] = *body.Value h.cfg.ClaudeKey[*body.Index] = value
h.cfg.SanitizeClaudeKeys()
h.persist(c) h.persist(c)
return return
} }
if body.Match != nil { if body.Match != nil {
for i := range h.cfg.ClaudeKey { for i := range h.cfg.ClaudeKey {
if h.cfg.ClaudeKey[i].APIKey == *body.Match { if h.cfg.ClaudeKey[i].APIKey == *body.Match {
h.cfg.ClaudeKey[i] = *body.Value h.cfg.ClaudeKey[i] = value
h.cfg.SanitizeClaudeKeys()
h.persist(c) h.persist(c)
return return
} }
@@ -366,6 +370,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) {
} }
} }
h.cfg.ClaudeKey = out h.cfg.ClaudeKey = out
h.cfg.SanitizeClaudeKeys()
h.persist(c) h.persist(c)
return return
} }
@@ -374,6 +379,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) {
_, err := fmt.Sscanf(idxStr, "%d", &idx) _, err := fmt.Sscanf(idxStr, "%d", &idx)
if err == nil && idx >= 0 && idx < len(h.cfg.ClaudeKey) { if err == nil && idx >= 0 && idx < len(h.cfg.ClaudeKey) {
h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:idx], h.cfg.ClaudeKey[idx+1:]...) h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:idx], h.cfg.ClaudeKey[idx+1:]...)
h.cfg.SanitizeClaudeKeys()
h.persist(c) h.persist(c)
return return
} }
@@ -413,6 +419,7 @@ func (h *Handler) PutOpenAICompat(c *gin.Context) {
} }
} }
h.cfg.OpenAICompatibility = filtered h.cfg.OpenAICompatibility = filtered
h.cfg.SanitizeOpenAICompatibility()
h.persist(c) h.persist(c)
} }
func (h *Handler) PatchOpenAICompat(c *gin.Context) { func (h *Handler) PatchOpenAICompat(c *gin.Context) {
@@ -430,6 +437,7 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
if strings.TrimSpace(body.Value.BaseURL) == "" { if strings.TrimSpace(body.Value.BaseURL) == "" {
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) { if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {
h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:*body.Index], h.cfg.OpenAICompatibility[*body.Index+1:]...) h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:*body.Index], h.cfg.OpenAICompatibility[*body.Index+1:]...)
h.cfg.SanitizeOpenAICompatibility()
h.persist(c) h.persist(c)
return return
} }
@@ -445,6 +453,7 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
} }
if removed { if removed {
h.cfg.OpenAICompatibility = out h.cfg.OpenAICompatibility = out
h.cfg.SanitizeOpenAICompatibility()
h.persist(c) h.persist(c)
return return
} }
@@ -454,6 +463,7 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
} }
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) { if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {
h.cfg.OpenAICompatibility[*body.Index] = *body.Value h.cfg.OpenAICompatibility[*body.Index] = *body.Value
h.cfg.SanitizeOpenAICompatibility()
h.persist(c) h.persist(c)
return return
} }
@@ -461,6 +471,7 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
for i := range h.cfg.OpenAICompatibility { for i := range h.cfg.OpenAICompatibility {
if h.cfg.OpenAICompatibility[i].Name == *body.Name { if h.cfg.OpenAICompatibility[i].Name == *body.Name {
h.cfg.OpenAICompatibility[i] = *body.Value h.cfg.OpenAICompatibility[i] = *body.Value
h.cfg.SanitizeOpenAICompatibility()
h.persist(c) h.persist(c)
return return
} }
@@ -477,6 +488,7 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
} }
} }
h.cfg.OpenAICompatibility = out h.cfg.OpenAICompatibility = out
h.cfg.SanitizeOpenAICompatibility()
h.persist(c) h.persist(c)
return return
} }
@@ -485,6 +497,7 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
_, err := fmt.Sscanf(idxStr, "%d", &idx) _, err := fmt.Sscanf(idxStr, "%d", &idx)
if err == nil && idx >= 0 && idx < len(h.cfg.OpenAICompatibility) { if err == nil && idx >= 0 && idx < len(h.cfg.OpenAICompatibility) {
h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:idx], h.cfg.OpenAICompatibility[idx+1:]...) h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:idx], h.cfg.OpenAICompatibility[idx+1:]...)
h.cfg.SanitizeOpenAICompatibility()
h.persist(c) h.persist(c)
return return
} }
@@ -517,13 +530,17 @@ func (h *Handler) PutCodexKeys(c *gin.Context) {
filtered := make([]config.CodexKey, 0, len(arr)) filtered := make([]config.CodexKey, 0, len(arr))
for i := range arr { for i := range arr {
entry := arr[i] entry := arr[i]
entry.APIKey = strings.TrimSpace(entry.APIKey)
entry.BaseURL = strings.TrimSpace(entry.BaseURL) entry.BaseURL = strings.TrimSpace(entry.BaseURL)
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
entry.Headers = config.NormalizeHeaders(entry.Headers)
if entry.BaseURL == "" { if entry.BaseURL == "" {
continue continue
} }
filtered = append(filtered, entry) filtered = append(filtered, entry)
} }
h.cfg.CodexKey = filtered h.cfg.CodexKey = filtered
h.cfg.SanitizeCodexKeys()
h.persist(c) h.persist(c)
} }
func (h *Handler) PatchCodexKey(c *gin.Context) { func (h *Handler) PatchCodexKey(c *gin.Context) {
@@ -536,10 +553,16 @@ func (h *Handler) PatchCodexKey(c *gin.Context) {
c.JSON(400, gin.H{"error": "invalid body"}) c.JSON(400, gin.H{"error": "invalid body"})
return return
} }
value := *body.Value
value.APIKey = strings.TrimSpace(value.APIKey)
value.BaseURL = strings.TrimSpace(value.BaseURL)
value.ProxyURL = strings.TrimSpace(value.ProxyURL)
value.Headers = config.NormalizeHeaders(value.Headers)
// If base-url becomes empty, delete instead of update // If base-url becomes empty, delete instead of update
if strings.TrimSpace(body.Value.BaseURL) == "" { if value.BaseURL == "" {
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) { if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
h.cfg.CodexKey = append(h.cfg.CodexKey[:*body.Index], h.cfg.CodexKey[*body.Index+1:]...) h.cfg.CodexKey = append(h.cfg.CodexKey[:*body.Index], h.cfg.CodexKey[*body.Index+1:]...)
h.cfg.SanitizeCodexKeys()
h.persist(c) h.persist(c)
return return
} }
@@ -555,20 +578,23 @@ func (h *Handler) PatchCodexKey(c *gin.Context) {
} }
if removed { if removed {
h.cfg.CodexKey = out h.cfg.CodexKey = out
h.cfg.SanitizeCodexKeys()
h.persist(c) h.persist(c)
return return
} }
} }
} else { } else {
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) { if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
h.cfg.CodexKey[*body.Index] = *body.Value h.cfg.CodexKey[*body.Index] = value
h.cfg.SanitizeCodexKeys()
h.persist(c) h.persist(c)
return return
} }
if body.Match != nil { if body.Match != nil {
for i := range h.cfg.CodexKey { for i := range h.cfg.CodexKey {
if h.cfg.CodexKey[i].APIKey == *body.Match { if h.cfg.CodexKey[i].APIKey == *body.Match {
h.cfg.CodexKey[i] = *body.Value h.cfg.CodexKey[i] = value
h.cfg.SanitizeCodexKeys()
h.persist(c) h.persist(c)
return return
} }
@@ -586,6 +612,7 @@ func (h *Handler) DeleteCodexKey(c *gin.Context) {
} }
} }
h.cfg.CodexKey = out h.cfg.CodexKey = out
h.cfg.SanitizeCodexKeys()
h.persist(c) h.persist(c)
return return
} }
@@ -594,6 +621,7 @@ func (h *Handler) DeleteCodexKey(c *gin.Context) {
_, err := fmt.Sscanf(idxStr, "%d", &idx) _, err := fmt.Sscanf(idxStr, "%d", &idx)
if err == nil && idx >= 0 && idx < len(h.cfg.CodexKey) { if err == nil && idx >= 0 && idx < len(h.cfg.CodexKey) {
h.cfg.CodexKey = append(h.cfg.CodexKey[:idx], h.cfg.CodexKey[idx+1:]...) h.cfg.CodexKey = append(h.cfg.CodexKey[:idx], h.cfg.CodexKey[idx+1:]...)
h.cfg.SanitizeCodexKeys()
h.persist(c) h.persist(c)
return return
} }
@@ -607,6 +635,7 @@ func normalizeOpenAICompatibilityEntry(entry *config.OpenAICompatibility) {
} }
// Trim base-url; empty base-url indicates provider should be removed by sanitization // Trim base-url; empty base-url indicates provider should be removed by sanitization
entry.BaseURL = strings.TrimSpace(entry.BaseURL) entry.BaseURL = strings.TrimSpace(entry.BaseURL)
entry.Headers = config.NormalizeHeaders(entry.Headers)
existing := make(map[string]struct{}, len(entry.APIKeyEntries)) existing := make(map[string]struct{}, len(entry.APIKeyEntries))
for i := range entry.APIKeyEntries { for i := range entry.APIKeyEntries {
trimmed := strings.TrimSpace(entry.APIKeyEntries[i].APIKey) trimmed := strings.TrimSpace(entry.APIKeyEntries[i].APIKey)
@@ -658,6 +687,7 @@ func normalizeClaudeKey(entry *config.ClaudeKey) {
entry.APIKey = strings.TrimSpace(entry.APIKey) entry.APIKey = strings.TrimSpace(entry.APIKey)
entry.BaseURL = strings.TrimSpace(entry.BaseURL) entry.BaseURL = strings.TrimSpace(entry.BaseURL)
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
entry.Headers = config.NormalizeHeaders(entry.Headers)
if len(entry.Models) == 0 { if len(entry.Models) == 0 {
return return
} }
+40 -13
View File
@@ -100,6 +100,9 @@ type ClaudeKey struct {
// Models defines upstream model names and aliases for request routing. // Models defines upstream model names and aliases for request routing.
Models []ClaudeModel `yaml:"models" json:"models"` 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. // 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 overrides the global proxy setting for this API key if provided.
ProxyURL string `yaml:"proxy-url" json:"proxy-url"` 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, // 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 defines the model configurations including aliases for routing.
Models []OpenAICompatibilityModel `yaml:"models" json:"models"` 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. // OpenAICompatibilityAPIKey represents an API key configuration with optional proxy setting.
@@ -246,23 +255,26 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
// Sync request authentication providers with inline API keys for backwards compatibility. // Sync request authentication providers with inline API keys for backwards compatibility.
syncInlineAccessProvider(&cfg) syncInlineAccessProvider(&cfg)
// Normalize Gemini API key configuration and migrate legacy entries. // Sanitize Gemini API key configuration and migrate legacy entries.
cfg.SyncGeminiKeys() cfg.SanitizeGeminiKeys()
// Sanitize OpenAI compatibility providers: drop entries without base-url
sanitizeOpenAICompatibility(&cfg)
// Sanitize Codex keys: drop entries without base-url // Sanitize Codex keys: drop entries without base-url
sanitizeCodexKeys(&cfg) cfg.SanitizeCodexKeys()
// Sanitize Claude key headers
cfg.SanitizeClaudeKeys()
// Sanitize OpenAI compatibility providers: drop entries without base-url
cfg.SanitizeOpenAICompatibility()
// Return the populated configuration struct. // Return the populated configuration struct.
return &cfg, nil return &cfg, nil
} }
// sanitizeOpenAICompatibility removes OpenAI-compatibility provider entries that are // SanitizeOpenAICompatibility removes OpenAI-compatibility provider entries that are
// not actionable, specifically those missing a BaseURL. It trims whitespace before // not actionable, specifically those missing a BaseURL. It trims whitespace before
// evaluation and preserves the relative order of remaining entries. // evaluation and preserves the relative order of remaining entries.
func sanitizeOpenAICompatibility(cfg *Config) { func (cfg *Config) SanitizeOpenAICompatibility() {
if cfg == nil || len(cfg.OpenAICompatibility) == 0 { if cfg == nil || len(cfg.OpenAICompatibility) == 0 {
return return
} }
@@ -271,6 +283,7 @@ func sanitizeOpenAICompatibility(cfg *Config) {
e := cfg.OpenAICompatibility[i] e := cfg.OpenAICompatibility[i]
e.Name = strings.TrimSpace(e.Name) e.Name = strings.TrimSpace(e.Name)
e.BaseURL = strings.TrimSpace(e.BaseURL) e.BaseURL = strings.TrimSpace(e.BaseURL)
e.Headers = NormalizeHeaders(e.Headers)
if e.BaseURL == "" { if e.BaseURL == "" {
// Skip providers with no base-url; treated as removed // Skip providers with no base-url; treated as removed
continue continue
@@ -280,9 +293,9 @@ func sanitizeOpenAICompatibility(cfg *Config) {
cfg.OpenAICompatibility = out cfg.OpenAICompatibility = out
} }
// sanitizeCodexKeys removes Codex API key entries missing a BaseURL. // SanitizeCodexKeys removes Codex API key entries missing a BaseURL.
// It trims whitespace and preserves order for remaining entries. // It trims whitespace and preserves order for remaining entries.
func sanitizeCodexKeys(cfg *Config) { func (cfg *Config) SanitizeCodexKeys() {
if cfg == nil || len(cfg.CodexKey) == 0 { if cfg == nil || len(cfg.CodexKey) == 0 {
return return
} }
@@ -290,6 +303,7 @@ func sanitizeCodexKeys(cfg *Config) {
for i := range cfg.CodexKey { for i := range cfg.CodexKey {
e := cfg.CodexKey[i] e := cfg.CodexKey[i]
e.BaseURL = strings.TrimSpace(e.BaseURL) e.BaseURL = strings.TrimSpace(e.BaseURL)
e.Headers = NormalizeHeaders(e.Headers)
if e.BaseURL == "" { if e.BaseURL == "" {
continue continue
} }
@@ -298,7 +312,19 @@ func sanitizeCodexKeys(cfg *Config) {
cfg.CodexKey = out cfg.CodexKey = out
} }
func (cfg *Config) SyncGeminiKeys() { // SanitizeClaudeKeys normalizes headers for Claude credentials.
func (cfg *Config) SanitizeClaudeKeys() {
if cfg == nil || len(cfg.ClaudeKey) == 0 {
return
}
for i := range cfg.ClaudeKey {
entry := &cfg.ClaudeKey[i]
entry.Headers = NormalizeHeaders(entry.Headers)
}
}
// SanitizeGeminiKeys deduplicates and normalizes Gemini credentials.
func (cfg *Config) SanitizeGeminiKeys() {
if cfg == nil { if cfg == nil {
return return
} }
@@ -313,7 +339,7 @@ func (cfg *Config) SyncGeminiKeys() {
} }
entry.BaseURL = strings.TrimSpace(entry.BaseURL) entry.BaseURL = strings.TrimSpace(entry.BaseURL)
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
entry.Headers = normalizeGeminiHeaders(entry.Headers) entry.Headers = NormalizeHeaders(entry.Headers)
if _, exists := seen[entry.APIKey]; exists { if _, exists := seen[entry.APIKey]; exists {
continue continue
} }
@@ -356,7 +382,8 @@ func looksLikeBcrypt(s string) bool {
return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$") return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$")
} }
func normalizeGeminiHeaders(headers map[string]string) map[string]string { // NormalizeHeaders trims header keys and values and removes empty pairs.
func NormalizeHeaders(headers map[string]string) map[string]string {
if len(headers) == 0 { if len(headers) == 0 {
return nil return nil
} }
+47 -20
View File
@@ -124,26 +124,53 @@ func GetGeminiModels() []*ModelInfo { return GeminiModels() }
// GetGeminiCLIModels returns the standard Gemini model definitions // GetGeminiCLIModels returns the standard Gemini model definitions
func GetGeminiCLIModels() []*ModelInfo { func GetGeminiCLIModels() []*ModelInfo {
base := GeminiModels() return []*ModelInfo{
return append(base, {
[]*ModelInfo{ ID: "gemini-2.5-flash",
{ Object: "model",
ID: "gemini-3-pro-preview-11-2025", Created: time.Now().Unix(),
Object: "model", OwnedBy: "google",
Created: time.Now().Unix(), Type: "gemini",
OwnedBy: "google", Name: "models/gemini-2.5-flash",
Type: "gemini", Version: "001",
Name: "models/gemini-3-pro-preview-11-2025", DisplayName: "Gemini 2.5 Flash",
Version: "3", Description: "Stable version of Gemini 2.5 Flash, our mid-size multimodal model that supports up to 1 million tokens, released in June of 2025.",
DisplayName: "Gemini 3 Pro Preview 11-2025", InputTokenLimit: 1048576,
Description: "Latest preview of Gemini Pro", OutputTokenLimit: 65536,
InputTokenLimit: 1048576, SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
OutputTokenLimit: 65536, Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true},
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"}, },
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, {
}, ID: "gemini-2.5-pro",
}..., Object: "model",
) Created: time.Now().Unix(),
OwnedBy: "google",
Type: "gemini",
Name: "models/gemini-2.5-pro",
Version: "2.5",
DisplayName: "Gemini 2.5 Pro",
Description: "Stable release (June 17th, 2025) of Gemini 2.5 Pro",
InputTokenLimit: 1048576,
OutputTokenLimit: 65536,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true},
},
{
ID: "gemini-3-pro-preview-11-2025",
Object: "model",
Created: time.Now().Unix(),
OwnedBy: "google",
Type: "gemini",
Name: "models/gemini-3-pro-preview-11-2025",
Version: "3",
DisplayName: "Gemini 3 Pro Preview 11-2025",
Description: "Latest preview of Gemini Pro",
InputTokenLimit: 1048576,
OutputTokenLimit: 65536,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true},
},
}
} }
// GetAIStudioModels returns the Gemini model definitions for AI Studio integrations // GetAIStudioModels returns the Gemini model definitions for AI Studio integrations
+12 -6
View File
@@ -17,6 +17,7 @@ import (
claudeauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude" 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/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc" "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" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" 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 { if err != nil {
return resp, err return resp, err
} }
applyClaudeHeaders(httpReq, apiKey, false) applyClaudeHeaders(httpReq, auth, apiKey, false)
var authID, authLabel, authType, authValue string var authID, authLabel, authType, authValue string
if auth != nil { if auth != nil {
authID = auth.ID authID = auth.ID
@@ -159,7 +160,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
if err != nil { if err != nil {
return nil, err return nil, err
} }
applyClaudeHeaders(httpReq, apiKey, true) applyClaudeHeaders(httpReq, auth, apiKey, true)
var authID, authLabel, authType, authValue string var authID, authLabel, authType, authValue string
if auth != nil { if auth != nil {
authID = auth.ID authID = auth.ID
@@ -290,7 +291,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
if err != nil { if err != nil {
return cliproxyexecutor.Response{}, err return cliproxyexecutor.Response{}, err
} }
applyClaudeHeaders(httpReq, apiKey, false) applyClaudeHeaders(httpReq, auth, apiKey, false)
var authID, authLabel, authType, authValue string var authID, authLabel, authType, authValue string
if auth != nil { if auth != nil {
authID = auth.ID authID = auth.ID
@@ -529,7 +530,7 @@ func decodeResponseBody(body io.ReadCloser, contentEncoding string) (io.ReadClos
return body, nil 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("Authorization", "Bearer "+apiKey)
r.Header.Set("Content-Type", "application/json") 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") r.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
if stream { if stream {
r.Header.Set("Accept", "text/event-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) { func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
@@ -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) { func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
+4 -37
View File
@@ -495,44 +495,11 @@ func resolveGeminiBaseURL(auth *cliproxyauth.Auth) string {
} }
func applyGeminiHeaders(req *http.Request, auth *cliproxyauth.Auth) { func applyGeminiHeaders(req *http.Request, auth *cliproxyauth.Auth) {
if req == nil { var attrs map[string]string
return if auth != nil {
attrs = auth.Attributes
} }
headers := geminiCustomHeaders(auth) util.ApplyCustomHeadersFromAttrs(req, attrs)
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
} }
func fixGeminiImageAspectRatio(modelName string, rawJSON []byte) []byte { func fixGeminiImageAspectRatio(modelName string, rawJSON []byte) []byte {
@@ -10,6 +10,7 @@ import (
"strings" "strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config" "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" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" 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("Authorization", "Bearer "+apiKey)
} }
httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat") 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 var authID, authLabel, authType, authValue string
if auth != nil { if auth != nil {
authID = auth.ID authID = auth.ID
@@ -110,6 +116,8 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
} }
appendAPIResponseChunk(ctx, e.cfg, body) appendAPIResponseChunk(ctx, e.cfg, body)
reporter.publish(ctx, parseOpenAIUsage(body)) reporter.publish(ctx, parseOpenAIUsage(body))
// Ensure we at least record the request even if upstream doesn't return usage
reporter.ensurePublished(ctx)
// Translate response back to source format when needed // Translate response back to source format when needed
var param any var param any
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, body, &param) out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, body, &param)
@@ -143,6 +151,11 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
httpReq.Header.Set("Authorization", "Bearer "+apiKey) httpReq.Header.Set("Authorization", "Bearer "+apiKey)
} }
httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat") 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("Accept", "text/event-stream")
httpReq.Header.Set("Cache-Control", "no-cache") httpReq.Header.Set("Cache-Control", "no-cache")
var authID, authLabel, authType, authValue string var authID, authLabel, authType, authValue string
@@ -214,6 +227,8 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
reporter.publishFailure(ctx) reporter.publishFailure(ctx)
out <- cliproxyexecutor.StreamChunk{Err: errScan} out <- cliproxyexecutor.StreamChunk{Err: errScan}
} }
// Ensure we record the request if no usage chunk was ever seen
reporter.ensurePublished(ctx)
}() }()
return stream, nil return stream, nil
} }
@@ -84,6 +84,28 @@ func (r *usageReporter) publishWithOutcome(ctx context.Context, detail usage.Det
}) })
} }
// ensurePublished guarantees that a usage record is emitted exactly once.
// It is safe to call multiple times; only the first call wins due to once.Do.
// This is used to ensure request counting even when upstream responses do not
// include any usage fields (tokens), especially for streaming paths.
func (r *usageReporter) ensurePublished(ctx context.Context) {
if r == nil {
return
}
r.once.Do(func() {
usage.PublishRecord(ctx, usage.Record{
Provider: r.provider,
Model: r.model,
Source: r.source,
APIKey: r.apiKey,
AuthID: r.authID,
RequestedAt: r.requestedAt,
Failed: false,
Detail: usage.Detail{},
})
})
}
func apiKeyFromContext(ctx context.Context) string { func apiKeyFromContext(ctx context.Context) string {
if ctx == nil { if ctx == nil {
return "" return ""
@@ -133,27 +133,16 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
return true return true
}) })
// Create main message if there's text content or tool calls // Emit text/image content as one message
if len(contentItems) > 0 || len(toolCalls) > 0 { if len(contentItems) > 0 {
msgJSON := `{"role":"","content":""}` msgJSON := `{"role":"","content":""}`
msgJSON, _ = sjson.Set(msgJSON, "role", role) msgJSON, _ = sjson.Set(msgJSON, "role", role)
// Set content contentArrayJSON := "[]"
if len(contentItems) > 0 { for _, contentItem := range contentItems {
contentArrayJSON := "[]" contentArrayJSON, _ = sjson.SetRaw(contentArrayJSON, "-1", contentItem)
for _, contentItem := range contentItems {
contentArrayJSON, _ = sjson.SetRaw(contentArrayJSON, "-1", contentItem)
}
msgJSON, _ = sjson.SetRaw(msgJSON, "content", contentArrayJSON)
} else {
msgJSON, _ = sjson.Set(msgJSON, "content", "")
}
// Set tool calls for assistant messages
if role == "assistant" && len(toolCalls) > 0 {
toolCallsJSON, _ := json.Marshal(toolCalls)
msgJSON, _ = sjson.SetRaw(msgJSON, "tool_calls", string(toolCallsJSON))
} }
msgJSON, _ = sjson.SetRaw(msgJSON, "content", contentArrayJSON)
contentValue := gjson.Get(msgJSON, "content") contentValue := gjson.Get(msgJSON, "content")
hasContent := false hasContent := false
@@ -168,11 +157,19 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
hasContent = contentValue.Raw != "" && contentValue.Raw != "null" hasContent = contentValue.Raw != "" && contentValue.Raw != "null"
} }
if hasContent || len(toolCalls) != 0 { if hasContent {
messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(msgJSON).Value()) messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(msgJSON).Value())
} }
} }
// Emit tool calls in a separate assistant message
if role == "assistant" && len(toolCalls) > 0 {
toolCallMsgJSON := `{"role":"assistant","tool_calls":[]}`
toolCallsJSON, _ := json.Marshal(toolCalls)
toolCallMsgJSON, _ = sjson.SetRaw(toolCallMsgJSON, "tool_calls", string(toolCallsJSON))
messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolCallMsgJSON).Value())
}
} else if contentResult.Exists() && contentResult.Type == gjson.String { } else if contentResult.Exists() && contentResult.Type == gjson.String {
// Simple string content // Simple string content
msgJSON := `{"role":"","content":""}` msgJSON := `{"role":"","content":""}`
+52
View File
@@ -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)
}
}
+30 -11
View File
@@ -762,16 +762,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
if base != "" { if base != "" {
attrs["base_url"] = base attrs["base_url"] = base
} }
if len(entry.Headers) > 0 { addConfigHeadersToAttrs(entry.Headers, attrs)
for hk, hv := range entry.Headers {
key := strings.TrimSpace(hk)
val := strings.TrimSpace(hv)
if key == "" || val == "" {
continue
}
attrs["header:"+key] = val
}
}
a := &coreauth.Auth{ a := &coreauth.Auth{
ID: id, ID: id,
Provider: "gemini", Provider: "gemini",
@@ -803,6 +794,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
if hash := computeClaudeModelsHash(ck.Models); hash != "" { if hash := computeClaudeModelsHash(ck.Models); hash != "" {
attrs["models_hash"] = hash attrs["models_hash"] = hash
} }
addConfigHeadersToAttrs(ck.Headers, attrs)
proxyURL := strings.TrimSpace(ck.ProxyURL) proxyURL := strings.TrimSpace(ck.ProxyURL)
a := &coreauth.Auth{ a := &coreauth.Auth{
ID: id, ID: id,
@@ -831,6 +823,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
if ck.BaseURL != "" { if ck.BaseURL != "" {
attrs["base_url"] = ck.BaseURL attrs["base_url"] = ck.BaseURL
} }
addConfigHeadersToAttrs(ck.Headers, attrs)
proxyURL := strings.TrimSpace(ck.ProxyURL) proxyURL := strings.TrimSpace(ck.ProxyURL)
a := &coreauth.Auth{ a := &coreauth.Auth{
ID: id, ID: id,
@@ -873,6 +866,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" { if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" {
attrs["models_hash"] = hash attrs["models_hash"] = hash
} }
addConfigHeadersToAttrs(compat.Headers, attrs)
a := &coreauth.Auth{ a := &coreauth.Auth{
ID: id, ID: id,
Provider: providerName, Provider: providerName,
@@ -905,6 +899,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" { if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" {
attrs["models_hash"] = hash attrs["models_hash"] = hash
} }
addConfigHeadersToAttrs(compat.Headers, attrs)
a := &coreauth.Auth{ a := &coreauth.Auth{
ID: id, ID: id,
Provider: providerName, Provider: providerName,
@@ -930,6 +925,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" { if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" {
attrs["models_hash"] = hash attrs["models_hash"] = hash
} }
addConfigHeadersToAttrs(compat.Headers, attrs)
a := &coreauth.Auth{ a := &coreauth.Auth{
ID: id, ID: id,
Provider: providerName, Provider: providerName,
@@ -1131,13 +1127,16 @@ func describeOpenAICompatibilityUpdate(oldEntry, newEntry config.OpenAICompatibi
newKeyCount := countAPIKeys(newEntry) newKeyCount := countAPIKeys(newEntry)
oldModelCount := countOpenAIModels(oldEntry.Models) oldModelCount := countOpenAIModels(oldEntry.Models)
newModelCount := countOpenAIModels(newEntry.Models) newModelCount := countOpenAIModels(newEntry.Models)
details := make([]string, 0, 2) details := make([]string, 0, 3)
if oldKeyCount != newKeyCount { if oldKeyCount != newKeyCount {
details = append(details, fmt.Sprintf("api-keys %d -> %d", oldKeyCount, newKeyCount)) details = append(details, fmt.Sprintf("api-keys %d -> %d", oldKeyCount, newKeyCount))
} }
if oldModelCount != newModelCount { if oldModelCount != newModelCount {
details = append(details, fmt.Sprintf("models %d -> %d", 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 { if len(details) == 0 {
return "" return ""
} }
@@ -1303,6 +1302,9 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) { if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
changes = append(changes, fmt.Sprintf("claude[%d].api-key: updated", i)) 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) { if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
changes = append(changes, fmt.Sprintf("codex[%d].api-key: updated", i)) 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 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 { func trimStrings(in []string) []string {
out := make([]string, len(in)) out := make([]string, len(in))
for i := range in { for i := range in {
+7 -1
View File
@@ -305,6 +305,12 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) {
if s == nil || a == nil { if s == nil || a == nil {
return return
} }
// Skip disabled auth entries when (re)binding executors.
// Disabled auths can linger during config reloads (e.g., removed OpenAI-compat entries)
// and must not override active provider executors (such as iFlow OAuth accounts).
if a.Disabled {
return
}
if compatProviderKey, _, isCompat := openAICompatInfoFromAuth(a); isCompat { if compatProviderKey, _, isCompat := openAICompatInfoFromAuth(a); isCompat {
if compatProviderKey == "" { if compatProviderKey == "" {
compatProviderKey = strings.ToLower(strings.TrimSpace(a.Provider)) compatProviderKey = strings.ToLower(strings.TrimSpace(a.Provider))
@@ -738,7 +744,7 @@ func (s *Service) resolveConfigClaudeKey(auth *coreauth.Auth) *config.ClaudeKey
continue continue
} }
if attrKey != "" && strings.EqualFold(cfgKey, attrKey) { if attrKey != "" && strings.EqualFold(cfgKey, attrKey) {
if attrBase == "" || cfgBase == "" || strings.EqualFold(cfgBase, attrBase) { if cfgBase == "" || strings.EqualFold(cfgBase, attrBase) {
return entry return entry
} }
} }