Merge pull request #63 from router-for-me/gemini-web

Gemini-web
This commit is contained in:
Luis Pater
2025-09-25 11:53:22 +08:00
committed by GitHub
8 changed files with 73 additions and 112 deletions

View File

@@ -714,6 +714,8 @@ func (h *Handler) CreateGeminiWebToken(c *gin.Context) {
Secure1PSID: payload.Secure1PSID, Secure1PSID: payload.Secure1PSID,
Secure1PSIDTS: payload.Secure1PSIDTS, Secure1PSIDTS: payload.Secure1PSIDTS,
} }
// Provide a stable label (gemini-web-<hash>) for logging and identification
tokenStorage.Label = strings.TrimSuffix(fileName, ".json")
record := &sdkAuth.TokenRecord{ record := &sdkAuth.TokenRecord{
Provider: "gemini-web", Provider: "gemini-web",

View File

@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc" "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
@@ -20,12 +21,25 @@ type GeminiWebTokenStorage struct {
Secure1PSIDTS string `json:"secure_1psidts"` Secure1PSIDTS string `json:"secure_1psidts"`
Type string `json:"type"` Type string `json:"type"`
LastRefresh string `json:"last_refresh,omitempty"` LastRefresh string `json:"last_refresh,omitempty"`
// Label is a stable account identifier used for logging, e.g. "gemini-web-<hash>".
// 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. // SaveTokenToFile serializes the Gemini Web token storage to a JSON file.
func (ts *GeminiWebTokenStorage) SaveTokenToFile(authFilePath string) error { func (ts *GeminiWebTokenStorage) SaveTokenToFile(authFilePath string) error {
misc.LogSavingCredentials(authFilePath) misc.LogSavingCredentials(authFilePath)
ts.Type = "gemini-web" 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 == "" { if ts.LastRefresh == "" {
ts.LastRefresh = time.Now().Format(time.RFC3339) ts.LastRefresh = time.Now().Format(time.RFC3339)
} }

View File

@@ -49,6 +49,10 @@ func DoGeminiWebAuth(cfg *config.Config) {
hasher.Write([]byte(secure1psid)) hasher.Write([]byte(secure1psid))
hash := hex.EncodeToString(hasher.Sum(nil)) hash := hex.EncodeToString(hasher.Sum(nil))
fileName := fmt.Sprintf("gemini-web-%s.json", hash[:16]) fileName := fmt.Sprintf("gemini-web-%s.json", hash[:16])
// Set a stable label for logging, e.g. gemini-web-<hash>
if tokenStorage != nil {
tokenStorage.Label = strings.TrimSuffix(fileName, ".json")
}
record := &sdkAuth.TokenRecord{ record := &sdkAuth.TokenRecord{
Provider: "gemini-web", Provider: "gemini-web",
FileName: fileName, FileName: fileName,

View File

@@ -97,8 +97,12 @@ func getAccessToken(baseCookies map[string]string, proxy string, verbose bool, i
{ {
client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true}) client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true})
req, _ := http.NewRequest(http.MethodGet, EndpointGoogle, nil) req, _ := http.NewRequest(http.MethodGet, EndpointGoogle, nil)
resp, _ := client.Do(req) resp, err := client.Do(req)
if resp != nil { 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 { if u, err := url.Parse(EndpointGoogle); err == nil {
for _, c := range client.Jar.Cookies(u) { for _, c := range client.Jar.Cookies(u) {
extraCookies[c.Name] = c.Value extraCookies[c.Name] = c.Value
@@ -172,18 +176,10 @@ func rotate1PSIDTS(cookies map[string]string, proxy string, insecure bool) (stri
return "", &AuthError{Msg: "__Secure-1PSID missing"} return "", &AuthError{Msg: "__Secure-1PSID missing"}
} }
tr := &http.Transport{} // Reuse shared HTTP client helper for consistency.
if proxy != "" { client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true})
if pu, err := url.Parse(proxy); err == nil {
tr.Proxy = http.ProxyURL(pu)
}
}
if insecure {
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
client := &http.Client{Transport: tr, Timeout: 60 * time.Second}
req, _ := http.NewRequest(http.MethodPost, EndpointRotateCookies, io.NopCloser(stringsReader("[000,\"-0000000000000000000\"]"))) req, _ := http.NewRequest(http.MethodPost, EndpointRotateCookies, strings.NewReader("[000,\"-0000000000000000000\"]"))
applyHeaders(req, HeadersRotateCookies) applyHeaders(req, HeadersRotateCookies)
applyCookies(req, cookies) applyCookies(req, cookies)
@@ -207,25 +203,18 @@ func rotate1PSIDTS(cookies map[string]string, proxy string, insecure bool) (stri
return c.Value, nil 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 return "", nil
} }
type constReader struct { // MaskToken28 masks a sensitive token for safe logging. Keep middle partially visible.
s string
i int
}
func (r *constReader) Read(p []byte) (int, error) {
if r.i >= len(r.s) {
return 0, io.EOF
}
n := copy(p, r.s[r.i:])
r.i += n
return n, nil
}
func stringsReader(s string) io.Reader { return &constReader{s: s} }
func MaskToken28(s string) string { func MaskToken28(s string) string {
n := len(s) n := len(s)
if n == 0 { if n == 0 {
@@ -431,21 +420,10 @@ func (c *GeminiClient) generateOnce(prompt string, files []string, model Model,
form.Set("f.req", string(outerJSON)) form.Set("f.req", string(outerJSON))
req, _ := http.NewRequest(http.MethodPost, EndpointGenerate, strings.NewReader(form.Encode())) req, _ := http.NewRequest(http.MethodPost, EndpointGenerate, strings.NewReader(form.Encode()))
// headers applyHeaders(req, HeadersGemini)
for k, v := range HeadersGemini { applyHeaders(req, model.ModelHeader)
for _, vv := range v {
req.Header.Add(k, vv)
}
}
for k, v := range model.ModelHeader {
for _, vv := range v {
req.Header.Add(k, vv)
}
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=utf-8") req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
for k, v := range c.Cookies { applyCookies(req, c.Cookies)
req.AddCookie(&http.Cookie{Name: k, Value: v})
}
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {

View File

@@ -2,7 +2,6 @@ package geminiwebapi
import ( import (
"bytes" "bytes"
"crypto/tls"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -11,8 +10,6 @@ import (
"math" "math"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/cookiejar"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
@@ -69,18 +66,9 @@ func (i Image) Save(path string, filename string, cookies map[string]string, ver
} }
} }
} }
// Build client with cookie jar so cookies persist across redirects. // Build client using shared helper to keep proxy/TLS behavior consistent.
tr := &http.Transport{} client := newHTTPClient(httpOptions{ProxyURL: i.Proxy, Insecure: insecure, FollowRedirects: true})
if i.Proxy != "" { client.Timeout = 120 * time.Second
if pu, err := url.Parse(i.Proxy); err == nil {
tr.Proxy = http.ProxyURL(pu)
}
}
if insecure {
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
jar, _ := cookiejar.New(nil)
client := &http.Client{Transport: tr, Timeout: 120 * time.Second, Jar: jar}
// Helper to set raw Cookie header using provided cookies (to mirror Python client behavior). // Helper to set raw Cookie header using provided cookies (to mirror Python client behavior).
buildCookieHeader := func(m map[string]string) string { buildCookieHeader := func(m map[string]string) string {
@@ -352,23 +340,11 @@ func uploadFile(path string, proxy string, insecure bool) (string, error) {
} }
_ = mw.Close() _ = mw.Close()
tr := &http.Transport{} client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true})
if proxy != "" { client.Timeout = 300 * time.Second
if pu, errParse := url.Parse(proxy); errParse == nil {
tr.Proxy = http.ProxyURL(pu)
}
}
if insecure {
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
client := &http.Client{Transport: tr, Timeout: 300 * time.Second}
req, _ := http.NewRequest(http.MethodPost, EndpointUpload, &buf) req, _ := http.NewRequest(http.MethodPost, EndpointUpload, &buf)
for k, v := range HeadersUpload { applyHeaders(req, HeadersUpload)
for _, vv := range v {
req.Header.Add(k, vv)
}
}
req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "*/*") req.Header.Set("Accept", "*/*")
req.Header.Set("Connection", "keep-alive") req.Header.Set("Connection", "keep-alive")

View File

@@ -21,6 +21,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
"github.com/tidwall/sjson" "github.com/tidwall/sjson"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
@@ -97,12 +98,12 @@ func (s *GeminiWebState) Label() string {
} }
func (s *GeminiWebState) loadConversationCaches() { func (s *GeminiWebState) loadConversationCaches() {
if path := s.convStorePath(); path != "" { if path := s.convPath(); path != "" {
if store, err := LoadConvStore(path); err == nil { if store, err := LoadConvStore(path); err == nil {
s.convStore = store s.convStore = store
} }
} }
if path := s.convDataPath(); path != "" { if path := s.convPath(); path != "" {
if items, index, err := LoadConvData(path); err == nil { if items, index, err := LoadConvData(path); err == nil {
s.convData = items s.convData = items
s.convIndex = index s.convIndex = index
@@ -110,20 +111,14 @@ func (s *GeminiWebState) loadConversationCaches() {
} }
} }
func (s *GeminiWebState) convStorePath() string { // convPath returns the BoltDB file path used for both account metadata and conversation data.
func (s *GeminiWebState) convPath() string {
base := s.storagePath base := s.storagePath
if base == "" { if base == "" {
base = s.accountID + ".json" // Use accountID directly as base name; ConvBoltPath will append .bolt.
base = s.accountID
} }
return ConvStorePath(base) return ConvBoltPath(base)
}
func (s *GeminiWebState) convDataPath() string {
base := s.storagePath
if base == "" {
base = s.accountID + ".json"
}
return ConvDataPath(base)
} }
func (s *GeminiWebState) GetRequestMutex() *sync.Mutex { return &s.reqMu } func (s *GeminiWebState) GetRequestMutex() *sync.Mutex { return &s.reqMu }
@@ -174,6 +169,8 @@ func (s *GeminiWebState) Refresh(ctx context.Context) error {
s.client.Cookies["__Secure-1PSIDTS"] = newTS s.client.Cookies["__Secure-1PSIDTS"] = newTS
} }
s.tokenMu.Unlock() s.tokenMu.Unlock()
// Detailed debug log: provider and account.
log.Debugf("gemini web account %s rotated 1PSIDTS: %s", s.accountID, MaskToken28(newTS))
} }
s.lastRefresh = time.Now() s.lastRefresh = time.Now()
return nil return nil
@@ -405,7 +402,7 @@ func (s *GeminiWebState) persistConversation(modelName string, prep *geminiWebPr
storeSnapshot[k] = cp storeSnapshot[k] = cp
} }
s.convMu.Unlock() s.convMu.Unlock()
_ = SaveConvStore(s.convStorePath(), storeSnapshot) _ = SaveConvStore(s.convPath(), storeSnapshot)
} }
if !s.useReusableContext() { if !s.useReusableContext() {
@@ -433,7 +430,7 @@ func (s *GeminiWebState) persistConversation(modelName string, prep *geminiWebPr
indexSnapshot[k] = v indexSnapshot[k] = v
} }
s.convMu.Unlock() s.convMu.Unlock()
_ = SaveConvData(s.convDataPath(), dataSnapshot, indexSnapshot) _ = SaveConvData(s.convPath(), dataSnapshot, indexSnapshot)
} }
func (s *GeminiWebState) addAPIResponseData(ctx context.Context, line []byte) { func (s *GeminiWebState) addAPIResponseData(ctx context.Context, line []byte) {
@@ -570,19 +567,9 @@ func HashConversation(clientID, model string, msgs []StoredMessage) string {
return Sha256Hex(b.String()) return Sha256Hex(b.String())
} }
// ConvStorePath returns the path for account-level metadata persistence based on token file path. // ConvBoltPath returns the BoltDB file path used for both account metadata and conversation data.
func ConvStorePath(tokenFilePath string) string { // Different logical datasets are kept in separate buckets within this single DB file.
wd, err := os.Getwd() func ConvBoltPath(tokenFilePath string) string {
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")
}
// ConvDataPath returns the path for full conversation persistence based on token file path.
func ConvDataPath(tokenFilePath string) string {
wd, err := os.Getwd() wd, err := os.Getwd()
if err != nil || wd == "" { if err != nil || wd == "" {
wd = "." wd = "."

View File

@@ -286,7 +286,8 @@ func (m *Manager) executeWithProvider(ctx context.Context, provider string, req
} else if accountType == "oauth" { } else if accountType == "oauth" {
log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model) log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)
} else if accountType == "cookie" { } else if accountType == "cookie" {
log.Debugf("Use Cookie %s for model %s", util.HideAPIKey(accountInfo), req.Model) // Only Gemini Web uses cookie; print stable account label as-is.
log.Debugf("Use Cookie %s for model %s", accountInfo, req.Model)
} }
tried[auth.ID] = struct{}{} tried[auth.ID] = struct{}{}
@@ -333,7 +334,7 @@ func (m *Manager) executeCountWithProvider(ctx context.Context, provider string,
} else if accountType == "oauth" { } else if accountType == "oauth" {
log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model) log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)
} else if accountType == "cookie" { } else if accountType == "cookie" {
log.Debugf("Use Cookie %s for model %s", util.HideAPIKey(accountInfo), req.Model) log.Debugf("Use Cookie %s for model %s", accountInfo, req.Model)
} }
tried[auth.ID] = struct{}{} tried[auth.ID] = struct{}{}
@@ -380,7 +381,7 @@ func (m *Manager) executeStreamWithProvider(ctx context.Context, provider string
} else if accountType == "oauth" { } else if accountType == "oauth" {
log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model) log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)
} else if accountType == "cookie" { } else if accountType == "cookie" {
log.Debugf("Use Cookie %s for model %s", util.HideAPIKey(accountInfo), req.Model) log.Debugf("Use Cookie %s for model %s", accountInfo, req.Model)
} }
tried[auth.ID] = struct{}{} tried[auth.ID] = struct{}{}

View File

@@ -129,6 +129,13 @@ func (a *Auth) AccountInfo() (string, string) {
return "", "" return "", ""
} }
if strings.ToLower(a.Provider) == "gemini-web" { if strings.ToLower(a.Provider) == "gemini-web" {
// Prefer explicit label written into auth file (e.g., gemini-web-<hash>)
if a.Metadata != nil {
if v, ok := a.Metadata["label"].(string); ok && strings.TrimSpace(v) != "" {
return "cookie", strings.TrimSpace(v)
}
}
// Minimal fallback to cookie value for backward compatibility
if a.Metadata != nil { if a.Metadata != nil {
if v, ok := a.Metadata["secure_1psid"].(string); ok && v != "" { if v, ok := a.Metadata["secure_1psid"].(string); ok && v != "" {
return "cookie", v return "cookie", v
@@ -137,14 +144,6 @@ func (a *Auth) AccountInfo() (string, string) {
return "cookie", v return "cookie", v
} }
} }
if a.Attributes != nil {
if v := a.Attributes["secure_1psid"]; v != "" {
return "cookie", v
}
if v := a.Attributes["api_key"]; v != "" {
return "cookie", v
}
}
} }
if a.Metadata != nil { if a.Metadata != nil {
if v, ok := a.Metadata["email"].(string); ok { if v, ok := a.Metadata["email"].(string); ok {