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