diff --git a/.gitignore b/.gitignore
index 5dd2c952..c9ae73b8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ auths/*
.serena/*
AGENTS.md
CLAUDE.md
+GEMINI.md
*.exe
temp/*
cli-proxy-api
\ No newline at end of file
diff --git a/internal/provider/gemini-web/conversation/alias.go b/internal/provider/gemini-web/conversation/alias.go
new file mode 100644
index 00000000..b0481883
--- /dev/null
+++ b/internal/provider/gemini-web/conversation/alias.go
@@ -0,0 +1,80 @@
+package conversation
+
+import (
+ "strings"
+ "sync"
+
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
+)
+
+var (
+ aliasOnce sync.Once
+ aliasMap map[string]string
+)
+
+// EnsureGeminiWebAliasMap populates the alias map once.
+func EnsureGeminiWebAliasMap() {
+ aliasOnce.Do(func() {
+ aliasMap = make(map[string]string)
+ for _, m := range registry.GetGeminiModels() {
+ if m.ID == "gemini-2.5-flash-lite" {
+ continue
+ }
+ if m.ID == "gemini-2.5-flash" {
+ aliasMap["gemini-2.5-flash-image-preview"] = "gemini-2.5-flash"
+ }
+ alias := AliasFromModelID(m.ID)
+ aliasMap[strings.ToLower(alias)] = strings.ToLower(m.ID)
+ }
+ })
+}
+
+// MapAliasToUnderlying normalizes a model alias to its underlying identifier.
+func MapAliasToUnderlying(name string) string {
+ EnsureGeminiWebAliasMap()
+ n := strings.ToLower(strings.TrimSpace(name))
+ if n == "" {
+ return n
+ }
+ if u, ok := aliasMap[n]; ok {
+ return u
+ }
+ const suffix = "-web"
+ if strings.HasSuffix(n, suffix) {
+ return strings.TrimSuffix(n, suffix)
+ }
+ return n
+}
+
+// AliasFromModelID mirrors the original helper for deriving alias IDs.
+func AliasFromModelID(modelID string) string {
+ return modelID + "-web"
+}
+
+// NormalizeModel returns the canonical identifier used for hashing.
+func NormalizeModel(model string) string {
+ return MapAliasToUnderlying(model)
+}
+
+// GetGeminiWebAliasedModels returns alias metadata for registry exposure.
+func GetGeminiWebAliasedModels() []*registry.ModelInfo {
+ EnsureGeminiWebAliasMap()
+ aliased := make([]*registry.ModelInfo, 0)
+ for _, m := range registry.GetGeminiModels() {
+ if m.ID == "gemini-2.5-flash-lite" {
+ continue
+ } else if m.ID == "gemini-2.5-flash" {
+ cpy := *m
+ cpy.ID = "gemini-2.5-flash-image-preview"
+ cpy.Name = "gemini-2.5-flash-image-preview"
+ cpy.DisplayName = "Nano Banana"
+ cpy.Description = "Gemini 2.5 Flash Preview Image"
+ aliased = append(aliased, &cpy)
+ }
+ cpy := *m
+ cpy.ID = AliasFromModelID(m.ID)
+ cpy.Name = cpy.ID
+ aliased = append(aliased, &cpy)
+ }
+ return aliased
+}
diff --git a/internal/provider/gemini-web/conversation/hash.go b/internal/provider/gemini-web/conversation/hash.go
new file mode 100644
index 00000000..a163a3b2
--- /dev/null
+++ b/internal/provider/gemini-web/conversation/hash.go
@@ -0,0 +1,74 @@
+package conversation
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "strings"
+)
+
+// Message represents a minimal role-text pair used for hashing and comparison.
+type Message struct {
+ Role string `json:"role"`
+ Text string `json:"text"`
+}
+
+// StoredMessage mirrors the persisted conversation message structure.
+type StoredMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ Name string `json:"name,omitempty"`
+}
+
+// Sha256Hex computes SHA-256 hex digest for the specified string.
+func Sha256Hex(s string) string {
+ sum := sha256.Sum256([]byte(s))
+ return hex.EncodeToString(sum[:])
+}
+
+// ToStoredMessages converts in-memory messages into the persisted representation.
+func ToStoredMessages(msgs []Message) []StoredMessage {
+ out := make([]StoredMessage, 0, len(msgs))
+ for _, m := range msgs {
+ out = append(out, StoredMessage{Role: m.Role, Content: m.Text})
+ }
+ return out
+}
+
+// StoredToMessages converts stored messages back into the in-memory representation.
+func StoredToMessages(msgs []StoredMessage) []Message {
+ out := make([]Message, 0, len(msgs))
+ for _, m := range msgs {
+ out = append(out, Message{Role: m.Role, Text: m.Content})
+ }
+ return out
+}
+
+// hashMessage normalizes message data and returns a stable digest.
+func hashMessage(m StoredMessage) string {
+ s := fmt.Sprintf(`{"content":%q,"role":%q}`, m.Content, strings.ToLower(m.Role))
+ return Sha256Hex(s)
+}
+
+// HashConversationWithPrefix computes a conversation hash using the provided prefix (client identifier) and model.
+func HashConversationWithPrefix(prefix, model string, msgs []StoredMessage) string {
+ var b strings.Builder
+ b.WriteString(strings.ToLower(strings.TrimSpace(prefix)))
+ b.WriteString("|")
+ b.WriteString(strings.ToLower(strings.TrimSpace(model)))
+ for _, m := range msgs {
+ b.WriteString("|")
+ b.WriteString(hashMessage(m))
+ }
+ return Sha256Hex(b.String())
+}
+
+// HashConversationForAccount keeps compatibility with the per-account hash previously used.
+func HashConversationForAccount(clientID, model string, msgs []StoredMessage) string {
+ return HashConversationWithPrefix(clientID, model, msgs)
+}
+
+// HashConversationGlobal produces a hash suitable for cross-account lookups.
+func HashConversationGlobal(model string, msgs []StoredMessage) string {
+ return HashConversationWithPrefix("global", model, msgs)
+}
diff --git a/internal/provider/gemini-web/conversation/index.go b/internal/provider/gemini-web/conversation/index.go
new file mode 100644
index 00000000..ab06bbf5
--- /dev/null
+++ b/internal/provider/gemini-web/conversation/index.go
@@ -0,0 +1,280 @@
+package conversation
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ bolt "go.etcd.io/bbolt"
+)
+
+const (
+ bucketMatches = "matches"
+ defaultIndexFile = "gemini-web-index.bolt"
+)
+
+// MatchRecord stores persisted mapping metadata for a conversation prefix.
+type MatchRecord struct {
+ AccountLabel string `json:"account_label"`
+ Metadata []string `json:"metadata,omitempty"`
+ PrefixLen int `json:"prefix_len"`
+ UpdatedAt int64 `json:"updated_at"`
+}
+
+// MatchResult combines a persisted record with the hash that produced it.
+type MatchResult struct {
+ Hash string
+ Record MatchRecord
+ Model string
+}
+
+var (
+ indexOnce sync.Once
+ indexDB *bolt.DB
+ indexErr error
+)
+
+func openIndex() (*bolt.DB, error) {
+ indexOnce.Do(func() {
+ path := indexPath()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ indexErr = err
+ return
+ }
+ db, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 2 * time.Second})
+ if err != nil {
+ indexErr = err
+ return
+ }
+ indexDB = db
+ })
+ return indexDB, indexErr
+}
+
+func indexPath() string {
+ wd, err := os.Getwd()
+ if err != nil || wd == "" {
+ wd = "."
+ }
+ return filepath.Join(wd, "conv", defaultIndexFile)
+}
+
+// StoreMatch persists or updates a conversation hash mapping.
+func StoreMatch(hash string, record MatchRecord) error {
+ if strings.TrimSpace(hash) == "" {
+ return errors.New("gemini-web conversation: empty hash")
+ }
+ db, err := openIndex()
+ if err != nil {
+ return err
+ }
+ record.UpdatedAt = time.Now().UTC().Unix()
+ payload, err := json.Marshal(record)
+ if err != nil {
+ return err
+ }
+ return db.Update(func(tx *bolt.Tx) error {
+ bucket, err := tx.CreateBucketIfNotExists([]byte(bucketMatches))
+ if err != nil {
+ return err
+ }
+ // Namespace by account label to avoid cross-account collisions.
+ label := strings.ToLower(strings.TrimSpace(record.AccountLabel))
+ if label == "" {
+ return errors.New("gemini-web conversation: empty account label")
+ }
+ key := []byte(hash + ":" + label)
+ if err := bucket.Put(key, payload); err != nil {
+ return err
+ }
+ // Best-effort cleanup of legacy single-key format (hash -> MatchRecord).
+ // We do not know its label; leave it for lookup fallback/cleanup elsewhere.
+ return nil
+ })
+}
+
+// LookupMatch retrieves a stored mapping.
+// It prefers namespaced entries (hash:label). If multiple labels exist for the same
+// hash, it returns not found to avoid redirecting to the wrong credential.
+// Falls back to legacy single-key entries if present.
+func LookupMatch(hash string) (MatchRecord, bool, error) {
+ db, err := openIndex()
+ if err != nil {
+ return MatchRecord{}, false, err
+ }
+ var foundOne bool
+ var ambiguous bool
+ var firstLabel string
+ var single MatchRecord
+ err = db.View(func(tx *bolt.Tx) error {
+ bucket := tx.Bucket([]byte(bucketMatches))
+ if bucket == nil {
+ return nil
+ }
+ // Scan namespaced keys with prefix "hash:"
+ prefix := []byte(hash + ":")
+ c := bucket.Cursor()
+ for k, v := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = c.Next() {
+ if len(v) == 0 {
+ continue
+ }
+ var rec MatchRecord
+ if err := json.Unmarshal(v, &rec); err != nil {
+ // Ignore malformed; removal is handled elsewhere.
+ continue
+ }
+ if strings.TrimSpace(rec.AccountLabel) == "" || rec.PrefixLen <= 0 {
+ continue
+ }
+ label := strings.ToLower(strings.TrimSpace(rec.AccountLabel))
+ if !foundOne {
+ firstLabel = label
+ single = rec
+ foundOne = true
+ continue
+ }
+ if label != firstLabel {
+ ambiguous = true
+ // Early exit scan; ambiguity detected.
+ return nil
+ }
+ }
+ if foundOne {
+ return nil
+ }
+ // Fallback to legacy single-key format
+ raw := bucket.Get([]byte(hash))
+ if len(raw) == 0 {
+ return nil
+ }
+ return json.Unmarshal(raw, &single)
+ })
+ if err != nil {
+ return MatchRecord{}, false, err
+ }
+ if ambiguous {
+ return MatchRecord{}, false, nil
+ }
+ if strings.TrimSpace(single.AccountLabel) == "" || single.PrefixLen <= 0 {
+ return MatchRecord{}, false, nil
+ }
+ return single, true, nil
+}
+
+// RemoveMatch deletes all mappings for the given hash (all labels and legacy key).
+func RemoveMatch(hash string) error {
+ db, err := openIndex()
+ if err != nil {
+ return err
+ }
+ return db.Update(func(tx *bolt.Tx) error {
+ bucket := tx.Bucket([]byte(bucketMatches))
+ if bucket == nil {
+ return nil
+ }
+ // Delete namespaced entries
+ prefix := []byte(hash + ":")
+ c := bucket.Cursor()
+ for k, _ := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, _ = c.Next() {
+ if err := bucket.Delete(k); err != nil {
+ return err
+ }
+ }
+ // Delete legacy entry
+ _ = bucket.Delete([]byte(hash))
+ return nil
+ })
+}
+
+// RemoveMatchForLabel deletes the mapping for the given hash and label only.
+func RemoveMatchForLabel(hash, label string) error {
+ label = strings.ToLower(strings.TrimSpace(label))
+ if strings.TrimSpace(hash) == "" || label == "" {
+ return nil
+ }
+ db, err := openIndex()
+ if err != nil {
+ return err
+ }
+ return db.Update(func(tx *bolt.Tx) error {
+ bucket := tx.Bucket([]byte(bucketMatches))
+ if bucket == nil {
+ return nil
+ }
+ // Remove namespaced key
+ _ = bucket.Delete([]byte(hash + ":" + label))
+ // If legacy single-key exists and matches label, remove it as well.
+ if raw := bucket.Get([]byte(hash)); len(raw) > 0 {
+ var rec MatchRecord
+ if err := json.Unmarshal(raw, &rec); err == nil {
+ if strings.EqualFold(strings.TrimSpace(rec.AccountLabel), label) {
+ _ = bucket.Delete([]byte(hash))
+ }
+ }
+ }
+ return nil
+ })
+}
+
+// RemoveMatchesByLabel removes all entries associated with the specified label.
+func RemoveMatchesByLabel(label string) error {
+ label = strings.TrimSpace(label)
+ if label == "" {
+ return nil
+ }
+ db, err := openIndex()
+ if err != nil {
+ return err
+ }
+ return db.Update(func(tx *bolt.Tx) error {
+ bucket := tx.Bucket([]byte(bucketMatches))
+ if bucket == nil {
+ return nil
+ }
+ cursor := bucket.Cursor()
+ for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
+ if len(v) == 0 {
+ continue
+ }
+ var record MatchRecord
+ if err := json.Unmarshal(v, &record); err != nil {
+ _ = bucket.Delete(k)
+ continue
+ }
+ if strings.EqualFold(strings.TrimSpace(record.AccountLabel), label) {
+ if err := bucket.Delete(k); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+ })
+}
+
+// StoreConversation updates all hashes representing the provided conversation snapshot.
+func StoreConversation(label, model string, msgs []Message, metadata []string) error {
+ label = strings.TrimSpace(label)
+ if label == "" || len(msgs) == 0 {
+ return nil
+ }
+ hashes := BuildStorageHashes(model, msgs)
+ if len(hashes) == 0 {
+ return nil
+ }
+ for _, h := range hashes {
+ rec := MatchRecord{
+ AccountLabel: label,
+ Metadata: append([]string(nil), metadata...),
+ PrefixLen: h.PrefixLen,
+ }
+ if err := StoreMatch(h.Hash, rec); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/internal/provider/gemini-web/conversation/lookup.go b/internal/provider/gemini-web/conversation/lookup.go
new file mode 100644
index 00000000..d9b3fb8a
--- /dev/null
+++ b/internal/provider/gemini-web/conversation/lookup.go
@@ -0,0 +1,64 @@
+package conversation
+
+import "strings"
+
+// PrefixHash represents a hash candidate for a specific prefix length.
+type PrefixHash struct {
+ Hash string
+ PrefixLen int
+}
+
+// BuildLookupHashes generates hash candidates ordered from longest to shortest prefix.
+func BuildLookupHashes(model string, msgs []Message) []PrefixHash {
+ if len(msgs) < 2 {
+ return nil
+ }
+ model = NormalizeModel(model)
+ sanitized := SanitizeAssistantMessages(msgs)
+ result := make([]PrefixHash, 0, len(sanitized))
+ for end := len(sanitized); end >= 2; end-- {
+ tailRole := strings.ToLower(strings.TrimSpace(sanitized[end-1].Role))
+ if tailRole != "assistant" && tailRole != "system" {
+ continue
+ }
+ prefix := sanitized[:end]
+ hash := HashConversationGlobal(model, ToStoredMessages(prefix))
+ result = append(result, PrefixHash{Hash: hash, PrefixLen: end})
+ }
+ return result
+}
+
+// BuildStorageHashes returns hashes representing the full conversation snapshot.
+func BuildStorageHashes(model string, msgs []Message) []PrefixHash {
+ if len(msgs) == 0 {
+ return nil
+ }
+ model = NormalizeModel(model)
+ sanitized := SanitizeAssistantMessages(msgs)
+ if len(sanitized) == 0 {
+ return nil
+ }
+ result := make([]PrefixHash, 0, len(sanitized))
+ seen := make(map[string]struct{}, len(sanitized))
+ for start := 0; start < len(sanitized); start++ {
+ segment := sanitized[start:]
+ if len(segment) < 2 {
+ continue
+ }
+ tailRole := strings.ToLower(strings.TrimSpace(segment[len(segment)-1].Role))
+ if tailRole != "assistant" && tailRole != "system" {
+ continue
+ }
+ hash := HashConversationGlobal(model, ToStoredMessages(segment))
+ if _, exists := seen[hash]; exists {
+ continue
+ }
+ seen[hash] = struct{}{}
+ result = append(result, PrefixHash{Hash: hash, PrefixLen: len(segment)})
+ }
+ if len(result) == 0 {
+ hash := HashConversationGlobal(model, ToStoredMessages(sanitized))
+ return []PrefixHash{{Hash: hash, PrefixLen: len(sanitized)}}
+ }
+ return result
+}
diff --git a/internal/provider/gemini-web/conversation/metadata.go b/internal/provider/gemini-web/conversation/metadata.go
new file mode 100644
index 00000000..ba20f5b3
--- /dev/null
+++ b/internal/provider/gemini-web/conversation/metadata.go
@@ -0,0 +1,6 @@
+package conversation
+
+const (
+ MetadataMessagesKey = "gemini_web_messages"
+ MetadataMatchKey = "gemini_web_match"
+)
diff --git a/internal/provider/gemini-web/conversation/parse.go b/internal/provider/gemini-web/conversation/parse.go
new file mode 100644
index 00000000..a27e952f
--- /dev/null
+++ b/internal/provider/gemini-web/conversation/parse.go
@@ -0,0 +1,110 @@
+package conversation
+
+import (
+ "strings"
+
+ "github.com/tidwall/gjson"
+)
+
+// ExtractMessages attempts to build a message list from the inbound request payload.
+func ExtractMessages(handlerType string, raw []byte) []Message {
+ if len(raw) == 0 {
+ return nil
+ }
+ if msgs := extractOpenAIStyle(raw); len(msgs) > 0 {
+ return msgs
+ }
+ if msgs := extractGeminiContents(raw); len(msgs) > 0 {
+ return msgs
+ }
+ return nil
+}
+
+func extractOpenAIStyle(raw []byte) []Message {
+ root := gjson.ParseBytes(raw)
+ messages := root.Get("messages")
+ if !messages.Exists() {
+ return nil
+ }
+ out := make([]Message, 0, 8)
+ messages.ForEach(func(_, entry gjson.Result) bool {
+ role := strings.ToLower(strings.TrimSpace(entry.Get("role").String()))
+ if role == "" {
+ return true
+ }
+ if role == "system" {
+ return true
+ }
+ // Ignore OpenAI tool messages to keep hashing aligned with
+ // persistence (which only keeps text/inlineData for Gemini contents).
+ // This avoids mismatches when a tool response is present: the
+ // storage path drops tool payloads while the lookup path would
+ // otherwise include them, causing sticky selection to fail.
+ if role == "tool" {
+ return true
+ }
+ var contentBuilder strings.Builder
+ content := entry.Get("content")
+ if !content.Exists() {
+ out = append(out, Message{Role: role, Text: ""})
+ return true
+ }
+ switch content.Type {
+ case gjson.String:
+ contentBuilder.WriteString(content.String())
+ case gjson.JSON:
+ if content.IsArray() {
+ content.ForEach(func(_, part gjson.Result) bool {
+ if text := part.Get("text"); text.Exists() {
+ if contentBuilder.Len() > 0 {
+ contentBuilder.WriteString("\n")
+ }
+ contentBuilder.WriteString(text.String())
+ }
+ return true
+ })
+ }
+ }
+ out = append(out, Message{Role: role, Text: contentBuilder.String()})
+ return true
+ })
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
+func extractGeminiContents(raw []byte) []Message {
+ contents := gjson.GetBytes(raw, "contents")
+ if !contents.Exists() {
+ return nil
+ }
+ out := make([]Message, 0, 8)
+ contents.ForEach(func(_, entry gjson.Result) bool {
+ role := strings.TrimSpace(entry.Get("role").String())
+ if role == "" {
+ role = "user"
+ } else {
+ role = strings.ToLower(role)
+ if role == "model" {
+ role = "assistant"
+ }
+ }
+ var builder strings.Builder
+ entry.Get("parts").ForEach(func(_, part gjson.Result) bool {
+ if text := part.Get("text"); text.Exists() {
+ if builder.Len() > 0 {
+ builder.WriteString("\n")
+ }
+ builder.WriteString(text.String())
+ }
+ return true
+ })
+ out = append(out, Message{Role: role, Text: builder.String()})
+ return true
+ })
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
diff --git a/internal/provider/gemini-web/conversation/sanitize.go b/internal/provider/gemini-web/conversation/sanitize.go
new file mode 100644
index 00000000..82359702
--- /dev/null
+++ b/internal/provider/gemini-web/conversation/sanitize.go
@@ -0,0 +1,39 @@
+package conversation
+
+import (
+ "regexp"
+ "strings"
+)
+
+var reThink = regexp.MustCompile(`(?is).*?`)
+
+// RemoveThinkTags strips ... blocks and trims whitespace.
+func RemoveThinkTags(s string) string {
+ return strings.TrimSpace(reThink.ReplaceAllString(s, ""))
+}
+
+// SanitizeAssistantMessages removes think tags from assistant messages while leaving others untouched.
+func SanitizeAssistantMessages(msgs []Message) []Message {
+ out := make([]Message, 0, len(msgs))
+ for _, m := range msgs {
+ if strings.EqualFold(strings.TrimSpace(m.Role), "assistant") {
+ out = append(out, Message{Role: m.Role, Text: RemoveThinkTags(m.Text)})
+ continue
+ }
+ out = append(out, m)
+ }
+ return out
+}
+
+// EqualMessages compares two message slices for equality.
+func EqualMessages(a, b []Message) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i].Role != b[i].Role || a[i].Text != b[i].Text {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/provider/gemini-web/models.go b/internal/provider/gemini-web/models.go
index c4cb29e8..b1e50dc3 100644
--- a/internal/provider/gemini-web/models.go
+++ b/internal/provider/gemini-web/models.go
@@ -4,10 +4,9 @@ import (
"fmt"
"html"
"net/http"
- "strings"
- "sync"
"time"
+ conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
)
@@ -105,76 +104,20 @@ const (
ErrorIPTemporarilyBlocked = 1060
)
-var (
- GeminiWebAliasOnce sync.Once
- GeminiWebAliasMap map[string]string
-)
-
-func EnsureGeminiWebAliasMap() {
- GeminiWebAliasOnce.Do(func() {
- GeminiWebAliasMap = make(map[string]string)
- for _, m := range registry.GetGeminiModels() {
- if m.ID == "gemini-2.5-flash-lite" {
- continue
- } else if m.ID == "gemini-2.5-flash" {
- GeminiWebAliasMap["gemini-2.5-flash-image-preview"] = "gemini-2.5-flash"
- }
- alias := AliasFromModelID(m.ID)
- GeminiWebAliasMap[strings.ToLower(alias)] = strings.ToLower(m.ID)
- }
- })
-}
+func EnsureGeminiWebAliasMap() { conversation.EnsureGeminiWebAliasMap() }
func GetGeminiWebAliasedModels() []*registry.ModelInfo {
- EnsureGeminiWebAliasMap()
- aliased := make([]*registry.ModelInfo, 0)
- for _, m := range registry.GetGeminiModels() {
- if m.ID == "gemini-2.5-flash-lite" {
- continue
- } else if m.ID == "gemini-2.5-flash" {
- cpy := *m
- cpy.ID = "gemini-2.5-flash-image-preview"
- cpy.Name = "gemini-2.5-flash-image-preview"
- cpy.DisplayName = "Nano Banana"
- cpy.Description = "Gemini 2.5 Flash Preview Image"
- aliased = append(aliased, &cpy)
- }
- cpy := *m
- cpy.ID = AliasFromModelID(m.ID)
- cpy.Name = cpy.ID
- aliased = append(aliased, &cpy)
- }
- return aliased
+ return conversation.GetGeminiWebAliasedModels()
}
-func MapAliasToUnderlying(name string) string {
- EnsureGeminiWebAliasMap()
- n := strings.ToLower(name)
- if u, ok := GeminiWebAliasMap[n]; ok {
- return u
- }
- const suffix = "-web"
- if strings.HasSuffix(n, suffix) {
- return strings.TrimSuffix(n, suffix)
- }
- return name
-}
+func MapAliasToUnderlying(name string) string { return conversation.MapAliasToUnderlying(name) }
-func AliasFromModelID(modelID string) string {
- return modelID + "-web"
-}
+func AliasFromModelID(modelID string) string { return conversation.AliasFromModelID(modelID) }
// Conversation domain structures -------------------------------------------
-type RoleText struct {
- Role string
- Text string
-}
+type RoleText = conversation.Message
-type StoredMessage struct {
- Role string `json:"role"`
- Content string `json:"content"`
- Name string `json:"name,omitempty"`
-}
+type StoredMessage = conversation.StoredMessage
type ConversationRecord struct {
Model string `json:"model"`
diff --git a/internal/provider/gemini-web/prompt.go b/internal/provider/gemini-web/prompt.go
index 1f9cd8be..e3051243 100644
--- a/internal/provider/gemini-web/prompt.go
+++ b/internal/provider/gemini-web/prompt.go
@@ -8,11 +8,11 @@ import (
"unicode/utf8"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
"github.com/tidwall/gjson"
)
var (
- reThink = regexp.MustCompile(`(?s)^\s*.*?\s*`)
reXMLAnyTag = regexp.MustCompile(`(?s)<\s*[^>]+>`)
)
@@ -77,20 +77,13 @@ func BuildPrompt(msgs []RoleText, tagged bool, appendAssistant bool) string {
// RemoveThinkTags strips ... blocks from a string.
func RemoveThinkTags(s string) string {
- return strings.TrimSpace(reThink.ReplaceAllString(s, ""))
+ return conversation.RemoveThinkTags(s)
}
// SanitizeAssistantMessages removes think tags from assistant messages.
func SanitizeAssistantMessages(msgs []RoleText) []RoleText {
- out := make([]RoleText, 0, len(msgs))
- for _, m := range msgs {
- if strings.ToLower(m.Role) == "assistant" {
- out = append(out, RoleText{Role: m.Role, Text: RemoveThinkTags(m.Text)})
- } else {
- out = append(out, m)
- }
- }
- return out
+ cleaned := conversation.SanitizeAssistantMessages(msgs)
+ return cleaned
}
// AppendXMLWrapHintIfNeeded appends an XML wrap hint to messages containing XML-like blocks.
diff --git a/internal/provider/gemini-web/state.go b/internal/provider/gemini-web/state.go
index e0044984..b642d2b1 100644
--- a/internal/provider/gemini-web/state.go
+++ b/internal/provider/gemini-web/state.go
@@ -3,8 +3,6 @@ package geminiwebapi
import (
"bytes"
"context"
- "crypto/sha256"
- "encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -19,6 +17,7 @@ import (
"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"
+ conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
log "github.com/sirupsen/logrus"
@@ -35,6 +34,7 @@ type GeminiWebState struct {
cfg *config.Config
token *gemini.GeminiWebTokenStorage
storagePath string
+ authLabel string
stableClientID string
accountID string
@@ -51,18 +51,28 @@ type GeminiWebState struct {
convIndex map[string]string
lastRefresh time.Time
+
+ pendingMatchMu sync.Mutex
+ pendingMatch *conversation.MatchResult
}
-func NewGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage, storagePath string) *GeminiWebState {
+type reuseComputation struct {
+ metadata []string
+ history []RoleText
+ overlap int
+}
+
+func NewGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage, storagePath, authLabel string) *GeminiWebState {
state := &GeminiWebState{
cfg: cfg,
token: token,
storagePath: storagePath,
+ authLabel: strings.TrimSpace(authLabel),
convStore: make(map[string][]string),
convData: make(map[string]ConversationRecord),
convIndex: make(map[string]string),
}
- suffix := Sha256Hex(token.Secure1PSID)
+ suffix := conversation.Sha256Hex(token.Secure1PSID)
if len(suffix) > 16 {
suffix = suffix[:16]
}
@@ -81,6 +91,28 @@ func NewGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage,
return state
}
+func (s *GeminiWebState) setPendingMatch(match *conversation.MatchResult) {
+ if s == nil {
+ return
+ }
+ s.pendingMatchMu.Lock()
+ s.pendingMatch = match
+ s.pendingMatchMu.Unlock()
+}
+
+func (s *GeminiWebState) consumePendingMatch() *conversation.MatchResult {
+ s.pendingMatchMu.Lock()
+ defer s.pendingMatchMu.Unlock()
+ match := s.pendingMatch
+ s.pendingMatch = nil
+ return match
+}
+
+// SetPendingMatch makes a cached conversation match available for the next request.
+func (s *GeminiWebState) SetPendingMatch(match *conversation.MatchResult) {
+ s.setPendingMatch(match)
+}
+
// Label returns a stable account label for logging and persistence.
// If a storage file path is known, it uses the file base name (without extension).
// Otherwise, it falls back to the stable client ID (e.g., "gemini-web-").
@@ -93,6 +125,9 @@ func (s *GeminiWebState) Label() string {
return lbl
}
}
+ if lbl := strings.TrimSpace(s.authLabel); lbl != "" {
+ return lbl
+ }
if s.storagePath != "" {
base := strings.TrimSuffix(filepath.Base(s.storagePath), filepath.Ext(s.storagePath))
if base != "" {
@@ -126,6 +161,78 @@ func (s *GeminiWebState) convPath() string {
return ConvBoltPath(base)
}
+func cloneRoleTextSlice(in []RoleText) []RoleText {
+ if len(in) == 0 {
+ return nil
+ }
+ out := make([]RoleText, len(in))
+ copy(out, in)
+ return out
+}
+
+func cloneStringSlice(in []string) []string {
+ if len(in) == 0 {
+ return nil
+ }
+ out := make([]string, len(in))
+ copy(out, in)
+ return out
+}
+
+func longestHistoryOverlap(history, incoming []RoleText) int {
+ max := len(history)
+ if len(incoming) < max {
+ max = len(incoming)
+ }
+ for overlap := max; overlap > 0; overlap-- {
+ if conversation.EqualMessages(history[len(history)-overlap:], incoming[:overlap]) {
+ return overlap
+ }
+ }
+ return 0
+}
+
+func equalStringSlice(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
+
+func storedMessagesToRoleText(stored []conversation.StoredMessage) []RoleText {
+ if len(stored) == 0 {
+ return nil
+ }
+ converted := make([]RoleText, len(stored))
+ for i, msg := range stored {
+ converted[i] = RoleText{Role: msg.Role, Text: msg.Content}
+ }
+ return converted
+}
+
+func (s *GeminiWebState) findConversationByMetadata(model string, metadata []string) ([]RoleText, bool) {
+ if len(metadata) == 0 {
+ return nil, false
+ }
+ s.convMu.RLock()
+ defer s.convMu.RUnlock()
+ for _, rec := range s.convData {
+ if !strings.EqualFold(strings.TrimSpace(rec.Model), strings.TrimSpace(model)) {
+ continue
+ }
+ if !equalStringSlice(rec.Metadata, metadata) {
+ continue
+ }
+ return cloneRoleTextSlice(storedMessagesToRoleText(rec.Messages)), true
+ }
+ return nil, false
+}
+
func (s *GeminiWebState) GetRequestMutex() *sync.Mutex { return &s.reqMu }
func (s *GeminiWebState) EnsureClient() error {
@@ -219,7 +326,7 @@ func (s *GeminiWebState) prepare(ctx context.Context, modelName string, rawJSON
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: fmt.Errorf("bad request: %w", err)}
}
cleaned := SanitizeAssistantMessages(messages)
- res.cleaned = cleaned
+ fullCleaned := cloneRoleTextSlice(cleaned)
res.underlying = MapAliasToUnderlying(modelName)
model, err := ModelFromName(res.underlying)
if err != nil {
@@ -232,15 +339,27 @@ func (s *GeminiWebState) prepare(ctx context.Context, modelName string, rawJSON
mimesSubset := mimes
if s.useReusableContext() {
- reuseMeta, remaining := s.findReusableSession(res.underlying, cleaned)
- if len(reuseMeta) > 0 {
+ reusePlan := s.reuseFromPending(res.underlying, cleaned)
+ if reusePlan == nil {
+ reusePlan = s.findReusableSession(res.underlying, cleaned)
+ }
+ if reusePlan != nil {
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 {
+ meta = cloneStringSlice(reusePlan.metadata)
+ overlap := reusePlan.overlap
+ if overlap > len(cleaned) {
+ overlap = len(cleaned)
+ } else if overlap < 0 {
+ overlap = 0
+ }
+ delta := cloneRoleTextSlice(cleaned[overlap:])
+ if len(reusePlan.history) > 0 {
+ fullCleaned = append(cloneRoleTextSlice(reusePlan.history), delta...)
+ } else {
+ fullCleaned = append(cloneRoleTextSlice(cleaned[:overlap]), delta...)
+ }
+ useMsgs = delta
+ if len(delta) == 0 && len(cleaned) > 0 {
useMsgs = []RoleText{cleaned[len(cleaned)-1]}
}
if len(useMsgs) == 1 && len(messages) > 0 && len(msgFileIdx) == len(messages) {
@@ -298,6 +417,8 @@ func (s *GeminiWebState) prepare(ctx context.Context, modelName string, rawJSON
s.convMu.RUnlock()
}
+ res.cleaned = fullCleaned
+
res.tagged = NeedRoleTags(useMsgs)
if res.reuse && len(useMsgs) == 1 {
res.tagged = false
@@ -421,8 +542,22 @@ func (s *GeminiWebState) persistConversation(modelName string, prep *geminiWebPr
if !ok {
return
}
- stableHash := HashConversation(rec.ClientID, prep.underlying, rec.Messages)
- accountHash := HashConversation(s.accountID, prep.underlying, rec.Messages)
+ label := strings.TrimSpace(s.Label())
+ if label == "" {
+ label = s.accountID
+ }
+ conversationMsgs := conversation.StoredToMessages(rec.Messages)
+ if err := conversation.StoreConversation(label, prep.underlying, conversationMsgs, metadata); err != nil {
+ log.Debugf("gemini web: failed to persist global conversation index: %v", err)
+ }
+ stableHash := conversation.HashConversationForAccount(rec.ClientID, prep.underlying, rec.Messages)
+ accountHash := conversation.HashConversationForAccount(s.accountID, prep.underlying, rec.Messages)
+
+ suffixSeen := make(map[string]struct{})
+ suffixSeen["hash:"+stableHash] = struct{}{}
+ if accountHash != stableHash {
+ suffixSeen["hash:"+accountHash] = struct{}{}
+ }
s.convMu.Lock()
s.convData[stableHash] = rec
@@ -430,6 +565,33 @@ func (s *GeminiWebState) persistConversation(modelName string, prep *geminiWebPr
if accountHash != stableHash {
s.convIndex["hash:"+accountHash] = stableHash
}
+
+ sanitizedHistory := conversation.SanitizeAssistantMessages(conversation.StoredToMessages(rec.Messages))
+ for start := 1; start < len(sanitizedHistory); start++ {
+ segment := sanitizedHistory[start:]
+ if len(segment) < 2 {
+ continue
+ }
+ tailRole := strings.ToLower(strings.TrimSpace(segment[len(segment)-1].Role))
+ if tailRole != "assistant" && tailRole != "system" {
+ continue
+ }
+ storedSegment := conversation.ToStoredMessages(segment)
+ segmentStableHash := conversation.HashConversationForAccount(rec.ClientID, prep.underlying, storedSegment)
+ keyStable := "hash:" + segmentStableHash
+ if _, exists := suffixSeen[keyStable]; !exists {
+ s.convIndex[keyStable] = stableHash
+ suffixSeen[keyStable] = struct{}{}
+ }
+ segmentAccountHash := conversation.HashConversationForAccount(s.accountID, prep.underlying, storedSegment)
+ if segmentAccountHash != segmentStableHash {
+ keyAccount := "hash:" + segmentAccountHash
+ if _, exists := suffixSeen[keyAccount]; !exists {
+ s.convIndex[keyAccount] = stableHash
+ suffixSeen[keyAccount] = struct{}{}
+ }
+ }
+ }
dataSnapshot := make(map[string]ConversationRecord, len(s.convData))
for k, v := range s.convData {
dataSnapshot[k] = v
@@ -493,12 +655,44 @@ func (s *GeminiWebState) useReusableContext() bool {
return s.cfg.GeminiWeb.Context
}
-func (s *GeminiWebState) findReusableSession(modelName string, msgs []RoleText) ([]string, []RoleText) {
+func (s *GeminiWebState) reuseFromPending(modelName string, msgs []RoleText) *reuseComputation {
+ match := s.consumePendingMatch()
+ if match == nil {
+ return nil
+ }
+ if !strings.EqualFold(strings.TrimSpace(match.Model), strings.TrimSpace(modelName)) {
+ return nil
+ }
+ metadata := cloneStringSlice(match.Record.Metadata)
+ if len(metadata) == 0 {
+ return nil
+ }
+ history, ok := s.findConversationByMetadata(modelName, metadata)
+ if !ok {
+ return nil
+ }
+ overlap := longestHistoryOverlap(history, msgs)
+ return &reuseComputation{metadata: metadata, history: history, overlap: overlap}
+}
+
+func (s *GeminiWebState) findReusableSession(modelName string, msgs []RoleText) *reuseComputation {
s.convMu.RLock()
items := s.convData
index := s.convIndex
s.convMu.RUnlock()
- return FindReusableSessionIn(items, index, s.stableClientID, s.accountID, modelName, msgs)
+ rec, metadata, overlap, ok := FindReusableSessionIn(items, index, s.stableClientID, s.accountID, modelName, msgs)
+ if !ok {
+ return nil
+ }
+ history := cloneRoleTextSlice(storedMessagesToRoleText(rec.Messages))
+ if len(history) == 0 {
+ return nil
+ }
+ // Ensure overlap reflects the actual history alignment.
+ if computed := longestHistoryOverlap(history, msgs); computed > 0 {
+ overlap = computed
+ }
+ return &reuseComputation{metadata: cloneStringSlice(metadata), history: history, overlap: overlap}
}
func (s *GeminiWebState) getConfiguredGem() *Gem {
@@ -540,42 +734,6 @@ func appendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byt
}
}
-// Persistence helpers --------------------------------------------------
-
-// Sha256Hex computes the SHA256 hash of a string and returns its hex representation.
-func Sha256Hex(s string) string {
- sum := sha256.Sum256([]byte(s))
- return hex.EncodeToString(sum[:])
-}
-
-func ToStoredMessages(msgs []RoleText) []StoredMessage {
- out := make([]StoredMessage, 0, len(msgs))
- for _, m := range msgs {
- out = append(out, StoredMessage{
- Role: m.Role,
- Content: m.Text,
- })
- }
- return out
-}
-
-func HashMessage(m StoredMessage) string {
- s := fmt.Sprintf(`{"content":%q,"role":%q}`, m.Content, strings.ToLower(m.Role))
- return Sha256Hex(s)
-}
-
-func HashConversation(clientID, model string, msgs []StoredMessage) string {
- var b strings.Builder
- b.WriteString(clientID)
- b.WriteString("|")
- b.WriteString(model)
- for _, m := range msgs {
- b.WriteString("|")
- b.WriteString(HashMessage(m))
- }
- return Sha256Hex(b.String())
-}
-
// ConvBoltPath returns the BoltDB file path used for both account metadata and conversation data.
// Different logical datasets are kept in separate buckets within this single DB file.
func ConvBoltPath(tokenFilePath string) string {
@@ -790,7 +948,7 @@ func BuildConversationRecord(model, clientID string, history []RoleText, output
Model: model,
ClientID: clientID,
Metadata: metadata,
- Messages: ToStoredMessages(final),
+ Messages: conversation.ToStoredMessages(final),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -800,9 +958,9 @@ func BuildConversationRecord(model, clientID string, history []RoleText, output
// FindByMessageListIn looks up a conversation record by hashed message list.
// It attempts both the stable client ID and a legacy email-based ID.
func FindByMessageListIn(items map[string]ConversationRecord, index map[string]string, stableClientID, email, model string, msgs []RoleText) (ConversationRecord, bool) {
- stored := ToStoredMessages(msgs)
- stableHash := HashConversation(stableClientID, model, stored)
- fallbackHash := HashConversation(email, model, stored)
+ stored := conversation.ToStoredMessages(msgs)
+ stableHash := conversation.HashConversationForAccount(stableClientID, model, stored)
+ fallbackHash := conversation.HashConversationForAccount(email, model, stored)
// Try stable hash via index indirection first
if key, ok := index["hash:"+stableHash]; ok {
@@ -840,9 +998,9 @@ func FindConversationIn(items map[string]ConversationRecord, index map[string]st
}
// FindReusableSessionIn returns reusable metadata and the remaining message suffix.
-func FindReusableSessionIn(items map[string]ConversationRecord, index map[string]string, stableClientID, email, model string, msgs []RoleText) ([]string, []RoleText) {
+func FindReusableSessionIn(items map[string]ConversationRecord, index map[string]string, stableClientID, email, model string, msgs []RoleText) (ConversationRecord, []string, int, bool) {
if len(msgs) < 2 {
- return nil, nil
+ return ConversationRecord{}, nil, 0, false
}
searchEnd := len(msgs)
for searchEnd >= 2 {
@@ -850,11 +1008,10 @@ func FindReusableSessionIn(items map[string]ConversationRecord, index map[string
tail := sub[len(sub)-1]
if strings.EqualFold(tail.Role, "assistant") || strings.EqualFold(tail.Role, "system") {
if rec, ok := FindConversationIn(items, index, stableClientID, email, model, sub); ok {
- remain := msgs[searchEnd:]
- return rec.Metadata, remain
+ return rec, rec.Metadata, searchEnd, true
}
}
searchEnd--
}
- return nil, nil
+ return ConversationRecord{}, nil, 0, false
}
diff --git a/internal/runtime/executor/gemini_web_executor.go b/internal/runtime/executor/gemini_web_executor.go
index f026299c..d7023f2d 100644
--- a/internal/runtime/executor/gemini_web_executor.go
+++ b/internal/runtime/executor/gemini_web_executor.go
@@ -13,6 +13,7 @@ import (
"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"
+ conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
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"
@@ -40,12 +41,18 @@ func (e *GeminiWebExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
if err = state.EnsureClient(); err != nil {
return cliproxyexecutor.Response{}, err
}
+ match := extractGeminiWebMatch(opts.Metadata)
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
mutex := state.GetRequestMutex()
if mutex != nil {
mutex.Lock()
defer mutex.Unlock()
+ if match != nil {
+ state.SetPendingMatch(match)
+ }
+ } else if match != nil {
+ state.SetPendingMatch(match)
}
payload := bytes.Clone(req.Payload)
@@ -72,11 +79,18 @@ func (e *GeminiWebExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
if err = state.EnsureClient(); err != nil {
return nil, err
}
+ match := extractGeminiWebMatch(opts.Metadata)
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
mutex := state.GetRequestMutex()
if mutex != nil {
mutex.Lock()
+ if match != nil {
+ state.SetPendingMatch(match)
+ }
+ }
+ if mutex == nil && match != nil {
+ state.SetPendingMatch(match)
}
gemBytes, errMsg, prep := state.Send(ctx, req.Model, bytes.Clone(req.Payload), opts)
@@ -182,7 +196,7 @@ func (e *GeminiWebExecutor) stateFor(auth *cliproxyauth.Auth) (*geminiwebapi.Gem
storagePath = p
}
}
- state := geminiwebapi.NewGeminiWebState(cfg, ts, storagePath)
+ state := geminiwebapi.NewGeminiWebState(cfg, ts, storagePath, auth.Label)
runtime := &geminiWebRuntime{state: state}
auth.Runtime = runtime
return state, nil
@@ -242,3 +256,21 @@ func (e geminiWebError) StatusCode() int {
}
return e.message.StatusCode
}
+
+func extractGeminiWebMatch(metadata map[string]any) *conversation.MatchResult {
+ if metadata == nil {
+ return nil
+ }
+ value, ok := metadata[conversation.MetadataMatchKey]
+ if !ok {
+ return nil
+ }
+ switch v := value.(type) {
+ case *conversation.MatchResult:
+ return v
+ case conversation.MatchResult:
+ return &v
+ default:
+ return nil
+ }
+}
diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go
index f9f86fd3..cfa61bc0 100644
--- a/sdk/api/handlers/handlers.go
+++ b/sdk/api/handlers/handlers.go
@@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
+ conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
@@ -48,6 +49,8 @@ type BaseAPIHandler struct {
Cfg *config.SDKConfig
}
+const geminiWebProvider = "gemini-web"
+
// NewBaseAPIHandlers creates a new API handlers instance.
// It takes a slice of clients and configuration as input.
//
@@ -137,6 +140,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType
if len(providers) == 0 {
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
}
+ metadata := h.buildGeminiWebMetadata(handlerType, providers, rawJSON)
req := coreexecutor.Request{
Model: modelName,
Payload: cloneBytes(rawJSON),
@@ -146,6 +150,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType
Alt: alt,
OriginalRequest: cloneBytes(rawJSON),
SourceFormat: sdktranslator.FromString(handlerType),
+ Metadata: metadata,
}
resp, err := h.AuthManager.Execute(ctx, providers, req, opts)
if err != nil {
@@ -161,6 +166,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
if len(providers) == 0 {
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
}
+ metadata := h.buildGeminiWebMetadata(handlerType, providers, rawJSON)
req := coreexecutor.Request{
Model: modelName,
Payload: cloneBytes(rawJSON),
@@ -170,6 +176,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
Alt: alt,
OriginalRequest: cloneBytes(rawJSON),
SourceFormat: sdktranslator.FromString(handlerType),
+ Metadata: metadata,
}
resp, err := h.AuthManager.ExecuteCount(ctx, providers, req, opts)
if err != nil {
@@ -188,6 +195,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
close(errChan)
return nil, errChan
}
+ metadata := h.buildGeminiWebMetadata(handlerType, providers, rawJSON)
req := coreexecutor.Request{
Model: modelName,
Payload: cloneBytes(rawJSON),
@@ -197,6 +205,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
Alt: alt,
OriginalRequest: cloneBytes(rawJSON),
SourceFormat: sdktranslator.FromString(handlerType),
+ Metadata: metadata,
}
chunks, err := h.AuthManager.ExecuteStream(ctx, providers, req, opts)
if err != nil {
@@ -232,6 +241,18 @@ func cloneBytes(src []byte) []byte {
return dst
}
+func (h *BaseAPIHandler) buildGeminiWebMetadata(handlerType string, providers []string, rawJSON []byte) map[string]any {
+ if !util.InArray(providers, geminiWebProvider) {
+ return nil
+ }
+ meta := make(map[string]any)
+ msgs := conversation.ExtractMessages(handlerType, rawJSON)
+ if len(msgs) > 0 {
+ meta[conversation.MetadataMessagesKey] = msgs
+ }
+ return meta
+}
+
// WriteErrorResponse writes an error message to the response writer using the HTTP status embedded in the message.
func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.ErrorMessage) {
status := http.StatusInternalServerError
diff --git a/sdk/cliproxy/auth/selector_rr.go b/sdk/cliproxy/auth/selector_rr.go
new file mode 100644
index 00000000..1dbf357d
--- /dev/null
+++ b/sdk/cliproxy/auth/selector_rr.go
@@ -0,0 +1,125 @@
+package auth
+
+import (
+ "context"
+ "strings"
+
+ conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
+ log "github.com/sirupsen/logrus"
+)
+
+const (
+ geminiWebProviderKey = "gemini-web"
+)
+
+type geminiWebStickySelector struct {
+ base Selector
+}
+
+func NewGeminiWebStickySelector(base Selector) Selector {
+ if selector, ok := base.(*geminiWebStickySelector); ok {
+ return selector
+ }
+ if base == nil {
+ base = &RoundRobinSelector{}
+ }
+ return &geminiWebStickySelector{base: base}
+}
+
+func (m *Manager) EnableGeminiWebStickySelector() {
+ if m == nil {
+ return
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if _, ok := m.selector.(*geminiWebStickySelector); ok {
+ return
+ }
+ m.selector = NewGeminiWebStickySelector(m.selector)
+}
+
+func (s *geminiWebStickySelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {
+ if !strings.EqualFold(provider, geminiWebProviderKey) {
+ if opts.Metadata != nil {
+ delete(opts.Metadata, conversation.MetadataMatchKey)
+ }
+ return s.base.Pick(ctx, provider, model, opts, auths)
+ }
+
+ messages := extractGeminiWebMessages(opts.Metadata)
+ if len(messages) >= 2 {
+ normalizedModel := conversation.NormalizeModel(model)
+ candidates := conversation.BuildLookupHashes(normalizedModel, messages)
+ for _, candidate := range candidates {
+ record, ok, err := conversation.LookupMatch(candidate.Hash)
+ if err != nil {
+ log.Warnf("gemini-web selector: lookup failed for hash %s: %v", candidate.Hash, err)
+ continue
+ }
+ if !ok {
+ continue
+ }
+ label := strings.TrimSpace(record.AccountLabel)
+ if label == "" {
+ continue
+ }
+ auth := findAuthByLabel(auths, label)
+ if auth != nil {
+ if opts.Metadata != nil {
+ opts.Metadata[conversation.MetadataMatchKey] = &conversation.MatchResult{
+ Hash: candidate.Hash,
+ Record: record,
+ Model: normalizedModel,
+ }
+ }
+ return auth, nil
+ }
+ _ = conversation.RemoveMatchForLabel(candidate.Hash, label)
+ }
+ }
+
+ return s.base.Pick(ctx, provider, model, opts, auths)
+}
+
+func extractGeminiWebMessages(metadata map[string]any) []conversation.Message {
+ if metadata == nil {
+ return nil
+ }
+ raw, ok := metadata[conversation.MetadataMessagesKey]
+ if !ok {
+ return nil
+ }
+ switch v := raw.(type) {
+ case []conversation.Message:
+ return v
+ case *[]conversation.Message:
+ if v == nil {
+ return nil
+ }
+ return *v
+ default:
+ return nil
+ }
+}
+
+func findAuthByLabel(auths []*Auth, label string) *Auth {
+ if len(auths) == 0 {
+ return nil
+ }
+ normalized := strings.ToLower(strings.TrimSpace(label))
+ for _, auth := range auths {
+ if auth == nil {
+ continue
+ }
+ if strings.ToLower(strings.TrimSpace(auth.Label)) == normalized {
+ return auth
+ }
+ if auth.Metadata != nil {
+ if v, ok := auth.Metadata["label"].(string); ok && strings.ToLower(strings.TrimSpace(v)) == normalized {
+ return auth
+ }
+ }
+ }
+ return nil
+}
diff --git a/sdk/cliproxy/executor/types.go b/sdk/cliproxy/executor/types.go
index 5b48b11d..c8bb9447 100644
--- a/sdk/cliproxy/executor/types.go
+++ b/sdk/cliproxy/executor/types.go
@@ -33,6 +33,8 @@ type Options struct {
OriginalRequest []byte
// SourceFormat identifies the inbound schema.
SourceFormat sdktranslator.Format
+ // Metadata carries extra execution hints shared across selection and executors.
+ Metadata map[string]any
}
// Response wraps either a full provider response or metadata for streaming flows.
diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go
index be9f3716..32738b7d 100644
--- a/sdk/cliproxy/service.go
+++ b/sdk/cliproxy/service.go
@@ -15,6 +15,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
geminiwebclient "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web"
+ conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
"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"
@@ -206,6 +207,23 @@ func (s *Service) applyCoreAuthRemoval(ctx context.Context, id string) {
}
GlobalModelRegistry().UnregisterClient(id)
if existing, ok := s.coreManager.GetByID(id); ok && existing != nil {
+ if strings.EqualFold(existing.Provider, "gemini-web") {
+ // Prefer the stable cookie label stored in metadata when available.
+ var label string
+ if existing.Metadata != nil {
+ if v, ok := existing.Metadata["label"].(string); ok {
+ label = strings.TrimSpace(v)
+ }
+ }
+ if label == "" {
+ label = strings.TrimSpace(existing.Label)
+ }
+ if label != "" {
+ if err := conversation.RemoveMatchesByLabel(label); err != nil {
+ log.Debugf("failed to remove gemini web sticky entries for %s: %v", label, err)
+ }
+ }
+ }
existing.Disabled = true
existing.Status = coreauth.StatusDisabled
if _, err := s.coreManager.Update(ctx, existing); err != nil {
@@ -225,6 +243,7 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) {
s.coreManager.RegisterExecutor(executor.NewGeminiCLIExecutor(s.cfg))
case "gemini-web":
s.coreManager.RegisterExecutor(executor.NewGeminiWebExecutor(s.cfg))
+ s.coreManager.EnableGeminiWebStickySelector()
case "claude":
s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg))
case "codex":