diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 41036a506..eef3010d1 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -28,6 +28,7 @@ import ( geminiAuth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/gemini" "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/kimi" xaiauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/xai" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" @@ -1253,6 +1254,39 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) { return } + if coreauth.IsConfigAPIKeyAuth(targetAuth) { + h.mu.Lock() + handled, errToggle := toggleConfigAPIKeyExcludedAll(h.cfg, targetAuth, *req.Disabled) + if errToggle != nil { + h.mu.Unlock() + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update config api key: %v", errToggle)}) + return + } + if !handled { + h.mu.Unlock() + c.JSON(http.StatusNotFound, gin.H{"error": "config api key entry not found"}) + return + } + if errSave := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); errSave != nil { + h.mu.Unlock() + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", errSave)}) + return + } + cfgSnapshot := h.cfg + h.mu.Unlock() + h.reloadConfigAfterManagementSave(ctx, cfgSnapshot) + if h.tokenStore != nil { + _ = h.tokenStore.Delete(ctx, targetAuth.ID) + } + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "disabled": *req.Disabled, + "via": "config:excluded-models", + "excluded_pattern": configAPIKeyDisablePattern, + }) + return + } + // Update disabled state targetAuth.Disabled = *req.Disabled if *req.Disabled { diff --git a/internal/api/handlers/management/config_apikey_disable.go b/internal/api/handlers/management/config_apikey_disable.go new file mode 100644 index 000000000..5a6c597dd --- /dev/null +++ b/internal/api/handlers/management/config_apikey_disable.go @@ -0,0 +1,78 @@ +package management + +import ( + "fmt" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/synthesizer" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" +) + +const configAPIKeyDisablePattern = "*" + +func setConfigAPIKeyExcludedAll(models []string, disable bool) []string { + if disable { + for _, item := range models { + if strings.TrimSpace(item) == configAPIKeyDisablePattern { + return config.NormalizeExcludedModels(models) + } + } + return config.NormalizeExcludedModels(append(append([]string(nil), models...), configAPIKeyDisablePattern)) + } + filtered := make([]string, 0, len(models)) + for _, item := range models { + if strings.TrimSpace(item) == configAPIKeyDisablePattern { + continue + } + filtered = append(filtered, item) + } + return config.NormalizeExcludedModels(filtered) +} + +func toggleConfigAPIKeyExcludedAll(cfg *config.Config, auth *coreauth.Auth, disable bool) (bool, error) { + if cfg == nil || auth == nil || !coreauth.IsConfigAPIKeyAuth(auth) { + return false, nil + } + authID := strings.TrimSpace(auth.ID) + if authID == "" { + return false, fmt.Errorf("auth id is empty") + } + + idGen := synthesizer.NewStableIDGenerator() + + for i := range cfg.GeminiKey { + entry := &cfg.GeminiKey[i] + id, _ := idGen.Next("gemini:apikey", entry.APIKey, entry.BaseURL) + if id == authID { + entry.ExcludedModels = setConfigAPIKeyExcludedAll(entry.ExcludedModels, disable) + return true, nil + } + } + for i := range cfg.ClaudeKey { + entry := &cfg.ClaudeKey[i] + id, _ := idGen.Next("claude:apikey", entry.APIKey, entry.BaseURL) + if id == authID { + entry.ExcludedModels = setConfigAPIKeyExcludedAll(entry.ExcludedModels, disable) + return true, nil + } + } + for i := range cfg.CodexKey { + entry := &cfg.CodexKey[i] + id, _ := idGen.Next("codex:apikey", entry.APIKey, entry.BaseURL) + if id == authID { + entry.ExcludedModels = setConfigAPIKeyExcludedAll(entry.ExcludedModels, disable) + return true, nil + } + } + for i := range cfg.VertexCompatAPIKey { + entry := &cfg.VertexCompatAPIKey[i] + id, _ := idGen.Next("vertex:apikey", entry.APIKey, entry.BaseURL, entry.ProxyURL) + if id == authID { + entry.ExcludedModels = setConfigAPIKeyExcludedAll(entry.ExcludedModels, disable) + return true, nil + } + } + + return false, nil +} diff --git a/internal/api/handlers/management/config_apikey_disable_test.go b/internal/api/handlers/management/config_apikey_disable_test.go new file mode 100644 index 000000000..0e7d3f099 --- /dev/null +++ b/internal/api/handlers/management/config_apikey_disable_test.go @@ -0,0 +1,56 @@ +package management + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/synthesizer" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" +) + +func TestSetConfigAPIKeyExcludedAll(t *testing.T) { + gotDisable := setConfigAPIKeyExcludedAll([]string{"gpt-5"}, true) + if len(gotDisable) != 2 || gotDisable[0] != "gpt-5" || gotDisable[1] != "*" { + t.Fatalf("unexpected disable list: %#v", gotDisable) + } + gotEnable := setConfigAPIKeyExcludedAll([]string{"gpt-5", "*"}, false) + if len(gotEnable) != 1 || gotEnable[0] != "gpt-5" { + t.Fatalf("unexpected enable list: %#v", gotEnable) + } +} + +func TestToggleConfigAPIKeyExcludedAll_Codex(t *testing.T) { + cfg := &config.Config{ + CodexKey: []config.CodexKey{{ + APIKey: "sk-test", + BaseURL: "https://example.com/v1", + }}, + } + idGen := synthesizer.NewStableIDGenerator() + authID, _ := idGen.Next("codex:apikey", "sk-test", "https://example.com/v1") + auth := &coreauth.Auth{ + ID: authID, + Provider: "codex", + Attributes: map[string]string{ + "api_key": "sk-test", + "base_url": "https://example.com/v1", + "source": "config:codex[abc]", + }, + } + + handled, err := toggleConfigAPIKeyExcludedAll(cfg, auth, true) + if err != nil || !handled { + t.Fatalf("toggle disable: handled=%v err=%v", handled, err) + } + if len(cfg.CodexKey[0].ExcludedModels) != 1 || cfg.CodexKey[0].ExcludedModels[0] != "*" { + t.Fatalf("expected excluded-models [*], got %#v", cfg.CodexKey[0].ExcludedModels) + } + + handled, err = toggleConfigAPIKeyExcludedAll(cfg, auth, false) + if err != nil || !handled { + t.Fatalf("toggle enable: handled=%v err=%v", handled, err) + } + if len(cfg.CodexKey[0].ExcludedModels) != 0 { + t.Fatalf("expected excluded-models cleared, got %#v", cfg.CodexKey[0].ExcludedModels) + } +} diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 9f8a4c314..d9f7e24a3 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -4427,6 +4427,9 @@ func (m *Manager) persist(ctx context.Context, auth *Auth) error { if shouldSkipPersist(ctx) { return nil } + if IsConfigAPIKeyAuth(auth) { + return nil + } if auth.Attributes != nil { if v := strings.ToLower(strings.TrimSpace(auth.Attributes["runtime_only"])); v == "true" { return nil diff --git a/sdk/cliproxy/auth/config_apikey.go b/sdk/cliproxy/auth/config_apikey.go new file mode 100644 index 000000000..3e05c5b35 --- /dev/null +++ b/sdk/cliproxy/auth/config_apikey.go @@ -0,0 +1,14 @@ +package auth + +import "strings" + +// IsConfigAPIKeyAuth reports whether the auth entry is synthesized from config *-api-key lists. +func IsConfigAPIKeyAuth(auth *Auth) bool { + if auth == nil || auth.Attributes == nil { + return false + } + if strings.TrimSpace(auth.Attributes["api_key"]) == "" { + return false + } + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(auth.Attributes["source"])), "config:") +} diff --git a/sdk/cliproxy/auth/config_apikey_test.go b/sdk/cliproxy/auth/config_apikey_test.go new file mode 100644 index 000000000..680fc2370 --- /dev/null +++ b/sdk/cliproxy/auth/config_apikey_test.go @@ -0,0 +1,22 @@ +package auth + +import "testing" + +func TestIsConfigAPIKeyAuth(t *testing.T) { + if IsConfigAPIKeyAuth(nil) { + 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") + } + if !IsConfigAPIKeyAuth(&Auth{ + ID: "codex:apikey:abc", + Provider: "codex", + Attributes: map[string]string{ + "api_key": "k", + "source": "config:codex[abc]", + }, + }) { + t.Fatal("expected config api key auth") + } +} diff --git a/sdk/cliproxy/auth/persist_policy_test.go b/sdk/cliproxy/auth/persist_policy_test.go index 6ec4aaf2f..82eb0512f 100644 --- a/sdk/cliproxy/auth/persist_policy_test.go +++ b/sdk/cliproxy/auth/persist_policy_test.go @@ -67,3 +67,27 @@ func TestWithSkipPersist_DisablesRegisterPersistence(t *testing.T) { t.Fatalf("expected 0 Save calls, got %d", got) } } + +func TestPersist_SkipsConfigAPIKeyAuth(t *testing.T) { + store := &countingStore{} + mgr := NewManager(store, nil, nil) + auth := &Auth{ + ID: "codex:apikey:abc", + Provider: "codex", + Attributes: map[string]string{ + "api_key": "secret", + "source": "config:codex[abc]", + }, + Metadata: map[string]any{"disable_cooling": true}, + } + if _, err := mgr.Register(context.Background(), auth); err != nil { + t.Fatalf("Register returned error: %v", err) + } + if got := store.saveCount.Load(); got != 0 { + t.Fatalf("expected 0 Save calls for config api key, got %d", got) + } + mgr.MarkResult(context.Background(), Result{AuthID: auth.ID, Provider: "codex", Model: "gpt-5", Success: true}) + if got := store.saveCount.Load(); got != 0 { + t.Fatalf("expected MarkResult to skip persist for config api key, got %d Save calls", got) + } +} diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index f5abd389c..bb5f08f0d 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -327,22 +327,12 @@ func (s *Service) runModelRegistrationTaskPhase(ctx context.Context, tasks []mod } func modelRegistrationPhase(auth *coreauth.Auth) int { - if isConfigAPIKeyAuth(auth) { + if coreauth.IsConfigAPIKeyAuth(auth) { return modelRegistrationPhaseConfigAPIKey } return modelRegistrationPhaseOther } -func isConfigAPIKeyAuth(auth *coreauth.Auth) bool { - if auth == nil || auth.Attributes == nil { - return false - } - if strings.TrimSpace(auth.Attributes["api_key"]) == "" { - return false - } - return strings.HasPrefix(strings.ToLower(strings.TrimSpace(auth.Attributes["source"])), "config:") -} - func modelRegistrationCategory(auth *coreauth.Auth) string { if auth == nil { return "unknown" @@ -1199,7 +1189,7 @@ func (s *Service) applyConfigUpdate(newCfg *config.Config) { forceReplaceAuths: true, auths: auths, }) - ctx := context.Background() + ctx := coreauth.WithSkipPersist(context.Background()) s.registerConfigAPIKeyAuths(ctx, newCfg) s.syncPluginRuntime(ctx) } @@ -1224,7 +1214,7 @@ func (s *Service) registerConfigAPIKeyAuths(ctx context.Context, cfg *config.Con tasks := make([]modelRegistrationTask, 0, len(auths)) for _, auth := range auths { - if !isConfigAPIKeyAuth(auth) { + if !coreauth.IsConfigAPIKeyAuth(auth) { continue } prepared := s.prepareCoreAuthForModelRegistration(ctx, auth)