From 92d96e0b729d4d48367b0c823329bf96aad31433 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 20 Aug 2026 15:19:07 +0800 Subject: [PATCH] fix(cliproxy): support base_URL-only config credentials and skip stale auth headers - Preserve API key entries with empty `api_key` when `base_url` is configured, and extend config dedupe/ID logic to include base URL, proxy, prefix, and headers so identities are stable. - Update config/auth handling so `auth_kind=apikey` is treated as config API-key auth even without an `api_key` field, enabling base_url-only credential records. - Ensure Gemini/Codex/XAI request path clears `Authorization`/provider auth headers when token is empty to avoid leaking unrelated auth state. --- .../management/config_apikey_disable.go | 50 ++++- .../management/config_apikey_disable_test.go | 82 ++++++++- .../handlers/management/config_auth_index.go | 40 +++- .../api/handlers/management/config_lists.go | 39 ++-- internal/config/config_normalization.go | 45 ++++- .../config/gemini_keys_normalization_test.go | 35 ++++ internal/runtime/executor/claude_executor.go | 18 +- .../executor/claude_executor_request.go | 22 ++- .../runtime/executor/claude_executor_test.go | 49 +++++ .../executor/codex_executor_request.go | 15 +- .../codex_websockets_executor_test.go | 77 ++++++++ .../executor/codex_websockets_request.go | 19 +- internal/runtime/executor/gemini_executor.go | 3 + .../runtime/executor/gemini_executor_test.go | 31 ++++ internal/runtime/executor/xai_executor.go | 2 + .../runtime/executor/xai_executor_request.go | 2 + .../runtime/executor/xai_executor_test.go | 41 +++++ .../executor/xai_websockets_executor.go | 2 + internal/watcher/synthesizer/config.go | 33 ++-- internal/watcher/synthesizer/config_test.go | 173 +++++++++++++++++- sdk/cliproxy/auth/config_apikey.go | 5 +- sdk/cliproxy/auth/config_apikey_test.go | 12 +- 22 files changed, 700 insertions(+), 95 deletions(-) create mode 100644 internal/config/gemini_keys_normalization_test.go diff --git a/internal/api/handlers/management/config_apikey_disable.go b/internal/api/handlers/management/config_apikey_disable.go index f935a8b5..e94c24b9 100644 --- a/internal/api/handlers/management/config_apikey_disable.go +++ b/internal/api/handlers/management/config_apikey_disable.go @@ -43,7 +43,14 @@ func toggleConfigAPIKeyExcludedAll(cfg *config.Config, auth *coreauth.Auth, disa for i := range cfg.GeminiKey { entry := &cfg.GeminiKey[i] - id, _ := idGen.Next("gemini:apikey", entry.APIKey, entry.BaseURL) + key := strings.TrimSpace(entry.APIKey) + base := strings.TrimSpace(entry.BaseURL) + proxyURL := strings.TrimSpace(entry.ProxyURL) + prefix := strings.TrimSpace(entry.Prefix) + if key == "" && base == "" { + continue + } + id, _ := idGen.Next("gemini:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)) if id == authID { entry.ExcludedModels = setConfigAPIKeyExcludedAll(entry.ExcludedModels, disable) return true, nil @@ -51,7 +58,14 @@ func toggleConfigAPIKeyExcludedAll(cfg *config.Config, auth *coreauth.Auth, disa } for i := range cfg.InteractionsKey { entry := &cfg.InteractionsKey[i] - id, _ := idGen.Next("gemini-interactions:apikey", entry.APIKey, entry.BaseURL) + key := strings.TrimSpace(entry.APIKey) + base := strings.TrimSpace(entry.BaseURL) + proxyURL := strings.TrimSpace(entry.ProxyURL) + prefix := strings.TrimSpace(entry.Prefix) + if key == "" && base == "" { + continue + } + id, _ := idGen.Next("gemini-interactions:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)) if id == authID { entry.ExcludedModels = setConfigAPIKeyExcludedAll(entry.ExcludedModels, disable) return true, nil @@ -59,7 +73,14 @@ func toggleConfigAPIKeyExcludedAll(cfg *config.Config, auth *coreauth.Auth, disa } for i := range cfg.ClaudeKey { entry := &cfg.ClaudeKey[i] - id, _ := idGen.Next("claude:apikey", entry.APIKey, entry.BaseURL) + key := strings.TrimSpace(entry.APIKey) + base := strings.TrimSpace(entry.BaseURL) + proxyURL := strings.TrimSpace(entry.ProxyURL) + prefix := strings.TrimSpace(entry.Prefix) + if key == "" && base == "" { + continue + } + id, _ := idGen.Next("claude:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)) if id == authID { entry.ExcludedModels = setConfigAPIKeyExcludedAll(entry.ExcludedModels, disable) return true, nil @@ -67,7 +88,14 @@ func toggleConfigAPIKeyExcludedAll(cfg *config.Config, auth *coreauth.Auth, disa } for i := range cfg.CodexKey { entry := &cfg.CodexKey[i] - id, _ := idGen.Next("codex:apikey", entry.APIKey, entry.BaseURL) + key := strings.TrimSpace(entry.APIKey) + base := strings.TrimSpace(entry.BaseURL) + proxyURL := strings.TrimSpace(entry.ProxyURL) + prefix := strings.TrimSpace(entry.Prefix) + if key == "" && base == "" { + continue + } + id, _ := idGen.Next("codex:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)) if id == authID { entry.ExcludedModels = setConfigAPIKeyExcludedAll(entry.ExcludedModels, disable) return true, nil @@ -75,7 +103,14 @@ func toggleConfigAPIKeyExcludedAll(cfg *config.Config, auth *coreauth.Auth, disa } for i := range cfg.XAIKey { entry := &cfg.XAIKey[i] - id, _ := idGen.Next("xai:apikey", entry.APIKey, entry.BaseURL) + key := strings.TrimSpace(entry.APIKey) + base := strings.TrimSpace(entry.BaseURL) + proxyURL := strings.TrimSpace(entry.ProxyURL) + prefix := strings.TrimSpace(entry.Prefix) + if key == "" && base == "" { + continue + } + id, _ := idGen.Next("xai:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)) if id == authID { entry.ExcludedModels = setConfigAPIKeyExcludedAll(entry.ExcludedModels, disable) return true, nil @@ -83,7 +118,10 @@ func toggleConfigAPIKeyExcludedAll(cfg *config.Config, auth *coreauth.Auth, disa } for i := range cfg.VertexCompatAPIKey { entry := &cfg.VertexCompatAPIKey[i] - id, _ := idGen.Next("vertex:apikey", entry.APIKey, entry.BaseURL, entry.ProxyURL) + key := strings.TrimSpace(entry.APIKey) + base := strings.TrimSpace(entry.BaseURL) + proxy := strings.TrimSpace(entry.ProxyURL) + id, _ := idGen.Next("vertex:apikey", key, base, proxy) if id == authID { entry.ExcludedModels = setConfigAPIKeyExcludedAll(entry.ExcludedModels, disable) return true, nil diff --git a/internal/api/handlers/management/config_apikey_disable_test.go b/internal/api/handlers/management/config_apikey_disable_test.go index 68b3d5a5..7772ea11 100644 --- a/internal/api/handlers/management/config_apikey_disable_test.go +++ b/internal/api/handlers/management/config_apikey_disable_test.go @@ -27,7 +27,7 @@ func TestToggleConfigAPIKeyExcludedAll_XAI(t *testing.T) { }}, } idGen := synthesizer.NewStableIDGenerator() - authID, _ := idGen.Next("xai:apikey", "xai-test", "https://api.x.ai/v1") + authID, _ := idGen.Next("xai:apikey", "xai-test", "https://api.x.ai/v1", "", "", "") auth := &coreauth.Auth{ ID: authID, Provider: "xai", @@ -55,7 +55,7 @@ func TestToggleConfigAPIKeyExcludedAll_Codex(t *testing.T) { }}, } idGen := synthesizer.NewStableIDGenerator() - authID, _ := idGen.Next("codex:apikey", "sk-test", "https://example.com/v1") + authID, _ := idGen.Next("codex:apikey", "sk-test", "https://example.com/v1", "", "", "") auth := &coreauth.Auth{ ID: authID, Provider: "codex", @@ -82,3 +82,81 @@ func TestToggleConfigAPIKeyExcludedAll_Codex(t *testing.T) { t.Fatalf("expected excluded-models cleared, got %#v", cfg.CodexKey[0].ExcludedModels) } } + +func TestToggleConfigAPIKeyExcludedAll_Vertex_NoBaseURL(t *testing.T) { + cfg := &config.Config{ + VertexCompatAPIKey: []config.VertexCompatKey{{ + APIKey: "vertex-key-only", + }}, + } + idGen := synthesizer.NewStableIDGenerator() + authID, _ := idGen.Next("vertex:apikey", "vertex-key-only", "", "") + auth := &coreauth.Auth{ + ID: authID, + Provider: "vertex", + Attributes: map[string]string{ + "auth_kind": "apikey", + "api_key": "vertex-key-only", + "source": "config:vertex[xyz]", + }, + } + + handled, errToggle := toggleConfigAPIKeyExcludedAll(cfg, auth, true) + if errToggle != nil || !handled { + t.Fatalf("toggle disable: handled=%v err=%v", handled, errToggle) + } + if len(cfg.VertexCompatAPIKey[0].ExcludedModels) != 1 || cfg.VertexCompatAPIKey[0].ExcludedModels[0] != "*" { + t.Fatalf("excluded-models = %#v, want [*]", cfg.VertexCompatAPIKey[0].ExcludedModels) + } +} + +func TestToggleConfigAPIKeyExcludedAll_EmptyKeyWithBaseURL(t *testing.T) { + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "", + BaseURL: "https://custom-claude.example.com", + }}, + GeminiKey: []config.GeminiKey{{ + APIKey: " ", + BaseURL: "https://custom-gemini.example.com", + }}, + } + idGen := synthesizer.NewStableIDGenerator() + claudeID, _ := idGen.Next("claude:apikey", "", "https://custom-claude.example.com", "", "", "") + geminiID, _ := idGen.Next("gemini:apikey", "", "https://custom-gemini.example.com", "", "", "") + + claudeAuth := &coreauth.Auth{ + ID: claudeID, + Provider: "claude", + Attributes: map[string]string{ + "auth_kind": "apikey", + "base_url": "https://custom-claude.example.com", + "source": "config:claude[abc]", + }, + } + geminiAuth := &coreauth.Auth{ + ID: geminiID, + Provider: "gemini", + Attributes: map[string]string{ + "auth_kind": "apikey", + "base_url": "https://custom-gemini.example.com", + "source": "config:gemini[def]", + }, + } + + handled, err := toggleConfigAPIKeyExcludedAll(cfg, claudeAuth, true) + if err != nil || !handled { + t.Fatalf("toggle claude: handled=%v err=%v", handled, err) + } + if len(cfg.ClaudeKey[0].ExcludedModels) != 1 || cfg.ClaudeKey[0].ExcludedModels[0] != "*" { + t.Fatalf("claude excluded-models = %#v, want [*]", cfg.ClaudeKey[0].ExcludedModels) + } + + handled, err = toggleConfigAPIKeyExcludedAll(cfg, geminiAuth, true) + if err != nil || !handled { + t.Fatalf("toggle gemini: handled=%v err=%v", handled, err) + } + if len(cfg.GeminiKey[0].ExcludedModels) != 1 || cfg.GeminiKey[0].ExcludedModels[0] != "*" { + t.Fatalf("gemini excluded-models = %#v, want [*]", cfg.GeminiKey[0].ExcludedModels) + } +} diff --git a/internal/api/handlers/management/config_auth_index.go b/internal/api/handlers/management/config_auth_index.go index d129047c..6cc41bc2 100644 --- a/internal/api/handlers/management/config_auth_index.go +++ b/internal/api/handlers/management/config_auth_index.go @@ -103,8 +103,12 @@ func (h *Handler) geminiKeysWithAuthIndex() []geminiKeyWithAuthIndex { for i := range h.cfg.GeminiKey { entry := h.cfg.GeminiKey[i] authIndex := "" - if key := strings.TrimSpace(entry.APIKey); key != "" { - id, _ := idGen.Next("gemini:apikey", key, entry.BaseURL) + key := strings.TrimSpace(entry.APIKey) + base := strings.TrimSpace(entry.BaseURL) + proxyURL := strings.TrimSpace(entry.ProxyURL) + prefix := strings.TrimSpace(entry.Prefix) + if key != "" || base != "" { + id, _ := idGen.Next("gemini:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)) authIndex = liveIndexByID[id] } out[i] = geminiKeyWithAuthIndex{ @@ -132,8 +136,12 @@ func (h *Handler) interactionsKeysWithAuthIndex() []geminiKeyWithAuthIndex { for i := range h.cfg.InteractionsKey { entry := h.cfg.InteractionsKey[i] authIndex := "" - if key := strings.TrimSpace(entry.APIKey); key != "" { - id, _ := idGen.Next("gemini-interactions:apikey", key, entry.BaseURL) + key := strings.TrimSpace(entry.APIKey) + base := strings.TrimSpace(entry.BaseURL) + proxyURL := strings.TrimSpace(entry.ProxyURL) + prefix := strings.TrimSpace(entry.Prefix) + if key != "" || base != "" { + id, _ := idGen.Next("gemini-interactions:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)) authIndex = liveIndexByID[id] } out[i] = geminiKeyWithAuthIndex{ @@ -161,8 +169,12 @@ func (h *Handler) claudeKeysWithAuthIndex() []claudeKeyWithAuthIndex { for i := range h.cfg.ClaudeKey { entry := h.cfg.ClaudeKey[i] authIndex := "" - if key := strings.TrimSpace(entry.APIKey); key != "" { - id, _ := idGen.Next("claude:apikey", key, entry.BaseURL) + key := strings.TrimSpace(entry.APIKey) + base := strings.TrimSpace(entry.BaseURL) + proxyURL := strings.TrimSpace(entry.ProxyURL) + prefix := strings.TrimSpace(entry.Prefix) + if key != "" || base != "" { + id, _ := idGen.Next("claude:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)) authIndex = liveIndexByID[id] } out[i] = claudeKeyWithAuthIndex{ @@ -190,8 +202,12 @@ func (h *Handler) codexKeysWithAuthIndex() []codexKeyWithAuthIndex { for i := range h.cfg.CodexKey { entry := h.cfg.CodexKey[i] authIndex := "" - if key := strings.TrimSpace(entry.APIKey); key != "" { - id, _ := idGen.Next("codex:apikey", key, entry.BaseURL) + key := strings.TrimSpace(entry.APIKey) + base := strings.TrimSpace(entry.BaseURL) + proxyURL := strings.TrimSpace(entry.ProxyURL) + prefix := strings.TrimSpace(entry.Prefix) + if key != "" || base != "" { + id, _ := idGen.Next("codex:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)) authIndex = liveIndexByID[id] } out[i] = codexKeyWithAuthIndex{ @@ -219,8 +235,12 @@ func (h *Handler) xaiKeysWithAuthIndex() []xaiKeyWithAuthIndex { for i := range h.cfg.XAIKey { entry := h.cfg.XAIKey[i] authIndex := "" - if key := strings.TrimSpace(entry.APIKey); key != "" { - id, _ := idGen.Next("xai:apikey", key, entry.BaseURL) + key := strings.TrimSpace(entry.APIKey) + base := strings.TrimSpace(entry.BaseURL) + proxyURL := strings.TrimSpace(entry.ProxyURL) + prefix := strings.TrimSpace(entry.Prefix) + if key != "" || base != "" { + id, _ := idGen.Next("xai:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)) authIndex = liveIndexByID[id] } out[i] = xaiKeyWithAuthIndex{ diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index cef93287..5cff5b9b 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -235,14 +235,7 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) { entry := h.cfg.GeminiKey[targetIndex] if body.Value.APIKey != nil { - trimmed := strings.TrimSpace(*body.Value.APIKey) - if trimmed == "" { - h.cfg.GeminiKey = append(h.cfg.GeminiKey[:targetIndex], h.cfg.GeminiKey[targetIndex+1:]...) - h.cfg.SanitizeGeminiKeys() - h.persistLocked(c) - return - } - entry.APIKey = trimmed + entry.APIKey = strings.TrimSpace(*body.Value.APIKey) } if len(body.Value.Weight) > 0 { weight, errWeight := parseCredentialWeightPatch(body.Value.Weight) @@ -276,6 +269,12 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) { if body.Value.RequestScopedErrors != nil { entry.RequestScopedErrors = append([]config.RequestScopedErrorRule(nil), *body.Value.RequestScopedErrors...) } + if entry.APIKey == "" && entry.BaseURL == "" { + h.cfg.GeminiKey = append(h.cfg.GeminiKey[:targetIndex], h.cfg.GeminiKey[targetIndex+1:]...) + h.cfg.SanitizeGeminiKeys() + h.persistLocked(c) + return + } h.cfg.GeminiKey[targetIndex] = entry h.cfg.SanitizeGeminiKeys() h.persistLocked(c) @@ -421,14 +420,7 @@ func (h *Handler) PatchInteractionsKey(c *gin.Context) { entry := h.cfg.InteractionsKey[targetIndex] if body.Value.APIKey != nil { - trimmed := strings.TrimSpace(*body.Value.APIKey) - if trimmed == "" { - h.cfg.InteractionsKey = append(h.cfg.InteractionsKey[:targetIndex], h.cfg.InteractionsKey[targetIndex+1:]...) - h.cfg.SanitizeInteractionsKeys() - h.persistLocked(c) - return - } - entry.APIKey = trimmed + entry.APIKey = strings.TrimSpace(*body.Value.APIKey) } if len(body.Value.Weight) > 0 { weight, errWeight := parseCredentialWeightPatch(body.Value.Weight) @@ -462,6 +454,12 @@ func (h *Handler) PatchInteractionsKey(c *gin.Context) { if body.Value.RequestScopedErrors != nil { entry.RequestScopedErrors = append([]config.RequestScopedErrorRule(nil), *body.Value.RequestScopedErrors...) } + if entry.APIKey == "" && entry.BaseURL == "" { + h.cfg.InteractionsKey = append(h.cfg.InteractionsKey[:targetIndex], h.cfg.InteractionsKey[targetIndex+1:]...) + h.cfg.SanitizeInteractionsKeys() + h.persistLocked(c) + return + } h.cfg.InteractionsKey[targetIndex] = entry h.cfg.SanitizeInteractionsKeys() h.persistLocked(c) @@ -986,14 +984,7 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) { entry.Prefix = strings.TrimSpace(*body.Value.Prefix) } if body.Value.BaseURL != nil { - trimmed := strings.TrimSpace(*body.Value.BaseURL) - if trimmed == "" { - h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...) - h.cfg.SanitizeVertexCompatKeys() - h.persistLocked(c) - return - } - entry.BaseURL = trimmed + entry.BaseURL = strings.TrimSpace(*body.Value.BaseURL) } if body.Value.ProxyURL != nil { entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL) diff --git a/internal/config/config_normalization.go b/internal/config/config_normalization.go index c6942260..3adeff79 100644 --- a/internal/config/config_normalization.go +++ b/internal/config/config_normalization.go @@ -1,6 +1,7 @@ package config import ( + "sort" "strings" sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore" @@ -239,15 +240,15 @@ func sanitizeGeminiKeyEntries(entries []GeminiKey) []GeminiKey { for i := range entries { entry := entries[i] entry.APIKey = strings.TrimSpace(entry.APIKey) - if entry.APIKey == "" { + entry.BaseURL = strings.TrimSpace(entry.BaseURL) + if entry.APIKey == "" && entry.BaseURL == "" { continue } entry.Prefix = normalizeModelPrefix(entry.Prefix) - entry.BaseURL = strings.TrimSpace(entry.BaseURL) entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) entry.Headers = NormalizeHeaders(entry.Headers) entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels) - uniqueKey := entry.APIKey + "|" + entry.BaseURL + uniqueKey := formatGeminiKeyDedupID(entry) if _, exists := seen[uniqueKey]; exists { continue } @@ -257,8 +258,42 @@ func sanitizeGeminiKeyEntries(entries []GeminiKey) []GeminiKey { return out } +func formatGeminiKeyDedupID(entry GeminiKey) string { + var b strings.Builder + b.WriteString(entry.APIKey) + b.WriteByte(0) + b.WriteString(entry.BaseURL) + b.WriteByte(0) + b.WriteString(entry.ProxyURL) + b.WriteByte(0) + b.WriteString(entry.Prefix) + b.WriteByte(0) + b.WriteString(FormatSortedHeaders(entry.Headers)) + return b.String() +} + +// FormatSortedHeaders serializes headers deterministically with null byte separators. +func FormatSortedHeaders(headers map[string]string) string { + if len(headers) == 0 { + return "" + } + keys := make([]string, 0, len(headers)) + for k := range headers { + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for _, k := range keys { + b.WriteString(k) + b.WriteByte(0) + b.WriteString(headers[k]) + b.WriteByte(0) + } + return b.String() +} + // SanitizeGeminiKeys deduplicates and normalizes Gemini credentials. -// It uses API key + base URL as the uniqueness key. +// It uses API key, base URL, proxy URL, prefix, and custom headers as the uniqueness key. func (cfg *Config) SanitizeGeminiKeys() { if cfg == nil { return @@ -267,7 +302,7 @@ func (cfg *Config) SanitizeGeminiKeys() { } // SanitizeInteractionsKeys deduplicates and normalizes native Interactions credentials. -// It uses API key + base URL as the uniqueness key. +// It uses API key, base URL, proxy URL, prefix, and custom headers as the uniqueness key. func (cfg *Config) SanitizeInteractionsKeys() { if cfg == nil { return diff --git a/internal/config/gemini_keys_normalization_test.go b/internal/config/gemini_keys_normalization_test.go new file mode 100644 index 00000000..08dd1003 --- /dev/null +++ b/internal/config/gemini_keys_normalization_test.go @@ -0,0 +1,35 @@ +package config + +import "testing" + +func TestSanitizeGeminiKeys_AllowsEmptyAPIKeyWithBaseURL(t *testing.T) { + cfg := &Config{ + GeminiKey: []GeminiKey{ + {APIKey: ""}, // empty key without base URL, should be dropped + {APIKey: " "}, // whitespace key without base URL, should be dropped + {APIKey: "", BaseURL: "https://custom-gemini.example.com", Headers: map[string]string{"Header-A": "1"}}, + {APIKey: "", BaseURL: "https://custom-gemini.example.com", Headers: map[string]string{"Header-B": "2"}}, + {APIKey: "key-1", BaseURL: "https://custom-gemini.example.com"}, + }, + InteractionsKey: []GeminiKey{ + {APIKey: ""}, // empty key without base URL, should be dropped + {APIKey: " "}, // whitespace key without base URL, should be dropped + {APIKey: "", BaseURL: "https://custom-interactions.example.com"}, + }, + } + cfg.SanitizeGeminiKeys() + cfg.SanitizeInteractionsKeys() + + if len(cfg.GeminiKey) != 3 { + t.Fatalf("expected 3 GeminiKey entries, got %d", len(cfg.GeminiKey)) + } + if cfg.GeminiKey[0].BaseURL != "https://custom-gemini.example.com" { + t.Fatalf("expected BaseURL https://custom-gemini.example.com, got %s", cfg.GeminiKey[0].BaseURL) + } + if len(cfg.InteractionsKey) != 1 { + t.Fatalf("expected 1 InteractionsKey entry, got %d", len(cfg.InteractionsKey)) + } + if cfg.InteractionsKey[0].BaseURL != "https://custom-interactions.example.com" { + t.Fatalf("expected BaseURL https://custom-interactions.example.com, got %s", cfg.InteractionsKey[0].BaseURL) + } +} diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index d2aef64b..f3dce1c5 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -212,17 +212,19 @@ func (e *ClaudeExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Au return nil } apiKey, _ := claudeCreds(auth) - if strings.TrimSpace(apiKey) == "" { - return nil - } - useAPIKey := auth != nil && auth.Attributes != nil && strings.TrimSpace(auth.Attributes["api_key"]) != "" + useAPIKey := auth != nil && (auth.AuthKind() == cliproxyauth.AuthKindAPIKey || (auth.Attributes != nil && strings.TrimSpace(auth.Attributes["api_key"]) != "")) isAnthropicBase := isAnthropicUpstreamURL(req.URL) - if isAnthropicBase && useAPIKey { - req.Header.Del("Authorization") - req.Header.Set("x-api-key", apiKey) + if strings.TrimSpace(apiKey) != "" { + if isAnthropicBase && useAPIKey { + req.Header.Del("Authorization") + req.Header.Set("x-api-key", apiKey) + } else { + req.Header.Del("x-api-key") + req.Header.Set("Authorization", "Bearer "+apiKey) + } } else { + req.Header.Del("Authorization") req.Header.Del("x-api-key") - req.Header.Set("Authorization", "Bearer "+apiKey) } var attrs map[string]string if auth != nil { diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index d72b5e2a..1bc2d6da 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -610,8 +610,14 @@ func isZlibHeader(header []byte) bool { // single authority for every decision that has to agree with the OAuth beta // profile, including the extended-cache-ttl beta and the matching body cache ttl. func claudeCredentialUsesOAuth(auth *cliproxyauth.Auth, apiKey string) bool { + if isClaudeOAuthToken(apiKey) { + return true + } + if auth != nil && auth.AuthKind() == cliproxyauth.AuthKindAPIKey { + return false + } hasAPIKeyAttr := auth != nil && auth.Attributes != nil && strings.TrimSpace(auth.Attributes["api_key"]) != "" - return isClaudeOAuthToken(apiKey) || !hasAPIKeyAttr + return !hasAPIKeyAttr } func copyClaudeCallerFingerprintHeaders(dst, src http.Header) { @@ -692,11 +698,17 @@ func applyClaudeHeadersWithNativeProfile( preserveCallerFingerprint := !applyCLIFingerprint && !confirmedClaudeCode useOAuthBetas := fp.UseOAuthBetas isAnthropicBase := isAnthropicUpstreamURL(r.URL) - if isAnthropicBase && useAPIKey { - r.Header.Del("Authorization") - r.Header.Set("x-api-key", apiKey) + if strings.TrimSpace(apiKey) != "" { + if isAnthropicBase && useAPIKey { + r.Header.Del("Authorization") + r.Header.Set("x-api-key", apiKey) + } else { + r.Header.Del("x-api-key") + r.Header.Set("Authorization", "Bearer "+apiKey) + } } else { - r.Header.Set("Authorization", "Bearer "+apiKey) + r.Header.Del("Authorization") + r.Header.Del("x-api-key") } r.Header.Set("Content-Type", "application/json") diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index b83c0d46..4bdd40c8 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -740,6 +740,55 @@ func TestApplyClaudeHeaders_UsesOAuthAuthorizationAndBrowserFingerprint(t *testi } } +func TestApplyClaudeHeaders_EmptyAPIKey_OmitsAuthHeaders(t *testing.T) { + auth := &cliproxyauth.Auth{ + Provider: "claude", + Attributes: map[string]string{ + "auth_kind": "apikey", + "base_url": "https://custom-claude.example.com", + "header:Custom-Token": "custom-secret", + }, + } + req, err := http.NewRequest(http.MethodPost, "https://custom-claude.example.com/v1/messages", nil) + if err != nil { + t.Fatalf("NewRequest() error = %v", err) + } + // Preset preexisting client headers to ensure they get stripped for empty API key + req.Header.Set("Authorization", "Bearer preexisting-bearer") + req.Header.Set("x-api-key", "preexisting-key") + + if errHeaders := applyClaudeHeaders(req, auth, "", false, nil, nil, &config.Config{}, nil, false); errHeaders != nil { + t.Fatalf("applyClaudeHeaders() error = %v", errHeaders) + } + if got := req.Header.Get("Authorization"); got != "" { + t.Fatalf("Authorization = %q, want empty for empty API key", got) + } + if got := req.Header.Get("x-api-key"); got != "" { + t.Fatalf("x-api-key = %q, want empty for empty API key", got) + } + if got := req.Header.Get("Custom-Token"); got != "custom-secret" { + t.Fatalf("Custom-Token = %q, want custom-secret", got) + } + + // Also verify PrepareRequest + req2, _ := http.NewRequest(http.MethodPost, "https://custom-claude.example.com/v1/messages", nil) + req2.Header.Set("Authorization", "Bearer preexisting-bearer") + req2.Header.Set("x-api-key", "preexisting-key") + exec := &ClaudeExecutor{} + if errPrep := exec.PrepareRequest(req2, auth); errPrep != nil { + t.Fatalf("PrepareRequest() error = %v", errPrep) + } + if got := req2.Header.Get("Authorization"); got != "" { + t.Fatalf("PrepareRequest Authorization = %q, want empty", got) + } + if got := req2.Header.Get("x-api-key"); got != "" { + t.Fatalf("PrepareRequest x-api-key = %q, want empty", got) + } + if got := req2.Header.Get("Custom-Token"); got != "custom-secret" { + t.Fatalf("PrepareRequest Custom-Token = %q, want custom-secret", got) + } +} + func TestClaudeExecutor_NonClaudeRequestUsesClaudeCode220CLIFingerprint(t *testing.T) { var seenBody []byte var seenHeaders http.Header diff --git a/internal/runtime/executor/codex_executor_request.go b/internal/runtime/executor/codex_executor_request.go index 205a4023..e713eaf8 100644 --- a/internal/runtime/executor/codex_executor_request.go +++ b/internal/runtime/executor/codex_executor_request.go @@ -57,6 +57,8 @@ func (e *CodexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Aut apiKey, _ := codexCreds(auth) if strings.TrimSpace(apiKey) != "" { req.Header.Set("Authorization", "Bearer "+apiKey) + } else { + req.Header.Del("Authorization") } var attrs map[string]string if auth != nil { @@ -319,7 +321,11 @@ func applyCodexDirectImageHeaders(r *http.Request, auth *cliproxyauth.Auth, toke func applyCodexHeadersFromSources(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config, ginHeaders http.Header) { r.Header.Set("Content-Type", "application/json") - r.Header.Set("Authorization", "Bearer "+token) + if strings.TrimSpace(token) != "" { + r.Header.Set("Authorization", "Bearer "+token) + } else { + r.Header.Del("Authorization") + } if ginHeaders != nil && ginHeaders.Get("X-Codex-Beta-Features") != "" { r.Header.Set("X-Codex-Beta-Features", ginHeaders.Get("X-Codex-Beta-Features")) @@ -342,12 +348,7 @@ func applyCodexHeadersFromSources(r *http.Request, auth *cliproxyauth.Auth, toke } r.Header.Set("Connection", "Keep-Alive") - isAPIKey := false - if auth != nil && auth.Attributes != nil { - if v := strings.TrimSpace(auth.Attributes["api_key"]); v != "" { - isAPIKey = true - } - } + isAPIKey := codexAuthUsesAPIKey(auth) if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" { r.Header.Set("Originator", originator) } else if !isAPIKey { diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go index 1f4a676a..755bf1fb 100644 --- a/internal/runtime/executor/codex_websockets_executor_test.go +++ b/internal/runtime/executor/codex_websockets_executor_test.go @@ -1641,6 +1641,83 @@ func TestApplyCodexHeadersDefaultsToCodexCloaking(t *testing.T) { } } +func TestApplyCodexHeaders_EmptyAPIKey_OmitsAuthorizationAndOAuthHeaders(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "https://example.com/responses", nil) + if err != nil { + t.Fatalf("NewRequest() error = %v", err) + } + auth := &cliproxyauth.Auth{ + Provider: "codex", + Attributes: map[string]string{ + "auth_kind": "apikey", + "base_url": "https://custom-codex.example.com", + }, + Metadata: map[string]any{ + "account_id": "acc-12345", + }, + } + cfg := &config.Config{ + Codex: config.CodexConfig{ + DisableCodexCloaking: true, + }, + CodexHeaderDefaults: config.CodexHeaderDefaults{ + UserAgent: "oauth-default-ua", + }, + } + applyCodexHeaders(req, auth, "", false, cfg) + + if got := req.Header.Get("Authorization"); got != "" { + t.Fatalf("Authorization = %q, want empty for empty API key", got) + } + if got := req.Header.Get("Chatgpt-Account-Id"); got != "" { + t.Fatalf("Chatgpt-Account-Id = %q, want empty for API key auth_kind", got) + } + if got := req.Header.Get("Originator"); got != "" { + t.Fatalf("Originator = %q, want empty for API key auth_kind when client originator omitted", got) + } + if got := req.Header.Get("User-Agent"); got == "oauth-default-ua" { + t.Fatalf("User-Agent unexpectedly used OAuth default UA %q for API key auth_kind", got) + } +} + +func TestApplyCodexWebsocketHeaders_EmptyAPIKey_OmitsAuthorizationAndOAuthHeaders(t *testing.T) { + auth := &cliproxyauth.Auth{ + Provider: "codex", + Attributes: map[string]string{ + "auth_kind": "apikey", + "base_url": "https://custom-codex.example.com", + }, + Metadata: map[string]any{ + "account_id": "acc-ws-123", + }, + } + cfg := &config.Config{ + Codex: config.CodexConfig{ + DisableCodexCloaking: true, + }, + CodexHeaderDefaults: config.CodexHeaderDefaults{ + UserAgent: "oauth-default-ua", + BetaFeatures: "oauth-beta", + }, + } + headers := applyCodexWebsocketHeaders(context.Background(), nil, auth, "", cfg) + if got := headers.Get("Authorization"); got != "" { + t.Fatalf("Authorization = %q, want empty for empty API key", got) + } + if got := headers.Get("ChatGPT-Account-ID"); got != "" { + t.Fatalf("ChatGPT-Account-ID = %q, want empty for API key auth_kind", got) + } + if got := headers.Get("Originator"); got != "" { + t.Fatalf("Originator = %q, want empty for API key auth_kind", got) + } + if got := headers.Get("x-codex-beta-features"); got != "" { + t.Fatalf("x-codex-beta-features = %q, want empty for API key auth_kind", got) + } + if got := headers.Get("User-Agent"); got == "oauth-default-ua" { + t.Fatalf("User-Agent unexpectedly used OAuth default UA %q for API key auth_kind", got) + } +} + func TestApplyModelHeaderOverridesFromModelConfig(t *testing.T) { const wantUA = "codex-tui/0.144.0 (Mac OS 26.5.1; arm64) iTerm.app/3.6.11 (codex-tui; 0.144.0)" req, err := http.NewRequest(http.MethodPost, "https://example.com/responses", nil) diff --git a/internal/runtime/executor/codex_websockets_request.go b/internal/runtime/executor/codex_websockets_request.go index 977102fe..d0ddb3db 100644 --- a/internal/runtime/executor/codex_websockets_request.go +++ b/internal/runtime/executor/codex_websockets_request.go @@ -70,6 +70,8 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * } if strings.TrimSpace(token) != "" { headers.Set("Authorization", "Bearer "+token) + } else { + headers.Del("Authorization") } var ginHeaders http.Header @@ -158,10 +160,16 @@ func codexSessionHeaderValue(headers http.Header) string { } func codexAuthUsesAPIKey(auth *cliproxyauth.Auth) bool { - if auth == nil || auth.Attributes == nil { + if auth == nil { return false } - return strings.TrimSpace(auth.Attributes["api_key"]) != "" + if auth.AuthKind() == cliproxyauth.AuthKindAPIKey { + return true + } + if auth.Attributes != nil { + return strings.TrimSpace(auth.Attributes["api_key"]) != "" + } + return false } func ensureHeaderCasePreserved(target http.Header, source http.Header, key, configValue, fallbackValue string) { @@ -270,14 +278,9 @@ func deleteHeaderCaseInsensitive(headers http.Header, key string) { } func codexHeaderDefaults(cfg *config.Config, auth *cliproxyauth.Auth) (string, string) { - if cfg == nil || auth == nil { + if cfg == nil || auth == nil || codexAuthUsesAPIKey(auth) { 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) } diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index e2c7d621..45973c37 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -88,6 +88,9 @@ func (e *GeminiExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Au if apiKey != "" { req.Header.Set("x-goog-api-key", apiKey) req.Header.Del("Authorization") + } else { + req.Header.Del("x-goog-api-key") + req.Header.Del("Authorization") } applyGeminiHeaders(req, auth) return nil diff --git a/internal/runtime/executor/gemini_executor_test.go b/internal/runtime/executor/gemini_executor_test.go index 37b796e1..9f1acc0c 100644 --- a/internal/runtime/executor/gemini_executor_test.go +++ b/internal/runtime/executor/gemini_executor_test.go @@ -1148,3 +1148,34 @@ func TestGeminiExecutorNativeInteractionsResponsesStreamEmitsDone(t *testing.T) t.Fatal("Responses [DONE] chunk not found") } } + +func TestGeminiExecutor_PrepareRequest_EmptyAPIKey_OmitsAuthHeaders(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "https://custom-gemini.example.com/v1beta/models", nil) + if err != nil { + t.Fatalf("NewRequest() error = %v", err) + } + req.Header.Set("Authorization", "Bearer preexisting-bearer") + req.Header.Set("x-goog-api-key", "preexisting-key") + + auth := &cliproxyauth.Auth{ + Provider: "gemini", + Attributes: map[string]string{ + "auth_kind": "apikey", + "base_url": "https://custom-gemini.example.com", + "header:Custom-Token": "gemini-secret", + }, + } + exec := &GeminiExecutor{} + if errPrep := exec.PrepareRequest(req, auth); errPrep != nil { + t.Fatalf("PrepareRequest() error = %v", errPrep) + } + if got := req.Header.Get("Authorization"); got != "" { + t.Fatalf("Authorization = %q, want empty", got) + } + if got := req.Header.Get("x-goog-api-key"); got != "" { + t.Fatalf("x-goog-api-key = %q, want empty", got) + } + if got := req.Header.Get("Custom-Token"); got != "gemini-secret" { + t.Fatalf("Custom-Token = %q, want gemini-secret", got) + } +} diff --git a/internal/runtime/executor/xai_executor.go b/internal/runtime/executor/xai_executor.go index 2726ad9e..6e3a6b41 100644 --- a/internal/runtime/executor/xai_executor.go +++ b/internal/runtime/executor/xai_executor.go @@ -83,6 +83,8 @@ func (e *XAIExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) token, _ := xaiCreds(auth) if strings.TrimSpace(token) != "" { req.Header.Set("Authorization", "Bearer "+token) + } else { + req.Header.Del("Authorization") } var attrs map[string]string if auth != nil { diff --git a/internal/runtime/executor/xai_executor_request.go b/internal/runtime/executor/xai_executor_request.go index 04810dcb..16954a7f 100644 --- a/internal/runtime/executor/xai_executor_request.go +++ b/internal/runtime/executor/xai_executor_request.go @@ -285,6 +285,8 @@ func applyXAIDefaultHeaders(r *http.Request, token string, stream bool, sessionI r.Header.Set("Content-Type", "application/json") if strings.TrimSpace(token) != "" { r.Header.Set("Authorization", "Bearer "+token) + } else { + r.Header.Del("Authorization") } if stream { r.Header.Set("Accept", "text/event-stream") diff --git a/internal/runtime/executor/xai_executor_test.go b/internal/runtime/executor/xai_executor_test.go index 0c69d75d..15de2a67 100644 --- a/internal/runtime/executor/xai_executor_test.go +++ b/internal/runtime/executor/xai_executor_test.go @@ -3843,6 +3843,47 @@ func TestXAIExecutorComposerReusesClaudeCodeSession(t *testing.T) { } } +func TestApplyXAIHeaders_EmptyAPIKey_OmitsAuthorization(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", nil) + if err != nil { + t.Fatalf("NewRequest() error = %v", err) + } + req.Header.Set("Authorization", "Bearer preexisting-bearer") + auth := &cliproxyauth.Auth{ + Provider: "xai", + Attributes: map[string]string{ + "auth_kind": "apikey", + "base_url": "https://custom-xai.example.com", + "header:Custom-Token": "xai-custom", + }, + } + applyXAIHeaders(req, auth, "", false, "session-123") + + if got := req.Header.Get("Authorization"); got != "" { + t.Fatalf("Authorization = %q, want empty for empty API key", got) + } + if got := req.Header.Get("x-grok-conv-id"); got != "session-123" { + t.Fatalf("x-grok-conv-id = %q, want session-123", got) + } + if got := req.Header.Get("Custom-Token"); got != "xai-custom" { + t.Fatalf("Custom-Token = %q, want xai-custom", got) + } + + // Also verify PrepareRequest + req2, _ := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", nil) + req2.Header.Set("Authorization", "Bearer preexisting-bearer") + exec := &XAIExecutor{} + if errPrep := exec.PrepareRequest(req2, auth); errPrep != nil { + t.Fatalf("PrepareRequest() error = %v", errPrep) + } + if got := req2.Header.Get("Authorization"); got != "" { + t.Fatalf("PrepareRequest Authorization = %q, want empty", got) + } + if got := req2.Header.Get("Custom-Token"); got != "xai-custom" { + t.Fatalf("PrepareRequest Custom-Token = %q, want xai-custom", got) + } +} + func TestSanitizeXAIInputEncryptedContent_DropsInvalidReasoningBlob(t *testing.T) { body := []byte(`{"model":"grok-4.3","input":[{"type":"reasoning","summary":[],"encrypted_content":"bad"},{"type":"reasoning","summary":[],"encrypted_content":"gAAAAABinvalid-gpt-shape"},{"role":"user","content":"hi"}]}`) got := sanitizeXAIInputEncryptedContent(body) diff --git a/internal/runtime/executor/xai_websockets_executor.go b/internal/runtime/executor/xai_websockets_executor.go index 95604681..a8bc15b9 100644 --- a/internal/runtime/executor/xai_websockets_executor.go +++ b/internal/runtime/executor/xai_websockets_executor.go @@ -1449,6 +1449,8 @@ func applyXAIWebsocketHeaders(headers http.Header, auth *cliproxyauth.Auth, toke headers.Set("Content-Type", "application/json") if strings.TrimSpace(token) != "" { headers.Set("Authorization", "Bearer "+token) + } else { + headers.Del("Authorization") } if sessionID != "" { headers.Set("x-grok-conv-id", sessionID) diff --git a/internal/watcher/synthesizer/config.go b/internal/watcher/synthesizer/config.go index c5f5722f..11ed7739 100644 --- a/internal/watcher/synthesizer/config.go +++ b/internal/watcher/synthesizer/config.go @@ -79,18 +79,20 @@ func (s *ConfigSynthesizer) synthesizeGeminiKeyEntries(ctx *SynthesisContext, en for i := range entries { entry := entries[i] key := strings.TrimSpace(entry.APIKey) - if key == "" { + base := strings.TrimSpace(entry.BaseURL) + if key == "" && base == "" { continue } prefix := strings.TrimSpace(entry.Prefix) - base := strings.TrimSpace(entry.BaseURL) proxyURL := strings.TrimSpace(entry.ProxyURL) - id, token := idGen.Next(idKind, key, base) + id, token := idGen.Next(idKind, key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)) attrs := map[string]string{ "source": fmt.Sprintf("config:%s[%s]", sourceName, token), - "api_key": key, "config_index": strconv.Itoa(i), } + if key != "" { + attrs["api_key"] = key + } metadata := map[string]any{} if entry.DisableCooling != nil { metadata["disable_cooling"] = *entry.DisableCooling @@ -139,17 +141,20 @@ func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*corea for i := range cfg.ClaudeKey { ck := cfg.ClaudeKey[i] key := strings.TrimSpace(ck.APIKey) - if key == "" { + base := strings.TrimSpace(ck.BaseURL) + if key == "" && base == "" { continue } prefix := strings.TrimSpace(ck.Prefix) - base := strings.TrimSpace(ck.BaseURL) - id, token := idGen.Next("claude:apikey", key, base) + proxyURL := strings.TrimSpace(ck.ProxyURL) + id, token := idGen.Next("claude:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(ck.Headers)) attrs := map[string]string{ "source": fmt.Sprintf("config:claude[%s]", token), - "api_key": key, "config_index": strconv.Itoa(i), } + if key != "" { + attrs["api_key"] = key + } metadata := map[string]any{} if ck.DisableCooling != nil { metadata["disable_cooling"] = *ck.DisableCooling @@ -173,7 +178,6 @@ func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*corea attrs["models_hash"] = hash } addConfigHeadersToAttrs(ck.Headers, attrs) - proxyURL := strings.TrimSpace(ck.ProxyURL) a := &coreauth.Auth{ ID: id, Provider: "claude", @@ -214,17 +218,20 @@ func (s *ConfigSynthesizer) synthesizeCodexStyleKeys(ctx *SynthesisContext, entr for i := range entries { entry := entries[i] key := strings.TrimSpace(entry.APIKey) - if key == "" { + baseURL := strings.TrimSpace(entry.BaseURL) + if key == "" && baseURL == "" { continue } prefix := strings.TrimSpace(entry.Prefix) - baseURL := strings.TrimSpace(entry.BaseURL) - id, token := idGen.Next(provider+":apikey", key, baseURL) + proxyURL := strings.TrimSpace(entry.ProxyURL) + id, token := idGen.Next(provider+":apikey", key, baseURL, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)) attrs := map[string]string{ "source": fmt.Sprintf("config:%s[%s]", provider, token), - "api_key": key, "config_index": strconv.Itoa(i), } + if key != "" { + attrs["api_key"] = key + } metadata := map[string]any{} if entry.DisableCooling != nil { metadata["disable_cooling"] = *entry.DisableCooling diff --git a/internal/watcher/synthesizer/config_test.go b/internal/watcher/synthesizer/config_test.go index e4a9abcb..aea4f00c 100644 --- a/internal/watcher/synthesizer/config_test.go +++ b/internal/watcher/synthesizer/config_test.go @@ -409,13 +409,141 @@ func TestConfigSynthesizer_XAIKeys(t *testing.T) { } } +func TestConfigSynthesizer_XAIKeys_AllowsEmptyAPIKeyWithBaseURL(t *testing.T) { + synth := NewConfigSynthesizer() + ctx := &SynthesisContext{ + Config: &config.Config{ + XAIKey: []config.CodexKey{ + { + APIKey: "", + BaseURL: "https://custom-xai.example.com", + Headers: map[string]string{"Custom-Auth": "secret"}, + }, + { + APIKey: " ", + BaseURL: "https://custom-xai-2.example.com", + }, + }, + }, + Now: time.Now(), + IDGenerator: NewStableIDGenerator(), + } + + auths, err := synth.Synthesize(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(auths) != 2 { + t.Fatalf("expected 2 auths for empty API keys with base URL, got %d", len(auths)) + } + if auths[0].Attributes["base_url"] != "https://custom-xai.example.com" { + t.Fatalf("expected base_url=https://custom-xai.example.com, got %s", auths[0].Attributes["base_url"]) + } + if auths[0].Attributes["header:Custom-Auth"] != "secret" { + t.Fatalf("expected header:Custom-Auth=secret, got %s", auths[0].Attributes["header:Custom-Auth"]) + } + if auths[0].Attributes["auth_kind"] != "apikey" { + t.Fatalf("expected auth_kind=apikey, got %s", auths[0].Attributes["auth_kind"]) + } + if _, exists := auths[0].Attributes["api_key"]; exists { + t.Fatalf("expected no api_key attribute for empty key, got %s", auths[0].Attributes["api_key"]) + } +} + +func TestConfigSynthesizer_ClaudeKeys_AllowsEmptyAPIKeyWithBaseURL(t *testing.T) { + synth := NewConfigSynthesizer() + ctx := &SynthesisContext{ + Config: &config.Config{ + ClaudeKey: []config.ClaudeKey{ + { + APIKey: "", + BaseURL: "https://custom-claude.example.com", + Headers: map[string]string{"Custom-Auth": "secret"}, + }, + { + APIKey: " ", + BaseURL: "https://custom-claude-2.example.com", + }, + }, + }, + Now: time.Now(), + IDGenerator: NewStableIDGenerator(), + } + + auths, err := synth.Synthesize(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(auths) != 2 { + t.Fatalf("expected 2 auths for empty API keys with base URL, got %d", len(auths)) + } + if auths[0].Attributes["base_url"] != "https://custom-claude.example.com" { + t.Fatalf("expected base_url=https://custom-claude.example.com, got %s", auths[0].Attributes["base_url"]) + } + if auths[0].Attributes["header:Custom-Auth"] != "secret" { + t.Fatalf("expected header:Custom-Auth=secret, got %s", auths[0].Attributes["header:Custom-Auth"]) + } + if auths[0].Attributes["auth_kind"] != "apikey" { + t.Fatalf("expected auth_kind=apikey, got %s", auths[0].Attributes["auth_kind"]) + } + if _, exists := auths[0].Attributes["api_key"]; exists { + t.Fatalf("expected no api_key attribute for empty key, got %s", auths[0].Attributes["api_key"]) + } +} + +func TestConfigSynthesizer_GeminiKeys_AllowsEmptyAPIKeyWithBaseURL(t *testing.T) { + synth := NewConfigSynthesizer() + ctx := &SynthesisContext{ + Config: &config.Config{ + GeminiKey: []config.GeminiKey{ + { + APIKey: "", + BaseURL: "https://custom-gemini.example.com", + Headers: map[string]string{"Custom-Auth": "secret"}, + }, + }, + InteractionsKey: []config.GeminiKey{ + { + APIKey: "", + BaseURL: "https://custom-interactions.example.com", + }, + }, + }, + Now: time.Now(), + IDGenerator: NewStableIDGenerator(), + } + + auths, err := synth.Synthesize(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(auths) != 2 { + t.Fatalf("expected 2 auths for empty API keys with base URL, got %d", len(auths)) + } + if auths[0].Attributes["base_url"] != "https://custom-gemini.example.com" { + t.Fatalf("expected base_url=https://custom-gemini.example.com, got %s", auths[0].Attributes["base_url"]) + } + if auths[0].Attributes["header:Custom-Auth"] != "secret" { + t.Fatalf("expected header:Custom-Auth=secret, got %s", auths[0].Attributes["header:Custom-Auth"]) + } + if auths[0].Attributes["auth_kind"] != "apikey" { + t.Fatalf("expected auth_kind=apikey, got %s", auths[0].Attributes["auth_kind"]) + } + if _, exists := auths[0].Attributes["api_key"]; exists { + t.Fatalf("expected no api_key attribute for empty key, got %s", auths[0].Attributes["api_key"]) + } + if auths[1].Attributes["base_url"] != "https://custom-interactions.example.com" { + t.Fatalf("expected base_url=https://custom-interactions.example.com, got %s", auths[1].Attributes["base_url"]) + } +} + func TestConfigSynthesizer_CodexKeys_SkipsEmptyAndHeaders(t *testing.T) { synth := NewConfigSynthesizer() ctx := &SynthesisContext{ Config: &config.Config{ CodexKey: []config.CodexKey{ - {APIKey: ""}, // empty, should be skipped - {APIKey: " "}, // whitespace, should be skipped + {APIKey: ""}, // empty key without base URL, should be skipped + {APIKey: " "}, // whitespace key without base URL, should be skipped {APIKey: "valid-key", Headers: map[string]string{"Authorization": "Bearer xyz"}}, }, }, @@ -438,6 +566,47 @@ func TestConfigSynthesizer_CodexKeys_SkipsEmptyAndHeaders(t *testing.T) { } } +func TestConfigSynthesizer_CodexKeys_AllowsEmptyAPIKeyWithBaseURL(t *testing.T) { + synth := NewConfigSynthesizer() + ctx := &SynthesisContext{ + Config: &config.Config{ + CodexKey: []config.CodexKey{ + { + APIKey: "", + BaseURL: "https://custom-codex.example.com", + Headers: map[string]string{"Custom-Auth": "secret"}, + }, + { + APIKey: " ", + BaseURL: "https://custom-codex-2.example.com", + }, + }, + }, + Now: time.Now(), + IDGenerator: NewStableIDGenerator(), + } + + auths, err := synth.Synthesize(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(auths) != 2 { + t.Fatalf("expected 2 auths for empty API keys with base URL, got %d", len(auths)) + } + if auths[0].Attributes["base_url"] != "https://custom-codex.example.com" { + t.Fatalf("expected base_url=https://custom-codex.example.com, got %s", auths[0].Attributes["base_url"]) + } + if auths[0].Attributes["header:Custom-Auth"] != "secret" { + t.Fatalf("expected header:Custom-Auth=secret, got %s", auths[0].Attributes["header:Custom-Auth"]) + } + if auths[0].Attributes["auth_kind"] != "apikey" { + t.Fatalf("expected auth_kind=apikey, got %s", auths[0].Attributes["auth_kind"]) + } + if _, exists := auths[0].Attributes["api_key"]; exists { + t.Fatalf("expected no api_key attribute for empty key, got %s", auths[0].Attributes["api_key"]) + } +} + func TestConfigSynthesizer_OpenAICompat(t *testing.T) { tests := []struct { name string diff --git a/sdk/cliproxy/auth/config_apikey.go b/sdk/cliproxy/auth/config_apikey.go index a6c0b664..44f48143 100644 --- a/sdk/cliproxy/auth/config_apikey.go +++ b/sdk/cliproxy/auth/config_apikey.go @@ -8,8 +8,5 @@ func IsConfigAPIKeyAuth(auth *Auth) bool { if auth.AuthKind() != AuthKindAPIKey { return false } - if auth.AuthSourceKind() != AuthSourceConfig { - return false - } - return authAttribute(auth, AttributeAPIKey) != "" + return auth.AuthSourceKind() == AuthSourceConfig } diff --git a/sdk/cliproxy/auth/config_apikey_test.go b/sdk/cliproxy/auth/config_apikey_test.go index 571d6487..749edfa0 100644 --- a/sdk/cliproxy/auth/config_apikey_test.go +++ b/sdk/cliproxy/auth/config_apikey_test.go @@ -7,7 +7,7 @@ func TestIsConfigAPIKeyAuth(t *testing.T) { t.Fatal("expected nil auth to be false") } if IsConfigAPIKeyAuth(&Auth{Attributes: map[string]string{"source": "config:codex[x]"}}) { - t.Fatal("expected missing api_key to be false") + t.Fatal("expected missing auth_kind and api_key to be false") } if IsConfigAPIKeyAuth(&Auth{ ID: "codex:oauth:abc", @@ -20,6 +20,16 @@ func TestIsConfigAPIKeyAuth(t *testing.T) { }) { t.Fatal("expected explicit oauth auth to be false") } + if !IsConfigAPIKeyAuth(&Auth{ + ID: "codex:apikey:abc", + Provider: "codex", + Attributes: map[string]string{ + "auth_kind": "apikey", + "source": "config:codex[abc]", + }, + }) { + t.Fatal("expected empty api_key with auth_kind=apikey and config source to be true") + } if !IsConfigAPIKeyAuth(&Auth{ ID: "codex:apikey:abc", Provider: "codex", -- knotmirror 0.0.1