Compare commits

...

8 Commits

Author SHA1 Message Date
Luis Pater
9761ac5045 feat(auth, docs): add label support for Gemini web token management
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
- Added `label` field to the management API for better token identification.
- Updated request payload and validation logic to include `label` as a required field.
- Adjusted documentation (`MANAGEMENT_API.md`, `MANAGEMENT_API_CN.md`) to reflect changes.
2025-09-25 22:12:50 +08:00
Luis Pater
8fa52e9d31 feat(auth): enhance Gemini web auth with macOS support and input fallback
- Added detection for macOS to adjust behavior for cookie input.
- Improved fallback prompts for missing cookies and email inputs.
2025-09-25 21:57:52 +08:00
Luis Pater
80b6a95eba Merge pull request #65 from router-for-me/gemini-web
feat(auth): Improve Gemini web auth with email label detection
2025-09-25 20:50:04 +08:00
hkfires
96cebd2a35 feat(auth): add interactive prompts to Gemini web auth flow 2025-09-25 20:39:15 +08:00
Luis Pater
fc103f6c17 Merge pull request #64 from router-for-me/bugfix
fix(auth): Scope unavailability checks to specific models
2025-09-25 20:33:26 +08:00
hkfires
a45d2109f3 feat(auth): Improve Gemini web auth with email label detection 2025-09-25 20:17:47 +08:00
hkfires
7a30e65175 refactor(gemini-web): Remove file-based PSIDTS cookie caching 2025-09-25 18:52:31 +08:00
hkfires
c63dc7fe2f fix(auth): Scope unavailability checks to specific models 2025-09-25 18:51:50 +08:00
8 changed files with 173 additions and 62 deletions

View File

@@ -664,7 +664,7 @@ These endpoints initiate provider login flows and return a URL to open in a brow
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-H 'Content-Type: application/json' \
-d '{"secure_1psid": "<__Secure-1PSID>", "secure_1psidts": "<__Secure-1PSIDTS>"}' \
-d '{"secure_1psid": "<__Secure-1PSID>", "secure_1psidts": "<__Secure-1PSIDTS>", "label": "<LABEL>"}' \
http://localhost:8317/v0/management/gemini-web-token
```
- Response:

View File

@@ -664,7 +664,7 @@
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-H 'Content-Type: application/json' \
-d '{"secure_1psid": "<__Secure-1PSID>", "secure_1psidts": "<__Secure-1PSIDTS>"}' \
-d '{"secure_1psid": "<__Secure-1PSID>", "secure_1psidts": "<__Secure-1PSIDTS>", "label": "<LABEL>"}' \
http://localhost:8317/v0/management/gemini-web-token
```
- 响应:

View File

@@ -689,6 +689,7 @@ func (h *Handler) CreateGeminiWebToken(c *gin.Context) {
var payload struct {
Secure1PSID string `json:"secure_1psid"`
Secure1PSIDTS string `json:"secure_1psidts"`
Label string `json:"label"`
}
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
@@ -696,6 +697,7 @@ func (h *Handler) CreateGeminiWebToken(c *gin.Context) {
}
payload.Secure1PSID = strings.TrimSpace(payload.Secure1PSID)
payload.Secure1PSIDTS = strings.TrimSpace(payload.Secure1PSIDTS)
payload.Label = strings.TrimSpace(payload.Label)
if payload.Secure1PSID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "secure_1psid is required"})
return
@@ -704,6 +706,10 @@ func (h *Handler) CreateGeminiWebToken(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "secure_1psidts is required"})
return
}
if payload.Label == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "label is required"})
return
}
sha := sha256.New()
sha.Write([]byte(payload.Secure1PSID))
@@ -713,6 +719,7 @@ func (h *Handler) CreateGeminiWebToken(c *gin.Context) {
tokenStorage := &geminiAuth.GeminiWebTokenStorage{
Secure1PSID: payload.Secure1PSID,
Secure1PSIDTS: payload.Secure1PSIDTS,
Label: payload.Label,
}
// Provide a stable label (gemini-web-<hash>) for logging and identification
tokenStorage.Label = strings.TrimSuffix(fileName, ".json")

View File

@@ -6,42 +6,134 @@ import (
"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"
log "github.com/sirupsen/logrus"
)
// DoGeminiWebAuth handles the process of creating a Gemini Web token file.
// It prompts the user for their cookie values and saves them to a JSON 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")
if !isMacOS {
fmt.Print("Paste your full Google Cookie and press Enter: ")
rawCookie, _ := reader.ReadString('\n')
rawCookie = strings.TrimSpace(rawCookie)
if rawCookie == "" {
log.Fatal("Cookie cannot be empty")
return
}
fmt.Print("Enter your __Secure-1PSID cookie value: ")
secure1psid, _ := reader.ReadString('\n')
secure1psid = strings.TrimSpace(secure1psid)
// 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, 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.Printf("Failed to create request: %v\n", err)
return
}
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, err := httpClient.Do(req)
if err != nil {
fmt.Printf("Request to ListAccounts failed: %v\n", 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.Printf("Failed to parse ListAccounts response: %v\n", 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 == "" {
log.Fatal("The __Secure-1PSID value cannot be empty.")
return
if !isMacOS {
fmt.Print("Cookie missing __Secure-1PSID. ")
}
fmt.Print("Enter __Secure-1PSID: ")
v, _ := reader.ReadString('\n')
secure1psid = strings.TrimSpace(v)
}
fmt.Print("Enter your __Secure-1PSIDTS cookie value: ")
secure1psidts, _ := reader.ReadString('\n')
secure1psidts = strings.TrimSpace(secure1psidts)
if secure1psidts == "" {
fmt.Println("The __Secure-1PSIDTS value cannot be empty.")
if !isMacOS {
fmt.Print("Cookie missing __Secure-1PSID. ")
}
fmt.Print("Enter __Secure-1PSIDTS: ")
v, _ := reader.ReadString('\n')
secure1psidts = strings.TrimSpace(v)
}
if secure1psid == "" || secure1psidts == "" {
log.Fatal("__Secure-1PSID and __Secure-1PSIDTS cannot be empty")
return
}
tokenStorage := &gemini.GeminiWebTokenStorage{
Secure1PSID: secure1psid,
Secure1PSIDTS: secure1psidts,
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
@@ -49,9 +141,25 @@ func DoGeminiWebAuth(cfg *config.Config) {
hasher.Write([]byte(secure1psid))
hash := hex.EncodeToString(hasher.Sum(nil))
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")
// Decide label: prefer email; fallback prompt then file name without .json
defaultLabel := strings.TrimSuffix(fileName, ".json")
label := email
if label == "" {
fmt.Printf("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 := &sdkAuth.TokenRecord{
Provider: "gemini-web",

View File

@@ -9,8 +9,6 @@ import (
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
@@ -126,19 +124,6 @@ func getAccessToken(baseCookies map[string]string, proxy string, verbose bool, i
}
}
cacheDir := "temp"
_ = os.MkdirAll(cacheDir, 0o755)
if v1, ok1 := baseCookies["__Secure-1PSID"]; ok1 {
cacheFile := filepath.Join(cacheDir, ".cached_1psidts_"+v1+".txt")
if b, err := os.ReadFile(cacheFile); err == nil {
cv := strings.TrimSpace(string(b))
if cv != "" {
merged := map[string]string{"__Secure-1PSID": v1, "__Secure-1PSIDTS": cv}
trySets = append(trySets, merged)
}
}
}
if len(extraCookies) > 0 {
trySets = append(trySets, extraCookies)
}
@@ -307,7 +292,7 @@ func (c *GeminiClient) Close(delaySec float64) {
c.Running = false
}
// ensureRunning mirrors the Python decorator behavior and retries on APIError.
// ensureRunning mirrors the decorator behavior and retries on APIError.
func (c *GeminiClient) ensureRunning() error {
if c.Running {
return nil
@@ -434,7 +419,7 @@ func (c *GeminiClient) generateOnce(prompt string, files []string, model Model,
}()
if resp.StatusCode == 429 {
// Surface 429 as TemporarilyBlocked to match Python behavior
// Surface 429 as TemporarilyBlocked to match reference behavior
c.Close(0)
return empty, &TemporarilyBlocked{GeminiError{Msg: "Too many requests. IP temporarily blocked."}}
}
@@ -514,7 +499,7 @@ func (c *GeminiClient) generateOnce(prompt string, files []string, model Model,
}
}
}
// Parse nested error code to align with Python mapping
// 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 {
@@ -537,7 +522,7 @@ func (c *GeminiClient) generateOnce(prompt string, files []string, model Model,
}
}
// Debug("Invalid response: control frames only; no body found")
// Close the client to force re-initialization on next request (parity with Python client behavior)
// 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."}
}
@@ -760,7 +745,7 @@ func (c *GeminiClient) generateOnce(prompt string, files []string, model Model,
}
// extractErrorCode attempts to navigate the known nested error structure and fetch the integer code.
// Mirrors Python path: response_json[0][5][2][0][1][0]
// Mirrors reference path: response_json[0][5][2][0][1][0]
func extractErrorCode(top []any) (int, bool) {
if len(top) == 0 {
return 0, false

View File

@@ -52,7 +52,7 @@ func (i Image) Save(path string, filename string, cookies map[string]string, ver
filename = q[0]
}
}
// Regex validation (align with Python: ^(.*\.\w+)) to extract name with extension.
// Regex validation (pattern: ^(.*\.\w+)) to extract name with extension.
if filename != "" {
re := regexp.MustCompile(`^(.*\.\w+)`)
if m := re.FindStringSubmatch(filename); len(m) >= 2 {
@@ -70,7 +70,7 @@ func (i Image) Save(path string, filename string, cookies map[string]string, ver
client := newHTTPClient(httpOptions{ProxyURL: i.Proxy, Insecure: insecure, FollowRedirects: true})
client.Timeout = 120 * time.Second
// Helper to set raw Cookie header using provided cookies (to mirror Python client behavior).
// 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 ""

View File

@@ -98,16 +98,16 @@ func (s *GeminiWebState) Label() string {
}
func (s *GeminiWebState) loadConversationCaches() {
if path := s.convPath(); path != "" {
if store, err := LoadConvStore(path); err == nil {
s.convStore = store
}
path := s.convPath()
if path == "" {
return
}
if path := s.convPath(); path != "" {
if items, index, err := LoadConvData(path); err == nil {
s.convData = items
s.convIndex = index
}
if store, err := LoadConvStore(path); err == nil {
s.convStore = store
}
if items, index, err := LoadConvData(path); err == nil {
s.convData = items
s.convIndex = index
}
}

View File

@@ -57,21 +57,32 @@ func isAuthBlockedForModel(auth *Auth, model string, now time.Time) bool {
if auth.Disabled || auth.Status == StatusDisabled {
return true
}
if model != "" && len(auth.ModelStates) > 0 {
if state, ok := auth.ModelStates[model]; ok && state != nil {
if state.Status == StatusDisabled {
return true
}
if state.Unavailable {
if state.NextRetryAfter.IsZero() {
return false
}
if state.NextRetryAfter.After(now) {
// If a specific model is requested, prefer its per-model state over any aggregated
// auth-level unavailable flag. This prevents a failure on one model (e.g., 429 quota)
// from blocking other models of the same provider that have no errors.
if model != "" {
if len(auth.ModelStates) > 0 {
if state, ok := auth.ModelStates[model]; ok && state != nil {
if state.Status == StatusDisabled {
return true
}
if state.Unavailable {
if state.NextRetryAfter.IsZero() {
return false
}
if state.NextRetryAfter.After(now) {
return true
}
}
// Explicit state exists and is not blocking.
return false
}
}
// No explicit state for this model; do not block based on aggregated
// auth-level unavailable status. Allow trying this model.
return false
}
// No specific model context: fall back to auth-level unavailable window.
if auth.Unavailable && auth.NextRetryAfter.After(now) {
return true
}