From b56edd4db03563c50c7d20bfe8b67a8c41e895fd Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sat, 27 Sep 2025 08:23:24 +0800 Subject: [PATCH 1/7] refactor(access): Introduce ApplyAccessProviders helper function The logic for reconciling access providers, updating the manager, and logging the changes was previously handled directly in the service layer. This commit introduces a new `ApplyAccessProviders` helper function in the `internal/access` package to encapsulate this entire process. The service layer is updated to use this new helper, which simplifies its implementation and reduces code duplication. This refactoring centralizes the provider update logic and improves overall code maintainability. Additionally, the `sdk/access` package import is now aliased to `sdkaccess` for clarity. --- internal/access/reconcile.go | 45 +++++++++++++++++++++++++++++------- internal/api/server.go | 12 +--------- sdk/cliproxy/service.go | 12 +--------- 3 files changed, 39 insertions(+), 30 deletions(-) diff --git a/internal/access/reconcile.go b/internal/access/reconcile.go index d279dc95..95026e57 100644 --- a/internal/access/reconcile.go +++ b/internal/access/reconcile.go @@ -1,25 +1,27 @@ package access import ( + "fmt" "reflect" "sort" "strings" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" + sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" sdkConfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + log "github.com/sirupsen/logrus" ) // ReconcileProviders builds the desired provider list by reusing existing providers when possible // and creating or removing providers only when their configuration changed. It returns the final // ordered provider slice along with the identifiers of providers that were added, updated, or // removed compared to the previous configuration. -func ReconcileProviders(oldCfg, newCfg *config.Config, existing []access.Provider) (result []access.Provider, added, updated, removed []string, err error) { +func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Provider) (result []sdkaccess.Provider, added, updated, removed []string, err error) { if newCfg == nil { return nil, nil, nil, nil, nil } - existingMap := make(map[string]access.Provider, len(existing)) + existingMap := make(map[string]sdkaccess.Provider, len(existing)) for _, provider := range existing { if provider == nil { continue @@ -30,7 +32,7 @@ func ReconcileProviders(oldCfg, newCfg *config.Config, existing []access.Provide oldCfgMap := accessProviderMap(oldCfg) newEntries := collectProviderEntries(newCfg) - result = make([]access.Provider, 0, len(newEntries)) + result = make([]sdkaccess.Provider, 0, len(newEntries)) finalIDs := make(map[string]struct{}, len(newEntries)) isInlineProvider := func(id string) bool { @@ -60,7 +62,7 @@ func ReconcileProviders(oldCfg, newCfg *config.Config, existing []access.Provide } } - provider, buildErr := access.BuildProvider(providerCfg, &newCfg.SDKConfig) + provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig) if buildErr != nil { return nil, nil, nil, nil, buildErr } @@ -88,7 +90,7 @@ func ReconcileProviders(oldCfg, newCfg *config.Config, existing []access.Provide if existingProvider, okExisting := existingMap[key]; okExisting { result = append(result, existingProvider) } else { - provider, buildErr := access.BuildProvider(providerCfg, &newCfg.SDKConfig) + provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig) if buildErr != nil { return nil, nil, nil, nil, buildErr } @@ -100,7 +102,7 @@ func ReconcileProviders(oldCfg, newCfg *config.Config, existing []access.Provide result = append(result, provider) } } else { - provider, buildErr := access.BuildProvider(providerCfg, &newCfg.SDKConfig) + provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig) if buildErr != nil { return nil, nil, nil, nil, buildErr } @@ -112,7 +114,7 @@ func ReconcileProviders(oldCfg, newCfg *config.Config, existing []access.Provide result = append(result, provider) } } else { - provider, buildErr := access.BuildProvider(providerCfg, &newCfg.SDKConfig) + provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig) if buildErr != nil { return nil, nil, nil, nil, buildErr } @@ -146,6 +148,33 @@ func ReconcileProviders(oldCfg, newCfg *config.Config, existing []access.Provide return result, added, updated, removed, nil } +// ApplyAccessProviders reconciles the configured access providers against the +// currently registered providers and updates the manager. It logs a concise +// summary of the detected changes and returns whether any provider changed. +func ApplyAccessProviders(manager *sdkaccess.Manager, oldCfg, newCfg *config.Config) (bool, error) { + if manager == nil || newCfg == nil { + return false, nil + } + + existing := manager.Providers() + providers, added, updated, removed, err := ReconcileProviders(oldCfg, newCfg, existing) + if err != nil { + log.Errorf("failed to reconcile request auth providers: %v", err) + return false, fmt.Errorf("reconciling access providers: %w", err) + } + + manager.SetProviders(providers) + + if len(added)+len(updated)+len(removed) > 0 { + log.Debugf("auth providers reconciled (added=%d updated=%d removed=%d)", len(added), len(updated), len(removed)) + log.Debugf("auth provider changes details - added=%v updated=%v removed=%v", added, updated, removed) + return true, nil + } + + // log.Debug("auth providers unchanged after config update") + return false, nil +} + func accessProviderMap(cfg *config.Config) map[string]*sdkConfig.AccessProvider { result := make(map[string]*sdkConfig.AccessProvider) if cfg == nil { diff --git a/internal/api/server.go b/internal/api/server.go index 7f09a037..5212be56 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -552,19 +552,9 @@ func (s *Server) applyAccessConfig(oldCfg, newCfg *config.Config) { if s == nil || s.accessManager == nil || newCfg == nil { return } - existing := s.accessManager.Providers() - providers, added, updated, removed, err := access.ReconcileProviders(oldCfg, newCfg, existing) - if err != nil { - log.Errorf("failed to reconcile request auth providers: %v", err) + if _, err := access.ApplyAccessProviders(s.accessManager, oldCfg, newCfg); err != nil { return } - s.accessManager.SetProviders(providers) - if len(added)+len(updated)+len(removed) > 0 { - log.Debugf("auth providers reconciled (added=%d updated=%d removed=%d)", len(added), len(updated), len(removed)) - log.Debugf("auth provider changes details - added=%v updated=%v removed=%v", added, updated, removed) - } else { - log.Debug("auth providers unchanged after config update") - } } // UpdateClients updates the server's client list and configuration. diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index b779f728..a92bf1fc 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -115,19 +115,9 @@ func (s *Service) refreshAccessProviders(cfg *config.Config) { oldCfg := s.cfg s.cfgMu.RUnlock() - existing := s.accessManager.Providers() - providers, added, updated, removed, err := access.ReconcileProviders(oldCfg, cfg, existing) - if err != nil { - log.Errorf("failed to reconcile request auth providers: %v", err) + if _, err := access.ApplyAccessProviders(s.accessManager, oldCfg, cfg); err != nil { return } - s.accessManager.SetProviders(providers) - if len(added)+len(updated)+len(removed) > 0 { - log.Debugf("auth providers reconciled (added=%d updated=%d removed=%d)", len(added), len(updated), len(removed)) - log.Debugf("auth provider changes details - added=%v updated=%v removed=%v", added, updated, removed) - } else { - log.Debug("auth providers unchanged after config reload") - } } func (s *Service) ensureAuthUpdateQueue(ctx context.Context) { From afff9216ea4aa9add38329597483e46718c03c15 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sat, 27 Sep 2025 08:43:06 +0800 Subject: [PATCH 2/7] perf(watcher): Avoid unnecessary auth dir scan on config reload --- internal/watcher/watcher.go | 60 +++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index cf8bd2bf..2fa7da20 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -145,7 +145,7 @@ func (w *Watcher) Start(ctx context.Context) error { go w.processEvents(ctx) // Perform an initial full reload based on current config and auth dir - w.reloadClients() + w.reloadClients(true) return nil } @@ -532,14 +532,16 @@ func (w *Watcher) reloadConfig() bool { } } + authDirChanged := oldConfig == nil || oldConfig.AuthDir != newConfig.AuthDir + log.Infof("config successfully reloaded, triggering client reload") // Reload clients with new config - w.reloadClients() + w.reloadClients(authDirChanged) return true } // reloadClients performs a full scan and reload of all clients. -func (w *Watcher) reloadClients() { +func (w *Watcher) reloadClients(rescanAuth bool) { log.Debugf("starting full client reload process") w.clientsMutex.RLock() @@ -556,33 +558,45 @@ func (w *Watcher) reloadClients() { // Create new API key clients based on the new config glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount := BuildAPIKeyClients(cfg) - log.Debugf("created %d new API key clients", 0) + totalAPIKeyClients := glAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount + log.Debugf("created %d new API key clients", totalAPIKeyClients) - // Load file-based clients - authFileCount := w.loadFileClients(cfg) - log.Debugf("loaded %d new file-based clients", 0) + var authFileCount int + if rescanAuth { + // Load file-based clients when explicitly requested (startup or authDir change) + authFileCount = w.loadFileClients(cfg) + log.Debugf("loaded %d new file-based clients", authFileCount) + } else { + // Preserve existing auth hashes and only report current known count to avoid redundant scans. + w.clientsMutex.RLock() + authFileCount = len(w.lastAuthHashes) + w.clientsMutex.RUnlock() + log.Debugf("skipping auth directory rescan; retaining %d existing auth files", authFileCount) + } // no legacy file-based clients to unregister // Update client maps - w.clientsMutex.Lock() + if rescanAuth { + w.clientsMutex.Lock() - // Rebuild auth file hash cache for current clients - w.lastAuthHashes = make(map[string]string) - // Recompute hashes for current auth files - _ = filepath.Walk(cfg.AuthDir, func(path string, info fs.FileInfo, err error) error { - if err != nil { - return nil - } - if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".json") { - if data, errReadFile := os.ReadFile(path); errReadFile == nil && len(data) > 0 { - sum := sha256.Sum256(data) - w.lastAuthHashes[path] = hex.EncodeToString(sum[:]) + // Rebuild auth file hash cache for current clients + w.lastAuthHashes = make(map[string]string) + // Recompute hashes for current auth files + _ = filepath.Walk(cfg.AuthDir, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return nil } - } - return nil - }) - w.clientsMutex.Unlock() + if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".json") { + if data, errReadFile := os.ReadFile(path); errReadFile == nil && len(data) > 0 { + sum := sha256.Sum256(data) + w.lastAuthHashes[path] = hex.EncodeToString(sum[:]) + } + } + return nil + }) + w.clientsMutex.Unlock() + } totalNewClients := authFileCount + glAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount From 6136a77eb3c43021f2171d32d5ffadb3cca47156 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sat, 27 Sep 2025 09:06:51 +0800 Subject: [PATCH 3/7] refactor(util): Centralize auth directory path resolution Introduces a new utility function, `util.ResolveAuthDir`, to handle the normalization and resolution of the authentication directory path. Previously, the logic for expanding the tilde (~) to the user's home directory was implemented inline in `main.go`. This refactoring extracts that logic into a reusable function within the `util` package. The new `ResolveAuthDir` function is now used consistently across the application: - During initial server startup in `main.go`. - When counting authentication files in `util.CountAuthFiles`. - When the configuration is reloaded by the watcher. This change eliminates code duplication, improves consistency, and makes the path resolution logic more robust and maintainable. --- cmd/server/main.go | 21 ++++------------ internal/util/util.go | 38 ++++++++++++++++++++++------- internal/watcher/watcher.go | 48 +++++++++++++++++++++---------------- 3 files changed, 61 insertions(+), 46 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 7c5d35ac..e5df3fec 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -8,7 +8,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "github.com/router-for-me/CLIProxyAPI/v6/internal/cmd" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" @@ -123,22 +122,10 @@ func main() { // Set the log level based on the configuration. util.SetLogLevel(cfg) - // Expand the tilde (~) in the auth directory path to the user's home directory. - if strings.HasPrefix(cfg.AuthDir, "~") { - home, errUserHomeDir := os.UserHomeDir() - if errUserHomeDir != nil { - log.Fatalf("failed to get home directory: %v", errUserHomeDir) - } - // Reconstruct the path by replacing the tilde with the user's home directory. - remainder := strings.TrimPrefix(cfg.AuthDir, "~") - remainder = strings.TrimLeft(remainder, "/\\") - if remainder == "" { - cfg.AuthDir = home - } else { - // Normalize any slash style in the remainder so Windows paths keep nested directories. - normalized := strings.ReplaceAll(remainder, "\\", "/") - cfg.AuthDir = filepath.Join(home, filepath.FromSlash(normalized)) - } + if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil { + log.Fatalf("failed to resolve auth directory: %v", errResolveAuthDir) + } else { + cfg.AuthDir = resolvedAuthDir } // Create login options to be used in authentication flows. diff --git a/internal/util/util.go b/internal/util/util.go index bad67aae..d14f1637 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -4,6 +4,7 @@ package util import ( + "fmt" "io/fs" "os" "path/filepath" @@ -30,23 +31,42 @@ func SetLogLevel(cfg *config.Config) { } } -// CountAuthFiles returns the number of JSON auth files located under the provided directory. -// The function resolves leading tildes to the user's home directory and performs a case-insensitive -// match on the ".json" suffix so that files saved with uppercase extensions are also counted. -func CountAuthFiles(authDir string) int { +// ResolveAuthDir normalizes the auth directory path for consistent reuse throughout the app. +// It expands a leading tilde (~) to the user's home directory and returns a cleaned path. +func ResolveAuthDir(authDir string) (string, error) { if authDir == "" { - return 0 + return "", nil } if strings.HasPrefix(authDir, "~") { home, err := os.UserHomeDir() if err != nil { - log.Debugf("countAuthFiles: failed to resolve home directory: %v", err) - return 0 + return "", fmt.Errorf("resolve auth dir: %w", err) } - authDir = filepath.Join(home, authDir[1:]) + remainder := strings.TrimPrefix(authDir, "~") + remainder = strings.TrimLeft(remainder, "/\\") + if remainder == "" { + return filepath.Clean(home), nil + } + normalized := strings.ReplaceAll(remainder, "\\", "/") + return filepath.Clean(filepath.Join(home, filepath.FromSlash(normalized))), nil + } + return filepath.Clean(authDir), nil +} + +// CountAuthFiles returns the number of JSON auth files located under the provided directory. +// The function resolves leading tildes to the user's home directory and performs a case-insensitive +// match on the ".json" suffix so that files saved with uppercase extensions are also counted. +func CountAuthFiles(authDir string) int { + dir, err := ResolveAuthDir(authDir) + if err != nil { + log.Debugf("countAuthFiles: failed to resolve auth directory: %v", err) + return 0 + } + if dir == "" { + return 0 } count := 0 - walkErr := filepath.WalkDir(authDir, func(path string, d fs.DirEntry, err error) error { + walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { log.Debugf("countAuthFiles: error accessing %s: %v", path, err) return nil diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 2fa7da20..874ed1ce 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -463,6 +463,12 @@ func (w *Watcher) reloadConfig() bool { return false } + if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(newConfig.AuthDir); errResolveAuthDir != nil { + log.Errorf("failed to resolve auth directory from config: %v", errResolveAuthDir) + } else { + newConfig.AuthDir = resolvedAuthDir + } + w.clientsMutex.Lock() oldConfig := w.config w.config = newConfig @@ -582,19 +588,22 @@ func (w *Watcher) reloadClients(rescanAuth bool) { // Rebuild auth file hash cache for current clients w.lastAuthHashes = make(map[string]string) - // Recompute hashes for current auth files - _ = filepath.Walk(cfg.AuthDir, func(path string, info fs.FileInfo, err error) error { - if err != nil { - return nil - } - if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".json") { - if data, errReadFile := os.ReadFile(path); errReadFile == nil && len(data) > 0 { - sum := sha256.Sum256(data) - w.lastAuthHashes[path] = hex.EncodeToString(sum[:]) + if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil { + log.Errorf("failed to resolve auth directory for hash cache: %v", errResolveAuthDir) + } else if resolvedAuthDir != "" { + _ = filepath.Walk(resolvedAuthDir, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return nil } - } - return nil - }) + if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".json") { + if data, errReadFile := os.ReadFile(path); errReadFile == nil && len(data) > 0 { + sum := sha256.Sum256(data) + w.lastAuthHashes[path] = hex.EncodeToString(sum[:]) + } + } + return nil + }) + } w.clientsMutex.Unlock() } @@ -869,14 +878,13 @@ func (w *Watcher) loadFileClients(cfg *config.Config) int { authFileCount := 0 successfulAuthCount := 0 - authDir := cfg.AuthDir - if strings.HasPrefix(authDir, "~") { - home, err := os.UserHomeDir() - if err != nil { - log.Errorf("failed to get home directory: %v", err) - return 0 - } - authDir = filepath.Join(home, authDir[1:]) + authDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir) + if errResolveAuthDir != nil { + log.Errorf("failed to resolve auth directory: %v", errResolveAuthDir) + return 0 + } + if authDir == "" { + return 0 } errWalk := filepath.Walk(authDir, func(path string, info fs.FileInfo, err error) error { From 562a49a194724e810ef33880d1ebb5f51f6dd81c Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sat, 27 Sep 2025 10:56:15 +0800 Subject: [PATCH 4/7] feat(provider/gemini-web): Prioritize explicit label for account identification --- internal/provider/gemini-web/state.go | 13 +++++++++++-- internal/runtime/executor/gemini_web_executor.go | 3 ++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/provider/gemini-web/state.go b/internal/provider/gemini-web/state.go index 37828dcb..e0044984 100644 --- a/internal/provider/gemini-web/state.go +++ b/internal/provider/gemini-web/state.go @@ -88,6 +88,11 @@ func (s *GeminiWebState) Label() string { if s == nil { return "" } + if s.token != nil { + if lbl := strings.TrimSpace(s.token.Label); lbl != "" { + return lbl + } + } if s.storagePath != "" { base := strings.TrimSuffix(filepath.Base(s.storagePath), filepath.Ext(s.storagePath)) if base != "" { @@ -169,8 +174,12 @@ func (s *GeminiWebState) Refresh(ctx context.Context) error { s.client.Cookies["__Secure-1PSIDTS"] = newTS } s.tokenMu.Unlock() - // Detailed debug log: provider and account. - log.Debugf("gemini web account %s rotated 1PSIDTS: %s", s.accountID, MaskToken28(newTS)) + // Detailed debug log: provider and account label. + label := strings.TrimSpace(s.Label()) + if label == "" { + label = s.accountID + } + log.Debugf("gemini web account %s rotated 1PSIDTS: %s", label, MaskToken28(newTS)) } s.lastRefresh = time.Now() return nil diff --git a/internal/runtime/executor/gemini_web_executor.go b/internal/runtime/executor/gemini_web_executor.go index 78f31abb..f026299c 100644 --- a/internal/runtime/executor/gemini_web_executor.go +++ b/internal/runtime/executor/gemini_web_executor.go @@ -200,7 +200,8 @@ func parseGeminiWebToken(auth *cliproxyauth.Auth) (*gemini.GeminiWebTokenStorage if psid == "" || psidts == "" { return nil, fmt.Errorf("gemini-web executor: incomplete cookie metadata") } - return &gemini.GeminiWebTokenStorage{Secure1PSID: psid, Secure1PSIDTS: psidts}, nil + label := strings.TrimSpace(stringFromMetadata(auth.Metadata, "label")) + return &gemini.GeminiWebTokenStorage{Secure1PSID: psid, Secure1PSIDTS: psidts, Label: label}, nil } func stringFromMetadata(meta map[string]any, keys ...string) string { From 88f06fc30532b753351df03e4c084794e99fa946 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sat, 27 Sep 2025 11:15:30 +0800 Subject: [PATCH 5/7] feat(watcher): Log detailed diff for openai-compatibility on reload --- internal/watcher/watcher.go | 118 ++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 874ed1ce..00fee2b3 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "reflect" + "sort" "strings" "sync" "time" @@ -536,6 +537,12 @@ func (w *Watcher) reloadConfig() bool { if oldConfig.UsageStatisticsEnabled != newConfig.UsageStatisticsEnabled { log.Debugf(" usage-statistics-enabled: %t -> %t", oldConfig.UsageStatisticsEnabled, newConfig.UsageStatisticsEnabled) } + if changes := diffOpenAICompatibility(oldConfig.OpenAICompatibility, newConfig.OpenAICompatibility); len(changes) > 0 { + log.Debugf(" openai-compatibility:") + for _, change := range changes { + log.Debugf(" %s", change) + } + } } authDirChanged := oldConfig == nil || oldConfig.AuthDir != newConfig.AuthDir @@ -934,3 +941,114 @@ func BuildAPIKeyClients(cfg *config.Config) (int, int, int, int) { } return glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount } + +func diffOpenAICompatibility(oldList, newList []config.OpenAICompatibility) []string { + changes := make([]string, 0) + oldMap := make(map[string]config.OpenAICompatibility, len(oldList)) + oldLabels := make(map[string]string, len(oldList)) + for idx, entry := range oldList { + key, label := openAICompatKey(entry, idx) + oldMap[key] = entry + oldLabels[key] = label + } + newMap := make(map[string]config.OpenAICompatibility, len(newList)) + newLabels := make(map[string]string, len(newList)) + for idx, entry := range newList { + key, label := openAICompatKey(entry, idx) + newMap[key] = entry + newLabels[key] = label + } + keySet := make(map[string]struct{}, len(oldMap)+len(newMap)) + for key := range oldMap { + keySet[key] = struct{}{} + } + for key := range newMap { + keySet[key] = struct{}{} + } + orderedKeys := make([]string, 0, len(keySet)) + for key := range keySet { + orderedKeys = append(orderedKeys, key) + } + sort.Strings(orderedKeys) + for _, key := range orderedKeys { + oldEntry, oldOk := oldMap[key] + newEntry, newOk := newMap[key] + label := oldLabels[key] + if label == "" { + label = newLabels[key] + } + switch { + case !oldOk: + changes = append(changes, fmt.Sprintf("provider added: %s (api-keys=%d, models=%d)", label, countNonEmptyStrings(newEntry.APIKeys), countOpenAIModels(newEntry.Models))) + case !newOk: + changes = append(changes, fmt.Sprintf("provider removed: %s (api-keys=%d, models=%d)", label, countNonEmptyStrings(oldEntry.APIKeys), countOpenAIModels(oldEntry.Models))) + default: + if detail := describeOpenAICompatibilityUpdate(oldEntry, newEntry); detail != "" { + changes = append(changes, fmt.Sprintf("provider updated: %s %s", label, detail)) + } + } + } + return changes +} + +func describeOpenAICompatibilityUpdate(oldEntry, newEntry config.OpenAICompatibility) string { + oldKeyCount := countNonEmptyStrings(oldEntry.APIKeys) + newKeyCount := countNonEmptyStrings(newEntry.APIKeys) + oldModelCount := countOpenAIModels(oldEntry.Models) + newModelCount := countOpenAIModels(newEntry.Models) + details := make([]string, 0, 2) + if oldKeyCount != newKeyCount { + details = append(details, fmt.Sprintf("api-keys %d -> %d", oldKeyCount, newKeyCount)) + } + if oldModelCount != newModelCount { + details = append(details, fmt.Sprintf("models %d -> %d", oldModelCount, newModelCount)) + } + if len(details) == 0 { + return "" + } + return "(" + strings.Join(details, ", ") + ")" +} + +func countNonEmptyStrings(values []string) int { + count := 0 + for _, value := range values { + if strings.TrimSpace(value) != "" { + count++ + } + } + return count +} + +func countOpenAIModels(models []config.OpenAICompatibilityModel) int { + count := 0 + for _, model := range models { + name := strings.TrimSpace(model.Name) + alias := strings.TrimSpace(model.Alias) + if name == "" && alias == "" { + continue + } + count++ + } + return count +} + +func openAICompatKey(entry config.OpenAICompatibility, index int) (string, string) { + name := strings.TrimSpace(entry.Name) + if name != "" { + return "name:" + name, name + } + base := strings.TrimSpace(entry.BaseURL) + if base != "" { + return "base:" + base, base + } + for _, model := range entry.Models { + alias := strings.TrimSpace(model.Alias) + if alias == "" { + alias = strings.TrimSpace(model.Name) + } + if alias != "" { + return "alias:" + alias, alias + } + } + return fmt.Sprintf("index:%d", index), fmt.Sprintf("entry-%d", index+1) +} From f9a170a3c4c7ad577d8754851be88d451766c748 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sat, 27 Sep 2025 11:25:40 +0800 Subject: [PATCH 6/7] chore(watcher): Clarify API key client reload log message --- internal/watcher/watcher.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 00fee2b3..b13f4679 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -572,7 +572,7 @@ func (w *Watcher) reloadClients(rescanAuth bool) { // Create new API key clients based on the new config glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount := BuildAPIKeyClients(cfg) totalAPIKeyClients := glAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount - log.Debugf("created %d new API key clients", totalAPIKeyClients) + log.Debugf("loaded %d API key clients", totalAPIKeyClients) var authFileCount int if rescanAuth { From da72ac1f6dc24260e4ea5c6427bf0ed9ee963f47 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sat, 27 Sep 2025 12:23:20 +0800 Subject: [PATCH 7/7] fix(config): Inline SDKConfig for proper YAML parsing --- internal/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index 97f056c1..f9d05e3b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,7 +15,7 @@ import ( // Config represents the application's configuration, loaded from a YAML file. type Config struct { - config.SDKConfig + config.SDKConfig `yaml:",inline"` // Port is the network port on which the API server will listen. Port int `yaml:"port" json:"-"`