Unified · Split
| @@ -10,6 +10,7 @@ auths/* · 9 unmodified lines | ||
| 10 | 10 | .serena/* |
| 11 | 11 | AGENTS.md |
| 12 | 12 | CLAUDE.md |
| 13 | +GEMINI.md | |
| 13 | 14 | *.exe |
| 14 | 15 | temp/* |
| 15 | 16 | cli-proxy-api |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -0,0 +1,6 @@ | ||
| 1 | +package conversation | |
| 2 | + | |
| 3 | +const ( | |
| 4 | + MetadataMessagesKey = "gemini_web_messages" | |
| 5 | + MetadataMatchKey = "gemini_web_match" | |
| 6 | +) | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -4,10 +4,9 @@ import ( · 3 unmodified lines | ||
| 4 | 4 | "fmt" |
| 5 | 5 | "html" |
| 6 | 6 | "net/http" |
| 7 | − "strings" | |
| 8 | − "sync" | |
| 9 | 7 | "time" |
| 10 | 8 | |
| 9 | + conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation" | |
| 11 | 10 | "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" |
| 12 | 11 | ) |
| 13 | 12 | |
| @@ -105,76 +104,20 @@ const ( · 91 unmodified lines | ||
| 105 | 104 | ErrorIPTemporarilyBlocked = 1060 |
| 106 | 105 | ) |
| 107 | 106 | |
| 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() } | |
| 127 | 108 | |
| 128 | 109 | 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() | |
| 148 | 111 | } |
| 149 | 112 | |
| 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) } | |
| 162 | 114 | |
| 163 | −func AliasFromModelID(modelID string) string { | |
| 164 | − return modelID + "-web" | |
| 165 | −} | |
| 115 | +func AliasFromModelID(modelID string) string { return conversation.AliasFromModelID(modelID) } | |
| 166 | 116 | |
| 167 | 117 | // Conversation domain structures ------------------------------------------- |
| 168 | −type RoleText struct { | |
| 169 | − Role string | |
| 170 | − Text string | |
| 171 | −} | |
| 118 | +type RoleText = conversation.Message | |
| 172 | 119 | |
| 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 | |
| 178 | 121 | |
| 179 | 122 | type ConversationRecord struct { |
| 180 | 123 | Model string `json:"model"` |
| @@ -8,11 +8,11 @@ import ( · 7 unmodified lines | ||
| 8 | 8 | "unicode/utf8" |
| 9 | 9 | |
| 10 | 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 | 12 | "github.com/tidwall/gjson" |
| 12 | 13 | ) |
| 13 | 14 | |
| 14 | 15 | var ( |
| 15 | − reThink = regexp.MustCompile(`(?s)^\s*<think>.*?</think>\s*`) | |
| 16 | 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 | 78 | // RemoveThinkTags strips <think>...</think> blocks from a string. |
| 79 | 79 | func RemoveThinkTags(s string) string { |
| 80 | − return strings.TrimSpace(reThink.ReplaceAllString(s, "")) | |
| 80 | + return conversation.RemoveThinkTags(s) | |
| 81 | 81 | } |
| 82 | 82 | |
| 83 | 83 | // SanitizeAssistantMessages removes think tags from assistant messages. |
| 84 | 84 | 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 | |
| 94 | 87 | } |
| 95 | 88 | |
| 96 | 89 | // AppendXMLWrapHintIfNeeded appends an XML wrap hint to messages containing XML-like blocks. |
| @@ -3,8 +3,6 @@ package geminiwebapi · 2 unmodified lines | ||
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | 5 | "context" |
| 6 | − "crypto/sha256" | |
| 7 | − "encoding/hex" | |
| 8 | 6 | "encoding/json" |
| 9 | 7 | "errors" |
| 10 | 8 | "fmt" |
| @@ -19,6 +17,7 @@ import ( · 8 unmodified lines | ||
| 19 | 17 | "github.com/router-for-me/CLIProxyAPI/v6/internal/config" |
| 20 | 18 | "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" |
| 21 | 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 | 21 | "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" |
| 23 | 22 | cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" |
| 24 | 23 | log "github.com/sirupsen/logrus" |
| @@ -35,6 +34,7 @@ type GeminiWebState struct { · 10 unmodified lines | ||
| 35 | 34 | cfg *config.Config |
| 36 | 35 | token *gemini.GeminiWebTokenStorage |
| 37 | 36 | storagePath string |
| 37 | + authLabel string | |
| 38 | 38 | |
| 39 | 39 | stableClientID string |
| 40 | 40 | accountID string |
| @@ -51,18 +51,28 @@ type GeminiWebState struct { · 10 unmodified lines | ||
| 51 | 51 | convIndex map[string]string |
| 52 | 52 | |
| 53 | 53 | 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 | |
| 54 | 63 | } |
| 55 | 64 | |
| 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 { | |
| 57 | 66 | state := &GeminiWebState{ |
| 58 | 67 | cfg: cfg, |
| 59 | 68 | token: token, |
| 60 | 69 | storagePath: storagePath, |
| 70 | + authLabel: strings.TrimSpace(authLabel), | |
| 61 | 71 | convStore: make(map[string][]string), |
| 62 | 72 | convData: make(map[string]ConversationRecord), |
| 63 | 73 | convIndex: make(map[string]string), |
| 64 | 74 | } |
| 65 | − suffix := Sha256Hex(token.Secure1PSID) | |
| 75 | + suffix := conversation.Sha256Hex(token.Secure1PSID) | |
| 66 | 76 | if len(suffix) > 16 { |
| 67 | 77 | suffix = suffix[:16] |
| 68 | 78 | } |
| @@ -81,6 +91,28 @@ func NewGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage, · 12 unmodified lines | ||
| 81 | 91 | return state |
| 82 | 92 | } |
| 83 | 93 | |
| 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 | + | |
| 84 | 116 | // Label returns a stable account label for logging and persistence. |
| 85 | 117 | // If a storage file path is known, it uses the file base name (without extension). |
| 86 | 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 | 125 | return lbl |
| 94 | 126 | } |
| 95 | 127 | } |
| 128 | + if lbl := strings.TrimSpace(s.authLabel); lbl != "" { | |
| 129 | + return lbl | |
| 130 | + } | |
| 96 | 131 | if s.storagePath != "" { |
| 97 | 132 | base := strings.TrimSuffix(filepath.Base(s.storagePath), filepath.Ext(s.storagePath)) |
| 98 | 133 | if base != "" { |
| @@ -126,6 +161,78 @@ func (s *GeminiWebState) convPath() string { · 27 unmodified lines | ||
| 126 | 161 | return ConvBoltPath(base) |
| 127 | 162 | } |
| 128 | 163 | |
| 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 | + | |
| 129 | 236 | func (s *GeminiWebState) GetRequestMutex() *sync.Mutex { return &s.reqMu } |
| 130 | 237 | |
| 131 | 238 | func (s *GeminiWebState) EnsureClient() error { |
| @@ -219,7 +326,7 @@ func (s *GeminiWebState) prepare(ctx context.Context, modelName string, rawJSON · 87 unmodified lines | ||
| 219 | 326 | return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: fmt.Errorf("bad request: %w", err)} |
| 220 | 327 | } |
| 221 | 328 | cleaned := SanitizeAssistantMessages(messages) |
| 222 | − res.cleaned = cleaned | |
| 329 | + fullCleaned := cloneRoleTextSlice(cleaned) | |
| 223 | 330 | res.underlying = MapAliasToUnderlying(modelName) |
| 224 | 331 | model, err := ModelFromName(res.underlying) |
| 225 | 332 | if err != nil { |
| @@ -232,15 +339,27 @@ func (s *GeminiWebState) prepare(ctx context.Context, modelName string, rawJSON · 6 unmodified lines | ||
| 232 | 339 | mimesSubset := mimes |
| 233 | 340 | |
| 234 | 341 | 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 { | |
| 237 | 347 | 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 { | |
| 244 | 363 | useMsgs = []RoleText{cleaned[len(cleaned)-1]} |
| 245 | 364 | } |
| 246 | 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 | 417 | s.convMu.RUnlock() |
| 299 | 418 | } |
| 300 | 419 | |
| 420 | + res.cleaned = fullCleaned | |
| 421 | + | |
| 301 | 422 | res.tagged = NeedRoleTags(useMsgs) |
| 302 | 423 | if res.reuse && len(useMsgs) == 1 { |
| 303 | 424 | res.tagged = false |
| @@ -421,8 +542,22 @@ func (s *GeminiWebState) persistConversation(modelName string, prep *geminiWebPr · 117 unmodified lines | ||
| 421 | 542 | if !ok { |
| 422 | 543 | return |
| 423 | 544 | } |
| 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 | + } | |
| 426 | 561 | |
| 427 | 562 | s.convMu.Lock() |
| 428 | 563 | s.convData[stableHash] = rec |
| @@ -430,6 +565,33 @@ func (s *GeminiWebState) persistConversation(modelName string, prep *geminiWebPr · 1 unmodified line | ||
| 430 | 565 | if accountHash != stableHash { |
| 431 | 566 | 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 | 595 | dataSnapshot := make(map[string]ConversationRecord, len(s.convData)) |
| 434 | 596 | for k, v := range s.convData { |
| 435 | 597 | dataSnapshot[k] = v |
| @@ -493,12 +655,44 @@ func (s *GeminiWebState) useReusableContext() bool { · 57 unmodified lines | ||
| 493 | 655 | return s.cfg.GeminiWeb.Context |
| 494 | 656 | } |
| 495 | 657 | |
| 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 { | |
| 497 | 679 | s.convMu.RLock() |
| 498 | 680 | items := s.convData |
| 499 | 681 | index := s.convIndex |
| 500 | 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 | |
| 504 | 698 | func (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. | |
| 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 | − | |
| 579 | 737 | // ConvBoltPath returns the BoltDB file path used for both account metadata and conversation data. |
| 580 | 738 | // Different logical datasets are kept in separate buckets within this single DB file. |
| 581 | 739 | func ConvBoltPath(tokenFilePath string) string { |
| @@ -790,7 +948,7 @@ func BuildConversationRecord(model, clientID string, history []RoleText, output · 208 unmodified lines | ||
| 790 | 948 | Model: model, |
| 791 | 949 | ClientID: clientID, |
| 792 | 950 | Metadata: metadata, |
| 793 | − Messages: ToStoredMessages(final), | |
| 951 | + Messages: conversation.ToStoredMessages(final), | |
| 794 | 952 | CreatedAt: time.Now(), |
| 795 | 953 | UpdatedAt: time.Now(), |
| 796 | 954 | } |
| @@ -800,9 +958,9 @@ func BuildConversationRecord(model, clientID string, history []RoleText, output · 3 unmodified lines | ||
| 800 | 958 | // FindByMessageListIn looks up a conversation record by hashed message list. |
| 801 | 959 | // It attempts both the stable client ID and a legacy email-based ID. |
| 802 | 960 | 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) | |
| 806 | 964 | |
| 807 | 965 | // Try stable hash via index indirection first |
| 808 | 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 | 1000 | // 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) { | |
| 844 | 1002 | if len(msgs) < 2 { |
| 845 | − return nil, nil | |
| 1003 | + return ConversationRecord{}, nil, 0, false | |
| 846 | 1004 | } |
| 847 | 1005 | searchEnd := len(msgs) |
| 848 | 1006 | for searchEnd >= 2 { |
| @@ -850,11 +1008,10 @@ func FindReusableSessionIn(items map[string]ConversationRecord, index map[string · 1 unmodified line | ||
| 850 | 1008 | tail := sub[len(sub)-1] |
| 851 | 1009 | if strings.EqualFold(tail.Role, "assistant") || strings.EqualFold(tail.Role, "system") { |
| 852 | 1010 | 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 | |
| 855 | 1012 | } |
| 856 | 1013 | } |
| 857 | 1014 | searchEnd-- |
| 858 | 1015 | } |
| 859 | − return nil, nil | |
| 1016 | + return ConversationRecord{}, nil, 0, false | |
| 860 | 1017 | } |
| @@ -13,6 +13,7 @@ import ( · 12 unmodified lines | ||
| 13 | 13 | "github.com/router-for-me/CLIProxyAPI/v6/internal/config" |
| 14 | 14 | "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" |
| 15 | 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 | 17 | cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" |
| 17 | 18 | cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" |
| 18 | 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 | 41 | if err = state.EnsureClient(); err != nil { |
| 41 | 42 | return cliproxyexecutor.Response{}, err |
| 42 | 43 | } |
| 44 | + match := extractGeminiWebMatch(opts.Metadata) | |
| 43 | 45 | reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) |
| 44 | 46 | |
| 45 | 47 | mutex := state.GetRequestMutex() |
| 46 | 48 | if mutex != nil { |
| 47 | 49 | mutex.Lock() |
| 48 | 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 | 58 | payload := bytes.Clone(req.Payload) |
| @@ -72,11 +79,18 @@ func (e *GeminiWebExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 20 unmodified lines | ||
| 72 | 79 | if err = state.EnsureClient(); err != nil { |
| 73 | 80 | return nil, err |
| 74 | 81 | } |
| 82 | + match := extractGeminiWebMatch(opts.Metadata) | |
| 75 | 83 | reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) |
| 76 | 84 | |
| 77 | 85 | mutex := state.GetRequestMutex() |
| 78 | 86 | if mutex != nil { |
| 79 | 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 | 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 | 196 | storagePath = p |
| 183 | 197 | } |
| 184 | 198 | } |
| 185 | − state := geminiwebapi.NewGeminiWebState(cfg, ts, storagePath) | |
| 199 | + state := geminiwebapi.NewGeminiWebState(cfg, ts, storagePath, auth.Label) | |
| 186 | 200 | runtime := &geminiWebRuntime{state: state} |
| 187 | 201 | auth.Runtime = runtime |
| 188 | 202 | return state, nil |
| @@ -242,3 +256,21 @@ func (e geminiWebError) StatusCode() int { · 53 unmodified lines | ||
| 242 | 256 | } |
| 243 | 257 | return e.message.StatusCode |
| 244 | 258 | } |
| 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 | +} | |
| @@ -9,6 +9,7 @@ import ( · 8 unmodified lines | ||
| 9 | 9 | |
| 10 | 10 | "github.com/gin-gonic/gin" |
| 11 | 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 | 13 | "github.com/router-for-me/CLIProxyAPI/v6/internal/util" |
| 13 | 14 | coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" |
| 14 | 15 | coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" |
| @@ -48,6 +49,8 @@ type BaseAPIHandler struct { · 33 unmodified lines | ||
| 48 | 49 | Cfg *config.SDKConfig |
| 49 | 50 | } |
| 50 | 51 | |
| 52 | +const geminiWebProvider = "gemini-web" | |
| 53 | + | |
| 51 | 54 | // NewBaseAPIHandlers creates a new API handlers instance. |
| 52 | 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 | 140 | if len(providers) == 0 { |
| 138 | 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 | 144 | req := coreexecutor.Request{ |
| 141 | 145 | Model: modelName, |
| 142 | 146 | Payload: cloneBytes(rawJSON), |
| @@ -146,6 +150,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType · 3 unmodified lines | ||
| 146 | 150 | Alt: alt, |
| 147 | 151 | OriginalRequest: cloneBytes(rawJSON), |
| 148 | 152 | SourceFormat: sdktranslator.FromString(handlerType), |
| 153 | + Metadata: metadata, | |
| 149 | 154 | } |
| 150 | 155 | resp, err := h.AuthManager.Execute(ctx, providers, req, opts) |
| 151 | 156 | if err != nil { |
| @@ -161,6 +166,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle · 9 unmodified lines | ||
| 161 | 166 | if len(providers) == 0 { |
| 162 | 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 | 170 | req := coreexecutor.Request{ |
| 165 | 171 | Model: modelName, |
| 166 | 172 | Payload: cloneBytes(rawJSON), |
| @@ -170,6 +176,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle · 3 unmodified lines | ||
| 170 | 176 | Alt: alt, |
| 171 | 177 | OriginalRequest: cloneBytes(rawJSON), |
| 172 | 178 | SourceFormat: sdktranslator.FromString(handlerType), |
| 179 | + Metadata: metadata, | |
| 173 | 180 | } |
| 174 | 181 | resp, err := h.AuthManager.ExecuteCount(ctx, providers, req, opts) |
| 175 | 182 | if err != nil { |
| @@ -188,6 +195,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl · 12 unmodified lines | ||
| 188 | 195 | close(errChan) |
| 189 | 196 | return nil, errChan |
| 190 | 197 | } |
| 198 | + metadata := h.buildGeminiWebMetadata(handlerType, providers, rawJSON) | |
| 191 | 199 | req := coreexecutor.Request{ |
| 192 | 200 | Model: modelName, |
| 193 | 201 | Payload: cloneBytes(rawJSON), |
| @@ -197,6 +205,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl · 3 unmodified lines | ||
| 197 | 205 | Alt: alt, |
| 198 | 206 | OriginalRequest: cloneBytes(rawJSON), |
| 199 | 207 | SourceFormat: sdktranslator.FromString(handlerType), |
| 208 | + Metadata: metadata, | |
| 200 | 209 | } |
| 201 | 210 | chunks, err := h.AuthManager.ExecuteStream(ctx, providers, req, opts) |
| 202 | 211 | if err != nil { |
| @@ -232,6 +241,18 @@ func cloneBytes(src []byte) []byte { · 29 unmodified lines | ||
| 232 | 241 | return dst |
| 233 | 242 | } |
| 234 | 243 | |
| 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 | + | |
| 235 | 256 | // WriteErrorResponse writes an error message to the response writer using the HTTP status embedded in the message. |
| 236 | 257 | func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.ErrorMessage) { |
| 237 | 258 | status := http.StatusInternalServerError |
| @@ -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 | +} | |
| @@ -33,6 +33,8 @@ type Options struct { · 32 unmodified lines | ||
| 33 | 33 | OriginalRequest []byte |
| 34 | 34 | // SourceFormat identifies the inbound schema. |
| 35 | 35 | SourceFormat sdktranslator.Format |
| 36 | + // Metadata carries extra execution hints shared across selection and executors. | |
| 37 | + Metadata map[string]any | |
| 36 | 38 | } |
| 37 | 39 | |
| 38 | 40 | // Response wraps either a full provider response or metadata for streaming flows. |
| @@ -15,6 +15,7 @@ import ( · 14 unmodified lines | ||
| 15 | 15 | "github.com/router-for-me/CLIProxyAPI/v6/internal/api" |
| 16 | 16 | "github.com/router-for-me/CLIProxyAPI/v6/internal/config" |
| 17 | 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 | 19 | "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" |
| 19 | 20 | "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor" |
| 20 | 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 | 208 | GlobalModelRegistry().UnregisterClient(id) |
| 208 | 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 | 227 | existing.Disabled = true |
| 210 | 228 | existing.Status = coreauth.StatusDisabled |
| 211 | 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 | 243 | s.coreManager.RegisterExecutor(executor.NewGeminiCLIExecutor(s.cfg)) |
| 226 | 244 | case "gemini-web": |
| 227 | 245 | s.coreManager.RegisterExecutor(executor.NewGeminiWebExecutor(s.cfg)) |
| 246 | + s.coreManager.EnableGeminiWebStickySelector() | |
| 228 | 247 | case "claude": |
| 229 | 248 | s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg)) |
| 230 | 249 | case "codex": |