History

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

Gemini-web

Luis Pater committed 1 year agov6.0.2

Commit 830b51c2 parents cc8c46d 2a274d48 files · +73 −112Browse files

Unified · Split

8 files
  1. internal/api/handlers/management/auth_files.go +2 −0
  2. internal/auth/gemini/gemini-web_token.go +14 −0
  3. internal/cmd/gemini-web_auth.go +4 −0
  4. internal/provider/gemini-web/client.go +21 −43
  5. internal/provider/gemini-web/media.go +6 −30
  6. internal/provider/gemini-web/state.go +15 −28
  7. sdk/cliproxy/auth/manager.go +4 −3
  8. sdk/cliproxy/auth/types.go +7 −8
internal/api/handlers/management/auth_files.go+2−0
@@ -714,6 +714,8 @@ func (h *Handler) CreateGeminiWebToken(c *gin.Context) { · 713 unmodified lines
714 Secure1PSID: payload.Secure1PSID,714 Secure1PSID: payload.Secure1PSID,
715 Secure1PSIDTS: payload.Secure1PSIDTS,715 Secure1PSIDTS: payload.Secure1PSIDTS,
716 }716 }
717 // Provide a stable label (gemini-web-<hash>) for logging and identification
718 tokenStorage.Label = strings.TrimSuffix(fileName, ".json")
717 719
718 record := &sdkAuth.TokenRecord{720 record := &sdkAuth.TokenRecord{
719 Provider: "gemini-web",721 Provider: "gemini-web",
internal/auth/gemini/gemini-web_token.go+14−0
@@ -8,6 +8,7 @@ import ( · 7 unmodified lines
8 "fmt"8 "fmt"
9 "os"9 "os"
10 "path/filepath"10 "path/filepath"
11 "strings"
11 "time"12 "time"
12 13
13 "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"14 "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
@@ -20,12 +21,25 @@ type GeminiWebTokenStorage struct { · 6 unmodified lines
20 Secure1PSIDTS string `json:"secure_1psidts"`21 Secure1PSIDTS string `json:"secure_1psidts"`
21 Type string `json:"type"`22 Type string `json:"type"`
22 LastRefresh string `json:"last_refresh,omitempty"`23 LastRefresh string `json:"last_refresh,omitempty"`
24 // Label is a stable account identifier used for logging, e.g. "gemini-web-<hash>".
25 // It is derived from the auth file name when not explicitly set.
26 Label string `json:"label,omitempty"`
23}27}
24 28
25// SaveTokenToFile serializes the Gemini Web token storage to a JSON file.29// SaveTokenToFile serializes the Gemini Web token storage to a JSON file.
26func (ts *GeminiWebTokenStorage) SaveTokenToFile(authFilePath string) error {30func (ts *GeminiWebTokenStorage) SaveTokenToFile(authFilePath string) error {
27 misc.LogSavingCredentials(authFilePath)31 misc.LogSavingCredentials(authFilePath)
28 ts.Type = "gemini-web"32 ts.Type = "gemini-web"
33 // Auto-derive a stable label from the file name if missing.
34 if ts.Label == "" {
35 base := filepath.Base(authFilePath)
36 if strings.HasSuffix(strings.ToLower(base), ".json") {
37 base = strings.TrimSuffix(base, filepath.Ext(base))
38 }
39 if base != "" {
40 ts.Label = base
41 }
42 }
29 if ts.LastRefresh == "" {43 if ts.LastRefresh == "" {
30 ts.LastRefresh = time.Now().Format(time.RFC3339)44 ts.LastRefresh = time.Now().Format(time.RFC3339)
31 }45 }
internal/cmd/gemini-web_auth.go+4−0
@@ -49,6 +49,10 @@ func DoGeminiWebAuth(cfg *config.Config) { · 48 unmodified lines
49 hasher.Write([]byte(secure1psid))49 hasher.Write([]byte(secure1psid))
50 hash := hex.EncodeToString(hasher.Sum(nil))50 hash := hex.EncodeToString(hasher.Sum(nil))
51 fileName := fmt.Sprintf("gemini-web-%s.json", hash[:16])51 fileName := fmt.Sprintf("gemini-web-%s.json", hash[:16])
52 // Set a stable label for logging, e.g. gemini-web-<hash>
53 if tokenStorage != nil {
54 tokenStorage.Label = strings.TrimSuffix(fileName, ".json")
55 }
52 record := &sdkAuth.TokenRecord{56 record := &sdkAuth.TokenRecord{
53 Provider: "gemini-web",57 Provider: "gemini-web",
54 FileName: fileName,58 FileName: fileName,
internal/provider/gemini-web/client.go+21−43
@@ -97,8 +97,12 @@ func getAccessToken(baseCookies map[string]string, proxy string, verbose bool, i · 96 unmodified lines
97 {97 {
98 client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true})98 client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true})
99 req, _ := http.NewRequest(http.MethodGet, EndpointGoogle, nil)99 req, _ := http.NewRequest(http.MethodGet, EndpointGoogle, nil)
100 resp, _ := client.Do(req)100 resp, err := client.Do(req)
101 if resp != nil {101 if err != nil {
102 if verbose {
103 log.Debugf("priming google cookies failed: %v", err)
104 }
105 } else if resp != nil {
102 if u, err := url.Parse(EndpointGoogle); err == nil {106 if u, err := url.Parse(EndpointGoogle); err == nil {
103 for _, c := range client.Jar.Cookies(u) {107 for _, c := range client.Jar.Cookies(u) {
104 extraCookies[c.Name] = c.Value108 extraCookies[c.Name] = c.Value
@@ -172,18 +176,10 @@ func rotate1PSIDTS(cookies map[string]string, proxy string, insecure bool) (stri · 67 unmodified lines
172 return "", &AuthError{Msg: "__Secure-1PSID missing"}176 return "", &AuthError{Msg: "__Secure-1PSID missing"}
173 }177 }
174 178
175 tr := &http.Transport{}179 // Reuse shared HTTP client helper for consistency.
176 if proxy != "" {180 client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true})
177 if pu, err := url.Parse(proxy); err == nil {
178 tr.Proxy = http.ProxyURL(pu)
179 }
180 }
181 if insecure {
182 tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
183 }
184 client := &http.Client{Transport: tr, Timeout: 60 * time.Second}
185 181
186 req, _ := http.NewRequest(http.MethodPost, EndpointRotateCookies, io.NopCloser(stringsReader("[000,\"-0000000000000000000\"]")))182 req, _ := http.NewRequest(http.MethodPost, EndpointRotateCookies, strings.NewReader("[000,\"-0000000000000000000\"]"))
187 applyHeaders(req, HeadersRotateCookies)183 applyHeaders(req, HeadersRotateCookies)
188 applyCookies(req, cookies)184 applyCookies(req, cookies)
189 185
@@ -207,25 +203,18 @@ func rotate1PSIDTS(cookies map[string]string, proxy string, insecure bool) (stri · 17 unmodified lines
207 return c.Value, nil203 return c.Value, nil
208 }204 }
209 }205 }
210 return "", nil206 // Fallback: check cookie jar in case the Set-Cookie was on a redirect hop
211}207 if u, err := url.Parse(EndpointRotateCookies); err == nil && client.Jar != nil {
212 208 for _, c := range client.Jar.Cookies(u) {
213type constReader struct {209 if c.Name == "__Secure-1PSIDTS" && c.Value != "" {
214 s string210 return c.Value, nil
215 i int211 }
216}212 }
217
218func (r *constReader) Read(p []byte) (int, error) {
219 if r.i >= len(r.s) {
220 return 0, io.EOF
221 }213 }
222 n := copy(p, r.s[r.i:])214 return "", nil
223 r.i += n
224 return n, nil
225}215}
226 216
227func stringsReader(s string) io.Reader { return &constReader{s: s} }217// MaskToken28 masks a sensitive token for safe logging. Keep middle partially visible.
228
229func MaskToken28(s string) string {218func MaskToken28(s string) string {
230 n := len(s)219 n := len(s)
231 if n == 0 {220 if n == 0 {
@@ -431,21 +420,10 @@ func (c *GeminiClient) generateOnce(prompt string, files []string, model Model, · 199 unmodified lines
431 form.Set("f.req", string(outerJSON))420 form.Set("f.req", string(outerJSON))
432 421
433 req, _ := http.NewRequest(http.MethodPost, EndpointGenerate, strings.NewReader(form.Encode()))422 req, _ := http.NewRequest(http.MethodPost, EndpointGenerate, strings.NewReader(form.Encode()))
434 // headers423 applyHeaders(req, HeadersGemini)
435 for k, v := range HeadersGemini {424 applyHeaders(req, model.ModelHeader)
436 for _, vv := range v {
437 req.Header.Add(k, vv)
438 }
439 }
440 for k, v := range model.ModelHeader {
441 for _, vv := range v {
442 req.Header.Add(k, vv)
443 }
444 }
445 req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")425 req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
446 for k, v := range c.Cookies {426 applyCookies(req, c.Cookies)
447 req.AddCookie(&http.Cookie{Name: k, Value: v})
448 }
449 427
450 resp, err := c.httpClient.Do(req)428 resp, err := c.httpClient.Do(req)
451 if err != nil {429 if err != nil {
internal/provider/gemini-web/media.go+6−30
@@ -2,7 +2,6 @@ package geminiwebapi · 1 unmodified line
2 2
3import (3import (
4 "bytes"4 "bytes"
5 "crypto/tls"
6 "encoding/base64"5 "encoding/base64"
7 "encoding/json"6 "encoding/json"
8 "errors"7 "errors"
@@ -11,8 +10,6 @@ import ( · 2 unmodified lines
11 "math"10 "math"
12 "mime/multipart"11 "mime/multipart"
13 "net/http"12 "net/http"
14 "net/http/cookiejar"
15 "net/url"
16 "os"13 "os"
17 "path/filepath"14 "path/filepath"
18 "regexp"15 "regexp"
@@ -69,18 +66,9 @@ func (i Image) Save(path string, filename string, cookies map[string]string, ver · 50 unmodified lines
69 }66 }
70 }67 }
71 }68 }
72 // Build client with cookie jar so cookies persist across redirects.69 // Build client using shared helper to keep proxy/TLS behavior consistent.
73 tr := &http.Transport{}70 client := newHTTPClient(httpOptions{ProxyURL: i.Proxy, Insecure: insecure, FollowRedirects: true})
74 if i.Proxy != "" {71 client.Timeout = 120 * time.Second
75 if pu, err := url.Parse(i.Proxy); err == nil {
76 tr.Proxy = http.ProxyURL(pu)
77 }
78 }
79 if insecure {
80 tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
81 }
82 jar, _ := cookiejar.New(nil)
83 client := &http.Client{Transport: tr, Timeout: 120 * time.Second, Jar: jar}
84 72
85 // Helper to set raw Cookie header using provided cookies (to mirror Python client behavior).73 // Helper to set raw Cookie header using provided cookies (to mirror Python client behavior).
86 buildCookieHeader := func(m map[string]string) string {74 buildCookieHeader := func(m map[string]string) string {
@@ -352,23 +340,11 @@ func uploadFile(path string, proxy string, insecure bool) (string, error) { · 265 unmodified lines
352 }340 }
353 _ = mw.Close()341 _ = mw.Close()
354 342
355 tr := &http.Transport{}343 client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true})
356 if proxy != "" {344 client.Timeout = 300 * time.Second
357 if pu, errParse := url.Parse(proxy); errParse == nil {
358 tr.Proxy = http.ProxyURL(pu)
359 }
360 }
361 if insecure {
362 tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
363 }
364 client := &http.Client{Transport: tr, Timeout: 300 * time.Second}
365 345
366 req, _ := http.NewRequest(http.MethodPost, EndpointUpload, &buf)346 req, _ := http.NewRequest(http.MethodPost, EndpointUpload, &buf)
367 for k, v := range HeadersUpload {347 applyHeaders(req, HeadersUpload)
368 for _, vv := range v {
369 req.Header.Add(k, vv)
370 }
371 }
372 req.Header.Set("Content-Type", mw.FormDataContentType())348 req.Header.Set("Content-Type", mw.FormDataContentType())
373 req.Header.Set("Accept", "*/*")349 req.Header.Set("Accept", "*/*")
374 req.Header.Set("Connection", "keep-alive")350 req.Header.Set("Connection", "keep-alive")
internal/provider/gemini-web/state.go+15−28
@@ -21,6 +21,7 @@ import ( · 20 unmodified lines
21 "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"21 "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
22 "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"22 "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
23 cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"23 cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
24 log "github.com/sirupsen/logrus"
24 "github.com/tidwall/gjson"25 "github.com/tidwall/gjson"
25 "github.com/tidwall/sjson"26 "github.com/tidwall/sjson"
26 bolt "go.etcd.io/bbolt"27 bolt "go.etcd.io/bbolt"
@@ -97,12 +98,12 @@ func (s *GeminiWebState) Label() string { · 70 unmodified lines
97}98}
98 99
99func (s *GeminiWebState) loadConversationCaches() {100func (s *GeminiWebState) loadConversationCaches() {
100 if path := s.convStorePath(); path != "" {101 if path := s.convPath(); path != "" {
101 if store, err := LoadConvStore(path); err == nil {102 if store, err := LoadConvStore(path); err == nil {
102 s.convStore = store103 s.convStore = store
103 }104 }
104 }105 }
105 if path := s.convDataPath(); path != "" {106 if path := s.convPath(); path != "" {
106 if items, index, err := LoadConvData(path); err == nil {107 if items, index, err := LoadConvData(path); err == nil {
107 s.convData = items108 s.convData = items
108 s.convIndex = index109 s.convIndex = index
@@ -110,20 +111,14 @@ func (s *GeminiWebState) loadConversationCaches() { · 1 unmodified line
110 }111 }
111}112}
112 113
113func (s *GeminiWebState) convStorePath() string {114// convPath returns the BoltDB file path used for both account metadata and conversation data.
115func (s *GeminiWebState) convPath() string {
114 base := s.storagePath116 base := s.storagePath
115 if base == "" {117 if base == "" {
116 base = s.accountID + ".json"118 // Use accountID directly as base name; ConvBoltPath will append .bolt.
119 base = s.accountID
117 }120 }
118 return ConvStorePath(base)121 return ConvBoltPath(base)
119}
120
121func (s *GeminiWebState) convDataPath() string {
122 base := s.storagePath
123 if base == "" {
124 base = s.accountID + ".json"
125 }
126 return ConvDataPath(base)
127}122}
128 123
129func (s *GeminiWebState) GetRequestMutex() *sync.Mutex { return &s.reqMu }124func (s *GeminiWebState) GetRequestMutex() *sync.Mutex { return &s.reqMu }
@@ -174,6 +169,8 @@ func (s *GeminiWebState) Refresh(ctx context.Context) error { · 44 unmodified lines
174 s.client.Cookies["__Secure-1PSIDTS"] = newTS169 s.client.Cookies["__Secure-1PSIDTS"] = newTS
175 }170 }
176 s.tokenMu.Unlock()171 s.tokenMu.Unlock()
172 // Detailed debug log: provider and account.
173 log.Debugf("gemini web account %s rotated 1PSIDTS: %s", s.accountID, MaskToken28(newTS))
177 }174 }
178 s.lastRefresh = time.Now()175 s.lastRefresh = time.Now()
179 return nil176 return nil
@@ -405,7 +402,7 @@ func (s *GeminiWebState) persistConversation(modelName string, prep *geminiWebPr · 225 unmodified lines
405 storeSnapshot[k] = cp402 storeSnapshot[k] = cp
406 }403 }
407 s.convMu.Unlock()404 s.convMu.Unlock()
408 _ = SaveConvStore(s.convStorePath(), storeSnapshot)405 _ = SaveConvStore(s.convPath(), storeSnapshot)
409 }406 }
410 407
411 if !s.useReusableContext() {408 if !s.useReusableContext() {
@@ -433,7 +430,7 @@ func (s *GeminiWebState) persistConversation(modelName string, prep *geminiWebPr · 21 unmodified lines
433 indexSnapshot[k] = v430 indexSnapshot[k] = v
434 }431 }
435 s.convMu.Unlock()432 s.convMu.Unlock()
436 _ = SaveConvData(s.convDataPath(), dataSnapshot, indexSnapshot)433 _ = SaveConvData(s.convPath(), dataSnapshot, indexSnapshot)
437}434}
438 435
439func (s *GeminiWebState) addAPIResponseData(ctx context.Context, line []byte) {436func (s *GeminiWebState) addAPIResponseData(ctx context.Context, line []byte) {
@@ -570,19 +567,9 @@ func HashConversation(clientID, model string, msgs []StoredMessage) string { · 130 unmodified lines
570 return Sha256Hex(b.String())567 return Sha256Hex(b.String())
571}568}
572 569
573// ConvStorePath returns the path for account-level metadata persistence based on token file path.570// ConvBoltPath returns the BoltDB file path used for both account metadata and conversation data.
574func ConvStorePath(tokenFilePath string) string {571// Different logical datasets are kept in separate buckets within this single DB file.
575 wd, err := os.Getwd()572func ConvBoltPath(tokenFilePath string) string {
576 if err != nil || wd == "" {
577 wd = "."
578 }
579 convDir := filepath.Join(wd, "conv")
580 base := strings.TrimSuffix(filepath.Base(tokenFilePath), filepath.Ext(tokenFilePath))
581 return filepath.Join(convDir, base+".bolt")
582}
583
584// ConvDataPath returns the path for full conversation persistence based on token file path.
585func ConvDataPath(tokenFilePath string) string {
586 wd, err := os.Getwd()573 wd, err := os.Getwd()
587 if err != nil || wd == "" {574 if err != nil || wd == "" {
588 wd = "."575 wd = "."
sdk/cliproxy/auth/manager.go+4−3
@@ -286,7 +286,8 @@ func (m *Manager) executeWithProvider(ctx context.Context, provider string, req · 285 unmodified lines
286 } else if accountType == "oauth" {286 } else if accountType == "oauth" {
287 log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)287 log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)
288 } else if accountType == "cookie" {288 } else if accountType == "cookie" {
289 log.Debugf("Use Cookie %s for model %s", util.HideAPIKey(accountInfo), req.Model)289 // Only Gemini Web uses cookie; print stable account label as-is.
290 log.Debugf("Use Cookie %s for model %s", accountInfo, req.Model)
290 }291 }
291 292
292 tried[auth.ID] = struct{}{}293 tried[auth.ID] = struct{}{}
@@ -333,7 +334,7 @@ func (m *Manager) executeCountWithProvider(ctx context.Context, provider string, · 40 unmodified lines
333 } else if accountType == "oauth" {334 } else if accountType == "oauth" {
334 log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)335 log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)
335 } else if accountType == "cookie" {336 } else if accountType == "cookie" {
336 log.Debugf("Use Cookie %s for model %s", util.HideAPIKey(accountInfo), req.Model)337 log.Debugf("Use Cookie %s for model %s", accountInfo, req.Model)
337 }338 }
338 339
339 tried[auth.ID] = struct{}{}340 tried[auth.ID] = struct{}{}
@@ -380,7 +381,7 @@ func (m *Manager) executeStreamWithProvider(ctx context.Context, provider string · 40 unmodified lines
380 } else if accountType == "oauth" {381 } else if accountType == "oauth" {
381 log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)382 log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)
382 } else if accountType == "cookie" {383 } else if accountType == "cookie" {
383 log.Debugf("Use Cookie %s for model %s", util.HideAPIKey(accountInfo), req.Model)384 log.Debugf("Use Cookie %s for model %s", accountInfo, req.Model)
384 }385 }
385 386
386 tried[auth.ID] = struct{}{}387 tried[auth.ID] = struct{}{}
sdk/cliproxy/auth/types.go+7−8
@@ -129,19 +129,18 @@ func (a *Auth) AccountInfo() (string, string) { · 128 unmodified lines
129 return "", ""129 return "", ""
130 }130 }
131 if strings.ToLower(a.Provider) == "gemini-web" {131 if strings.ToLower(a.Provider) == "gemini-web" {
132 // Prefer explicit label written into auth file (e.g., gemini-web-<hash>)
132 if a.Metadata != nil {133 if a.Metadata != nil {
133 if v, ok := a.Metadata["secure_1psid"].(string); ok && v != "" {134 if v, ok := a.Metadata["label"].(string); ok && strings.TrimSpace(v) != "" {
134 return "cookie", v135 return "cookie", strings.TrimSpace(v)
135 }
136 if v, ok := a.Metadata["__Secure-1PSID"].(string); ok && v != "" {
137 return "cookie", v
138 }136 }
139 }137 }
140 if a.Attributes != nil {138 // Minimal fallback to cookie value for backward compatibility
141 if v := a.Attributes["secure_1psid"]; v != "" {139 if a.Metadata != nil {
140 if v, ok := a.Metadata["secure_1psid"].(string); ok && v != "" {
142 return "cookie", v141 return "cookie", v
143 }142 }
144 if v := a.Attributes["api_key"]; v != "" {143 if v, ok := a.Metadata["__Secure-1PSID"].(string); ok && v != "" {
145 return "cookie", v144 return "cookie", v
146 }145 }
147 }146 }

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t