Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9761ac5045 | ||
|
|
8fa52e9d31 | ||
|
|
80b6a95eba | ||
|
|
96cebd2a35 | ||
|
|
fc103f6c17 | ||
|
|
a45d2109f3 | ||
|
|
7a30e65175 | ||
|
|
c63dc7fe2f | ||
|
|
830b51c75b | ||
|
|
2a274d4a08 | ||
|
|
20f3e62529 | ||
|
|
7f2e2fee56 | ||
|
|
9810834f20 | ||
|
|
0d4cb9e9fb |
@@ -664,7 +664,7 @@ These endpoints initiate provider login flows and return a URL to open in a brow
|
|||||||
```bash
|
```bash
|
||||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
-H 'Content-Type: application/json' \
|
-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
|
http://localhost:8317/v0/management/gemini-web-token
|
||||||
```
|
```
|
||||||
- Response:
|
- Response:
|
||||||
|
|||||||
@@ -664,7 +664,7 @@
|
|||||||
```bash
|
```bash
|
||||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
-H 'Content-Type: application/json' \
|
-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
|
http://localhost:8317/v0/management/gemini-web-token
|
||||||
```
|
```
|
||||||
- 响应:
|
- 响应:
|
||||||
|
|||||||
@@ -689,6 +689,7 @@ func (h *Handler) CreateGeminiWebToken(c *gin.Context) {
|
|||||||
var payload struct {
|
var payload struct {
|
||||||
Secure1PSID string `json:"secure_1psid"`
|
Secure1PSID string `json:"secure_1psid"`
|
||||||
Secure1PSIDTS string `json:"secure_1psidts"`
|
Secure1PSIDTS string `json:"secure_1psidts"`
|
||||||
|
Label string `json:"label"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
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.Secure1PSID = strings.TrimSpace(payload.Secure1PSID)
|
||||||
payload.Secure1PSIDTS = strings.TrimSpace(payload.Secure1PSIDTS)
|
payload.Secure1PSIDTS = strings.TrimSpace(payload.Secure1PSIDTS)
|
||||||
|
payload.Label = strings.TrimSpace(payload.Label)
|
||||||
if payload.Secure1PSID == "" {
|
if payload.Secure1PSID == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "secure_1psid is required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "secure_1psid is required"})
|
||||||
return
|
return
|
||||||
@@ -704,6 +706,10 @@ func (h *Handler) CreateGeminiWebToken(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "secure_1psidts is required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "secure_1psidts is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if payload.Label == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "label is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
sha := sha256.New()
|
sha := sha256.New()
|
||||||
sha.Write([]byte(payload.Secure1PSID))
|
sha.Write([]byte(payload.Secure1PSID))
|
||||||
@@ -713,7 +719,10 @@ func (h *Handler) CreateGeminiWebToken(c *gin.Context) {
|
|||||||
tokenStorage := &geminiAuth.GeminiWebTokenStorage{
|
tokenStorage := &geminiAuth.GeminiWebTokenStorage{
|
||||||
Secure1PSID: payload.Secure1PSID,
|
Secure1PSID: payload.Secure1PSID,
|
||||||
Secure1PSIDTS: payload.Secure1PSIDTS,
|
Secure1PSIDTS: payload.Secure1PSIDTS,
|
||||||
|
Label: payload.Label,
|
||||||
}
|
}
|
||||||
|
// 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",
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,42 +6,134 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
"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/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DoGeminiWebAuth handles the process of creating a Gemini Web token file.
|
// 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) {
|
func DoGeminiWebAuth(cfg *config.Config) {
|
||||||
|
var secure1psid, secure1psidts, email string
|
||||||
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
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: ")
|
// Parse K=V cookie pairs separated by ';'
|
||||||
secure1psid, _ := reader.ReadString('\n')
|
cookieMap := make(map[string]string)
|
||||||
secure1psid = strings.TrimSpace(secure1psid)
|
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 == "" {
|
if secure1psid == "" {
|
||||||
log.Fatal("The __Secure-1PSID value cannot be empty.")
|
if !isMacOS {
|
||||||
return
|
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 == "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
if isMacOS {
|
||||||
tokenStorage := &gemini.GeminiWebTokenStorage{
|
fmt.Print("Enter your account email: ")
|
||||||
Secure1PSID: secure1psid,
|
v, _ := reader.ReadString('\n')
|
||||||
Secure1PSIDTS: secure1psidts,
|
email = strings.TrimSpace(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a filename based on the SHA256 hash of the PSID
|
// Generate a filename based on the SHA256 hash of the PSID
|
||||||
@@ -49,6 +141,26 @@ 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])
|
||||||
|
|
||||||
|
// 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{
|
record := &sdkAuth.TokenRecord{
|
||||||
Provider: "gemini-web",
|
Provider: "gemini-web",
|
||||||
FileName: fileName,
|
FileName: fileName,
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -97,8 +95,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
|
||||||
@@ -122,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 {
|
if len(extraCookies) > 0 {
|
||||||
trySets = append(trySets, extraCookies)
|
trySets = append(trySets, extraCookies)
|
||||||
}
|
}
|
||||||
@@ -172,18 +161,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 +188,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 {
|
||||||
@@ -318,7 +292,7 @@ func (c *GeminiClient) Close(delaySec float64) {
|
|||||||
c.Running = false
|
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 {
|
func (c *GeminiClient) ensureRunning() error {
|
||||||
if c.Running {
|
if c.Running {
|
||||||
return nil
|
return nil
|
||||||
@@ -431,21 +405,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 {
|
||||||
@@ -456,7 +419,7 @@ func (c *GeminiClient) generateOnce(prompt string, files []string, model Model,
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
if resp.StatusCode == 429 {
|
if resp.StatusCode == 429 {
|
||||||
// Surface 429 as TemporarilyBlocked to match Python behavior
|
// Surface 429 as TemporarilyBlocked to match reference behavior
|
||||||
c.Close(0)
|
c.Close(0)
|
||||||
return empty, &TemporarilyBlocked{GeminiError{Msg: "Too many requests. IP temporarily blocked."}}
|
return empty, &TemporarilyBlocked{GeminiError{Msg: "Too many requests. IP temporarily blocked."}}
|
||||||
}
|
}
|
||||||
@@ -536,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
|
var top []any
|
||||||
// Prefer lastTop from fallback scan; otherwise try parts[2]
|
// Prefer lastTop from fallback scan; otherwise try parts[2]
|
||||||
if len(lastTop) > 0 {
|
if len(lastTop) > 0 {
|
||||||
@@ -559,7 +522,7 @@ func (c *GeminiClient) generateOnce(prompt string, files []string, model Model,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Debug("Invalid response: control frames only; no body found")
|
// 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)
|
c.Close(0)
|
||||||
return empty, &APIError{Msg: "Failed to generate contents. Invalid response data received."}
|
return empty, &APIError{Msg: "Failed to generate contents. Invalid response data received."}
|
||||||
}
|
}
|
||||||
@@ -782,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.
|
// 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) {
|
func extractErrorCode(top []any) (int, bool) {
|
||||||
if len(top) == 0 {
|
if len(top) == 0 {
|
||||||
return 0, false
|
return 0, false
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -55,7 +52,7 @@ func (i Image) Save(path string, filename string, cookies map[string]string, ver
|
|||||||
filename = q[0]
|
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 != "" {
|
if filename != "" {
|
||||||
re := regexp.MustCompile(`^(.*\.\w+)`)
|
re := regexp.MustCompile(`^(.*\.\w+)`)
|
||||||
if m := re.FindStringSubmatch(filename); len(m) >= 2 {
|
if m := re.FindStringSubmatch(filename); len(m) >= 2 {
|
||||||
@@ -69,20 +66,11 @@ 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 (parity with the reference client behavior).
|
||||||
buildCookieHeader := func(m map[string]string) string {
|
buildCookieHeader := func(m map[string]string) string {
|
||||||
if len(m) == 0 {
|
if len(m) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,33 +98,27 @@ func (s *GeminiWebState) Label() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *GeminiWebState) loadConversationCaches() {
|
func (s *GeminiWebState) loadConversationCaches() {
|
||||||
if path := s.convStorePath(); path != "" {
|
path := s.convPath()
|
||||||
if store, err := LoadConvStore(path); err == nil {
|
if path == "" {
|
||||||
s.convStore = store
|
return
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if path := s.convDataPath(); path != "" {
|
if store, err := LoadConvStore(path); err == nil {
|
||||||
if items, index, err := LoadConvData(path); err == nil {
|
s.convStore = store
|
||||||
s.convData = items
|
}
|
||||||
s.convIndex = index
|
if items, index, err := LoadConvData(path); err == nil {
|
||||||
}
|
s.convData = items
|
||||||
|
s.convIndex = index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = "."
|
||||||
|
|||||||
@@ -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{}{}
|
||||||
|
|||||||
@@ -57,21 +57,32 @@ func isAuthBlockedForModel(auth *Auth, model string, now time.Time) bool {
|
|||||||
if auth.Disabled || auth.Status == StatusDisabled {
|
if auth.Disabled || auth.Status == StatusDisabled {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if model != "" && len(auth.ModelStates) > 0 {
|
// If a specific model is requested, prefer its per-model state over any aggregated
|
||||||
if state, ok := auth.ModelStates[model]; ok && state != nil {
|
// auth-level unavailable flag. This prevents a failure on one model (e.g., 429 quota)
|
||||||
if state.Status == StatusDisabled {
|
// from blocking other models of the same provider that have no errors.
|
||||||
return true
|
if model != "" {
|
||||||
}
|
if len(auth.ModelStates) > 0 {
|
||||||
if state.Unavailable {
|
if state, ok := auth.ModelStates[model]; ok && state != nil {
|
||||||
if state.NextRetryAfter.IsZero() {
|
if state.Status == StatusDisabled {
|
||||||
return false
|
|
||||||
}
|
|
||||||
if state.NextRetryAfter.After(now) {
|
|
||||||
return true
|
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) {
|
if auth.Unavailable && auth.NextRetryAfter.After(now) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user