Skip to content

Commit 938af75

Browse files
authored
Merge branch 'router-for-me:main' into main
2 parents c42480a + 1dba2d0 commit 938af75

10 files changed

Lines changed: 569 additions & 30 deletions

File tree

internal/api/handlers/management/config_lists.go

Lines changed: 124 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -214,19 +214,46 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) {
214214

215215
func (h *Handler) DeleteGeminiKey(c *gin.Context) {
216216
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
217-
out := make([]config.GeminiKey, 0, len(h.cfg.GeminiKey))
218-
for _, v := range h.cfg.GeminiKey {
219-
if v.APIKey != val {
217+
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
218+
base := strings.TrimSpace(baseRaw)
219+
out := make([]config.GeminiKey, 0, len(h.cfg.GeminiKey))
220+
for _, v := range h.cfg.GeminiKey {
221+
if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base {
222+
continue
223+
}
220224
out = append(out, v)
221225
}
226+
if len(out) != len(h.cfg.GeminiKey) {
227+
h.cfg.GeminiKey = out
228+
h.cfg.SanitizeGeminiKeys()
229+
h.persist(c)
230+
} else {
231+
c.JSON(404, gin.H{"error": "item not found"})
232+
}
233+
return
222234
}
223-
if len(out) != len(h.cfg.GeminiKey) {
224-
h.cfg.GeminiKey = out
225-
h.cfg.SanitizeGeminiKeys()
226-
h.persist(c)
227-
} else {
235+
236+
matchIndex := -1
237+
matchCount := 0
238+
for i := range h.cfg.GeminiKey {
239+
if strings.TrimSpace(h.cfg.GeminiKey[i].APIKey) == val {
240+
matchCount++
241+
if matchIndex == -1 {
242+
matchIndex = i
243+
}
244+
}
245+
}
246+
if matchCount == 0 {
228247
c.JSON(404, gin.H{"error": "item not found"})
248+
return
249+
}
250+
if matchCount > 1 {
251+
c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"})
252+
return
229253
}
254+
h.cfg.GeminiKey = append(h.cfg.GeminiKey[:matchIndex], h.cfg.GeminiKey[matchIndex+1:]...)
255+
h.cfg.SanitizeGeminiKeys()
256+
h.persist(c)
230257
return
231258
}
232259
if idxStr := c.Query("index"); idxStr != "" {
@@ -335,14 +362,39 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) {
335362
}
336363

337364
func (h *Handler) DeleteClaudeKey(c *gin.Context) {
338-
if val := c.Query("api-key"); val != "" {
339-
out := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey))
340-
for _, v := range h.cfg.ClaudeKey {
341-
if v.APIKey != val {
365+
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
366+
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
367+
base := strings.TrimSpace(baseRaw)
368+
out := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey))
369+
for _, v := range h.cfg.ClaudeKey {
370+
if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base {
371+
continue
372+
}
342373
out = append(out, v)
343374
}
375+
h.cfg.ClaudeKey = out
376+
h.cfg.SanitizeClaudeKeys()
377+
h.persist(c)
378+
return
379+
}
380+
381+
matchIndex := -1
382+
matchCount := 0
383+
for i := range h.cfg.ClaudeKey {
384+
if strings.TrimSpace(h.cfg.ClaudeKey[i].APIKey) == val {
385+
matchCount++
386+
if matchIndex == -1 {
387+
matchIndex = i
388+
}
389+
}
390+
}
391+
if matchCount > 1 {
392+
c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"})
393+
return
394+
}
395+
if matchIndex != -1 {
396+
h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:matchIndex], h.cfg.ClaudeKey[matchIndex+1:]...)
344397
}
345-
h.cfg.ClaudeKey = out
346398
h.cfg.SanitizeClaudeKeys()
347399
h.persist(c)
348400
return
@@ -601,13 +653,38 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
601653

602654
func (h *Handler) DeleteVertexCompatKey(c *gin.Context) {
603655
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
604-
out := make([]config.VertexCompatKey, 0, len(h.cfg.VertexCompatAPIKey))
605-
for _, v := range h.cfg.VertexCompatAPIKey {
606-
if v.APIKey != val {
656+
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
657+
base := strings.TrimSpace(baseRaw)
658+
out := make([]config.VertexCompatKey, 0, len(h.cfg.VertexCompatAPIKey))
659+
for _, v := range h.cfg.VertexCompatAPIKey {
660+
if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base {
661+
continue
662+
}
607663
out = append(out, v)
608664
}
665+
h.cfg.VertexCompatAPIKey = out
666+
h.cfg.SanitizeVertexCompatKeys()
667+
h.persist(c)
668+
return
669+
}
670+
671+
matchIndex := -1
672+
matchCount := 0
673+
for i := range h.cfg.VertexCompatAPIKey {
674+
if strings.TrimSpace(h.cfg.VertexCompatAPIKey[i].APIKey) == val {
675+
matchCount++
676+
if matchIndex == -1 {
677+
matchIndex = i
678+
}
679+
}
680+
}
681+
if matchCount > 1 {
682+
c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"})
683+
return
684+
}
685+
if matchIndex != -1 {
686+
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:matchIndex], h.cfg.VertexCompatAPIKey[matchIndex+1:]...)
609687
}
610-
h.cfg.VertexCompatAPIKey = out
611688
h.cfg.SanitizeVertexCompatKeys()
612689
h.persist(c)
613690
return
@@ -919,14 +996,39 @@ func (h *Handler) PatchCodexKey(c *gin.Context) {
919996
}
920997

921998
func (h *Handler) DeleteCodexKey(c *gin.Context) {
922-
if val := c.Query("api-key"); val != "" {
923-
out := make([]config.CodexKey, 0, len(h.cfg.CodexKey))
924-
for _, v := range h.cfg.CodexKey {
925-
if v.APIKey != val {
999+
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
1000+
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
1001+
base := strings.TrimSpace(baseRaw)
1002+
out := make([]config.CodexKey, 0, len(h.cfg.CodexKey))
1003+
for _, v := range h.cfg.CodexKey {
1004+
if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base {
1005+
continue
1006+
}
9261007
out = append(out, v)
9271008
}
1009+
h.cfg.CodexKey = out
1010+
h.cfg.SanitizeCodexKeys()
1011+
h.persist(c)
1012+
return
1013+
}
1014+
1015+
matchIndex := -1
1016+
matchCount := 0
1017+
for i := range h.cfg.CodexKey {
1018+
if strings.TrimSpace(h.cfg.CodexKey[i].APIKey) == val {
1019+
matchCount++
1020+
if matchIndex == -1 {
1021+
matchIndex = i
1022+
}
1023+
}
1024+
}
1025+
if matchCount > 1 {
1026+
c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"})
1027+
return
1028+
}
1029+
if matchIndex != -1 {
1030+
h.cfg.CodexKey = append(h.cfg.CodexKey[:matchIndex], h.cfg.CodexKey[matchIndex+1:]...)
9281031
}
929-
h.cfg.CodexKey = out
9301032
h.cfg.SanitizeCodexKeys()
9311033
h.persist(c)
9321034
return
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package management
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
10+
"github.com/gin-gonic/gin"
11+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
12+
)
13+
14+
func writeTestConfigFile(t *testing.T) string {
15+
t.Helper()
16+
17+
dir := t.TempDir()
18+
path := filepath.Join(dir, "config.yaml")
19+
if errWrite := os.WriteFile(path, []byte("{}\n"), 0o600); errWrite != nil {
20+
t.Fatalf("failed to write test config: %v", errWrite)
21+
}
22+
return path
23+
}
24+
25+
func TestDeleteGeminiKey_RequiresBaseURLWhenAPIKeyDuplicated(t *testing.T) {
26+
t.Parallel()
27+
gin.SetMode(gin.TestMode)
28+
29+
h := &Handler{
30+
cfg: &config.Config{
31+
GeminiKey: []config.GeminiKey{
32+
{APIKey: "shared-key", BaseURL: "https://a.example.com"},
33+
{APIKey: "shared-key", BaseURL: "https://b.example.com"},
34+
},
35+
},
36+
configFilePath: writeTestConfigFile(t),
37+
}
38+
39+
rec := httptest.NewRecorder()
40+
c, _ := gin.CreateTestContext(rec)
41+
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/gemini-api-key?api-key=shared-key", nil)
42+
43+
h.DeleteGeminiKey(c)
44+
45+
if rec.Code != http.StatusBadRequest {
46+
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
47+
}
48+
if got := len(h.cfg.GeminiKey); got != 2 {
49+
t.Fatalf("gemini keys len = %d, want 2", got)
50+
}
51+
}
52+
53+
func TestDeleteGeminiKey_DeletesOnlyMatchingBaseURL(t *testing.T) {
54+
t.Parallel()
55+
gin.SetMode(gin.TestMode)
56+
57+
h := &Handler{
58+
cfg: &config.Config{
59+
GeminiKey: []config.GeminiKey{
60+
{APIKey: "shared-key", BaseURL: "https://a.example.com"},
61+
{APIKey: "shared-key", BaseURL: "https://b.example.com"},
62+
},
63+
},
64+
configFilePath: writeTestConfigFile(t),
65+
}
66+
67+
rec := httptest.NewRecorder()
68+
c, _ := gin.CreateTestContext(rec)
69+
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/gemini-api-key?api-key=shared-key&base-url=https://a.example.com", nil)
70+
71+
h.DeleteGeminiKey(c)
72+
73+
if rec.Code != http.StatusOK {
74+
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
75+
}
76+
if got := len(h.cfg.GeminiKey); got != 1 {
77+
t.Fatalf("gemini keys len = %d, want 1", got)
78+
}
79+
if got := h.cfg.GeminiKey[0].BaseURL; got != "https://b.example.com" {
80+
t.Fatalf("remaining base-url = %q, want %q", got, "https://b.example.com")
81+
}
82+
}
83+
84+
func TestDeleteClaudeKey_DeletesEmptyBaseURLWhenExplicitlyProvided(t *testing.T) {
85+
t.Parallel()
86+
gin.SetMode(gin.TestMode)
87+
88+
h := &Handler{
89+
cfg: &config.Config{
90+
ClaudeKey: []config.ClaudeKey{
91+
{APIKey: "shared-key", BaseURL: ""},
92+
{APIKey: "shared-key", BaseURL: "https://claude.example.com"},
93+
},
94+
},
95+
configFilePath: writeTestConfigFile(t),
96+
}
97+
98+
rec := httptest.NewRecorder()
99+
c, _ := gin.CreateTestContext(rec)
100+
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/claude-api-key?api-key=shared-key&base-url=", nil)
101+
102+
h.DeleteClaudeKey(c)
103+
104+
if rec.Code != http.StatusOK {
105+
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
106+
}
107+
if got := len(h.cfg.ClaudeKey); got != 1 {
108+
t.Fatalf("claude keys len = %d, want 1", got)
109+
}
110+
if got := h.cfg.ClaudeKey[0].BaseURL; got != "https://claude.example.com" {
111+
t.Fatalf("remaining base-url = %q, want %q", got, "https://claude.example.com")
112+
}
113+
}
114+
115+
func TestDeleteVertexCompatKey_DeletesOnlyMatchingBaseURL(t *testing.T) {
116+
t.Parallel()
117+
gin.SetMode(gin.TestMode)
118+
119+
h := &Handler{
120+
cfg: &config.Config{
121+
VertexCompatAPIKey: []config.VertexCompatKey{
122+
{APIKey: "shared-key", BaseURL: "https://a.example.com"},
123+
{APIKey: "shared-key", BaseURL: "https://b.example.com"},
124+
},
125+
},
126+
configFilePath: writeTestConfigFile(t),
127+
}
128+
129+
rec := httptest.NewRecorder()
130+
c, _ := gin.CreateTestContext(rec)
131+
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/vertex-api-key?api-key=shared-key&base-url=https://b.example.com", nil)
132+
133+
h.DeleteVertexCompatKey(c)
134+
135+
if rec.Code != http.StatusOK {
136+
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
137+
}
138+
if got := len(h.cfg.VertexCompatAPIKey); got != 1 {
139+
t.Fatalf("vertex keys len = %d, want 1", got)
140+
}
141+
if got := h.cfg.VertexCompatAPIKey[0].BaseURL; got != "https://a.example.com" {
142+
t.Fatalf("remaining base-url = %q, want %q", got, "https://a.example.com")
143+
}
144+
}
145+
146+
func TestDeleteCodexKey_RequiresBaseURLWhenAPIKeyDuplicated(t *testing.T) {
147+
t.Parallel()
148+
gin.SetMode(gin.TestMode)
149+
150+
h := &Handler{
151+
cfg: &config.Config{
152+
CodexKey: []config.CodexKey{
153+
{APIKey: "shared-key", BaseURL: "https://a.example.com"},
154+
{APIKey: "shared-key", BaseURL: "https://b.example.com"},
155+
},
156+
},
157+
configFilePath: writeTestConfigFile(t),
158+
}
159+
160+
rec := httptest.NewRecorder()
161+
c, _ := gin.CreateTestContext(rec)
162+
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/codex-api-key?api-key=shared-key", nil)
163+
164+
h.DeleteCodexKey(c)
165+
166+
if rec.Code != http.StatusBadRequest {
167+
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
168+
}
169+
if got := len(h.cfg.CodexKey); got != 2 {
170+
t.Fatalf("codex keys len = %d, want 2", got)
171+
}
172+
}

internal/config/config.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,7 @@ func (cfg *Config) SanitizeKiroKeys() {
981981
}
982982

983983
// SanitizeGeminiKeys deduplicates and normalizes Gemini credentials.
984+
// It uses API key + base URL as the uniqueness key.
984985
func (cfg *Config) SanitizeGeminiKeys() {
985986
if cfg == nil {
986987
return
@@ -999,10 +1000,11 @@ func (cfg *Config) SanitizeGeminiKeys() {
9991000
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
10001001
entry.Headers = NormalizeHeaders(entry.Headers)
10011002
entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)
1002-
if _, exists := seen[entry.APIKey]; exists {
1003+
uniqueKey := entry.APIKey + "|" + entry.BaseURL
1004+
if _, exists := seen[uniqueKey]; exists {
10031005
continue
10041006
}
1005-
seen[entry.APIKey] = struct{}{}
1007+
seen[uniqueKey] = struct{}{}
10061008
out = append(out, entry)
10071009
}
10081010
cfg.GeminiKey = out

0 commit comments

Comments
 (0)