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/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/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:"-"` 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 { 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 cf8bd2bf..b13f4679 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "reflect" + "sort" "strings" "sync" "time" @@ -145,7 +146,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 } @@ -463,6 +464,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 @@ -530,16 +537,24 @@ 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 + 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 +571,48 @@ 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("loaded %d 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 + // Rebuild auth file hash cache for current clients + w.lastAuthHashes = make(map[string]string) + 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 + } + 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 + }) } - 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() + w.clientsMutex.Unlock() + } totalNewClients := authFileCount + glAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount @@ -855,14 +885,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 { @@ -912,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) +} 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) {