mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-06-20 21:06:01 +08:00
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:
@@ -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 {
|
||||
|
||||
78
internal/api/handlers/management/config_apikey_disable.go
Normal file
78
internal/api/handlers/management/config_apikey_disable.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
14
sdk/cliproxy/auth/config_apikey.go
Normal file
14
sdk/cliproxy/auth/config_apikey.go
Normal 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:")
|
||||
}
|
||||
22
sdk/cliproxy/auth/config_apikey_test.go
Normal file
22
sdk/cliproxy/auth/config_apikey_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user