mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 13:00:52 +08:00
The Gemini Web API client logic has been relocated from `internal/client/gemini-web` to a new, more specific `internal/provider/gemini-web` package. This refactoring improves code organization and modularity by better isolating provider-specific implementations. As a result of this move, the `GeminiWebState` struct and its methods have been exported (capitalized) to make them accessible from the executor. All call sites have been updated to use the new package path and the exported identifiers.
515 lines
15 KiB
Go
515 lines
15 KiB
Go
package geminiwebapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"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/constant"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
const (
|
|
geminiWebDefaultTimeoutSec = 300
|
|
)
|
|
|
|
type GeminiWebState struct {
|
|
cfg *config.Config
|
|
token *gemini.GeminiWebTokenStorage
|
|
storagePath string
|
|
|
|
stableClientID string
|
|
accountID string
|
|
|
|
reqMu sync.Mutex
|
|
client *GeminiClient
|
|
|
|
tokenMu sync.Mutex
|
|
tokenDirty bool
|
|
|
|
convMu sync.RWMutex
|
|
convStore map[string][]string
|
|
convData map[string]ConversationRecord
|
|
convIndex map[string]string
|
|
|
|
lastRefresh time.Time
|
|
}
|
|
|
|
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]ConversationRecord),
|
|
convIndex: make(map[string]string),
|
|
}
|
|
suffix := Sha256Hex(token.Secure1PSID)
|
|
if len(suffix) > 16 {
|
|
suffix = suffix[:16]
|
|
}
|
|
state.stableClientID = "gemini-web-" + suffix
|
|
if storagePath != "" {
|
|
base := strings.TrimSuffix(filepath.Base(storagePath), filepath.Ext(storagePath))
|
|
if base != "" {
|
|
state.accountID = base
|
|
} else {
|
|
state.accountID = suffix
|
|
}
|
|
} else {
|
|
state.accountID = suffix
|
|
}
|
|
state.loadConversationCaches()
|
|
return state
|
|
}
|
|
|
|
func (s *GeminiWebState) loadConversationCaches() {
|
|
if path := s.convStorePath(); path != "" {
|
|
if store, err := LoadConvStore(path); err == nil {
|
|
s.convStore = store
|
|
}
|
|
}
|
|
if path := s.convDataPath(); path != "" {
|
|
if items, index, err := LoadConvData(path); err == nil {
|
|
s.convData = items
|
|
s.convIndex = index
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *GeminiWebState) convStorePath() string {
|
|
base := s.storagePath
|
|
if base == "" {
|
|
base = s.accountID + ".json"
|
|
}
|
|
return ConvStorePath(base)
|
|
}
|
|
|
|
func (s *GeminiWebState) convDataPath() string {
|
|
base := s.storagePath
|
|
if base == "" {
|
|
base = s.accountID + ".json"
|
|
}
|
|
return ConvDataPath(base)
|
|
}
|
|
|
|
func (s *GeminiWebState) GetRequestMutex() *sync.Mutex { return &s.reqMu }
|
|
|
|
func (s *GeminiWebState) EnsureClient() error {
|
|
if s.client != nil && s.client.Running {
|
|
return nil
|
|
}
|
|
proxyURL := ""
|
|
if s.cfg != nil {
|
|
proxyURL = s.cfg.ProxyURL
|
|
}
|
|
s.client = NewGeminiClient(
|
|
s.token.Secure1PSID,
|
|
s.token.Secure1PSIDTS,
|
|
proxyURL,
|
|
)
|
|
timeout := geminiWebDefaultTimeoutSec
|
|
if err := s.client.Init(float64(timeout), false); err != nil {
|
|
s.client = nil
|
|
return err
|
|
}
|
|
s.lastRefresh = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (s *GeminiWebState) Refresh(ctx context.Context) error {
|
|
_ = ctx
|
|
proxyURL := ""
|
|
if s.cfg != nil {
|
|
proxyURL = s.cfg.ProxyURL
|
|
}
|
|
s.client = NewGeminiClient(
|
|
s.token.Secure1PSID,
|
|
s.token.Secure1PSIDTS,
|
|
proxyURL,
|
|
)
|
|
timeout := geminiWebDefaultTimeoutSec
|
|
if err := s.client.Init(float64(timeout), false); err != nil {
|
|
return err
|
|
}
|
|
// Attempt rotation proactively to persist new TS sooner
|
|
if newTS, err := s.client.RotateTS(); err == nil && newTS != "" && newTS != s.token.Secure1PSIDTS {
|
|
s.tokenMu.Lock()
|
|
s.token.Secure1PSIDTS = newTS
|
|
s.tokenDirty = true
|
|
if s.client != nil && s.client.Cookies != nil {
|
|
s.client.Cookies["__Secure-1PSIDTS"] = newTS
|
|
}
|
|
s.tokenMu.Unlock()
|
|
}
|
|
s.lastRefresh = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (s *GeminiWebState) TokenSnapshot() *gemini.GeminiWebTokenStorage {
|
|
s.tokenMu.Lock()
|
|
defer s.tokenMu.Unlock()
|
|
c := *s.token
|
|
return &c
|
|
}
|
|
|
|
type geminiWebPrepared struct {
|
|
handlerType string
|
|
translatedRaw []byte
|
|
prompt string
|
|
uploaded []string
|
|
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) {
|
|
res := &geminiWebPrepared{originalRaw: original}
|
|
res.translatedRaw = bytes.Clone(rawJSON)
|
|
if handler, ok := ctx.Value("handler").(interfaces.APIHandler); ok && handler != nil {
|
|
res.handlerType = handler.HandlerType()
|
|
res.translatedRaw = translator.Request(res.handlerType, constant.GeminiWeb, modelName, res.translatedRaw, stream)
|
|
}
|
|
recordAPIRequest(ctx, s.cfg, 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 := SanitizeAssistantMessages(messages)
|
|
res.cleaned = cleaned
|
|
res.underlying = MapAliasToUnderlying(modelName)
|
|
model, err := ModelFromName(res.underlying)
|
|
if err != nil {
|
|
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: err}
|
|
}
|
|
|
|
var meta []string
|
|
useMsgs := cleaned
|
|
filesSubset := files
|
|
mimesSubset := mimes
|
|
|
|
if s.useReusableContext() {
|
|
reuseMeta, remaining := s.findReusableSession(res.underlying, cleaned)
|
|
if len(reuseMeta) > 0 {
|
|
res.reuse = true
|
|
meta = reuseMeta
|
|
if len(remaining) == 1 {
|
|
useMsgs = []RoleText{remaining[0]}
|
|
} else if len(remaining) > 1 {
|
|
useMsgs = remaining
|
|
} else if len(cleaned) > 0 {
|
|
useMsgs = []RoleText{cleaned[len(cleaned)-1]}
|
|
}
|
|
if len(useMsgs) == 1 && len(messages) > 0 && len(msgFileIdx) == len(messages) {
|
|
lastIdx := len(msgFileIdx) - 1
|
|
idxs := msgFileIdx[lastIdx]
|
|
if len(idxs) > 0 {
|
|
filesSubset = make([][]byte, 0, len(idxs))
|
|
mimesSubset = make([]string, 0, len(idxs))
|
|
for _, fi := range idxs {
|
|
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 = nil
|
|
mimesSubset = nil
|
|
}
|
|
} else {
|
|
filesSubset = nil
|
|
mimesSubset = nil
|
|
}
|
|
} else {
|
|
if len(cleaned) >= 2 && strings.EqualFold(cleaned[len(cleaned)-2].Role, "assistant") {
|
|
keyUnderlying := AccountMetaKey(s.accountID, res.underlying)
|
|
keyAlias := AccountMetaKey(s.accountID, modelName)
|
|
s.convMu.RLock()
|
|
fallbackMeta := s.convStore[keyUnderlying]
|
|
if len(fallbackMeta) == 0 {
|
|
fallbackMeta = s.convStore[keyAlias]
|
|
}
|
|
s.convMu.RUnlock()
|
|
if len(fallbackMeta) > 0 {
|
|
meta = fallbackMeta
|
|
useMsgs = []RoleText{cleaned[len(cleaned)-1]}
|
|
res.reuse = true
|
|
filesSubset = nil
|
|
mimesSubset = nil
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
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
|
|
} else {
|
|
meta = s.convStore[keyAlias]
|
|
}
|
|
s.convMu.RUnlock()
|
|
}
|
|
|
|
res.tagged = NeedRoleTags(useMsgs)
|
|
if res.reuse && len(useMsgs) == 1 {
|
|
res.tagged = false
|
|
}
|
|
|
|
enableXML := s.cfg != nil && s.cfg.GeminiWeb.CodeMode
|
|
useMsgs = AppendXMLWrapHintIfNeeded(useMsgs, !enableXML)
|
|
|
|
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 := MaterializeInlineFiles(filesSubset, mimesSubset)
|
|
if upErr != nil {
|
|
return nil, upErr
|
|
}
|
|
res.uploaded = uploaded
|
|
|
|
if err = s.EnsureClient(); err != nil {
|
|
return nil, &interfaces.ErrorMessage{StatusCode: 500, Error: err}
|
|
}
|
|
chat := s.client.StartChat(model, s.getConfiguredGem(), meta)
|
|
chat.SetRequestedModel(modelName)
|
|
res.chat = chat
|
|
|
|
return res, nil
|
|
}
|
|
|
|
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 CleanupFiles(prep.uploaded)
|
|
|
|
output, err := SendWithSplit(prep.chat, prep.prompt, prep.uploaded, s.cfg)
|
|
if err != nil {
|
|
return nil, s.wrapSendError(err), nil
|
|
}
|
|
|
|
// Hook: For gemini-2.5-flash-image-preview, if the API returns only images without any text,
|
|
// inject a small textual summary so that conversation persistence has non-empty assistant text.
|
|
// This helps conversation recovery (conv store) to match sessions reliably.
|
|
if strings.EqualFold(modelName, "gemini-2.5-flash-image-preview") {
|
|
if len(output.Candidates) > 0 {
|
|
c := output.Candidates[output.Chosen]
|
|
hasNoText := strings.TrimSpace(c.Text) == ""
|
|
hasImages := len(c.GeneratedImages) > 0 || len(c.WebImages) > 0
|
|
if hasNoText && hasImages {
|
|
// Build a stable, concise fallback text. Avoid dynamic details to keep hashes stable.
|
|
// Prefer a deterministic phrase with count to aid users while keeping consistency.
|
|
fallback := "Done"
|
|
// Mutate the chosen candidate's text so both response conversion and
|
|
// conversation persistence observe the same fallback.
|
|
output.Candidates[output.Chosen].Text = fallback
|
|
}
|
|
}
|
|
}
|
|
|
|
gemBytes, err := ConvertOutputToGemini(&output, modelName, prep.prompt)
|
|
if err != nil {
|
|
return nil, &interfaces.ErrorMessage{StatusCode: 500, Error: err}, nil
|
|
}
|
|
|
|
s.addAPIResponseData(ctx, gemBytes)
|
|
s.persistConversation(modelName, prep, &output)
|
|
return gemBytes, nil, prep
|
|
}
|
|
|
|
func (s *GeminiWebState) wrapSendError(genErr error) *interfaces.ErrorMessage {
|
|
status := 500
|
|
var usage *UsageLimitExceeded
|
|
var blocked *TemporarilyBlocked
|
|
var invalid *ModelInvalid
|
|
var valueErr *ValueError
|
|
var timeout *TimeoutError
|
|
switch {
|
|
case errors.As(genErr, &usage):
|
|
status = 429
|
|
case errors.As(genErr, &blocked):
|
|
status = 429
|
|
case errors.As(genErr, &invalid):
|
|
status = 400
|
|
case errors.As(genErr, &valueErr):
|
|
status = 400
|
|
case errors.As(genErr, &timeout):
|
|
status = 504
|
|
}
|
|
return &interfaces.ErrorMessage{StatusCode: status, Error: genErr}
|
|
}
|
|
|
|
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 := AccountMetaKey(s.accountID, prep.underlying)
|
|
keyAlias := AccountMetaKey(s.accountID, modelName)
|
|
s.convMu.Lock()
|
|
s.convStore[keyUnderlying] = metadata
|
|
s.convStore[keyAlias] = metadata
|
|
storeSnapshot := make(map[string][]string, len(s.convStore))
|
|
for k, v := range s.convStore {
|
|
if v == nil {
|
|
continue
|
|
}
|
|
cp := make([]string, len(v))
|
|
copy(cp, v)
|
|
storeSnapshot[k] = cp
|
|
}
|
|
s.convMu.Unlock()
|
|
_ = SaveConvStore(s.convStorePath(), storeSnapshot)
|
|
}
|
|
|
|
if !s.useReusableContext() {
|
|
return
|
|
}
|
|
rec, ok := BuildConversationRecord(prep.underlying, s.stableClientID, prep.cleaned, output, metadata)
|
|
if !ok {
|
|
return
|
|
}
|
|
stableHash := HashConversation(rec.ClientID, prep.underlying, rec.Messages)
|
|
accountHash := HashConversation(s.accountID, prep.underlying, rec.Messages)
|
|
|
|
s.convMu.Lock()
|
|
s.convData[stableHash] = rec
|
|
s.convIndex["hash:"+stableHash] = stableHash
|
|
if accountHash != stableHash {
|
|
s.convIndex["hash:"+accountHash] = stableHash
|
|
}
|
|
dataSnapshot := make(map[string]ConversationRecord, len(s.convData))
|
|
for k, v := range s.convData {
|
|
dataSnapshot[k] = v
|
|
}
|
|
indexSnapshot := make(map[string]string, len(s.convIndex))
|
|
for k, v := range s.convIndex {
|
|
indexSnapshot[k] = v
|
|
}
|
|
s.convMu.Unlock()
|
|
_ = SaveConvData(s.convDataPath(), dataSnapshot, indexSnapshot)
|
|
}
|
|
|
|
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 {
|
|
if prep == nil || prep.handlerType == "" {
|
|
return gemBytes
|
|
}
|
|
if !translator.NeedConvert(prep.handlerType, constant.GeminiWeb) {
|
|
return gemBytes
|
|
}
|
|
var param any
|
|
out := translator.ResponseNonStream(prep.handlerType, constant.GeminiWeb, ctx, modelName, prep.originalRaw, prep.translatedRaw, gemBytes, ¶m)
|
|
if prep.handlerType == constant.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)
|
|
}
|
|
|
|
func (s *GeminiWebState) ConvertStream(ctx context.Context, modelName string, prep *geminiWebPrepared, gemBytes []byte) []string {
|
|
if prep == nil || prep.handlerType == "" {
|
|
return []string{string(gemBytes)}
|
|
}
|
|
if !translator.NeedConvert(prep.handlerType, constant.GeminiWeb) {
|
|
return []string{string(gemBytes)}
|
|
}
|
|
var param any
|
|
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 {
|
|
if prep == nil || prep.handlerType == "" {
|
|
return nil
|
|
}
|
|
if !translator.NeedConvert(prep.handlerType, constant.GeminiWeb) {
|
|
return nil
|
|
}
|
|
var param any
|
|
return translator.Response(prep.handlerType, constant.GeminiWeb, ctx, modelName, prep.originalRaw, prep.translatedRaw, []byte("[DONE]"), ¶m)
|
|
}
|
|
|
|
func (s *GeminiWebState) useReusableContext() bool {
|
|
if s.cfg == nil {
|
|
return true
|
|
}
|
|
return s.cfg.GeminiWeb.Context
|
|
}
|
|
|
|
func (s *GeminiWebState) findReusableSession(modelName string, msgs []RoleText) ([]string, []RoleText) {
|
|
s.convMu.RLock()
|
|
items := s.convData
|
|
index := s.convIndex
|
|
s.convMu.RUnlock()
|
|
return FindReusableSessionIn(items, index, s.stableClientID, s.accountID, modelName, msgs)
|
|
}
|
|
|
|
func (s *GeminiWebState) getConfiguredGem() *Gem {
|
|
if s.cfg != nil && s.cfg.GeminiWeb.CodeMode {
|
|
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)
|
|
}
|
|
}
|