History

Merge pull request #78 from router-for-me/gemini

Luis Pater committed 1 year ago

Commit beba2a72 parents 832268c f2201da16 files · +1084 −138Browse files

Unified · Split

16 files
  1. .gitignore +1 −0
  2. internal/provider/gemini-web/conversation/alias.go +80 −0
  3. internal/provider/gemini-web/conversation/hash.go +74 −0
  4. internal/provider/gemini-web/conversation/index.go +280 −0
  5. internal/provider/gemini-web/conversation/lookup.go +64 −0
  6. internal/provider/gemini-web/conversation/metadata.go +6 −0
  7. internal/provider/gemini-web/conversation/parse.go +110 −0
  8. internal/provider/gemini-web/conversation/sanitize.go +39 −0
  9. internal/provider/gemini-web/models.go +7 −64
  10. internal/provider/gemini-web/prompt.go +4 −11
  11. internal/provider/gemini-web/state.go +219 −62
  12. internal/runtime/executor/gemini_web_executor.go +33 −1
  13. sdk/api/handlers/handlers.go +21 −0
  14. sdk/cliproxy/auth/selector_rr.go +125 −0
  15. sdk/cliproxy/executor/types.go +2 −0
  16. sdk/cliproxy/service.go +19 −0
.gitignore+1−0
@@ -10,6 +10,7 @@ auths/* · 9 unmodified lines
1010 .serena/*
1111 AGENTS.md
1212 CLAUDE.md
13+GEMINI.md
1314 *.exe
1415 temp/*
1516 cli-proxy-api
internal/provider/gemini-web/conversation/alias.go+80−0
@@ -0,0 +1,80 @@
1+package conversation
2+
3+import (
4+ "strings"
5+ "sync"
6+
7+ "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
8+)
9+
10+var (
11+ aliasOnce sync.Once
12+ aliasMap map[string]string
13+)
14+
15+// EnsureGeminiWebAliasMap populates the alias map once.
16+func EnsureGeminiWebAliasMap() {
17+ aliasOnce.Do(func() {
18+ aliasMap = make(map[string]string)
19+ for _, m := range registry.GetGeminiModels() {
20+ if m.ID == "gemini-2.5-flash-lite" {
21+ continue
22+ }
23+ if m.ID == "gemini-2.5-flash" {
24+ aliasMap["gemini-2.5-flash-image-preview"] = "gemini-2.5-flash"
25+ }
26+ alias := AliasFromModelID(m.ID)
27+ aliasMap[strings.ToLower(alias)] = strings.ToLower(m.ID)
28+ }
29+ })
30+}
31+
32+// MapAliasToUnderlying normalizes a model alias to its underlying identifier.
33+func MapAliasToUnderlying(name string) string {
34+ EnsureGeminiWebAliasMap()
35+ n := strings.ToLower(strings.TrimSpace(name))
36+ if n == "" {
37+ return n
38+ }
39+ if u, ok := aliasMap[n]; ok {
40+ return u
41+ }
42+ const suffix = "-web"
43+ if strings.HasSuffix(n, suffix) {
44+ return strings.TrimSuffix(n, suffix)
45+ }
46+ return n
47+}
48+
49+// AliasFromModelID mirrors the original helper for deriving alias IDs.
50+func AliasFromModelID(modelID string) string {
51+ return modelID + "-web"
52+}
53+
54+// NormalizeModel returns the canonical identifier used for hashing.
55+func NormalizeModel(model string) string {
56+ return MapAliasToUnderlying(model)
57+}
58+
59+// GetGeminiWebAliasedModels returns alias metadata for registry exposure.
60+func GetGeminiWebAliasedModels() []*registry.ModelInfo {
61+ EnsureGeminiWebAliasMap()
62+ aliased := make([]*registry.ModelInfo, 0)
63+ for _, m := range registry.GetGeminiModels() {
64+ if m.ID == "gemini-2.5-flash-lite" {
65+ continue
66+ } else if m.ID == "gemini-2.5-flash" {
67+ cpy := *m
68+ cpy.ID = "gemini-2.5-flash-image-preview"
69+ cpy.Name = "gemini-2.5-flash-image-preview"
70+ cpy.DisplayName = "Nano Banana"
71+ cpy.Description = "Gemini 2.5 Flash Preview Image"
72+ aliased = append(aliased, &cpy)
73+ }
74+ cpy := *m
75+ cpy.ID = AliasFromModelID(m.ID)
76+ cpy.Name = cpy.ID
77+ aliased = append(aliased, &cpy)
78+ }
79+ return aliased
80+}
internal/provider/gemini-web/conversation/hash.go+74−0
@@ -0,0 +1,74 @@
1+package conversation
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "fmt"
7+ "strings"
8+)
9+
10+// Message represents a minimal role-text pair used for hashing and comparison.
11+type Message struct {
12+ Role string `json:"role"`
13+ Text string `json:"text"`
14+}
15+
16+// StoredMessage mirrors the persisted conversation message structure.
17+type StoredMessage struct {
18+ Role string `json:"role"`
19+ Content string `json:"content"`
20+ Name string `json:"name,omitempty"`
21+}
22+
23+// Sha256Hex computes SHA-256 hex digest for the specified string.
24+func Sha256Hex(s string) string {
25+ sum := sha256.Sum256([]byte(s))
26+ return hex.EncodeToString(sum[:])
27+}
28+
29+// ToStoredMessages converts in-memory messages into the persisted representation.
30+func ToStoredMessages(msgs []Message) []StoredMessage {
31+ out := make([]StoredMessage, 0, len(msgs))
32+ for _, m := range msgs {
33+ out = append(out, StoredMessage{Role: m.Role, Content: m.Text})
34+ }
35+ return out
36+}
37+
38+// StoredToMessages converts stored messages back into the in-memory representation.
39+func StoredToMessages(msgs []StoredMessage) []Message {
40+ out := make([]Message, 0, len(msgs))
41+ for _, m := range msgs {
42+ out = append(out, Message{Role: m.Role, Text: m.Content})
43+ }
44+ return out
45+}
46+
47+// hashMessage normalizes message data and returns a stable digest.
48+func hashMessage(m StoredMessage) string {
49+ s := fmt.Sprintf(`{"content":%q,"role":%q}`, m.Content, strings.ToLower(m.Role))
50+ return Sha256Hex(s)
51+}
52+
53+// HashConversationWithPrefix computes a conversation hash using the provided prefix (client identifier) and model.
54+func HashConversationWithPrefix(prefix, model string, msgs []StoredMessage) string {
55+ var b strings.Builder
56+ b.WriteString(strings.ToLower(strings.TrimSpace(prefix)))
57+ b.WriteString("|")
58+ b.WriteString(strings.ToLower(strings.TrimSpace(model)))
59+ for _, m := range msgs {
60+ b.WriteString("|")
61+ b.WriteString(hashMessage(m))
62+ }
63+ return Sha256Hex(b.String())
64+}
65+
66+// HashConversationForAccount keeps compatibility with the per-account hash previously used.
67+func HashConversationForAccount(clientID, model string, msgs []StoredMessage) string {
68+ return HashConversationWithPrefix(clientID, model, msgs)
69+}
70+
71+// HashConversationGlobal produces a hash suitable for cross-account lookups.
72+func HashConversationGlobal(model string, msgs []StoredMessage) string {
73+ return HashConversationWithPrefix("global", model, msgs)
74+}
internal/provider/gemini-web/conversation/index.go+280−0
@@ -0,0 +1,280 @@
1+package conversation
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+ "time"
12+
13+ bolt "go.etcd.io/bbolt"
14+)
15+
16+const (
17+ bucketMatches = "matches"
18+ defaultIndexFile = "gemini-web-index.bolt"
19+)
20+
21+// MatchRecord stores persisted mapping metadata for a conversation prefix.
22+type MatchRecord struct {
23+ AccountLabel string `json:"account_label"`
24+ Metadata []string `json:"metadata,omitempty"`
25+ PrefixLen int `json:"prefix_len"`
26+ UpdatedAt int64 `json:"updated_at"`
27+}
28+
29+// MatchResult combines a persisted record with the hash that produced it.
30+type MatchResult struct {
31+ Hash string
32+ Record MatchRecord
33+ Model string
34+}
35+
36+var (
37+ indexOnce sync.Once
38+ indexDB *bolt.DB
39+ indexErr error
40+)
41+
42+func openIndex() (*bolt.DB, error) {
43+ indexOnce.Do(func() {
44+ path := indexPath()
45+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
46+ indexErr = err
47+ return
48+ }
49+ db, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 2 * time.Second})
50+ if err != nil {
51+ indexErr = err
52+ return
53+ }
54+ indexDB = db
55+ })
56+ return indexDB, indexErr
57+}
58+
59+func indexPath() string {
60+ wd, err := os.Getwd()
61+ if err != nil || wd == "" {
62+ wd = "."
63+ }
64+ return filepath.Join(wd, "conv", defaultIndexFile)
65+}
66+
67+// StoreMatch persists or updates a conversation hash mapping.
68+func StoreMatch(hash string, record MatchRecord) error {
69+ if strings.TrimSpace(hash) == "" {
70+ return errors.New("gemini-web conversation: empty hash")
71+ }
72+ db, err := openIndex()
73+ if err != nil {
74+ return err
75+ }
76+ record.UpdatedAt = time.Now().UTC().Unix()
77+ payload, err := json.Marshal(record)
78+ if err != nil {
79+ return err
80+ }
81+ return db.Update(func(tx *bolt.Tx) error {
82+ bucket, err := tx.CreateBucketIfNotExists([]byte(bucketMatches))
83+ if err != nil {
84+ return err
85+ }
86+ // Namespace by account label to avoid cross-account collisions.
87+ label := strings.ToLower(strings.TrimSpace(record.AccountLabel))
88+ if label == "" {
89+ return errors.New("gemini-web conversation: empty account label")
90+ }
91+ key := []byte(hash + ":" + label)
92+ if err := bucket.Put(key, payload); err != nil {
93+ return err
94+ }
95+ // Best-effort cleanup of legacy single-key format (hash -> MatchRecord).
96+ // We do not know its label; leave it for lookup fallback/cleanup elsewhere.
97+ return nil
98+ })
99+}
100+
101+// LookupMatch retrieves a stored mapping.
102+// It prefers namespaced entries (hash:label). If multiple labels exist for the same
103+// hash, it returns not found to avoid redirecting to the wrong credential.
104+// Falls back to legacy single-key entries if present.
105+func LookupMatch(hash string) (MatchRecord, bool, error) {
106+ db, err := openIndex()
107+ if err != nil {
108+ return MatchRecord{}, false, err
109+ }
110+ var foundOne bool
111+ var ambiguous bool
112+ var firstLabel string
113+ var single MatchRecord
114+ err = db.View(func(tx *bolt.Tx) error {
115+ bucket := tx.Bucket([]byte(bucketMatches))
116+ if bucket == nil {
117+ return nil
118+ }
119+ // Scan namespaced keys with prefix "hash:"
120+ prefix := []byte(hash + ":")
121+ c := bucket.Cursor()
122+ for k, v := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = c.Next() {
123+ if len(v) == 0 {
124+ continue
125+ }
126+ var rec MatchRecord
127+ if err := json.Unmarshal(v, &rec); err != nil {
128+ // Ignore malformed; removal is handled elsewhere.
129+ continue
130+ }
131+ if strings.TrimSpace(rec.AccountLabel) == "" || rec.PrefixLen <= 0 {
132+ continue
133+ }
134+ label := strings.ToLower(strings.TrimSpace(rec.AccountLabel))
135+ if !foundOne {
136+ firstLabel = label
137+ single = rec
138+ foundOne = true
139+ continue
140+ }
141+ if label != firstLabel {
142+ ambiguous = true
143+ // Early exit scan; ambiguity detected.
144+ return nil
145+ }
146+ }
147+ if foundOne {
148+ return nil
149+ }
150+ // Fallback to legacy single-key format
151+ raw := bucket.Get([]byte(hash))
152+ if len(raw) == 0 {
153+ return nil
154+ }
155+ return json.Unmarshal(raw, &single)
156+ })
157+ if err != nil {
158+ return MatchRecord{}, false, err
159+ }
160+ if ambiguous {
161+ return MatchRecord{}, false, nil
162+ }
163+ if strings.TrimSpace(single.AccountLabel) == "" || single.PrefixLen <= 0 {
164+ return MatchRecord{}, false, nil
165+ }
166+ return single, true, nil
167+}
168+
169+// RemoveMatch deletes all mappings for the given hash (all labels and legacy key).
170+func RemoveMatch(hash string) error {
171+ db, err := openIndex()
172+ if err != nil {
173+ return err
174+ }
175+ return db.Update(func(tx *bolt.Tx) error {
176+ bucket := tx.Bucket([]byte(bucketMatches))
177+ if bucket == nil {
178+ return nil
179+ }
180+ // Delete namespaced entries
181+ prefix := []byte(hash + ":")
182+ c := bucket.Cursor()
183+ for k, _ := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, _ = c.Next() {
184+ if err := bucket.Delete(k); err != nil {
185+ return err
186+ }
187+ }
188+ // Delete legacy entry
189+ _ = bucket.Delete([]byte(hash))
190+ return nil
191+ })
192+}
193+
194+// RemoveMatchForLabel deletes the mapping for the given hash and label only.
195+func RemoveMatchForLabel(hash, label string) error {
196+ label = strings.ToLower(strings.TrimSpace(label))
197+ if strings.TrimSpace(hash) == "" || label == "" {
198+ return nil
199+ }
200+ db, err := openIndex()
201+ if err != nil {
202+ return err
203+ }
204+ return db.Update(func(tx *bolt.Tx) error {
205+ bucket := tx.Bucket([]byte(bucketMatches))
206+ if bucket == nil {
207+ return nil
208+ }
209+ // Remove namespaced key
210+ _ = bucket.Delete([]byte(hash + ":" + label))
211+ // If legacy single-key exists and matches label, remove it as well.
212+ if raw := bucket.Get([]byte(hash)); len(raw) > 0 {
213+ var rec MatchRecord
214+ if err := json.Unmarshal(raw, &rec); err == nil {
215+ if strings.EqualFold(strings.TrimSpace(rec.AccountLabel), label) {
216+ _ = bucket.Delete([]byte(hash))
217+ }
218+ }
219+ }
220+ return nil
221+ })
222+}
223+
224+// RemoveMatchesByLabel removes all entries associated with the specified label.
225+func RemoveMatchesByLabel(label string) error {
226+ label = strings.TrimSpace(label)
227+ if label == "" {
228+ return nil
229+ }
230+ db, err := openIndex()
231+ if err != nil {
232+ return err
233+ }
234+ return db.Update(func(tx *bolt.Tx) error {
235+ bucket := tx.Bucket([]byte(bucketMatches))
236+ if bucket == nil {
237+ return nil
238+ }
239+ cursor := bucket.Cursor()
240+ for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
241+ if len(v) == 0 {
242+ continue
243+ }
244+ var record MatchRecord
245+ if err := json.Unmarshal(v, &record); err != nil {
246+ _ = bucket.Delete(k)
247+ continue
248+ }
249+ if strings.EqualFold(strings.TrimSpace(record.AccountLabel), label) {
250+ if err := bucket.Delete(k); err != nil {
251+ return err
252+ }
253+ }
254+ }
255+ return nil
256+ })
257+}
258+
259+// StoreConversation updates all hashes representing the provided conversation snapshot.
260+func StoreConversation(label, model string, msgs []Message, metadata []string) error {
261+ label = strings.TrimSpace(label)
262+ if label == "" || len(msgs) == 0 {
263+ return nil
264+ }
265+ hashes := BuildStorageHashes(model, msgs)
266+ if len(hashes) == 0 {
267+ return nil
268+ }
269+ for _, h := range hashes {
270+ rec := MatchRecord{
271+ AccountLabel: label,
272+ Metadata: append([]string(nil), metadata...),
273+ PrefixLen: h.PrefixLen,
274+ }
275+ if err := StoreMatch(h.Hash, rec); err != nil {
276+ return err
277+ }
278+ }
279+ return nil
280+}
internal/provider/gemini-web/conversation/lookup.go+64−0
@@ -0,0 +1,64 @@
1+package conversation
2+
3+import "strings"
4+
5+// PrefixHash represents a hash candidate for a specific prefix length.
6+type PrefixHash struct {
7+ Hash string
8+ PrefixLen int
9+}
10+
11+// BuildLookupHashes generates hash candidates ordered from longest to shortest prefix.
12+func BuildLookupHashes(model string, msgs []Message) []PrefixHash {
13+ if len(msgs) < 2 {
14+ return nil
15+ }
16+ model = NormalizeModel(model)
17+ sanitized := SanitizeAssistantMessages(msgs)
18+ result := make([]PrefixHash, 0, len(sanitized))
19+ for end := len(sanitized); end >= 2; end-- {
20+ tailRole := strings.ToLower(strings.TrimSpace(sanitized[end-1].Role))
21+ if tailRole != "assistant" && tailRole != "system" {
22+ continue
23+ }
24+ prefix := sanitized[:end]
25+ hash := HashConversationGlobal(model, ToStoredMessages(prefix))
26+ result = append(result, PrefixHash{Hash: hash, PrefixLen: end})
27+ }
28+ return result
29+}
30+
31+// BuildStorageHashes returns hashes representing the full conversation snapshot.
32+func BuildStorageHashes(model string, msgs []Message) []PrefixHash {
33+ if len(msgs) == 0 {
34+ return nil
35+ }
36+ model = NormalizeModel(model)
37+ sanitized := SanitizeAssistantMessages(msgs)
38+ if len(sanitized) == 0 {
39+ return nil
40+ }
41+ result := make([]PrefixHash, 0, len(sanitized))
42+ seen := make(map[string]struct{}, len(sanitized))
43+ for start := 0; start < len(sanitized); start++ {
44+ segment := sanitized[start:]
45+ if len(segment) < 2 {
46+ continue
47+ }
48+ tailRole := strings.ToLower(strings.TrimSpace(segment[len(segment)-1].Role))
49+ if tailRole != "assistant" && tailRole != "system" {
50+ continue
51+ }
52+ hash := HashConversationGlobal(model, ToStoredMessages(segment))
53+ if _, exists := seen[hash]; exists {
54+ continue
55+ }
56+ seen[hash] = struct{}{}
57+ result = append(result, PrefixHash{Hash: hash, PrefixLen: len(segment)})
58+ }
59+ if len(result) == 0 {
60+ hash := HashConversationGlobal(model, ToStoredMessages(sanitized))
61+ return []PrefixHash{{Hash: hash, PrefixLen: len(sanitized)}}
62+ }
63+ return result
64+}
internal/provider/gemini-web/conversation/metadata.go+6−0
@@ -0,0 +1,6 @@
1+package conversation
2+
3+const (
4+ MetadataMessagesKey = "gemini_web_messages"
5+ MetadataMatchKey = "gemini_web_match"
6+)
internal/provider/gemini-web/conversation/parse.go+110−0
@@ -0,0 +1,110 @@
1+package conversation
2+
3+import (
4+ "strings"
5+
6+ "github.com/tidwall/gjson"
7+)
8+
9+// ExtractMessages attempts to build a message list from the inbound request payload.
10+func ExtractMessages(handlerType string, raw []byte) []Message {
11+ if len(raw) == 0 {
12+ return nil
13+ }
14+ if msgs := extractOpenAIStyle(raw); len(msgs) > 0 {
15+ return msgs
16+ }
17+ if msgs := extractGeminiContents(raw); len(msgs) > 0 {
18+ return msgs
19+ }
20+ return nil
21+}
22+
23+func extractOpenAIStyle(raw []byte) []Message {
24+ root := gjson.ParseBytes(raw)
25+ messages := root.Get("messages")
26+ if !messages.Exists() {
27+ return nil
28+ }
29+ out := make([]Message, 0, 8)
30+ messages.ForEach(func(_, entry gjson.Result) bool {
31+ role := strings.ToLower(strings.TrimSpace(entry.Get("role").String()))
32+ if role == "" {
33+ return true
34+ }
35+ if role == "system" {
36+ return true
37+ }
38+ // Ignore OpenAI tool messages to keep hashing aligned with
39+ // persistence (which only keeps text/inlineData for Gemini contents).
40+ // This avoids mismatches when a tool response is present: the
41+ // storage path drops tool payloads while the lookup path would
42+ // otherwise include them, causing sticky selection to fail.
43+ if role == "tool" {
44+ return true
45+ }
46+ var contentBuilder strings.Builder
47+ content := entry.Get("content")
48+ if !content.Exists() {
49+ out = append(out, Message{Role: role, Text: ""})
50+ return true
51+ }
52+ switch content.Type {
53+ case gjson.String:
54+ contentBuilder.WriteString(content.String())
55+ case gjson.JSON:
56+ if content.IsArray() {
57+ content.ForEach(func(_, part gjson.Result) bool {
58+ if text := part.Get("text"); text.Exists() {
59+ if contentBuilder.Len() > 0 {
60+ contentBuilder.WriteString("\n")
61+ }
62+ contentBuilder.WriteString(text.String())
63+ }
64+ return true
65+ })
66+ }
67+ }
68+ out = append(out, Message{Role: role, Text: contentBuilder.String()})
69+ return true
70+ })
71+ if len(out) == 0 {
72+ return nil
73+ }
74+ return out
75+}
76+
77+func extractGeminiContents(raw []byte) []Message {
78+ contents := gjson.GetBytes(raw, "contents")
79+ if !contents.Exists() {
80+ return nil
81+ }
82+ out := make([]Message, 0, 8)
83+ contents.ForEach(func(_, entry gjson.Result) bool {
84+ role := strings.TrimSpace(entry.Get("role").String())
85+ if role == "" {
86+ role = "user"
87+ } else {
88+ role = strings.ToLower(role)
89+ if role == "model" {
90+ role = "assistant"
91+ }
92+ }
93+ var builder strings.Builder
94+ entry.Get("parts").ForEach(func(_, part gjson.Result) bool {
95+ if text := part.Get("text"); text.Exists() {
96+ if builder.Len() > 0 {
97+ builder.WriteString("\n")
98+ }
99+ builder.WriteString(text.String())
100+ }
101+ return true
102+ })
103+ out = append(out, Message{Role: role, Text: builder.String()})
104+ return true
105+ })
106+ if len(out) == 0 {
107+ return nil
108+ }
109+ return out
110+}
internal/provider/gemini-web/conversation/sanitize.go+39−0
@@ -0,0 +1,39 @@
1+package conversation
2+
3+import (
4+ "regexp"
5+ "strings"
6+)
7+
8+var reThink = regexp.MustCompile(`(?is)<think>.*?</think>`)
9+
10+// RemoveThinkTags strips <think>...</think> blocks and trims whitespace.
11+func RemoveThinkTags(s string) string {
12+ return strings.TrimSpace(reThink.ReplaceAllString(s, ""))
13+}
14+
15+// SanitizeAssistantMessages removes think tags from assistant messages while leaving others untouched.
16+func SanitizeAssistantMessages(msgs []Message) []Message {
17+ out := make([]Message, 0, len(msgs))
18+ for _, m := range msgs {
19+ if strings.EqualFold(strings.TrimSpace(m.Role), "assistant") {
20+ out = append(out, Message{Role: m.Role, Text: RemoveThinkTags(m.Text)})
21+ continue
22+ }
23+ out = append(out, m)
24+ }
25+ return out
26+}
27+
28+// EqualMessages compares two message slices for equality.
29+func EqualMessages(a, b []Message) bool {
30+ if len(a) != len(b) {
31+ return false
32+ }
33+ for i := range a {
34+ if a[i].Role != b[i].Role || a[i].Text != b[i].Text {
35+ return false
36+ }
37+ }
38+ return true
39+}
internal/provider/gemini-web/models.go+7−64
@@ -4,10 +4,9 @@ import ( · 3 unmodified lines
44 "fmt"
55 "html"
66 "net/http"
7− "strings"
8− "sync"
97 "time"
108
9+ conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
1110 "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
1211 )
1312
@@ -105,76 +104,20 @@ const ( · 91 unmodified lines
105104 ErrorIPTemporarilyBlocked = 1060
106105 )
107106
108−var (
109− GeminiWebAliasOnce sync.Once
110− GeminiWebAliasMap map[string]string
111−)
112−
113−func EnsureGeminiWebAliasMap() {
114− GeminiWebAliasOnce.Do(func() {
115− GeminiWebAliasMap = make(map[string]string)
116− for _, m := range registry.GetGeminiModels() {
117− if m.ID == "gemini-2.5-flash-lite" {
118− continue
119− } else if m.ID == "gemini-2.5-flash" {
120− GeminiWebAliasMap["gemini-2.5-flash-image-preview"] = "gemini-2.5-flash"
121− }
122− alias := AliasFromModelID(m.ID)
123− GeminiWebAliasMap[strings.ToLower(alias)] = strings.ToLower(m.ID)
124− }
125− })
126−}
107+func EnsureGeminiWebAliasMap() { conversation.EnsureGeminiWebAliasMap() }
127108
128109 func GetGeminiWebAliasedModels() []*registry.ModelInfo {
129− EnsureGeminiWebAliasMap()
130− aliased := make([]*registry.ModelInfo, 0)
131− for _, m := range registry.GetGeminiModels() {
132− if m.ID == "gemini-2.5-flash-lite" {
133− continue
134− } else if m.ID == "gemini-2.5-flash" {
135− cpy := *m
136− cpy.ID = "gemini-2.5-flash-image-preview"
137− cpy.Name = "gemini-2.5-flash-image-preview"
138− cpy.DisplayName = "Nano Banana"
139− cpy.Description = "Gemini 2.5 Flash Preview Image"
140− aliased = append(aliased, &cpy)
141− }
142− cpy := *m
143− cpy.ID = AliasFromModelID(m.ID)
144− cpy.Name = cpy.ID
145− aliased = append(aliased, &cpy)
146− }
147− return aliased
110+ return conversation.GetGeminiWebAliasedModels()
148111 }
149112
150−func MapAliasToUnderlying(name string) string {
151− EnsureGeminiWebAliasMap()
152− n := strings.ToLower(name)
153− if u, ok := GeminiWebAliasMap[n]; ok {
154− return u
155− }
156− const suffix = "-web"
157− if strings.HasSuffix(n, suffix) {
158− return strings.TrimSuffix(n, suffix)
159− }
160− return name
161−}
113+func MapAliasToUnderlying(name string) string { return conversation.MapAliasToUnderlying(name) }
162114
163−func AliasFromModelID(modelID string) string {
164− return modelID + "-web"
165−}
115+func AliasFromModelID(modelID string) string { return conversation.AliasFromModelID(modelID) }
166116
167117 // Conversation domain structures -------------------------------------------
168−type RoleText struct {
169− Role string
170− Text string
171−}
118+type RoleText = conversation.Message
172119
173−type StoredMessage struct {
174− Role string `json:"role"`
175− Content string `json:"content"`
176− Name string `json:"name,omitempty"`
177−}
120+type StoredMessage = conversation.StoredMessage
178121
179122 type ConversationRecord struct {
180123 Model string `json:"model"`
internal/provider/gemini-web/prompt.go+4−11
@@ -8,11 +8,11 @@ import ( · 7 unmodified lines
88 "unicode/utf8"
99
1010 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
11+ conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
1112 "github.com/tidwall/gjson"
1213 )
1314
1415 var (
15− reThink = regexp.MustCompile(`(?s)^\s*<think>.*?</think>\s*`)
1616 reXMLAnyTag = regexp.MustCompile(`(?s)<\s*[^>]+>`)
1717 )
1818
@@ -77,20 +77,13 @@ func BuildPrompt(msgs []RoleText, tagged bool, appendAssistant bool) string { · 58 unmodified lines
7777
7878 // RemoveThinkTags strips <think>...</think> blocks from a string.
7979 func RemoveThinkTags(s string) string {
80− return strings.TrimSpace(reThink.ReplaceAllString(s, ""))
80+ return conversation.RemoveThinkTags(s)
8181 }
8282
8383 // SanitizeAssistantMessages removes think tags from assistant messages.
8484 func SanitizeAssistantMessages(msgs []RoleText) []RoleText {
85− out := make([]RoleText, 0, len(msgs))
86− for _, m := range msgs {
87− if strings.ToLower(m.Role) == "assistant" {
88− out = append(out, RoleText{Role: m.Role, Text: RemoveThinkTags(m.Text)})
89− } else {
90− out = append(out, m)
91− }
92− }
93− return out
85+ cleaned := conversation.SanitizeAssistantMessages(msgs)
86+ return cleaned
9487 }
9588
9689 // AppendXMLWrapHintIfNeeded appends an XML wrap hint to messages containing XML-like blocks.
internal/provider/gemini-web/state.go+219−62
@@ -3,8 +3,6 @@ package geminiwebapi · 2 unmodified lines
33 import (
44 "bytes"
55 "context"
6− "crypto/sha256"
7− "encoding/hex"
86 "encoding/json"
97 "errors"
108 "fmt"
@@ -19,6 +17,7 @@ import ( · 8 unmodified lines
1917 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
2018 "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
2119 "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
20+ conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
2221 "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
2322 cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
2423 log "github.com/sirupsen/logrus"
@@ -35,6 +34,7 @@ type GeminiWebState struct { · 10 unmodified lines
3534 cfg *config.Config
3635 token *gemini.GeminiWebTokenStorage
3736 storagePath string
37+ authLabel string
3838
3939 stableClientID string
4040 accountID string
@@ -51,18 +51,28 @@ type GeminiWebState struct { · 10 unmodified lines
5151 convIndex map[string]string
5252
5353 lastRefresh time.Time
54+
55+ pendingMatchMu sync.Mutex
56+ pendingMatch *conversation.MatchResult
57+}
58+
59+type reuseComputation struct {
60+ metadata []string
61+ history []RoleText
62+ overlap int
5463 }
5564
56−func NewGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage, storagePath string) *GeminiWebState {
65+func NewGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage, storagePath, authLabel string) *GeminiWebState {
5766 state := &GeminiWebState{
5867 cfg: cfg,
5968 token: token,
6069 storagePath: storagePath,
70+ authLabel: strings.TrimSpace(authLabel),
6171 convStore: make(map[string][]string),
6272 convData: make(map[string]ConversationRecord),
6373 convIndex: make(map[string]string),
6474 }
65− suffix := Sha256Hex(token.Secure1PSID)
75+ suffix := conversation.Sha256Hex(token.Secure1PSID)
6676 if len(suffix) > 16 {
6777 suffix = suffix[:16]
6878 }
@@ -81,6 +91,28 @@ func NewGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage, · 12 unmodified lines
8191 return state
8292 }
8393
94+func (s *GeminiWebState) setPendingMatch(match *conversation.MatchResult) {
95+ if s == nil {
96+ return
97+ }
98+ s.pendingMatchMu.Lock()
99+ s.pendingMatch = match
100+ s.pendingMatchMu.Unlock()
101+}
102+
103+func (s *GeminiWebState) consumePendingMatch() *conversation.MatchResult {
104+ s.pendingMatchMu.Lock()
105+ defer s.pendingMatchMu.Unlock()
106+ match := s.pendingMatch
107+ s.pendingMatch = nil
108+ return match
109+}
110+
111+// SetPendingMatch makes a cached conversation match available for the next request.
112+func (s *GeminiWebState) SetPendingMatch(match *conversation.MatchResult) {
113+ s.setPendingMatch(match)
114+}
115+
84116 // Label returns a stable account label for logging and persistence.
85117 // If a storage file path is known, it uses the file base name (without extension).
86118 // Otherwise, it falls back to the stable client ID (e.g., "gemini-web-<hash>").
@@ -93,6 +125,9 @@ func (s *GeminiWebState) Label() string { · 6 unmodified lines
93125 return lbl
94126 }
95127 }
128+ if lbl := strings.TrimSpace(s.authLabel); lbl != "" {
129+ return lbl
130+ }
96131 if s.storagePath != "" {
97132 base := strings.TrimSuffix(filepath.Base(s.storagePath), filepath.Ext(s.storagePath))
98133 if base != "" {
@@ -126,6 +161,78 @@ func (s *GeminiWebState) convPath() string { · 27 unmodified lines
126161 return ConvBoltPath(base)
127162 }
128163
164+func cloneRoleTextSlice(in []RoleText) []RoleText {
165+ if len(in) == 0 {
166+ return nil
167+ }
168+ out := make([]RoleText, len(in))
169+ copy(out, in)
170+ return out
171+}
172+
173+func cloneStringSlice(in []string) []string {
174+ if len(in) == 0 {
175+ return nil
176+ }
177+ out := make([]string, len(in))
178+ copy(out, in)
179+ return out
180+}
181+
182+func longestHistoryOverlap(history, incoming []RoleText) int {
183+ max := len(history)
184+ if len(incoming) < max {
185+ max = len(incoming)
186+ }
187+ for overlap := max; overlap > 0; overlap-- {
188+ if conversation.EqualMessages(history[len(history)-overlap:], incoming[:overlap]) {
189+ return overlap
190+ }
191+ }
192+ return 0
193+}
194+
195+func equalStringSlice(a, b []string) bool {
196+ if len(a) != len(b) {
197+ return false
198+ }
199+ for i := range a {
200+ if a[i] != b[i] {
201+ return false
202+ }
203+ }
204+ return true
205+}
206+
207+func storedMessagesToRoleText(stored []conversation.StoredMessage) []RoleText {
208+ if len(stored) == 0 {
209+ return nil
210+ }
211+ converted := make([]RoleText, len(stored))
212+ for i, msg := range stored {
213+ converted[i] = RoleText{Role: msg.Role, Text: msg.Content}
214+ }
215+ return converted
216+}
217+
218+func (s *GeminiWebState) findConversationByMetadata(model string, metadata []string) ([]RoleText, bool) {
219+ if len(metadata) == 0 {
220+ return nil, false
221+ }
222+ s.convMu.RLock()
223+ defer s.convMu.RUnlock()
224+ for _, rec := range s.convData {
225+ if !strings.EqualFold(strings.TrimSpace(rec.Model), strings.TrimSpace(model)) {
226+ continue
227+ }
228+ if !equalStringSlice(rec.Metadata, metadata) {
229+ continue
230+ }
231+ return cloneRoleTextSlice(storedMessagesToRoleText(rec.Messages)), true
232+ }
233+ return nil, false
234+}
235+
129236 func (s *GeminiWebState) GetRequestMutex() *sync.Mutex { return &s.reqMu }
130237
131238 func (s *GeminiWebState) EnsureClient() error {
@@ -219,7 +326,7 @@ func (s *GeminiWebState) prepare(ctx context.Context, modelName string, rawJSON · 87 unmodified lines
219326 return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: fmt.Errorf("bad request: %w", err)}
220327 }
221328 cleaned := SanitizeAssistantMessages(messages)
222− res.cleaned = cleaned
329+ fullCleaned := cloneRoleTextSlice(cleaned)
223330 res.underlying = MapAliasToUnderlying(modelName)
224331 model, err := ModelFromName(res.underlying)
225332 if err != nil {
@@ -232,15 +339,27 @@ func (s *GeminiWebState) prepare(ctx context.Context, modelName string, rawJSON · 6 unmodified lines
232339 mimesSubset := mimes
233340
234341 if s.useReusableContext() {
235− reuseMeta, remaining := s.findReusableSession(res.underlying, cleaned)
236− if len(reuseMeta) > 0 {
342+ reusePlan := s.reuseFromPending(res.underlying, cleaned)
343+ if reusePlan == nil {
344+ reusePlan = s.findReusableSession(res.underlying, cleaned)
345+ }
346+ if reusePlan != nil {
237347 res.reuse = true
238− meta = reuseMeta
239− if len(remaining) == 1 {
240− useMsgs = []RoleText{remaining[0]}
241− } else if len(remaining) > 1 {
242− useMsgs = remaining
243− } else if len(cleaned) > 0 {
348+ meta = cloneStringSlice(reusePlan.metadata)
349+ overlap := reusePlan.overlap
350+ if overlap > len(cleaned) {
351+ overlap = len(cleaned)
352+ } else if overlap < 0 {
353+ overlap = 0
354+ }
355+ delta := cloneRoleTextSlice(cleaned[overlap:])
356+ if len(reusePlan.history) > 0 {
357+ fullCleaned = append(cloneRoleTextSlice(reusePlan.history), delta...)
358+ } else {
359+ fullCleaned = append(cloneRoleTextSlice(cleaned[:overlap]), delta...)
360+ }
361+ useMsgs = delta
362+ if len(delta) == 0 && len(cleaned) > 0 {
244363 useMsgs = []RoleText{cleaned[len(cleaned)-1]}
245364 }
246365 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 · 51 unmodified lines
298417 s.convMu.RUnlock()
299418 }
300419
420+ res.cleaned = fullCleaned
421+
301422 res.tagged = NeedRoleTags(useMsgs)
302423 if res.reuse && len(useMsgs) == 1 {
303424 res.tagged = false
@@ -421,8 +542,22 @@ func (s *GeminiWebState) persistConversation(modelName string, prep *geminiWebPr · 117 unmodified lines
421542 if !ok {
422543 return
423544 }
424− stableHash := HashConversation(rec.ClientID, prep.underlying, rec.Messages)
425− accountHash := HashConversation(s.accountID, prep.underlying, rec.Messages)
545+ label := strings.TrimSpace(s.Label())
546+ if label == "" {
547+ label = s.accountID
548+ }
549+ conversationMsgs := conversation.StoredToMessages(rec.Messages)
550+ if err := conversation.StoreConversation(label, prep.underlying, conversationMsgs, metadata); err != nil {
551+ log.Debugf("gemini web: failed to persist global conversation index: %v", err)
552+ }
553+ stableHash := conversation.HashConversationForAccount(rec.ClientID, prep.underlying, rec.Messages)
554+ accountHash := conversation.HashConversationForAccount(s.accountID, prep.underlying, rec.Messages)
555+
556+ suffixSeen := make(map[string]struct{})
557+ suffixSeen["hash:"+stableHash] = struct{}{}
558+ if accountHash != stableHash {
559+ suffixSeen["hash:"+accountHash] = struct{}{}
560+ }
426561
427562 s.convMu.Lock()
428563 s.convData[stableHash] = rec
@@ -430,6 +565,33 @@ func (s *GeminiWebState) persistConversation(modelName string, prep *geminiWebPr · 1 unmodified line
430565 if accountHash != stableHash {
431566 s.convIndex["hash:"+accountHash] = stableHash
432567 }
568+
569+ sanitizedHistory := conversation.SanitizeAssistantMessages(conversation.StoredToMessages(rec.Messages))
570+ for start := 1; start < len(sanitizedHistory); start++ {
571+ segment := sanitizedHistory[start:]
572+ if len(segment) < 2 {
573+ continue
574+ }
575+ tailRole := strings.ToLower(strings.TrimSpace(segment[len(segment)-1].Role))
576+ if tailRole != "assistant" && tailRole != "system" {
577+ continue
578+ }
579+ storedSegment := conversation.ToStoredMessages(segment)
580+ segmentStableHash := conversation.HashConversationForAccount(rec.ClientID, prep.underlying, storedSegment)
581+ keyStable := "hash:" + segmentStableHash
582+ if _, exists := suffixSeen[keyStable]; !exists {
583+ s.convIndex[keyStable] = stableHash
584+ suffixSeen[keyStable] = struct{}{}
585+ }
586+ segmentAccountHash := conversation.HashConversationForAccount(s.accountID, prep.underlying, storedSegment)
587+ if segmentAccountHash != segmentStableHash {
588+ keyAccount := "hash:" + segmentAccountHash
589+ if _, exists := suffixSeen[keyAccount]; !exists {
590+ s.convIndex[keyAccount] = stableHash
591+ suffixSeen[keyAccount] = struct{}{}
592+ }
593+ }
594+ }
433595 dataSnapshot := make(map[string]ConversationRecord, len(s.convData))
434596 for k, v := range s.convData {
435597 dataSnapshot[k] = v
@@ -493,12 +655,44 @@ func (s *GeminiWebState) useReusableContext() bool { · 57 unmodified lines
493655 return s.cfg.GeminiWeb.Context
494656 }
495657
496−func (s *GeminiWebState) findReusableSession(modelName string, msgs []RoleText) ([]string, []RoleText) {
658+func (s *GeminiWebState) reuseFromPending(modelName string, msgs []RoleText) *reuseComputation {
659+ match := s.consumePendingMatch()
660+ if match == nil {
661+ return nil
662+ }
663+ if !strings.EqualFold(strings.TrimSpace(match.Model), strings.TrimSpace(modelName)) {
664+ return nil
665+ }
666+ metadata := cloneStringSlice(match.Record.Metadata)
667+ if len(metadata) == 0 {
668+ return nil
669+ }
670+ history, ok := s.findConversationByMetadata(modelName, metadata)
671+ if !ok {
672+ return nil
673+ }
674+ overlap := longestHistoryOverlap(history, msgs)
675+ return &reuseComputation{metadata: metadata, history: history, overlap: overlap}
676+}
677+
678+func (s *GeminiWebState) findReusableSession(modelName string, msgs []RoleText) *reuseComputation {
497679 s.convMu.RLock()
498680 items := s.convData
499681 index := s.convIndex
500682 s.convMu.RUnlock()
501− return FindReusableSessionIn(items, index, s.stableClientID, s.accountID, modelName, msgs)
683+ rec, metadata, overlap, ok := FindReusableSessionIn(items, index, s.stableClientID, s.accountID, modelName, msgs)
684+ if !ok {
685+ return nil
686+ }
687+ history := cloneRoleTextSlice(storedMessagesToRoleText(rec.Messages))
688+ if len(history) == 0 {
689+ return nil
690+ }
691+ // Ensure overlap reflects the actual history alignment.
692+ if computed := longestHistoryOverlap(history, msgs); computed > 0 {
693+ overlap = computed
694+ }
695+ return &reuseComputation{metadata: cloneStringSlice(metadata), history: history, overlap: overlap}
502696 }
503697
504698 func (s *GeminiWebState) getConfiguredGem() *Gem {
@@ -540,42 +734,6 @@ func appendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byt · 35 unmodified lines
540734 }
541735 }
542736
543−// Persistence helpers --------------------------------------------------
544−
545−// Sha256Hex computes the SHA256 hash of a string and returns its hex representation.
546−func Sha256Hex(s string) string {
547− sum := sha256.Sum256([]byte(s))
548− return hex.EncodeToString(sum[:])
549−}
550−
551−func ToStoredMessages(msgs []RoleText) []StoredMessage {
552− out := make([]StoredMessage, 0, len(msgs))
553− for _, m := range msgs {
554− out = append(out, StoredMessage{
555− Role: m.Role,
556− Content: m.Text,
557− })
558− }
559− return out
560−}
561−
562−func HashMessage(m StoredMessage) string {
563− s := fmt.Sprintf(`{"content":%q,"role":%q}`, m.Content, strings.ToLower(m.Role))
564− return Sha256Hex(s)
565−}
566−
567−func HashConversation(clientID, model string, msgs []StoredMessage) string {
568− var b strings.Builder
569− b.WriteString(clientID)
570− b.WriteString("|")
571− b.WriteString(model)
572− for _, m := range msgs {
573− b.WriteString("|")
574− b.WriteString(HashMessage(m))
575− }
576− return Sha256Hex(b.String())
577−}
578−
579737 // ConvBoltPath returns the BoltDB file path used for both account metadata and conversation data.
580738 // Different logical datasets are kept in separate buckets within this single DB file.
581739 func ConvBoltPath(tokenFilePath string) string {
@@ -790,7 +948,7 @@ func BuildConversationRecord(model, clientID string, history []RoleText, output · 208 unmodified lines
790948 Model: model,
791949 ClientID: clientID,
792950 Metadata: metadata,
793− Messages: ToStoredMessages(final),
951+ Messages: conversation.ToStoredMessages(final),
794952 CreatedAt: time.Now(),
795953 UpdatedAt: time.Now(),
796954 }
@@ -800,9 +958,9 @@ func BuildConversationRecord(model, clientID string, history []RoleText, output · 3 unmodified lines
800958 // FindByMessageListIn looks up a conversation record by hashed message list.
801959 // It attempts both the stable client ID and a legacy email-based ID.
802960 func FindByMessageListIn(items map[string]ConversationRecord, index map[string]string, stableClientID, email, model string, msgs []RoleText) (ConversationRecord, bool) {
803− stored := ToStoredMessages(msgs)
804− stableHash := HashConversation(stableClientID, model, stored)
805− fallbackHash := HashConversation(email, model, stored)
961+ stored := conversation.ToStoredMessages(msgs)
962+ stableHash := conversation.HashConversationForAccount(stableClientID, model, stored)
963+ fallbackHash := conversation.HashConversationForAccount(email, model, stored)
806964
807965 // Try stable hash via index indirection first
808966 if key, ok := index["hash:"+stableHash]; ok {
@@ -840,9 +998,9 @@ func FindConversationIn(items map[string]ConversationRecord, index map[string]st · 31 unmodified lines
840998 }
841999
8421000 // FindReusableSessionIn returns reusable metadata and the remaining message suffix.
843−func FindReusableSessionIn(items map[string]ConversationRecord, index map[string]string, stableClientID, email, model string, msgs []RoleText) ([]string, []RoleText) {
1001+func FindReusableSessionIn(items map[string]ConversationRecord, index map[string]string, stableClientID, email, model string, msgs []RoleText) (ConversationRecord, []string, int, bool) {
8441002 if len(msgs) < 2 {
845− return nil, nil
1003+ return ConversationRecord{}, nil, 0, false
8461004 }
8471005 searchEnd := len(msgs)
8481006 for searchEnd >= 2 {
@@ -850,11 +1008,10 @@ func FindReusableSessionIn(items map[string]ConversationRecord, index map[string · 1 unmodified line
8501008 tail := sub[len(sub)-1]
8511009 if strings.EqualFold(tail.Role, "assistant") || strings.EqualFold(tail.Role, "system") {
8521010 if rec, ok := FindConversationIn(items, index, stableClientID, email, model, sub); ok {
853− remain := msgs[searchEnd:]
854− return rec.Metadata, remain
1011+ return rec, rec.Metadata, searchEnd, true
8551012 }
8561013 }
8571014 searchEnd--
8581015 }
859− return nil, nil
1016+ return ConversationRecord{}, nil, 0, false
8601017 }
internal/runtime/executor/gemini_web_executor.go+33−1
@@ -13,6 +13,7 @@ import ( · 12 unmodified lines
1313 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
1414 "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
1515 geminiwebapi "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web"
16+ conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
1617 cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
1718 cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
1819 sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
@@ -40,12 +41,18 @@ func (e *GeminiWebExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth · 21 unmodified lines
4041 if err = state.EnsureClient(); err != nil {
4142 return cliproxyexecutor.Response{}, err
4243 }
44+ match := extractGeminiWebMatch(opts.Metadata)
4345 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
4446
4547 mutex := state.GetRequestMutex()
4648 if mutex != nil {
4749 mutex.Lock()
4850 defer mutex.Unlock()
51+ if match != nil {
52+ state.SetPendingMatch(match)
53+ }
54+ } else if match != nil {
55+ state.SetPendingMatch(match)
4956 }
5057
5158 payload := bytes.Clone(req.Payload)
@@ -72,11 +79,18 @@ func (e *GeminiWebExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 20 unmodified lines
7279 if err = state.EnsureClient(); err != nil {
7380 return nil, err
7481 }
82+ match := extractGeminiWebMatch(opts.Metadata)
7583 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
7684
7785 mutex := state.GetRequestMutex()
7886 if mutex != nil {
7987 mutex.Lock()
88+ if match != nil {
89+ state.SetPendingMatch(match)
90+ }
91+ }
92+ if mutex == nil && match != nil {
93+ state.SetPendingMatch(match)
8094 }
8195
8296 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 · 99 unmodified lines
182196 storagePath = p
183197 }
184198 }
185− state := geminiwebapi.NewGeminiWebState(cfg, ts, storagePath)
199+ state := geminiwebapi.NewGeminiWebState(cfg, ts, storagePath, auth.Label)
186200 runtime := &geminiWebRuntime{state: state}
187201 auth.Runtime = runtime
188202 return state, nil
@@ -242,3 +256,21 @@ func (e geminiWebError) StatusCode() int { · 53 unmodified lines
242256 }
243257 return e.message.StatusCode
244258 }
259+
260+func extractGeminiWebMatch(metadata map[string]any) *conversation.MatchResult {
261+ if metadata == nil {
262+ return nil
263+ }
264+ value, ok := metadata[conversation.MetadataMatchKey]
265+ if !ok {
266+ return nil
267+ }
268+ switch v := value.(type) {
269+ case *conversation.MatchResult:
270+ return v
271+ case conversation.MatchResult:
272+ return &v
273+ default:
274+ return nil
275+ }
276+}
sdk/api/handlers/handlers.go+21−0
@@ -9,6 +9,7 @@ import ( · 8 unmodified lines
99
1010 "github.com/gin-gonic/gin"
1111 "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
12+ conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
1213 "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
1314 coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
1415 coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
@@ -48,6 +49,8 @@ type BaseAPIHandler struct { · 33 unmodified lines
4849 Cfg *config.SDKConfig
4950 }
5051
52+const geminiWebProvider = "gemini-web"
53+
5154 // NewBaseAPIHandlers creates a new API handlers instance.
5255 // It takes a slice of clients and configuration as input.
5356 //
@@ -137,6 +140,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType · 83 unmodified lines
137140 if len(providers) == 0 {
138141 return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
139142 }
143+ metadata := h.buildGeminiWebMetadata(handlerType, providers, rawJSON)
140144 req := coreexecutor.Request{
141145 Model: modelName,
142146 Payload: cloneBytes(rawJSON),
@@ -146,6 +150,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType · 3 unmodified lines
146150 Alt: alt,
147151 OriginalRequest: cloneBytes(rawJSON),
148152 SourceFormat: sdktranslator.FromString(handlerType),
153+ Metadata: metadata,
149154 }
150155 resp, err := h.AuthManager.Execute(ctx, providers, req, opts)
151156 if err != nil {
@@ -161,6 +166,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle · 9 unmodified lines
161166 if len(providers) == 0 {
162167 return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
163168 }
169+ metadata := h.buildGeminiWebMetadata(handlerType, providers, rawJSON)
164170 req := coreexecutor.Request{
165171 Model: modelName,
166172 Payload: cloneBytes(rawJSON),
@@ -170,6 +176,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle · 3 unmodified lines
170176 Alt: alt,
171177 OriginalRequest: cloneBytes(rawJSON),
172178 SourceFormat: sdktranslator.FromString(handlerType),
179+ Metadata: metadata,
173180 }
174181 resp, err := h.AuthManager.ExecuteCount(ctx, providers, req, opts)
175182 if err != nil {
@@ -188,6 +195,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl · 12 unmodified lines
188195 close(errChan)
189196 return nil, errChan
190197 }
198+ metadata := h.buildGeminiWebMetadata(handlerType, providers, rawJSON)
191199 req := coreexecutor.Request{
192200 Model: modelName,
193201 Payload: cloneBytes(rawJSON),
@@ -197,6 +205,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl · 3 unmodified lines
197205 Alt: alt,
198206 OriginalRequest: cloneBytes(rawJSON),
199207 SourceFormat: sdktranslator.FromString(handlerType),
208+ Metadata: metadata,
200209 }
201210 chunks, err := h.AuthManager.ExecuteStream(ctx, providers, req, opts)
202211 if err != nil {
@@ -232,6 +241,18 @@ func cloneBytes(src []byte) []byte { · 29 unmodified lines
232241 return dst
233242 }
234243
244+func (h *BaseAPIHandler) buildGeminiWebMetadata(handlerType string, providers []string, rawJSON []byte) map[string]any {
245+ if !util.InArray(providers, geminiWebProvider) {
246+ return nil
247+ }
248+ meta := make(map[string]any)
249+ msgs := conversation.ExtractMessages(handlerType, rawJSON)
250+ if len(msgs) > 0 {
251+ meta[conversation.MetadataMessagesKey] = msgs
252+ }
253+ return meta
254+}
255+
235256 // WriteErrorResponse writes an error message to the response writer using the HTTP status embedded in the message.
236257 func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.ErrorMessage) {
237258 status := http.StatusInternalServerError
sdk/cliproxy/auth/selector_rr.go+125−0
@@ -0,0 +1,125 @@
1+package auth
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
8+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
9+ log "github.com/sirupsen/logrus"
10+)
11+
12+const (
13+ geminiWebProviderKey = "gemini-web"
14+)
15+
16+type geminiWebStickySelector struct {
17+ base Selector
18+}
19+
20+func NewGeminiWebStickySelector(base Selector) Selector {
21+ if selector, ok := base.(*geminiWebStickySelector); ok {
22+ return selector
23+ }
24+ if base == nil {
25+ base = &RoundRobinSelector{}
26+ }
27+ return &geminiWebStickySelector{base: base}
28+}
29+
30+func (m *Manager) EnableGeminiWebStickySelector() {
31+ if m == nil {
32+ return
33+ }
34+ m.mu.Lock()
35+ defer m.mu.Unlock()
36+ if _, ok := m.selector.(*geminiWebStickySelector); ok {
37+ return
38+ }
39+ m.selector = NewGeminiWebStickySelector(m.selector)
40+}
41+
42+func (s *geminiWebStickySelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {
43+ if !strings.EqualFold(provider, geminiWebProviderKey) {
44+ if opts.Metadata != nil {
45+ delete(opts.Metadata, conversation.MetadataMatchKey)
46+ }
47+ return s.base.Pick(ctx, provider, model, opts, auths)
48+ }
49+
50+ messages := extractGeminiWebMessages(opts.Metadata)
51+ if len(messages) >= 2 {
52+ normalizedModel := conversation.NormalizeModel(model)
53+ candidates := conversation.BuildLookupHashes(normalizedModel, messages)
54+ for _, candidate := range candidates {
55+ record, ok, err := conversation.LookupMatch(candidate.Hash)
56+ if err != nil {
57+ log.Warnf("gemini-web selector: lookup failed for hash %s: %v", candidate.Hash, err)
58+ continue
59+ }
60+ if !ok {
61+ continue
62+ }
63+ label := strings.TrimSpace(record.AccountLabel)
64+ if label == "" {
65+ continue
66+ }
67+ auth := findAuthByLabel(auths, label)
68+ if auth != nil {
69+ if opts.Metadata != nil {
70+ opts.Metadata[conversation.MetadataMatchKey] = &conversation.MatchResult{
71+ Hash: candidate.Hash,
72+ Record: record,
73+ Model: normalizedModel,
74+ }
75+ }
76+ return auth, nil
77+ }
78+ _ = conversation.RemoveMatchForLabel(candidate.Hash, label)
79+ }
80+ }
81+
82+ return s.base.Pick(ctx, provider, model, opts, auths)
83+}
84+
85+func extractGeminiWebMessages(metadata map[string]any) []conversation.Message {
86+ if metadata == nil {
87+ return nil
88+ }
89+ raw, ok := metadata[conversation.MetadataMessagesKey]
90+ if !ok {
91+ return nil
92+ }
93+ switch v := raw.(type) {
94+ case []conversation.Message:
95+ return v
96+ case *[]conversation.Message:
97+ if v == nil {
98+ return nil
99+ }
100+ return *v
101+ default:
102+ return nil
103+ }
104+}
105+
106+func findAuthByLabel(auths []*Auth, label string) *Auth {
107+ if len(auths) == 0 {
108+ return nil
109+ }
110+ normalized := strings.ToLower(strings.TrimSpace(label))
111+ for _, auth := range auths {
112+ if auth == nil {
113+ continue
114+ }
115+ if strings.ToLower(strings.TrimSpace(auth.Label)) == normalized {
116+ return auth
117+ }
118+ if auth.Metadata != nil {
119+ if v, ok := auth.Metadata["label"].(string); ok && strings.ToLower(strings.TrimSpace(v)) == normalized {
120+ return auth
121+ }
122+ }
123+ }
124+ return nil
125+}
sdk/cliproxy/executor/types.go+2−0
@@ -33,6 +33,8 @@ type Options struct { · 32 unmodified lines
3333 OriginalRequest []byte
3434 // SourceFormat identifies the inbound schema.
3535 SourceFormat sdktranslator.Format
36+ // Metadata carries extra execution hints shared across selection and executors.
37+ Metadata map[string]any
3638 }
3739
3840 // Response wraps either a full provider response or metadata for streaming flows.
sdk/cliproxy/service.go+19−0
@@ -15,6 +15,7 @@ import ( · 14 unmodified lines
1515 "github.com/router-for-me/CLIProxyAPI/v6/internal/api"
1616 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
1717 geminiwebclient "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web"
18+ conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
1819 "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
1920 "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
2021 _ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
@@ -206,6 +207,23 @@ func (s *Service) applyCoreAuthRemoval(ctx context.Context, id string) { · 185 unmodified lines
206207 }
207208 GlobalModelRegistry().UnregisterClient(id)
208209 if existing, ok := s.coreManager.GetByID(id); ok && existing != nil {
210+ if strings.EqualFold(existing.Provider, "gemini-web") {
211+ // Prefer the stable cookie label stored in metadata when available.
212+ var label string
213+ if existing.Metadata != nil {
214+ if v, ok := existing.Metadata["label"].(string); ok {
215+ label = strings.TrimSpace(v)
216+ }
217+ }
218+ if label == "" {
219+ label = strings.TrimSpace(existing.Label)
220+ }
221+ if label != "" {
222+ if err := conversation.RemoveMatchesByLabel(label); err != nil {
223+ log.Debugf("failed to remove gemini web sticky entries for %s: %v", label, err)
224+ }
225+ }
226+ }
209227 existing.Disabled = true
210228 existing.Status = coreauth.StatusDisabled
211229 if _, err := s.coreManager.Update(ctx, existing); err != nil {
@@ -225,6 +243,7 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) { · 13 unmodified lines
225243 s.coreManager.RegisterExecutor(executor.NewGeminiCLIExecutor(s.cfg))
226244 case "gemini-web":
227245 s.coreManager.RegisterExecutor(executor.NewGeminiWebExecutor(s.cfg))
246+ s.coreManager.EnableGeminiWebStickySelector()
228247 case "claude":
229248 s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg))
230249 case "codex":