diff --git a/.dockerignore b/.dockerignore index f75c7ca6..cb4c5bfc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -27,5 +27,6 @@ conv/* config.yaml # Development/editor +bin/* .claude/* .vscode/* diff --git a/.gitignore b/.gitignore index 8edd14f9..f4a56164 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ config.yaml -*.exe bin/* docs/* logs/* @@ -9,4 +8,5 @@ auths/* .vscode/* .claude/* AGENTS.md -CLAUDE.md \ No newline at end of file +CLAUDE.md +*.exe \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index 47138331..e5cb5a94 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -72,6 +72,7 @@ func main() { var codexLogin bool var claudeLogin bool var qwenLogin bool + var geminiWebAuth bool var noBrowser bool var projectID string var configPath string @@ -81,6 +82,7 @@ func main() { flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex 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(&geminiWebAuth, "gemini-web-auth", false, "Auth Gemini Web using cookies") 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(&configPath, "config", "", "Configure File Path") @@ -151,6 +153,8 @@ func main() { cmd.DoClaudeLogin(cfg, options) } else if qwenLogin { cmd.DoQwenLogin(cfg, options) + } else if geminiWebAuth { + cmd.DoGeminiWebAuth(cfg) } else { // Start the main proxy service cmd.StartService(cfg, configFilePath) diff --git a/config.example.yaml b/config.example.yaml index 3b84c7e7..635c87a3 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -65,3 +65,23 @@ openai-compatibility: models: # The models supported by the provider. - name: "moonshotai/kimi-k2:free" # The actual model name. 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 ( 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 separate +# code-mode: false diff --git a/docker-compose.yml b/docker-compose.yml index 7984eaae..aadb5c56 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,4 +19,5 @@ services: - ./config.yaml:/CLIProxyAPI/config.yaml - ./auths:/root/.cli-proxy-api - ./logs:/CLIProxyAPI/logs + - ./conv:/CLIProxyAPI/conv restart: unless-stopped \ No newline at end of file diff --git a/internal/api/server.go b/internal/api/server.go index e8e56fe2..87f9d167 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -380,6 +380,8 @@ func (s *Server) UpdateClients(clients map[string]interfaces.Client, cfg *config switch cl := c.(type) { case *client.GeminiCLIClient: authFiles++ + case *client.GeminiWebClient: + authFiles++ case *client.CodexClient: if cl.GetAPIKey() == "" { authFiles++ diff --git a/internal/auth/gemini/gemini-web_token.go b/internal/auth/gemini/gemini-web_token.go new file mode 100644 index 00000000..3c6ebfe2 --- /dev/null +++ b/internal/auth/gemini/gemini-web_token.go @@ -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 +} diff --git a/internal/client/claude_client.go b/internal/client/claude_client.go index 9fb93b06..ba990feb 100644 --- a/internal/client/claude_client.go +++ b/internal/client/claude_client.go @@ -331,8 +331,16 @@ func (c *ClaudeClient) SendRawTokenCount(_ context.Context, _ string, _ []byte, // Returns: // - error: An error if the save operation fails, nil otherwise. func (c *ClaudeClient) SaveTokenToFile() error { - fileName := filepath.Join(c.cfg.AuthDir, fmt.Sprintf("claude-%s.json", c.tokenStorage.(*claude.ClaudeTokenStorage).Email)) - return c.tokenStorage.SaveTokenToFile(fileName) + // API-key based clients don't have a file-backed token to persist. + 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. diff --git a/internal/client/codex_client.go b/internal/client/codex_client.go index 87bb8105..59327f77 100644 --- a/internal/client/codex_client.go +++ b/internal/client/codex_client.go @@ -324,8 +324,16 @@ func (c *CodexClient) SendRawTokenCount(_ context.Context, _ string, _ []byte, _ // Returns: // - error: An error if the save operation fails, nil otherwise. func (c *CodexClient) SaveTokenToFile() error { - fileName := filepath.Join(c.cfg.AuthDir, fmt.Sprintf("codex-%s.json", c.tokenStorage.(*codex.CodexTokenStorage).Email)) - return c.tokenStorage.SaveTokenToFile(fileName) + // API-key based clients don't have a file-backed token to persist. + 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 diff --git a/internal/client/gemini-web/auth.go b/internal/client/gemini-web/auth.go new file mode 100644 index 00000000..ed21369f --- /dev/null +++ b/internal/client/gemini-web/auth.go @@ -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} } diff --git a/internal/client/gemini-web/client.go b/internal/client/gemini-web/client.go new file mode 100644 index 00000000..66dd4d71 --- /dev/null +++ b/internal/client/gemini-web/client.go @@ -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 +} diff --git a/internal/client/gemini-web/convert_ext.go b/internal/client/gemini-web/convert_ext.go new file mode 100644 index 00000000..fb7cdde0 --- /dev/null +++ b/internal/client/gemini-web/convert_ext.go @@ -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, "<", "<") + 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 +} diff --git a/internal/client/gemini-web/errors.go b/internal/client/gemini-web/errors.go new file mode 100644 index 00000000..6341b696 --- /dev/null +++ b/internal/client/gemini-web/errors.go @@ -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 +} diff --git a/internal/client/gemini-web/logging.go b/internal/client/gemini-web/logging.go new file mode 100644 index 00000000..161243a5 --- /dev/null +++ b/internal/client/gemini-web/logging.go @@ -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() +} diff --git a/internal/client/gemini-web/media.go b/internal/client/gemini-web/media.go new file mode 100644 index 00000000..f2b6d61c --- /dev/null +++ b/internal/client/gemini-web/media.go @@ -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 +} diff --git a/internal/client/gemini-web/models.go b/internal/client/gemini-web/models.go new file mode 100644 index 00000000..1051ff0c --- /dev/null +++ b/internal/client/gemini-web/models.go @@ -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" +} diff --git a/internal/client/gemini-web/persistence.go b/internal/client/gemini-web/persistence.go new file mode 100644 index 00000000..e9631da7 --- /dev/null +++ b/internal/client/gemini-web/persistence.go @@ -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) diff --git a/internal/client/gemini-web/prompt.go b/internal/client/gemini-web/prompt.go new file mode 100644 index 00000000..50760b36 --- /dev/null +++ b/internal/client/gemini-web/prompt.go @@ -0,0 +1,130 @@ +package geminiwebapi + +import ( + "math" + "regexp" + "strings" + "unicode/utf8" + + "github.com/tidwall/gjson" +) + +var ( + reThink = regexp.MustCompile(`(?s)^\s*.*?\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 ... 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)) +} diff --git a/internal/client/gemini-web/request.go b/internal/client/gemini-web/request.go new file mode 100644 index 00000000..58c9ec0b --- /dev/null +++ b/internal/client/gemini-web/request.go @@ -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) +} diff --git a/internal/client/gemini-web/types.go b/internal/client/gemini-web/types.go new file mode 100644 index 00000000..7edacbdf --- /dev/null +++ b/internal/client/gemini-web/types.go @@ -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) } diff --git a/internal/client/gemini-web_client.go b/internal/client/gemini-web_client.go new file mode 100644 index 00000000..73dcc626 --- /dev/null +++ b/internal/client/gemini-web_client.go @@ -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, ¶m) + 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 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, ¶m) + for _, l := range lines { + if l != "" { + dataChan <- []byte(l) + } + } + done := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte("[DONE]"), ¶m) + 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, ¶m) + for _, l := range lines { + if l != "" { + dataChan <- []byte(l) + } + } + } else { + lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte(thoughtOnly), ¶m) + 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, ¶m) + for _, l := range lines { + if l != "" { + dataChan <- []byte(l) + } + } + } else { + lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte(othersOnly), ¶m) + for _, l := range lines { + if l != "" { + dataChan <- []byte(l) + } + } + } + done := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte("[DONE]"), ¶m) + 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, ¶m) + for _, l := range lines { + if l != "" { + dataChan <- []byte(l) + } + } + } else { + lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, gemBytes, ¶m) + for _, l := range lines { + if l != "" { + dataChan <- []byte(l) + } + } + } + done := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte("[DONE]"), ¶m) + 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 ... 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("") + combined.WriteString(thought.String()) + combined.WriteString("\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) +} diff --git a/internal/cmd/gemini-web_auth.go b/internal/cmd/gemini-web_auth.go new file mode 100644 index 00000000..2e3329b7 --- /dev/null +++ b/internal/cmd/gemini-web_auth.go @@ -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) +} diff --git a/internal/cmd/run.go b/internal/cmd/run.go index fcf74330..57743e7e 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -48,6 +48,9 @@ import ( // - cfg: The application configuration containing settings like port, auth directory, API keys // - configPath: The path to the configuration file for watching changes 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. cliClients := make(map[string]interfaces.Client) successfulAuthCount := 0 @@ -141,6 +144,24 @@ func StartService(cfg *config.Config, configPath string) { cliClients[path] = qwenClient 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 @@ -165,6 +186,20 @@ func StartService(cfg *config.Config, configPath string) { allClients := clientsToSlice(cliClients) 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. apiServer := api.NewServer(cfg, allClients, configPath) 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) { // Update the API server with new clients and configuration when files change. apiServer.UpdateClients(newClients, newCfg) + // Keep an up-to-date snapshot for graceful shutdown persistence. + activeClientsMu.Lock() + activeClients = newClients + activeClientsMu.Unlock() }) if errNewWatcher != nil { log.Fatalf("failed to create file watcher: %v", errNewWatcher) @@ -286,10 +325,33 @@ func StartService(cfg *config.Config, configPath string) { cancelRefresh() 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. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) _ = 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. if err = apiServer.Stop(ctx); err != nil { log.Debugf("Error stopping API server: %v", err) diff --git a/internal/config/config.go b/internal/config/config.go index ce4fb257..9f53092d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -58,6 +58,37 @@ type Config struct { // RemoteManagement nests management-related options under 'remote-management'. 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 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'. @@ -145,6 +176,8 @@ func LoadConfig(configFile string) (*Config, error) { // Unmarshal the YAML data into the Config struct. 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 { return nil, fmt.Errorf("failed to parse config file: %w", err) } diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index ad7c18f3..2264a204 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -227,6 +227,21 @@ func (w *Watcher) reloadConfig() bool { if 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) { 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 { 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