diff --git a/.dockerignore b/.dockerignore index cb4c5bfc..a794020d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -30,3 +30,4 @@ config.yaml bin/* .claude/* .vscode/* +.serena/* diff --git a/.gitignore b/.gitignore index ea0cda89..800d9a7d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,8 @@ auths/* !auths/.gitkeep .vscode/* .claude/* +.serena/* AGENTS.md CLAUDE.md *.exe -temp/* -.serena/ \ No newline at end of file +temp/* \ No newline at end of file diff --git a/internal/client/gemini-web/logging.go b/internal/client/gemini-web/logging.go deleted file mode 100644 index fe892d90..00000000 --- a/internal/client/gemini-web/logging.go +++ /dev/null @@ -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() -} diff --git a/internal/client/gemini-web/auth.go b/internal/provider/gemini-web/auth.go similarity index 89% rename from internal/client/gemini-web/auth.go rename to internal/provider/gemini-web/auth.go index 05d8bd48..c10f76ee 100644 --- a/internal/client/gemini-web/auth.go +++ b/internal/provider/gemini-web/auth.go @@ -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 +} diff --git a/internal/client/gemini-web/client.go b/internal/provider/gemini-web/client.go similarity index 99% rename from internal/client/gemini-web/client.go rename to internal/provider/gemini-web/client.go index 8f84eaa3..829f21ee 100644 --- a/internal/client/gemini-web/client.go +++ b/internal/provider/gemini-web/client.go @@ -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 } diff --git a/internal/client/gemini-web/convert_ext.go b/internal/provider/gemini-web/convert_ext.go similarity index 100% rename from internal/client/gemini-web/convert_ext.go rename to internal/provider/gemini-web/convert_ext.go diff --git a/internal/client/gemini-web/errors.go b/internal/provider/gemini-web/errors.go similarity index 100% rename from internal/client/gemini-web/errors.go rename to internal/provider/gemini-web/errors.go diff --git a/internal/client/gemini-web/media.go b/internal/provider/gemini-web/media.go similarity index 98% rename from internal/client/gemini-web/media.go rename to internal/provider/gemini-web/media.go index 58651453..3c843c62 100644 --- a/internal/client/gemini-web/media.go +++ b/internal/provider/gemini-web/media.go @@ -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 diff --git a/internal/client/gemini-web/models.go b/internal/provider/gemini-web/models.go similarity index 100% rename from internal/client/gemini-web/models.go rename to internal/provider/gemini-web/models.go diff --git a/internal/client/gemini-web/persistence.go b/internal/provider/gemini-web/persistence.go similarity index 100% rename from internal/client/gemini-web/persistence.go rename to internal/provider/gemini-web/persistence.go diff --git a/internal/client/gemini-web/prompt.go b/internal/provider/gemini-web/prompt.go similarity index 100% rename from internal/client/gemini-web/prompt.go rename to internal/provider/gemini-web/prompt.go diff --git a/internal/client/gemini-web/request.go b/internal/provider/gemini-web/request.go similarity index 100% rename from internal/client/gemini-web/request.go rename to internal/provider/gemini-web/request.go diff --git a/internal/runtime/executor/gemini_web_state.go b/internal/provider/gemini-web/state.go similarity index 68% rename from internal/runtime/executor/gemini_web_state.go rename to internal/provider/gemini-web/state.go index 2b10a3f0..aed61b74 100644 --- a/internal/runtime/executor/gemini_web_state.go +++ b/internal/provider/gemini-web/state.go @@ -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, ¶m) } -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]"), ¶m) } -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) + } +} diff --git a/internal/client/gemini-web/types.go b/internal/provider/gemini-web/types.go similarity index 100% rename from internal/client/gemini-web/types.go rename to internal/provider/gemini-web/types.go diff --git a/internal/runtime/executor/gemini_web_executor.go b/internal/runtime/executor/gemini_web_executor.go index a9cc57c5..5f2e09a6 100644 --- a/internal/runtime/executor/gemini_web_executor.go +++ b/internal/runtime/executor/gemini_web_executor.go @@ -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 diff --git a/internal/util/cookie_snapshot.go b/internal/util/cookie_snapshot.go deleted file mode 100644 index 2572feea..00000000 --- a/internal/util/cookie_snapshot.go +++ /dev/null @@ -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 -} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 7fbe869f..fb1667ce 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -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++ } } diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 9d6a34d5..98c9f05c 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -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"