fix(management): stabilize auth-index mapping

This commit is contained in:
Supra4E8C
2026-04-18 17:12:14 +08:00
parent 894baad829
commit c26936e2e6
4 changed files with 451 additions and 150 deletions

View File

@@ -1,22 +1,13 @@
package management
import (
"fmt"
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer"
)
type configAuthIndexViews struct {
gemini []string
claude []string
codex []string
vertex []string
openAIEntries [][]string
openAIFallback []string
}
type geminiKeyWithAuthIndex struct {
config.GeminiKey
AuthIndex string `json:"auth-index,omitempty"`
@@ -53,170 +44,174 @@ type openAICompatibilityWithAuthIndex struct {
AuthIndex string `json:"auth-index,omitempty"`
}
func (h *Handler) buildConfigAuthIndexViews() configAuthIndexViews {
cfg := h.cfg
if cfg == nil {
return configAuthIndexViews{}
func (h *Handler) liveAuthIndexByID() map[string]string {
out := map[string]string{}
if h == nil {
return out
}
liveIndexByID := map[string]string{}
if h != nil && h.authManager != nil {
for _, auth := range h.authManager.List() {
if auth == nil || strings.TrimSpace(auth.ID) == "" {
continue
}
auth.EnsureIndex()
if auth.Index == "" {
continue
}
liveIndexByID[auth.ID] = auth.Index
}
h.mu.Lock()
manager := h.authManager
h.mu.Unlock()
if manager == nil {
return out
}
views := configAuthIndexViews{
gemini: make([]string, len(cfg.GeminiKey)),
claude: make([]string, len(cfg.ClaudeKey)),
codex: make([]string, len(cfg.CodexKey)),
vertex: make([]string, len(cfg.VertexCompatAPIKey)),
openAIEntries: make([][]string, len(cfg.OpenAICompatibility)),
openAIFallback: make([]string, len(cfg.OpenAICompatibility)),
}
auths, errSynthesize := synthesizer.NewConfigSynthesizer().Synthesize(&synthesizer.SynthesisContext{
Config: cfg,
Now: time.Now(),
IDGenerator: synthesizer.NewStableIDGenerator(),
})
if errSynthesize != nil {
return views
}
cursor := 0
nextAuthIndex := func() string {
if cursor >= len(auths) {
return ""
}
auth := auths[cursor]
cursor++
if auth == nil || strings.TrimSpace(auth.ID) == "" {
return ""
}
// Do not expose an auth-index until it is present in the live auth manager.
// API tools resolve auth_index against h.authManager.List(), so returning
// config-only indexes can temporarily break tool calls around config edits.
return liveIndexByID[auth.ID]
}
for i := range cfg.GeminiKey {
if strings.TrimSpace(cfg.GeminiKey[i].APIKey) == "" {
// authManager.List() returns clones, so EnsureIndex only affects these copies.
for _, auth := range manager.List() {
if auth == nil {
continue
}
views.gemini[i] = nextAuthIndex()
}
for i := range cfg.ClaudeKey {
if strings.TrimSpace(cfg.ClaudeKey[i].APIKey) == "" {
id := strings.TrimSpace(auth.ID)
if id == "" {
continue
}
views.claude[i] = nextAuthIndex()
}
for i := range cfg.CodexKey {
if strings.TrimSpace(cfg.CodexKey[i].APIKey) == "" {
idx := strings.TrimSpace(auth.Index)
if idx == "" {
idx = auth.EnsureIndex()
}
if idx == "" {
continue
}
views.codex[i] = nextAuthIndex()
out[id] = idx
}
for i := range cfg.OpenAICompatibility {
entries := cfg.OpenAICompatibility[i].APIKeyEntries
if len(entries) == 0 {
views.openAIFallback[i] = nextAuthIndex()
continue
}
views.openAIEntries[i] = make([]string, len(entries))
for j := range entries {
views.openAIEntries[i][j] = nextAuthIndex()
}
}
for i := range cfg.VertexCompatAPIKey {
if strings.TrimSpace(cfg.VertexCompatAPIKey[i].APIKey) == "" {
continue
}
views.vertex[i] = nextAuthIndex()
}
return views
return out
}
func (h *Handler) geminiKeysWithAuthIndex() []geminiKeyWithAuthIndex {
if h == nil || h.cfg == nil {
if h == nil {
return nil
}
views := h.buildConfigAuthIndexViews()
liveIndexByID := h.liveAuthIndexByID()
h.mu.Lock()
defer h.mu.Unlock()
if h.cfg == nil {
return nil
}
idGen := synthesizer.NewStableIDGenerator()
out := make([]geminiKeyWithAuthIndex, len(h.cfg.GeminiKey))
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)
authIndex = liveIndexByID[id]
}
out[i] = geminiKeyWithAuthIndex{
GeminiKey: h.cfg.GeminiKey[i],
AuthIndex: views.gemini[i],
GeminiKey: entry,
AuthIndex: authIndex,
}
}
return out
}
func (h *Handler) claudeKeysWithAuthIndex() []claudeKeyWithAuthIndex {
if h == nil || h.cfg == nil {
if h == nil {
return nil
}
views := h.buildConfigAuthIndexViews()
liveIndexByID := h.liveAuthIndexByID()
h.mu.Lock()
defer h.mu.Unlock()
if h.cfg == nil {
return nil
}
idGen := synthesizer.NewStableIDGenerator()
out := make([]claudeKeyWithAuthIndex, len(h.cfg.ClaudeKey))
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)
authIndex = liveIndexByID[id]
}
out[i] = claudeKeyWithAuthIndex{
ClaudeKey: h.cfg.ClaudeKey[i],
AuthIndex: views.claude[i],
ClaudeKey: entry,
AuthIndex: authIndex,
}
}
return out
}
func (h *Handler) codexKeysWithAuthIndex() []codexKeyWithAuthIndex {
if h == nil || h.cfg == nil {
if h == nil {
return nil
}
views := h.buildConfigAuthIndexViews()
liveIndexByID := h.liveAuthIndexByID()
h.mu.Lock()
defer h.mu.Unlock()
if h.cfg == nil {
return nil
}
idGen := synthesizer.NewStableIDGenerator()
out := make([]codexKeyWithAuthIndex, len(h.cfg.CodexKey))
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)
authIndex = liveIndexByID[id]
}
out[i] = codexKeyWithAuthIndex{
CodexKey: h.cfg.CodexKey[i],
AuthIndex: views.codex[i],
CodexKey: entry,
AuthIndex: authIndex,
}
}
return out
}
func (h *Handler) vertexCompatKeysWithAuthIndex() []vertexCompatKeyWithAuthIndex {
if h == nil || h.cfg == nil {
if h == nil {
return nil
}
views := h.buildConfigAuthIndexViews()
liveIndexByID := h.liveAuthIndexByID()
h.mu.Lock()
defer h.mu.Unlock()
if h.cfg == nil {
return nil
}
idGen := synthesizer.NewStableIDGenerator()
out := make([]vertexCompatKeyWithAuthIndex, len(h.cfg.VertexCompatAPIKey))
for i := range h.cfg.VertexCompatAPIKey {
entry := h.cfg.VertexCompatAPIKey[i]
id, _ := idGen.Next("vertex:apikey", entry.APIKey, entry.BaseURL, entry.ProxyURL)
authIndex := liveIndexByID[id]
out[i] = vertexCompatKeyWithAuthIndex{
VertexCompatKey: h.cfg.VertexCompatAPIKey[i],
AuthIndex: views.vertex[i],
VertexCompatKey: entry,
AuthIndex: authIndex,
}
}
return out
}
func (h *Handler) openAICompatibilityWithAuthIndex() []openAICompatibilityWithAuthIndex {
if h == nil || h.cfg == nil {
if h == nil {
return nil
}
liveIndexByID := h.liveAuthIndexByID()
h.mu.Lock()
defer h.mu.Unlock()
if h.cfg == nil {
return nil
}
views := h.buildConfigAuthIndexViews()
normalized := normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility)
out := make([]openAICompatibilityWithAuthIndex, len(normalized))
idGen := synthesizer.NewStableIDGenerator()
for i := range normalized {
entry := normalized[i]
providerName := strings.ToLower(strings.TrimSpace(entry.Name))
if providerName == "" {
providerName = "openai-compatibility"
}
idKind := fmt.Sprintf("openai-compatibility:%s", providerName)
response := openAICompatibilityWithAuthIndex{
Name: entry.Name,
Priority: entry.Priority,
@@ -224,18 +219,19 @@ func (h *Handler) openAICompatibilityWithAuthIndex() []openAICompatibilityWithAu
BaseURL: entry.BaseURL,
Models: entry.Models,
Headers: entry.Headers,
AuthIndex: views.openAIFallback[i],
AuthIndex: "",
}
if len(entry.APIKeyEntries) > 0 {
if len(entry.APIKeyEntries) == 0 {
id, _ := idGen.Next(idKind, entry.BaseURL)
response.AuthIndex = liveIndexByID[id]
} else {
response.APIKeyEntries = make([]openAICompatibilityAPIKeyWithAuthIndex, len(entry.APIKeyEntries))
for j := range entry.APIKeyEntries {
authIndex := ""
if i < len(views.openAIEntries) && j < len(views.openAIEntries[i]) {
authIndex = views.openAIEntries[i][j]
}
apiKeyEntry := entry.APIKeyEntries[j]
id, _ := idGen.Next(idKind, apiKeyEntry.APIKey, entry.BaseURL, apiKeyEntry.ProxyURL)
response.APIKeyEntries[j] = openAICompatibilityAPIKeyWithAuthIndex{
OpenAICompatibilityAPIKey: entry.APIKeyEntries[j],
AuthIndex: authIndex,
OpenAICompatibilityAPIKey: apiKeyEntry,
AuthIndex: liveIndexByID[id],
}
}
}

View File

@@ -0,0 +1,250 @@
package management
import (
"context"
"testing"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
)
func synthesizeConfigAuths(t *testing.T, cfg *config.Config) []*coreauth.Auth {
t.Helper()
auths, errSynthesize := synthesizer.NewConfigSynthesizer().Synthesize(&synthesizer.SynthesisContext{
Config: cfg,
Now: time.Unix(0, 0),
IDGenerator: synthesizer.NewStableIDGenerator(),
})
if errSynthesize != nil {
t.Fatalf("synthesize config auths: %v", errSynthesize)
}
return auths
}
func findAuth(t *testing.T, auths []*coreauth.Auth, predicate func(*coreauth.Auth) bool) *coreauth.Auth {
t.Helper()
for _, auth := range auths {
if predicate(auth) {
return auth
}
}
return nil
}
func TestConfigAuthIndexResolvesLiveIndexes(t *testing.T) {
t.Parallel()
cfg := &config.Config{
GeminiKey: []config.GeminiKey{
{APIKey: "shared-key", BaseURL: "https://a.example.com"},
{APIKey: "shared-key", BaseURL: "https://b.example.com"},
},
ClaudeKey: []config.ClaudeKey{
{APIKey: "claude-key", BaseURL: "https://claude.example.com"},
},
CodexKey: []config.CodexKey{
{APIKey: "codex-key", BaseURL: "https://codex.example.com/v1"},
},
VertexCompatAPIKey: []config.VertexCompatKey{
{APIKey: "vertex-key", BaseURL: "https://vertex.example.com", ProxyURL: "http://proxy.example.com:8080"},
},
OpenAICompatibility: []config.OpenAICompatibility{
{
Name: "bohe",
BaseURL: "https://bohe.example.com/v1",
APIKeyEntries: []config.OpenAICompatibilityAPIKey{
{APIKey: "compat-key"},
},
},
},
}
auths := synthesizeConfigAuths(t, cfg)
manager := coreauth.NewManager(nil, nil, nil)
for _, auth := range auths {
if auth == nil {
continue
}
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
t.Fatalf("register auth %q: %v", auth.ID, errRegister)
}
}
h := &Handler{cfg: cfg, authManager: manager}
geminiAuthA := findAuth(t, auths, func(auth *coreauth.Auth) bool {
if auth == nil {
return false
}
return auth.Provider == "gemini" && auth.Attributes["api_key"] == "shared-key" && auth.Attributes["base_url"] == "https://a.example.com"
})
if geminiAuthA == nil {
t.Fatal("expected synthesized gemini auth (base a)")
}
geminiAuthB := findAuth(t, auths, func(auth *coreauth.Auth) bool {
if auth == nil {
return false
}
return auth.Provider == "gemini" && auth.Attributes["api_key"] == "shared-key" && auth.Attributes["base_url"] == "https://b.example.com"
})
if geminiAuthB == nil {
t.Fatal("expected synthesized gemini auth (base b)")
}
gemini := h.geminiKeysWithAuthIndex()
if len(gemini) != 2 {
t.Fatalf("gemini keys = %d, want 2", len(gemini))
}
if got, want := gemini[0].AuthIndex, geminiAuthA.EnsureIndex(); got != want {
t.Fatalf("gemini[0] auth-index = %q, want %q", got, want)
}
if got, want := gemini[1].AuthIndex, geminiAuthB.EnsureIndex(); got != want {
t.Fatalf("gemini[1] auth-index = %q, want %q", got, want)
}
if gemini[0].AuthIndex == gemini[1].AuthIndex {
t.Fatalf("duplicate gemini entries returned the same auth-index %q", gemini[0].AuthIndex)
}
claudeAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool {
if auth == nil {
return false
}
return auth.Provider == "claude" && auth.Attributes["api_key"] == "claude-key"
})
if claudeAuth == nil {
t.Fatal("expected synthesized claude auth")
}
claude := h.claudeKeysWithAuthIndex()
if len(claude) != 1 {
t.Fatalf("claude keys = %d, want 1", len(claude))
}
if got, want := claude[0].AuthIndex, claudeAuth.EnsureIndex(); got != want {
t.Fatalf("claude auth-index = %q, want %q", got, want)
}
codexAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool {
if auth == nil {
return false
}
return auth.Provider == "codex" && auth.Attributes["api_key"] == "codex-key"
})
if codexAuth == nil {
t.Fatal("expected synthesized codex auth")
}
codex := h.codexKeysWithAuthIndex()
if len(codex) != 1 {
t.Fatalf("codex keys = %d, want 1", len(codex))
}
if got, want := codex[0].AuthIndex, codexAuth.EnsureIndex(); got != want {
t.Fatalf("codex auth-index = %q, want %q", got, want)
}
vertexAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool {
if auth == nil {
return false
}
return auth.Provider == "vertex" && auth.Attributes["api_key"] == "vertex-key"
})
if vertexAuth == nil {
t.Fatal("expected synthesized vertex auth")
}
vertex := h.vertexCompatKeysWithAuthIndex()
if len(vertex) != 1 {
t.Fatalf("vertex keys = %d, want 1", len(vertex))
}
if got, want := vertex[0].AuthIndex, vertexAuth.EnsureIndex(); got != want {
t.Fatalf("vertex auth-index = %q, want %q", got, want)
}
compatAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool {
if auth == nil {
return false
}
if auth.Provider != "bohe" {
return false
}
if auth.Attributes["provider_key"] != "bohe" || auth.Attributes["compat_name"] != "bohe" {
return false
}
return auth.Attributes["api_key"] == "compat-key"
})
if compatAuth == nil {
t.Fatal("expected synthesized openai-compat auth")
}
compat := h.openAICompatibilityWithAuthIndex()
if len(compat) != 1 {
t.Fatalf("openai-compat providers = %d, want 1", len(compat))
}
if len(compat[0].APIKeyEntries) != 1 {
t.Fatalf("openai-compat api-key-entries = %d, want 1", len(compat[0].APIKeyEntries))
}
if compat[0].AuthIndex != "" {
t.Fatalf("provider-level auth-index should be empty when api-key-entries exist, got %q", compat[0].AuthIndex)
}
if got, want := compat[0].APIKeyEntries[0].AuthIndex, compatAuth.EnsureIndex(); got != want {
t.Fatalf("openai-compat auth-index = %q, want %q", got, want)
}
}
func TestConfigAuthIndexOmitsIndexesNotInManager(t *testing.T) {
t.Parallel()
cfg := &config.Config{
GeminiKey: []config.GeminiKey{
{APIKey: "gemini-key", BaseURL: "https://a.example.com"},
},
OpenAICompatibility: []config.OpenAICompatibility{
{
Name: "bohe",
BaseURL: "https://bohe.example.com/v1",
APIKeyEntries: []config.OpenAICompatibilityAPIKey{
{APIKey: "compat-key"},
},
},
},
}
auths := synthesizeConfigAuths(t, cfg)
geminiAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool {
if auth == nil {
return false
}
return auth.Provider == "gemini" && auth.Attributes["api_key"] == "gemini-key"
})
if geminiAuth == nil {
t.Fatal("expected synthesized gemini auth")
}
manager := coreauth.NewManager(nil, nil, nil)
if _, errRegister := manager.Register(context.Background(), geminiAuth); errRegister != nil {
t.Fatalf("register gemini auth: %v", errRegister)
}
h := &Handler{cfg: cfg, authManager: manager}
gemini := h.geminiKeysWithAuthIndex()
if len(gemini) != 1 {
t.Fatalf("gemini keys = %d, want 1", len(gemini))
}
if gemini[0].AuthIndex == "" {
t.Fatal("expected gemini auth-index to be set")
}
compat := h.openAICompatibilityWithAuthIndex()
if len(compat) != 1 {
t.Fatalf("openai-compat providers = %d, want 1", len(compat))
}
if len(compat[0].APIKeyEntries) != 1 {
t.Fatalf("openai-compat api-key-entries = %d, want 1", len(compat[0].APIKeyEntries))
}
if compat[0].APIKeyEntries[0].AuthIndex != "" {
t.Fatalf("openai-compat auth-index = %q, want empty", compat[0].APIKeyEntries[0].AuthIndex)
}
}

View File

@@ -139,9 +139,11 @@ func (h *Handler) PutGeminiKeys(c *gin.Context) {
}
arr = obj.Items
}
h.mu.Lock()
defer h.mu.Unlock()
h.cfg.GeminiKey = append([]config.GeminiKey(nil), arr...)
h.cfg.SanitizeGeminiKeys()
h.persist(c)
h.persistLocked(c)
}
func (h *Handler) PatchGeminiKey(c *gin.Context) {
type geminiKeyPatch struct {
@@ -161,6 +163,9 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
h.mu.Lock()
defer h.mu.Unlock()
targetIndex := -1
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) {
targetIndex = *body.Index
@@ -187,7 +192,7 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) {
if trimmed == "" {
h.cfg.GeminiKey = append(h.cfg.GeminiKey[:targetIndex], h.cfg.GeminiKey[targetIndex+1:]...)
h.cfg.SanitizeGeminiKeys()
h.persist(c)
h.persistLocked(c)
return
}
entry.APIKey = trimmed
@@ -209,10 +214,12 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) {
}
h.cfg.GeminiKey[targetIndex] = entry
h.cfg.SanitizeGeminiKeys()
h.persist(c)
h.persistLocked(c)
}
func (h *Handler) DeleteGeminiKey(c *gin.Context) {
h.mu.Lock()
defer h.mu.Unlock()
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
base := strings.TrimSpace(baseRaw)
@@ -226,7 +233,7 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) {
if len(out) != len(h.cfg.GeminiKey) {
h.cfg.GeminiKey = out
h.cfg.SanitizeGeminiKeys()
h.persist(c)
h.persistLocked(c)
} else {
c.JSON(404, gin.H{"error": "item not found"})
}
@@ -253,7 +260,7 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) {
}
h.cfg.GeminiKey = append(h.cfg.GeminiKey[:matchIndex], h.cfg.GeminiKey[matchIndex+1:]...)
h.cfg.SanitizeGeminiKeys()
h.persist(c)
h.persistLocked(c)
return
}
if idxStr := c.Query("index"); idxStr != "" {
@@ -261,7 +268,7 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) {
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.SanitizeGeminiKeys()
h.persist(c)
h.persistLocked(c)
return
}
}
@@ -292,9 +299,11 @@ func (h *Handler) PutClaudeKeys(c *gin.Context) {
for i := range arr {
normalizeClaudeKey(&arr[i])
}
h.mu.Lock()
defer h.mu.Unlock()
h.cfg.ClaudeKey = arr
h.cfg.SanitizeClaudeKeys()
h.persist(c)
h.persistLocked(c)
}
func (h *Handler) PatchClaudeKey(c *gin.Context) {
type claudeKeyPatch struct {
@@ -315,6 +324,9 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
h.mu.Lock()
defer h.mu.Unlock()
targetIndex := -1
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.ClaudeKey) {
targetIndex = *body.Index
@@ -358,10 +370,12 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) {
normalizeClaudeKey(&entry)
h.cfg.ClaudeKey[targetIndex] = entry
h.cfg.SanitizeClaudeKeys()
h.persist(c)
h.persistLocked(c)
}
func (h *Handler) DeleteClaudeKey(c *gin.Context) {
h.mu.Lock()
defer h.mu.Unlock()
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
base := strings.TrimSpace(baseRaw)
@@ -374,7 +388,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) {
}
h.cfg.ClaudeKey = out
h.cfg.SanitizeClaudeKeys()
h.persist(c)
h.persistLocked(c)
return
}
@@ -396,7 +410,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) {
h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:matchIndex], h.cfg.ClaudeKey[matchIndex+1:]...)
}
h.cfg.SanitizeClaudeKeys()
h.persist(c)
h.persistLocked(c)
return
}
if idxStr := c.Query("index"); idxStr != "" {
@@ -405,7 +419,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) {
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.SanitizeClaudeKeys()
h.persist(c)
h.persistLocked(c)
return
}
}
@@ -440,9 +454,11 @@ func (h *Handler) PutOpenAICompat(c *gin.Context) {
filtered = append(filtered, arr[i])
}
}
h.mu.Lock()
defer h.mu.Unlock()
h.cfg.OpenAICompatibility = filtered
h.cfg.SanitizeOpenAICompatibility()
h.persist(c)
h.persistLocked(c)
}
func (h *Handler) PatchOpenAICompat(c *gin.Context) {
type openAICompatPatch struct {
@@ -462,6 +478,9 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
h.mu.Lock()
defer h.mu.Unlock()
targetIndex := -1
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {
targetIndex = *body.Index
@@ -492,7 +511,7 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
if trimmed == "" {
h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:targetIndex], h.cfg.OpenAICompatibility[targetIndex+1:]...)
h.cfg.SanitizeOpenAICompatibility()
h.persist(c)
h.persistLocked(c)
return
}
entry.BaseURL = trimmed
@@ -509,10 +528,12 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
normalizeOpenAICompatibilityEntry(&entry)
h.cfg.OpenAICompatibility[targetIndex] = entry
h.cfg.SanitizeOpenAICompatibility()
h.persist(c)
h.persistLocked(c)
}
func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
h.mu.Lock()
defer h.mu.Unlock()
if name := c.Query("name"); name != "" {
out := make([]config.OpenAICompatibility, 0, len(h.cfg.OpenAICompatibility))
for _, v := range h.cfg.OpenAICompatibility {
@@ -522,7 +543,7 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
}
h.cfg.OpenAICompatibility = out
h.cfg.SanitizeOpenAICompatibility()
h.persist(c)
h.persistLocked(c)
return
}
if idxStr := c.Query("index"); idxStr != "" {
@@ -531,7 +552,7 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
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.SanitizeOpenAICompatibility()
h.persist(c)
h.persistLocked(c)
return
}
}
@@ -566,9 +587,11 @@ func (h *Handler) PutVertexCompatKeys(c *gin.Context) {
return
}
}
h.mu.Lock()
defer h.mu.Unlock()
h.cfg.VertexCompatAPIKey = append([]config.VertexCompatKey(nil), arr...)
h.cfg.SanitizeVertexCompatKeys()
h.persist(c)
h.persistLocked(c)
}
func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
type vertexCompatPatch struct {
@@ -589,6 +612,9 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
h.mu.Lock()
defer h.mu.Unlock()
targetIndex := -1
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.VertexCompatAPIKey) {
targetIndex = *body.Index
@@ -615,7 +641,7 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
if trimmed == "" {
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...)
h.cfg.SanitizeVertexCompatKeys()
h.persist(c)
h.persistLocked(c)
return
}
entry.APIKey = trimmed
@@ -628,7 +654,7 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
if trimmed == "" {
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...)
h.cfg.SanitizeVertexCompatKeys()
h.persist(c)
h.persistLocked(c)
return
}
entry.BaseURL = trimmed
@@ -648,10 +674,12 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
normalizeVertexCompatKey(&entry)
h.cfg.VertexCompatAPIKey[targetIndex] = entry
h.cfg.SanitizeVertexCompatKeys()
h.persist(c)
h.persistLocked(c)
}
func (h *Handler) DeleteVertexCompatKey(c *gin.Context) {
h.mu.Lock()
defer h.mu.Unlock()
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
base := strings.TrimSpace(baseRaw)
@@ -664,7 +692,7 @@ func (h *Handler) DeleteVertexCompatKey(c *gin.Context) {
}
h.cfg.VertexCompatAPIKey = out
h.cfg.SanitizeVertexCompatKeys()
h.persist(c)
h.persistLocked(c)
return
}
@@ -686,7 +714,7 @@ func (h *Handler) DeleteVertexCompatKey(c *gin.Context) {
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:matchIndex], h.cfg.VertexCompatAPIKey[matchIndex+1:]...)
}
h.cfg.SanitizeVertexCompatKeys()
h.persist(c)
h.persistLocked(c)
return
}
if idxStr := c.Query("index"); idxStr != "" {
@@ -695,7 +723,7 @@ func (h *Handler) DeleteVertexCompatKey(c *gin.Context) {
if errScan == nil && idx >= 0 && idx < len(h.cfg.VertexCompatAPIKey) {
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:idx], h.cfg.VertexCompatAPIKey[idx+1:]...)
h.cfg.SanitizeVertexCompatKeys()
h.persist(c)
h.persistLocked(c)
return
}
}
@@ -915,9 +943,11 @@ func (h *Handler) PutCodexKeys(c *gin.Context) {
}
filtered = append(filtered, entry)
}
h.mu.Lock()
defer h.mu.Unlock()
h.cfg.CodexKey = filtered
h.cfg.SanitizeCodexKeys()
h.persist(c)
h.persistLocked(c)
}
func (h *Handler) PatchCodexKey(c *gin.Context) {
type codexKeyPatch struct {
@@ -938,6 +968,9 @@ func (h *Handler) PatchCodexKey(c *gin.Context) {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
h.mu.Lock()
defer h.mu.Unlock()
targetIndex := -1
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
targetIndex = *body.Index
@@ -968,7 +1001,7 @@ func (h *Handler) PatchCodexKey(c *gin.Context) {
if trimmed == "" {
h.cfg.CodexKey = append(h.cfg.CodexKey[:targetIndex], h.cfg.CodexKey[targetIndex+1:]...)
h.cfg.SanitizeCodexKeys()
h.persist(c)
h.persistLocked(c)
return
}
entry.BaseURL = trimmed
@@ -988,10 +1021,12 @@ func (h *Handler) PatchCodexKey(c *gin.Context) {
normalizeCodexKey(&entry)
h.cfg.CodexKey[targetIndex] = entry
h.cfg.SanitizeCodexKeys()
h.persist(c)
h.persistLocked(c)
}
func (h *Handler) DeleteCodexKey(c *gin.Context) {
h.mu.Lock()
defer h.mu.Unlock()
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
base := strings.TrimSpace(baseRaw)
@@ -1004,7 +1039,7 @@ func (h *Handler) DeleteCodexKey(c *gin.Context) {
}
h.cfg.CodexKey = out
h.cfg.SanitizeCodexKeys()
h.persist(c)
h.persistLocked(c)
return
}
@@ -1026,7 +1061,7 @@ func (h *Handler) DeleteCodexKey(c *gin.Context) {
h.cfg.CodexKey = append(h.cfg.CodexKey[:matchIndex], h.cfg.CodexKey[matchIndex+1:]...)
}
h.cfg.SanitizeCodexKeys()
h.persist(c)
h.persistLocked(c)
return
}
if idxStr := c.Query("index"); idxStr != "" {
@@ -1035,7 +1070,7 @@ func (h *Handler) DeleteCodexKey(c *gin.Context) {
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.SanitizeCodexKeys()
h.persist(c)
h.persistLocked(c)
return
}
}

View File

@@ -105,10 +105,24 @@ func NewHandlerWithoutConfigFilePath(cfg *config.Config, manager *coreauth.Manag
}
// SetConfig updates the in-memory config reference when the server hot-reloads.
func (h *Handler) SetConfig(cfg *config.Config) { h.cfg = cfg }
func (h *Handler) SetConfig(cfg *config.Config) {
if h == nil {
return
}
h.mu.Lock()
h.cfg = cfg
h.mu.Unlock()
}
// SetAuthManager updates the auth manager reference used by management endpoints.
func (h *Handler) SetAuthManager(manager *coreauth.Manager) { h.authManager = manager }
func (h *Handler) SetAuthManager(manager *coreauth.Manager) {
if h == nil {
return
}
h.mu.Lock()
h.authManager = manager
h.mu.Unlock()
}
// SetUsageStatistics allows replacing the usage statistics reference.
func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageStats = stats }
@@ -276,6 +290,12 @@ func (h *Handler) Middleware() gin.HandlerFunc {
func (h *Handler) persist(c *gin.Context) bool {
h.mu.Lock()
defer h.mu.Unlock()
return h.persistLocked(c)
}
// persistLocked saves the current in-memory config to disk.
// It expects the caller to hold h.mu.
func (h *Handler) persistLocked(c *gin.Context) bool {
// Preserve comments when writing
if err := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", err)})