Merge pull request #60 from router-for-me/v6-test

Move gemini-web to provider
This commit is contained in:
Luis Pater
2025-09-24 22:15:54 +08:00
committed by GitHub
18 changed files with 159 additions and 509 deletions

View File

@@ -30,3 +30,4 @@ config.yaml
bin/*
.claude/*
.vscode/*
.serena/*

2
.gitignore vendored
View File

@@ -7,8 +7,8 @@ auths/*
!auths/.gitkeep
.vscode/*
.claude/*
.serena/*
AGENTS.md
CLAUDE.md
*.exe
temp/*
.serena/

View File

@@ -1,131 +0,0 @@
package geminiwebapi
import (
"fmt"
"os"
"strings"
log "github.com/sirupsen/logrus"
)
// init honors GEMINI_WEBAPI_LOG to keep parity with the Python client.
func init() {
if lvl := os.Getenv("GEMINI_WEBAPI_LOG"); lvl != "" {
SetLogLevel(lvl)
}
}
// SetLogLevel adjusts logging verbosity using CLI-style strings.
func SetLogLevel(level string) {
switch strings.ToUpper(level) {
case "TRACE":
log.SetLevel(log.TraceLevel)
case "DEBUG":
log.SetLevel(log.DebugLevel)
case "INFO":
log.SetLevel(log.InfoLevel)
case "WARNING", "WARN":
log.SetLevel(log.WarnLevel)
case "ERROR":
log.SetLevel(log.ErrorLevel)
case "CRITICAL", "FATAL":
log.SetLevel(log.FatalLevel)
default:
log.SetLevel(log.InfoLevel)
}
}
func prefix(format string) string { return "[gemini_webapi] " + format }
func Debug(format string, v ...any) { log.Debugf(prefix(format), v...) }
// DebugRaw logs without the module prefix; use sparingly for messages
// that should integrate with global formatting without extra tags.
func DebugRaw(format string, v ...any) { log.Debugf(format, v...) }
func Info(format string, v ...any) { log.Infof(prefix(format), v...) }
func Warning(format string, v ...any) { log.Warnf(prefix(format), v...) }
func Error(format string, v ...any) { log.Errorf(prefix(format), v...) }
func Success(format string, v ...any) { log.Infof(prefix("SUCCESS "+format), v...) }
// MaskToken28 returns a fixed-length (28) masked representation showing:
// first 8 chars + 8 asterisks + 4 middle chars + last 8 chars.
// If the input is shorter than 20 characters, it returns a fully masked string
// of length min(len(s), 28).
func MaskToken28(s string) string {
n := len(s)
if n == 0 {
return ""
}
if n < 20 {
return strings.Repeat("*", n)
}
// Pick 4 middle characters around the center
midStart := n/2 - 2
if midStart < 8 {
midStart = 8
}
if midStart+4 > n-8 {
midStart = n - 8 - 4
if midStart < 8 {
midStart = 8
}
}
prefixByte := s[:8]
middle := s[midStart : midStart+4]
suffix := s[n-8:]
return prefixByte + strings.Repeat("*", 4) + middle + strings.Repeat("*", 4) + suffix
}
// BuildUpstreamRequestLog builds a compact preview string for upstream request logging.
func BuildUpstreamRequestLog(account string, contextOn bool, useTags, explicitContext bool, prompt string, filesCount int, reuse bool, metaLen int, gem *Gem) string {
var sb strings.Builder
sb.WriteString("\n\n=== GEMINI WEB UPSTREAM ===\n")
sb.WriteString(fmt.Sprintf("account: %s\n", account))
if contextOn {
sb.WriteString("context_mode: on\n")
} else {
sb.WriteString("context_mode: off\n")
}
if reuse {
sb.WriteString("reuseIdx: 1\n")
} else {
sb.WriteString("reuseIdx: 0\n")
}
sb.WriteString(fmt.Sprintf("useTags: %t\n", useTags))
sb.WriteString(fmt.Sprintf("metadata_len: %d\n", metaLen))
if explicitContext {
sb.WriteString("explicit_context: true\n")
} else {
sb.WriteString("explicit_context: false\n")
}
if filesCount > 0 {
sb.WriteString(fmt.Sprintf("files: %d\n", filesCount))
}
if gem != nil {
sb.WriteString("gem:\n")
if gem.ID != "" {
sb.WriteString(fmt.Sprintf(" id: %s\n", gem.ID))
}
if gem.Name != "" {
sb.WriteString(fmt.Sprintf(" name: %s\n", gem.Name))
}
sb.WriteString(fmt.Sprintf(" predefined: %t\n", gem.Predefined))
} else {
sb.WriteString("gem: none\n")
}
chunks := ChunkByRunes(prompt, 4096)
preview := prompt
truncated := false
if len(chunks) > 1 {
preview = chunks[0]
truncated = true
}
sb.WriteString("prompt_preview:\n")
sb.WriteString(preview)
if truncated {
sb.WriteString("\n... [truncated]\n")
}
return sb.String()
}

View File

@@ -12,6 +12,8 @@ import (
"regexp"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
type httpOptions struct {
@@ -103,7 +105,7 @@ func getAccessToken(baseCookies map[string]string, proxy string, verbose bool, i
}
trySets = append(trySets, merged)
} else if verbose {
Debug("Skipping base cookies: __Secure-1PSIDTS missing")
log.Debug("Skipping base cookies: __Secure-1PSIDTS missing")
}
}
@@ -130,7 +132,7 @@ func getAccessToken(baseCookies map[string]string, proxy string, verbose bool, i
resp, mergedCookies, err := sendInitRequest(cookies, proxy, insecure)
if err != nil {
if verbose {
Warning("Failed init request: %v", err)
log.Warnf("Failed init request: %v", err)
}
continue
}
@@ -143,7 +145,7 @@ func getAccessToken(baseCookies map[string]string, proxy string, verbose bool, i
if len(matches) >= 2 {
token := matches[1]
if verbose {
Success("Gemini access token acquired.")
log.Infof("Gemini access token acquired.")
}
return token, mergedCookies, nil
}
@@ -212,3 +214,27 @@ func (r *constReader) Read(p []byte) (int, error) {
}
func stringsReader(s string) io.Reader { return &constReader{s: s} }
func MaskToken28(s string) string {
n := len(s)
if n == 0 {
return ""
}
if n < 20 {
return strings.Repeat("*", n)
}
midStart := n/2 - 2
if midStart < 8 {
midStart = 8
}
if midStart+4 > n-8 {
midStart = n - 8 - 4
if midStart < 8 {
midStart = 8
}
}
prefixByte := s[:8]
middle := s[midStart : midStart+4]
suffix := s[n-8:]
return prefixByte + strings.Repeat("*", 4) + middle + strings.Repeat("*", 4) + suffix
}

View File

@@ -10,6 +10,8 @@ import (
"regexp"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
// GeminiClient is the async http client interface (Go port)
@@ -79,7 +81,7 @@ func (c *GeminiClient) Init(timeoutSec float64, verbose bool) error {
c.Timeout = time.Duration(timeoutSec * float64(time.Second))
if verbose {
Success("Gemini client initialized successfully.")
log.Infof("Gemini client initialized successfully.")
}
return nil
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
@@ -58,7 +59,7 @@ func (i Image) Save(path string, filename string, cookies map[string]string, ver
filename = m[1]
} else {
if verbose {
Warning("Invalid filename: %s", filename)
log.Warnf("Invalid filename: %s", filename)
}
if skipInvalidFilename {
return "", nil
@@ -125,7 +126,7 @@ func (i Image) Save(path string, filename string, cookies map[string]string, ver
return "", fmt.Errorf("error downloading image: %d %s", resp.StatusCode, resp.Status)
}
if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(strings.ToLower(ct), "image") {
Warning("Content type of %s is not image, but %s.", filename, ct)
log.Warnf("Content type of %s is not image, but %s.", filename, ct)
}
if path == "" {
path = "temp"
@@ -144,7 +145,7 @@ func (i Image) Save(path string, filename string, cookies map[string]string, ver
return "", err
}
if verbose {
Info("Image saved as %s", dest)
log.Infof("Image saved as %s", dest)
}
abspath, _ := filepath.Abs(dest)
return abspath, nil

View File

@@ -1,4 +1,4 @@
package executor
package geminiwebapi
import (
"bytes"
@@ -10,8 +10,8 @@ import (
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
geminiwebapi "github.com/router-for-me/CLIProxyAPI/v6/internal/client/gemini-web"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
@@ -25,7 +25,7 @@ const (
geminiWebDefaultTimeoutSec = 300
)
type geminiWebState struct {
type GeminiWebState struct {
cfg *config.Config
token *gemini.GeminiWebTokenStorage
storagePath string
@@ -34,29 +34,29 @@ type geminiWebState struct {
accountID string
reqMu sync.Mutex
client *geminiwebapi.GeminiClient
client *GeminiClient
tokenMu sync.Mutex
tokenDirty bool
convMu sync.RWMutex
convStore map[string][]string
convData map[string]geminiwebapi.ConversationRecord
convData map[string]ConversationRecord
convIndex map[string]string
lastRefresh time.Time
}
func newGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage, storagePath string) *geminiWebState {
state := &geminiWebState{
func NewGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage, storagePath string) *GeminiWebState {
state := &GeminiWebState{
cfg: cfg,
token: token,
storagePath: storagePath,
convStore: make(map[string][]string),
convData: make(map[string]geminiwebapi.ConversationRecord),
convData: make(map[string]ConversationRecord),
convIndex: make(map[string]string),
}
suffix := geminiwebapi.Sha256Hex(token.Secure1PSID)
suffix := Sha256Hex(token.Secure1PSID)
if len(suffix) > 16 {
suffix = suffix[:16]
}
@@ -75,39 +75,39 @@ func newGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage,
return state
}
func (s *geminiWebState) loadConversationCaches() {
func (s *GeminiWebState) loadConversationCaches() {
if path := s.convStorePath(); path != "" {
if store, err := geminiwebapi.LoadConvStore(path); err == nil {
if store, err := LoadConvStore(path); err == nil {
s.convStore = store
}
}
if path := s.convDataPath(); path != "" {
if items, index, err := geminiwebapi.LoadConvData(path); err == nil {
if items, index, err := LoadConvData(path); err == nil {
s.convData = items
s.convIndex = index
}
}
}
func (s *geminiWebState) convStorePath() string {
func (s *GeminiWebState) convStorePath() string {
base := s.storagePath
if base == "" {
base = s.accountID + ".json"
}
return geminiwebapi.ConvStorePath(base)
return ConvStorePath(base)
}
func (s *geminiWebState) convDataPath() string {
func (s *GeminiWebState) convDataPath() string {
base := s.storagePath
if base == "" {
base = s.accountID + ".json"
}
return geminiwebapi.ConvDataPath(base)
return ConvDataPath(base)
}
func (s *geminiWebState) getRequestMutex() *sync.Mutex { return &s.reqMu }
func (s *GeminiWebState) GetRequestMutex() *sync.Mutex { return &s.reqMu }
func (s *geminiWebState) ensureClient() error {
func (s *GeminiWebState) EnsureClient() error {
if s.client != nil && s.client.Running {
return nil
}
@@ -115,7 +115,7 @@ func (s *geminiWebState) ensureClient() error {
if s.cfg != nil {
proxyURL = s.cfg.ProxyURL
}
s.client = geminiwebapi.NewGeminiClient(
s.client = NewGeminiClient(
s.token.Secure1PSID,
s.token.Secure1PSIDTS,
proxyURL,
@@ -129,13 +129,13 @@ func (s *geminiWebState) ensureClient() error {
return nil
}
func (s *geminiWebState) refresh(ctx context.Context) error {
func (s *GeminiWebState) Refresh(ctx context.Context) error {
_ = ctx
proxyURL := ""
if s.cfg != nil {
proxyURL = s.cfg.ProxyURL
}
s.client = geminiwebapi.NewGeminiClient(
s.client = NewGeminiClient(
s.token.Secure1PSID,
s.token.Secure1PSIDTS,
proxyURL,
@@ -158,7 +158,7 @@ func (s *geminiWebState) refresh(ctx context.Context) error {
return nil
}
func (s *geminiWebState) tokenSnapshot() *gemini.GeminiWebTokenStorage {
func (s *GeminiWebState) TokenSnapshot() *gemini.GeminiWebTokenStorage {
s.tokenMu.Lock()
defer s.tokenMu.Unlock()
c := *s.token
@@ -170,15 +170,15 @@ type geminiWebPrepared struct {
translatedRaw []byte
prompt string
uploaded []string
chat *geminiwebapi.ChatSession
cleaned []geminiwebapi.RoleText
chat *ChatSession
cleaned []RoleText
underlying string
reuse bool
tagged bool
originalRaw []byte
}
func (s *geminiWebState) prepare(ctx context.Context, modelName string, rawJSON []byte, stream bool, original []byte) (*geminiWebPrepared, *interfaces.ErrorMessage) {
func (s *GeminiWebState) prepare(ctx context.Context, modelName string, rawJSON []byte, stream bool, original []byte) (*geminiWebPrepared, *interfaces.ErrorMessage) {
res := &geminiWebPrepared{originalRaw: original}
res.translatedRaw = bytes.Clone(rawJSON)
if handler, ok := ctx.Value("handler").(interfaces.APIHandler); ok && handler != nil {
@@ -187,14 +187,14 @@ func (s *geminiWebState) prepare(ctx context.Context, modelName string, rawJSON
}
recordAPIRequest(ctx, s.cfg, res.translatedRaw)
messages, files, mimes, msgFileIdx, err := geminiwebapi.ParseMessagesAndFiles(res.translatedRaw)
messages, files, mimes, msgFileIdx, err := ParseMessagesAndFiles(res.translatedRaw)
if err != nil {
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: fmt.Errorf("bad request: %w", err)}
}
cleaned := geminiwebapi.SanitizeAssistantMessages(messages)
cleaned := SanitizeAssistantMessages(messages)
res.cleaned = cleaned
res.underlying = geminiwebapi.MapAliasToUnderlying(modelName)
model, err := geminiwebapi.ModelFromName(res.underlying)
res.underlying = MapAliasToUnderlying(modelName)
model, err := ModelFromName(res.underlying)
if err != nil {
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: err}
}
@@ -210,11 +210,11 @@ func (s *geminiWebState) prepare(ctx context.Context, modelName string, rawJSON
res.reuse = true
meta = reuseMeta
if len(remaining) == 1 {
useMsgs = []geminiwebapi.RoleText{remaining[0]}
useMsgs = []RoleText{remaining[0]}
} else if len(remaining) > 1 {
useMsgs = remaining
} else if len(cleaned) > 0 {
useMsgs = []geminiwebapi.RoleText{cleaned[len(cleaned)-1]}
useMsgs = []RoleText{cleaned[len(cleaned)-1]}
}
if len(useMsgs) == 1 && len(messages) > 0 && len(msgFileIdx) == len(messages) {
lastIdx := len(msgFileIdx) - 1
@@ -242,8 +242,8 @@ func (s *geminiWebState) prepare(ctx context.Context, modelName string, rawJSON
}
} else {
if len(cleaned) >= 2 && strings.EqualFold(cleaned[len(cleaned)-2].Role, "assistant") {
keyUnderlying := geminiwebapi.AccountMetaKey(s.accountID, res.underlying)
keyAlias := geminiwebapi.AccountMetaKey(s.accountID, modelName)
keyUnderlying := AccountMetaKey(s.accountID, res.underlying)
keyAlias := AccountMetaKey(s.accountID, modelName)
s.convMu.RLock()
fallbackMeta := s.convStore[keyUnderlying]
if len(fallbackMeta) == 0 {
@@ -252,7 +252,7 @@ func (s *geminiWebState) prepare(ctx context.Context, modelName string, rawJSON
s.convMu.RUnlock()
if len(fallbackMeta) > 0 {
meta = fallbackMeta
useMsgs = []geminiwebapi.RoleText{cleaned[len(cleaned)-1]}
useMsgs = []RoleText{cleaned[len(cleaned)-1]}
res.reuse = true
filesSubset = nil
mimesSubset = nil
@@ -260,8 +260,8 @@ func (s *geminiWebState) prepare(ctx context.Context, modelName string, rawJSON
}
}
} else {
keyUnderlying := geminiwebapi.AccountMetaKey(s.accountID, res.underlying)
keyAlias := geminiwebapi.AccountMetaKey(s.accountID, modelName)
keyUnderlying := AccountMetaKey(s.accountID, res.underlying)
keyAlias := AccountMetaKey(s.accountID, modelName)
s.convMu.RLock()
if v, ok := s.convStore[keyUnderlying]; ok && len(v) > 0 {
meta = v
@@ -271,26 +271,26 @@ func (s *geminiWebState) prepare(ctx context.Context, modelName string, rawJSON
s.convMu.RUnlock()
}
res.tagged = geminiwebapi.NeedRoleTags(useMsgs)
res.tagged = NeedRoleTags(useMsgs)
if res.reuse && len(useMsgs) == 1 {
res.tagged = false
}
enableXML := s.cfg != nil && s.cfg.GeminiWeb.CodeMode
useMsgs = geminiwebapi.AppendXMLWrapHintIfNeeded(useMsgs, !enableXML)
useMsgs = AppendXMLWrapHintIfNeeded(useMsgs, !enableXML)
res.prompt = geminiwebapi.BuildPrompt(useMsgs, res.tagged, res.tagged)
res.prompt = BuildPrompt(useMsgs, res.tagged, res.tagged)
if strings.TrimSpace(res.prompt) == "" {
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: errors.New("bad request: empty prompt after filtering system/thought content")}
}
uploaded, upErr := geminiwebapi.MaterializeInlineFiles(filesSubset, mimesSubset)
uploaded, upErr := MaterializeInlineFiles(filesSubset, mimesSubset)
if upErr != nil {
return nil, upErr
}
res.uploaded = uploaded
if err = s.ensureClient(); err != nil {
if err = s.EnsureClient(); err != nil {
return nil, &interfaces.ErrorMessage{StatusCode: 500, Error: err}
}
chat := s.client.StartChat(model, s.getConfiguredGem(), meta)
@@ -300,14 +300,14 @@ func (s *geminiWebState) prepare(ctx context.Context, modelName string, rawJSON
return res, nil
}
func (s *geminiWebState) send(ctx context.Context, modelName string, reqPayload []byte, opts cliproxyexecutor.Options) ([]byte, *interfaces.ErrorMessage, *geminiWebPrepared) {
func (s *GeminiWebState) Send(ctx context.Context, modelName string, reqPayload []byte, opts cliproxyexecutor.Options) ([]byte, *interfaces.ErrorMessage, *geminiWebPrepared) {
prep, errMsg := s.prepare(ctx, modelName, reqPayload, opts.Stream, opts.OriginalRequest)
if errMsg != nil {
return nil, errMsg, nil
}
defer geminiwebapi.CleanupFiles(prep.uploaded)
defer CleanupFiles(prep.uploaded)
output, err := geminiwebapi.SendWithSplit(prep.chat, prep.prompt, prep.uploaded, s.cfg)
output, err := SendWithSplit(prep.chat, prep.prompt, prep.uploaded, s.cfg)
if err != nil {
return nil, s.wrapSendError(err), nil
}
@@ -331,7 +331,7 @@ func (s *geminiWebState) send(ctx context.Context, modelName string, reqPayload
}
}
gemBytes, err := geminiwebapi.ConvertOutputToGemini(&output, modelName, prep.prompt)
gemBytes, err := ConvertOutputToGemini(&output, modelName, prep.prompt)
if err != nil {
return nil, &interfaces.ErrorMessage{StatusCode: 500, Error: err}, nil
}
@@ -341,13 +341,13 @@ func (s *geminiWebState) send(ctx context.Context, modelName string, reqPayload
return gemBytes, nil, prep
}
func (s *geminiWebState) wrapSendError(genErr error) *interfaces.ErrorMessage {
func (s *GeminiWebState) wrapSendError(genErr error) *interfaces.ErrorMessage {
status := 500
var usage *geminiwebapi.UsageLimitExceeded
var blocked *geminiwebapi.TemporarilyBlocked
var invalid *geminiwebapi.ModelInvalid
var valueErr *geminiwebapi.ValueError
var timeout *geminiwebapi.TimeoutError
var usage *UsageLimitExceeded
var blocked *TemporarilyBlocked
var invalid *ModelInvalid
var valueErr *ValueError
var timeout *TimeoutError
switch {
case errors.As(genErr, &usage):
status = 429
@@ -363,14 +363,14 @@ func (s *geminiWebState) wrapSendError(genErr error) *interfaces.ErrorMessage {
return &interfaces.ErrorMessage{StatusCode: status, Error: genErr}
}
func (s *geminiWebState) persistConversation(modelName string, prep *geminiWebPrepared, output *geminiwebapi.ModelOutput) {
func (s *GeminiWebState) persistConversation(modelName string, prep *geminiWebPrepared, output *ModelOutput) {
if output == nil || prep == nil || prep.chat == nil {
return
}
metadata := prep.chat.Metadata()
if len(metadata) > 0 {
keyUnderlying := geminiwebapi.AccountMetaKey(s.accountID, prep.underlying)
keyAlias := geminiwebapi.AccountMetaKey(s.accountID, modelName)
keyUnderlying := AccountMetaKey(s.accountID, prep.underlying)
keyAlias := AccountMetaKey(s.accountID, modelName)
s.convMu.Lock()
s.convStore[keyUnderlying] = metadata
s.convStore[keyAlias] = metadata
@@ -384,18 +384,18 @@ func (s *geminiWebState) persistConversation(modelName string, prep *geminiWebPr
storeSnapshot[k] = cp
}
s.convMu.Unlock()
_ = geminiwebapi.SaveConvStore(s.convStorePath(), storeSnapshot)
_ = SaveConvStore(s.convStorePath(), storeSnapshot)
}
if !s.useReusableContext() {
return
}
rec, ok := geminiwebapi.BuildConversationRecord(prep.underlying, s.stableClientID, prep.cleaned, output, metadata)
rec, ok := BuildConversationRecord(prep.underlying, s.stableClientID, prep.cleaned, output, metadata)
if !ok {
return
}
stableHash := geminiwebapi.HashConversation(rec.ClientID, prep.underlying, rec.Messages)
accountHash := geminiwebapi.HashConversation(s.accountID, prep.underlying, rec.Messages)
stableHash := HashConversation(rec.ClientID, prep.underlying, rec.Messages)
accountHash := HashConversation(s.accountID, prep.underlying, rec.Messages)
s.convMu.Lock()
s.convData[stableHash] = rec
@@ -403,7 +403,7 @@ func (s *geminiWebState) persistConversation(modelName string, prep *geminiWebPr
if accountHash != stableHash {
s.convIndex["hash:"+accountHash] = stableHash
}
dataSnapshot := make(map[string]geminiwebapi.ConversationRecord, len(s.convData))
dataSnapshot := make(map[string]ConversationRecord, len(s.convData))
for k, v := range s.convData {
dataSnapshot[k] = v
}
@@ -412,14 +412,14 @@ func (s *geminiWebState) persistConversation(modelName string, prep *geminiWebPr
indexSnapshot[k] = v
}
s.convMu.Unlock()
_ = geminiwebapi.SaveConvData(s.convDataPath(), dataSnapshot, indexSnapshot)
_ = SaveConvData(s.convDataPath(), dataSnapshot, indexSnapshot)
}
func (s *geminiWebState) addAPIResponseData(ctx context.Context, line []byte) {
func (s *GeminiWebState) addAPIResponseData(ctx context.Context, line []byte) {
appendAPIResponseChunk(ctx, s.cfg, line)
}
func (s *geminiWebState) convertToTarget(ctx context.Context, modelName string, prep *geminiWebPrepared, gemBytes []byte) []byte {
func (s *GeminiWebState) ConvertToTarget(ctx context.Context, modelName string, prep *geminiWebPrepared, gemBytes []byte) []byte {
if prep == nil || prep.handlerType == "" {
return gemBytes
}
@@ -437,7 +437,7 @@ func (s *geminiWebState) convertToTarget(ctx context.Context, modelName string,
return []byte(out)
}
func (s *geminiWebState) convertStream(ctx context.Context, modelName string, prep *geminiWebPrepared, gemBytes []byte) []string {
func (s *GeminiWebState) ConvertStream(ctx context.Context, modelName string, prep *geminiWebPrepared, gemBytes []byte) []string {
if prep == nil || prep.handlerType == "" {
return []string{string(gemBytes)}
}
@@ -448,7 +448,7 @@ func (s *geminiWebState) convertStream(ctx context.Context, modelName string, pr
return translator.Response(prep.handlerType, constant.GeminiWeb, ctx, modelName, prep.originalRaw, prep.translatedRaw, gemBytes, &param)
}
func (s *geminiWebState) doneStream(ctx context.Context, modelName string, prep *geminiWebPrepared) []string {
func (s *GeminiWebState) DoneStream(ctx context.Context, modelName string, prep *geminiWebPrepared) []string {
if prep == nil || prep.handlerType == "" {
return nil
}
@@ -459,24 +459,56 @@ func (s *geminiWebState) doneStream(ctx context.Context, modelName string, prep
return translator.Response(prep.handlerType, constant.GeminiWeb, ctx, modelName, prep.originalRaw, prep.translatedRaw, []byte("[DONE]"), &param)
}
func (s *geminiWebState) useReusableContext() bool {
func (s *GeminiWebState) useReusableContext() bool {
if s.cfg == nil {
return true
}
return s.cfg.GeminiWeb.Context
}
func (s *geminiWebState) findReusableSession(modelName string, msgs []geminiwebapi.RoleText) ([]string, []geminiwebapi.RoleText) {
func (s *GeminiWebState) findReusableSession(modelName string, msgs []RoleText) ([]string, []RoleText) {
s.convMu.RLock()
items := s.convData
index := s.convIndex
s.convMu.RUnlock()
return geminiwebapi.FindReusableSessionIn(items, index, s.stableClientID, s.accountID, modelName, msgs)
return FindReusableSessionIn(items, index, s.stableClientID, s.accountID, modelName, msgs)
}
func (s *geminiWebState) getConfiguredGem() *geminiwebapi.Gem {
func (s *GeminiWebState) getConfiguredGem() *Gem {
if s.cfg != nil && s.cfg.GeminiWeb.CodeMode {
return &geminiwebapi.Gem{ID: "coding-partner", Name: "Coding partner", Predefined: true}
return &Gem{ID: "coding-partner", Name: "Coding partner", Predefined: true}
}
return nil
}
// recordAPIRequest stores the upstream request payload in Gin context for request logging.
func recordAPIRequest(ctx context.Context, cfg *config.Config, payload []byte) {
if cfg == nil || !cfg.RequestLog || len(payload) == 0 {
return
}
if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil {
ginCtx.Set("API_REQUEST", bytes.Clone(payload))
}
}
// appendAPIResponseChunk appends an upstream response chunk to Gin context for request logging.
func appendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byte) {
if cfg == nil || !cfg.RequestLog {
return
}
data := bytes.TrimSpace(bytes.Clone(chunk))
if len(data) == 0 {
return
}
if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil {
if existing, exists := ginCtx.Get("API_RESPONSE"); exists {
if prev, okBytes := existing.([]byte); okBytes {
prev = append(prev, data...)
prev = append(prev, []byte("\n\n")...)
ginCtx.Set("API_RESPONSE", prev)
return
}
}
ginCtx.Set("API_RESPONSE", data)
}
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
geminiwebapi "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
@@ -35,23 +36,23 @@ func (e *GeminiWebExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
if err != nil {
return cliproxyexecutor.Response{}, err
}
if err = state.ensureClient(); err != nil {
if err = state.EnsureClient(); err != nil {
return cliproxyexecutor.Response{}, err
}
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
mutex := state.getRequestMutex()
mutex := state.GetRequestMutex()
if mutex != nil {
mutex.Lock()
defer mutex.Unlock()
}
payload := bytes.Clone(req.Payload)
resp, errMsg, prep := state.send(ctx, req.Model, payload, opts)
resp, errMsg, prep := state.Send(ctx, req.Model, payload, opts)
if errMsg != nil {
return cliproxyexecutor.Response{}, geminiWebErrorFromMessage(errMsg)
}
resp = state.convertToTarget(ctx, req.Model, prep, resp)
resp = state.ConvertToTarget(ctx, req.Model, prep, resp)
reporter.publish(ctx, parseGeminiUsage(resp))
from := opts.SourceFormat
@@ -67,17 +68,17 @@ func (e *GeminiWebExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
if err != nil {
return nil, err
}
if err = state.ensureClient(); err != nil {
if err = state.EnsureClient(); err != nil {
return nil, err
}
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
mutex := state.getRequestMutex()
mutex := state.GetRequestMutex()
if mutex != nil {
mutex.Lock()
}
gemBytes, errMsg, prep := state.send(ctx, req.Model, bytes.Clone(req.Payload), opts)
gemBytes, errMsg, prep := state.Send(ctx, req.Model, bytes.Clone(req.Payload), opts)
if errMsg != nil {
if mutex != nil {
mutex.Unlock()
@@ -90,8 +91,8 @@ func (e *GeminiWebExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
to := sdktranslator.FromString("gemini-web")
var param any
lines := state.convertStream(ctx, req.Model, prep, gemBytes)
done := state.doneStream(ctx, req.Model, prep)
lines := state.ConvertStream(ctx, req.Model, prep, gemBytes)
done := state.DoneStream(ctx, req.Model, prep)
out := make(chan cliproxyexecutor.StreamChunk)
go func() {
defer close(out)
@@ -124,10 +125,10 @@ func (e *GeminiWebExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth
if err != nil {
return nil, err
}
if err = state.refresh(ctx); err != nil {
if err = state.Refresh(ctx); err != nil {
return nil, err
}
ts := state.tokenSnapshot()
ts := state.TokenSnapshot()
if auth.Metadata == nil {
auth.Metadata = make(map[string]any)
}
@@ -139,10 +140,10 @@ func (e *GeminiWebExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth
}
type geminiWebRuntime struct {
state *geminiWebState
state *geminiwebapi.GeminiWebState
}
func (e *GeminiWebExecutor) stateFor(auth *cliproxyauth.Auth) (*geminiWebState, error) {
func (e *GeminiWebExecutor) stateFor(auth *cliproxyauth.Auth) (*geminiwebapi.GeminiWebState, error) {
if auth == nil {
return nil, fmt.Errorf("gemini-web executor: auth is nil")
}
@@ -175,7 +176,7 @@ func (e *GeminiWebExecutor) stateFor(auth *cliproxyauth.Auth) (*geminiWebState,
storagePath = p
}
}
state := newGeminiWebState(cfg, ts, storagePath)
state := geminiwebapi.NewGeminiWebState(cfg, ts, storagePath)
runtime := &geminiWebRuntime{state: state}
auth.Runtime = runtime
return state, nil

View File

@@ -1,280 +0,0 @@
package util
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/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 errors.Is(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 }
}
// 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

@@ -69,8 +69,6 @@ type AuthUpdate struct {
}
const (
authFileReadMaxAttempts = 5
authFileReadRetryDelay = 0
// replaceCheckDelay is a short delay to allow atomic replace (rename) to settle
// before deciding whether a Remove event indicates a real deletion.
replaceCheckDelay = 50 * time.Millisecond
@@ -530,7 +528,7 @@ func (w *Watcher) reloadClients() {
return nil
}
if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".json") {
if data, errReadAuthFileWithRetry := util.ReadAuthFileWithRetry(path, authFileReadMaxAttempts, authFileReadRetryDelay); errReadAuthFileWithRetry == nil && len(data) > 0 {
if data, err := os.ReadFile(path); err == nil && len(data) > 0 {
sum := sha256.Sum256(data)
w.lastAuthHashes[path] = hex.EncodeToString(sum[:])
}
@@ -565,7 +563,7 @@ func (w *Watcher) reloadClients() {
// addOrUpdateClient handles the addition or update of a single client.
func (w *Watcher) addOrUpdateClient(path string) {
data, errRead := util.ReadAuthFileWithRetry(path, authFileReadMaxAttempts, authFileReadRetryDelay)
data, errRead := os.ReadFile(path)
if errRead != nil {
log.Errorf("failed to read auth file %s: %v", filepath.Base(path), errRead)
return
@@ -806,7 +804,7 @@ func (w *Watcher) loadFileClients(cfg *config.Config) int {
authFileCount++
log.Debugf("processing auth file %d: %s", authFileCount, filepath.Base(path))
// Count readable JSON files as successful auth entries
if data, errCreate := util.ReadAuthFileWithRetry(path, authFileReadMaxAttempts, authFileReadRetryDelay); errCreate == nil && len(data) > 0 {
if data, errCreate := os.ReadFile(path); errCreate == nil && len(data) > 0 {
successfulAuthCount++
}
}

View File

@@ -10,8 +10,8 @@ import (
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
geminiwebclient "github.com/router-for-me/CLIProxyAPI/v6/internal/client/gemini-web"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
geminiwebclient "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"