diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index 083d4e31..fbaad956 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -214,19 +214,46 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) { func (h *Handler) DeleteGeminiKey(c *gin.Context) { if val := strings.TrimSpace(c.Query("api-key")); val != "" { - out := make([]config.GeminiKey, 0, len(h.cfg.GeminiKey)) - for _, v := range h.cfg.GeminiKey { - if v.APIKey != val { + if baseRaw, okBase := c.GetQuery("base-url"); okBase { + base := strings.TrimSpace(baseRaw) + out := make([]config.GeminiKey, 0, len(h.cfg.GeminiKey)) + for _, v := range h.cfg.GeminiKey { + if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base { + continue + } out = append(out, v) } + if len(out) != len(h.cfg.GeminiKey) { + h.cfg.GeminiKey = out + h.cfg.SanitizeGeminiKeys() + h.persist(c) + } else { + c.JSON(404, gin.H{"error": "item not found"}) + } + return } - if len(out) != len(h.cfg.GeminiKey) { - h.cfg.GeminiKey = out - h.cfg.SanitizeGeminiKeys() - h.persist(c) - } else { + + matchIndex := -1 + matchCount := 0 + for i := range h.cfg.GeminiKey { + if strings.TrimSpace(h.cfg.GeminiKey[i].APIKey) == val { + matchCount++ + if matchIndex == -1 { + matchIndex = i + } + } + } + if matchCount == 0 { c.JSON(404, gin.H{"error": "item not found"}) + return } + if matchCount > 1 { + c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"}) + return + } + h.cfg.GeminiKey = append(h.cfg.GeminiKey[:matchIndex], h.cfg.GeminiKey[matchIndex+1:]...) + h.cfg.SanitizeGeminiKeys() + h.persist(c) return } if idxStr := c.Query("index"); idxStr != "" { @@ -335,14 +362,39 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) { } func (h *Handler) DeleteClaudeKey(c *gin.Context) { - if val := c.Query("api-key"); val != "" { - out := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey)) - for _, v := range h.cfg.ClaudeKey { - if v.APIKey != val { + if val := strings.TrimSpace(c.Query("api-key")); val != "" { + if baseRaw, okBase := c.GetQuery("base-url"); okBase { + base := strings.TrimSpace(baseRaw) + out := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey)) + for _, v := range h.cfg.ClaudeKey { + if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base { + continue + } out = append(out, v) } + h.cfg.ClaudeKey = out + h.cfg.SanitizeClaudeKeys() + h.persist(c) + return + } + + matchIndex := -1 + matchCount := 0 + for i := range h.cfg.ClaudeKey { + if strings.TrimSpace(h.cfg.ClaudeKey[i].APIKey) == val { + matchCount++ + if matchIndex == -1 { + matchIndex = i + } + } + } + if matchCount > 1 { + c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"}) + return + } + if matchIndex != -1 { + h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:matchIndex], h.cfg.ClaudeKey[matchIndex+1:]...) } - h.cfg.ClaudeKey = out h.cfg.SanitizeClaudeKeys() h.persist(c) return @@ -601,13 +653,38 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) { func (h *Handler) DeleteVertexCompatKey(c *gin.Context) { if val := strings.TrimSpace(c.Query("api-key")); val != "" { - out := make([]config.VertexCompatKey, 0, len(h.cfg.VertexCompatAPIKey)) - for _, v := range h.cfg.VertexCompatAPIKey { - if v.APIKey != val { + if baseRaw, okBase := c.GetQuery("base-url"); okBase { + base := strings.TrimSpace(baseRaw) + out := make([]config.VertexCompatKey, 0, len(h.cfg.VertexCompatAPIKey)) + for _, v := range h.cfg.VertexCompatAPIKey { + if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base { + continue + } out = append(out, v) } + h.cfg.VertexCompatAPIKey = out + h.cfg.SanitizeVertexCompatKeys() + h.persist(c) + return + } + + matchIndex := -1 + matchCount := 0 + for i := range h.cfg.VertexCompatAPIKey { + if strings.TrimSpace(h.cfg.VertexCompatAPIKey[i].APIKey) == val { + matchCount++ + if matchIndex == -1 { + matchIndex = i + } + } + } + if matchCount > 1 { + c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"}) + return + } + if matchIndex != -1 { + h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:matchIndex], h.cfg.VertexCompatAPIKey[matchIndex+1:]...) } - h.cfg.VertexCompatAPIKey = out h.cfg.SanitizeVertexCompatKeys() h.persist(c) return @@ -915,14 +992,39 @@ func (h *Handler) PatchCodexKey(c *gin.Context) { } func (h *Handler) DeleteCodexKey(c *gin.Context) { - if val := c.Query("api-key"); val != "" { - out := make([]config.CodexKey, 0, len(h.cfg.CodexKey)) - for _, v := range h.cfg.CodexKey { - if v.APIKey != val { + if val := strings.TrimSpace(c.Query("api-key")); val != "" { + if baseRaw, okBase := c.GetQuery("base-url"); okBase { + base := strings.TrimSpace(baseRaw) + out := make([]config.CodexKey, 0, len(h.cfg.CodexKey)) + for _, v := range h.cfg.CodexKey { + if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base { + continue + } out = append(out, v) } + h.cfg.CodexKey = out + h.cfg.SanitizeCodexKeys() + h.persist(c) + return + } + + matchIndex := -1 + matchCount := 0 + for i := range h.cfg.CodexKey { + if strings.TrimSpace(h.cfg.CodexKey[i].APIKey) == val { + matchCount++ + if matchIndex == -1 { + matchIndex = i + } + } + } + if matchCount > 1 { + c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"}) + return + } + if matchIndex != -1 { + h.cfg.CodexKey = append(h.cfg.CodexKey[:matchIndex], h.cfg.CodexKey[matchIndex+1:]...) } - h.cfg.CodexKey = out h.cfg.SanitizeCodexKeys() h.persist(c) return diff --git a/internal/api/handlers/management/config_lists_delete_keys_test.go b/internal/api/handlers/management/config_lists_delete_keys_test.go new file mode 100644 index 00000000..aaa43910 --- /dev/null +++ b/internal/api/handlers/management/config_lists_delete_keys_test.go @@ -0,0 +1,172 @@ +package management + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +) + +func writeTestConfigFile(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if errWrite := os.WriteFile(path, []byte("{}\n"), 0o600); errWrite != nil { + t.Fatalf("failed to write test config: %v", errWrite) + } + return path +} + +func TestDeleteGeminiKey_RequiresBaseURLWhenAPIKeyDuplicated(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + h := &Handler{ + cfg: &config.Config{ + GeminiKey: []config.GeminiKey{ + {APIKey: "shared-key", BaseURL: "https://a.example.com"}, + {APIKey: "shared-key", BaseURL: "https://b.example.com"}, + }, + }, + configFilePath: writeTestConfigFile(t), + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/gemini-api-key?api-key=shared-key", nil) + + h.DeleteGeminiKey(c) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if got := len(h.cfg.GeminiKey); got != 2 { + t.Fatalf("gemini keys len = %d, want 2", got) + } +} + +func TestDeleteGeminiKey_DeletesOnlyMatchingBaseURL(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + h := &Handler{ + cfg: &config.Config{ + GeminiKey: []config.GeminiKey{ + {APIKey: "shared-key", BaseURL: "https://a.example.com"}, + {APIKey: "shared-key", BaseURL: "https://b.example.com"}, + }, + }, + configFilePath: writeTestConfigFile(t), + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/gemini-api-key?api-key=shared-key&base-url=https://a.example.com", nil) + + h.DeleteGeminiKey(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if got := len(h.cfg.GeminiKey); got != 1 { + t.Fatalf("gemini keys len = %d, want 1", got) + } + if got := h.cfg.GeminiKey[0].BaseURL; got != "https://b.example.com" { + t.Fatalf("remaining base-url = %q, want %q", got, "https://b.example.com") + } +} + +func TestDeleteClaudeKey_DeletesEmptyBaseURLWhenExplicitlyProvided(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + h := &Handler{ + cfg: &config.Config{ + ClaudeKey: []config.ClaudeKey{ + {APIKey: "shared-key", BaseURL: ""}, + {APIKey: "shared-key", BaseURL: "https://claude.example.com"}, + }, + }, + configFilePath: writeTestConfigFile(t), + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/claude-api-key?api-key=shared-key&base-url=", nil) + + h.DeleteClaudeKey(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if got := len(h.cfg.ClaudeKey); got != 1 { + t.Fatalf("claude keys len = %d, want 1", got) + } + if got := h.cfg.ClaudeKey[0].BaseURL; got != "https://claude.example.com" { + t.Fatalf("remaining base-url = %q, want %q", got, "https://claude.example.com") + } +} + +func TestDeleteVertexCompatKey_DeletesOnlyMatchingBaseURL(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + h := &Handler{ + cfg: &config.Config{ + VertexCompatAPIKey: []config.VertexCompatKey{ + {APIKey: "shared-key", BaseURL: "https://a.example.com"}, + {APIKey: "shared-key", BaseURL: "https://b.example.com"}, + }, + }, + configFilePath: writeTestConfigFile(t), + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/vertex-api-key?api-key=shared-key&base-url=https://b.example.com", nil) + + h.DeleteVertexCompatKey(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if got := len(h.cfg.VertexCompatAPIKey); got != 1 { + t.Fatalf("vertex keys len = %d, want 1", got) + } + if got := h.cfg.VertexCompatAPIKey[0].BaseURL; got != "https://a.example.com" { + t.Fatalf("remaining base-url = %q, want %q", got, "https://a.example.com") + } +} + +func TestDeleteCodexKey_RequiresBaseURLWhenAPIKeyDuplicated(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + h := &Handler{ + cfg: &config.Config{ + CodexKey: []config.CodexKey{ + {APIKey: "shared-key", BaseURL: "https://a.example.com"}, + {APIKey: "shared-key", BaseURL: "https://b.example.com"}, + }, + }, + configFilePath: writeTestConfigFile(t), + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/codex-api-key?api-key=shared-key", nil) + + h.DeleteCodexKey(c) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if got := len(h.cfg.CodexKey); got != 2 { + t.Fatalf("codex keys len = %d, want 2", got) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 15847f57..f25b0aa2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -865,6 +865,7 @@ func (cfg *Config) SanitizeClaudeKeys() { } // SanitizeGeminiKeys deduplicates and normalizes Gemini credentials. +// It uses API key + base URL as the uniqueness key. func (cfg *Config) SanitizeGeminiKeys() { if cfg == nil { return @@ -883,10 +884,11 @@ func (cfg *Config) SanitizeGeminiKeys() { entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) entry.Headers = NormalizeHeaders(entry.Headers) entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels) - if _, exists := seen[entry.APIKey]; exists { + uniqueKey := entry.APIKey + "|" + entry.BaseURL + if _, exists := seen[uniqueKey]; exists { continue } - seen[entry.APIKey] = struct{}{} + seen[uniqueKey] = struct{}{} out = append(out, entry) } cfg.GeminiKey = out