diff --git a/internal/auth/gemini/gemini-web_token.go b/internal/auth/gemini/gemini-web_token.go deleted file mode 100644 index 1fc20e4e..00000000 --- a/internal/auth/gemini/gemini-web_token.go +++ /dev/null @@ -1,64 +0,0 @@ -// Package gemini provides authentication and token management functionality -// for Google's Gemini AI services. It handles OAuth2 token storage, serialization, -// and retrieval for maintaining authenticated sessions with the Gemini API. -package gemini - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - log "github.com/sirupsen/logrus" -) - -// GeminiWebTokenStorage stores cookie information for Google Gemini Web authentication. -type GeminiWebTokenStorage struct { - Secure1PSID string `json:"secure_1psid"` - Secure1PSIDTS string `json:"secure_1psidts"` - Type string `json:"type"` - LastRefresh string `json:"last_refresh,omitempty"` - // Label is a stable account identifier used for logging, e.g. "gemini-web-". - // It is derived from the auth file name when not explicitly set. - Label string `json:"label,omitempty"` -} - -// SaveTokenToFile serializes the Gemini Web token storage to a JSON file. -func (ts *GeminiWebTokenStorage) SaveTokenToFile(authFilePath string) error { - misc.LogSavingCredentials(authFilePath) - ts.Type = "gemini-web" - // Auto-derive a stable label from the file name if missing. - if ts.Label == "" { - base := filepath.Base(authFilePath) - if strings.HasSuffix(strings.ToLower(base), ".json") { - base = strings.TrimSuffix(base, filepath.Ext(base)) - } - if base != "" { - ts.Label = base - } - } - if ts.LastRefresh == "" { - ts.LastRefresh = time.Now().Format(time.RFC3339) - } - if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil { - return fmt.Errorf("failed to create directory: %v", err) - } - - f, err := os.Create(authFilePath) - if err != nil { - return fmt.Errorf("failed to create token file: %w", err) - } - defer func() { - if errClose := f.Close(); errClose != nil { - log.Errorf("failed to close file: %v", errClose) - } - }() - - if err = json.NewEncoder(f).Encode(ts); err != nil { - return fmt.Errorf("failed to write token to file: %w", err) - } - return nil -} diff --git a/internal/cmd/gemini-web_auth.go b/internal/cmd/gemini-web_auth.go deleted file mode 100644 index d00d2ab6..00000000 --- a/internal/cmd/gemini-web_auth.go +++ /dev/null @@ -1,197 +0,0 @@ -// Package cmd provides command-line interface functionality for the CLI Proxy API. -package cmd - -import ( - "bufio" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "net/http" - "os" - "runtime" - "strings" - "time" - - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" -) - -// banner prints a simple ASCII banner for clarity without ANSI colors. -func banner(title string) { - line := strings.Repeat("=", len(title)+8) - fmt.Println(line) - fmt.Println("=== " + title + " ===") - fmt.Println(line) -} - -// DoGeminiWebAuth handles the process of creating a Gemini Web token file. -// New flow: -// 1. Prompt user to paste the full cookie string. -// 2. Extract __Secure-1PSID and __Secure-1PSIDTS from the cookie string. -// 3. Call https://accounts.google.com/ListAccounts with the cookie to obtain email. -// 4. Save auth file with the same structure, and set Label to the email. -func DoGeminiWebAuth(cfg *config.Config) { - var secure1psid, secure1psidts, email string - - reader := bufio.NewReader(os.Stdin) - isMacOS := strings.HasPrefix(runtime.GOOS, "darwin") - cookieProvided := false - banner("Gemini Web Cookie Sign-in") - if !isMacOS { - // NOTE: Provide extra guidance for macOS users or anyone unsure about retrieving cookies. - fmt.Println("--- Cookie Input ---") - fmt.Println(">> Paste your full Google Cookie and press Enter") - fmt.Println("Tip: If you are on macOS, or don't know how to get the cookie, just press Enter and follow the prompts.") - fmt.Print("Cookie: ") - rawCookie, _ := reader.ReadString('\n') - rawCookie = strings.TrimSpace(rawCookie) - if rawCookie == "" { - // Skip cookie-based parsing; fall back to manual field prompts. - fmt.Println("==> No cookie provided. Proceeding with manual input.") - } else { - cookieProvided = true - // Parse K=V cookie pairs separated by ';' - cookieMap := make(map[string]string) - parts := strings.Split(rawCookie, ";") - for _, p := range parts { - p = strings.TrimSpace(p) - if p == "" { - continue - } - if eq := strings.Index(p, "="); eq > 0 { - k := strings.TrimSpace(p[:eq]) - v := strings.TrimSpace(p[eq+1:]) - if k != "" { - cookieMap[k] = v - } - } - } - secure1psid = strings.TrimSpace(cookieMap["__Secure-1PSID"]) - secure1psidts = strings.TrimSpace(cookieMap["__Secure-1PSIDTS"]) - - // Build HTTP client with proxy settings respected. - httpClient := &http.Client{Timeout: 15 * time.Second} - httpClient = util.SetProxy(&cfg.SDKConfig, httpClient) - - // Request ListAccounts to extract email as label (use POST per upstream behavior). - req, err := http.NewRequest(http.MethodPost, "https://accounts.google.com/ListAccounts", nil) - if err != nil { - fmt.Println("!! Failed to create request:", err) - } else { - req.Header.Set("Cookie", rawCookie) - req.Header.Set("Accept", "application/json, text/plain, */*") - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36") - req.Header.Set("Origin", "https://accounts.google.com") - req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8") - - resp, errDo := httpClient.Do(req) - if errDo != nil { - fmt.Println("!! Request to ListAccounts failed:", err) - } else { - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - fmt.Printf("!! ListAccounts returned status code: %d\n", resp.StatusCode) - } else { - var payload []any - if err = json.NewDecoder(resp.Body).Decode(&payload); err != nil { - fmt.Println("!! Failed to parse ListAccounts response:", err) - } else { - // Expected structure like: ["gaia.l.a.r", [["gaia.l.a",1,"Name","email@example.com", ... ]]] - if len(payload) >= 2 { - if accounts, ok := payload[1].([]any); ok && len(accounts) >= 1 { - if first, ok1 := accounts[0].([]any); ok1 && len(first) >= 4 { - if em, ok2 := first[3].(string); ok2 { - email = strings.TrimSpace(em) - } - } - } - } - if email == "" { - fmt.Println("!! Failed to parse email from ListAccounts response") - } - } - } - } - } - } - } - - // Fallback: prompt user to input missing values - if secure1psid == "" { - if cookieProvided && !isMacOS { - fmt.Println("!! Cookie missing __Secure-1PSID.") - } - fmt.Print("Enter __Secure-1PSID: ") - v, _ := reader.ReadString('\n') - secure1psid = strings.TrimSpace(v) - } - if secure1psidts == "" { - if cookieProvided && !isMacOS { - fmt.Println("!! Cookie missing __Secure-1PSIDTS.") - } - fmt.Print("Enter __Secure-1PSIDTS: ") - v, _ := reader.ReadString('\n') - secure1psidts = strings.TrimSpace(v) - } - if secure1psid == "" || secure1psidts == "" { - // Use print instead of logger to avoid log redirection. - fmt.Println("!! __Secure-1PSID and __Secure-1PSIDTS cannot be empty") - return - } - if isMacOS { - fmt.Print("Enter your account email: ") - v, _ := reader.ReadString('\n') - email = strings.TrimSpace(v) - } - - // Generate a filename based on the SHA256 hash of the PSID - hasher := sha256.New() - hasher.Write([]byte(secure1psid)) - hash := hex.EncodeToString(hasher.Sum(nil)) - fileName := fmt.Sprintf("gemini-web-%s.json", hash[:16]) - - // Decide label: prefer email; fallback prompt then file name without .json - defaultLabel := strings.TrimSuffix(fileName, ".json") - label := email - if label == "" { - fmt.Print(fmt.Sprintf("Enter label for this auth (default: %s): ", defaultLabel)) - v, _ := reader.ReadString('\n') - v = strings.TrimSpace(v) - if v != "" { - label = v - } else { - label = defaultLabel - } - } - - tokenStorage := &gemini.GeminiWebTokenStorage{ - Secure1PSID: secure1psid, - Secure1PSIDTS: secure1psidts, - Label: label, - } - record := &coreauth.Auth{ - ID: fileName, - Provider: "gemini-web", - FileName: fileName, - Storage: tokenStorage, - } - store := sdkAuth.GetTokenStore() - if cfg != nil { - if dirSetter, ok := store.(interface{ SetBaseDir(string) }); ok { - dirSetter.SetBaseDir(cfg.AuthDir) - } - } - savedPath, err := store.Save(context.Background(), record) - if err != nil { - fmt.Println("!! Failed to save Gemini Web token to file:", err) - return - } - - fmt.Println("==> Successfully saved Gemini Web token!") - fmt.Println("==> Saved to:", savedPath) -} diff --git a/internal/provider/gemini-web/client.go b/internal/provider/gemini-web/client.go deleted file mode 100644 index 0b29dbae..00000000 --- a/internal/provider/gemini-web/client.go +++ /dev/null @@ -1,884 +0,0 @@ -package geminiwebapi - -import ( - "crypto/tls" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/cookiejar" - "net/url" - "regexp" - "strings" - "time" - - log "github.com/sirupsen/logrus" -) - -// GeminiClient is the async http client interface (Go port) -type GeminiClient struct { - Cookies map[string]string - Proxy string - Running bool - httpClient *http.Client - AccessToken string - Timeout time.Duration - insecure bool -} - -// HTTP bootstrap utilities ------------------------------------------------- -type httpOptions struct { - ProxyURL string - Insecure bool - FollowRedirects bool -} - -func newHTTPClient(opts httpOptions) *http.Client { - transport := &http.Transport{} - if opts.ProxyURL != "" { - if pu, err := url.Parse(opts.ProxyURL); err == nil { - transport.Proxy = http.ProxyURL(pu) - } - } - if opts.Insecure { - transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - } - jar, _ := cookiejar.New(nil) - client := &http.Client{Transport: transport, Timeout: 60 * time.Second, Jar: jar} - if !opts.FollowRedirects { - client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - } - } - return client -} - -func applyHeaders(req *http.Request, headers http.Header) { - for k, v := range headers { - for _, vv := range v { - req.Header.Add(k, vv) - } - } -} - -func applyCookies(req *http.Request, cookies map[string]string) { - for k, v := range cookies { - req.AddCookie(&http.Cookie{Name: k, Value: v}) - } -} - -func sendInitRequest(cookies map[string]string, proxy string, insecure bool) (*http.Response, map[string]string, error) { - client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true}) - req, _ := http.NewRequest(http.MethodGet, EndpointInit, nil) - applyHeaders(req, HeadersGemini) - applyCookies(req, cookies) - resp, err := client.Do(req) - if err != nil { - return nil, nil, err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return resp, nil, &AuthError{Msg: resp.Status} - } - outCookies := map[string]string{} - for _, c := range resp.Cookies() { - outCookies[c.Name] = c.Value - } - for k, v := range cookies { - outCookies[k] = v - } - return resp, outCookies, nil -} - -func getAccessToken(baseCookies map[string]string, proxy string, verbose bool, insecure bool) (string, map[string]string, error) { - extraCookies := map[string]string{} - { - client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true}) - req, _ := http.NewRequest(http.MethodGet, EndpointGoogle, nil) - resp, err := client.Do(req) - if err != nil { - if verbose { - log.Debugf("priming google cookies failed: %v", err) - } - } else if resp != nil { - if u, err := url.Parse(EndpointGoogle); err == nil { - for _, c := range client.Jar.Cookies(u) { - extraCookies[c.Name] = c.Value - } - } - _ = resp.Body.Close() - } - } - - trySets := make([]map[string]string, 0, 8) - - if v1, ok1 := baseCookies["__Secure-1PSID"]; ok1 { - if v2, ok2 := baseCookies["__Secure-1PSIDTS"]; ok2 { - merged := map[string]string{"__Secure-1PSID": v1, "__Secure-1PSIDTS": v2} - if nid, ok := baseCookies["NID"]; ok { - merged["NID"] = nid - } - trySets = append(trySets, merged) - } else if verbose { - log.Debug("Skipping base cookies: __Secure-1PSIDTS missing") - } - } - - if len(extraCookies) > 0 { - trySets = append(trySets, extraCookies) - } - - reToken := regexp.MustCompile(`"SNlM0e":"([^"]+)"`) - - for _, cookies := range trySets { - resp, mergedCookies, err := sendInitRequest(cookies, proxy, insecure) - if err != nil { - if verbose { - log.Warnf("Failed init request: %v", err) - } - continue - } - body, err := io.ReadAll(resp.Body) - _ = resp.Body.Close() - if err != nil { - return "", nil, err - } - matches := reToken.FindStringSubmatch(string(body)) - if len(matches) >= 2 { - token := matches[1] - if verbose { - fmt.Println("Gemini access token acquired.") - } - return token, mergedCookies, nil - } - } - return "", nil, &AuthError{Msg: "Failed to retrieve token."} -} - -func rotate1PSIDTS(cookies map[string]string, proxy string, insecure bool) (string, error) { - _, ok := cookies["__Secure-1PSID"] - if !ok { - return "", &AuthError{Msg: "__Secure-1PSID missing"} - } - - // Reuse shared HTTP client helper for consistency. - client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true}) - - req, _ := http.NewRequest(http.MethodPost, EndpointRotateCookies, strings.NewReader("[000,\"-0000000000000000000\"]")) - applyHeaders(req, HeadersRotateCookies) - applyCookies(req, cookies) - - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer func() { - _ = resp.Body.Close() - }() - - if resp.StatusCode == http.StatusUnauthorized { - return "", &AuthError{Msg: "unauthorized"} - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", errors.New(resp.Status) - } - - for _, c := range resp.Cookies() { - if c.Name == "__Secure-1PSIDTS" { - return c.Value, nil - } - } - // Fallback: check cookie jar in case the Set-Cookie was on a redirect hop - if u, err := url.Parse(EndpointRotateCookies); err == nil && client.Jar != nil { - for _, c := range client.Jar.Cookies(u) { - if c.Name == "__Secure-1PSIDTS" && c.Value != "" { - return c.Value, nil - } - } - } - return "", nil -} - -// MaskToken28 masks a sensitive token for safe logging. Keep middle partially visible. -func MaskToken28(s string) string { - n := len(s) - if n == 0 { - return "" - } - if n < 20 { - return strings.Repeat("*", n) - } - midStart := n/2 - 2 - if midStart < 8 { - midStart = 8 - } - if midStart+4 > n-8 { - midStart = n - 8 - 4 - if midStart < 8 { - midStart = 8 - } - } - prefixByte := s[:8] - middle := s[midStart : midStart+4] - suffix := s[n-8:] - return prefixByte + strings.Repeat("*", 4) + middle + strings.Repeat("*", 4) + suffix -} - -var NanoBananaModel = map[string]struct{}{ - "gemini-2.5-flash-image-web": {}, -} - -// NewGeminiClient creates a client. Pass empty strings to auto-detect via browser cookies (not implemented in Go port). -func NewGeminiClient(secure1psid string, secure1psidts string, proxy string, opts ...func(*GeminiClient)) *GeminiClient { - c := &GeminiClient{ - Cookies: map[string]string{}, - Proxy: proxy, - Running: false, - Timeout: 300 * time.Second, - insecure: false, - } - if secure1psid != "" { - c.Cookies["__Secure-1PSID"] = secure1psid - if secure1psidts != "" { - c.Cookies["__Secure-1PSIDTS"] = secure1psidts - } - } - for _, f := range opts { - f(c) - } - return c -} - -// WithInsecureTLS sets skipping TLS verification (to mirror httpx verify=False) -func WithInsecureTLS(insecure bool) func(*GeminiClient) { - return func(c *GeminiClient) { c.insecure = insecure } -} - -// Init initializes the access token and http client. -func (c *GeminiClient) Init(timeoutSec float64, verbose bool) error { - // get access token - token, validCookies, err := getAccessToken(c.Cookies, c.Proxy, verbose, c.insecure) - if err != nil { - c.Close(0) - return err - } - c.AccessToken = token - c.Cookies = validCookies - - tr := &http.Transport{} - if c.Proxy != "" { - if pu, errParse := url.Parse(c.Proxy); errParse == nil { - tr.Proxy = http.ProxyURL(pu) - } - } - if c.insecure { - // set via roundtripper in utils_get_access_token for token; here we reuse via default Transport - // intentionally not adding here, as requests rely on endpoints with normal TLS - } - c.httpClient = &http.Client{Transport: tr, Timeout: time.Duration(timeoutSec * float64(time.Second))} - c.Running = true - - c.Timeout = time.Duration(timeoutSec * float64(time.Second)) - if verbose { - fmt.Println("Gemini client initialized successfully.") - } - return nil -} - -func (c *GeminiClient) Close(delaySec float64) { - if delaySec > 0 { - time.Sleep(time.Duration(delaySec * float64(time.Second))) - } - c.Running = false -} - -// ensureRunning mirrors the decorator behavior and retries on APIError. -func (c *GeminiClient) ensureRunning() error { - if c.Running { - return nil - } - return c.Init(float64(c.Timeout/time.Second), false) -} - -// RotateTS performs a RotateCookies request and returns the new __Secure-1PSIDTS value (if any). -func (c *GeminiClient) RotateTS() (string, error) { - if c == nil { - return "", fmt.Errorf("gemini web client is nil") - } - return rotate1PSIDTS(c.Cookies, c.Proxy, c.insecure) -} - -// GenerateContent sends a prompt (with optional files) and parses the response into ModelOutput. -func (c *GeminiClient) GenerateContent(prompt string, files []string, model Model, gem *Gem, chat *ChatSession) (ModelOutput, error) { - var empty ModelOutput - if prompt == "" { - return empty, &ValueError{Msg: "Prompt cannot be empty."} - } - if err := c.ensureRunning(); err != nil { - return empty, err - } - - // Retry wrapper similar to decorator (retry=2) - retries := 2 - for { - out, err := c.generateOnce(prompt, files, model, gem, chat) - if err == nil { - return out, nil - } - var apiErr *APIError - var imgErr *ImageGenerationError - shouldRetry := false - if errors.As(err, &imgErr) { - if retries > 1 { - retries = 1 - } // only once for image generation - shouldRetry = true - } else if errors.As(err, &apiErr) { - shouldRetry = true - } - if shouldRetry && retries > 0 { - time.Sleep(time.Second) - retries-- - continue - } - return empty, err - } -} - -func ensureAnyLen(slice []any, index int) []any { - if index < len(slice) { - return slice - } - gap := index + 1 - len(slice) - return append(slice, make([]any, gap)...) -} - -func (c *GeminiClient) generateOnce(prompt string, files []string, model Model, gem *Gem, chat *ChatSession) (ModelOutput, error) { - var empty ModelOutput - // Build f.req - var uploaded [][]any - for _, fp := range files { - id, err := uploadFile(fp, c.Proxy, c.insecure) - if err != nil { - return empty, err - } - name, err := parseFileName(fp) - if err != nil { - return empty, err - } - uploaded = append(uploaded, []any{[]any{id}, name}) - } - var item0 any - if len(uploaded) > 0 { - item0 = []any{prompt, 0, nil, uploaded} - } else { - item0 = []any{prompt} - } - var item2 any = nil - if chat != nil { - item2 = chat.Metadata() - } - - inner := []any{item0, nil, item2} - // Attach Gem first to keep index alignment with reference implementation - // so the Gemini Web UI can recognize the selected Gem. - if gem != nil { - // pad with 16 nils then gem ID - for i := 0; i < 16; i++ { - inner = append(inner, nil) - } - inner = append(inner, gem.ID) - } - requestedModel := strings.ToLower(model.Name) - if chat != nil && chat.RequestedModel() != "" { - requestedModel = chat.RequestedModel() - } - if _, ok := NanoBananaModel[requestedModel]; ok { - inner = ensureAnyLen(inner, 49) - inner[49] = 14 - } - innerJSON, _ := json.Marshal(inner) - outer := []any{nil, string(innerJSON)} - outerJSON, _ := json.Marshal(outer) - - // form - form := url.Values{} - form.Set("at", c.AccessToken) - form.Set("f.req", string(outerJSON)) - - req, _ := http.NewRequest(http.MethodPost, EndpointGenerate, strings.NewReader(form.Encode())) - applyHeaders(req, HeadersGemini) - applyHeaders(req, model.ModelHeader) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=utf-8") - applyCookies(req, c.Cookies) - - resp, err := c.httpClient.Do(req) - if err != nil { - return empty, &TimeoutError{GeminiError{Msg: "Generate content request timed out."}} - } - defer func() { - _ = resp.Body.Close() - }() - - if resp.StatusCode == 429 { - // Surface 429 as TemporarilyBlocked to match reference behavior - c.Close(0) - return empty, &TemporarilyBlocked{GeminiError{Msg: "Too many requests. IP temporarily blocked."}} - } - if resp.StatusCode != 200 { - c.Close(0) - return empty, &APIError{Msg: fmt.Sprintf("Failed to generate contents. Status %d", resp.StatusCode)} - } - - // Read body and split lines; take the 3rd line (index 2) - b, _ := io.ReadAll(resp.Body) - parts := strings.Split(string(b), "\n") - if len(parts) < 3 { - c.Close(0) - return empty, &APIError{Msg: "Invalid response data received."} - } - var responseJSON []any - if err = json.Unmarshal([]byte(parts[2]), &responseJSON); err != nil { - c.Close(0) - return empty, &APIError{Msg: "Invalid response data received."} - } - - // find body where main_part[4] exists - var ( - body any - bodyIndex int - ) - for i, p := range responseJSON { - arr, ok := p.([]any) - if !ok || len(arr) < 3 { - continue - } - s, ok := arr[2].(string) - if !ok { - continue - } - var mainPart []any - if err = json.Unmarshal([]byte(s), &mainPart); err != nil { - continue - } - if len(mainPart) > 4 && mainPart[4] != nil { - body = mainPart - bodyIndex = i - break - } - } - if body == nil { - // Fallback: scan subsequent lines to locate a data frame with a non-empty body (mainPart[4]). - var lastTop []any - for li := 3; li < len(parts) && body == nil; li++ { - line := strings.TrimSpace(parts[li]) - if line == "" { - continue - } - var top []any - if err = json.Unmarshal([]byte(line), &top); err != nil { - continue - } - lastTop = top - for i, p := range top { - arr, ok := p.([]any) - if !ok || len(arr) < 3 { - continue - } - s, ok := arr[2].(string) - if !ok { - continue - } - var mainPart []any - if err = json.Unmarshal([]byte(s), &mainPart); err != nil { - continue - } - if len(mainPart) > 4 && mainPart[4] != nil { - body = mainPart - bodyIndex = i - responseJSON = top - break - } - } - } - // Parse nested error code to align with error mapping - var top []any - // Prefer lastTop from fallback scan; otherwise try parts[2] - if len(lastTop) > 0 { - top = lastTop - } else { - _ = json.Unmarshal([]byte(parts[2]), &top) - } - if len(top) > 0 { - if code, ok := extractErrorCode(top); ok { - switch code { - case ErrorUsageLimitExceeded: - return empty, &UsageLimitExceeded{GeminiError{Msg: fmt.Sprintf("Failed to generate contents. Usage limit of %s has exceeded. Please try switching to another model.", model.Name)}} - case ErrorModelInconsistent: - return empty, &ModelInvalid{GeminiError{Msg: "Selected model is inconsistent or unavailable."}} - case ErrorModelHeaderInvalid: - return empty, &APIError{Msg: "Invalid model header string. Please update the selected model header."} - case ErrorIPTemporarilyBlocked: - return empty, &TemporarilyBlocked{GeminiError{Msg: "Too many requests. IP temporarily blocked."}} - } - } - } - // Debug("Invalid response: control frames only; no body found") - // Close the client to force re-initialization on next request (parity with reference client behavior) - c.Close(0) - return empty, &APIError{Msg: "Failed to generate contents. Invalid response data received."} - } - - bodyArr := body.([]any) - // metadata - var metadata []string - if len(bodyArr) > 1 { - if metaArr, ok := bodyArr[1].([]any); ok { - for _, v := range metaArr { - if s, isOk := v.(string); isOk { - metadata = append(metadata, s) - } - } - } - } - - // candidates parsing - candContainer, ok := bodyArr[4].([]any) - if !ok { - return empty, &APIError{Msg: "Failed to parse response body."} - } - candidates := make([]Candidate, 0, len(candContainer)) - reCard := regexp.MustCompile(`^http://googleusercontent\.com/card_content/\d+`) - reGen := regexp.MustCompile(`http://googleusercontent\.com/image_generation_content/\d+`) - - for ci, candAny := range candContainer { - cArr, isOk := candAny.([]any) - if !isOk { - continue - } - // text: cArr[1][0] - var text string - if len(cArr) > 1 { - if sArr, isOk1 := cArr[1].([]any); isOk1 && len(sArr) > 0 { - text, _ = sArr[0].(string) - } - } - if reCard.MatchString(text) { - // candidate[22] and candidate[22][0] or text - if len(cArr) > 22 { - if arr, isOk1 := cArr[22].([]any); isOk1 && len(arr) > 0 { - if s, isOk2 := arr[0].(string); isOk2 { - text = s - } - } - } - } - - // thoughts: candidate[37][0][0] - var thoughts *string - if len(cArr) > 37 { - if a, ok1 := cArr[37].([]any); ok1 && len(a) > 0 { - if b1, ok2 := a[0].([]any); ok2 && len(b1) > 0 { - if s, ok3 := b1[0].(string); ok3 { - ss := decodeHTML(s) - thoughts = &ss - } - } - } - } - - // web images: candidate[12][1] - var webImages []WebImage - var imgSection any - if len(cArr) > 12 { - imgSection = cArr[12] - } - if arr, ok1 := imgSection.([]any); ok1 && len(arr) > 1 { - if imagesArr, ok2 := arr[1].([]any); ok2 { - for _, wiAny := range imagesArr { - wiArr, ok3 := wiAny.([]any) - if !ok3 { - continue - } - // url: wiArr[0][0][0], title: wiArr[7][0], alt: wiArr[0][4] - var urlStr, title, alt string - if len(wiArr) > 0 { - if a, ok5 := wiArr[0].([]any); ok5 && len(a) > 0 { - if b1, ok6 := a[0].([]any); ok6 && len(b1) > 0 { - urlStr, _ = b1[0].(string) - } - if len(a) > 4 { - if s, ok6 := a[4].(string); ok6 { - alt = s - } - } - } - } - if len(wiArr) > 7 { - if a, ok4 := wiArr[7].([]any); ok4 && len(a) > 0 { - title, _ = a[0].(string) - } - } - webImages = append(webImages, WebImage{Image: Image{URL: urlStr, Title: title, Alt: alt, Proxy: c.Proxy}}) - } - } - } - - // generated images - var genImages []GeneratedImage - hasGen := false - if arr, ok1 := imgSection.([]any); ok1 && len(arr) > 7 { - if a, ok2 := arr[7].([]any); ok2 && len(a) > 0 && a[0] != nil { - hasGen = true - } - } - if hasGen { - // find img part - var imgBody []any - for pi := bodyIndex; pi < len(responseJSON); pi++ { - part := responseJSON[pi] - arr, ok1 := part.([]any) - if !ok1 || len(arr) < 3 { - continue - } - s, ok1 := arr[2].(string) - if !ok1 { - continue - } - var mp []any - if err = json.Unmarshal([]byte(s), &mp); err != nil { - continue - } - if len(mp) > 4 { - if tt, ok2 := mp[4].([]any); ok2 && len(tt) > ci { - if sec, ok3 := tt[ci].([]any); ok3 && len(sec) > 12 { - if ss, ok4 := sec[12].([]any); ok4 && len(ss) > 7 { - if first, ok5 := ss[7].([]any); ok5 && len(first) > 0 && first[0] != nil { - imgBody = mp - break - } - } - } - } - } - } - if imgBody == nil { - return empty, &ImageGenerationError{APIError{Msg: "Failed to parse generated images."}} - } - imgCand := imgBody[4].([]any)[ci].([]any) - if len(imgCand) > 1 { - if a, ok1 := imgCand[1].([]any); ok1 && len(a) > 0 { - if s, ok2 := a[0].(string); ok2 { - text = strings.TrimSpace(reGen.ReplaceAllString(s, "")) - } - } - } - // images list at imgCand[12][7][0] - if len(imgCand) > 12 { - if s1, ok1 := imgCand[12].([]any); ok1 && len(s1) > 7 { - if s2, ok2 := s1[7].([]any); ok2 && len(s2) > 0 { - if s3, ok3 := s2[0].([]any); ok3 { - for ii, giAny := range s3 { - ga, ok4 := giAny.([]any) - if !ok4 || len(ga) < 4 { - continue - } - // url: ga[0][3][3] - var urlStr, title, alt string - if a, ok5 := ga[0].([]any); ok5 && len(a) > 3 { - if b1, ok6 := a[3].([]any); ok6 && len(b1) > 3 { - urlStr, _ = b1[3].(string) - } - } - // title from ga[3][6] - if len(ga) > 3 { - if a, ok5 := ga[3].([]any); ok5 { - if len(a) > 6 { - if v, ok6 := a[6].(float64); ok6 && v != 0 { - title = fmt.Sprintf("[Generated Image %.0f]", v) - } else { - title = "[Generated Image]" - } - } else { - title = "[Generated Image]" - } - // alt from ga[3][5][ii] fallback - if len(a) > 5 { - if tt, ok6 := a[5].([]any); ok6 { - if ii < len(tt) { - if s, ok7 := tt[ii].(string); ok7 { - alt = s - } - } else if len(tt) > 0 { - if s, ok7 := tt[0].(string); ok7 { - alt = s - } - } - } - } - } - } - genImages = append(genImages, GeneratedImage{Image: Image{URL: urlStr, Title: title, Alt: alt, Proxy: c.Proxy}, Cookies: c.Cookies}) - } - } - } - } - } - } - - cand := Candidate{ - RCID: fmt.Sprintf("%v", cArr[0]), - Text: decodeHTML(text), - Thoughts: thoughts, - WebImages: webImages, - GeneratedImages: genImages, - } - candidates = append(candidates, cand) - } - - if len(candidates) == 0 { - return empty, &GeminiError{Msg: "Failed to generate contents. No output data found in response."} - } - output := ModelOutput{Metadata: metadata, Candidates: candidates, Chosen: 0} - if chat != nil { - chat.lastOutput = &output - } - return output, nil -} - -// extractErrorCode attempts to navigate the known nested error structure and fetch the integer code. -// Mirrors reference path: response_json[0][5][2][0][1][0] -func extractErrorCode(top []any) (int, bool) { - if len(top) == 0 { - return 0, false - } - a, ok := top[0].([]any) - if !ok || len(a) <= 5 { - return 0, false - } - b, ok := a[5].([]any) - if !ok || len(b) <= 2 { - return 0, false - } - c, ok := b[2].([]any) - if !ok || len(c) == 0 { - return 0, false - } - d, ok := c[0].([]any) - if !ok || len(d) <= 1 { - return 0, false - } - e, ok := d[1].([]any) - if !ok || len(e) == 0 { - return 0, false - } - f, ok := e[0].(float64) - if !ok { - return 0, false - } - return int(f), true -} - -// StartChat returns a ChatSession attached to the client -func (c *GeminiClient) StartChat(model Model, gem *Gem, metadata []string) *ChatSession { - return &ChatSession{client: c, metadata: normalizeMeta(metadata), model: model, gem: gem, requestedModel: strings.ToLower(model.Name)} -} - -// ChatSession holds conversation metadata -type ChatSession struct { - client *GeminiClient - metadata []string // cid, rid, rcid - lastOutput *ModelOutput - model Model - gem *Gem - requestedModel string -} - -func (cs *ChatSession) String() string { - var cid, rid, rcid string - if len(cs.metadata) > 0 { - cid = cs.metadata[0] - } - if len(cs.metadata) > 1 { - rid = cs.metadata[1] - } - if len(cs.metadata) > 2 { - rcid = cs.metadata[2] - } - return fmt.Sprintf("ChatSession(cid='%s', rid='%s', rcid='%s')", cid, rid, rcid) -} - -func normalizeMeta(v []string) []string { - out := []string{"", "", ""} - for i := 0; i < len(v) && i < 3; i++ { - out[i] = v[i] - } - return out -} - -func (cs *ChatSession) Metadata() []string { return cs.metadata } -func (cs *ChatSession) SetMetadata(v []string) { cs.metadata = normalizeMeta(v) } -func (cs *ChatSession) RequestedModel() string { return cs.requestedModel } -func (cs *ChatSession) SetRequestedModel(name string) { - cs.requestedModel = strings.ToLower(name) -} -func (cs *ChatSession) CID() string { - if len(cs.metadata) > 0 { - return cs.metadata[0] - } - return "" -} -func (cs *ChatSession) RID() string { - if len(cs.metadata) > 1 { - return cs.metadata[1] - } - return "" -} -func (cs *ChatSession) RCID() string { - if len(cs.metadata) > 2 { - return cs.metadata[2] - } - return "" -} -func (cs *ChatSession) setCID(v string) { - if len(cs.metadata) < 1 { - cs.metadata = normalizeMeta(cs.metadata) - } - cs.metadata[0] = v -} -func (cs *ChatSession) setRID(v string) { - if len(cs.metadata) < 2 { - cs.metadata = normalizeMeta(cs.metadata) - } - cs.metadata[1] = v -} -func (cs *ChatSession) setRCID(v string) { - if len(cs.metadata) < 3 { - cs.metadata = normalizeMeta(cs.metadata) - } - cs.metadata[2] = v -} - -// SendMessage shortcut to client's GenerateContent -func (cs *ChatSession) SendMessage(prompt string, files []string) (ModelOutput, error) { - out, err := cs.client.GenerateContent(prompt, files, cs.model, cs.gem, cs) - if err == nil { - cs.lastOutput = &out - cs.SetMetadata(out.Metadata) - cs.setRCID(out.RCID()) - } - return out, err -} - -// ChooseCandidate selects a candidate from last output and updates rcid -func (cs *ChatSession) ChooseCandidate(index int) (ModelOutput, error) { - if cs.lastOutput == nil { - return ModelOutput{}, &ValueError{Msg: "No previous output data found in this chat session."} - } - if index >= len(cs.lastOutput.Candidates) { - return ModelOutput{}, &ValueError{Msg: fmt.Sprintf("Index %d exceeds candidates", index)} - } - cs.lastOutput.Chosen = index - cs.setRCID(cs.lastOutput.RCID()) - return *cs.lastOutput, nil -} diff --git a/internal/provider/gemini-web/conversation/alias.go b/internal/provider/gemini-web/conversation/alias.go deleted file mode 100644 index 8cb31c86..00000000 --- a/internal/provider/gemini-web/conversation/alias.go +++ /dev/null @@ -1,80 +0,0 @@ -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-web"] = "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-web" - cpy.Name = "gemini-2.5-flash-image-web" - 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 deleted file mode 100644 index a163a3b2..00000000 --- a/internal/provider/gemini-web/conversation/hash.go +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index ab06bbf5..00000000 --- a/internal/provider/gemini-web/conversation/index.go +++ /dev/null @@ -1,280 +0,0 @@ -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 deleted file mode 100644 index d9b3fb8a..00000000 --- a/internal/provider/gemini-web/conversation/lookup.go +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index ba20f5b3..00000000 --- a/internal/provider/gemini-web/conversation/metadata.go +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index a27e952f..00000000 --- a/internal/provider/gemini-web/conversation/parse.go +++ /dev/null @@ -1,110 +0,0 @@ -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 deleted file mode 100644 index 82359702..00000000 --- a/internal/provider/gemini-web/conversation/sanitize.go +++ /dev/null @@ -1,39 +0,0 @@ -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/media.go b/internal/provider/gemini-web/media.go deleted file mode 100644 index c5dbba87..00000000 --- a/internal/provider/gemini-web/media.go +++ /dev/null @@ -1,542 +0,0 @@ -package geminiwebapi - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "math" - "mime/multipart" - "net/http" - "os" - "path/filepath" - "regexp" - "sort" - "strings" - "time" - "unicode/utf8" - - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - log "github.com/sirupsen/logrus" - "github.com/tidwall/gjson" -) - -// Image helpers ------------------------------------------------------------ - -type Image struct { - URL string - Title string - Alt string - Proxy string -} - -func (i Image) String() string { - short := i.URL - if len(short) > 20 { - short = short[:8] + "..." + short[len(short)-12:] - } - return fmt.Sprintf("Image(title='%s', alt='%s', url='%s')", i.Title, i.Alt, short) -} - -func (i Image) Save(path string, filename string, cookies map[string]string, verbose bool, skipInvalidFilename bool, insecure bool) (string, error) { - if filename == "" { - // Try to parse filename from URL. - u := i.URL - if p := strings.Split(u, "/"); len(p) > 0 { - filename = p[len(p)-1] - } - if q := strings.Split(filename, "?"); len(q) > 0 { - filename = q[0] - } - } - // Regex validation (pattern: ^(.*\.\w+)) to extract name with extension. - if filename != "" { - re := regexp.MustCompile(`^(.*\.\w+)`) - if m := re.FindStringSubmatch(filename); len(m) >= 2 { - filename = m[1] - } else { - if verbose { - log.Warnf("Invalid filename: %s", filename) - } - if skipInvalidFilename { - return "", nil - } - } - } - // Build client using shared helper to keep proxy/TLS behavior consistent. - client := newHTTPClient(httpOptions{ProxyURL: i.Proxy, Insecure: insecure, FollowRedirects: true}) - client.Timeout = 120 * time.Second - - // Helper to set raw Cookie header using provided cookies (parity with the reference client behavior). - buildCookieHeader := func(m map[string]string) string { - if len(m) == 0 { - return "" - } - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - parts := make([]string, 0, len(keys)) - for _, k := range keys { - parts = append(parts, fmt.Sprintf("%s=%s", k, m[k])) - } - return strings.Join(parts, "; ") - } - rawCookie := buildCookieHeader(cookies) - - client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - // Ensure provided cookies are always sent across redirects (domain-agnostic). - if rawCookie != "" { - req.Header.Set("Cookie", rawCookie) - } - if len(via) >= 10 { - return errors.New("stopped after 10 redirects") - } - return nil - } - - req, _ := http.NewRequest(http.MethodGet, i.URL, nil) - if rawCookie != "" { - req.Header.Set("Cookie", rawCookie) - } - // Add browser-like headers to improve compatibility. - req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8") - req.Header.Set("Connection", "keep-alive") - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer func() { - _ = resp.Body.Close() - }() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("error downloading image: %d %s", resp.StatusCode, resp.Status) - } - if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(strings.ToLower(ct), "image") { - log.Warnf("Content type of %s is not image, but %s.", filename, ct) - } - if path == "" { - path = "temp" - } - if err = os.MkdirAll(path, 0o755); err != nil { - return "", err - } - dest := filepath.Join(path, filename) - f, err := os.Create(dest) - if err != nil { - return "", err - } - _, err = io.Copy(f, resp.Body) - _ = f.Close() - if err != nil { - return "", err - } - if verbose { - fmt.Printf("Image saved as %s\n", dest) - } - abspath, _ := filepath.Abs(dest) - return abspath, nil -} - -type WebImage struct{ Image } - -type GeneratedImage struct { - Image - Cookies map[string]string -} - -func (g GeneratedImage) Save(path string, filename string, fullSize bool, verbose bool, skipInvalidFilename bool, insecure bool) (string, error) { - if len(g.Cookies) == 0 { - return "", &ValueError{Msg: "GeneratedImage requires cookies."} - } - strURL := g.URL - if fullSize { - strURL = strURL + "=s2048" - } - if filename == "" { - name := time.Now().Format("20060102150405") - if len(strURL) >= 10 { - name = fmt.Sprintf("%s_%s.png", name, strURL[len(strURL)-10:]) - } else { - name += ".png" - } - filename = name - } - tmp := g.Image - tmp.URL = strURL - return tmp.Save(path, filename, g.Cookies, verbose, skipInvalidFilename, insecure) -} - -// Request parsing & file helpers ------------------------------------------- - -func ParseMessagesAndFiles(rawJSON []byte) ([]RoleText, [][]byte, []string, [][]int, error) { - var messages []RoleText - var files [][]byte - var mimes []string - var perMsgFileIdx [][]int - - contents := gjson.GetBytes(rawJSON, "contents") - if contents.Exists() { - contents.ForEach(func(_, content gjson.Result) bool { - role := NormalizeRole(content.Get("role").String()) - var b strings.Builder - startFile := len(files) - content.Get("parts").ForEach(func(_, part gjson.Result) bool { - if text := part.Get("text"); text.Exists() { - if b.Len() > 0 { - b.WriteString("\n") - } - b.WriteString(text.String()) - } - if inlineData := part.Get("inlineData"); inlineData.Exists() { - data := inlineData.Get("data").String() - if data != "" { - if dec, err := base64.StdEncoding.DecodeString(data); err == nil { - files = append(files, dec) - m := inlineData.Get("mimeType").String() - if m == "" { - m = inlineData.Get("mime_type").String() - } - mimes = append(mimes, m) - } - } - } - return true - }) - messages = append(messages, RoleText{Role: role, Text: b.String()}) - endFile := len(files) - if endFile > startFile { - idxs := make([]int, 0, endFile-startFile) - for i := startFile; i < endFile; i++ { - idxs = append(idxs, i) - } - perMsgFileIdx = append(perMsgFileIdx, idxs) - } else { - perMsgFileIdx = append(perMsgFileIdx, nil) - } - return true - }) - } - return messages, files, mimes, perMsgFileIdx, nil -} - -func MaterializeInlineFiles(files [][]byte, mimes []string) ([]string, *interfaces.ErrorMessage) { - if len(files) == 0 { - return nil, nil - } - paths := make([]string, 0, len(files)) - for i, data := range files { - ext := MimeToExt(mimes, i) - f, err := os.CreateTemp("", "gemini-upload-*"+ext) - if err != nil { - return nil, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: fmt.Errorf("failed to create temp file: %w", err)} - } - if _, err = f.Write(data); err != nil { - _ = f.Close() - _ = os.Remove(f.Name()) - return nil, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: fmt.Errorf("failed to write temp file: %w", err)} - } - if err = f.Close(); err != nil { - _ = os.Remove(f.Name()) - return nil, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: fmt.Errorf("failed to close temp file: %w", err)} - } - paths = append(paths, f.Name()) - } - return paths, nil -} - -func CleanupFiles(paths []string) { - for _, p := range paths { - if p != "" { - _ = os.Remove(p) - } - } -} - -func FetchGeneratedImageData(gi GeneratedImage) (string, string, error) { - path, err := gi.Save("", "", true, false, true, false) - if err != nil { - return "", "", err - } - defer func() { _ = os.Remove(path) }() - b, err := os.ReadFile(path) - if err != nil { - return "", "", err - } - mime := http.DetectContentType(b) - if !strings.HasPrefix(mime, "image/") { - if guessed := mimeFromExtension(filepath.Ext(path)); guessed != "" { - mime = guessed - } else { - mime = "image/png" - } - } - return mime, base64.StdEncoding.EncodeToString(b), nil -} - -func MimeToExt(mimes []string, i int) string { - if i < len(mimes) { - return MimeToPreferredExt(strings.ToLower(mimes[i])) - } - return ".png" -} - -var preferredExtByMIME = map[string]string{ - "image/png": ".png", - "image/jpeg": ".jpg", - "image/jpg": ".jpg", - "image/webp": ".webp", - "image/gif": ".gif", - "image/bmp": ".bmp", - "image/heic": ".heic", - "application/pdf": ".pdf", -} - -func MimeToPreferredExt(mime string) string { - normalized := strings.ToLower(strings.TrimSpace(mime)) - if normalized == "" { - return ".png" - } - if ext, ok := preferredExtByMIME[normalized]; ok { - return ext - } - return ".png" -} - -func mimeFromExtension(ext string) string { - cleaned := strings.TrimPrefix(strings.ToLower(ext), ".") - if cleaned == "" { - return "" - } - if mt, ok := misc.MimeTypes[cleaned]; ok && mt != "" { - return mt - } - return "" -} - -// File upload helpers ------------------------------------------------------ - -func uploadFile(path string, proxy string, insecure bool) (string, error) { - f, err := os.Open(path) - if err != nil { - return "", err - } - defer func() { - _ = f.Close() - }() - - var buf bytes.Buffer - mw := multipart.NewWriter(&buf) - fw, err := mw.CreateFormFile("file", filepath.Base(path)) - if err != nil { - return "", err - } - if _, err = io.Copy(fw, f); err != nil { - return "", err - } - _ = mw.Close() - - client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true}) - client.Timeout = 300 * time.Second - - req, _ := http.NewRequest(http.MethodPost, EndpointUpload, &buf) - applyHeaders(req, HeadersUpload) - req.Header.Set("Content-Type", mw.FormDataContentType()) - req.Header.Set("Accept", "*/*") - req.Header.Set("Connection", "keep-alive") - - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer func() { - _ = resp.Body.Close() - }() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", &APIError{Msg: resp.Status} - } - b, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - return string(b), nil -} - -func parseFileName(path string) (string, error) { - if st, err := os.Stat(path); err != nil || st.IsDir() { - return "", &ValueError{Msg: path + " is not a valid file."} - } - return filepath.Base(path), nil -} - -// Response formatting helpers ---------------------------------------------- - -var ( - reGoogle = regexp.MustCompile("(\\()?\\[`([^`]+?)`\\]\\(https://www\\.google\\.com/search\\?q=[^)]*\\)(\\))?") - reColonNum = regexp.MustCompile(`([^:]+:\d+)`) - reInline = regexp.MustCompile("`(\\[[^\\]]+\\]\\([^\\)]+\\))`") -) - -func unescapeGeminiText(s string) string { - if s == "" { - return s - } - s = strings.ReplaceAll(s, "<", "<") - s = strings.ReplaceAll(s, "\\<", "<") - s = strings.ReplaceAll(s, "\\_", "_") - s = strings.ReplaceAll(s, "\\>", ">") - return s -} - -func postProcessModelText(text string) string { - text = reGoogle.ReplaceAllStringFunc(text, func(m string) string { - subs := reGoogle.FindStringSubmatch(m) - if len(subs) < 4 { - return m - } - outerOpen := subs[1] - display := subs[2] - target := display - if loc := reColonNum.FindString(display); loc != "" { - target = loc - } - newSeg := "[`" + display + "`](" + target + ")" - if outerOpen != "" { - return "(" + newSeg + ")" - } - return newSeg - }) - text = reInline.ReplaceAllString(text, "$1") - return text -} - -func estimateTokens(s string) int { - if s == "" { - return 0 - } - rc := float64(utf8.RuneCountInString(s)) - if rc <= 0 { - return 0 - } - est := int(math.Ceil(rc / 4.0)) - if est < 0 { - return 0 - } - return est -} - -// ConvertOutputToGemini converts simplified ModelOutput to Gemini API-like JSON. -// promptText is used only to estimate usage tokens to populate usage fields. -func ConvertOutputToGemini(output *ModelOutput, modelName string, promptText string) ([]byte, error) { - if output == nil || len(output.Candidates) == 0 { - return nil, fmt.Errorf("empty output") - } - - parts := make([]map[string]any, 0, 2) - - var thoughtsText string - if output.Candidates[0].Thoughts != nil { - if t := strings.TrimSpace(*output.Candidates[0].Thoughts); t != "" { - thoughtsText = unescapeGeminiText(t) - parts = append(parts, map[string]any{ - "text": thoughtsText, - "thought": true, - }) - } - } - - visible := unescapeGeminiText(output.Candidates[0].Text) - finalText := postProcessModelText(visible) - if finalText != "" { - parts = append(parts, map[string]any{"text": finalText}) - } - - if imgs := output.Candidates[0].GeneratedImages; len(imgs) > 0 { - for _, gi := range imgs { - if mime, data, err := FetchGeneratedImageData(gi); err == nil && data != "" { - parts = append(parts, map[string]any{ - "inlineData": map[string]any{ - "mimeType": mime, - "data": data, - }, - }) - } - } - } - - promptTokens := estimateTokens(promptText) - completionTokens := estimateTokens(finalText) - thoughtsTokens := 0 - if thoughtsText != "" { - thoughtsTokens = estimateTokens(thoughtsText) - } - totalTokens := promptTokens + completionTokens - - now := time.Now() - resp := map[string]any{ - "candidates": []any{ - map[string]any{ - "content": map[string]any{ - "parts": parts, - "role": "model", - }, - "finishReason": "stop", - "index": 0, - }, - }, - "createTime": now.Format(time.RFC3339Nano), - "responseId": fmt.Sprintf("gemini-web-%d", now.UnixNano()), - "modelVersion": modelName, - "usageMetadata": map[string]any{ - "promptTokenCount": promptTokens, - "candidatesTokenCount": completionTokens, - "thoughtsTokenCount": thoughtsTokens, - "totalTokenCount": totalTokens, - }, - } - b, err := json.Marshal(resp) - if err != nil { - return nil, fmt.Errorf("failed to marshal gemini response: %w", err) - } - return ensureColonSpacing(b), nil -} - -// ensureColonSpacing inserts a single space after JSON key-value colons while -// leaving string content untouched. This matches the relaxed formatting used by -// Gemini responses and keeps downstream text-processing tools compatible with -// the proxy output. -func ensureColonSpacing(b []byte) []byte { - if len(b) == 0 { - return b - } - var out bytes.Buffer - out.Grow(len(b) + len(b)/8) - inString := false - escaped := false - for i := 0; i < len(b); i++ { - ch := b[i] - out.WriteByte(ch) - if escaped { - escaped = false - continue - } - switch ch { - case '\\': - escaped = true - case '"': - inString = !inString - case ':': - if !inString && i+1 < len(b) { - next := b[i+1] - if next != ' ' && next != '\n' && next != '\r' && next != '\t' { - out.WriteByte(' ') - } - } - } - } - return out.Bytes() -} diff --git a/internal/provider/gemini-web/models.go b/internal/provider/gemini-web/models.go deleted file mode 100644 index b1e50dc3..00000000 --- a/internal/provider/gemini-web/models.go +++ /dev/null @@ -1,253 +0,0 @@ -package geminiwebapi - -import ( - "fmt" - "html" - "net/http" - "time" - - conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" -) - -// Gemini web endpoints and default headers ---------------------------------- -const ( - EndpointGoogle = "https://www.google.com" - EndpointInit = "https://gemini.google.com/app" - EndpointGenerate = "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate" - EndpointRotateCookies = "https://accounts.google.com/RotateCookies" - EndpointUpload = "https://content-push.googleapis.com/upload" -) - -var ( - HeadersGemini = http.Header{ - "Content-Type": []string{"application/x-www-form-urlencoded;charset=utf-8"}, - "Host": []string{"gemini.google.com"}, - "Origin": []string{"https://gemini.google.com"}, - "Referer": []string{"https://gemini.google.com/"}, - "User-Agent": []string{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}, - "X-Same-Domain": []string{"1"}, - } - HeadersRotateCookies = http.Header{ - "Content-Type": []string{"application/json"}, - } - HeadersUpload = http.Header{ - "Push-ID": []string{"feeds/mcudyrk2a4khkz"}, - } -) - -// Model metadata ------------------------------------------------------------- -type Model struct { - Name string - ModelHeader http.Header - AdvancedOnly bool -} - -var ( - ModelUnspecified = Model{ - Name: "unspecified", - ModelHeader: http.Header{}, - AdvancedOnly: false, - } - ModelG25Flash = Model{ - Name: "gemini-2.5-flash", - ModelHeader: http.Header{ - "x-goog-ext-525001261-jspb": []string{"[1,null,null,null,\"71c2d248d3b102ff\",null,null,0,[4]]"}, - }, - AdvancedOnly: false, - } - ModelG25Pro = Model{ - Name: "gemini-2.5-pro", - ModelHeader: http.Header{ - "x-goog-ext-525001261-jspb": []string{"[1,null,null,null,\"4af6c7f5da75d65d\",null,null,0,[4]]"}, - }, - AdvancedOnly: false, - } - ModelG20Flash = Model{ - Name: "gemini-2.0-flash", - ModelHeader: http.Header{ - "x-goog-ext-525001261-jspb": []string{"[1,null,null,null,\"f299729663a2343f\"]"}, - }, - AdvancedOnly: false, - } - ModelG20FlashThinking = Model{ - Name: "gemini-2.0-flash-thinking", - ModelHeader: http.Header{ - "x-goog-ext-525001261-jspb": []string{"[null,null,null,null,\"7ca48d02d802f20a\"]"}, - }, - AdvancedOnly: false, - } -) - -func ModelFromName(name string) (Model, error) { - switch name { - case ModelUnspecified.Name: - return ModelUnspecified, nil - case ModelG25Flash.Name: - return ModelG25Flash, nil - case ModelG25Pro.Name: - return ModelG25Pro, nil - case ModelG20Flash.Name: - return ModelG20Flash, nil - case ModelG20FlashThinking.Name: - return ModelG20FlashThinking, nil - default: - return Model{}, &ValueError{Msg: "Unknown model name: " + name} - } -} - -// Known error codes returned from the server. -const ( - ErrorUsageLimitExceeded = 1037 - ErrorModelInconsistent = 1050 - ErrorModelHeaderInvalid = 1052 - ErrorIPTemporarilyBlocked = 1060 -) - -func EnsureGeminiWebAliasMap() { conversation.EnsureGeminiWebAliasMap() } - -func GetGeminiWebAliasedModels() []*registry.ModelInfo { - return conversation.GetGeminiWebAliasedModels() -} - -func MapAliasToUnderlying(name string) string { return conversation.MapAliasToUnderlying(name) } - -func AliasFromModelID(modelID string) string { return conversation.AliasFromModelID(modelID) } - -// Conversation domain structures ------------------------------------------- -type RoleText = conversation.Message - -type StoredMessage = conversation.StoredMessage - -type ConversationRecord struct { - Model string `json:"model"` - ClientID string `json:"client_id"` - Metadata []string `json:"metadata,omitempty"` - Messages []StoredMessage `json:"messages"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type Candidate struct { - RCID string - Text string - Thoughts *string - WebImages []WebImage - GeneratedImages []GeneratedImage -} - -func (c Candidate) String() string { - t := c.Text - if len(t) > 20 { - t = t[:20] + "..." - } - return fmt.Sprintf("Candidate(rcid='%s', text='%s', images=%d)", c.RCID, t, len(c.WebImages)+len(c.GeneratedImages)) -} - -func (c Candidate) Images() []Image { - images := make([]Image, 0, len(c.WebImages)+len(c.GeneratedImages)) - for _, wi := range c.WebImages { - images = append(images, wi.Image) - } - for _, gi := range c.GeneratedImages { - images = append(images, gi.Image) - } - return images -} - -type ModelOutput struct { - Metadata []string - Candidates []Candidate - Chosen int -} - -func (m ModelOutput) String() string { return m.Text() } - -func (m ModelOutput) Text() string { - if len(m.Candidates) == 0 { - return "" - } - return m.Candidates[m.Chosen].Text -} - -func (m ModelOutput) Thoughts() *string { - if len(m.Candidates) == 0 { - return nil - } - return m.Candidates[m.Chosen].Thoughts -} - -func (m ModelOutput) Images() []Image { - if len(m.Candidates) == 0 { - return nil - } - return m.Candidates[m.Chosen].Images() -} - -func (m ModelOutput) RCID() string { - if len(m.Candidates) == 0 { - return "" - } - return m.Candidates[m.Chosen].RCID -} - -type Gem struct { - ID string - Name string - Description *string - Prompt *string - Predefined bool -} - -func (g Gem) String() string { - return fmt.Sprintf("Gem(id='%s', name='%s', description='%v', prompt='%v', predefined=%v)", g.ID, g.Name, g.Description, g.Prompt, g.Predefined) -} - -func decodeHTML(s string) string { return html.UnescapeString(s) } - -// Error hierarchy ----------------------------------------------------------- -type AuthError struct{ Msg string } - -func (e *AuthError) Error() string { - if e.Msg == "" { - return "authentication error" - } - return e.Msg -} - -type APIError struct{ Msg string } - -func (e *APIError) Error() string { - if e.Msg == "" { - return "api error" - } - return e.Msg -} - -type ImageGenerationError struct{ APIError } - -type GeminiError struct{ Msg string } - -func (e *GeminiError) Error() string { - if e.Msg == "" { - return "gemini error" - } - return e.Msg -} - -type TimeoutError struct{ GeminiError } - -type UsageLimitExceeded struct{ GeminiError } - -type ModelInvalid struct{ GeminiError } - -type TemporarilyBlocked struct{ GeminiError } - -type ValueError struct{ Msg string } - -func (e *ValueError) Error() string { - if e.Msg == "" { - return "value error" - } - return e.Msg -} diff --git a/internal/provider/gemini-web/prompt.go b/internal/provider/gemini-web/prompt.go deleted file mode 100644 index e3051243..00000000 --- a/internal/provider/gemini-web/prompt.go +++ /dev/null @@ -1,220 +0,0 @@ -package geminiwebapi - -import ( - "fmt" - "math" - "regexp" - "strings" - "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 ( - reXMLAnyTag = regexp.MustCompile(`(?s)<\s*[^>]+>`) -) - -// NormalizeRole converts a role to a standard format (lowercase, 'model' -> 'assistant'). -func NormalizeRole(role string) string { - r := strings.ToLower(role) - if r == "model" { - return "assistant" - } - return r -} - -// NeedRoleTags checks if a list of messages requires role tags. -func NeedRoleTags(msgs []RoleText) bool { - for _, m := range msgs { - if strings.ToLower(m.Role) != "user" { - return true - } - } - return false -} - -// AddRoleTag wraps content with a role tag. -func AddRoleTag(role, content string, unclose bool) string { - if role == "" { - role = "user" - } - if unclose { - return "<|im_start|>" + role + "\n" + content - } - return "<|im_start|>" + role + "\n" + content + "\n<|im_end|>" -} - -// BuildPrompt constructs the final prompt from a list of messages. -func BuildPrompt(msgs []RoleText, tagged bool, appendAssistant bool) string { - if len(msgs) == 0 { - if tagged && appendAssistant { - return AddRoleTag("assistant", "", true) - } - return "" - } - if !tagged { - var sb strings.Builder - for i, m := range msgs { - if i > 0 { - sb.WriteString("\n") - } - sb.WriteString(m.Text) - } - return sb.String() - } - var sb strings.Builder - for _, m := range msgs { - sb.WriteString(AddRoleTag(m.Role, m.Text, false)) - sb.WriteString("\n") - } - if appendAssistant { - sb.WriteString(AddRoleTag("assistant", "", true)) - } - return strings.TrimSpace(sb.String()) -} - -// RemoveThinkTags strips ... blocks from a string. -func RemoveThinkTags(s string) string { - return conversation.RemoveThinkTags(s) -} - -// SanitizeAssistantMessages removes think tags from assistant messages. -func SanitizeAssistantMessages(msgs []RoleText) []RoleText { - cleaned := conversation.SanitizeAssistantMessages(msgs) - return cleaned -} - -// AppendXMLWrapHintIfNeeded appends an XML wrap hint to messages containing XML-like blocks. -func AppendXMLWrapHintIfNeeded(msgs []RoleText, disable bool) []RoleText { - if disable { - return msgs - } - const xmlWrapHint = "\nFor any xml block, e.g. tool call, always wrap it with: \n`````xml\n...\n`````\n" - out := make([]RoleText, 0, len(msgs)) - for _, m := range msgs { - t := m.Text - if reXMLAnyTag.MatchString(t) { - t = t + xmlWrapHint - } - out = append(out, RoleText{Role: m.Role, Text: t}) - } - return out -} - -// EstimateTotalTokensFromRawJSON estimates token count by summing text parts. -func EstimateTotalTokensFromRawJSON(rawJSON []byte) int { - totalChars := 0 - contents := gjson.GetBytes(rawJSON, "contents") - if contents.Exists() { - contents.ForEach(func(_, content gjson.Result) bool { - content.Get("parts").ForEach(func(_, part gjson.Result) bool { - if t := part.Get("text"); t.Exists() { - totalChars += utf8.RuneCountInString(t.String()) - } - return true - }) - return true - }) - } - if totalChars <= 0 { - return 0 - } - return int(math.Ceil(float64(totalChars) / 4.0)) -} - -// Request chunking helpers ------------------------------------------------ - -const continuationHint = "\n(More messages to come, please reply with just 'ok.')" - -func ChunkByRunes(s string, size int) []string { - if size <= 0 { - return []string{s} - } - chunks := make([]string, 0, (len(s)/size)+1) - var buf strings.Builder - count := 0 - for _, r := range s { - buf.WriteRune(r) - count++ - if count >= size { - chunks = append(chunks, buf.String()) - buf.Reset() - count = 0 - } - } - if buf.Len() > 0 { - chunks = append(chunks, buf.String()) - } - if len(chunks) == 0 { - return []string{""} - } - return chunks -} - -func MaxCharsPerRequest(cfg *config.Config) int { - // Read max characters per request from config with a conservative default. - if cfg != nil { - if v := cfg.GeminiWeb.MaxCharsPerRequest; v > 0 { - return v - } - } - return 1_000_000 -} - -func SendWithSplit(chat *ChatSession, text string, files []string, cfg *config.Config) (ModelOutput, error) { - // Validate chat session - if chat == nil { - return ModelOutput{}, fmt.Errorf("nil chat session") - } - - // Resolve maxChars characters per request - maxChars := MaxCharsPerRequest(cfg) - if maxChars <= 0 { - maxChars = 1_000_000 - } - - // If within limit, send directly - if utf8.RuneCountInString(text) <= maxChars { - return chat.SendMessage(text, files) - } - - // Decide whether to use continuation hint (enabled by default) - useHint := true - if cfg != nil && cfg.GeminiWeb.DisableContinuationHint { - useHint = false - } - - // Compute chunk size in runes. If the hint does not fit, disable it for this request. - hintLen := 0 - if useHint { - hintLen = utf8.RuneCountInString(continuationHint) - } - chunkSize := maxChars - hintLen - if chunkSize <= 0 { - // maxChars is too small to accommodate the hint; fall back to no-hint splitting - useHint = false - chunkSize = maxChars - } - - // Split into rune-safe chunks - chunks := ChunkByRunes(text, chunkSize) - if len(chunks) == 0 { - chunks = []string{""} - } - - // Send all but the last chunk without files, optionally appending hint - for i := 0; i < len(chunks)-1; i++ { - part := chunks[i] - if useHint { - part += continuationHint - } - if _, err := chat.SendMessage(part, nil); err != nil { - return ModelOutput{}, err - } - } - - // Send final chunk with files and return the actual output - return chat.SendMessage(chunks[len(chunks)-1], files) -} diff --git a/internal/provider/gemini-web/state.go b/internal/provider/gemini-web/state.go deleted file mode 100644 index bc15790c..00000000 --- a/internal/provider/gemini-web/state.go +++ /dev/null @@ -1,1039 +0,0 @@ -package geminiwebapi - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini" - "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" - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" - bolt "go.etcd.io/bbolt" -) - -const ( - geminiWebDefaultTimeoutSec = 300 -) - -type GeminiWebState struct { - cfg *config.Config - token *gemini.GeminiWebTokenStorage - storagePath string - authLabel string - - stableClientID string - accountID string - - reqMu sync.Mutex - client *GeminiClient - - tokenMu sync.Mutex - tokenDirty bool - - convMu sync.RWMutex - convStore map[string][]string - convData map[string]ConversationRecord - convIndex map[string]string - - lastRefresh time.Time - - pendingMatchMu sync.Mutex - pendingMatch *conversation.MatchResult -} - -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 := conversation.Sha256Hex(token.Secure1PSID) - if len(suffix) > 16 { - suffix = suffix[:16] - } - state.stableClientID = "gemini-web-" + suffix - if storagePath != "" { - base := strings.TrimSuffix(filepath.Base(storagePath), filepath.Ext(storagePath)) - if base != "" { - state.accountID = base - } else { - state.accountID = suffix - } - } else { - state.accountID = suffix - } - state.loadConversationCaches() - 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-"). -func (s *GeminiWebState) Label() string { - if s == nil { - return "" - } - if s.token != nil { - if lbl := strings.TrimSpace(s.token.Label); lbl != "" { - 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 != "" { - return base - } - } - return s.stableClientID -} - -func (s *GeminiWebState) loadConversationCaches() { - path := s.convPath() - if path == "" { - return - } - if store, err := LoadConvStore(path); err == nil { - s.convStore = store - } - if items, index, err := LoadConvData(path); err == nil { - s.convData = items - s.convIndex = index - } -} - -// convPath returns the BoltDB file path used for both account metadata and conversation data. -func (s *GeminiWebState) convPath() string { - base := s.storagePath - if base == "" { - // Use accountID directly as base name; ConvBoltPath will append .bolt. - base = s.accountID - } - 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 { - if s.client != nil && s.client.Running { - return nil - } - proxyURL := "" - if s.cfg != nil { - proxyURL = s.cfg.ProxyURL - } - s.client = NewGeminiClient( - s.token.Secure1PSID, - s.token.Secure1PSIDTS, - proxyURL, - ) - timeout := geminiWebDefaultTimeoutSec - if err := s.client.Init(float64(timeout), false); err != nil { - s.client = nil - return err - } - s.lastRefresh = time.Now() - return nil -} - -func (s *GeminiWebState) Refresh(ctx context.Context) error { - _ = ctx - proxyURL := "" - if s.cfg != nil { - proxyURL = s.cfg.ProxyURL - } - s.client = NewGeminiClient( - s.token.Secure1PSID, - s.token.Secure1PSIDTS, - proxyURL, - ) - timeout := geminiWebDefaultTimeoutSec - if err := s.client.Init(float64(timeout), false); err != nil { - return err - } - // Attempt rotation proactively to persist new TS sooner - if newTS, err := s.client.RotateTS(); err == nil && newTS != "" && newTS != s.token.Secure1PSIDTS { - s.tokenMu.Lock() - s.token.Secure1PSIDTS = newTS - s.tokenDirty = true - if s.client != nil && s.client.Cookies != nil { - s.client.Cookies["__Secure-1PSIDTS"] = newTS - } - s.tokenMu.Unlock() - // Detailed debug log: provider and account label. - label := strings.TrimSpace(s.Label()) - if label == "" { - label = s.accountID - } - log.Debugf("gemini web account %s rotated 1PSIDTS: %s", label, MaskToken28(newTS)) - } - s.lastRefresh = time.Now() - return nil -} - -func (s *GeminiWebState) TokenSnapshot() *gemini.GeminiWebTokenStorage { - s.tokenMu.Lock() - defer s.tokenMu.Unlock() - c := *s.token - return &c -} - -type geminiWebPrepared struct { - handlerType string - translatedRaw []byte - prompt string - uploaded []string - chat *ChatSession - cleaned []RoleText - underlying string - reuse bool - tagged bool - originalRaw []byte -} - -func (s *GeminiWebState) prepare(ctx context.Context, modelName string, rawJSON []byte, stream bool, original []byte) (*geminiWebPrepared, *interfaces.ErrorMessage) { - res := &geminiWebPrepared{originalRaw: original} - res.translatedRaw = bytes.Clone(rawJSON) - if handler, ok := ctx.Value("handler").(interfaces.APIHandler); ok && handler != nil { - res.handlerType = handler.HandlerType() - res.translatedRaw = translator.Request(res.handlerType, constant.GeminiWeb, modelName, res.translatedRaw, stream) - } - recordAPIRequest(ctx, s.cfg, res.translatedRaw) - - messages, files, mimes, msgFileIdx, err := ParseMessagesAndFiles(res.translatedRaw) - if err != nil { - return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: fmt.Errorf("bad request: %w", err)} - } - cleaned := SanitizeAssistantMessages(messages) - fullCleaned := cloneRoleTextSlice(cleaned) - res.underlying = MapAliasToUnderlying(modelName) - model, err := ModelFromName(res.underlying) - if err != nil { - return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: err} - } - - var meta []string - useMsgs := cleaned - filesSubset := files - mimesSubset := mimes - - if s.useReusableContext() { - reusePlan := s.reuseFromPending(res.underlying, cleaned) - if reusePlan == nil { - reusePlan = s.findReusableSession(res.underlying, cleaned) - } - if reusePlan != nil { - res.reuse = true - 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) { - lastIdx := len(msgFileIdx) - 1 - idxs := msgFileIdx[lastIdx] - if len(idxs) > 0 { - filesSubset = make([][]byte, 0, len(idxs)) - mimesSubset = make([]string, 0, len(idxs)) - for _, fi := range idxs { - if fi >= 0 && fi < len(files) { - filesSubset = append(filesSubset, files[fi]) - if fi < len(mimes) { - mimesSubset = append(mimesSubset, mimes[fi]) - } else { - mimesSubset = append(mimesSubset, "") - } - } - } - } else { - filesSubset = nil - mimesSubset = nil - } - } else { - filesSubset = nil - mimesSubset = nil - } - } else { - if len(cleaned) >= 2 && strings.EqualFold(cleaned[len(cleaned)-2].Role, "assistant") { - keyUnderlying := AccountMetaKey(s.accountID, res.underlying) - keyAlias := AccountMetaKey(s.accountID, modelName) - s.convMu.RLock() - fallbackMeta := s.convStore[keyUnderlying] - if len(fallbackMeta) == 0 { - fallbackMeta = s.convStore[keyAlias] - } - s.convMu.RUnlock() - if len(fallbackMeta) > 0 { - meta = fallbackMeta - useMsgs = []RoleText{cleaned[len(cleaned)-1]} - res.reuse = true - filesSubset = nil - mimesSubset = nil - } - } - } - } else { - keyUnderlying := AccountMetaKey(s.accountID, res.underlying) - keyAlias := AccountMetaKey(s.accountID, modelName) - s.convMu.RLock() - if v, ok := s.convStore[keyUnderlying]; ok && len(v) > 0 { - meta = v - } else { - meta = s.convStore[keyAlias] - } - s.convMu.RUnlock() - } - - res.cleaned = fullCleaned - - res.tagged = NeedRoleTags(useMsgs) - if res.reuse && len(useMsgs) == 1 { - res.tagged = false - } - - enableXML := s.cfg != nil && s.cfg.GeminiWeb.CodeMode - useMsgs = AppendXMLWrapHintIfNeeded(useMsgs, !enableXML) - - res.prompt = BuildPrompt(useMsgs, res.tagged, res.tagged) - if strings.TrimSpace(res.prompt) == "" { - return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: errors.New("bad request: empty prompt after filtering system/thought content")} - } - - uploaded, upErr := MaterializeInlineFiles(filesSubset, mimesSubset) - if upErr != nil { - return nil, upErr - } - res.uploaded = uploaded - - if err = s.EnsureClient(); err != nil { - return nil, &interfaces.ErrorMessage{StatusCode: 500, Error: err} - } - chat := s.client.StartChat(model, s.getConfiguredGem(), meta) - chat.SetRequestedModel(modelName) - res.chat = chat - - return res, nil -} - -func (s *GeminiWebState) Send(ctx context.Context, modelName string, reqPayload []byte, opts cliproxyexecutor.Options) ([]byte, *interfaces.ErrorMessage, *geminiWebPrepared) { - prep, errMsg := s.prepare(ctx, modelName, reqPayload, opts.Stream, opts.OriginalRequest) - if errMsg != nil { - return nil, errMsg, nil - } - defer CleanupFiles(prep.uploaded) - - output, err := SendWithSplit(prep.chat, prep.prompt, prep.uploaded, s.cfg) - if err != nil { - return nil, s.wrapSendError(err), nil - } - - // Hook: For gemini-2.5-flash-image-web, if the API returns only images without any text, - // inject a small textual summary so that conversation persistence has non-empty assistant text. - // This helps conversation recovery (conv store) to match sessions reliably. - if strings.EqualFold(modelName, "gemini-2.5-flash-image-web") { - if len(output.Candidates) > 0 { - c := output.Candidates[output.Chosen] - hasNoText := strings.TrimSpace(c.Text) == "" - hasImages := len(c.GeneratedImages) > 0 || len(c.WebImages) > 0 - if hasNoText && hasImages { - // Build a stable, concise fallback text. Avoid dynamic details to keep hashes stable. - // Prefer a deterministic phrase with count to aid users while keeping consistency. - fallback := "Done" - // Mutate the chosen candidate's text so both response conversion and - // conversation persistence observe the same fallback. - output.Candidates[output.Chosen].Text = fallback - } - } - } - - gemBytes, err := ConvertOutputToGemini(&output, modelName, prep.prompt) - if err != nil { - return nil, &interfaces.ErrorMessage{StatusCode: 500, Error: err}, nil - } - - s.addAPIResponseData(ctx, gemBytes) - s.persistConversation(modelName, prep, &output) - return gemBytes, nil, prep -} - -func (s *GeminiWebState) wrapSendError(genErr error) *interfaces.ErrorMessage { - status := 500 - var usage *UsageLimitExceeded - var blocked *TemporarilyBlocked - var invalid *ModelInvalid - var valueErr *ValueError - var timeout *TimeoutError - switch { - case errors.As(genErr, &usage): - status = 429 - case errors.As(genErr, &blocked): - status = 429 - case errors.As(genErr, &invalid): - status = 400 - case errors.As(genErr, &valueErr): - status = 400 - case errors.As(genErr, &timeout): - status = 504 - } - return &interfaces.ErrorMessage{StatusCode: status, Error: genErr} -} - -func (s *GeminiWebState) persistConversation(modelName string, prep *geminiWebPrepared, output *ModelOutput) { - if output == nil || prep == nil || prep.chat == nil { - return - } - metadata := prep.chat.Metadata() - if len(metadata) > 0 { - keyUnderlying := AccountMetaKey(s.accountID, prep.underlying) - keyAlias := AccountMetaKey(s.accountID, modelName) - s.convMu.Lock() - s.convStore[keyUnderlying] = metadata - s.convStore[keyAlias] = metadata - storeSnapshot := make(map[string][]string, len(s.convStore)) - for k, v := range s.convStore { - if v == nil { - continue - } - cp := make([]string, len(v)) - copy(cp, v) - storeSnapshot[k] = cp - } - s.convMu.Unlock() - _ = SaveConvStore(s.convPath(), storeSnapshot) - } - - if !s.useReusableContext() { - return - } - rec, ok := BuildConversationRecord(prep.underlying, s.stableClientID, prep.cleaned, output, metadata) - if !ok { - return - } - 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 - s.convIndex["hash:"+stableHash] = stableHash - 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 - } - indexSnapshot := make(map[string]string, len(s.convIndex)) - for k, v := range s.convIndex { - indexSnapshot[k] = v - } - s.convMu.Unlock() - _ = SaveConvData(s.convPath(), dataSnapshot, indexSnapshot) -} - -func (s *GeminiWebState) addAPIResponseData(ctx context.Context, line []byte) { - appendAPIResponseChunk(ctx, s.cfg, line) -} - -func (s *GeminiWebState) ConvertToTarget(ctx context.Context, modelName string, prep *geminiWebPrepared, gemBytes []byte) []byte { - if prep == nil || prep.handlerType == "" { - return gemBytes - } - if !translator.NeedConvert(prep.handlerType, constant.GeminiWeb) { - return gemBytes - } - var param any - out := translator.ResponseNonStream(prep.handlerType, constant.GeminiWeb, ctx, modelName, prep.originalRaw, prep.translatedRaw, gemBytes, ¶m) - if prep.handlerType == constant.OpenAI && out != "" { - newID := fmt.Sprintf("chatcmpl-%x", time.Now().UnixNano()) - if v := gjson.Parse(out).Get("id"); v.Exists() { - out, _ = sjson.Set(out, "id", newID) - } - } - return []byte(out) -} - -func (s *GeminiWebState) ConvertStream(ctx context.Context, modelName string, prep *geminiWebPrepared, gemBytes []byte) []string { - if prep == nil || prep.handlerType == "" { - return []string{string(gemBytes)} - } - if !translator.NeedConvert(prep.handlerType, constant.GeminiWeb) { - return []string{string(gemBytes)} - } - var param any - return translator.Response(prep.handlerType, constant.GeminiWeb, ctx, modelName, prep.originalRaw, prep.translatedRaw, gemBytes, ¶m) -} - -func (s *GeminiWebState) DoneStream(ctx context.Context, modelName string, prep *geminiWebPrepared) []string { - if prep == nil || prep.handlerType == "" { - return nil - } - if !translator.NeedConvert(prep.handlerType, constant.GeminiWeb) { - return nil - } - var param any - return translator.Response(prep.handlerType, constant.GeminiWeb, ctx, modelName, prep.originalRaw, prep.translatedRaw, []byte("[DONE]"), ¶m) -} - -func (s *GeminiWebState) useReusableContext() bool { - if s.cfg == nil { - return true - } - return s.cfg.GeminiWeb.Context -} - -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() - 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 { - if s.cfg == nil { - return nil - } - // New behavior: attach Gem based on explicit GemMode selection. - // Only attaches the Gem; does not toggle any other behavior. - if gm := strings.ToLower(strings.TrimSpace(s.cfg.GeminiWeb.GemMode)); gm != "" { - switch gm { - case "coding-partner": - return &Gem{ID: "coding-partner", Name: "Coding partner", Predefined: true} - case "writing-editor": - return &Gem{ID: "writing-editor", Name: "Writing editor", Predefined: true} - } - } - // Backwards compatibility: legacy CodeMode still attaches Coding partner - // and may enable extra behaviors elsewhere. - if s.cfg.GeminiWeb.CodeMode { - return &Gem{ID: "coding-partner", Name: "Coding partner", Predefined: true} - } - return nil -} - -// recordAPIRequest stores the upstream request payload in Gin context for request logging. -func recordAPIRequest(ctx context.Context, cfg *config.Config, payload []byte) { - if cfg == nil || !cfg.RequestLog || len(payload) == 0 { - return - } - if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil { - ginCtx.Set("API_REQUEST", bytes.Clone(payload)) - } -} - -// appendAPIResponseChunk appends an upstream response chunk to Gin context for request logging. -func appendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byte) { - if cfg == nil || !cfg.RequestLog { - return - } - data := bytes.TrimSpace(bytes.Clone(chunk)) - if len(data) == 0 { - return - } - if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil { - if existing, exists := ginCtx.Get("API_RESPONSE"); exists { - if prev, okBytes := existing.([]byte); okBytes { - prev = append(prev, data...) - prev = append(prev, []byte("\n\n")...) - ginCtx.Set("API_RESPONSE", prev) - return - } - } - ginCtx.Set("API_RESPONSE", data) - } -} - -// 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 { - wd, err := os.Getwd() - if err != nil || wd == "" { - wd = "." - } - convDir := filepath.Join(wd, "conv") - base := strings.TrimSuffix(filepath.Base(tokenFilePath), filepath.Ext(tokenFilePath)) - return filepath.Join(convDir, base+".bolt") -} - -// LoadConvStore reads the account-level metadata store from disk. -func LoadConvStore(path string) (map[string][]string, error) { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return nil, err - } - db, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: time.Second}) - if err != nil { - return nil, err - } - defer func() { - _ = db.Close() - }() - out := map[string][]string{} - err = db.View(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte("account_meta")) - if b == nil { - return nil - } - return b.ForEach(func(k, v []byte) error { - var arr []string - if len(v) > 0 { - if e := json.Unmarshal(v, &arr); e != nil { - // Skip malformed entries instead of failing the whole load - return nil - } - } - out[string(k)] = arr - return nil - }) - }) - if err != nil { - return nil, err - } - return out, nil -} - -// SaveConvStore writes the account-level metadata store to disk atomically. -func SaveConvStore(path string, data map[string][]string) error { - if data == nil { - data = map[string][]string{} - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - db, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 2 * time.Second}) - if err != nil { - return err - } - defer func() { - _ = db.Close() - }() - return db.Update(func(tx *bolt.Tx) error { - // Recreate bucket to reflect the given snapshot exactly. - if b := tx.Bucket([]byte("account_meta")); b != nil { - if err = tx.DeleteBucket([]byte("account_meta")); err != nil { - return err - } - } - b, errCreateBucket := tx.CreateBucket([]byte("account_meta")) - if errCreateBucket != nil { - return errCreateBucket - } - for k, v := range data { - enc, e := json.Marshal(v) - if e != nil { - return e - } - if e = b.Put([]byte(k), enc); e != nil { - return e - } - } - return nil - }) -} - -// AccountMetaKey builds the key for account-level metadata map. -func AccountMetaKey(email, modelName string) string { - return fmt.Sprintf("account-meta|%s|%s", email, modelName) -} - -// LoadConvData reads the full conversation data and index from disk. -func LoadConvData(path string) (map[string]ConversationRecord, map[string]string, error) { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return nil, nil, err - } - db, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: time.Second}) - if err != nil { - return nil, nil, err - } - defer func() { - _ = db.Close() - }() - items := map[string]ConversationRecord{} - index := map[string]string{} - err = db.View(func(tx *bolt.Tx) error { - // Load conv_items - if b := tx.Bucket([]byte("conv_items")); b != nil { - if e := b.ForEach(func(k, v []byte) error { - var rec ConversationRecord - if len(v) > 0 { - if e2 := json.Unmarshal(v, &rec); e2 != nil { - // Skip malformed - return nil - } - items[string(k)] = rec - } - return nil - }); e != nil { - return e - } - } - // Load conv_index - if b := tx.Bucket([]byte("conv_index")); b != nil { - if e := b.ForEach(func(k, v []byte) error { - index[string(k)] = string(v) - return nil - }); e != nil { - return e - } - } - return nil - }) - if err != nil { - return nil, nil, err - } - return items, index, nil -} - -// SaveConvData writes the full conversation data and index to disk atomically. -func SaveConvData(path string, items map[string]ConversationRecord, index map[string]string) error { - if items == nil { - items = map[string]ConversationRecord{} - } - if index == nil { - index = map[string]string{} - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - db, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 2 * time.Second}) - if err != nil { - return err - } - defer func() { - _ = db.Close() - }() - return db.Update(func(tx *bolt.Tx) error { - // Recreate items bucket - if b := tx.Bucket([]byte("conv_items")); b != nil { - if err = tx.DeleteBucket([]byte("conv_items")); err != nil { - return err - } - } - bi, errCreateBucket := tx.CreateBucket([]byte("conv_items")) - if errCreateBucket != nil { - return errCreateBucket - } - for k, rec := range items { - enc, e := json.Marshal(rec) - if e != nil { - return e - } - if e = bi.Put([]byte(k), enc); e != nil { - return e - } - } - - // Recreate index bucket - if b := tx.Bucket([]byte("conv_index")); b != nil { - if err = tx.DeleteBucket([]byte("conv_index")); err != nil { - return err - } - } - bx, errCreateBucket := tx.CreateBucket([]byte("conv_index")) - if errCreateBucket != nil { - return errCreateBucket - } - for k, v := range index { - if e := bx.Put([]byte(k), []byte(v)); e != nil { - return e - } - } - return nil - }) -} - -// BuildConversationRecord constructs a ConversationRecord from history and the latest output. -// Returns false when output is empty or has no candidates. -func BuildConversationRecord(model, clientID string, history []RoleText, output *ModelOutput, metadata []string) (ConversationRecord, bool) { - if output == nil || len(output.Candidates) == 0 { - return ConversationRecord{}, false - } - text := "" - if t := output.Candidates[0].Text; t != "" { - text = RemoveThinkTags(t) - } - final := append([]RoleText{}, history...) - final = append(final, RoleText{Role: "assistant", Text: text}) - rec := ConversationRecord{ - Model: model, - ClientID: clientID, - Metadata: metadata, - Messages: conversation.ToStoredMessages(final), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - return rec, true -} - -// 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 := 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 { - if rec, ok2 := items[key]; ok2 { - return rec, true - } - } - if rec, ok := items[stableHash]; ok { - return rec, true - } - // Fallback to legacy hash (email-based) - if key, ok := index["hash:"+fallbackHash]; ok { - if rec, ok2 := items[key]; ok2 { - return rec, true - } - } - if rec, ok := items[fallbackHash]; ok { - return rec, true - } - return ConversationRecord{}, false -} - -// FindConversationIn tries exact then sanitized assistant messages. -func FindConversationIn(items map[string]ConversationRecord, index map[string]string, stableClientID, email, model string, msgs []RoleText) (ConversationRecord, bool) { - if len(msgs) == 0 { - return ConversationRecord{}, false - } - if rec, ok := FindByMessageListIn(items, index, stableClientID, email, model, msgs); ok { - return rec, true - } - if rec, ok := FindByMessageListIn(items, index, stableClientID, email, model, SanitizeAssistantMessages(msgs)); ok { - return rec, true - } - return ConversationRecord{}, false -} - -// 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) (ConversationRecord, []string, int, bool) { - if len(msgs) < 2 { - return ConversationRecord{}, nil, 0, false - } - searchEnd := len(msgs) - for searchEnd >= 2 { - sub := msgs[:searchEnd] - 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 { - return rec, rec.Metadata, searchEnd, true - } - } - searchEnd-- - } - return ConversationRecord{}, nil, 0, false -} - -// SetConfig updates the configuration reference used by the state. -// This allows hot-reload of configuration to take effect for existing -// runtime states that were cached on auth during previous requests. -func (s *GeminiWebState) SetConfig(cfg *config.Config) { - s.cfg = cfg -} diff --git a/internal/runtime/executor/gemini_web_executor.go b/internal/runtime/executor/gemini_web_executor.go deleted file mode 100644 index 435b346f..00000000 --- a/internal/runtime/executor/gemini_web_executor.go +++ /dev/null @@ -1,280 +0,0 @@ -package executor - -import ( - "bytes" - "context" - "fmt" - "net/http" - "strings" - "sync" - "time" - - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini" - "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" - log "github.com/sirupsen/logrus" -) - -type GeminiWebExecutor struct { - cfg *config.Config - mu sync.Mutex -} - -func NewGeminiWebExecutor(cfg *config.Config) *GeminiWebExecutor { - return &GeminiWebExecutor{cfg: cfg} -} - -func (e *GeminiWebExecutor) Identifier() string { return "gemini-web" } - -func (e *GeminiWebExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil } - -func (e *GeminiWebExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { - state, err := e.stateFor(auth) - if err != nil { - return cliproxyexecutor.Response{}, err - } - 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) - resp, errMsg, prep := state.Send(ctx, req.Model, payload, opts) - if errMsg != nil { - return cliproxyexecutor.Response{}, geminiWebErrorFromMessage(errMsg) - } - resp = state.ConvertToTarget(ctx, req.Model, prep, resp) - reporter.publish(ctx, parseGeminiUsage(resp)) - - from := opts.SourceFormat - to := sdktranslator.FromString("gemini-web") - var param any - out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), payload, bytes.Clone(resp), ¶m) - - return cliproxyexecutor.Response{Payload: []byte(out)}, nil -} - -func (e *GeminiWebExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) { - state, err := e.stateFor(auth) - if err != nil { - return nil, err - } - 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) - if errMsg != nil { - if mutex != nil { - mutex.Unlock() - } - return nil, geminiWebErrorFromMessage(errMsg) - } - reporter.publish(ctx, parseGeminiUsage(gemBytes)) - - from := opts.SourceFormat - to := sdktranslator.FromString("gemini-web") - var param any - - lines := state.ConvertStream(ctx, req.Model, prep, gemBytes) - done := state.DoneStream(ctx, req.Model, prep) - out := make(chan cliproxyexecutor.StreamChunk) - go func() { - defer close(out) - if mutex != nil { - defer mutex.Unlock() - } - for _, line := range lines { - lines = sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), req.Payload, bytes.Clone([]byte(line)), ¶m) - for _, l := range lines { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(l)} - } - } - for _, line := range done { - lines = sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), req.Payload, bytes.Clone([]byte(line)), ¶m) - for _, l := range lines { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(l)} - } - } - }() - return out, nil -} - -func (e *GeminiWebExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { - return cliproxyexecutor.Response{Payload: []byte{}}, fmt.Errorf("not implemented") -} - -func (e *GeminiWebExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { - log.Debugf("gemini web executor: refresh called") - state, err := e.stateFor(auth) - if err != nil { - return nil, err - } - if err = state.Refresh(ctx); err != nil { - return nil, err - } - ts := state.TokenSnapshot() - if auth.Metadata == nil { - auth.Metadata = make(map[string]any) - } - auth.Metadata["secure_1psid"] = ts.Secure1PSID - auth.Metadata["secure_1psidts"] = ts.Secure1PSIDTS - auth.Metadata["type"] = "gemini-web" - auth.Metadata["last_refresh"] = time.Now().Format(time.RFC3339) - if v, ok := auth.Metadata["label"].(string); !ok || strings.TrimSpace(v) == "" { - if lbl := state.Label(); strings.TrimSpace(lbl) != "" { - auth.Metadata["label"] = strings.TrimSpace(lbl) - } - } - return auth, nil -} - -type geminiWebRuntime struct { - state *geminiwebapi.GeminiWebState -} - -func (e *GeminiWebExecutor) stateFor(auth *cliproxyauth.Auth) (*geminiwebapi.GeminiWebState, error) { - if auth == nil { - return nil, fmt.Errorf("gemini-web executor: auth is nil") - } - if runtime, ok := auth.Runtime.(*geminiWebRuntime); ok && runtime != nil && runtime.state != nil { - // Hot-reload: ensure cached state sees the latest config - runtime.state.SetConfig(e.cfg) - return runtime.state, nil - } - - e.mu.Lock() - defer e.mu.Unlock() - - if runtime, ok := auth.Runtime.(*geminiWebRuntime); ok && runtime != nil && runtime.state != nil { - // Hot-reload: ensure cached state sees the latest config - runtime.state.SetConfig(e.cfg) - return runtime.state, nil - } - - ts, err := parseGeminiWebToken(auth) - if err != nil { - return nil, err - } - - cfg := e.cfg - if auth.ProxyURL != "" && cfg != nil { - copyCfg := *cfg - copyCfg.ProxyURL = auth.ProxyURL - cfg = ©Cfg - } - - storagePath := "" - if auth.Attributes != nil { - if p, ok := auth.Attributes["path"]; ok { - storagePath = p - } - } - state := geminiwebapi.NewGeminiWebState(cfg, ts, storagePath, auth.Label) - runtime := &geminiWebRuntime{state: state} - auth.Runtime = runtime - return state, nil -} - -func parseGeminiWebToken(auth *cliproxyauth.Auth) (*gemini.GeminiWebTokenStorage, error) { - if auth == nil { - return nil, fmt.Errorf("gemini-web executor: auth is nil") - } - if auth.Metadata == nil { - return nil, fmt.Errorf("gemini-web executor: missing metadata") - } - psid := stringFromMetadata(auth.Metadata, "secure_1psid", "secure_1psid", "__Secure-1PSID") - psidts := stringFromMetadata(auth.Metadata, "secure_1psidts", "secure_1psidts", "__Secure-1PSIDTS") - if psid == "" || psidts == "" { - return nil, fmt.Errorf("gemini-web executor: incomplete cookie metadata") - } - label := strings.TrimSpace(stringFromMetadata(auth.Metadata, "label")) - return &gemini.GeminiWebTokenStorage{Secure1PSID: psid, Secure1PSIDTS: psidts, Label: label}, nil -} - -func stringFromMetadata(meta map[string]any, keys ...string) string { - for _, key := range keys { - if val, ok := meta[key]; ok { - if s, okStr := val.(string); okStr && s != "" { - return s - } - } - } - return "" -} - -func geminiWebErrorFromMessage(msg *interfaces.ErrorMessage) error { - if msg == nil { - return nil - } - return geminiWebError{message: msg} -} - -type geminiWebError struct { - message *interfaces.ErrorMessage -} - -func (e geminiWebError) Error() string { - if e.message == nil { - return "gemini-web error" - } - if e.message.Error != nil { - return e.message.Error.Error() - } - return fmt.Sprintf("gemini-web error: status %d", e.message.StatusCode) -} - -func (e geminiWebError) StatusCode() int { - if e.message == nil { - return 0 - } - 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/internal/translator/gemini-web/openai/chat-completions/init.go b/internal/translator/gemini-web/openai/chat-completions/init.go deleted file mode 100644 index 7e8dc53e..00000000 --- a/internal/translator/gemini-web/openai/chat-completions/init.go +++ /dev/null @@ -1,20 +0,0 @@ -package chat_completions - -import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - geminiChat "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/chat-completions" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" -) - -func init() { - translator.Register( - OpenAI, - GeminiWeb, - geminiChat.ConvertOpenAIRequestToGemini, - interfaces.TranslateResponse{ - Stream: geminiChat.ConvertGeminiResponseToOpenAI, - NonStream: geminiChat.ConvertGeminiResponseToOpenAINonStream, - }, - ) -} diff --git a/internal/translator/gemini-web/openai/responses/init.go b/internal/translator/gemini-web/openai/responses/init.go deleted file mode 100644 index 84cdec72..00000000 --- a/internal/translator/gemini-web/openai/responses/init.go +++ /dev/null @@ -1,20 +0,0 @@ -package responses - -import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - geminiResponses "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" -) - -func init() { - translator.Register( - OpenaiResponse, - GeminiWeb, - geminiResponses.ConvertOpenAIResponsesRequestToGemini, - interfaces.TranslateResponse{ - Stream: geminiResponses.ConvertGeminiResponseToOpenAIResponses, - NonStream: geminiResponses.ConvertGeminiResponseToOpenAIResponsesNonStream, - }, - ) -} diff --git a/sdk/auth/gemini-web.go b/sdk/auth/gemini-web.go deleted file mode 100644 index 4e10e368..00000000 --- a/sdk/auth/gemini-web.go +++ /dev/null @@ -1,30 +0,0 @@ -package auth - -import ( - "context" - "fmt" - "time" - - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" -) - -// GeminiWebAuthenticator provides a minimal wrapper so core components can treat -// Gemini Web credentials via the shared Authenticator contract. -type GeminiWebAuthenticator struct{} - -func NewGeminiWebAuthenticator() *GeminiWebAuthenticator { return &GeminiWebAuthenticator{} } - -func (a *GeminiWebAuthenticator) Provider() string { return "gemini-web" } - -func (a *GeminiWebAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { - _ = ctx - _ = cfg - _ = opts - return nil, fmt.Errorf("gemini-web authenticator does not support scripted login; use CLI --gemini-web-auth") -} - -func (a *GeminiWebAuthenticator) RefreshLead() *time.Duration { - d := time.Hour - return &d -} diff --git a/sdk/cliproxy/auth/selector_rr.go b/sdk/cliproxy/auth/selector_rr.go deleted file mode 100644 index 1dbf357d..00000000 --- a/sdk/cliproxy/auth/selector_rr.go +++ /dev/null @@ -1,125 +0,0 @@ -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 -}