Compare commits

...

22 Commits

Author SHA1 Message Date
Luis Pater
b07ed71de2 Merge pull request #51 from router-for-me/gemini-web
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
feat(gemini-web): Add support for real Nano Banana model
2025-09-20 14:26:03 +08:00
hkfires
deaa64b080 feat(gemini-web): Add support for real Nano Banana model 2025-09-20 13:35:27 +08:00
Luis Pater
d42384cdb7 Merge branch 'dev'
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
2025-09-20 01:38:36 +08:00
Luis Pater
24f243a1bc feat: add support for Gemini 2.5 Flash image preview alias
- Introduced `gemini-2.5-flash-image-preview` alias in `GeminiWebAliasMap` for enhanced model handling.
- Added `gemini-2.5-flash-image-preview` as a new model variant with custom ID, name, display name, and description.
2025-09-20 01:37:42 +08:00
Luis Pater
67a4fe703c Merge branch 'dev'
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
2025-09-20 00:33:36 +08:00
Luis Pater
c16a989287 Merge pull request #50 from router-for-me/auth-dev 2025-09-20 00:31:08 +08:00
Luis Pater
bc376ad419 Merge pull request #49 from router-for-me/gemini-web 2025-09-20 00:28:46 +08:00
hkfires
aba719f5fe refactor(auth): Centralize auth file reading with snapshot preference
The logic for reading authentication files, which includes retries and a preference for cookie snapshot files, was previously implemented locally within the `watcher` package. This was done to handle potential file locks during writes.

This change moves this functionality into a shared `ReadAuthFileWithRetry` function in the `util` package to promote code reuse and consistency.

The `watcher` package is updated to use this new centralized function. Additionally, the initial token loading in the `run` command now also uses this logic, making it more resilient to file access issues and consistent with the watcher's behavior.
2025-09-20 00:14:26 +08:00
hkfires
1d7abc95b8 fix(gemini-web): ensure colon spacing in JSON output for compatibility 2025-09-19 23:32:52 +08:00
hkfires
1dccdb7ff2 Merge branch 'cookie_snapshot' into dev 2025-09-19 12:40:59 +08:00
hkfires
395164e2d4 feat(log): Add separator when saving client credentials 2025-09-19 12:36:17 +08:00
Luis Pater
b449d17124 Merge pull request #47 from router-for-me/dev
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
2025-09-19 12:03:30 +08:00
Luis Pater
6ad5e0709c Merge pull request #46 from router-for-me/cookie_snapshot 2025-09-19 12:01:21 +08:00
hkfires
4bfafbe3aa refactor(watcher): Move API key client creation to watcher package 2025-09-19 11:46:17 +08:00
hkfires
2274d7488b refactor(auth): Centralize logging for saving credentials
The logic for logging the path where credentials are saved was duplicated across several client implementations.

This commit refactors this behavior by creating a new centralized function, `misc.LogSavingCredentials`, to handle this logging. The `SaveTokenToFile` method in each authentication token storage struct now calls this new function, ensuring consistent logging and reducing code duplication.

The redundant logging statements in the client-level `SaveTokenToFile` methods have been removed.
2025-09-19 11:46:17 +08:00
hkfires
39518ec633 refactor(client): Improve auth file handling and client lifecycle 2025-09-19 11:46:17 +08:00
hkfires
6bd37b2a2b fix(client): Prevent overwriting auth file on update 2025-09-19 11:46:16 +08:00
hkfires
f17ec7ffd8 fix(client): Prevent overwriting auth file on update 2025-09-19 11:46:16 +08:00
hkfires
d9f8129a32 fix(client): Add reason to unregistration to skip persistence 2025-09-19 11:46:16 +08:00
hkfires
8f0a345e2a refactor(watcher): Filter irrelevant file system events early 2025-09-19 11:46:16 +08:00
hkfires
56b2dabcca refactor(auth): Introduce generic cookie snapshot manager
This commit introduces a generic `cookies.Manager` to centralize the logic for handling cookie snapshots, which was previously duplicated across the Gemini and PaLM clients. This refactoring eliminates code duplication and improves maintainability.

The new `cookies.Manager[T]` in `internal/auth/cookies` orchestrates the lifecycle of cookie data between a temporary snapshot file and the main token file. It provides `Apply`, `Persist`, and `Flush` methods to manage this process.

Key changes:
- A generic `Manager` is created in `internal/auth/cookies`, usable for any token storage type.
- A `Hooks` struct allows for customizable behavior, such as custom merging strategies for different token types.
- Duplicated snapshot handling code has been removed from the `gemini-web` and `palm` persistence packages.
- The `GeminiWebClient` and `PaLMClient` have been updated to use the new `cookies.Manager`.
- The `auth_gemini` and `auth_palm` CLI commands now leverage the client's `Flush` method, simplifying the command logic.
- Cookie snapshot utility functions have been moved from `internal/util/files.go` to a new `internal/util/cookies.go` for better organization.
2025-09-19 11:46:09 +08:00
hkfires
7632204966 refactor(cookie): Extract cookie snapshot logic to util package
The logic for managing cookie persistence files was previously implemented directly within the `gemini-web` client's persistence layer. This approach was not reusable and led to duplicated helper functions.

This commit refactors the cookie persistence mechanism by:
- Renaming the concept from "sidecar" to "snapshot" for clarity.
- Extracting file I/O and path manipulation logic into a new, generic `internal/util/cookie_snapshot.go` file.
- Creating reusable utility functions: `WriteCookieSnapshot`, `TryReadCookieSnapshotInto`, and `RemoveCookieSnapshot`.
- Updating the `gemini-web` persistence code to use these new centralized utility functions.

This change improves code organization, reduces duplication, and makes the cookie snapshot functionality easier to maintain and potentially reuse across other clients.
2025-09-19 11:44:27 +08:00
17 changed files with 646 additions and 244 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,6 +33,10 @@ type GeminiClient struct {
accountLabel string
}
var NanoBananaModel = map[string]struct{}{
"gemini-2.5-flash-image-preview": {},
}
// NewGeminiClient creates a client. Pass empty strings to auto-detect via browser cookies (not implemented in Go port).
func NewGeminiClient(secure1psid string, secure1psidts string, proxy string, opts ...func(*GeminiClient)) *GeminiClient {
c := &GeminiClient{
@@ -239,6 +243,14 @@ func (c *GeminiClient) GenerateContent(prompt string, files []string, model Mode
}
}
func ensureAnyLen(slice []any, index int) []any {
if index < len(slice) {
return slice
}
gap := index + 1 - len(slice)
return append(slice, make([]any, gap)...)
}
func (c *GeminiClient) generateOnce(prompt string, files []string, model Model, gem *Gem, chat *ChatSession) (ModelOutput, error) {
var empty ModelOutput
// Build f.req
@@ -266,6 +278,14 @@ func (c *GeminiClient) generateOnce(prompt string, files []string, model Model,
}
inner := []any{item0, nil, item2}
requestedModel := strings.ToLower(model.Name)
if chat != nil && chat.RequestedModel() != "" {
requestedModel = chat.RequestedModel()
}
if _, ok := NanoBananaModel[requestedModel]; ok {
inner = ensureAnyLen(inner, 49)
inner[49] = 14
}
if gem != nil {
// pad with 16 nils then gem ID
for i := 0; i < 16; i++ {
@@ -674,16 +694,17 @@ func truncateForLog(s string, n int) string {
// StartChat returns a ChatSession attached to the client
func (c *GeminiClient) StartChat(model Model, gem *Gem, metadata []string) *ChatSession {
return &ChatSession{client: c, metadata: normalizeMeta(metadata), model: model, gem: gem}
return &ChatSession{client: c, metadata: normalizeMeta(metadata), model: model, gem: gem, requestedModel: strings.ToLower(model.Name)}
}
// ChatSession holds conversation metadata
type ChatSession struct {
client *GeminiClient
metadata []string // cid, rid, rcid
lastOutput *ModelOutput
model Model
gem *Gem
client *GeminiClient
metadata []string // cid, rid, rcid
lastOutput *ModelOutput
model Model
gem *Gem
requestedModel string
}
func (cs *ChatSession) String() string {
@@ -710,6 +731,10 @@ func normalizeMeta(v []string) []string {
func (cs *ChatSession) Metadata() []string { return cs.metadata }
func (cs *ChatSession) SetMetadata(v []string) { cs.metadata = normalizeMeta(v) }
func (cs *ChatSession) RequestedModel() string { return cs.requestedModel }
func (cs *ChatSession) SetRequestedModel(name string) {
cs.requestedModel = strings.ToLower(name)
}
func (cs *ChatSession) CID() string {
if len(cs.metadata) > 0 {
return cs.metadata[0]

View File

@@ -1,6 +1,7 @@
package geminiwebapi
import (
"bytes"
"encoding/json"
"fmt"
"math"
@@ -137,5 +138,41 @@ func ConvertOutputToGemini(output *ModelOutput, modelName string, promptText str
if err != nil {
return nil, fmt.Errorf("failed to marshal gemini response: %w", err)
}
return b, nil
return ensureColonSpacing(b), nil
}
// ensureColonSpacing inserts a single space after JSON key-value colons while
// leaving string content untouched. This matches the relaxed formatting used by
// Gemini responses and keeps downstream text-processing tools compatible with
// the proxy output.
func ensureColonSpacing(b []byte) []byte {
if len(b) == 0 {
return b
}
var out bytes.Buffer
out.Grow(len(b) + len(b)/8)
inString := false
escaped := false
for i := 0; i < len(b); i++ {
ch := b[i]
out.WriteByte(ch)
if escaped {
escaped = false
continue
}
switch ch {
case '\\':
escaped = true
case '"':
inString = !inString
case ':':
if !inString && i+1 < len(b) {
next := b[i+1]
if next != ' ' && next != '\n' && next != '\r' && next != '\t' {
out.WriteByte(' ')
}
}
}
}
return out.Bytes()
}

View File

@@ -116,6 +116,8 @@ func EnsureGeminiWebAliasMap() {
for _, m := range registry.GetGeminiModels() {
if m.ID == "gemini-2.5-flash-lite" {
continue
} else if m.ID == "gemini-2.5-flash" {
GeminiWebAliasMap["gemini-2.5-flash-image-preview"] = "gemini-2.5-flash"
}
alias := AliasFromModelID(m.ID)
GeminiWebAliasMap[strings.ToLower(alias)] = strings.ToLower(m.ID)
@@ -130,6 +132,13 @@ func GetGeminiWebAliasedModels() []*registry.ModelInfo {
for _, m := range registry.GetGeminiModels() {
if m.ID == "gemini-2.5-flash-lite" {
continue
} else if m.ID == "gemini-2.5-flash" {
cpy := *m
cpy.ID = "gemini-2.5-flash-image-preview"
cpy.Name = "gemini-2.5-flash-image-preview"
cpy.DisplayName = "Nano Banana"
cpy.Description = "Gemini 2.5 Flash Preview Image"
aliased = append(aliased, &cpy)
}
cpy := *m
cpy.ID = AliasFromModelID(m.ID)

View File

@@ -9,8 +9,6 @@ import (
"path/filepath"
"strings"
"time"
"github.com/luispater/CLIProxyAPI/v5/internal/auth/gemini"
)
// StoredMessage represents a single message in a conversation record.
@@ -267,109 +265,3 @@ func FindReusableSessionIn(items map[string]ConversationRecord, index map[string
}
return nil, nil
}
// CookiesSidecarPath derives the sidecar cookie file path from the main token JSON path.
func CookiesSidecarPath(mainPath string) string {
if strings.HasSuffix(mainPath, ".json") {
return strings.TrimSuffix(mainPath, ".json") + ".cookies"
}
return mainPath + ".cookies"
}
// FileExists reports whether the given path exists and is a regular file.
func FileExists(path string) bool {
if path == "" {
return false
}
if st, err := os.Stat(path); err == nil && !st.IsDir() {
return true
}
return false
}
// ApplyCookiesSidecarToTokenStorage loads cookies from sidecar into the provided token storage.
// Returns true when a sidecar was found and applied.
func ApplyCookiesSidecarToTokenStorage(tokenFilePath string, ts *gemini.GeminiWebTokenStorage) (bool, error) {
if ts == nil {
return false, nil
}
side := CookiesSidecarPath(tokenFilePath)
if !FileExists(side) {
return false, nil
}
data, err := os.ReadFile(side)
if err != nil || len(data) == 0 {
return false, err
}
var latest gemini.GeminiWebTokenStorage
if err := json.Unmarshal(data, &latest); err != nil {
return false, err
}
if latest.Secure1PSID != "" {
ts.Secure1PSID = latest.Secure1PSID
}
if latest.Secure1PSIDTS != "" {
ts.Secure1PSIDTS = latest.Secure1PSIDTS
}
return true, nil
}
// SaveCookiesSidecar writes the current cookies into a sidecar file next to the token file.
// This keeps the main token JSON stable until an orderly flush.
func SaveCookiesSidecar(tokenFilePath string, cookies map[string]string) error {
side := CookiesSidecarPath(tokenFilePath)
ts := &gemini.GeminiWebTokenStorage{Type: "gemini-web"}
if v := cookies["__Secure-1PSID"]; v != "" {
ts.Secure1PSID = v
}
if v := cookies["__Secure-1PSIDTS"]; v != "" {
ts.Secure1PSIDTS = v
}
if err := os.MkdirAll(filepath.Dir(side), 0o700); err != nil {
return err
}
return ts.SaveTokenToFile(side)
}
// FlushCookiesSidecarToMain merges the sidecar cookies into the main token file and removes the sidecar.
// If sidecar is missing, it will combine the provided base token storage with the latest cookies.
func FlushCookiesSidecarToMain(tokenFilePath string, cookies map[string]string, base *gemini.GeminiWebTokenStorage) error {
if tokenFilePath == "" {
return nil
}
side := CookiesSidecarPath(tokenFilePath)
var merged gemini.GeminiWebTokenStorage
var fromSidecar bool
if FileExists(side) {
if data, err := os.ReadFile(side); err == nil && len(data) > 0 {
if err2 := json.Unmarshal(data, &merged); err2 == nil {
fromSidecar = true
}
}
}
if !fromSidecar {
if base != nil {
merged = *base
}
if v := cookies["__Secure-1PSID"]; v != "" {
merged.Secure1PSID = v
}
if v := cookies["__Secure-1PSIDTS"]; v != "" {
merged.Secure1PSIDTS = v
}
}
merged.Type = "gemini-web"
if err := os.MkdirAll(filepath.Dir(tokenFilePath), 0o700); err != nil {
return err
}
if err := merged.SaveTokenToFile(tokenFilePath); err != nil {
return err
}
if FileExists(side) {
_ = os.Remove(side)
}
return nil
}
// IsSelfPersistedToken compares provided token storage with current cookies.
// Removed: IsSelfPersistedToken (client-side no longer needs self-originated write detection)

View File

@@ -40,10 +40,11 @@ const (
type GeminiWebClient struct {
ClientBase
gwc *geminiWeb.GeminiClient
tokenFilePath string
convStore map[string][]string
convMutex sync.RWMutex
gwc *geminiWeb.GeminiClient
tokenFilePath string
snapshotManager *util.Manager[gemini.GeminiWebTokenStorage]
convStore map[string][]string
convMutex sync.RWMutex
// JSON-based conversation persistence
convData map[string]geminiWeb.ConversationRecord
@@ -60,13 +61,33 @@ type GeminiWebClient struct {
modelsRegistered bool
}
func (c *GeminiWebClient) UnregisterClient() {
func (c *GeminiWebClient) UnregisterClient() { c.unregisterClient(interfaces.UnregisterReasonReload) }
// UnregisterClientWithReason allows the watcher to avoid recreating deleted auth files.
func (c *GeminiWebClient) UnregisterClientWithReason(reason interfaces.UnregisterReason) {
c.unregisterClient(reason)
}
func (c *GeminiWebClient) unregisterClient(reason interfaces.UnregisterReason) {
if c.cookiePersistCancel != nil {
c.cookiePersistCancel()
c.cookiePersistCancel = nil
}
// Flush sidecar cookies to main token file and remove sidecar
c.flushCookiesSidecarToMain()
switch reason {
case interfaces.UnregisterReasonAuthFileRemoved:
if c.snapshotManager != nil && c.tokenFilePath != "" {
log.Debugf("skipping Gemini Web snapshot flush because auth file is missing: %s", filepath.Base(c.tokenFilePath))
util.RemoveCookieSnapshots(c.tokenFilePath)
}
case interfaces.UnregisterReasonAuthFileUpdated:
if c.snapshotManager != nil && c.tokenFilePath != "" {
log.Debugf("skipping Gemini Web snapshot flush because auth file was updated: %s", filepath.Base(c.tokenFilePath))
util.RemoveCookieSnapshots(c.tokenFilePath)
}
default:
// Flush cookie snapshot to main token file and remove snapshot
c.flushCookieSnapshotToMain()
}
if c.gwc != nil {
c.gwc.Close(0)
c.gwc = nil
@@ -113,13 +134,33 @@ func NewGeminiWebClient(cfg *config.Config, ts *gemini.GeminiWebTokenStorage, to
client.convIndex = index
}
client.InitializeModelRegistry(clientID)
// Prefer sidecar cookies at startup if present
if ok, err := geminiWeb.ApplyCookiesSidecarToTokenStorage(tokenFilePath, ts); err == nil && ok {
log.Debugf("Loaded Gemini Web cookies from sidecar: %s", filepath.Base(geminiWeb.CookiesSidecarPath(tokenFilePath)))
if tokenFilePath != "" {
client.snapshotManager = util.NewManager[gemini.GeminiWebTokenStorage](
tokenFilePath,
ts,
util.Hooks[gemini.GeminiWebTokenStorage]{
Apply: func(store, snapshot *gemini.GeminiWebTokenStorage) {
if snapshot.Secure1PSID != "" {
store.Secure1PSID = snapshot.Secure1PSID
}
if snapshot.Secure1PSIDTS != "" {
store.Secure1PSIDTS = snapshot.Secure1PSIDTS
}
},
WriteMain: func(path string, data *gemini.GeminiWebTokenStorage) error {
return data.SaveTokenToFile(path)
},
},
)
if applied, err := client.snapshotManager.Apply(); err != nil {
log.Warnf("Failed to apply Gemini Web cookie snapshot for %s: %v", filepath.Base(tokenFilePath), err)
} else if applied {
log.Debugf("Loaded Gemini Web cookie snapshot: %s", filepath.Base(util.CookieSnapshotPath(tokenFilePath)))
}
}
client.InitializeModelRegistry(clientID)
client.gwc = geminiWeb.NewGeminiClient(ts.Secure1PSID, ts.Secure1PSIDTS, cfg.ProxyURL, geminiWeb.WithAccountLabel(strings.TrimSuffix(filepath.Base(tokenFilePath), ".json")))
timeoutSec := geminiWebDefaultTimeoutSec
refreshIntervalSec := cfg.GeminiWeb.TokenRefreshSeconds
@@ -131,6 +172,7 @@ func NewGeminiWebClient(cfg *config.Config, ts *gemini.GeminiWebTokenStorage, to
go client.backgroundInitRetry()
} else {
client.cookieRotationStarted = true
client.registerModelsOnce()
// Persist immediately once after successful init to capture fresh cookies
_ = client.SaveTokenToFile()
client.startCookiePersist()
@@ -352,6 +394,7 @@ func (c *GeminiWebClient) prepareChat(ctx context.Context, modelName string, raw
c.appendUpstreamRequestLog(ctx, modelName, res.tagged, true, res.prompt, len(uploadedFiles), res.reuse, res.metaLen)
gem := c.getConfiguredGem()
res.chat = c.gwc.StartChat(model, gem, meta)
res.chat.SetRequestedModel(modelName)
return res, nil
}
@@ -783,7 +826,7 @@ func (c *GeminiWebClient) SendRawTokenCount(ctx context.Context, modelName strin
return []byte(fmt.Sprintf(`{"totalTokens":%d}`, est)), nil
}
// SaveTokenToFile persists current cookies to a sidecar file via gemini-web helpers.
// SaveTokenToFile persists current cookies to a cookie snapshot via gemini-web helpers.
func (c *GeminiWebClient) SaveTokenToFile() error {
ts := c.tokenStorage.(*gemini.GeminiWebTokenStorage)
if c.gwc != nil && c.gwc.Cookies != nil {
@@ -794,11 +837,16 @@ func (c *GeminiWebClient) SaveTokenToFile() error {
ts.Secure1PSIDTS = v
}
}
log.Debugf("Saving Gemini Web cookies sidecar to %s", filepath.Base(geminiWeb.CookiesSidecarPath(c.tokenFilePath)))
return geminiWeb.SaveCookiesSidecar(c.tokenFilePath, c.gwc.Cookies)
if c.snapshotManager == nil {
if c.tokenFilePath == "" {
return nil
}
return ts.SaveTokenToFile(c.tokenFilePath)
}
return c.snapshotManager.Persist()
}
// startCookiePersist periodically writes refreshed cookies into the sidecar file.
// startCookiePersist periodically writes refreshed cookies into the cookie snapshot file.
func (c *GeminiWebClient) startCookiePersist() {
if c.gwc == nil {
return
@@ -827,7 +875,7 @@ func (c *GeminiWebClient) startCookiePersist() {
case <-ticker.C:
if c.gwc != nil && c.gwc.Cookies != nil {
if err := c.SaveTokenToFile(); err != nil {
log.Errorf("Failed to persist cookies sidecar for %s: %v", c.GetEmail(), err)
log.Errorf("Failed to persist cookie snapshot for %s: %v", c.GetEmail(), err)
}
}
}
@@ -1020,22 +1068,32 @@ func (c *GeminiWebClient) backgroundInitRetry() {
}
}
// IsSelfPersistedToken compares provided token storage with currently active cookies.
// Removed: IsSelfPersistedToken (no longer needed with sidecar-only periodic persistence)
// flushCookiesSidecarToMain merges sidecar cookies into the main token file.
func (c *GeminiWebClient) flushCookiesSidecarToMain() {
if c.tokenFilePath == "" {
// flushCookieSnapshotToMain merges snapshot cookies into the main token file.
func (c *GeminiWebClient) flushCookieSnapshotToMain() {
if c.snapshotManager == nil {
return
}
base := c.tokenStorage.(*gemini.GeminiWebTokenStorage)
if err := geminiWeb.FlushCookiesSidecarToMain(c.tokenFilePath, c.gwc.Cookies, base); err != nil {
log.Errorf("Failed to flush cookies sidecar to main for %s: %v", filepath.Base(c.tokenFilePath), err)
ts := c.tokenStorage.(*gemini.GeminiWebTokenStorage)
var opts []util.FlushOption[gemini.GeminiWebTokenStorage]
if c.gwc != nil && c.gwc.Cookies != nil {
gwCookies := c.gwc.Cookies
opts = append(opts, util.WithFallback(func() *gemini.GeminiWebTokenStorage {
merged := *ts
if v := gwCookies["__Secure-1PSID"]; v != "" {
merged.Secure1PSID = v
}
if v := gwCookies["__Secure-1PSIDTS"]; v != "" {
merged.Secure1PSIDTS = v
}
return &merged
}))
}
if err := c.snapshotManager.Flush(opts...); err != nil {
log.Errorf("Failed to flush cookie snapshot to main for %s: %v", filepath.Base(c.tokenFilePath), err)
}
}
// findReusableSession and storeConversationJSON live here as client bridges; hashing/records in gemini-web
func (c *GeminiWebClient) getConfiguredGem() *geminiWeb.Gem {
if c.cfg.GeminiWeb.CodeMode {
return &geminiWeb.Gem{ID: "coding-partner", Name: "Coding partner", Predefined: true}

View File

@@ -37,7 +37,9 @@ const (
// QwenClient implements the Client interface for OpenAI API
type QwenClient struct {
ClientBase
qwenAuth *qwen.QwenAuth
qwenAuth *qwen.QwenAuth
tokenFilePath string
snapshotManager *util.Manager[qwen.QwenTokenStorage]
}
// NewQwenClient creates a new OpenAI client instance
@@ -48,7 +50,7 @@ type QwenClient struct {
//
// Returns:
// - *QwenClient: A new Qwen client instance.
func NewQwenClient(cfg *config.Config, ts *qwen.QwenTokenStorage) *QwenClient {
func NewQwenClient(cfg *config.Config, ts *qwen.QwenTokenStorage, tokenFilePath ...string) *QwenClient {
httpClient := util.SetProxy(cfg, &http.Client{})
// Generate unique client ID
@@ -66,6 +68,47 @@ func NewQwenClient(cfg *config.Config, ts *qwen.QwenTokenStorage) *QwenClient {
qwenAuth: qwen.NewQwenAuth(cfg),
}
// If created with a known token file path, record it.
if len(tokenFilePath) > 0 && tokenFilePath[0] != "" {
client.tokenFilePath = filepath.Clean(tokenFilePath[0])
}
// If no explicit path provided but email exists, derive the canonical path.
if client.tokenFilePath == "" && ts != nil && ts.Email != "" {
client.tokenFilePath = filepath.Clean(filepath.Join(cfg.AuthDir, fmt.Sprintf("qwen-%s.json", ts.Email)))
}
if client.tokenFilePath != "" {
client.snapshotManager = util.NewManager[qwen.QwenTokenStorage](
client.tokenFilePath,
ts,
util.Hooks[qwen.QwenTokenStorage]{
Apply: func(store, snapshot *qwen.QwenTokenStorage) {
if snapshot.AccessToken != "" {
store.AccessToken = snapshot.AccessToken
}
if snapshot.RefreshToken != "" {
store.RefreshToken = snapshot.RefreshToken
}
if snapshot.ResourceURL != "" {
store.ResourceURL = snapshot.ResourceURL
}
if snapshot.Expire != "" {
store.Expire = snapshot.Expire
}
},
WriteMain: func(path string, data *qwen.QwenTokenStorage) error {
return data.SaveTokenToFile(path)
},
},
)
if applied, err := client.snapshotManager.Apply(); err != nil {
log.Warnf("Failed to apply Qwen cookie snapshot for %s: %v", filepath.Base(client.tokenFilePath), err)
} else if applied {
log.Debugf("Loaded Qwen cookie snapshot: %s", filepath.Base(util.CookieSnapshotPath(client.tokenFilePath)))
}
}
// Initialize model registry and register Qwen models
client.InitializeModelRegistry(clientID)
client.RegisterModels("qwen", registry.GetQwenModels())
@@ -275,7 +318,13 @@ func (c *QwenClient) SendRawTokenCount(_ context.Context, _ string, _ []byte, _
// Returns:
// - error: An error if the save operation fails, nil otherwise.
func (c *QwenClient) SaveTokenToFile() error {
fileName := filepath.Join(c.cfg.AuthDir, fmt.Sprintf("qwen-%s.json", c.tokenStorage.(*qwen.QwenTokenStorage).Email))
ts := c.tokenStorage.(*qwen.QwenTokenStorage)
// When the client was created from an auth file, persist via cookie snapshot
if c.snapshotManager != nil {
return c.snapshotManager.Persist()
}
// Initial bootstrap (e.g., during OAuth flow) writes the main token file
fileName := filepath.Join(c.cfg.AuthDir, fmt.Sprintf("qwen-%s.json", ts.Email))
return c.tokenStorage.SaveTokenToFile(fileName)
}
@@ -347,7 +396,7 @@ func (c *QwenClient) APIRequest(ctx context.Context, modelName, endpoint string,
}
var url string
if c.tokenStorage.(*qwen.QwenTokenStorage).ResourceURL == "" {
if c.tokenStorage.(*qwen.QwenTokenStorage).ResourceURL != "" {
url = fmt.Sprintf("https://%s/v1%s", c.tokenStorage.(*qwen.QwenTokenStorage).ResourceURL, endpoint)
} else {
url = fmt.Sprintf("%s%s", qwenEndpoint, endpoint)
@@ -458,3 +507,39 @@ func (c *QwenClient) IsAvailable() bool {
func (c *QwenClient) SetUnavailable() {
c.isAvailable = false
}
// UnregisterClient flushes cookie snapshot back into the main token file.
func (c *QwenClient) UnregisterClient() { c.unregisterClient(interfaces.UnregisterReasonReload) }
// UnregisterClientWithReason allows the watcher to adjust persistence behaviour.
func (c *QwenClient) UnregisterClientWithReason(reason interfaces.UnregisterReason) {
c.unregisterClient(reason)
}
func (c *QwenClient) unregisterClient(reason interfaces.UnregisterReason) {
if c.snapshotManager != nil {
switch reason {
case interfaces.UnregisterReasonAuthFileRemoved:
if c.tokenFilePath != "" {
log.Debugf("skipping Qwen snapshot flush because auth file is missing: %s", filepath.Base(c.tokenFilePath))
util.RemoveCookieSnapshots(c.tokenFilePath)
}
case interfaces.UnregisterReasonAuthFileUpdated:
if c.tokenFilePath != "" {
log.Debugf("skipping Qwen snapshot flush because auth file was updated: %s", filepath.Base(c.tokenFilePath))
util.RemoveCookieSnapshots(c.tokenFilePath)
}
case interfaces.UnregisterReasonShutdown, interfaces.UnregisterReasonReload:
if err := c.snapshotManager.Flush(); err != nil {
log.Errorf("Failed to flush Qwen cookie snapshot to main for %s: %v", filepath.Base(c.tokenFilePath), err)
}
default:
if err := c.snapshotManager.Flush(); err != nil {
log.Errorf("Failed to flush Qwen cookie snapshot to main for %s: %v", filepath.Base(c.tokenFilePath), err)
}
}
} else if c.tokenFilePath != "" && (reason == interfaces.UnregisterReasonAuthFileRemoved || reason == interfaces.UnregisterReasonAuthFileUpdated) {
util.RemoveCookieSnapshots(c.tokenFilePath)
}
c.ClientBase.UnregisterClient()
}

View File

@@ -9,7 +9,6 @@ import (
"context"
"encoding/json"
"io/fs"
"net/http"
"os"
"os/signal"
"path/filepath"
@@ -26,6 +25,7 @@ import (
"github.com/luispater/CLIProxyAPI/v5/internal/client"
"github.com/luispater/CLIProxyAPI/v5/internal/config"
"github.com/luispater/CLIProxyAPI/v5/internal/interfaces"
"github.com/luispater/CLIProxyAPI/v5/internal/misc"
"github.com/luispater/CLIProxyAPI/v5/internal/util"
"github.com/luispater/CLIProxyAPI/v5/internal/watcher"
log "github.com/sirupsen/logrus"
@@ -75,8 +75,9 @@ func StartService(cfg *config.Config, configPath string) {
// Process only JSON files in the auth directory to load authentication tokens.
if !info.IsDir() && strings.HasSuffix(info.Name(), ".json") {
misc.LogCredentialSeparator()
log.Debugf("Loading token from: %s", path)
data, errReadFile := os.ReadFile(path)
data, errReadFile := util.ReadAuthFilePreferSnapshot(path)
if errReadFile != nil {
return errReadFile
}
@@ -139,7 +140,7 @@ func StartService(cfg *config.Config, configPath string) {
if err = json.Unmarshal(data, &ts); err == nil {
// For each valid Qwen token, create an authenticated client.
log.Info("Initializing qwen authentication for token...")
qwenClient := client.NewQwenClient(cfg, &ts)
qwenClient := client.NewQwenClient(cfg, &ts, path)
log.Info("Authentication successful.")
cliClients[path] = qwenClient
successfulAuthCount++
@@ -170,7 +171,7 @@ func StartService(cfg *config.Config, configPath string) {
log.Fatalf("Error walking auth directory: %v", err)
}
apiKeyClients, glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount := buildAPIKeyClients(cfg)
apiKeyClients, glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount := watcher.BuildAPIKeyClients(cfg)
totalNewClients := len(cliClients) + len(apiKeyClients)
log.Infof("full client load complete - %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)",
@@ -344,9 +345,15 @@ func StartService(cfg *config.Config, configPath string) {
}
activeClientsMu.RUnlock()
for _, c := range snapshot {
misc.LogCredentialSeparator()
// Persist tokens/cookies then unregister/cleanup per client.
_ = c.SaveTokenToFile()
if u, ok := any(c).(interface{ UnregisterClient() }); ok {
switch u := any(c).(type) {
case interface {
UnregisterClientWithReason(interfaces.UnregisterReason)
}:
u.UnregisterClientWithReason(interfaces.UnregisterReasonShutdown)
case interface{ UnregisterClient() }:
u.UnregisterClient()
}
}
@@ -372,57 +379,3 @@ func clientsToSlice(clientMap map[string]interfaces.Client) []interfaces.Client
}
return s
}
// buildAPIKeyClients creates clients from API keys in the config
func buildAPIKeyClients(cfg *config.Config) (map[string]interfaces.Client, int, int, int, int) {
apiKeyClients := make(map[string]interfaces.Client)
glAPIKeyCount := 0
claudeAPIKeyCount := 0
codexAPIKeyCount := 0
openAICompatCount := 0
if len(cfg.GlAPIKey) > 0 {
for _, key := range cfg.GlAPIKey {
httpClient := util.SetProxy(cfg, &http.Client{})
log.Debug("Initializing with Generative Language API Key...")
cliClient := client.NewGeminiClient(httpClient, cfg, key)
apiKeyClients[cliClient.GetClientID()] = cliClient
glAPIKeyCount++
}
}
if len(cfg.ClaudeKey) > 0 {
for i := range cfg.ClaudeKey {
log.Debug("Initializing with Claude API Key...")
cliClient := client.NewClaudeClientWithKey(cfg, i)
apiKeyClients[cliClient.GetClientID()] = cliClient
claudeAPIKeyCount++
}
}
if len(cfg.CodexKey) > 0 {
for i := range cfg.CodexKey {
log.Debug("Initializing with Codex API Key...")
cliClient := client.NewCodexClientWithKey(cfg, i)
apiKeyClients[cliClient.GetClientID()] = cliClient
codexAPIKeyCount++
}
}
if len(cfg.OpenAICompatibility) > 0 {
for _, compatConfig := range cfg.OpenAICompatibility {
for i := 0; i < len(compatConfig.APIKeys); i++ {
log.Debugf("Initializing OpenAI compatibility client for provider: %s", compatConfig.Name)
compatClient, errClient := client.NewOpenAICompatibilityClient(cfg, &compatConfig, i)
if errClient != nil {
log.Errorf("failed to create OpenAI compatibility client for %s: %v", compatConfig.Name, errClient)
continue
}
apiKeyClients[compatClient.GetClientID()] = compatClient
openAICompatCount++
}
}
}
return apiKeyClients, glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount
}

View File

@@ -61,3 +61,17 @@ type Client interface {
// SetUnavailable sets the client to unavailable.
SetUnavailable()
}
// UnregisterReason describes the context for unregistering a client instance.
type UnregisterReason string
const (
// UnregisterReasonReload indicates a full reload is replacing the client.
UnregisterReasonReload UnregisterReason = "reload"
// UnregisterReasonShutdown indicates the service is shutting down.
UnregisterReasonShutdown UnregisterReason = "shutdown"
// UnregisterReasonAuthFileRemoved indicates the underlying auth file was deleted.
UnregisterReasonAuthFileRemoved UnregisterReason = "auth-file-removed"
// UnregisterReasonAuthFileUpdated indicates the auth file content was modified.
UnregisterReasonAuthFileUpdated UnregisterReason = "auth-file-updated"
)

View File

@@ -0,0 +1,24 @@
package misc
import (
"path/filepath"
"strings"
log "github.com/sirupsen/logrus"
)
var credentialSeparator = strings.Repeat("-", 70)
// 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))
}
// LogCredentialSeparator adds a visual separator to group auth/key processing logs.
func LogCredentialSeparator() {
log.Info(credentialSeparator)
}

View File

@@ -0,0 +1,285 @@
package util
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"time"
"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 .cookie, or appends .cookie if missing.
func CookieSnapshotPath(mainPath string) string {
if strings.HasSuffix(mainPath, ".json") {
return strings.TrimSuffix(mainPath, ".json") + cookieSnapshotExt
}
return mainPath + cookieSnapshotExt
}
// IsRegularFile reports whether the given path exists and is a regular file.
func IsRegularFile(path string) bool {
if path == "" {
return false
}
if st, err := os.Stat(path); err == nil && !st.IsDir() {
return true
}
return false
}
// ReadJSON reads and unmarshals a JSON file into v.
// Returns os.ErrNotExist if the file does not exist.
func ReadJSON(path string, v any) error {
b, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return os.ErrNotExist
}
return err
}
if len(b) == 0 {
return nil
}
return json.Unmarshal(b, v)
}
// WriteJSON marshals v as JSON and writes to path, creating parent directories as needed.
func WriteJSON(path string, v any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
enc := json.NewEncoder(f)
return enc.Encode(v)
}
// RemoveFile removes the file if it exists.
func RemoveFile(path string) error {
if IsRegularFile(path) {
return os.Remove(path)
}
return nil
}
// 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 {
if err == os.ErrNotExist {
return false, nil
}
return false, err
}
return true, nil
}
// WriteCookieSnapshot writes v to the snapshot path derived from mainPath using the .cookie suffix.
func WriteCookieSnapshot(mainPath string, v any) error {
path := CookieSnapshotPath(mainPath)
misc.LogSavingCredentials(path)
if err := WriteJSON(path, v); err != nil {
return err
}
return nil
}
// ReadAuthFilePreferSnapshot returns the first non-empty auth payload preferring snapshots.
func ReadAuthFilePreferSnapshot(path string) ([]byte, error) {
return ReadAuthFileWithRetry(path, 1, 0)
}
// ReadAuthFileWithRetry attempts to read an auth file multiple times and prefers cookie snapshots.
func ReadAuthFileWithRetry(path string, attempts int, delay time.Duration) ([]byte, error) {
if attempts < 1 {
attempts = 1
}
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{
CookieSnapshotPath(path),
path,
}
for idx, candidate := range candidates {
data, err := read(candidate)
if err == nil {
return data, nil
}
if errors.Is(err, os.ErrNotExist) {
if idx < len(candidates)-1 {
continue
}
}
return nil, err
}
return nil, os.ErrNotExist
}
// 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 {
// Apply merges snapshot data into the in-memory store during Apply().
// Defaults to overwriting the store with the snapshot contents.
Apply func(store *T, snapshot *T)
// Snapshot prepares the payload to persist during Persist().
// Defaults to cloning the store value.
Snapshot func(store *T) *T
// Merge chooses which data to flush when a snapshot exists.
// Defaults to using the snapshot payload as-is.
Merge func(store *T, snapshot *T) *T
// WriteMain persists the merged payload into the canonical token path.
// Defaults to WriteJSON.
WriteMain func(path string, data *T) error
}
// Manager orchestrates cookie snapshot lifecycle for token storages.
type Manager[T any] struct {
mainPath string
store *T
hooks Hooks[T]
}
// NewManager constructs a Manager bound to mainPath and store.
func NewManager[T any](mainPath string, store *T, hooks Hooks[T]) *Manager[T] {
return &Manager[T]{
mainPath: mainPath,
store: store,
hooks: hooks,
}
}
// Apply loads snapshot data into the in-memory store if available.
// Returns true when a snapshot was applied.
func (m *Manager[T]) Apply() (bool, error) {
if m == nil || m.store == nil || m.mainPath == "" {
return false, nil
}
var snapshot T
ok, err := TryReadCookieSnapshotInto(m.mainPath, &snapshot)
if err != nil {
return false, err
}
if !ok {
return false, nil
}
if m.hooks.Apply != nil {
m.hooks.Apply(m.store, &snapshot)
} else {
*m.store = snapshot
}
return true, nil
}
// Persist writes the current store state to the snapshot file.
func (m *Manager[T]) Persist() error {
if m == nil || m.store == nil || m.mainPath == "" {
return nil
}
var payload *T
if m.hooks.Snapshot != nil {
payload = m.hooks.Snapshot(m.store)
} else {
clone := new(T)
*clone = *m.store
payload = clone
}
return WriteCookieSnapshot(m.mainPath, payload)
}
// FlushOptions configure Flush behaviour.
type FlushOptions[T any] struct {
Fallback func() *T
Mutate func(*T)
}
// FlushOption mutates FlushOptions.
type FlushOption[T any] func(*FlushOptions[T])
// WithFallback provides fallback payload when no snapshot exists.
func WithFallback[T any](fn func() *T) FlushOption[T] {
return func(opts *FlushOptions[T]) { opts.Fallback = fn }
}
// WithMutate allows last-minute mutation of the payload before writing main file.
func WithMutate[T any](fn func(*T)) FlushOption[T] {
return func(opts *FlushOptions[T]) { opts.Mutate = fn }
}
// Flush commits snapshot (or fallback) into the main token file and removes the snapshot.
func (m *Manager[T]) Flush(options ...FlushOption[T]) error {
if m == nil || m.mainPath == "" {
return nil
}
cfg := FlushOptions[T]{}
for _, opt := range options {
if opt != nil {
opt(&cfg)
}
}
var snapshot T
ok, err := TryReadCookieSnapshotInto(m.mainPath, &snapshot)
if err != nil {
return err
}
var payload *T
if ok {
if m.hooks.Merge != nil {
payload = m.hooks.Merge(m.store, &snapshot)
} else {
payload = &snapshot
}
} else if cfg.Fallback != nil {
payload = cfg.Fallback()
} else if m.store != nil {
payload = m.store
}
if payload == nil {
return RemoveFile(CookieSnapshotPath(m.mainPath))
}
if cfg.Mutate != nil {
cfg.Mutate(payload)
}
if m.hooks.WriteMain != nil {
if err := m.hooks.WriteMain(m.mainPath, payload); err != nil {
return err
}
} else {
if err := WriteJSON(m.mainPath, payload); err != nil {
return err
}
}
RemoveCookieSnapshots(m.mainPath)
return nil
}

View File

@@ -25,6 +25,7 @@ import (
"github.com/luispater/CLIProxyAPI/v5/internal/client"
"github.com/luispater/CLIProxyAPI/v5/internal/config"
"github.com/luispater/CLIProxyAPI/v5/internal/interfaces"
"github.com/luispater/CLIProxyAPI/v5/internal/misc"
"github.com/luispater/CLIProxyAPI/v5/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
@@ -137,11 +138,19 @@ func (w *Watcher) processEvents(ctx context.Context) {
// handleEvent processes individual file system events
func (w *Watcher) handleEvent(event fsnotify.Event) {
// Filter only relevant events: config file or auth-dir JSON files.
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 *.cookie) and other noise.
return
}
now := time.Now()
log.Debugf("file system event detected: %s %s", event.Op.String(), event.Name)
// Handle config file changes
if event.Name == w.configPath && (event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create) {
if isConfigEvent {
log.Debugf("config file change details - operation: %s, timestamp: %s", event.Op.String(), now.Format("2006-01-02 15:04:05.000"))
data, err := os.ReadFile(w.configPath)
if err != nil {
@@ -172,8 +181,8 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
return
}
// Handle auth directory changes incrementally
if strings.HasPrefix(event.Name, w.authDir) && strings.HasSuffix(event.Name, ".json") {
// Handle auth directory changes incrementally (.json only)
if isAuthJSON {
log.Infof("auth file changed (%s): %s, processing incrementally", event.Op.String(), filepath.Base(event.Name))
if event.Op&fsnotify.Create == fsnotify.Create || event.Op&fsnotify.Write == fsnotify.Write {
w.addOrUpdateClient(event.Name)
@@ -289,13 +298,11 @@ func (w *Watcher) reloadClients() {
// Unregister all old API key clients before creating new ones
log.Debugf("unregistering %d old API key clients", oldAPIKeyClientCount)
for _, oldClient := range w.apiKeyClients {
if u, ok := oldClient.(interface{ UnregisterClient() }); ok {
u.UnregisterClient()
}
unregisterClientWithReason(oldClient, interfaces.UnregisterReasonReload)
}
// Create new API key clients based on the new config
newAPIKeyClients, glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount := buildAPIKeyClients(cfg)
newAPIKeyClients, glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount := BuildAPIKeyClients(cfg)
log.Debugf("created %d new API key clients", len(newAPIKeyClients))
// Load file-based clients
@@ -305,9 +312,7 @@ func (w *Watcher) reloadClients() {
// Unregister all old file-based clients
log.Debugf("unregistering %d old file-based clients", oldFileClientCount)
for _, oldClient := range w.clients {
if u, ok := any(oldClient).(interface{ UnregisterClient() }); ok {
u.UnregisterClient()
}
unregisterClientWithReason(oldClient, interfaces.UnregisterReasonReload)
}
// Update client maps
@@ -318,7 +323,7 @@ func (w *Watcher) reloadClients() {
// Rebuild auth file hash cache for current clients
w.lastAuthHashes = make(map[string]string, len(newFileClients))
for path := range newFileClients {
if data, err := readAuthFileWithRetry(path, authFileReadMaxAttempts, authFileReadRetryDelay); err == nil && len(data) > 0 {
if data, err := util.ReadAuthFileWithRetry(path, authFileReadMaxAttempts, authFileReadRetryDelay); err == nil && len(data) > 0 {
sum := sha256.Sum256(data)
w.lastAuthHashes[path] = hex.EncodeToString(sum[:])
}
@@ -347,7 +352,7 @@ func (w *Watcher) reloadClients() {
// createClientFromFile creates a single client instance from a given token file path.
func (w *Watcher) createClientFromFile(path string, cfg *config.Config) (interfaces.Client, error) {
data, errReadFile := readAuthFileWithRetry(path, authFileReadMaxAttempts, authFileReadRetryDelay)
data, errReadFile := util.ReadAuthFileWithRetry(path, authFileReadMaxAttempts, authFileReadRetryDelay)
if errReadFile != nil {
return nil, errReadFile
}
@@ -389,7 +394,7 @@ func (w *Watcher) createClientFromFile(path string, cfg *config.Config) (interfa
} else if tokenType == "qwen" {
var ts qwen.QwenTokenStorage
if err = json.Unmarshal(data, &ts); err == nil {
return client.NewQwenClient(cfg, &ts), nil
return client.NewQwenClient(cfg, &ts, path), nil
}
} else if tokenType == "gemini-web" {
var ts gemini.GeminiWebTokenStorage
@@ -410,26 +415,9 @@ func (w *Watcher) clientsToSlice(clientMap map[string]interfaces.Client) []inter
return s
}
// 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)
if err == nil {
return data, nil
}
lastErr = err
if i < attempts-1 {
time.Sleep(delay)
}
}
return nil, lastErr
}
// addOrUpdateClient handles the addition or update of a single client.
func (w *Watcher) addOrUpdateClient(path string) {
data, errRead := readAuthFileWithRetry(path, authFileReadMaxAttempts, authFileReadRetryDelay)
data, errRead := util.ReadAuthFileWithRetry(path, authFileReadMaxAttempts, authFileReadRetryDelay)
if errRead != nil {
log.Errorf("failed to read auth file %s: %v", filepath.Base(path), errRead)
return
@@ -458,10 +446,10 @@ func (w *Watcher) addOrUpdateClient(path string) {
// If an old client exists, unregister it first
if oldClient, ok := w.clients[path]; ok {
if u, canUnregister := any(oldClient).(interface{ UnregisterClient() }); canUnregister {
if _, canUnregister := any(oldClient).(interface{ UnregisterClient() }); canUnregister {
log.Debugf("unregistering old client for updated file: %s", filepath.Base(path))
u.UnregisterClient()
}
unregisterClientWithReason(oldClient, interfaces.UnregisterReasonAuthFileUpdated)
}
// Create new client (reads the file again internally; this is acceptable as the files are small and it keeps the change minimal)
@@ -503,10 +491,10 @@ func (w *Watcher) removeClient(path string) {
// Unregister client if it exists
if oldClient, ok := w.clients[path]; ok {
if u, canUnregister := any(oldClient).(interface{ UnregisterClient() }); canUnregister {
if _, canUnregister := any(oldClient).(interface{ UnregisterClient() }); canUnregister {
log.Debugf("unregistering client for removed file: %s", filepath.Base(path))
u.UnregisterClient()
}
unregisterClientWithReason(oldClient, interfaces.UnregisterReasonAuthFileRemoved)
delete(w.clients, path)
delete(w.lastAuthHashes, path)
log.Debugf("removed client for %s", filepath.Base(path))
@@ -542,6 +530,18 @@ func (w *Watcher) buildCombinedClientMap() map[string]interfaces.Client {
return combined
}
// unregisterClientWithReason attempts to call client-specific unregister hooks with context.
func unregisterClientWithReason(c interfaces.Client, reason interfaces.UnregisterReason) {
switch u := any(c).(type) {
case interface {
UnregisterClientWithReason(interfaces.UnregisterReason)
}:
u.UnregisterClientWithReason(reason)
case interface{ UnregisterClient() }:
u.UnregisterClient()
}
}
// loadFileClients scans the auth directory and creates clients from .json files.
func (w *Watcher) loadFileClients(cfg *config.Config) (map[string]interfaces.Client, int) {
newClients := make(map[string]interfaces.Client)
@@ -565,6 +565,7 @@ func (w *Watcher) loadFileClients(cfg *config.Config) (map[string]interfaces.Cli
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".json") {
authFileCount++
misc.LogCredentialSeparator()
log.Debugf("processing auth file %d: %s", authFileCount, filepath.Base(path))
if cliClient, errCreate := w.createClientFromFile(path, cfg); errCreate == nil && cliClient != nil {
newClients[path] = cliClient
@@ -583,8 +584,7 @@ func (w *Watcher) loadFileClients(cfg *config.Config) (map[string]interfaces.Cli
return newClients, successfulAuthCount
}
// buildAPIKeyClients creates clients from API keys in the config.
func buildAPIKeyClients(cfg *config.Config) (map[string]interfaces.Client, int, int, int, int) {
func BuildAPIKeyClients(cfg *config.Config) (map[string]interfaces.Client, int, int, int, int) {
apiKeyClients := make(map[string]interfaces.Client)
glAPIKeyCount := 0
claudeAPIKeyCount := 0
@@ -594,6 +594,8 @@ func buildAPIKeyClients(cfg *config.Config) (map[string]interfaces.Client, int,
if len(cfg.GlAPIKey) > 0 {
for _, key := range cfg.GlAPIKey {
httpClient := util.SetProxy(cfg, &http.Client{})
misc.LogCredentialSeparator()
log.Debug("Initializing with Gemini API Key...")
cliClient := client.NewGeminiClient(httpClient, cfg, key)
apiKeyClients[cliClient.GetClientID()] = cliClient
glAPIKeyCount++
@@ -601,6 +603,8 @@ func buildAPIKeyClients(cfg *config.Config) (map[string]interfaces.Client, int,
}
if len(cfg.ClaudeKey) > 0 {
for i := range cfg.ClaudeKey {
misc.LogCredentialSeparator()
log.Debug("Initializing with Claude API Key...")
cliClient := client.NewClaudeClientWithKey(cfg, i)
apiKeyClients[cliClient.GetClientID()] = cliClient
claudeAPIKeyCount++
@@ -608,6 +612,8 @@ func buildAPIKeyClients(cfg *config.Config) (map[string]interfaces.Client, int,
}
if len(cfg.CodexKey) > 0 {
for i := range cfg.CodexKey {
misc.LogCredentialSeparator()
log.Debug("Initializing with Codex API Key...")
cliClient := client.NewCodexClientWithKey(cfg, i)
apiKeyClients[cliClient.GetClientID()] = cliClient
codexAPIKeyCount++
@@ -616,9 +622,11 @@ func buildAPIKeyClients(cfg *config.Config) (map[string]interfaces.Client, int,
if len(cfg.OpenAICompatibility) > 0 {
for _, compatConfig := range cfg.OpenAICompatibility {
for i := 0; i < len(compatConfig.APIKeys); i++ {
misc.LogCredentialSeparator()
log.Debugf("Initializing OpenAI compatibility client for provider: %s", compatConfig.Name)
compatClient, errClient := client.NewOpenAICompatibilityClient(cfg, &compatConfig, i)
if errClient != nil {
log.Errorf("failed to create OpenAI-compatibility client for %s: %v", compatConfig.Name, errClient)
log.Errorf("failed to create OpenAI compatibility client for %s: %v", compatConfig.Name, errClient)
continue
}
apiKeyClients[compatClient.GetClientID()] = compatClient