feat(auth): add config API key exclusion management with tests

- Implemented helper methods `IsConfigAPIKeyAuth` and `toggleConfigAPIKeyExcludedAll` for managing config API key exclusions.
- Updated API request handling to support enabling/disabling config API key exclusion patterns.
- Added test coverage to validate exclusion toggling logic and persistence behavior.
- Refactored duplicate code for identifying config API key auth entries into reusable utilities.
This commit is contained in:
Luis Pater
2026-06-15 11:14:05 +08:00
parent f33bc56bb9
commit f85768eef3
8 changed files with 234 additions and 13 deletions

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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:")
}

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View File

@@ -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)