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
10.serena/*10.serena/*
11AGENTS.md11AGENTS.md
12CLAUDE.md12CLAUDE.md
13GEMINI.md
13*.exe14*.exe
14temp/*15temp/*
15cli-proxy-api16cli-proxy-api
internal/provider/gemini-web/conversation/alias.go+80−0
@@ -0,0 +1,80 @@
1package conversation
2
3import (
4 "strings"
5 "sync"
6
7 "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
8)
9
10var (
11 aliasOnce sync.Once
12 aliasMap map[string]string
13)
14
15// EnsureGeminiWebAliasMap populates the alias map once.
16func 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.
33func 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.
50func AliasFromModelID(modelID string) string {
51 return modelID + "-web"
52}
53
54// NormalizeModel returns the canonical identifier used for hashing.
55func NormalizeModel(model string) string {
56 return MapAliasToUnderlying(model)
57}
58
59// GetGeminiWebAliasedModels returns alias metadata for registry exposure.
60func 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 @@
1package conversation
2
3import (
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.
11type Message struct {
12 Role string `json:"role"`
13 Text string `json:"text"`
14}
15
16// StoredMessage mirrors the persisted conversation message structure.
17type 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.
24func 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.
30func 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.
39func 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.
48func 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.
54func 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.
67func 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.
72func 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 @@
1package conversation
2
3import (
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
16const (
17 bucketMatches = "matches"
18 defaultIndexFile = "gemini-web-index.bolt"
19)
20
21// MatchRecord stores persisted mapping metadata for a conversation prefix.
22type 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.
30type MatchResult struct {
31 Hash string
32 Record MatchRecord
33 Model string
34}
35
36var (
37 indexOnce sync.Once
38 indexDB *bolt.DB
39 indexErr error
40)
41
42func 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
59func 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.
68func 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.
105func 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).
170func 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.
195func 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.
225func 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.
260func 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 @@
1package conversation
2
3import "strings"
4
5// PrefixHash represents a hash candidate for a specific prefix length.
6type PrefixHash struct {
7 Hash string
8 PrefixLen int
9}
10
11// BuildLookupHashes generates hash candidates ordered from longest to shortest prefix.
12func 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.
32func 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 @@
1package conversation
2
3const (
4 MetadataMessagesKey = "gemini_web_messages"
5 MetadataMatchKey = "gemini_web_match"
6)
internal/provider/gemini-web/conversation/parse.go+110−0
@@ -0,0 +1,110 @@
1package conversation
2
3import (
4 "strings"
5
6 "github.com/tidwall/gjson"
7)
8
9// ExtractMessages attempts to build a message list from the inbound request payload.
10func 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
23func 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
77func 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 @@
1package conversation
2
3import (
4 "regexp"
5 "strings"
6)
7
8var reThink = regexp.MustCompile(`(?is)<think>.*?</think>`)
9
10// RemoveThinkTags strips <think>...</think> blocks and trims whitespace.
11func 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.
16func 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.
29func 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
4 "fmt"4 "fmt"
5 "html"5 "html"
6 "net/http"6 "net/http"
7 "strings"
8 "sync"
9 "time"7 "time"
10 8
9 conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
11 "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"10 "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
12)11)
13 12
@@ -105,76 +104,20 @@ const ( · 91 unmodified lines
105 ErrorIPTemporarilyBlocked = 1060104 ErrorIPTemporarilyBlocked = 1060
106)105)
107 106
108var (107func EnsureGeminiWebAliasMap() { conversation.EnsureGeminiWebAliasMap() }
109 GeminiWebAliasOnce sync.Once
110 GeminiWebAliasMap map[string]string
111)
112
113func 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}
127 108
128func GetGeminiWebAliasedModels() []*registry.ModelInfo {109func GetGeminiWebAliasedModels() []*registry.ModelInfo {
129 EnsureGeminiWebAliasMap()110 return conversation.GetGeminiWebAliasedModels()
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
148}111}
149 112
150func MapAliasToUnderlying(name string) string {113func MapAliasToUnderlying(name string) string { return conversation.MapAliasToUnderlying(name) }
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}
162 114
163func AliasFromModelID(modelID string) string {115func AliasFromModelID(modelID string) string { return conversation.AliasFromModelID(modelID) }
164 return modelID + "-web"
165}
166 116
167// Conversation domain structures -------------------------------------------117// Conversation domain structures -------------------------------------------
168type RoleText struct {118type RoleText = conversation.Message
169 Role string
170 Text string
171}
172 119
173type StoredMessage struct {120type StoredMessage = conversation.StoredMessage
174 Role string `json:"role"`
175 Content string `json:"content"`
176 Name string `json:"name,omitempty"`
177}
178 121
179type ConversationRecord struct {122type ConversationRecord struct {
180 Model string `json:"model"`123 Model string `json:"model"`
internal/provider/gemini-web/prompt.go+4−11
@@ -8,11 +8,11 @@ import ( · 7 unmodified lines
8 "unicode/utf8"8 "unicode/utf8"
9 9
10 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"10 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
11 conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
11 "github.com/tidwall/gjson"12 "github.com/tidwall/gjson"
12)13)
13 14
14var (15var (
15 reThink = regexp.MustCompile(`(?s)^\s*<think>.*?</think>\s*`)
16 reXMLAnyTag = regexp.MustCompile(`(?s)<\s*[^>]+>`)16 reXMLAnyTag = regexp.MustCompile(`(?s)<\s*[^>]+>`)
17)17)
18 18
@@ -77,20 +77,13 @@ func BuildPrompt(msgs []RoleText, tagged bool, appendAssistant bool) string { · 58 unmodified lines
77 77
78// RemoveThinkTags strips <think>...</think> blocks from a string.78// RemoveThinkTags strips <think>...</think> blocks from a string.
79func RemoveThinkTags(s string) string {79func RemoveThinkTags(s string) string {
80 return strings.TrimSpace(reThink.ReplaceAllString(s, ""))80 return conversation.RemoveThinkTags(s)
81}81}
82 82
83// SanitizeAssistantMessages removes think tags from assistant messages.83// SanitizeAssistantMessages removes think tags from assistant messages.
84func SanitizeAssistantMessages(msgs []RoleText) []RoleText {84func SanitizeAssistantMessages(msgs []RoleText) []RoleText {
85 out := make([]RoleText, 0, len(msgs))85 cleaned := conversation.SanitizeAssistantMessages(msgs)
86 for _, m := range msgs {86 return cleaned
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
94}87}
95 88
96// AppendXMLWrapHintIfNeeded appends an XML wrap hint to messages containing XML-like blocks.89// 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
3import (3import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"6 "encoding/json"
9 "errors"7 "errors"
10 "fmt"8 "fmt"
@@ -19,6 +17,7 @@ import ( · 8 unmodified lines
19 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"17 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
20 "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"18 "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
21 "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"19 "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
20 conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
22 "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"21 "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
23 cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"22 cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
24 log "github.com/sirupsen/logrus"23 log "github.com/sirupsen/logrus"
@@ -35,6 +34,7 @@ type GeminiWebState struct { · 10 unmodified lines
35 cfg *config.Config34 cfg *config.Config
36 token *gemini.GeminiWebTokenStorage35 token *gemini.GeminiWebTokenStorage
37 storagePath string36 storagePath string
37 authLabel string
38 38
39 stableClientID string39 stableClientID string
40 accountID string40 accountID string
@@ -51,18 +51,28 @@ type GeminiWebState struct { · 10 unmodified lines
51 convIndex map[string]string51 convIndex map[string]string
52 52
53 lastRefresh time.Time53 lastRefresh time.Time
54
55 pendingMatchMu sync.Mutex
56 pendingMatch *conversation.MatchResult
57}
58
59type reuseComputation struct {
60 metadata []string
61 history []RoleText
62 overlap int
54}63}
55 64
56func NewGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage, storagePath string) *GeminiWebState {65func NewGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage, storagePath, authLabel string) *GeminiWebState {
57 state := &GeminiWebState{66 state := &GeminiWebState{
58 cfg: cfg,67 cfg: cfg,
59 token: token,68 token: token,
60 storagePath: storagePath,69 storagePath: storagePath,
70 authLabel: strings.TrimSpace(authLabel),
61 convStore: make(map[string][]string),71 convStore: make(map[string][]string),
62 convData: make(map[string]ConversationRecord),72 convData: make(map[string]ConversationRecord),
63 convIndex: make(map[string]string),73 convIndex: make(map[string]string),
64 }74 }
65 suffix := Sha256Hex(token.Secure1PSID)75 suffix := conversation.Sha256Hex(token.Secure1PSID)
66 if len(suffix) > 16 {76 if len(suffix) > 16 {
67 suffix = suffix[:16]77 suffix = suffix[:16]
68 }78 }
@@ -81,6 +91,28 @@ func NewGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage, · 12 unmodified lines
81 return state91 return state
82}92}
83 93
94func (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
103func (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.
112func (s *GeminiWebState) SetPendingMatch(match *conversation.MatchResult) {
113 s.setPendingMatch(match)
114}
115
84// Label returns a stable account label for logging and persistence.116// Label returns a stable account label for logging and persistence.
85// If a storage file path is known, it uses the file base name (without extension).117// If a storage file path is known, it uses the file base name (without extension).
86// Otherwise, it falls back to the stable client ID (e.g., "gemini-web-<hash>").118// 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
93 return lbl125 return lbl
94 }126 }
95 }127 }
128 if lbl := strings.TrimSpace(s.authLabel); lbl != "" {
129 return lbl
130 }
96 if s.storagePath != "" {131 if s.storagePath != "" {
97 base := strings.TrimSuffix(filepath.Base(s.storagePath), filepath.Ext(s.storagePath))132 base := strings.TrimSuffix(filepath.Base(s.storagePath), filepath.Ext(s.storagePath))
98 if base != "" {133 if base != "" {
@@ -126,6 +161,78 @@ func (s *GeminiWebState) convPath() string { · 27 unmodified lines
126 return ConvBoltPath(base)161 return ConvBoltPath(base)
127}162}
128 163
164func 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
173func 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
182func 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
195func 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
207func 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
218func (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
129func (s *GeminiWebState) GetRequestMutex() *sync.Mutex { return &s.reqMu }236func (s *GeminiWebState) GetRequestMutex() *sync.Mutex { return &s.reqMu }
130 237
131func (s *GeminiWebState) EnsureClient() error {238func (s *GeminiWebState) EnsureClient() error {
@@ -219,7 +326,7 @@ func (s *GeminiWebState) prepare(ctx context.Context, modelName string, rawJSON · 87 unmodified lines
219 return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: fmt.Errorf("bad request: %w", err)}326 return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: fmt.Errorf("bad request: %w", err)}
220 }327 }
221 cleaned := SanitizeAssistantMessages(messages)328 cleaned := SanitizeAssistantMessages(messages)
222 res.cleaned = cleaned329 fullCleaned := cloneRoleTextSlice(cleaned)
223 res.underlying = MapAliasToUnderlying(modelName)330 res.underlying = MapAliasToUnderlying(modelName)
224 model, err := ModelFromName(res.underlying)331 model, err := ModelFromName(res.underlying)
225 if err != nil {332 if err != nil {
@@ -232,15 +339,27 @@ func (s *GeminiWebState) prepare(ctx context.Context, modelName string, rawJSON · 6 unmodified lines
232 mimesSubset := mimes339 mimesSubset := mimes
233 340
234 if s.useReusableContext() {341 if s.useReusableContext() {
235 reuseMeta, remaining := s.findReusableSession(res.underlying, cleaned)342 reusePlan := s.reuseFromPending(res.underlying, cleaned)
236 if len(reuseMeta) > 0 {343 if reusePlan == nil {
344 reusePlan = s.findReusableSession(res.underlying, cleaned)
345 }
346 if reusePlan != nil {
237 res.reuse = true347 res.reuse = true
238 meta = reuseMeta348 meta = cloneStringSlice(reusePlan.metadata)
239 if len(remaining) == 1 {349 overlap := reusePlan.overlap
240 useMsgs = []RoleText{remaining[0]}350 if overlap > len(cleaned) {
241 } else if len(remaining) > 1 {351 overlap = len(cleaned)
242 useMsgs = remaining352 } else if overlap < 0 {
243 } else if len(cleaned) > 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 {
244 useMsgs = []RoleText{cleaned[len(cleaned)-1]}363 useMsgs = []RoleText{cleaned[len(cleaned)-1]}
245 }364 }
246 if len(useMsgs) == 1 && len(messages) > 0 && len(msgFileIdx) == len(messages) {365 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
298 s.convMu.RUnlock()417 s.convMu.RUnlock()
299 }418 }
300 419
420 res.cleaned = fullCleaned
421
301 res.tagged = NeedRoleTags(useMsgs)422 res.tagged = NeedRoleTags(useMsgs)
302 if res.reuse && len(useMsgs) == 1 {423 if res.reuse && len(useMsgs) == 1 {
303 res.tagged = false424 res.tagged = false
@@ -421,8 +542,22 @@ func (s *GeminiWebState) persistConversation(modelName string, prep *geminiWebPr · 117 unmodified lines
421 if !ok {542 if !ok {
422 return543 return
423 }544 }
424 stableHash := HashConversation(rec.ClientID, prep.underlying, rec.Messages)545 label := strings.TrimSpace(s.Label())
425 accountHash := HashConversation(s.accountID, prep.underlying, rec.Messages)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 }
426 561
427 s.convMu.Lock()562 s.convMu.Lock()
428 s.convData[stableHash] = rec563 s.convData[stableHash] = rec
@@ -430,6 +565,33 @@ func (s *GeminiWebState) persistConversation(modelName string, prep *geminiWebPr · 1 unmodified line
430 if accountHash != stableHash {565 if accountHash != stableHash {
431 s.convIndex["hash:"+accountHash] = stableHash566 s.convIndex["hash:"+accountHash] = stableHash
432 }567 }
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 }
433 dataSnapshot := make(map[string]ConversationRecord, len(s.convData))595 dataSnapshot := make(map[string]ConversationRecord, len(s.convData))
434 for k, v := range s.convData {596 for k, v := range s.convData {
435 dataSnapshot[k] = v597 dataSnapshot[k] = v
@@ -493,12 +655,44 @@ func (s *GeminiWebState) useReusableContext() bool { · 57 unmodified lines
493 return s.cfg.GeminiWeb.Context655 return s.cfg.GeminiWeb.Context
494}656}
495 657
496func (s *GeminiWebState) findReusableSession(modelName string, msgs []RoleText) ([]string, []RoleText) {658func (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
678func (s *GeminiWebState) findReusableSession(modelName string, msgs []RoleText) *reuseComputation {
497 s.convMu.RLock()679 s.convMu.RLock()
498 items := s.convData680 items := s.convData
499 index := s.convIndex681 index := s.convIndex
500 s.convMu.RUnlock()682 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}
502}696}
503 697
504func (s *GeminiWebState) getConfiguredGem() *Gem {698func (s *GeminiWebState) getConfiguredGem() *Gem {
@@ -540,42 +734,6 @@ func appendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byt · 35 unmodified lines
540 }734 }
541}735}
542 736
543// Persistence helpers --------------------------------------------------
544
545// Sha256Hex computes the SHA256 hash of a string and returns its hex representation.
546func Sha256Hex(s string) string {
547 sum := sha256.Sum256([]byte(s))
548 return hex.EncodeToString(sum[:])
549}
550
551func 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
562func HashMessage(m StoredMessage) string {
563 s := fmt.Sprintf(`{"content":%q,"role":%q}`, m.Content, strings.ToLower(m.Role))
564 return Sha256Hex(s)
565}
566
567func 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
579// ConvBoltPath returns the BoltDB file path used for both account metadata and conversation data.737// ConvBoltPath returns the BoltDB file path used for both account metadata and conversation data.
580// Different logical datasets are kept in separate buckets within this single DB file.738// Different logical datasets are kept in separate buckets within this single DB file.
581func ConvBoltPath(tokenFilePath string) string {739func ConvBoltPath(tokenFilePath string) string {
@@ -790,7 +948,7 @@ func BuildConversationRecord(model, clientID string, history []RoleText, output · 208 unmodified lines
790 Model: model,948 Model: model,
791 ClientID: clientID,949 ClientID: clientID,
792 Metadata: metadata,950 Metadata: metadata,
793 Messages: ToStoredMessages(final),951 Messages: conversation.ToStoredMessages(final),
794 CreatedAt: time.Now(),952 CreatedAt: time.Now(),
795 UpdatedAt: time.Now(),953 UpdatedAt: time.Now(),
796 }954 }
@@ -800,9 +958,9 @@ func BuildConversationRecord(model, clientID string, history []RoleText, output · 3 unmodified lines
800// FindByMessageListIn looks up a conversation record by hashed message list.958// FindByMessageListIn looks up a conversation record by hashed message list.
801// It attempts both the stable client ID and a legacy email-based ID.959// It attempts both the stable client ID and a legacy email-based ID.
802func FindByMessageListIn(items map[string]ConversationRecord, index map[string]string, stableClientID, email, model string, msgs []RoleText) (ConversationRecord, bool) {960func FindByMessageListIn(items map[string]ConversationRecord, index map[string]string, stableClientID, email, model string, msgs []RoleText) (ConversationRecord, bool) {
803 stored := ToStoredMessages(msgs)961 stored := conversation.ToStoredMessages(msgs)
804 stableHash := HashConversation(stableClientID, model, stored)962 stableHash := conversation.HashConversationForAccount(stableClientID, model, stored)
805 fallbackHash := HashConversation(email, model, stored)963 fallbackHash := conversation.HashConversationForAccount(email, model, stored)
806 964
807 // Try stable hash via index indirection first965 // Try stable hash via index indirection first
808 if key, ok := index["hash:"+stableHash]; ok {966 if key, ok := index["hash:"+stableHash]; ok {
@@ -840,9 +998,9 @@ func FindConversationIn(items map[string]ConversationRecord, index map[string]st · 31 unmodified lines
840}998}
841 999
842// FindReusableSessionIn returns reusable metadata and the remaining message suffix.1000// FindReusableSessionIn returns reusable metadata and the remaining message suffix.
843func FindReusableSessionIn(items map[string]ConversationRecord, index map[string]string, stableClientID, email, model string, msgs []RoleText) ([]string, []RoleText) {1001func FindReusableSessionIn(items map[string]ConversationRecord, index map[string]string, stableClientID, email, model string, msgs []RoleText) (ConversationRecord, []string, int, bool) {
844 if len(msgs) < 2 {1002 if len(msgs) < 2 {
845 return nil, nil1003 return ConversationRecord{}, nil, 0, false
846 }1004 }
847 searchEnd := len(msgs)1005 searchEnd := len(msgs)
848 for searchEnd >= 2 {1006 for searchEnd >= 2 {
@@ -850,11 +1008,10 @@ func FindReusableSessionIn(items map[string]ConversationRecord, index map[string · 1 unmodified line
850 tail := sub[len(sub)-1]1008 tail := sub[len(sub)-1]
851 if strings.EqualFold(tail.Role, "assistant") || strings.EqualFold(tail.Role, "system") {1009 if strings.EqualFold(tail.Role, "assistant") || strings.EqualFold(tail.Role, "system") {
852 if rec, ok := FindConversationIn(items, index, stableClientID, email, model, sub); ok {1010 if rec, ok := FindConversationIn(items, index, stableClientID, email, model, sub); ok {
853 remain := msgs[searchEnd:]1011 return rec, rec.Metadata, searchEnd, true
854 return rec.Metadata, remain
855 }1012 }
856 }1013 }
857 searchEnd--1014 searchEnd--
858 }1015 }
859 return nil, nil1016 return ConversationRecord{}, nil, 0, false
860}1017}
internal/runtime/executor/gemini_web_executor.go+33−1
@@ -13,6 +13,7 @@ import ( · 12 unmodified lines
13 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"13 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
14 "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"14 "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
15 geminiwebapi "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web"15 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"
16 cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"17 cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
17 cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"18 cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
18 sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"19 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
40 if err = state.EnsureClient(); err != nil {41 if err = state.EnsureClient(); err != nil {
41 return cliproxyexecutor.Response{}, err42 return cliproxyexecutor.Response{}, err
42 }43 }
44 match := extractGeminiWebMatch(opts.Metadata)
43 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)45 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
44 46
45 mutex := state.GetRequestMutex()47 mutex := state.GetRequestMutex()
46 if mutex != nil {48 if mutex != nil {
47 mutex.Lock()49 mutex.Lock()
48 defer mutex.Unlock()50 defer mutex.Unlock()
51 if match != nil {
52 state.SetPendingMatch(match)
53 }
54 } else if match != nil {
55 state.SetPendingMatch(match)
49 }56 }
50 57
51 payload := bytes.Clone(req.Payload)58 payload := bytes.Clone(req.Payload)
@@ -72,11 +79,18 @@ func (e *GeminiWebExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 20 unmodified lines
72 if err = state.EnsureClient(); err != nil {79 if err = state.EnsureClient(); err != nil {
73 return nil, err80 return nil, err
74 }81 }
82 match := extractGeminiWebMatch(opts.Metadata)
75 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)83 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
76 84
77 mutex := state.GetRequestMutex()85 mutex := state.GetRequestMutex()
78 if mutex != nil {86 if mutex != nil {
79 mutex.Lock()87 mutex.Lock()
88 if match != nil {
89 state.SetPendingMatch(match)
90 }
91 }
92 if mutex == nil && match != nil {
93 state.SetPendingMatch(match)
80 }94 }
81 95
82 gemBytes, errMsg, prep := state.Send(ctx, req.Model, bytes.Clone(req.Payload), opts)96 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
182 storagePath = p196 storagePath = p
183 }197 }
184 }198 }
185 state := geminiwebapi.NewGeminiWebState(cfg, ts, storagePath)199 state := geminiwebapi.NewGeminiWebState(cfg, ts, storagePath, auth.Label)
186 runtime := &geminiWebRuntime{state: state}200 runtime := &geminiWebRuntime{state: state}
187 auth.Runtime = runtime201 auth.Runtime = runtime
188 return state, nil202 return state, nil
@@ -242,3 +256,21 @@ func (e geminiWebError) StatusCode() int { · 53 unmodified lines
242 }256 }
243 return e.message.StatusCode257 return e.message.StatusCode
244}258}
259
260func 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
9 9
10 "github.com/gin-gonic/gin"10 "github.com/gin-gonic/gin"
11 "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"11 "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
12 conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
12 "github.com/router-for-me/CLIProxyAPI/v6/internal/util"13 "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
13 coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"14 coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
14 coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"15 coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
@@ -48,6 +49,8 @@ type BaseAPIHandler struct { · 33 unmodified lines
48 Cfg *config.SDKConfig49 Cfg *config.SDKConfig
49}50}
50 51
52const geminiWebProvider = "gemini-web"
53
51// NewBaseAPIHandlers creates a new API handlers instance.54// NewBaseAPIHandlers creates a new API handlers instance.
52// It takes a slice of clients and configuration as input.55// It takes a slice of clients and configuration as input.
53//56//
@@ -137,6 +140,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType · 83 unmodified lines
137 if len(providers) == 0 {140 if len(providers) == 0 {
138 return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}141 return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
139 }142 }
143 metadata := h.buildGeminiWebMetadata(handlerType, providers, rawJSON)
140 req := coreexecutor.Request{144 req := coreexecutor.Request{
141 Model: modelName,145 Model: modelName,
142 Payload: cloneBytes(rawJSON),146 Payload: cloneBytes(rawJSON),
@@ -146,6 +150,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType · 3 unmodified lines
146 Alt: alt,150 Alt: alt,
147 OriginalRequest: cloneBytes(rawJSON),151 OriginalRequest: cloneBytes(rawJSON),
148 SourceFormat: sdktranslator.FromString(handlerType),152 SourceFormat: sdktranslator.FromString(handlerType),
153 Metadata: metadata,
149 }154 }
150 resp, err := h.AuthManager.Execute(ctx, providers, req, opts)155 resp, err := h.AuthManager.Execute(ctx, providers, req, opts)
151 if err != nil {156 if err != nil {
@@ -161,6 +166,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle · 9 unmodified lines
161 if len(providers) == 0 {166 if len(providers) == 0 {
162 return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}167 return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
163 }168 }
169 metadata := h.buildGeminiWebMetadata(handlerType, providers, rawJSON)
164 req := coreexecutor.Request{170 req := coreexecutor.Request{
165 Model: modelName,171 Model: modelName,
166 Payload: cloneBytes(rawJSON),172 Payload: cloneBytes(rawJSON),
@@ -170,6 +176,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle · 3 unmodified lines
170 Alt: alt,176 Alt: alt,
171 OriginalRequest: cloneBytes(rawJSON),177 OriginalRequest: cloneBytes(rawJSON),
172 SourceFormat: sdktranslator.FromString(handlerType),178 SourceFormat: sdktranslator.FromString(handlerType),
179 Metadata: metadata,
173 }180 }
174 resp, err := h.AuthManager.ExecuteCount(ctx, providers, req, opts)181 resp, err := h.AuthManager.ExecuteCount(ctx, providers, req, opts)
175 if err != nil {182 if err != nil {
@@ -188,6 +195,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl · 12 unmodified lines
188 close(errChan)195 close(errChan)
189 return nil, errChan196 return nil, errChan
190 }197 }
198 metadata := h.buildGeminiWebMetadata(handlerType, providers, rawJSON)
191 req := coreexecutor.Request{199 req := coreexecutor.Request{
192 Model: modelName,200 Model: modelName,
193 Payload: cloneBytes(rawJSON),201 Payload: cloneBytes(rawJSON),
@@ -197,6 +205,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl · 3 unmodified lines
197 Alt: alt,205 Alt: alt,
198 OriginalRequest: cloneBytes(rawJSON),206 OriginalRequest: cloneBytes(rawJSON),
199 SourceFormat: sdktranslator.FromString(handlerType),207 SourceFormat: sdktranslator.FromString(handlerType),
208 Metadata: metadata,
200 }209 }
201 chunks, err := h.AuthManager.ExecuteStream(ctx, providers, req, opts)210 chunks, err := h.AuthManager.ExecuteStream(ctx, providers, req, opts)
202 if err != nil {211 if err != nil {
@@ -232,6 +241,18 @@ func cloneBytes(src []byte) []byte { · 29 unmodified lines
232 return dst241 return dst
233}242}
234 243
244func (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
235// WriteErrorResponse writes an error message to the response writer using the HTTP status embedded in the message.256// WriteErrorResponse writes an error message to the response writer using the HTTP status embedded in the message.
236func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.ErrorMessage) {257func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.ErrorMessage) {
237 status := http.StatusInternalServerError258 status := http.StatusInternalServerError
sdk/cliproxy/auth/selector_rr.go+125−0
@@ -0,0 +1,125 @@
1package auth
2
3import (
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
12const (
13 geminiWebProviderKey = "gemini-web"
14)
15
16type geminiWebStickySelector struct {
17 base Selector
18}
19
20func 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
30func (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
42func (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
85func 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
106func 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
33 OriginalRequest []byte33 OriginalRequest []byte
34 // SourceFormat identifies the inbound schema.34 // SourceFormat identifies the inbound schema.
35 SourceFormat sdktranslator.Format35 SourceFormat sdktranslator.Format
36 // Metadata carries extra execution hints shared across selection and executors.
37 Metadata map[string]any
36}38}
37 39
38// Response wraps either a full provider response or metadata for streaming flows.40// 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
15 "github.com/router-for-me/CLIProxyAPI/v6/internal/api"15 "github.com/router-for-me/CLIProxyAPI/v6/internal/api"
16 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"16 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
17 geminiwebclient "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web"17 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"
18 "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"19 "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
19 "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"20 "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
20 _ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"21 _ "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
206 }207 }
207 GlobalModelRegistry().UnregisterClient(id)208 GlobalModelRegistry().UnregisterClient(id)
208 if existing, ok := s.coreManager.GetByID(id); ok && existing != nil {209 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 }
209 existing.Disabled = true227 existing.Disabled = true
210 existing.Status = coreauth.StatusDisabled228 existing.Status = coreauth.StatusDisabled
211 if _, err := s.coreManager.Update(ctx, existing); err != nil {229 if _, err := s.coreManager.Update(ctx, existing); err != nil {
@@ -225,6 +243,7 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) { · 13 unmodified lines
225 s.coreManager.RegisterExecutor(executor.NewGeminiCLIExecutor(s.cfg))243 s.coreManager.RegisterExecutor(executor.NewGeminiCLIExecutor(s.cfg))
226 case "gemini-web":244 case "gemini-web":
227 s.coreManager.RegisterExecutor(executor.NewGeminiWebExecutor(s.cfg))245 s.coreManager.RegisterExecutor(executor.NewGeminiWebExecutor(s.cfg))
246 s.coreManager.EnableGeminiWebStickySelector()
228 case "claude":247 case "claude":
229 s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg))248 s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg))
230 case "codex":249 case "codex":