diff --git a/internal/auth/claude/token.go b/internal/auth/claude/token.go index 5e126864..b1ddddba 100644 --- a/internal/auth/claude/token.go +++ b/internal/auth/claude/token.go @@ -8,6 +8,8 @@ import ( "fmt" "os" "path/filepath" + + "github.com/luispater/CLIProxyAPI/v5/internal/misc" ) // ClaudeTokenStorage stores OAuth2 token information for Anthropic Claude API authentication. @@ -46,6 +48,7 @@ type ClaudeTokenStorage struct { // Returns: // - error: An error if the operation fails, nil otherwise func (ts *ClaudeTokenStorage) SaveTokenToFile(authFilePath string) error { + misc.LogSavingCredentials(authFilePath) ts.Type = "claude" // Create directory structure if it doesn't exist diff --git a/internal/auth/codex/token.go b/internal/auth/codex/token.go index 34021ca9..368b0a26 100644 --- a/internal/auth/codex/token.go +++ b/internal/auth/codex/token.go @@ -8,6 +8,8 @@ import ( "fmt" "os" "path/filepath" + + "github.com/luispater/CLIProxyAPI/v5/internal/misc" ) // CodexTokenStorage stores OAuth2 token information for OpenAI Codex API authentication. @@ -42,6 +44,7 @@ type CodexTokenStorage struct { // Returns: // - error: An error if the operation fails, nil otherwise func (ts *CodexTokenStorage) SaveTokenToFile(authFilePath string) error { + misc.LogSavingCredentials(authFilePath) ts.Type = "codex" if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil { return fmt.Errorf("failed to create directory: %v", err) diff --git a/internal/auth/gemini/gemini-web_token.go b/internal/auth/gemini/gemini-web_token.go index 3c6ebfe2..9ed535a7 100644 --- a/internal/auth/gemini/gemini-web_token.go +++ b/internal/auth/gemini/gemini-web_token.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" + "github.com/luispater/CLIProxyAPI/v5/internal/misc" log "github.com/sirupsen/logrus" ) @@ -21,6 +22,7 @@ type GeminiWebTokenStorage struct { // SaveTokenToFile serializes the Gemini Web token storage to a JSON file. func (ts *GeminiWebTokenStorage) SaveTokenToFile(authFilePath string) error { + misc.LogSavingCredentials(authFilePath) ts.Type = "gemini-web" if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil { return fmt.Errorf("failed to create directory: %v", err) diff --git a/internal/auth/gemini/gemini_token.go b/internal/auth/gemini/gemini_token.go index 67a51091..1630faa6 100644 --- a/internal/auth/gemini/gemini_token.go +++ b/internal/auth/gemini/gemini_token.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" + "github.com/luispater/CLIProxyAPI/v5/internal/misc" log "github.com/sirupsen/logrus" ) @@ -45,6 +46,7 @@ type GeminiTokenStorage struct { // Returns: // - error: An error if the operation fails, nil otherwise func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error { + misc.LogSavingCredentials(authFilePath) ts.Type = "gemini" if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil { return fmt.Errorf("failed to create directory: %v", err) diff --git a/internal/auth/qwen/qwen_token.go b/internal/auth/qwen/qwen_token.go index 726938dc..076cca8c 100644 --- a/internal/auth/qwen/qwen_token.go +++ b/internal/auth/qwen/qwen_token.go @@ -8,6 +8,8 @@ import ( "fmt" "os" "path/filepath" + + "github.com/luispater/CLIProxyAPI/v5/internal/misc" ) // QwenTokenStorage stores OAuth2 token information for Alibaba Qwen API authentication. @@ -40,6 +42,7 @@ type QwenTokenStorage struct { // Returns: // - error: An error if the operation fails, nil otherwise func (ts *QwenTokenStorage) SaveTokenToFile(authFilePath string) error { + misc.LogSavingCredentials(authFilePath) ts.Type = "qwen" if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil { return fmt.Errorf("failed to create directory: %v", err) diff --git a/internal/client/gemini-cli_client.go b/internal/client/gemini-cli_client.go index e058a00e..c2b48683 100644 --- a/internal/client/gemini-cli_client.go +++ b/internal/client/gemini-cli_client.go @@ -831,7 +831,6 @@ func (c *GeminiCLIClient) GetProjectList(ctx context.Context) (*interfaces.GCPPr // - error: An error if the save operation fails, nil otherwise. func (c *GeminiCLIClient) SaveTokenToFile() error { fileName := filepath.Join(c.cfg.AuthDir, fmt.Sprintf("%s-%s.json", c.tokenStorage.(*geminiAuth.GeminiTokenStorage).Email, c.tokenStorage.(*geminiAuth.GeminiTokenStorage).ProjectID)) - log.Infof("Saving credentials to %s", fileName) return c.tokenStorage.SaveTokenToFile(fileName) } diff --git a/internal/client/gemini-web_client.go b/internal/client/gemini-web_client.go index ebbf0c5c..5c76918a 100644 --- a/internal/client/gemini-web_client.go +++ b/internal/client/gemini-web_client.go @@ -842,7 +842,6 @@ func (c *GeminiWebClient) SaveTokenToFile() error { } return ts.SaveTokenToFile(c.tokenFilePath) } - log.Debugf("Saving Gemini Web cookie snapshot to %s", filepath.Base(util.CookieSnapshotPath(c.tokenFilePath))) return c.snapshotManager.Persist() } diff --git a/internal/misc/credentials.go b/internal/misc/credentials.go new file mode 100644 index 00000000..f7e8119c --- /dev/null +++ b/internal/misc/credentials.go @@ -0,0 +1,16 @@ +package misc + +import ( + "path/filepath" + + log "github.com/sirupsen/logrus" +) + +// LogSavingCredentials emits a consistent log message when persisting auth material. +func LogSavingCredentials(path string) { + if path == "" { + return + } + // Use filepath.Clean so logs remain stable even if callers pass redundant separators. + log.Infof("Saving credentials to %s", filepath.Clean(path)) +} diff --git a/internal/util/cookie_snapshot.go b/internal/util/cookie_snapshot.go index c1b00638..9a049c59 100644 --- a/internal/util/cookie_snapshot.go +++ b/internal/util/cookie_snapshot.go @@ -6,15 +6,19 @@ import ( "os" "path/filepath" "strings" + + "github.com/luispater/CLIProxyAPI/v5/internal/misc" ) +const cookieSnapshotExt = ".cookie" + // CookieSnapshotPath derives the cookie snapshot file path from the main token JSON path. -// It replaces the .json suffix with .cookies, or appends .cookies if missing. +// It replaces the .json suffix with .cookie, or appends .cookie if missing. func CookieSnapshotPath(mainPath string) string { if strings.HasSuffix(mainPath, ".json") { - return strings.TrimSuffix(mainPath, ".json") + ".cookies" + return strings.TrimSuffix(mainPath, ".json") + cookieSnapshotExt } - return mainPath + ".cookies" + return mainPath + cookieSnapshotExt } // IsRegularFile reports whether the given path exists and is a regular file. @@ -66,9 +70,8 @@ func RemoveFile(path string) error { return nil } -// TryReadCookieSnapshotInto tries to read a cookie snapshot into v. -// It attempts the .cookies suffix; returns (true, nil) when found and decoded, -// or (false, nil) when none exists. +// TryReadCookieSnapshotInto tries to read a cookie snapshot into v using the .cookie suffix. +// Returns (true, nil) when a snapshot was decoded, or (false, nil) when none exists. func TryReadCookieSnapshotInto(mainPath string, v any) (bool, error) { snap := CookieSnapshotPath(mainPath) if err := ReadJSON(snap, v); err != nil { @@ -80,13 +83,20 @@ func TryReadCookieSnapshotInto(mainPath string, v any) (bool, error) { return true, nil } -// WriteCookieSnapshot writes v to the snapshot path derived from mainPath using the .cookies suffix. +// WriteCookieSnapshot writes v to the snapshot path derived from mainPath using the .cookie suffix. func WriteCookieSnapshot(mainPath string, v any) error { - return WriteJSON(CookieSnapshotPath(mainPath), v) + path := CookieSnapshotPath(mainPath) + misc.LogSavingCredentials(path) + if err := WriteJSON(path, v); err != nil { + return err + } + return nil } -// RemoveCookieSnapshots removes both modern and legacy snapshot files. -func RemoveCookieSnapshots(mainPath string) { _ = RemoveFile(CookieSnapshotPath(mainPath)) } +// RemoveCookieSnapshots removes the snapshot file if it exists. +func RemoveCookieSnapshots(mainPath string) { + _ = RemoveFile(CookieSnapshotPath(mainPath)) +} // Hooks provide customization points for snapshot lifecycle operations. type Hooks[T any] struct { diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 44f72e2f..a4393bb7 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -9,6 +9,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "io/fs" "net/http" "os" @@ -141,7 +142,7 @@ func (w *Watcher) handleEvent(event fsnotify.Event) { isConfigEvent := event.Name == w.configPath && (event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create) isAuthJSON := strings.HasPrefix(event.Name, w.authDir) && strings.HasSuffix(event.Name, ".json") if !isConfigEvent && !isAuthJSON { - // Ignore unrelated files (e.g., cookie snapshots *.cookies) and other noise. + // Ignore unrelated files (e.g., cookie snapshots *.cookie) and other noise. return } @@ -417,18 +418,40 @@ func (w *Watcher) clientsToSlice(clientMap map[string]interfaces.Client) []inter // readAuthFileWithRetry attempts to read the auth file multiple times to work around // short-lived locks on Windows while token files are being written. func readAuthFileWithRetry(path string, attempts int, delay time.Duration) ([]byte, error) { - var lastErr error - for i := 0; i < attempts; i++ { - data, err := os.ReadFile(path) + read := func(target string) ([]byte, error) { + var lastErr error + for i := 0; i < attempts; i++ { + data, err := os.ReadFile(target) + if err == nil { + return data, nil + } + lastErr = err + if i < attempts-1 { + time.Sleep(delay) + } + } + return nil, lastErr + } + + candidates := []string{ + util.CookieSnapshotPath(path), + path, + } + + for idx, candidate := range candidates { + data, err := read(candidate) if err == nil { return data, nil } - lastErr = err - if i < attempts-1 { - time.Sleep(delay) + if errors.Is(err, os.ErrNotExist) { + if idx < len(candidates)-1 { + continue + } } + return nil, err } - return nil, lastErr + + return nil, os.ErrNotExist } // addOrUpdateClient handles the addition or update of a single client.