mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 13:00:52 +08:00
1144 lines
38 KiB
Go
1144 lines
38 KiB
Go
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/luispater/CLIProxyAPI/v5/internal/auth/gemini"
|
|
geminiWeb "github.com/luispater/CLIProxyAPI/v5/internal/client/gemini-web"
|
|
"github.com/luispater/CLIProxyAPI/v5/internal/config"
|
|
. "github.com/luispater/CLIProxyAPI/v5/internal/constant"
|
|
"github.com/luispater/CLIProxyAPI/v5/internal/interfaces"
|
|
"github.com/luispater/CLIProxyAPI/v5/internal/translator/translator"
|
|
"github.com/luispater/CLIProxyAPI/v5/internal/util"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
// This file wires the external-facing client for Gemini Web.
|
|
|
|
// Defaults for Gemini Web behavior that are no longer configurable via YAML.
|
|
const (
|
|
// geminiWebDefaultTimeoutSec defines the per-request HTTP timeout seconds.
|
|
geminiWebDefaultTimeoutSec = 300
|
|
// geminiWebDefaultRefreshIntervalSec defines background cookie auto-refresh interval seconds.
|
|
geminiWebDefaultRefreshIntervalSec = 540
|
|
// geminiWebDefaultPersistIntervalSec defines how often rotated cookies are persisted to disk (3 hours).
|
|
geminiWebDefaultPersistIntervalSec = 10800
|
|
)
|
|
|
|
type GeminiWebClient struct {
|
|
ClientBase
|
|
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
|
|
convIndex map[string]string
|
|
|
|
// restart-stable id for conversation hashing/lookup
|
|
stableClientID string
|
|
|
|
cookieRotationStarted bool
|
|
cookiePersistCancel context.CancelFunc
|
|
lastPersistedTS string
|
|
|
|
// register models once after successful auth init
|
|
modelsRegistered bool
|
|
}
|
|
|
|
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
|
|
}
|
|
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
|
|
}
|
|
c.ClientBase.UnregisterClient()
|
|
}
|
|
|
|
func NewGeminiWebClient(cfg *config.Config, ts *gemini.GeminiWebTokenStorage, tokenFilePath string) (*GeminiWebClient, error) {
|
|
jar, _ := cookiejar.New(nil)
|
|
httpClient := util.SetProxy(cfg, &http.Client{Jar: jar})
|
|
|
|
// derive a restart-stable id from tokens (sha256 of 1PSID, hex prefix)
|
|
stableSuffix := geminiWeb.Sha256Hex(ts.Secure1PSID)
|
|
if len(stableSuffix) > 16 {
|
|
stableSuffix = stableSuffix[:16]
|
|
}
|
|
idPrefix := stableSuffix
|
|
if len(idPrefix) > 8 {
|
|
idPrefix = idPrefix[:8]
|
|
}
|
|
clientID := fmt.Sprintf("gemini-web-%s-%d", idPrefix, time.Now().UnixNano())
|
|
|
|
client := &GeminiWebClient{
|
|
ClientBase: ClientBase{
|
|
RequestMutex: &sync.Mutex{},
|
|
httpClient: httpClient,
|
|
cfg: cfg,
|
|
tokenStorage: ts,
|
|
modelQuotaExceeded: make(map[string]*time.Time),
|
|
isAvailable: true,
|
|
},
|
|
tokenFilePath: tokenFilePath,
|
|
convStore: make(map[string][]string),
|
|
convData: make(map[string]geminiWeb.ConversationRecord),
|
|
convIndex: make(map[string]string),
|
|
stableClientID: "gemini-web-" + stableSuffix,
|
|
}
|
|
// Load persisted conversation stores
|
|
if store, err := geminiWeb.LoadConvStore(geminiWeb.ConvStorePath(tokenFilePath)); err == nil {
|
|
client.convStore = store
|
|
}
|
|
if items, index, err := geminiWeb.LoadConvData(geminiWeb.ConvDataPath(tokenFilePath)); err == nil {
|
|
client.convData = items
|
|
client.convIndex = index
|
|
}
|
|
|
|
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
|
|
if refreshIntervalSec <= 0 {
|
|
refreshIntervalSec = geminiWebDefaultRefreshIntervalSec
|
|
}
|
|
if err := client.gwc.Init(float64(timeoutSec), false, 300, true, float64(refreshIntervalSec), false); err != nil {
|
|
log.Warnf("Gemini Web init failed for %s: %v. Will retry in background.", client.GetEmail(), err)
|
|
go client.backgroundInitRetry()
|
|
} else {
|
|
client.cookieRotationStarted = true
|
|
client.registerModelsOnce()
|
|
// Persist immediately once after successful init to capture fresh cookies
|
|
_ = client.SaveTokenToFile()
|
|
client.startCookiePersist()
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
func (c *GeminiWebClient) Init() error {
|
|
ts := c.tokenStorage.(*gemini.GeminiWebTokenStorage)
|
|
c.gwc = geminiWeb.NewGeminiClient(ts.Secure1PSID, ts.Secure1PSIDTS, c.cfg.ProxyURL, geminiWeb.WithAccountLabel(c.GetEmail()))
|
|
timeoutSec := geminiWebDefaultTimeoutSec
|
|
refreshIntervalSec := c.cfg.GeminiWeb.TokenRefreshSeconds
|
|
if refreshIntervalSec <= 0 {
|
|
refreshIntervalSec = geminiWebDefaultRefreshIntervalSec
|
|
}
|
|
if err := c.gwc.Init(float64(timeoutSec), false, 300, true, float64(refreshIntervalSec), false); err != nil {
|
|
return err
|
|
}
|
|
c.registerModelsOnce()
|
|
// Persist immediately once after successful init to capture fresh cookies
|
|
_ = c.SaveTokenToFile()
|
|
c.startCookiePersist()
|
|
return nil
|
|
}
|
|
|
|
// IsReady reports whether the underlying Gemini Web client is initialized and running.
|
|
func (c *GeminiWebClient) IsReady() bool {
|
|
return c != nil && c.gwc != nil && c.gwc.Running
|
|
}
|
|
|
|
func (c *GeminiWebClient) registerModelsOnce() {
|
|
if c.modelsRegistered {
|
|
return
|
|
}
|
|
c.RegisterModels(GEMINIWEB, geminiWeb.GetGeminiWebAliasedModels())
|
|
c.modelsRegistered = true
|
|
}
|
|
|
|
// EnsureRegistered registers models if the client is ready and not yet registered.
|
|
// It is safe to call multiple times.
|
|
func (c *GeminiWebClient) EnsureRegistered() {
|
|
if c.IsReady() {
|
|
c.registerModelsOnce()
|
|
}
|
|
}
|
|
|
|
func (c *GeminiWebClient) Type() string { return GEMINIWEB }
|
|
func (c *GeminiWebClient) Provider() string { return GEMINIWEB }
|
|
func (c *GeminiWebClient) CanProvideModel(modelName string) bool {
|
|
geminiWeb.EnsureGeminiWebAliasMap()
|
|
_, ok := geminiWeb.GeminiWebAliasMap[strings.ToLower(modelName)]
|
|
return ok
|
|
}
|
|
func (c *GeminiWebClient) GetEmail() string {
|
|
base := filepath.Base(c.tokenFilePath)
|
|
return strings.TrimSuffix(base, ".json")
|
|
}
|
|
func (c *GeminiWebClient) StableClientID() string {
|
|
if c.stableClientID != "" {
|
|
return c.stableClientID
|
|
}
|
|
sum := geminiWeb.Sha256Hex(c.GetEmail())
|
|
if len(sum) > 16 {
|
|
sum = sum[:16]
|
|
}
|
|
return "gemini-web-" + sum
|
|
}
|
|
|
|
// useReusableContext reports whether JSON-based reusable conversation matching is enabled.
|
|
// Controlled by `gemini-web.context` boolean in config (true enables reuse, default true).
|
|
func (c *GeminiWebClient) useReusableContext() bool {
|
|
if c == nil || c.cfg == nil {
|
|
return true
|
|
}
|
|
return c.cfg.GeminiWeb.Context
|
|
}
|
|
|
|
// chatPrep encapsulates shared request preparation results for both stream and non-stream flows.
|
|
type chatPrep struct {
|
|
chat *geminiWeb.ChatSession
|
|
prompt string
|
|
uploaded []string
|
|
reuse bool
|
|
metaLen int
|
|
handlerType string
|
|
tagged bool
|
|
underlying string
|
|
cleaned []geminiWeb.RoleText
|
|
translatedRaw []byte
|
|
}
|
|
|
|
// prepareChat performs translation, message parsing, metadata reuse, prompt build and StartChat.
|
|
func (c *GeminiWebClient) prepareChat(ctx context.Context, modelName string, rawJSON []byte, isStream bool) (*chatPrep, *interfaces.ErrorMessage) {
|
|
res := &chatPrep{}
|
|
if handler, ok := ctx.Value("handler").(interfaces.APIHandler); ok {
|
|
res.handlerType = handler.HandlerType()
|
|
rawJSON = translator.Request(res.handlerType, c.Type(), modelName, rawJSON, isStream)
|
|
}
|
|
res.translatedRaw = rawJSON
|
|
if c.cfg.RequestLog {
|
|
if ginContext, ok := ctx.Value("gin").(*gin.Context); ok {
|
|
ginContext.Set("API_REQUEST", rawJSON)
|
|
}
|
|
}
|
|
messages, files, mimes, msgFileIdx, err := geminiWeb.ParseMessagesAndFiles(rawJSON)
|
|
if err != nil {
|
|
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: fmt.Errorf("bad request: %w", err)}
|
|
}
|
|
cleaned := geminiWeb.SanitizeAssistantMessages(messages)
|
|
res.cleaned = cleaned
|
|
res.underlying = geminiWeb.MapAliasToUnderlying(modelName)
|
|
model, err := geminiWeb.ModelFromName(res.underlying)
|
|
if err != nil {
|
|
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: err}
|
|
}
|
|
|
|
var (
|
|
meta []string
|
|
useMsgs []geminiWeb.RoleText
|
|
filesSubset [][]byte
|
|
mimesSubset []string
|
|
)
|
|
if c.useReusableContext() {
|
|
reuseMeta, remaining := c.findReusableSession(res.underlying, cleaned)
|
|
res.reuse = len(reuseMeta) > 0
|
|
if res.reuse {
|
|
meta = reuseMeta
|
|
if len(remaining) == 1 {
|
|
useMsgs = []geminiWeb.RoleText{remaining[0]}
|
|
} else {
|
|
useMsgs = remaining
|
|
}
|
|
} else {
|
|
// Fallback: only when there is clear continuation context.
|
|
// Require at least two messages and the previous turn is assistant.
|
|
if len(cleaned) >= 2 && strings.EqualFold(cleaned[len(cleaned)-2].Role, "assistant") {
|
|
// Prefer canonical (underlying) model key; fall back to alias key for backward-compatibility.
|
|
keyUnderlying := geminiWeb.AccountMetaKey(c.GetEmail(), res.underlying)
|
|
keyAlias := geminiWeb.AccountMetaKey(c.GetEmail(), modelName)
|
|
c.convMutex.RLock()
|
|
fallbackMeta := c.convStore[keyUnderlying]
|
|
if len(fallbackMeta) == 0 {
|
|
fallbackMeta = c.convStore[keyAlias]
|
|
}
|
|
c.convMutex.RUnlock()
|
|
if len(fallbackMeta) > 0 {
|
|
meta = fallbackMeta
|
|
// Only send the newest user message as continuation.
|
|
useMsgs = []geminiWeb.RoleText{cleaned[len(cleaned)-1]}
|
|
res.reuse = true
|
|
} else {
|
|
meta = nil
|
|
useMsgs = cleaned
|
|
}
|
|
} else {
|
|
// No safe continuation context detected; do not reuse metadata.
|
|
meta = nil
|
|
useMsgs = cleaned
|
|
}
|
|
}
|
|
res.tagged = geminiWeb.NeedRoleTags(useMsgs)
|
|
if res.reuse && len(useMsgs) == 1 {
|
|
res.tagged = false
|
|
}
|
|
if res.reuse && len(useMsgs) == 1 && len(messages) > 0 {
|
|
lastIdx := len(messages) - 1
|
|
if lastIdx >= 0 && lastIdx < len(msgFileIdx) {
|
|
for _, fi := range msgFileIdx[lastIdx] {
|
|
if fi >= 0 && fi < len(files) {
|
|
filesSubset = append(filesSubset, files[fi])
|
|
if fi < len(mimes) {
|
|
mimesSubset = append(mimesSubset, mimes[fi])
|
|
} else {
|
|
mimesSubset = append(mimesSubset, "")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
filesSubset = files
|
|
mimesSubset = mimes
|
|
}
|
|
res.metaLen = len(meta)
|
|
} else {
|
|
// Context reuse disabled: use account-level metadata if present.
|
|
// Check both canonical model and alias for compatibility.
|
|
keyUnderlying := geminiWeb.AccountMetaKey(c.GetEmail(), res.underlying)
|
|
keyAlias := geminiWeb.AccountMetaKey(c.GetEmail(), modelName)
|
|
c.convMutex.RLock()
|
|
if v, ok := c.convStore[keyUnderlying]; ok && len(v) > 0 {
|
|
meta = v
|
|
} else {
|
|
meta = c.convStore[keyAlias]
|
|
}
|
|
c.convMutex.RUnlock()
|
|
useMsgs = cleaned
|
|
res.tagged = geminiWeb.NeedRoleTags(useMsgs)
|
|
filesSubset = files
|
|
mimesSubset = mimes
|
|
res.reuse = false
|
|
res.metaLen = len(meta)
|
|
}
|
|
|
|
uploadedFiles, upErr := geminiWeb.MaterializeInlineFiles(filesSubset, mimesSubset)
|
|
if upErr != nil {
|
|
return nil, upErr
|
|
}
|
|
res.uploaded = uploadedFiles
|
|
|
|
// XML hint follows code-mode only:
|
|
// - code-mode = true -> enable XML wrapping hint
|
|
// - code-mode = false -> disable XML wrapping hint
|
|
enableXMLHint := c.cfg != nil && c.cfg.GeminiWeb.CodeMode
|
|
useMsgs = geminiWeb.AppendXMLWrapHintIfNeeded(useMsgs, !enableXMLHint)
|
|
res.prompt = geminiWeb.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")}
|
|
}
|
|
c.appendUpstreamRequestLog(ctx, modelName, res.tagged, true, res.prompt, len(uploadedFiles), res.reuse, res.metaLen)
|
|
gem := c.getConfiguredGem()
|
|
res.chat = c.gwc.StartChat(model, gem, meta)
|
|
res.chat.SetRequestedModel(modelName)
|
|
return res, nil
|
|
}
|
|
|
|
func (c *GeminiWebClient) SendRawMessage(ctx context.Context, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage) {
|
|
original := bytes.Clone(rawJSON)
|
|
prep, prepErr := c.prepareChat(ctx, modelName, rawJSON, false)
|
|
if prepErr != nil {
|
|
return nil, prepErr
|
|
}
|
|
defer geminiWeb.CleanupFiles(prep.uploaded)
|
|
log.Debugf("Use Gemini Web account %s for model %s", c.GetEmail(), modelName)
|
|
out, genErr := geminiWeb.SendWithSplit(prep.chat, prep.prompt, prep.uploaded, c.cfg)
|
|
if genErr != nil {
|
|
return nil, c.handleSendError(genErr, modelName)
|
|
}
|
|
gemBytes, errMsg := c.handleSendSuccess(ctx, prep, &out, modelName)
|
|
if errMsg != nil {
|
|
return nil, errMsg
|
|
}
|
|
if translator.NeedConvert(prep.handlerType, c.Type()) {
|
|
var param any
|
|
out := translator.ResponseNonStream(prep.handlerType, c.Type(), ctx, modelName, original, prep.translatedRaw, gemBytes, ¶m)
|
|
if prep.handlerType == OPENAI && out != "" {
|
|
newID := fmt.Sprintf("chatcmpl-%x", time.Now().UnixNano())
|
|
if v := gjson.Parse(out).Get("id"); v.Exists() {
|
|
out, _ = sjson.Set(out, "id", newID)
|
|
}
|
|
}
|
|
return []byte(out), nil
|
|
}
|
|
return gemBytes, nil
|
|
}
|
|
|
|
func (c *GeminiWebClient) SendRawMessageStream(ctx context.Context, modelName string, rawJSON []byte, alt string) (<-chan []byte, <-chan *interfaces.ErrorMessage) {
|
|
dataChan := make(chan []byte)
|
|
errChan := make(chan *interfaces.ErrorMessage)
|
|
go func() {
|
|
defer close(dataChan)
|
|
defer close(errChan)
|
|
original := bytes.Clone(rawJSON)
|
|
prep, prepErr := c.prepareChat(ctx, modelName, rawJSON, true)
|
|
if prepErr != nil {
|
|
errChan <- prepErr
|
|
return
|
|
}
|
|
defer geminiWeb.CleanupFiles(prep.uploaded)
|
|
log.Debugf("Use Gemini Web account %s for model %s", c.GetEmail(), modelName)
|
|
out, genErr := geminiWeb.SendWithSplit(prep.chat, prep.prompt, prep.uploaded, c.cfg)
|
|
if genErr != nil {
|
|
errChan <- c.handleSendError(genErr, modelName)
|
|
return
|
|
}
|
|
gemBytes, errMsg := c.handleSendSuccess(ctx, prep, &out, modelName)
|
|
if errMsg != nil {
|
|
errChan <- errMsg
|
|
return
|
|
}
|
|
// Branch by handler type:
|
|
// - Native Gemini handler: emit at most two messages (thoughts, then others), no [DONE].
|
|
// - Translated handlers (e.g., OpenAI Responses): split first payload into two (if thoughts exist), then emit translator's [DONE].
|
|
if prep.handlerType == GEMINI {
|
|
root := gjson.ParseBytes(gemBytes)
|
|
parts := root.Get("candidates.0.content.parts")
|
|
if parts.Exists() && parts.IsArray() {
|
|
var thoughtArr, otherArr strings.Builder
|
|
thoughtCount := 0
|
|
thoughtArr.WriteByte('[')
|
|
otherArr.WriteByte('[')
|
|
firstThought := true
|
|
firstOther := true
|
|
parts.ForEach(func(_, part gjson.Result) bool {
|
|
if part.Get("thought").Bool() {
|
|
if !firstThought {
|
|
thoughtArr.WriteByte(',')
|
|
}
|
|
thoughtArr.WriteString(part.Raw)
|
|
firstThought = false
|
|
thoughtCount++
|
|
} else {
|
|
if !firstOther {
|
|
otherArr.WriteByte(',')
|
|
}
|
|
otherArr.WriteString(part.Raw)
|
|
firstOther = false
|
|
}
|
|
return true
|
|
})
|
|
thoughtArr.WriteByte(']')
|
|
otherArr.WriteByte(']')
|
|
if thoughtCount > 0 {
|
|
thoughtOnly, _ := sjson.SetRaw(string(gemBytes), "candidates.0.content.parts", thoughtArr.String())
|
|
// Only when the first chunk contains thoughts, set finishReason to null
|
|
thoughtOnly, _ = sjson.SetRaw(thoughtOnly, "candidates.0.finishReason", "null")
|
|
dataChan <- []byte(thoughtOnly)
|
|
}
|
|
othersOnly, _ := sjson.SetRaw(string(gemBytes), "candidates.0.content.parts", otherArr.String())
|
|
// Do not modify finishReason for non-thought first chunks or subsequent chunks
|
|
dataChan <- []byte(othersOnly)
|
|
return
|
|
}
|
|
// Fallback: no parts array; emit single message
|
|
// No special handling when no parts or no thoughts
|
|
dataChan <- gemBytes
|
|
return
|
|
}
|
|
|
|
// Translated handlers: when code-mode is ON, merge <think> into content and emit a single chunk; otherwise keep split.
|
|
newCtx := context.WithValue(ctx, "alt", alt)
|
|
var param any
|
|
if c.cfg.GeminiWeb.CodeMode {
|
|
combined := mergeThoughtIntoSingleContent(gemBytes)
|
|
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, combined, ¶m)
|
|
for _, l := range lines {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
}
|
|
}
|
|
done := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte("[DONE]"), ¶m)
|
|
for _, l := range done {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
root := gjson.ParseBytes(gemBytes)
|
|
parts := root.Get("candidates.0.content.parts")
|
|
if parts.Exists() && parts.IsArray() {
|
|
// Non code-mode: perform pseudo streaming by splitting text into small chunks
|
|
if !c.cfg.GeminiWeb.CodeMode {
|
|
chunkSize := 40
|
|
fr := strings.ToUpper(root.Get("candidates.0.finishReason").String())
|
|
units := make([][]byte, 0, 16)
|
|
units = append(units, buildPseudoUnits(gemBytes, true, chunkSize, false)...)
|
|
other := buildPseudoUnits(gemBytes, false, chunkSize, false)
|
|
if len(other) > 0 && fr != "" {
|
|
if updated, err := sjson.SetBytes(other[len(other)-1], "candidates.0.finishReason", fr); err == nil {
|
|
other[len(other)-1] = updated
|
|
}
|
|
}
|
|
units = append(units, other...)
|
|
for _, u := range units {
|
|
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, u, ¶m)
|
|
for _, l := range lines {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
// 80ms interval between pseudo chunks
|
|
time.Sleep(80 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
// translator-level done signal
|
|
done := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte("[DONE]"), ¶m)
|
|
for _, l := range done {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
var thoughtArr, otherArr strings.Builder
|
|
thoughtCount := 0
|
|
thoughtArr.WriteByte('[')
|
|
otherArr.WriteByte('[')
|
|
firstThought := true
|
|
firstOther := true
|
|
parts.ForEach(func(_, part gjson.Result) bool {
|
|
if part.Get("thought").Bool() {
|
|
if !firstThought {
|
|
thoughtArr.WriteByte(',')
|
|
}
|
|
thoughtArr.WriteString(part.Raw)
|
|
firstThought = false
|
|
thoughtCount++
|
|
} else {
|
|
if !firstOther {
|
|
otherArr.WriteByte(',')
|
|
}
|
|
otherArr.WriteString(part.Raw)
|
|
firstOther = false
|
|
}
|
|
return true
|
|
})
|
|
thoughtArr.WriteByte(']')
|
|
otherArr.WriteByte(']')
|
|
|
|
if thoughtCount > 0 {
|
|
thoughtOnly, _ := sjson.SetRaw(string(gemBytes), "candidates.0.content.parts", thoughtArr.String())
|
|
// Only when the first chunk contains thoughts, suppress finishReason before translation
|
|
thoughtOnly, _ = sjson.Delete(thoughtOnly, "candidates.0.finishReason")
|
|
// If CodeMode enabled, demote thought parts to content before translating
|
|
if c.cfg.GeminiWeb.CodeMode {
|
|
processed := collapseThoughtPartsToContent([]byte(thoughtOnly))
|
|
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, processed, ¶m)
|
|
for _, l := range lines {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
// Apply 80ms delay between pseudo chunks in non-code mode
|
|
if !c.cfg.GeminiWeb.CodeMode {
|
|
time.Sleep(80 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte(thoughtOnly), ¶m)
|
|
for _, l := range lines {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
// Apply 80ms delay between pseudo chunks in non-code mode
|
|
if !c.cfg.GeminiWeb.CodeMode {
|
|
time.Sleep(80 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
othersOnly, _ := sjson.SetRaw(string(gemBytes), "candidates.0.content.parts", otherArr.String())
|
|
// Do not modify finishReason if there is no thought chunk
|
|
if c.cfg.GeminiWeb.CodeMode {
|
|
processed := collapseThoughtPartsToContent([]byte(othersOnly))
|
|
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, processed, ¶m)
|
|
for _, l := range lines {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
// Apply 80ms delay between pseudo chunks in non-code mode
|
|
if !c.cfg.GeminiWeb.CodeMode {
|
|
time.Sleep(80 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte(othersOnly), ¶m)
|
|
for _, l := range lines {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
// Apply 80ms delay between pseudo chunks in non-code mode
|
|
if !c.cfg.GeminiWeb.CodeMode {
|
|
time.Sleep(80 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
done := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte("[DONE]"), ¶m)
|
|
for _, l := range done {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
// Fallback: no parts array; forward as a single translated payload then DONE
|
|
// If code-mode is ON, still merge to a single content block.
|
|
if c.cfg.GeminiWeb.CodeMode {
|
|
processed := mergeThoughtIntoSingleContent(gemBytes)
|
|
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, processed, ¶m)
|
|
for _, l := range lines {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
}
|
|
}
|
|
} else {
|
|
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, gemBytes, ¶m)
|
|
for _, l := range lines {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
// Apply 80ms delay between pseudo chunks in non-code mode
|
|
if !c.cfg.GeminiWeb.CodeMode {
|
|
time.Sleep(80 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
done := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte("[DONE]"), ¶m)
|
|
for _, l := range done {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
}
|
|
}
|
|
}()
|
|
return dataChan, errChan
|
|
}
|
|
|
|
func (c *GeminiWebClient) handleSendError(genErr error, modelName string) *interfaces.ErrorMessage {
|
|
log.Errorf("failed to generate content: %v", genErr)
|
|
status := 500
|
|
var eUsage *geminiWeb.UsageLimitExceeded
|
|
var eTempBlock *geminiWeb.TemporarilyBlocked
|
|
if errors.As(genErr, &eUsage) || errors.As(genErr, &eTempBlock) {
|
|
status = 429
|
|
}
|
|
var eModelInvalid *geminiWeb.ModelInvalid
|
|
if status == 500 && errors.As(genErr, &eModelInvalid) {
|
|
status = 400
|
|
}
|
|
var eValue *geminiWeb.ValueError
|
|
if status == 500 && errors.As(genErr, &eValue) {
|
|
status = 400
|
|
}
|
|
var eTimeout *geminiWeb.TimeoutError
|
|
if status == 500 && errors.As(genErr, &eTimeout) {
|
|
status = 504
|
|
}
|
|
if status == 429 {
|
|
now := time.Now()
|
|
c.modelQuotaExceeded[modelName] = &now
|
|
c.SetModelQuotaExceeded(modelName)
|
|
}
|
|
return &interfaces.ErrorMessage{StatusCode: status, Error: genErr}
|
|
}
|
|
|
|
func (c *GeminiWebClient) handleSendSuccess(ctx context.Context, prep *chatPrep, output *geminiWeb.ModelOutput, modelName string) ([]byte, *interfaces.ErrorMessage) {
|
|
delete(c.modelQuotaExceeded, modelName)
|
|
c.ClearModelQuotaExceeded(modelName)
|
|
gemBytes, err := geminiWeb.ConvertOutputToGemini(output, modelName, prep.prompt)
|
|
if err != nil {
|
|
return nil, &interfaces.ErrorMessage{StatusCode: 500, Error: err}
|
|
}
|
|
c.AddAPIResponseData(ctx, gemBytes)
|
|
if output != nil {
|
|
metaAfter := prep.chat.Metadata()
|
|
if len(metaAfter) > 0 {
|
|
// Store under canonical (underlying) model key for stability across aliases.
|
|
key := geminiWeb.AccountMetaKey(c.GetEmail(), prep.underlying)
|
|
c.convMutex.Lock()
|
|
c.convStore[key] = metaAfter
|
|
snapshot := c.convStore
|
|
c.convMutex.Unlock()
|
|
_ = geminiWeb.SaveConvStore(geminiWeb.ConvStorePath(c.tokenFilePath), snapshot)
|
|
}
|
|
if c.useReusableContext() {
|
|
c.storeConversationJSON(prep.underlying, prep.cleaned, prep.chat.Metadata(), output)
|
|
}
|
|
}
|
|
return gemBytes, nil
|
|
}
|
|
|
|
// collapseThoughtPartsToContent flattens Gemini "thought" parts into regular text parts
|
|
// so downstream OpenAI translators emit them as `content` instead of `reasoning_content`.
|
|
// It preserves part order and keeps non-text parts intact.
|
|
func collapseThoughtPartsToContent(gemBytes []byte) []byte {
|
|
parts := gjson.GetBytes(gemBytes, "candidates.0.content.parts")
|
|
if !parts.Exists() || !parts.IsArray() {
|
|
return gemBytes
|
|
}
|
|
arr := parts.Array()
|
|
newParts := make([]json.RawMessage, 0, len(arr))
|
|
for _, part := range arr {
|
|
if t := part.Get("text"); t.Exists() {
|
|
obj, _ := json.Marshal(map[string]string{"text": t.String()})
|
|
newParts = append(newParts, obj)
|
|
} else {
|
|
newParts = append(newParts, json.RawMessage(part.Raw))
|
|
}
|
|
}
|
|
var sb strings.Builder
|
|
sb.WriteByte('[')
|
|
for i, p := range newParts {
|
|
if i > 0 {
|
|
sb.WriteByte(',')
|
|
}
|
|
sb.Write(p)
|
|
}
|
|
sb.WriteByte(']')
|
|
if updated, err := sjson.SetRawBytes(gemBytes, "candidates.0.content.parts", []byte(sb.String())); err == nil {
|
|
return updated
|
|
}
|
|
return gemBytes
|
|
}
|
|
|
|
// mergeThoughtIntoSingleContent merges all thought text and normal text into one text part.
|
|
// The output places the thought text inside <think>...</think> followed by a newline and then the normal text.
|
|
// Non-text parts are ignored for the combined output chunk.
|
|
func mergeThoughtIntoSingleContent(gemBytes []byte) []byte {
|
|
parts := gjson.GetBytes(gemBytes, "candidates.0.content.parts")
|
|
if !parts.Exists() || !parts.IsArray() {
|
|
return gemBytes
|
|
}
|
|
var thought strings.Builder
|
|
var visible strings.Builder
|
|
parts.ForEach(func(_, part gjson.Result) bool {
|
|
if t := part.Get("text"); t.Exists() {
|
|
if part.Get("thought").Bool() {
|
|
thought.WriteString(t.String())
|
|
} else {
|
|
visible.WriteString(t.String())
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
var combined strings.Builder
|
|
if thought.Len() > 0 {
|
|
combined.WriteString("<think>")
|
|
combined.WriteString(thought.String())
|
|
combined.WriteString("</think>\n\n")
|
|
}
|
|
combined.WriteString(visible.String())
|
|
|
|
// Build a single-part array
|
|
obj, _ := json.Marshal(map[string]string{"text": combined.String()})
|
|
var arr strings.Builder
|
|
arr.WriteByte('[')
|
|
arr.Write(obj)
|
|
arr.WriteByte(']')
|
|
if updated, err := sjson.SetRawBytes(gemBytes, "candidates.0.content.parts", []byte(arr.String())); err == nil {
|
|
return updated
|
|
}
|
|
return gemBytes
|
|
}
|
|
|
|
func (c *GeminiWebClient) appendUpstreamRequestLog(ctx context.Context, modelName string, useTags, explicitContext bool, prompt string, filesCount int, reuse bool, metaLen int) {
|
|
if !c.cfg.RequestLog {
|
|
return
|
|
}
|
|
ginContext, ok := ctx.Value("gin").(*gin.Context)
|
|
if !ok || ginContext == nil {
|
|
return
|
|
}
|
|
preview := geminiWeb.BuildUpstreamRequestLog(c.GetEmail(), c.useReusableContext(), useTags, explicitContext, prompt, filesCount, reuse, metaLen, c.getConfiguredGem())
|
|
if existing, exists := ginContext.Get("API_REQUEST"); exists {
|
|
if base, ok2 := existing.([]byte); ok2 {
|
|
merged := append(append([]byte{}, base...), []byte(preview)...)
|
|
ginContext.Set("API_REQUEST", merged)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *GeminiWebClient) SendRawTokenCount(ctx context.Context, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage) {
|
|
est := geminiWeb.EstimateTotalTokensFromRawJSON(rawJSON)
|
|
return []byte(fmt.Sprintf(`{"totalTokens":%d}`, est)), nil
|
|
}
|
|
|
|
// 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 {
|
|
if v, ok := c.gwc.Cookies["__Secure-1PSID"]; ok && v != "" {
|
|
ts.Secure1PSID = v
|
|
}
|
|
if v, ok := c.gwc.Cookies["__Secure-1PSIDTS"]; ok && v != "" {
|
|
ts.Secure1PSIDTS = v
|
|
}
|
|
}
|
|
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 cookie snapshot file.
|
|
func (c *GeminiWebClient) startCookiePersist() {
|
|
if c.gwc == nil {
|
|
return
|
|
}
|
|
if c.cookiePersistCancel != nil {
|
|
c.cookiePersistCancel()
|
|
c.cookiePersistCancel = nil
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
c.cookiePersistCancel = cancel
|
|
go func() {
|
|
// Persist cookies at the same cadence as auto-refresh when enabled,
|
|
// otherwise use a coarse default interval.
|
|
persistSec := geminiWebDefaultPersistIntervalSec
|
|
if c.gwc != nil && c.gwc.AutoRefresh {
|
|
if sec := int(c.gwc.RefreshInterval / time.Second); sec > 0 {
|
|
persistSec = sec
|
|
}
|
|
}
|
|
ticker := time.NewTicker(time.Duration(persistSec) * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if c.gwc != nil && c.gwc.Cookies != nil {
|
|
if err := c.SaveTokenToFile(); err != nil {
|
|
log.Errorf("Failed to persist cookie snapshot for %s: %v", c.GetEmail(), err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (c *GeminiWebClient) IsModelQuotaExceeded(model string) bool {
|
|
if t, ok := c.modelQuotaExceeded[model]; ok {
|
|
return time.Since(*t) <= 30*time.Minute
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (c *GeminiWebClient) GetUserAgent() string {
|
|
if ua := geminiWeb.HeadersGemini.Get("User-Agent"); ua != "" {
|
|
return ua
|
|
}
|
|
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
}
|
|
|
|
func (c *GeminiWebClient) GetRequestMutex() *sync.Mutex { return nil }
|
|
|
|
func (c *GeminiWebClient) RefreshTokens(ctx context.Context) error { return c.Init() }
|
|
|
|
// runeChunks splits a string into rune-safe chunks of roughly the given size.
|
|
// It preserves UTF-8 boundaries to avoid breaking characters mid-sequence.
|
|
func runeChunks(s string, size int) []string {
|
|
if size <= 0 || len(s) == 0 {
|
|
return []string{s}
|
|
}
|
|
var chunks []string
|
|
var b strings.Builder
|
|
count := 0
|
|
for _, r := range s {
|
|
b.WriteRune(r)
|
|
count++
|
|
if count >= size {
|
|
chunks = append(chunks, b.String())
|
|
b.Reset()
|
|
count = 0
|
|
}
|
|
}
|
|
if b.Len() > 0 {
|
|
chunks = append(chunks, b.String())
|
|
}
|
|
if len(chunks) == 0 {
|
|
return []string{""}
|
|
}
|
|
return chunks
|
|
}
|
|
|
|
// splitCodeBlocks splits text by triple backtick code fences, marking code blocks.
|
|
type textBlock struct {
|
|
text string
|
|
isCode bool
|
|
}
|
|
|
|
func splitCodeBlocks(s string) []textBlock {
|
|
var blocks []textBlock
|
|
for {
|
|
start := strings.Index(s, "```")
|
|
if start == -1 {
|
|
if s != "" {
|
|
blocks = append(blocks, textBlock{text: s, isCode: false})
|
|
}
|
|
break
|
|
}
|
|
// prepend plain text before code block
|
|
if start > 0 {
|
|
blocks = append(blocks, textBlock{text: s[:start], isCode: false})
|
|
}
|
|
s = s[start+3:]
|
|
end := strings.Index(s, "```")
|
|
if end == -1 {
|
|
// unmatched fence, treat rest as code
|
|
blocks = append(blocks, textBlock{text: s, isCode: true})
|
|
break
|
|
}
|
|
code := s[:end]
|
|
blocks = append(blocks, textBlock{text: code, isCode: true})
|
|
s = s[end+3:]
|
|
}
|
|
return blocks
|
|
}
|
|
|
|
// buildPseudoUnits constructs a series of Gemini JSON payloads that each contain
|
|
// a small portion of the original response's parts. When thoughtOnly is true,
|
|
// it chunks only reasoning text; otherwise it chunks visible text and forwards
|
|
// functionCall parts as separate units. All generated units have finishReason removed.
|
|
func buildPseudoUnits(gemBytes []byte, thoughtOnly bool, chunkSize int, _ bool) [][]byte {
|
|
base := gemBytes
|
|
base, _ = sjson.DeleteBytes(base, "candidates.0.finishReason")
|
|
setParts := func(partsRaw string) []byte {
|
|
s, _ := sjson.SetRawBytes(base, "candidates.0.content.parts", []byte(partsRaw))
|
|
return s
|
|
}
|
|
parts := gjson.GetBytes(gemBytes, "candidates.0.content.parts")
|
|
var units [][]byte
|
|
|
|
if thoughtOnly {
|
|
var buf strings.Builder
|
|
parts.ForEach(func(_, p gjson.Result) bool {
|
|
if p.Get("thought").Bool() {
|
|
if t := p.Get("text"); t.Exists() {
|
|
buf.WriteString(t.String())
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
if buf.Len() > 0 {
|
|
// Chunk by runes to preserve exact formatting (including newlines)
|
|
segs := runeChunks(buf.String(), chunkSize)
|
|
for _, piece := range segs {
|
|
obj := map[string]any{"text": piece, "thought": true}
|
|
arr, _ := json.Marshal([]map[string]any{obj})
|
|
units = append(units, setParts(string(arr)))
|
|
}
|
|
}
|
|
return units
|
|
}
|
|
|
|
// Non-thought: chunk visible text semantically and forward functionCall parts in order
|
|
flushText := func(sb *strings.Builder) {
|
|
if sb.Len() == 0 {
|
|
return
|
|
}
|
|
s := sb.String()
|
|
// Preserve code fences as whole blocks; otherwise chunk by runes
|
|
blocks := splitCodeBlocks(s)
|
|
for _, blk := range blocks {
|
|
if blk.isCode {
|
|
obj := map[string]any{"text": "```" + blk.text + "```"}
|
|
arr, _ := json.Marshal([]map[string]any{obj})
|
|
units = append(units, setParts(string(arr)))
|
|
continue
|
|
}
|
|
for _, piece := range runeChunks(blk.text, chunkSize) {
|
|
if piece == "" {
|
|
continue
|
|
}
|
|
obj := map[string]any{"text": piece}
|
|
arr, _ := json.Marshal([]map[string]any{obj})
|
|
units = append(units, setParts(string(arr)))
|
|
}
|
|
}
|
|
sb.Reset()
|
|
}
|
|
|
|
var textBuf strings.Builder
|
|
parts.ForEach(func(_, p gjson.Result) bool {
|
|
if p.Get("thought").Bool() {
|
|
return true
|
|
}
|
|
if fc := p.Get("functionCall"); fc.Exists() {
|
|
flushText(&textBuf)
|
|
units = append(units, setParts("["+fc.Raw+"]"))
|
|
return true
|
|
}
|
|
if t := p.Get("text"); t.Exists() {
|
|
textBuf.WriteString(t.String())
|
|
return true
|
|
}
|
|
// Unknown part: forward as its own unit
|
|
flushText(&textBuf)
|
|
units = append(units, setParts("["+p.Raw+"]"))
|
|
return true
|
|
})
|
|
flushText(&textBuf)
|
|
return units
|
|
}
|
|
|
|
func (c *GeminiWebClient) backgroundInitRetry() {
|
|
backoffs := []time.Duration{5 * time.Second, 10 * time.Second, 30 * time.Second, 1 * time.Minute, 2 * time.Minute, 5 * time.Minute}
|
|
i := 0
|
|
for {
|
|
if err := c.Init(); err == nil {
|
|
log.Infof("Gemini Web token recovered for %s", c.GetEmail())
|
|
if !c.cookieRotationStarted {
|
|
c.cookieRotationStarted = true
|
|
}
|
|
c.startCookiePersist()
|
|
return
|
|
}
|
|
d := backoffs[i]
|
|
if i < len(backoffs)-1 {
|
|
i++
|
|
}
|
|
time.Sleep(d)
|
|
}
|
|
}
|
|
|
|
// flushCookieSnapshotToMain merges snapshot cookies into the main token file.
|
|
func (c *GeminiWebClient) flushCookieSnapshotToMain() {
|
|
if c.snapshotManager == nil {
|
|
return
|
|
}
|
|
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}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// findReusableSession bridges to gemini-web conversation reuse using in-memory stores.
|
|
func (c *GeminiWebClient) findReusableSession(model string, msgs []geminiWeb.RoleText) ([]string, []geminiWeb.RoleText) {
|
|
c.convMutex.RLock()
|
|
items := c.convData
|
|
index := c.convIndex
|
|
c.convMutex.RUnlock()
|
|
return geminiWeb.FindReusableSessionIn(items, index, c.StableClientID(), c.GetEmail(), model, msgs)
|
|
}
|
|
|
|
// storeConversationJSON persists conversation records and updates in-memory indexes.
|
|
func (c *GeminiWebClient) storeConversationJSON(model string, history []geminiWeb.RoleText, metadata []string, output *geminiWeb.ModelOutput) {
|
|
rec, ok := geminiWeb.BuildConversationRecord(model, c.StableClientID(), history, output, metadata)
|
|
if !ok {
|
|
return
|
|
}
|
|
stableID := rec.ClientID
|
|
stableHash := geminiWeb.HashConversation(stableID, model, rec.Messages)
|
|
legacyID := c.GetEmail()
|
|
legacyHash := geminiWeb.HashConversation(legacyID, model, rec.Messages)
|
|
c.convMutex.Lock()
|
|
c.convData[stableHash] = rec
|
|
c.convIndex["hash:"+stableHash] = stableHash
|
|
if legacyID != stableID {
|
|
c.convIndex["hash:"+legacyHash] = stableHash
|
|
}
|
|
items := c.convData
|
|
index := c.convIndex
|
|
c.convMutex.Unlock()
|
|
_ = geminiWeb.SaveConvData(geminiWeb.ConvDataPath(c.tokenFilePath), items, index)
|
|
}
|
|
|
|
// IsAvailable returns true if the client is available for use.
|
|
func (c *GeminiWebClient) IsAvailable() bool {
|
|
return c.isAvailable
|
|
}
|
|
|
|
// SetUnavailable sets the client to unavailable.
|
|
func (c *GeminiWebClient) SetUnavailable() {
|
|
c.isAvailable = false
|
|
}
|