Compare commits

...

7 Commits

Author SHA1 Message Date
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
4 changed files with 91 additions and 45 deletions

View File

@@ -1,6 +1,7 @@
package geminiwebapi package geminiwebapi
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"math" "math"
@@ -137,5 +138,41 @@ func ConvertOutputToGemini(output *ModelOutput, modelName string, promptText str
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to marshal gemini response: %w", err) 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

@@ -26,6 +26,7 @@ import (
"github.com/luispater/CLIProxyAPI/v5/internal/config" "github.com/luispater/CLIProxyAPI/v5/internal/config"
"github.com/luispater/CLIProxyAPI/v5/internal/interfaces" "github.com/luispater/CLIProxyAPI/v5/internal/interfaces"
"github.com/luispater/CLIProxyAPI/v5/internal/misc" "github.com/luispater/CLIProxyAPI/v5/internal/misc"
"github.com/luispater/CLIProxyAPI/v5/internal/util"
"github.com/luispater/CLIProxyAPI/v5/internal/watcher" "github.com/luispater/CLIProxyAPI/v5/internal/watcher"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
@@ -76,7 +77,7 @@ func StartService(cfg *config.Config, configPath string) {
if !info.IsDir() && strings.HasSuffix(info.Name(), ".json") { if !info.IsDir() && strings.HasSuffix(info.Name(), ".json") {
misc.LogCredentialSeparator() misc.LogCredentialSeparator()
log.Debugf("Loading token from: %s", path) log.Debugf("Loading token from: %s", path)
data, errReadFile := os.ReadFile(path) data, errReadFile := util.ReadAuthFilePreferSnapshot(path)
if errReadFile != nil { if errReadFile != nil {
return errReadFile return errReadFile
} }
@@ -344,6 +345,7 @@ func StartService(cfg *config.Config, configPath string) {
} }
activeClientsMu.RUnlock() activeClientsMu.RUnlock()
for _, c := range snapshot { for _, c := range snapshot {
misc.LogCredentialSeparator()
// Persist tokens/cookies then unregister/cleanup per client. // Persist tokens/cookies then unregister/cleanup per client.
_ = c.SaveTokenToFile() _ = c.SaveTokenToFile()
switch u := any(c).(type) { switch u := any(c).(type) {

View File

@@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"github.com/luispater/CLIProxyAPI/v5/internal/misc" "github.com/luispater/CLIProxyAPI/v5/internal/misc"
) )
@@ -93,6 +94,52 @@ func WriteCookieSnapshot(mainPath string, v any) error {
return nil 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. // RemoveCookieSnapshots removes the snapshot file if it exists.
func RemoveCookieSnapshots(mainPath string) { func RemoveCookieSnapshots(mainPath string) {
_ = RemoveFile(CookieSnapshotPath(mainPath)) _ = RemoveFile(CookieSnapshotPath(mainPath))

View File

@@ -9,7 +9,6 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"io/fs" "io/fs"
"net/http" "net/http"
"os" "os"
@@ -324,7 +323,7 @@ func (w *Watcher) reloadClients() {
// Rebuild auth file hash cache for current clients // Rebuild auth file hash cache for current clients
w.lastAuthHashes = make(map[string]string, len(newFileClients)) w.lastAuthHashes = make(map[string]string, len(newFileClients))
for path := range 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) sum := sha256.Sum256(data)
w.lastAuthHashes[path] = hex.EncodeToString(sum[:]) w.lastAuthHashes[path] = hex.EncodeToString(sum[:])
} }
@@ -353,7 +352,7 @@ func (w *Watcher) reloadClients() {
// createClientFromFile creates a single client instance from a given token file path. // createClientFromFile creates a single client instance from a given token file path.
func (w *Watcher) createClientFromFile(path string, cfg *config.Config) (interfaces.Client, error) { 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 { if errReadFile != nil {
return nil, errReadFile return nil, errReadFile
} }
@@ -416,48 +415,9 @@ func (w *Watcher) clientsToSlice(clientMap map[string]interfaces.Client) []inter
return s 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) {
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
}
if errors.Is(err, os.ErrNotExist) {
if idx < len(candidates)-1 {
continue
}
}
return nil, err
}
return nil, os.ErrNotExist
}
// addOrUpdateClient handles the addition or update of a single client. // addOrUpdateClient handles the addition or update of a single client.
func (w *Watcher) addOrUpdateClient(path string) { func (w *Watcher) addOrUpdateClient(path string) {
data, errRead := readAuthFileWithRetry(path, authFileReadMaxAttempts, authFileReadRetryDelay) data, errRead := util.ReadAuthFileWithRetry(path, authFileReadMaxAttempts, authFileReadRetryDelay)
if errRead != nil { if errRead != nil {
log.Errorf("failed to read auth file %s: %v", filepath.Base(path), errRead) log.Errorf("failed to read auth file %s: %v", filepath.Base(path), errRead)
return return