Unified · Split
| @@ -10,6 +10,7 @@ auths/* · 9 unmodified lines | |||
| 10 | .serena/* | 10 | .serena/* |
| 11 | AGENTS.md | 11 | AGENTS.md |
| 12 | CLAUDE.md | 12 | CLAUDE.md |
| 13 | GEMINI.md | ||
| 13 | *.exe | 14 | *.exe |
| 14 | temp/* | 15 | temp/* |
| 15 | cli-proxy-api | 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 | "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 = 1060 | 104 | ErrorIPTemporarilyBlocked = 1060 |
| 106 | ) | 105 | ) |
| 107 | 106 | ||
| 108 | var ( | 107 | func EnsureGeminiWebAliasMap() { conversation.EnsureGeminiWebAliasMap() } |
| 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 | } | ||
| 127 | 108 | ||
| 128 | func GetGeminiWebAliasedModels() []*registry.ModelInfo { | 109 | func 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 | ||
| 150 | func MapAliasToUnderlying(name string) string { | 113 | func 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 | ||
| 163 | func AliasFromModelID(modelID string) string { | 115 | func AliasFromModelID(modelID string) string { return conversation.AliasFromModelID(modelID) } |
| 164 | return modelID + "-web" | ||
| 165 | } | ||
| 166 | 116 | ||
| 167 | // Conversation domain structures ------------------------------------------- | 117 | // Conversation domain structures ------------------------------------------- |
| 168 | type RoleText struct { | 118 | type RoleText = conversation.Message |
| 169 | Role string | ||
| 170 | Text string | ||
| 171 | } | ||
| 172 | 119 | ||
| 173 | type StoredMessage struct { | 120 | type StoredMessage = conversation.StoredMessage |
| 174 | Role string `json:"role"` | ||
| 175 | Content string `json:"content"` | ||
| 176 | Name string `json:"name,omitempty"` | ||
| 177 | } | ||
| 178 | 121 | ||
| 179 | type ConversationRecord struct { | 122 | type ConversationRecord struct { |
| 180 | Model string `json:"model"` | 123 | Model string `json:"model"` |
| @@ -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 | ||
| 14 | var ( | 15 | var ( |
| 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. |
| 79 | func RemoveThinkTags(s string) string { | 79 | func 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. |
| 84 | func SanitizeAssistantMessages(msgs []RoleText) []RoleText { | 84 | func 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. |
| @@ -3,8 +3,6 @@ package geminiwebapi · 2 unmodified lines | |||
| 3 | import ( | 3 | import ( |
| 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.Config | 34 | cfg *config.Config |
| 36 | token *gemini.GeminiWebTokenStorage | 35 | token *gemini.GeminiWebTokenStorage |
| 37 | storagePath string | 36 | storagePath string |
| 37 | authLabel string | ||
| 38 | 38 | ||
| 39 | stableClientID string | 39 | stableClientID string |
| 40 | accountID string | 40 | accountID string |
| @@ -51,18 +51,28 @@ type GeminiWebState struct { · 10 unmodified lines | |||
| 51 | convIndex map[string]string | 51 | convIndex map[string]string |
| 52 | 52 | ||
| 53 | lastRefresh time.Time | 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 | 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 state | 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 | // 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 lbl | 125 | 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 | ||
| 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 | func (s *GeminiWebState) GetRequestMutex() *sync.Mutex { return &s.reqMu } | 236 | func (s *GeminiWebState) GetRequestMutex() *sync.Mutex { return &s.reqMu } |
| 130 | 237 | ||
| 131 | func (s *GeminiWebState) EnsureClient() error { | 238 | func (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 = cleaned | 329 | 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 := mimes | 339 | 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 = true | 347 | res.reuse = true |
| 238 | meta = reuseMeta | 348 | 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 = remaining | 352 | } 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 = false | 424 | 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 | return | 543 | 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] = rec | 563 | 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] = stableHash | 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 | 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] = v | 597 | dataSnapshot[k] = v |
| @@ -493,12 +655,44 @@ func (s *GeminiWebState) useReusableContext() bool { · 57 unmodified lines | |||
| 493 | return s.cfg.GeminiWeb.Context | 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 | s.convMu.RLock() | 679 | s.convMu.RLock() |
| 498 | items := s.convData | 680 | items := s.convData |
| 499 | index := s.convIndex | 681 | 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 | ||
| 504 | func (s *GeminiWebState) getConfiguredGem() *Gem { | 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 | // 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. |
| 581 | func ConvBoltPath(tokenFilePath string) string { | 739 | func 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. |
| 802 | func FindByMessageListIn(items map[string]ConversationRecord, index map[string]string, stableClientID, email, model string, msgs []RoleText) (ConversationRecord, bool) { | 960 | func 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 first | 965 | // 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. |
| 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 | if len(msgs) < 2 { | 1002 | if len(msgs) < 2 { |
| 845 | return nil, nil | 1003 | 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, nil | 1016 | return ConversationRecord{}, nil, 0, false |
| 860 | } | 1017 | } |
| @@ -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{}, err | 42 | 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, err | 80 | 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 = p | 196 | 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 = runtime | 201 | auth.Runtime = runtime |
| 188 | return state, nil | 202 | return state, nil |
| @@ -242,3 +256,21 @@ func (e geminiWebError) StatusCode() int { · 53 unmodified lines | |||
| 242 | } | 256 | } |
| 243 | return e.message.StatusCode | 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 | "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.SDKConfig | 49 | Cfg *config.SDKConfig |
| 49 | } | 50 | } |
| 50 | 51 | ||
| 52 | const 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, errChan | 196 | 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 dst | 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 | // 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. |
| 236 | func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.ErrorMessage) { | 257 | func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.ErrorMessage) { |
| 237 | status := http.StatusInternalServerError | 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 | OriginalRequest []byte | 33 | OriginalRequest []byte |
| 34 | // SourceFormat identifies the inbound schema. | 34 | // SourceFormat identifies the inbound schema. |
| 35 | SourceFormat sdktranslator.Format | 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 | // 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. |
| @@ -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 = true | 227 | existing.Disabled = true |
| 210 | existing.Status = coreauth.StatusDisabled | 228 | 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": |