mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-03-09 15:25:17 +00:00
Merge pull request #47 from router-for-me/dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -783,7 +825,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 +836,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 +874,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 +1067,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}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -26,7 +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/util"
|
||||
"github.com/luispater/CLIProxyAPI/v5/internal/misc"
|
||||
"github.com/luispater/CLIProxyAPI/v5/internal/watcher"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
@@ -75,6 +74,7 @@ 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)
|
||||
if errReadFile != nil {
|
||||
@@ -139,7 +139,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 +170,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)",
|
||||
@@ -346,7 +346,12 @@ func StartService(cfg *config.Config, configPath string) {
|
||||
for _, c := range snapshot {
|
||||
// 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 +377,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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
24
internal/misc/credentials.go
Normal file
24
internal/misc/credentials.go
Normal 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)
|
||||
}
|
||||
238
internal/util/cookie_snapshot.go
Normal file
238
internal/util/cookie_snapshot.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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 .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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -25,6 +26,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 +139,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 +182,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 +299,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 +313,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
|
||||
@@ -389,7 +395,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
|
||||
@@ -413,18 +419,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.
|
||||
@@ -458,10 +486,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 +531,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 +570,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 +605,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 +624,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 +634,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 +643,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 +652,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 +662,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
|
||||
|
||||
Reference in New Issue
Block a user