feat(gemini-web): squash all features and fixes for gemini-web

This commit is contained in:
hkfires
2025-09-17 20:24:23 +08:00
parent 172f282e9e
commit e4dd22b260
25 changed files with 3710 additions and 6 deletions

View File

@@ -27,5 +27,6 @@ conv/*
config.yaml config.yaml
# Development/editor # Development/editor
bin/*
.claude/* .claude/*
.vscode/* .vscode/*

2
.gitignore vendored
View File

@@ -1,5 +1,4 @@
config.yaml config.yaml
*.exe
bin/* bin/*
docs/* docs/*
logs/* logs/*
@@ -10,3 +9,4 @@ auths/*
.claude/* .claude/*
AGENTS.md AGENTS.md
CLAUDE.md CLAUDE.md
*.exe

View File

@@ -72,6 +72,7 @@ func main() {
var codexLogin bool var codexLogin bool
var claudeLogin bool var claudeLogin bool
var qwenLogin bool var qwenLogin bool
var geminiWebAuth bool
var noBrowser bool var noBrowser bool
var projectID string var projectID string
var configPath string var configPath string
@@ -81,6 +82,7 @@ func main() {
flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex using OAuth") flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex using OAuth")
flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth") flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth")
flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth") flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth")
flag.BoolVar(&geminiWebAuth, "gemini-web-auth", false, "Auth Gemini Web using cookies")
flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth") flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)") flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
flag.StringVar(&configPath, "config", "", "Configure File Path") flag.StringVar(&configPath, "config", "", "Configure File Path")
@@ -151,6 +153,8 @@ func main() {
cmd.DoClaudeLogin(cfg, options) cmd.DoClaudeLogin(cfg, options)
} else if qwenLogin { } else if qwenLogin {
cmd.DoQwenLogin(cfg, options) cmd.DoQwenLogin(cfg, options)
} else if geminiWebAuth {
cmd.DoGeminiWebAuth(cfg)
} else { } else {
// Start the main proxy service // Start the main proxy service
cmd.StartService(cfg, configFilePath) cmd.StartService(cfg, configFilePath)

View File

@@ -65,3 +65,23 @@ openai-compatibility:
models: # The models supported by the provider. models: # The models supported by the provider.
- name: "moonshotai/kimi-k2:free" # The actual model name. - name: "moonshotai/kimi-k2:free" # The actual model name.
alias: "kimi-k2" # The alias used in the API. alias: "kimi-k2" # The alias used in the API.
# Gemini Web settings
# gemini-web:
# # Conversation reuse: set to true to enable (default), false to disable.
# context: true
# # Maximum characters per single request to Gemini Web. Requests exceeding this
# # size split into chunks. Only the last chunk carries files and yields the final answer.
# max-chars-per-request: 1000000
# # Disable the short continuation hint appended to intermediate chunks
# # when splitting long prompts. Default is false (hint enabled by default).
# disable-continuation-hint: false
# # Background token auto-refresh interval seconds (defaults to 540 if unset or <= 0)
# token-refresh-seconds: 540
# # Code mode:
# # - true: enable XML wrapping hint and attach the coding-partner Gem.
# # Thought merging (<think> into visible content) applies to STREAMING only;
# # non-stream responses keep reasoning/thought parts separate for clients
# # that expect explicit reasoning fields.
# # - false: disable XML hint and keep <think> separate
# code-mode: false

View File

@@ -19,4 +19,5 @@ services:
- ./config.yaml:/CLIProxyAPI/config.yaml - ./config.yaml:/CLIProxyAPI/config.yaml
- ./auths:/root/.cli-proxy-api - ./auths:/root/.cli-proxy-api
- ./logs:/CLIProxyAPI/logs - ./logs:/CLIProxyAPI/logs
- ./conv:/CLIProxyAPI/conv
restart: unless-stopped restart: unless-stopped

View File

@@ -380,6 +380,8 @@ func (s *Server) UpdateClients(clients map[string]interfaces.Client, cfg *config
switch cl := c.(type) { switch cl := c.(type) {
case *client.GeminiCLIClient: case *client.GeminiCLIClient:
authFiles++ authFiles++
case *client.GeminiWebClient:
authFiles++
case *client.CodexClient: case *client.CodexClient:
if cl.GetAPIKey() == "" { if cl.GetAPIKey() == "" {
authFiles++ authFiles++

View File

@@ -0,0 +1,43 @@
// 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"
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"`
}
// SaveTokenToFile serializes the Gemini Web token storage to a JSON file.
func (ts *GeminiWebTokenStorage) SaveTokenToFile(authFilePath string) error {
ts.Type = "gemini-web"
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
}

View File

@@ -331,8 +331,16 @@ func (c *ClaudeClient) SendRawTokenCount(_ context.Context, _ string, _ []byte,
// Returns: // Returns:
// - error: An error if the save operation fails, nil otherwise. // - error: An error if the save operation fails, nil otherwise.
func (c *ClaudeClient) SaveTokenToFile() error { func (c *ClaudeClient) SaveTokenToFile() error {
fileName := filepath.Join(c.cfg.AuthDir, fmt.Sprintf("claude-%s.json", c.tokenStorage.(*claude.ClaudeTokenStorage).Email)) // API-key based clients don't have a file-backed token to persist.
return c.tokenStorage.SaveTokenToFile(fileName) if c.apiKeyIndex != -1 {
return nil
}
ts, ok := c.tokenStorage.(*claude.ClaudeTokenStorage)
if !ok || ts == nil || ts.Email == "" {
return nil
}
fileName := filepath.Join(c.cfg.AuthDir, fmt.Sprintf("claude-%s.json", ts.Email))
return ts.SaveTokenToFile(fileName)
} }
// RefreshTokens refreshes the access tokens if they have expired. // RefreshTokens refreshes the access tokens if they have expired.

View File

@@ -324,8 +324,16 @@ func (c *CodexClient) SendRawTokenCount(_ context.Context, _ string, _ []byte, _
// Returns: // Returns:
// - error: An error if the save operation fails, nil otherwise. // - error: An error if the save operation fails, nil otherwise.
func (c *CodexClient) SaveTokenToFile() error { func (c *CodexClient) SaveTokenToFile() error {
fileName := filepath.Join(c.cfg.AuthDir, fmt.Sprintf("codex-%s.json", c.tokenStorage.(*codex.CodexTokenStorage).Email)) // API-key based clients don't have a file-backed token to persist.
return c.tokenStorage.SaveTokenToFile(fileName) if c.apiKeyIndex != -1 {
return nil
}
ts, ok := c.tokenStorage.(*codex.CodexTokenStorage)
if !ok || ts == nil || ts.Email == "" {
return nil
}
fileName := filepath.Join(c.cfg.AuthDir, fmt.Sprintf("codex-%s.json", ts.Email))
return ts.SaveTokenToFile(fileName)
} }
// RefreshTokens refreshes the access tokens if needed // RefreshTokens refreshes the access tokens if needed

View File

@@ -0,0 +1,228 @@
package geminiwebapi
import (
"crypto/tls"
"errors"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
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) {
// Warm-up google.com to gain extra cookies (NID, etc.) and capture them.
extraCookies := map[string]string{}
{
client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true})
req, _ := http.NewRequest(http.MethodGet, EndpointGoogle, nil)
resp, _ := client.Do(req)
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 {
Debug("Skipping base cookies: __Secure-1PSIDTS missing")
}
}
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)
}
reToken := regexp.MustCompile(`"SNlM0e":"([^"]+)"`)
for _, cookies := range trySets {
resp, mergedCookies, err := sendInitRequest(cookies, proxy, insecure)
if err != nil {
if verbose {
Warning("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 {
Success("Gemini access token acquired.")
}
return token, mergedCookies, nil
}
}
return "", nil, &AuthError{Msg: "Failed to retrieve token."}
}
// rotate1psidts refreshes __Secure-1PSIDTS and caches it locally.
func rotate1psidts(cookies map[string]string, proxy string, insecure bool) (string, error) {
psid, ok := cookies["__Secure-1PSID"]
if !ok {
return "", &AuthError{Msg: "__Secure-1PSID missing"}
}
cacheDir := "temp"
_ = os.MkdirAll(cacheDir, 0o755)
cacheFile := filepath.Join(cacheDir, ".cached_1psidts_"+psid+".txt")
if st, err := os.Stat(cacheFile); err == nil {
if time.Since(st.ModTime()) <= time.Minute {
if b, err := os.ReadFile(cacheFile); err == nil {
v := strings.TrimSpace(string(b))
if v != "" {
return v, nil
}
}
}
}
tr := &http.Transport{}
if proxy != "" {
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\"]")))
applyHeaders(req, HeadersRotateCookies)
applyCookies(req, cookies)
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer 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" {
_ = os.WriteFile(cacheFile, []byte(c.Value), 0o644)
return c.Value, nil
}
}
return "", nil
}
// Minimal reader helpers to avoid importing strings everywhere.
type constReader struct {
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} }

View File

@@ -0,0 +1,772 @@
package geminiwebapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
)
// 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
AutoClose bool
CloseDelay time.Duration
closeMu sync.Mutex
closeTimer *time.Timer
AutoRefresh bool
RefreshInterval time.Duration
rotateCancel context.CancelFunc
insecure bool
accountLabel string
}
// 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,
AutoClose: false,
CloseDelay: 300 * time.Second,
AutoRefresh: true,
RefreshInterval: 540 * 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 }
}
// WithAccountLabel sets an identifying label (e.g., token filename sans .json)
// for logging purposes.
func WithAccountLabel(label string) func(*GeminiClient) {
return func(c *GeminiClient) { c.accountLabel = label }
}
// Init initializes the access token and http client.
func (c *GeminiClient) Init(timeoutSec float64, autoClose bool, closeDelaySec float64, autoRefresh bool, refreshIntervalSec 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, err := url.Parse(c.Proxy); err == 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))
c.AutoClose = autoClose
c.CloseDelay = time.Duration(closeDelaySec * float64(time.Second))
if c.AutoClose {
c.resetCloseTimer()
}
c.AutoRefresh = autoRefresh
c.RefreshInterval = time.Duration(refreshIntervalSec * float64(time.Second))
if c.AutoRefresh {
c.startAutoRefresh()
}
if verbose {
Success("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
c.closeMu.Lock()
if c.closeTimer != nil {
c.closeTimer.Stop()
c.closeTimer = nil
}
c.closeMu.Unlock()
// Transport/client closed by GC; nothing explicit
if c.rotateCancel != nil {
c.rotateCancel()
c.rotateCancel = nil
}
}
func (c *GeminiClient) resetCloseTimer() {
c.closeMu.Lock()
defer c.closeMu.Unlock()
if c.closeTimer != nil {
c.closeTimer.Stop()
c.closeTimer = nil
}
c.closeTimer = time.AfterFunc(c.CloseDelay, func() { c.Close(0) })
}
func (c *GeminiClient) startAutoRefresh() {
if c.rotateCancel != nil {
c.rotateCancel()
}
ctx, cancel := context.WithCancel(context.Background())
c.rotateCancel = cancel
go func() {
ticker := time.NewTicker(c.RefreshInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
// Step 1: rotate __Secure-1PSIDTS
newTS, err := rotate1psidts(c.Cookies, c.Proxy, c.insecure)
if err != nil {
Warning("Failed to refresh cookies. Background auto refresh canceled: %v", err)
cancel()
return
}
// Prepare a snapshot of cookies for access token refresh
nextCookies := map[string]string{}
for k, v := range c.Cookies {
nextCookies[k] = v
}
if newTS != "" {
nextCookies["__Secure-1PSIDTS"] = newTS
}
// Step 2: refresh access token using updated cookies
token, validCookies, err := getAccessToken(nextCookies, c.Proxy, false, c.insecure)
if err != nil {
// Apply rotated cookies even if token refresh fails, then retry on next tick
c.Cookies = nextCookies
Warning("Failed to refresh access token after cookie rotation: %v", err)
} else {
c.AccessToken = token
c.Cookies = validCookies
}
if c.accountLabel != "" {
DebugRaw("Cookies and token refreshed [%s]. New __Secure-1PSIDTS: %s", c.accountLabel, MaskToken28(nextCookies["__Secure-1PSIDTS"]))
} else {
DebugRaw("Cookies and token refreshed. New __Secure-1PSIDTS: %s", MaskToken28(nextCookies["__Secure-1PSIDTS"]))
}
}
}
}()
}
// ensureRunning mirrors the Python decorator behavior and retries on APIError.
func (c *GeminiClient) ensureRunning() error {
if c.Running {
return nil
}
return c.Init(float64(c.Timeout/time.Second), c.AutoClose, float64(c.CloseDelay/time.Second), c.AutoRefresh, float64(c.RefreshInterval/time.Second), false)
}
// 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
}
if c.AutoClose {
c.resetCloseTimer()
}
// 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 (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}
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)
}
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()))
// headers
for k, v := range HeadersGemini {
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")
for k, v := range c.Cookies {
req.AddCookie(&http.Cookie{Name: k, Value: v})
}
resp, err := c.httpClient.Do(req)
if err != nil {
return empty, &TimeoutError{GeminiError{Msg: "Generate content request timed out."}}
}
defer resp.Body.Close()
if resp.StatusCode == 429 {
// Surface 429 as TemporarilyBlocked to match Python 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 Python 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 Python 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, ok := v.(string); ok {
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, ok := candAny.([]any)
if !ok {
continue
}
// text: cArr[1][0]
var text string
if len(cArr) > 1 {
if sArr, ok := cArr[1].([]any); ok && 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, ok := cArr[22].([]any); ok && len(arr) > 0 {
if s, ok := arr[0].(string); ok {
text = s
}
}
}
}
// thoughts: candidate[37][0][0]
var thoughts *string
if len(cArr) > 37 {
if a, ok := cArr[37].([]any); ok && len(a) > 0 {
if b, ok := a[0].([]any); ok && len(b) > 0 {
if s, ok := b[0].(string); ok {
ss := decodeHTML(s)
thoughts = &ss
}
}
}
}
// web images: candidate[12][1]
webImages := []WebImage{}
var imgSection any
if len(cArr) > 12 {
imgSection = cArr[12]
}
if arr, ok := imgSection.([]any); ok && len(arr) > 1 {
if imagesArr, ok := arr[1].([]any); ok {
for _, wiAny := range imagesArr {
wiArr, ok := wiAny.([]any)
if !ok {
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, ok := wiArr[0].([]any); ok && len(a) > 0 {
if b, ok := a[0].([]any); ok && len(b) > 0 {
urlStr, _ = b[0].(string)
}
if len(a) > 4 {
if s, ok := a[4].(string); ok {
alt = s
}
}
}
}
if len(wiArr) > 7 {
if a, ok := wiArr[7].([]any); ok && len(a) > 0 {
title, _ = a[0].(string)
}
}
webImages = append(webImages, WebImage{Image: Image{URL: urlStr, Title: title, Alt: alt, Proxy: c.Proxy}})
}
}
}
// generated images
genImages := []GeneratedImage{}
hasGen := false
if arr, ok := imgSection.([]any); ok && len(arr) > 7 {
if a, ok := arr[7].([]any); ok && 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, ok := part.([]any)
if !ok || len(arr) < 3 {
continue
}
s, ok := arr[2].(string)
if !ok {
continue
}
var mp []any
if err := json.Unmarshal([]byte(s), &mp); err != nil {
continue
}
if len(mp) > 4 {
if tt, ok := mp[4].([]any); ok && len(tt) > ci {
if sec, ok := tt[ci].([]any); ok && len(sec) > 12 {
if ss, ok := sec[12].([]any); ok && len(ss) > 7 {
if first, ok := ss[7].([]any); ok && 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, ok := imgCand[1].([]any); ok && len(a) > 0 {
if s, ok := a[0].(string); ok {
text = strings.TrimSpace(reGen.ReplaceAllString(s, ""))
}
}
}
// images list at imgCand[12][7][0]
if len(imgCand) > 12 {
if s1, ok := imgCand[12].([]any); ok && len(s1) > 7 {
if s2, ok := s1[7].([]any); ok && len(s2) > 0 {
if s3, ok := s2[0].([]any); ok {
for ii, giAny := range s3 {
ga, ok := giAny.([]any)
if !ok || len(ga) < 4 {
continue
}
// url: ga[0][3][3]
var urlStr, title, alt string
if a, ok := ga[0].([]any); ok && len(a) > 3 {
if b, ok := a[3].([]any); ok && len(b) > 3 {
urlStr, _ = b[3].(string)
}
}
// title from ga[3][6]
if len(ga) > 3 {
if a, ok := ga[3].([]any); ok {
if len(a) > 6 {
if v, ok := a[6].(float64); ok && 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, ok := a[5].([]any); ok {
if ii < len(tt) {
if s, ok := tt[ii].(string); ok {
alt = s
}
} else if len(tt) > 0 {
if s, ok := tt[0].(string); ok {
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 Python 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
}
// truncateForLog returns a shortened string for logging
func truncateForLog(s string, n int) string {
if n <= 0 || len(s) <= n {
return s
}
return s[:n]
}
// 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}
}
// ChatSession holds conversation metadata
type ChatSession struct {
client *GeminiClient
metadata []string // cid, rid, rcid
lastOutput *ModelOutput
model Model
gem *Gem
}
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) 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
}

View File

@@ -0,0 +1,141 @@
package geminiwebapi
import (
"encoding/json"
"fmt"
"math"
"regexp"
"strings"
"time"
"unicode/utf8"
)
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, "&lt;", "<")
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 b, nil
}

View File

@@ -0,0 +1,47 @@
package geminiwebapi
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
}

View File

@@ -0,0 +1,168 @@
package geminiwebapi
import (
"fmt"
"os"
"strings"
log "github.com/sirupsen/logrus"
)
// init honors GEMINI_WEBAPI_LOG to keep parity with the Python client.
func init() {
if lvl := os.Getenv("GEMINI_WEBAPI_LOG"); lvl != "" {
SetLogLevel(lvl)
}
}
// SetLogLevel adjusts logging verbosity using CLI-style strings.
func SetLogLevel(level string) {
switch strings.ToUpper(level) {
case "TRACE":
log.SetLevel(log.TraceLevel)
case "DEBUG":
log.SetLevel(log.DebugLevel)
case "INFO":
log.SetLevel(log.InfoLevel)
case "WARNING", "WARN":
log.SetLevel(log.WarnLevel)
case "ERROR":
log.SetLevel(log.ErrorLevel)
case "CRITICAL", "FATAL":
log.SetLevel(log.FatalLevel)
default:
log.SetLevel(log.InfoLevel)
}
}
func prefix(format string) string { return "[gemini_webapi] " + format }
func Debug(format string, v ...any) { log.Debugf(prefix(format), v...) }
// DebugRaw logs without the module prefix; use sparingly for messages
// that should integrate with global formatting without extra tags.
func DebugRaw(format string, v ...any) { log.Debugf(format, v...) }
func Info(format string, v ...any) { log.Infof(prefix(format), v...) }
func Warning(format string, v ...any) { log.Warnf(prefix(format), v...) }
func Error(format string, v ...any) { log.Errorf(prefix(format), v...) }
func Success(format string, v ...any) { log.Infof(prefix("SUCCESS "+format), v...) }
// MaskToken hides the middle part of a sensitive value with '*'.
// It keeps up to left and right edge characters for readability.
// If input is very short, it returns a fully masked string of the same length.
func MaskToken(s string) string {
n := len(s)
if n == 0 {
return ""
}
if n <= 6 {
return strings.Repeat("*", n)
}
// Keep up to 6 chars on the left and 4 on the right, but never exceed available length
left := 6
if left > n-4 {
left = n - 4
}
right := 4
if right > n-left {
right = n - left
}
if left < 0 {
left = 0
}
if right < 0 {
right = 0
}
middle := n - left - right
if middle < 0 {
middle = 0
}
return s[:left] + strings.Repeat("*", middle) + s[n-right:]
}
// MaskToken28 returns a fixed-length (28) masked representation showing:
// first 8 chars + 8 asterisks + 4 middle chars + last 8 chars.
// If the input is shorter than 20 characters, it returns a fully masked string
// of length min(len(s), 28).
func MaskToken28(s string) string {
n := len(s)
if n == 0 {
return ""
}
if n < 20 {
// Too short to safely reveal; mask entirely but cap to 28
if n > 28 {
n = 28
}
return strings.Repeat("*", n)
}
// Pick 4 middle characters around the center
midStart := n/2 - 2
if midStart < 8 {
midStart = 8
}
if midStart+4 > n-8 {
midStart = n - 8 - 4
if midStart < 8 {
midStart = 8
}
}
prefix := s[:8]
middle := s[midStart : midStart+4]
suffix := s[n-8:]
return prefix + strings.Repeat("*", 4) + middle + strings.Repeat("*", 4) + suffix
}
// BuildUpstreamRequestLog builds a compact preview string for upstream request logging.
func BuildUpstreamRequestLog(account string, contextOn bool, useTags, explicitContext bool, prompt string, filesCount int, reuse bool, metaLen int, gem *Gem) string {
var sb strings.Builder
sb.WriteString("\n\n=== GEMINI WEB UPSTREAM ===\n")
sb.WriteString(fmt.Sprintf("account: %s\n", account))
if contextOn {
sb.WriteString("context_mode: on\n")
} else {
sb.WriteString("context_mode: off\n")
}
if reuse {
sb.WriteString("reuseIdx: 1\n")
} else {
sb.WriteString("reuseIdx: 0\n")
}
sb.WriteString(fmt.Sprintf("useTags: %t\n", useTags))
sb.WriteString(fmt.Sprintf("metadata_len: %d\n", metaLen))
if explicitContext {
sb.WriteString("explicit_context: true\n")
} else {
sb.WriteString("explicit_context: false\n")
}
if filesCount > 0 {
sb.WriteString(fmt.Sprintf("files: %d\n", filesCount))
}
if gem != nil {
sb.WriteString("gem:\n")
if gem.ID != "" {
sb.WriteString(fmt.Sprintf(" id: %s\n", gem.ID))
}
if gem.Name != "" {
sb.WriteString(fmt.Sprintf(" name: %s\n", gem.Name))
}
sb.WriteString(fmt.Sprintf(" predefined: %t\n", gem.Predefined))
} else {
sb.WriteString("gem: none\n")
}
chunks := ChunkByRunes(prompt, 4096)
preview := prompt
truncated := false
if len(chunks) > 1 {
preview = chunks[0]
truncated = true
}
sb.WriteString("prompt_preview:\n")
sb.WriteString(preview)
if truncated {
sb.WriteString("\n... [truncated]\n")
}
return sb.String()
}

View File

@@ -0,0 +1,388 @@
package geminiwebapi
import (
"bytes"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/luispater/CLIProxyAPI/v5/internal/interfaces"
misc "github.com/luispater/CLIProxyAPI/v5/internal/misc"
"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 (align with Python: ^(.*\.\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 {
Warning("Invalid filename: %s", filename)
}
if skipInvalidFilename {
return "", nil
}
}
}
// Build client with cookie jar so cookies persist across redirects.
tr := &http.Transport{}
if i.Proxy != "" {
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).
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 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") {
Warning("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 {
Info("Image saved as %s", 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."}
}
url := g.URL
if fullSize {
url = url + "=s2048"
}
if filename == "" {
name := time.Now().Format("20060102150405")
if len(url) >= 10 {
name = fmt.Sprintf("%s_%s.png", name, url[len(url)-10:])
} else {
name += ".png"
}
filename = name
}
tmp := g.Image
tmp.URL = url
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 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()
tr := &http.Transport{}
if proxy != "" {
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: 300 * time.Second}
req, _ := http.NewRequest(http.MethodPost, EndpointUpload, &buf)
for k, v := range HeadersUpload {
for _, vv := range v {
req.Header.Add(k, vv)
}
}
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 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
}

View File

@@ -0,0 +1,159 @@
package geminiwebapi
import (
"net/http"
"strings"
"sync"
"github.com/luispater/CLIProxyAPI/v5/internal/registry"
)
// Endpoints used by the Gemini web app
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"
)
// Default headers
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 defines available model names and headers
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{ // Deprecated, still supported
Name: "gemini-2.0-flash",
ModelHeader: http.Header{
"x-goog-ext-525001261-jspb": []string{"[1,null,null,null,\"f299729663a2343f\"]"},
},
AdvancedOnly: false,
}
ModelG20FlashThinking = Model{ // Deprecated, still supported
Name: "gemini-2.0-flash-thinking",
ModelHeader: http.Header{
"x-goog-ext-525001261-jspb": []string{"[null,null,null,null,\"7ca48d02d802f20a\"]"},
},
AdvancedOnly: false,
}
)
// ModelFromName returns a model by name or error if not found
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 server
const (
ErrorUsageLimitExceeded = 1037
ErrorModelInconsistent = 1050
ErrorModelHeaderInvalid = 1052
ErrorIPTemporarilyBlocked = 1060
)
var (
GeminiWebAliasOnce sync.Once
GeminiWebAliasMap map[string]string
)
// EnsureGeminiWebAliasMap initializes alias lookup lazily.
func EnsureGeminiWebAliasMap() {
GeminiWebAliasOnce.Do(func() {
GeminiWebAliasMap = make(map[string]string)
for _, m := range registry.GetGeminiModels() {
if m.ID == "gemini-2.5-flash-lite" {
continue
}
alias := AliasFromModelID(m.ID)
GeminiWebAliasMap[strings.ToLower(alias)] = strings.ToLower(m.ID)
}
})
}
// GetGeminiWebAliasedModels returns Gemini models exposed with web aliases.
func GetGeminiWebAliasedModels() []*registry.ModelInfo {
EnsureGeminiWebAliasMap()
aliased := make([]*registry.ModelInfo, 0)
for _, m := range registry.GetGeminiModels() {
if m.ID == "gemini-2.5-flash-lite" {
continue
}
cpy := *m
cpy.ID = AliasFromModelID(m.ID)
cpy.Name = cpy.ID
aliased = append(aliased, &cpy)
}
return aliased
}
// MapAliasToUnderlying normalizes web aliases back to canonical Gemini IDs.
func MapAliasToUnderlying(name string) string {
EnsureGeminiWebAliasMap()
n := strings.ToLower(name)
if u, ok := GeminiWebAliasMap[n]; ok {
return u
}
const suffix = "-web"
if strings.HasSuffix(n, suffix) {
return strings.TrimSuffix(n, suffix)
}
return name
}
// AliasFromModelID builds the web alias for a Gemini model identifier.
func AliasFromModelID(modelID string) string {
return modelID + "-web"
}

View File

@@ -0,0 +1,375 @@
package geminiwebapi
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/luispater/CLIProxyAPI/v5/internal/auth/gemini"
)
// StoredMessage represents a single message in a conversation record.
type StoredMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Name string `json:"name,omitempty"`
}
// ConversationRecord stores a full conversation with its metadata for persistence.
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"`
}
// Sha256Hex computes the SHA256 hash of a string and returns its hex representation.
func Sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// RoleText represents a turn in a conversation with a role and text content.
type RoleText struct {
Role string
Text string
}
func ToStoredMessages(msgs []RoleText) []StoredMessage {
out := make([]StoredMessage, 0, len(msgs))
for _, m := range msgs {
out = append(out, StoredMessage{
Role: m.Role,
Content: m.Text,
})
}
return out
}
func HashMessage(m StoredMessage) string {
s := fmt.Sprintf(`{"content":%q,"role":%q}`, m.Content, strings.ToLower(m.Role))
return Sha256Hex(s)
}
func HashConversation(clientID, model string, msgs []StoredMessage) string {
var b strings.Builder
b.WriteString(clientID)
b.WriteString("|")
b.WriteString(model)
for _, m := range msgs {
b.WriteString("|")
b.WriteString(HashMessage(m))
}
return Sha256Hex(b.String())
}
// ConvStorePath returns the path for account-level metadata persistence based on token file path.
func ConvStorePath(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+".conv.json")
}
// ConvDataPath returns the path for full conversation persistence based on token file path.
func ConvDataPath(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+".data.json")
}
// LoadConvStore reads the account-level metadata store from disk.
func LoadConvStore(path string) (map[string][]string, error) {
b, err := os.ReadFile(path)
if err != nil {
// Missing file is not an error; return empty map
return map[string][]string{}, nil
}
var tmp map[string][]string
if err := json.Unmarshal(b, &tmp); err != nil {
return nil, err
}
if tmp == nil {
tmp = map[string][]string{}
}
return tmp, 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{}
}
payload, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, payload, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
// 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) {
b, err := os.ReadFile(path)
if err != nil {
// Missing file is not an error; return empty sets
return map[string]ConversationRecord{}, map[string]string{}, nil
}
var wrapper struct {
Items map[string]ConversationRecord `json:"items"`
Index map[string]string `json:"index"`
}
if err := json.Unmarshal(b, &wrapper); err != nil {
return nil, nil, err
}
if wrapper.Items == nil {
wrapper.Items = map[string]ConversationRecord{}
}
if wrapper.Index == nil {
wrapper.Index = map[string]string{}
}
return wrapper.Items, wrapper.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{}
}
wrapper := struct {
Items map[string]ConversationRecord `json:"items"`
Index map[string]string `json:"index"`
}{Items: items, Index: index}
payload, err := json.MarshalIndent(wrapper, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, payload, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
// 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: 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 := ToStoredMessages(msgs)
stableHash := HashConversation(stableClientID, model, stored)
fallbackHash := HashConversation(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) ([]string, []RoleText) {
if len(msgs) < 2 {
return nil, nil
}
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 {
remain := msgs[searchEnd:]
return rec.Metadata, remain
}
}
searchEnd--
}
return nil, nil
}
// CookiesSidecarPath derives the sidecar cookie file path from the main token JSON path.
func CookiesSidecarPath(mainPath string) string {
if strings.HasSuffix(mainPath, ".json") {
return strings.TrimSuffix(mainPath, ".json") + ".cookies"
}
return mainPath + ".cookies"
}
// FileExists reports whether the given path exists and is a regular file.
func FileExists(path string) bool {
if path == "" {
return false
}
if st, err := os.Stat(path); err == nil && !st.IsDir() {
return true
}
return false
}
// ApplyCookiesSidecarToTokenStorage loads cookies from sidecar into the provided token storage.
// Returns true when a sidecar was found and applied.
func ApplyCookiesSidecarToTokenStorage(tokenFilePath string, ts *gemini.GeminiWebTokenStorage) (bool, error) {
if ts == nil {
return false, nil
}
side := CookiesSidecarPath(tokenFilePath)
if !FileExists(side) {
return false, nil
}
data, err := os.ReadFile(side)
if err != nil || len(data) == 0 {
return false, err
}
var latest gemini.GeminiWebTokenStorage
if err := json.Unmarshal(data, &latest); err != nil {
return false, err
}
if latest.Secure1PSID != "" {
ts.Secure1PSID = latest.Secure1PSID
}
if latest.Secure1PSIDTS != "" {
ts.Secure1PSIDTS = latest.Secure1PSIDTS
}
return true, nil
}
// SaveCookiesSidecar writes the current cookies into a sidecar file next to the token file.
// This keeps the main token JSON stable until an orderly flush.
func SaveCookiesSidecar(tokenFilePath string, cookies map[string]string) error {
side := CookiesSidecarPath(tokenFilePath)
ts := &gemini.GeminiWebTokenStorage{Type: "gemini-web"}
if v := cookies["__Secure-1PSID"]; v != "" {
ts.Secure1PSID = v
}
if v := cookies["__Secure-1PSIDTS"]; v != "" {
ts.Secure1PSIDTS = v
}
if err := os.MkdirAll(filepath.Dir(side), 0o700); err != nil {
return err
}
return ts.SaveTokenToFile(side)
}
// FlushCookiesSidecarToMain merges the sidecar cookies into the main token file and removes the sidecar.
// If sidecar is missing, it will combine the provided base token storage with the latest cookies.
func FlushCookiesSidecarToMain(tokenFilePath string, cookies map[string]string, base *gemini.GeminiWebTokenStorage) error {
if tokenFilePath == "" {
return nil
}
side := CookiesSidecarPath(tokenFilePath)
var merged gemini.GeminiWebTokenStorage
var fromSidecar bool
if FileExists(side) {
if data, err := os.ReadFile(side); err == nil && len(data) > 0 {
if err2 := json.Unmarshal(data, &merged); err2 == nil {
fromSidecar = true
}
}
}
if !fromSidecar {
if base != nil {
merged = *base
}
if v := cookies["__Secure-1PSID"]; v != "" {
merged.Secure1PSID = v
}
if v := cookies["__Secure-1PSIDTS"]; v != "" {
merged.Secure1PSIDTS = v
}
}
merged.Type = "gemini-web"
if err := os.MkdirAll(filepath.Dir(tokenFilePath), 0o700); err != nil {
return err
}
if err := merged.SaveTokenToFile(tokenFilePath); err != nil {
return err
}
if FileExists(side) {
_ = os.Remove(side)
}
return nil
}
// IsSelfPersistedToken compares provided token storage with current cookies.
// Removed: IsSelfPersistedToken (client-side no longer needs self-originated write detection)

View File

@@ -0,0 +1,130 @@
package geminiwebapi
import (
"math"
"regexp"
"strings"
"unicode/utf8"
"github.com/tidwall/gjson"
)
var (
reThink = regexp.MustCompile(`(?s)^\s*<think>.*?</think>\s*`)
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 <think>...</think> blocks from a string.
func RemoveThinkTags(s string) string {
return strings.TrimSpace(reThink.ReplaceAllString(s, ""))
}
// SanitizeAssistantMessages removes think tags from assistant messages.
func SanitizeAssistantMessages(msgs []RoleText) []RoleText {
out := make([]RoleText, 0, len(msgs))
for _, m := range msgs {
if strings.ToLower(m.Role) == "assistant" {
out = append(out, RoleText{Role: m.Role, Text: RemoveThinkTags(m.Text)})
} else {
out = append(out, m)
}
}
return out
}
// 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))
}

View File

@@ -0,0 +1,106 @@
package geminiwebapi
import (
"fmt"
"strings"
"unicode/utf8"
"github.com/luispater/CLIProxyAPI/v5/internal/config"
)
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 max characters per request
max := MaxCharsPerRequest(cfg)
if max <= 0 {
max = 1_000_000
}
// If within limit, send directly
if utf8.RuneCountInString(text) <= max {
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 := max - hintLen
if chunkSize <= 0 {
// max is too small to accommodate the hint; fall back to no-hint splitting
useHint = false
chunkSize = max
}
if chunkSize <= 0 {
// As a last resort, split by single rune to avoid exceeding the limit
chunkSize = 1
}
// 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)
}

View File

@@ -0,0 +1,83 @@
package geminiwebapi
import (
"fmt"
"html"
)
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) }

View File

@@ -0,0 +1,845 @@
package client
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/cookiejar"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/luispater/CLIProxyAPI/v5/internal/auth/gemini"
geminiWeb "github.com/luispater/CLIProxyAPI/v5/internal/client/gemini-web"
"github.com/luispater/CLIProxyAPI/v5/internal/config"
. "github.com/luispater/CLIProxyAPI/v5/internal/constant"
"github.com/luispater/CLIProxyAPI/v5/internal/interfaces"
"github.com/luispater/CLIProxyAPI/v5/internal/translator/translator"
"github.com/luispater/CLIProxyAPI/v5/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// This file wires the external-facing client for Gemini Web.
// Defaults for Gemini Web behavior that are no longer configurable via YAML.
const (
// geminiWebDefaultTimeoutSec defines the per-request HTTP timeout seconds.
geminiWebDefaultTimeoutSec = 300
// geminiWebDefaultRefreshIntervalSec defines background cookie auto-refresh interval seconds.
geminiWebDefaultRefreshIntervalSec = 540
// geminiWebDefaultPersistIntervalSec defines how often rotated cookies are persisted to disk (3 hours).
geminiWebDefaultPersistIntervalSec = 10800
)
type GeminiWebClient struct {
ClientBase
gwc *geminiWeb.GeminiClient
tokenFilePath string
convStore map[string][]string
convMutex sync.RWMutex
// JSON-based conversation persistence
convData map[string]geminiWeb.ConversationRecord
convIndex map[string]string
// restart-stable id for conversation hashing/lookup
stableClientID string
cookieRotationStarted bool
cookiePersistCancel context.CancelFunc
lastPersistedTS string
// register models once after successful auth init
modelsRegistered bool
}
func (c *GeminiWebClient) UnregisterClient() {
if c.cookiePersistCancel != nil {
c.cookiePersistCancel()
c.cookiePersistCancel = nil
}
// Flush sidecar cookies to main token file and remove sidecar
c.flushCookiesSidecarToMain()
if c.gwc != nil {
c.gwc.Close(0)
c.gwc = nil
}
c.ClientBase.UnregisterClient()
}
func NewGeminiWebClient(cfg *config.Config, ts *gemini.GeminiWebTokenStorage, tokenFilePath string) (*GeminiWebClient, error) {
jar, _ := cookiejar.New(nil)
httpClient := util.SetProxy(cfg, &http.Client{Jar: jar})
// derive a restart-stable id from tokens (sha256 of 1PSID, hex prefix)
stableSuffix := geminiWeb.Sha256Hex(ts.Secure1PSID)
if len(stableSuffix) > 16 {
stableSuffix = stableSuffix[:16]
}
idPrefix := stableSuffix
if len(idPrefix) > 8 {
idPrefix = idPrefix[:8]
}
clientID := fmt.Sprintf("gemini-web-%s-%d", idPrefix, time.Now().UnixNano())
client := &GeminiWebClient{
ClientBase: ClientBase{
RequestMutex: &sync.Mutex{},
httpClient: httpClient,
cfg: cfg,
tokenStorage: ts,
modelQuotaExceeded: make(map[string]*time.Time),
},
tokenFilePath: tokenFilePath,
convStore: make(map[string][]string),
convData: make(map[string]geminiWeb.ConversationRecord),
convIndex: make(map[string]string),
stableClientID: "gemini-web-" + stableSuffix,
}
// Load persisted conversation stores
if store, err := geminiWeb.LoadConvStore(geminiWeb.ConvStorePath(tokenFilePath)); err == nil {
client.convStore = store
}
if items, index, err := geminiWeb.LoadConvData(geminiWeb.ConvDataPath(tokenFilePath)); err == nil {
client.convData = items
client.convIndex = index
}
client.InitializeModelRegistry(clientID)
// Prefer sidecar cookies at startup if present
if ok, err := geminiWeb.ApplyCookiesSidecarToTokenStorage(tokenFilePath, ts); err == nil && ok {
log.Debugf("Loaded Gemini Web cookies from sidecar: %s", filepath.Base(geminiWeb.CookiesSidecarPath(tokenFilePath)))
}
client.gwc = geminiWeb.NewGeminiClient(ts.Secure1PSID, ts.Secure1PSIDTS, cfg.ProxyURL, geminiWeb.WithAccountLabel(strings.TrimSuffix(filepath.Base(tokenFilePath), ".json")))
timeoutSec := geminiWebDefaultTimeoutSec
refreshIntervalSec := cfg.GeminiWeb.TokenRefreshSeconds
if refreshIntervalSec <= 0 {
refreshIntervalSec = geminiWebDefaultRefreshIntervalSec
}
if err := client.gwc.Init(float64(timeoutSec), false, 300, true, float64(refreshIntervalSec), false); err != nil {
log.Warnf("Gemini Web init failed for %s: %v. Will retry in background.", client.GetEmail(), err)
go client.backgroundInitRetry()
} else {
client.cookieRotationStarted = true
// Persist immediately once after successful init to capture fresh cookies
_ = client.SaveTokenToFile()
client.startCookiePersist()
}
return client, nil
}
func (c *GeminiWebClient) Init() error {
ts := c.tokenStorage.(*gemini.GeminiWebTokenStorage)
c.gwc = geminiWeb.NewGeminiClient(ts.Secure1PSID, ts.Secure1PSIDTS, c.cfg.ProxyURL, geminiWeb.WithAccountLabel(c.GetEmail()))
timeoutSec := geminiWebDefaultTimeoutSec
refreshIntervalSec := c.cfg.GeminiWeb.TokenRefreshSeconds
if refreshIntervalSec <= 0 {
refreshIntervalSec = geminiWebDefaultRefreshIntervalSec
}
if err := c.gwc.Init(float64(timeoutSec), false, 300, true, float64(refreshIntervalSec), false); err != nil {
return err
}
c.registerModelsOnce()
// Persist immediately once after successful init to capture fresh cookies
_ = c.SaveTokenToFile()
c.startCookiePersist()
return nil
}
// IsReady reports whether the underlying Gemini Web client is initialized and running.
func (c *GeminiWebClient) IsReady() bool {
return c != nil && c.gwc != nil && c.gwc.Running
}
func (c *GeminiWebClient) registerModelsOnce() {
if c.modelsRegistered {
return
}
c.RegisterModels(GEMINI, geminiWeb.GetGeminiWebAliasedModels())
c.modelsRegistered = true
}
// EnsureRegistered registers models if the client is ready and not yet registered.
// It is safe to call multiple times.
func (c *GeminiWebClient) EnsureRegistered() {
if c.IsReady() {
c.registerModelsOnce()
}
}
func (c *GeminiWebClient) Type() string { return GEMINI }
func (c *GeminiWebClient) Provider() string { return GEMINI }
func (c *GeminiWebClient) CanProvideModel(modelName string) bool {
geminiWeb.EnsureGeminiWebAliasMap()
_, ok := geminiWeb.GeminiWebAliasMap[strings.ToLower(modelName)]
return ok
}
func (c *GeminiWebClient) GetEmail() string {
base := filepath.Base(c.tokenFilePath)
return strings.TrimSuffix(base, ".json")
}
func (c *GeminiWebClient) StableClientID() string {
if c.stableClientID != "" {
return c.stableClientID
}
sum := geminiWeb.Sha256Hex(c.GetEmail())
if len(sum) > 16 {
sum = sum[:16]
}
return "gemini-web-" + sum
}
// useReusableContext reports whether JSON-based reusable conversation matching is enabled.
// Controlled by `gemini-web.context` boolean in config (true enables reuse, default true).
func (c *GeminiWebClient) useReusableContext() bool {
if c == nil || c.cfg == nil {
return true
}
return c.cfg.GeminiWeb.Context
}
// chatPrep encapsulates shared request preparation results for both stream and non-stream flows.
type chatPrep struct {
chat *geminiWeb.ChatSession
prompt string
uploaded []string
reuse bool
metaLen int
handlerType string
tagged bool
underlying string
cleaned []geminiWeb.RoleText
translatedRaw []byte
}
// prepareChat performs translation, message parsing, metadata reuse, prompt build and StartChat.
func (c *GeminiWebClient) prepareChat(ctx context.Context, modelName string, rawJSON []byte, isStream bool) (*chatPrep, *interfaces.ErrorMessage) {
res := &chatPrep{}
if handler, ok := ctx.Value("handler").(interfaces.APIHandler); ok {
res.handlerType = handler.HandlerType()
rawJSON = translator.Request(res.handlerType, c.Type(), modelName, rawJSON, isStream)
}
res.translatedRaw = rawJSON
if c.cfg.RequestLog {
if ginContext, ok := ctx.Value("gin").(*gin.Context); ok {
ginContext.Set("API_REQUEST", rawJSON)
}
}
messages, files, mimes, msgFileIdx, err := geminiWeb.ParseMessagesAndFiles(rawJSON)
if err != nil {
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: fmt.Errorf("bad request: %w", err)}
}
cleaned := geminiWeb.SanitizeAssistantMessages(messages)
res.cleaned = cleaned
res.underlying = geminiWeb.MapAliasToUnderlying(modelName)
model, err := geminiWeb.ModelFromName(res.underlying)
if err != nil {
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: err}
}
var (
meta []string
useMsgs []geminiWeb.RoleText
filesSubset [][]byte
mimesSubset []string
)
if c.useReusableContext() {
reuseMeta, remaining := c.findReusableSession(res.underlying, cleaned)
res.reuse = len(reuseMeta) > 0
if res.reuse {
meta = reuseMeta
if len(remaining) == 1 {
useMsgs = []geminiWeb.RoleText{remaining[0]}
} else {
useMsgs = remaining
}
} else {
meta = nil
useMsgs = cleaned
}
res.tagged = geminiWeb.NeedRoleTags(useMsgs)
if res.reuse && len(useMsgs) == 1 {
res.tagged = false
}
if res.reuse && len(useMsgs) == 1 && len(messages) > 0 {
lastIdx := len(messages) - 1
if lastIdx >= 0 && lastIdx < len(msgFileIdx) {
for _, fi := range msgFileIdx[lastIdx] {
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 = files
mimesSubset = mimes
}
res.metaLen = len(meta)
} else {
key := geminiWeb.AccountMetaKey(c.GetEmail(), modelName)
c.convMutex.RLock()
meta = c.convStore[key]
c.convMutex.RUnlock()
useMsgs = cleaned
res.tagged = geminiWeb.NeedRoleTags(useMsgs)
filesSubset = files
mimesSubset = mimes
res.reuse = false
res.metaLen = len(meta)
}
uploadedFiles, upErr := geminiWeb.MaterializeInlineFiles(filesSubset, mimesSubset)
if upErr != nil {
return nil, upErr
}
res.uploaded = uploadedFiles
// XML hint follows code-mode only:
// - code-mode = true -> enable XML wrapping hint
// - code-mode = false -> disable XML wrapping hint
enableXMLHint := c.cfg != nil && c.cfg.GeminiWeb.CodeMode
useMsgs = geminiWeb.AppendXMLWrapHintIfNeeded(useMsgs, !enableXMLHint)
res.prompt = geminiWeb.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")}
}
c.appendUpstreamRequestLog(ctx, modelName, res.tagged, true, res.prompt, len(uploadedFiles), res.reuse, res.metaLen)
gem := c.getConfiguredGem()
res.chat = c.gwc.StartChat(model, gem, meta)
return res, nil
}
func (c *GeminiWebClient) SendRawMessage(ctx context.Context, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage) {
original := bytes.Clone(rawJSON)
prep, prepErr := c.prepareChat(ctx, modelName, rawJSON, false)
if prepErr != nil {
return nil, prepErr
}
defer geminiWeb.CleanupFiles(prep.uploaded)
log.Debugf("Use Gemini Web account %s for model %s", c.GetEmail(), modelName)
out, genErr := geminiWeb.SendWithSplit(prep.chat, prep.prompt, prep.uploaded, c.cfg)
if genErr != nil {
return nil, c.handleSendError(genErr, modelName)
}
gemBytes, errMsg := c.handleSendSuccess(ctx, prep, &out, modelName)
if errMsg != nil {
return nil, errMsg
}
if translator.NeedConvert(prep.handlerType, c.Type()) {
var param any
out := translator.ResponseNonStream(prep.handlerType, c.Type(), ctx, modelName, original, prep.translatedRaw, gemBytes, &param)
if prep.handlerType == 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), nil
}
return gemBytes, nil
}
func (c *GeminiWebClient) SendRawMessageStream(ctx context.Context, modelName string, rawJSON []byte, alt string) (<-chan []byte, <-chan *interfaces.ErrorMessage) {
dataChan := make(chan []byte)
errChan := make(chan *interfaces.ErrorMessage)
go func() {
defer close(dataChan)
defer close(errChan)
original := bytes.Clone(rawJSON)
prep, prepErr := c.prepareChat(ctx, modelName, rawJSON, true)
if prepErr != nil {
errChan <- prepErr
return
}
defer geminiWeb.CleanupFiles(prep.uploaded)
log.Debugf("Use Gemini Web account %s for model %s", c.GetEmail(), modelName)
out, genErr := geminiWeb.SendWithSplit(prep.chat, prep.prompt, prep.uploaded, c.cfg)
if genErr != nil {
errChan <- c.handleSendError(genErr, modelName)
return
}
gemBytes, errMsg := c.handleSendSuccess(ctx, prep, &out, modelName)
if errMsg != nil {
errChan <- errMsg
return
}
// Branch by handler type:
// - Native Gemini handler: emit at most two messages (thoughts, then others), no [DONE].
// - Translated handlers (e.g., OpenAI Responses): split first payload into two (if thoughts exist), then emit translator's [DONE].
if prep.handlerType == GEMINI {
root := gjson.ParseBytes(gemBytes)
parts := root.Get("candidates.0.content.parts")
if parts.Exists() && parts.IsArray() {
var thoughtArr, otherArr strings.Builder
thoughtCount := 0
thoughtArr.WriteByte('[')
otherArr.WriteByte('[')
firstThought := true
firstOther := true
parts.ForEach(func(_, part gjson.Result) bool {
if part.Get("thought").Bool() {
if !firstThought {
thoughtArr.WriteByte(',')
}
thoughtArr.WriteString(part.Raw)
firstThought = false
thoughtCount++
} else {
if !firstOther {
otherArr.WriteByte(',')
}
otherArr.WriteString(part.Raw)
firstOther = false
}
return true
})
thoughtArr.WriteByte(']')
otherArr.WriteByte(']')
if thoughtCount > 0 {
thoughtOnly, _ := sjson.SetRaw(string(gemBytes), "candidates.0.content.parts", thoughtArr.String())
// Only when the first chunk contains thoughts, set finishReason to null
thoughtOnly, _ = sjson.SetRaw(thoughtOnly, "candidates.0.finishReason", "null")
dataChan <- []byte(thoughtOnly)
}
othersOnly, _ := sjson.SetRaw(string(gemBytes), "candidates.0.content.parts", otherArr.String())
// Do not modify finishReason for non-thought first chunks or subsequent chunks
dataChan <- []byte(othersOnly)
return
}
// Fallback: no parts array; emit single message
// No special handling when no parts or no thoughts
dataChan <- gemBytes
return
}
// Translated handlers: when code-mode is ON, merge <think> into content and emit a single chunk; otherwise keep split.
newCtx := context.WithValue(ctx, "alt", alt)
var param any
if c.cfg.GeminiWeb.CodeMode {
combined := mergeThoughtIntoSingleContent(gemBytes)
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, combined, &param)
for _, l := range lines {
if l != "" {
dataChan <- []byte(l)
}
}
done := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte("[DONE]"), &param)
for _, l := range done {
if l != "" {
dataChan <- []byte(l)
}
}
return
}
root := gjson.ParseBytes(gemBytes)
parts := root.Get("candidates.0.content.parts")
if parts.Exists() && parts.IsArray() {
var thoughtArr, otherArr strings.Builder
thoughtCount := 0
thoughtArr.WriteByte('[')
otherArr.WriteByte('[')
firstThought := true
firstOther := true
parts.ForEach(func(_, part gjson.Result) bool {
if part.Get("thought").Bool() {
if !firstThought {
thoughtArr.WriteByte(',')
}
thoughtArr.WriteString(part.Raw)
firstThought = false
thoughtCount++
} else {
if !firstOther {
otherArr.WriteByte(',')
}
otherArr.WriteString(part.Raw)
firstOther = false
}
return true
})
thoughtArr.WriteByte(']')
otherArr.WriteByte(']')
if thoughtCount > 0 {
thoughtOnly, _ := sjson.SetRaw(string(gemBytes), "candidates.0.content.parts", thoughtArr.String())
// Only when the first chunk contains thoughts, suppress finishReason before translation
thoughtOnly, _ = sjson.Delete(thoughtOnly, "candidates.0.finishReason")
// If CodeMode enabled, demote thought parts to content before translating
if c.cfg.GeminiWeb.CodeMode {
processed := collapseThoughtPartsToContent([]byte(thoughtOnly))
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, processed, &param)
for _, l := range lines {
if l != "" {
dataChan <- []byte(l)
}
}
} else {
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte(thoughtOnly), &param)
for _, l := range lines {
if l != "" {
dataChan <- []byte(l)
}
}
}
}
othersOnly, _ := sjson.SetRaw(string(gemBytes), "candidates.0.content.parts", otherArr.String())
// Do not modify finishReason if there is no thought chunk
if c.cfg.GeminiWeb.CodeMode {
processed := collapseThoughtPartsToContent([]byte(othersOnly))
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, processed, &param)
for _, l := range lines {
if l != "" {
dataChan <- []byte(l)
}
}
} else {
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte(othersOnly), &param)
for _, l := range lines {
if l != "" {
dataChan <- []byte(l)
}
}
}
done := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte("[DONE]"), &param)
for _, l := range done {
if l != "" {
dataChan <- []byte(l)
}
}
return
}
// Fallback: no parts array; forward as a single translated payload then DONE
// If code-mode is ON, still merge to a single content block.
if c.cfg.GeminiWeb.CodeMode {
processed := mergeThoughtIntoSingleContent(gemBytes)
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, processed, &param)
for _, l := range lines {
if l != "" {
dataChan <- []byte(l)
}
}
} else {
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, gemBytes, &param)
for _, l := range lines {
if l != "" {
dataChan <- []byte(l)
}
}
}
done := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte("[DONE]"), &param)
for _, l := range done {
if l != "" {
dataChan <- []byte(l)
}
}
}()
return dataChan, errChan
}
func (c *GeminiWebClient) handleSendError(genErr error, modelName string) *interfaces.ErrorMessage {
log.Errorf("failed to generate content: %v", genErr)
status := 500
var eUsage *geminiWeb.UsageLimitExceeded
var eTempBlock *geminiWeb.TemporarilyBlocked
if errors.As(genErr, &eUsage) || errors.As(genErr, &eTempBlock) {
status = 429
}
var eModelInvalid *geminiWeb.ModelInvalid
if status == 500 && errors.As(genErr, &eModelInvalid) {
status = 400
}
var eValue *geminiWeb.ValueError
if status == 500 && errors.As(genErr, &eValue) {
status = 400
}
var eTimeout *geminiWeb.TimeoutError
if status == 500 && errors.As(genErr, &eTimeout) {
status = 504
}
if status == 429 {
now := time.Now()
c.modelQuotaExceeded[modelName] = &now
c.SetModelQuotaExceeded(modelName)
}
return &interfaces.ErrorMessage{StatusCode: status, Error: genErr}
}
func (c *GeminiWebClient) handleSendSuccess(ctx context.Context, prep *chatPrep, output *geminiWeb.ModelOutput, modelName string) ([]byte, *interfaces.ErrorMessage) {
delete(c.modelQuotaExceeded, modelName)
c.ClearModelQuotaExceeded(modelName)
gemBytes, err := geminiWeb.ConvertOutputToGemini(output, modelName, prep.prompt)
if err != nil {
return nil, &interfaces.ErrorMessage{StatusCode: 500, Error: err}
}
c.AddAPIResponseData(ctx, gemBytes)
if output != nil {
metaAfter := prep.chat.Metadata()
if len(metaAfter) > 0 {
key := geminiWeb.AccountMetaKey(c.GetEmail(), modelName)
c.convMutex.Lock()
c.convStore[key] = metaAfter
snapshot := c.convStore
c.convMutex.Unlock()
_ = geminiWeb.SaveConvStore(geminiWeb.ConvStorePath(c.tokenFilePath), snapshot)
}
if c.useReusableContext() {
c.storeConversationJSON(prep.underlying, prep.cleaned, prep.chat.Metadata(), output)
}
}
return gemBytes, nil
}
// collapseThoughtPartsToContent flattens Gemini "thought" parts into regular text parts
// so downstream OpenAI translators emit them as `content` instead of `reasoning_content`.
// It preserves part order and keeps non-text parts intact.
func collapseThoughtPartsToContent(gemBytes []byte) []byte {
parts := gjson.GetBytes(gemBytes, "candidates.0.content.parts")
if !parts.Exists() || !parts.IsArray() {
return gemBytes
}
arr := parts.Array()
newParts := make([]json.RawMessage, 0, len(arr))
for _, part := range arr {
if t := part.Get("text"); t.Exists() {
obj, _ := json.Marshal(map[string]string{"text": t.String()})
newParts = append(newParts, obj)
} else {
newParts = append(newParts, json.RawMessage(part.Raw))
}
}
var sb strings.Builder
sb.WriteByte('[')
for i, p := range newParts {
if i > 0 {
sb.WriteByte(',')
}
sb.Write(p)
}
sb.WriteByte(']')
if updated, err := sjson.SetRawBytes(gemBytes, "candidates.0.content.parts", []byte(sb.String())); err == nil {
return updated
}
return gemBytes
}
// mergeThoughtIntoSingleContent merges all thought text and normal text into one text part.
// The output places the thought text inside <think>...</think> followed by a newline and then the normal text.
// Non-text parts are ignored for the combined output chunk.
func mergeThoughtIntoSingleContent(gemBytes []byte) []byte {
parts := gjson.GetBytes(gemBytes, "candidates.0.content.parts")
if !parts.Exists() || !parts.IsArray() {
return gemBytes
}
var thought strings.Builder
var visible strings.Builder
parts.ForEach(func(_, part gjson.Result) bool {
if t := part.Get("text"); t.Exists() {
if part.Get("thought").Bool() {
thought.WriteString(t.String())
} else {
visible.WriteString(t.String())
}
}
return true
})
var combined strings.Builder
if thought.Len() > 0 {
combined.WriteString("<think>")
combined.WriteString(thought.String())
combined.WriteString("</think>\n\n")
}
combined.WriteString(visible.String())
// Build a single-part array
obj, _ := json.Marshal(map[string]string{"text": combined.String()})
var arr strings.Builder
arr.WriteByte('[')
arr.Write(obj)
arr.WriteByte(']')
if updated, err := sjson.SetRawBytes(gemBytes, "candidates.0.content.parts", []byte(arr.String())); err == nil {
return updated
}
return gemBytes
}
func (c *GeminiWebClient) appendUpstreamRequestLog(ctx context.Context, modelName string, useTags, explicitContext bool, prompt string, filesCount int, reuse bool, metaLen int) {
if !c.cfg.RequestLog {
return
}
ginContext, ok := ctx.Value("gin").(*gin.Context)
if !ok || ginContext == nil {
return
}
preview := geminiWeb.BuildUpstreamRequestLog(c.GetEmail(), c.useReusableContext(), useTags, explicitContext, prompt, filesCount, reuse, metaLen, c.getConfiguredGem())
if existing, exists := ginContext.Get("API_REQUEST"); exists {
if base, ok2 := existing.([]byte); ok2 {
merged := append(append([]byte{}, base...), []byte(preview)...)
ginContext.Set("API_REQUEST", merged)
}
}
}
func (c *GeminiWebClient) SendRawTokenCount(ctx context.Context, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage) {
est := geminiWeb.EstimateTotalTokensFromRawJSON(rawJSON)
return []byte(fmt.Sprintf(`{"totalTokens":%d}`, est)), nil
}
// SaveTokenToFile persists current cookies to a sidecar file via gemini-web helpers.
func (c *GeminiWebClient) SaveTokenToFile() error {
ts := c.tokenStorage.(*gemini.GeminiWebTokenStorage)
if c.gwc != nil && c.gwc.Cookies != nil {
if v, ok := c.gwc.Cookies["__Secure-1PSID"]; ok && v != "" {
ts.Secure1PSID = v
}
if v, ok := c.gwc.Cookies["__Secure-1PSIDTS"]; ok && v != "" {
ts.Secure1PSIDTS = v
}
}
log.Debugf("Saving Gemini Web cookies sidecar to %s", filepath.Base(geminiWeb.CookiesSidecarPath(c.tokenFilePath)))
return geminiWeb.SaveCookiesSidecar(c.tokenFilePath, c.gwc.Cookies)
}
// startCookiePersist periodically writes refreshed cookies into the sidecar file.
func (c *GeminiWebClient) startCookiePersist() {
if c.gwc == nil {
return
}
if c.cookiePersistCancel != nil {
c.cookiePersistCancel()
c.cookiePersistCancel = nil
}
ctx, cancel := context.WithCancel(context.Background())
c.cookiePersistCancel = cancel
go func() {
// Persist cookies at the same cadence as auto-refresh when enabled,
// otherwise use a coarse default interval.
persistSec := geminiWebDefaultPersistIntervalSec
if c.gwc != nil && c.gwc.AutoRefresh {
if sec := int(c.gwc.RefreshInterval / time.Second); sec > 0 {
persistSec = sec
}
}
ticker := time.NewTicker(time.Duration(persistSec) * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if c.gwc != nil && c.gwc.Cookies != nil {
if err := c.SaveTokenToFile(); err != nil {
log.Errorf("Failed to persist cookies sidecar for %s: %v", c.GetEmail(), err)
} else {
log.Debugf("Persisted cookies sidecar for %s", c.GetEmail())
}
}
}
}
}()
}
func (c *GeminiWebClient) IsModelQuotaExceeded(model string) bool {
if t, ok := c.modelQuotaExceeded[model]; ok {
return time.Since(*t) <= 30*time.Minute
}
return false
}
func (c *GeminiWebClient) GetUserAgent() string {
if ua := geminiWeb.HeadersGemini.Get("User-Agent"); ua != "" {
return ua
}
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
func (c *GeminiWebClient) GetRequestMutex() *sync.Mutex { return nil }
func (c *GeminiWebClient) RefreshTokens(ctx context.Context) error { return c.Init() }
func (c *GeminiWebClient) backgroundInitRetry() {
backoffs := []time.Duration{5 * time.Second, 10 * time.Second, 30 * time.Second, 1 * time.Minute, 2 * time.Minute, 5 * time.Minute}
i := 0
for {
if err := c.Init(); err == nil {
log.Infof("Gemini Web token recovered for %s", c.GetEmail())
if !c.cookieRotationStarted {
c.cookieRotationStarted = true
}
c.startCookiePersist()
return
}
d := backoffs[i]
if i < len(backoffs)-1 {
i++
}
time.Sleep(d)
}
}
// IsSelfPersistedToken compares provided token storage with currently active cookies.
// Removed: IsSelfPersistedToken (no longer needed with sidecar-only periodic persistence)
// flushCookiesSidecarToMain merges sidecar cookies into the main token file.
func (c *GeminiWebClient) flushCookiesSidecarToMain() {
if c.tokenFilePath == "" {
return
}
base := c.tokenStorage.(*gemini.GeminiWebTokenStorage)
if err := geminiWeb.FlushCookiesSidecarToMain(c.tokenFilePath, c.gwc.Cookies, base); err != nil {
log.Errorf("Failed to flush cookies sidecar to main for %s: %v", filepath.Base(c.tokenFilePath), err)
}
}
// findReusableSession and storeConversationJSON live here as client bridges; hashing/records in gemini-web
func (c *GeminiWebClient) getConfiguredGem() *geminiWeb.Gem {
if c.cfg.GeminiWeb.CodeMode {
return &geminiWeb.Gem{ID: "coding-partner", Name: "Coding partner", Predefined: true}
}
return nil
}
// findReusableSession bridges to gemini-web conversation reuse using in-memory stores.
func (c *GeminiWebClient) findReusableSession(model string, msgs []geminiWeb.RoleText) ([]string, []geminiWeb.RoleText) {
c.convMutex.RLock()
items := c.convData
index := c.convIndex
c.convMutex.RUnlock()
return geminiWeb.FindReusableSessionIn(items, index, c.StableClientID(), c.GetEmail(), model, msgs)
}
// storeConversationJSON persists conversation records and updates in-memory indexes.
func (c *GeminiWebClient) storeConversationJSON(model string, history []geminiWeb.RoleText, metadata []string, output *geminiWeb.ModelOutput) {
rec, ok := geminiWeb.BuildConversationRecord(model, c.StableClientID(), history, output, metadata)
if !ok {
return
}
stableID := rec.ClientID
stableHash := geminiWeb.HashConversation(stableID, model, rec.Messages)
legacyID := c.GetEmail()
legacyHash := geminiWeb.HashConversation(legacyID, model, rec.Messages)
c.convMutex.Lock()
c.convData[stableHash] = rec
c.convIndex["hash:"+stableHash] = stableHash
if legacyID != stableID {
c.convIndex["hash:"+legacyHash] = stableHash
}
items := c.convData
index := c.convIndex
c.convMutex.Unlock()
_ = geminiWeb.SaveConvData(geminiWeb.ConvDataPath(c.tokenFilePath), items, index)
}

View File

@@ -0,0 +1,60 @@
// Package cmd provides command-line interface functionality for the CLI Proxy API.
package cmd
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/luispater/CLIProxyAPI/v5/internal/auth/gemini"
"github.com/luispater/CLIProxyAPI/v5/internal/config"
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.
func DoGeminiWebAuth(cfg *config.Config) {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter your __Secure-1PSID cookie value: ")
secure1psid, _ := reader.ReadString('\n')
secure1psid = strings.TrimSpace(secure1psid)
if secure1psid == "" {
log.Fatal("The __Secure-1PSID value cannot be empty.")
return
}
fmt.Print("Enter your __Secure-1PSIDTS cookie value: ")
secure1psidts, _ := reader.ReadString('\n')
secure1psidts = strings.TrimSpace(secure1psidts)
if secure1psidts == "" {
log.Fatal("The __Secure-1PSIDTS value cannot be empty.")
return
}
tokenStorage := &gemini.GeminiWebTokenStorage{
Secure1PSID: secure1psid,
Secure1PSIDTS: secure1psidts,
}
// 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])
filePath := filepath.Join(cfg.AuthDir, fileName)
err := tokenStorage.SaveTokenToFile(filePath)
if err != nil {
log.Fatalf("Failed to save Gemini Web token to file: %v", err)
return
}
log.Infof("Successfully saved Gemini Web token to: %s", filePath)
}

View File

@@ -48,6 +48,9 @@ import (
// - cfg: The application configuration containing settings like port, auth directory, API keys // - cfg: The application configuration containing settings like port, auth directory, API keys
// - configPath: The path to the configuration file for watching changes // - configPath: The path to the configuration file for watching changes
func StartService(cfg *config.Config, configPath string) { func StartService(cfg *config.Config, configPath string) {
// Track the current active clients for graceful shutdown persistence.
var activeClients map[string]interfaces.Client
var activeClientsMu sync.RWMutex
// Create a pool of API clients, one for each token file found. // Create a pool of API clients, one for each token file found.
cliClients := make(map[string]interfaces.Client) cliClients := make(map[string]interfaces.Client)
successfulAuthCount := 0 successfulAuthCount := 0
@@ -141,6 +144,24 @@ func StartService(cfg *config.Config, configPath string) {
cliClients[path] = qwenClient cliClients[path] = qwenClient
successfulAuthCount++ successfulAuthCount++
} }
} else if tokenType == "gemini-web" {
var ts gemini.GeminiWebTokenStorage
if err = json.Unmarshal(data, &ts); err == nil {
log.Info("Initializing gemini web authentication for token...")
geminiWebClient, errClient := client.NewGeminiWebClient(cfg, &ts, path)
if errClient != nil {
log.Errorf("failed to create gemini web client for token %s: %v", path, errClient)
return errClient
}
if geminiWebClient.IsReady() {
log.Info("Authentication successful.")
geminiWebClient.EnsureRegistered()
} else {
log.Info("Client created. Authentication pending (background retry in progress).")
}
cliClients[path] = geminiWebClient
successfulAuthCount++
}
} }
} }
return nil return nil
@@ -165,6 +186,20 @@ func StartService(cfg *config.Config, configPath string) {
allClients := clientsToSlice(cliClients) allClients := clientsToSlice(cliClients)
allClients = append(allClients, clientsToSlice(apiKeyClients)...) allClients = append(allClients, clientsToSlice(apiKeyClients)...)
// Initialize activeClients map for shutdown persistence
{
combined := make(map[string]interfaces.Client, len(cliClients)+len(apiKeyClients))
for k, v := range cliClients {
combined[k] = v
}
for k, v := range apiKeyClients {
combined[k] = v
}
activeClientsMu.Lock()
activeClients = combined
activeClientsMu.Unlock()
}
// Create and start the API server with the pool of clients in a separate goroutine. // Create and start the API server with the pool of clients in a separate goroutine.
apiServer := api.NewServer(cfg, allClients, configPath) apiServer := api.NewServer(cfg, allClients, configPath)
log.Infof("Starting API server on port %d", cfg.Port) log.Infof("Starting API server on port %d", cfg.Port)
@@ -184,6 +219,10 @@ func StartService(cfg *config.Config, configPath string) {
fileWatcher, errNewWatcher := watcher.NewWatcher(configPath, cfg.AuthDir, func(newClients map[string]interfaces.Client, newCfg *config.Config) { fileWatcher, errNewWatcher := watcher.NewWatcher(configPath, cfg.AuthDir, func(newClients map[string]interfaces.Client, newCfg *config.Config) {
// Update the API server with new clients and configuration when files change. // Update the API server with new clients and configuration when files change.
apiServer.UpdateClients(newClients, newCfg) apiServer.UpdateClients(newClients, newCfg)
// Keep an up-to-date snapshot for graceful shutdown persistence.
activeClientsMu.Lock()
activeClients = newClients
activeClientsMu.Unlock()
}) })
if errNewWatcher != nil { if errNewWatcher != nil {
log.Fatalf("failed to create file watcher: %v", errNewWatcher) log.Fatalf("failed to create file watcher: %v", errNewWatcher)
@@ -286,10 +325,33 @@ func StartService(cfg *config.Config, configPath string) {
cancelRefresh() cancelRefresh()
wgRefresh.Wait() wgRefresh.Wait()
// Stop file watcher early to avoid token save triggering reloads/registrations during shutdown.
watcherCancel()
if errStopWatcher := fileWatcher.Stop(); errStopWatcher != nil {
log.Errorf("error stopping file watcher: %v", errStopWatcher)
}
// Create a context with a timeout for the shutdown process. // Create a context with a timeout for the shutdown process.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
_ = cancel _ = cancel
// Persist tokens/cookies for all active clients before stopping services.
func() {
activeClientsMu.RLock()
snapshot := make([]interfaces.Client, 0, len(activeClients))
for _, c := range activeClients {
snapshot = append(snapshot, c)
}
activeClientsMu.RUnlock()
for _, c := range snapshot {
// Persist tokens/cookies then unregister/cleanup per client.
_ = c.SaveTokenToFile()
if u, ok := any(c).(interface{ UnregisterClient() }); ok {
u.UnregisterClient()
}
}
}()
// Stop the API server gracefully. // Stop the API server gracefully.
if err = apiServer.Stop(ctx); err != nil { if err = apiServer.Stop(ctx); err != nil {
log.Debugf("Error stopping API server: %v", err) log.Debugf("Error stopping API server: %v", err)

View File

@@ -58,6 +58,37 @@ type Config struct {
// RemoteManagement nests management-related options under 'remote-management'. // RemoteManagement nests management-related options under 'remote-management'.
RemoteManagement RemoteManagement `yaml:"remote-management" json:"-"` RemoteManagement RemoteManagement `yaml:"remote-management" json:"-"`
// GeminiWeb groups configuration for Gemini Web client
GeminiWeb GeminiWebConfig `yaml:"gemini-web" json:"gemini-web"`
}
// GeminiWebConfig nests Gemini Web related options under 'gemini-web'.
type GeminiWebConfig struct {
// Context enables JSON-based conversation reuse.
// Defaults to true if not set in YAML (see LoadConfig).
Context bool `yaml:"context" json:"context"`
// CodeMode, when true, enables coding mode behaviors for Gemini Web:
// - Attach the predefined "Coding partner" Gem
// - Enable XML wrapping hint for tool markup
// - Merge <think> content into visible content for tool-friendly output
CodeMode bool `yaml:"code-mode" json:"code-mode"`
// MaxCharsPerRequest caps the number of characters (runes) sent to
// Gemini Web in a single request. Long prompts will be split into
// multiple requests with a continuation hint, and only the final
// request will carry any files. When unset or <=0, a conservative
// default of 1,000,000 will be used.
MaxCharsPerRequest int `yaml:"max-chars-per-request" json:"max-chars-per-request"`
// DisableContinuationHint, when true, disables the continuation hint for split prompts.
// The hint is enabled by default.
DisableContinuationHint bool `yaml:"disable-continuation-hint,omitempty" json:"disable-continuation-hint,omitempty"`
// TokenRefreshSeconds controls the background cookie auto-refresh interval in seconds.
// When unset or <= 0, defaults to 540 seconds.
TokenRefreshSeconds int `yaml:"token-refresh-seconds" json:"token-refresh-seconds"`
} }
// RemoteManagement holds management API configuration under 'remote-management'. // RemoteManagement holds management API configuration under 'remote-management'.
@@ -145,6 +176,8 @@ func LoadConfig(configFile string) (*Config, error) {
// Unmarshal the YAML data into the Config struct. // Unmarshal the YAML data into the Config struct.
var config Config var config Config
// Set defaults before unmarshal so that absent keys keep defaults.
config.GeminiWeb.Context = true
if err = yaml.Unmarshal(data, &config); err != nil { if err = yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err) return nil, fmt.Errorf("failed to parse config file: %w", err)
} }

View File

@@ -227,6 +227,21 @@ func (w *Watcher) reloadConfig() bool {
if oldConfig.RequestRetry != newConfig.RequestRetry { if oldConfig.RequestRetry != newConfig.RequestRetry {
log.Debugf(" request-retry: %d -> %d", oldConfig.RequestRetry, newConfig.RequestRetry) log.Debugf(" request-retry: %d -> %d", oldConfig.RequestRetry, newConfig.RequestRetry)
} }
if oldConfig.GeminiWeb.Context != newConfig.GeminiWeb.Context {
log.Debugf(" gemini-web.context: %t -> %t", oldConfig.GeminiWeb.Context, newConfig.GeminiWeb.Context)
}
if oldConfig.GeminiWeb.MaxCharsPerRequest != newConfig.GeminiWeb.MaxCharsPerRequest {
log.Debugf(" gemini-web.max-chars-per-request: %d -> %d", oldConfig.GeminiWeb.MaxCharsPerRequest, newConfig.GeminiWeb.MaxCharsPerRequest)
}
if oldConfig.GeminiWeb.DisableContinuationHint != newConfig.GeminiWeb.DisableContinuationHint {
log.Debugf(" gemini-web.disable-continuation-hint: %t -> %t", oldConfig.GeminiWeb.DisableContinuationHint, newConfig.GeminiWeb.DisableContinuationHint)
}
if oldConfig.GeminiWeb.TokenRefreshSeconds != newConfig.GeminiWeb.TokenRefreshSeconds {
log.Debugf(" gemini-web.token-refresh-seconds: %d -> %d", oldConfig.GeminiWeb.TokenRefreshSeconds, newConfig.GeminiWeb.TokenRefreshSeconds)
}
if oldConfig.GeminiWeb.CodeMode != newConfig.GeminiWeb.CodeMode {
log.Debugf(" gemini-web.code-mode: %t -> %t", oldConfig.GeminiWeb.CodeMode, newConfig.GeminiWeb.CodeMode)
}
if len(oldConfig.APIKeys) != len(newConfig.APIKeys) { if len(oldConfig.APIKeys) != len(newConfig.APIKeys) {
log.Debugf(" api-keys count: %d -> %d", len(oldConfig.APIKeys), len(newConfig.APIKeys)) log.Debugf(" api-keys count: %d -> %d", len(oldConfig.APIKeys), len(newConfig.APIKeys))
} }
@@ -376,6 +391,11 @@ func (w *Watcher) createClientFromFile(path string, cfg *config.Config) (interfa
if err = json.Unmarshal(data, &ts); err == nil { if err = json.Unmarshal(data, &ts); err == nil {
return client.NewQwenClient(cfg, &ts), nil return client.NewQwenClient(cfg, &ts), nil
} }
} else if tokenType == "gemini-web" {
var ts gemini.GeminiWebTokenStorage
if err = json.Unmarshal(data, &ts); err == nil {
return client.NewGeminiWebClient(cfg, &ts, path)
}
} }
return nil, err return nil, err