Skip to main content

codetether_agent/provider/
gemini_web.rs

1//! Gemini Web provider  drives the Gemini chat UI's undocumented
2//! BardChatUi endpoint using browser cookies stored in HashiCorp Vault.
3//!
4//! This provider reverse-engineers the same HTTP request the Gemini web app
5//! sends so no official API key is required.  Authentication is entirely
6//! cookie-based (Netscape cookies.txt from Cookie-Editor stored in Vault under
7//! `secret/codetether/providers/gemini-web` as the `cookies` key).
8//!
9//! Supported models (Gemini 3 / 3.1 family):
10//! - `gemini-web-fast`       Gemini 3 Fast (mode_id fbb127bbb056c959)
11//! - `gemini-web-thinking`   Gemini 3 Thinking (mode_id 5bf011840784117a)
12//! - `gemini-web-pro`        Gemini 3.1 Pro (mode_id 9d8ca3786ebdfbea)
13//! - `gemini-web-deep-think` Gemini 3 Deep Think (mode_id e6fa609c3fa255c0)
14//!
15//! The model is selected via the `x-goog-ext-525001261-jspb` request header.
16
17use super::util;
18use super::{
19    CompletionRequest, CompletionResponse, ContentPart, FinishReason, Message, ModelInfo, Provider,
20    Role, StreamChunk, Usage,
21};
22use anyhow::{Context, Result};
23use async_trait::async_trait;
24use futures::StreamExt as _;
25use regex::Regex;
26use reqwest::Client;
27use serde_json::{Value, json};
28use std::sync::OnceLock;
29use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
30use tokio::sync::Mutex;
31
32const GEMINI_ORIGIN: &str = "https://gemini.google.com";
33const GEMINI_STREAM_PATH: &str =
34    "/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate";
35
36/// How long cached session tokens remain valid before re-scraping the home page.
37const TOKEN_TTL: Duration = Duration::from_secs(20 * 60);
38
39/// (model_id, mode_id, display_name, context_window)
40const MODELS: &[(&str, &str, &str, usize)] = &[
41    (
42        "gemini-web-fast",
43        "fbb127bbb056c959",
44        "Gemini 3 Fast",
45        1_048_576_usize,
46    ),
47    (
48        "gemini-web-thinking",
49        "5bf011840784117a",
50        "Gemini 3 Thinking",
51        1_048_576_usize,
52    ),
53    (
54        "gemini-web-pro",
55        "9d8ca3786ebdfbea",
56        "Gemini 3.1 Pro",
57        1_048_576_usize,
58    ),
59    (
60        "gemini-web-deep-think",
61        "e6fa609c3fa255c0",
62        "Gemini 3 Deep Think",
63        1_048_576_usize,
64    ),
65];
66
67#[derive(Clone)]
68struct SessionTokens {
69    at_token: String,
70    f_sid: String,
71    bl: String,
72    /// Path prefix extracted from the /u/N/ portion of the home-page redirect
73    /// URL (e.g. "/u/1"). Empty for the primary account which uses no prefix.
74    acct_prefix: String,
75}
76
77pub struct GeminiWebProvider {
78    client: Client,
79    /// Raw contents of cookies.txt (Netscape / Cookie-Editor export format)
80    cookies: String,
81    /// Cached session tokens with the instant they were fetched.
82    token_cache: Mutex<Option<(SessionTokens, Instant)>>,
83}
84
85impl std::fmt::Debug for GeminiWebProvider {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.debug_struct("GeminiWebProvider")
88            .field("cookies", &"[redacted]")
89            .finish_non_exhaustive()
90    }
91}
92
93impl GeminiWebProvider {
94    /// Create a new provider from Netscape cookies.txt content.
95    pub fn new(cookies: String) -> Result<Self> {
96        let client = Client::builder()
97            .user_agent(
98                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
99                 AppleWebKit/537.36 (KHTML, like Gecko) \
100                 Chrome/131.0.0.0 Safari/537.36",
101            )
102            .connect_timeout(std::time::Duration::from_secs(30))
103            .timeout(std::time::Duration::from_secs(600))
104            .build()
105            .context("Failed to build reqwest client for GeminiWebProvider")?;
106        Ok(Self {
107            client,
108            cookies,
109            token_cache: Mutex::new(None),
110        })
111    }
112
113    /// Convert a Netscape cookies.txt (7 tab-separated columns) into a
114    /// `Cookie: name=value; ...` header string.
115    ///
116    /// Standard Netscape format columns:
117    ///   0: domain  1: flag  2: path  3: secure  4: expiration  5: name  6: value
118    ///
119    /// If only 2 columns are present (simple `name\tvalue` format), those are
120    /// used directly so the function remains backward-compatible.
121    fn cookie_header(&self) -> String {
122        self.cookies
123            .lines()
124            .filter_map(|line| {
125                let t = line.trim();
126                if t.is_empty() {
127                    return None;
128                }
129                // "#HttpOnly_" prefix marks HttpOnly cookies in Netscape format —
130                // strip it so the cookie is still included.  Pure comment lines
131                // (e.g. the header block starting with "# Netscape") are skipped.
132                let line = if let Some(rest) = t.strip_prefix("#HttpOnly_") {
133                    rest
134                } else if t.starts_with('#') {
135                    return None;
136                } else {
137                    t
138                };
139                Some(line)
140            })
141            .filter_map(|line| {
142                let parts: Vec<&str> = line.split('\t').collect();
143                let (name, value) = if parts.len() >= 7 {
144                    // Standard Netscape format
145                    (parts[5].trim(), parts[6].trim())
146                } else if parts.len() >= 2 {
147                    // Simple name\tvalue format (backward compat)
148                    (parts[0].trim(), parts[1].trim())
149                } else {
150                    return None;
151                };
152                if name.is_empty() {
153                    return None;
154                }
155                Some(format!("{name}={value}"))
156            })
157            .collect::<Vec<_>>()
158            .join("; ")
159    }
160
161    /// GET the Gemini home page and extract the three tokens we need:
162    ///   - `at_token`  XSRF token (key `thykhd` or legacy `SNlM0e`)
163    ///   - `f_sid`     session ID (`FdrFJe`)
164    ///   - `bl`        build label (`cfb2h`)
165    ///
166    /// Regexes are compiled once and reused across calls via `OnceLock`.
167    async fn get_session_tokens(&self) -> Result<SessionTokens> {
168        static RE_NEW: OnceLock<Regex> = OnceLock::new();
169        static RE_OLD: OnceLock<Regex> = OnceLock::new();
170        static RE_BL: OnceLock<Regex> = OnceLock::new();
171        static RE_SID: OnceLock<Regex> = OnceLock::new();
172
173        let re_new = RE_NEW.get_or_init(|| Regex::new(r#""thykhd":"([^"]+)""#).unwrap());
174        let re_old = RE_OLD.get_or_init(|| Regex::new(r#""SNlM0e":"([^"]+)""#).unwrap());
175        let re_bl = RE_BL.get_or_init(|| Regex::new(r#""cfb2h":"([^"]+)""#).unwrap());
176        let re_sid = RE_SID.get_or_init(|| Regex::new(r#""FdrFJe":"([^"]+)""#).unwrap());
177
178        let cookie_hdr = self.cookie_header();
179        let resp = self
180            .client
181            .get(GEMINI_ORIGIN)
182            .header("Cookie", &cookie_hdr)
183            .send()
184            .await
185            .context("Failed to fetch Gemini home page")?;
186
187        // Extract the account prefix from the final URL after redirects.
188        // Multi-account: redirects to /u/N/app  → prefix is "/u/N"
189        // Primary account: redirects to /app    → prefix is ""
190        let acct_prefix: String = {
191            static RE_ACCT: OnceLock<Regex> = OnceLock::new();
192            let re = RE_ACCT.get_or_init(|| Regex::new(r"(/u/\d+)/").unwrap());
193            re.captures(resp.url().path())
194                .map(|c| c[1].to_string())
195                .unwrap_or_default()
196        };
197        tracing::debug!(acct_prefix = %acct_prefix, final_url = %resp.url(), "Gemini home page resolved");
198
199        let html = resp
200            .text()
201            .await
202            .context("Failed to read Gemini home page body")?;
203
204        let at_token = if let Some(cap) = re_new.captures(&html) {
205            cap[1].to_string()
206        } else if let Some(cap) = re_old.captures(&html) {
207            cap[1].to_string()
208        } else {
209            anyhow::bail!(
210                "Could not find Gemini at-token (thykhd / SNlM0e)  \
211                 cookies may be expired or invalid"
212            );
213        };
214
215        let bl = re_bl
216            .captures(&html)
217            .map(|c| c[1].to_string())
218            .unwrap_or_default();
219        let f_sid = re_sid
220            .captures(&html)
221            .map(|c| c[1].to_string())
222            .unwrap_or_default();
223
224        if bl.is_empty() {
225            tracing::warn!("Gemini bl token not found in home page — request may fail");
226        }
227        if f_sid.is_empty() {
228            tracing::warn!("Gemini f_sid token not found in home page — request may fail");
229        }
230
231        Ok(SessionTokens {
232            at_token,
233            f_sid,
234            bl,
235            acct_prefix,
236        })
237    }
238
239    /// Return session tokens, re-fetching only when the cached copy has
240    /// exceeded `TOKEN_TTL` (20 minutes).
241    async fn get_or_refresh_tokens(&self) -> Result<SessionTokens> {
242        let mut cache = self.token_cache.lock().await;
243        if let Some((ref tokens, fetched_at)) = *cache
244            && fetched_at.elapsed() < TOKEN_TTL
245        {
246            return Ok(tokens.clone());
247        }
248        let fresh = self.get_session_tokens().await?;
249        *cache = Some((fresh.clone(), Instant::now()));
250        Ok(fresh)
251    }
252
253    /// Drop cached session tokens so the next request re-scrapes fresh values.
254    async fn invalidate_tokens(&self) {
255        let mut cache = self.token_cache.lock().await;
256        *cache = None;
257    }
258
259    /// Build the `f.req` JSON payload — a two-element outer array whose
260    /// second slot is a JSON-encoded 69-element inner array.
261    ///
262    /// Index layout matches the Python GemChat reference implementation.
263    fn build_freq(prompt: &str) -> String {
264        let ts = SystemTime::now()
265            .duration_since(UNIX_EPOCH)
266            .unwrap_or_default()
267            .as_secs();
268
269        let mut inner: Vec<Value> = vec![Value::Null; 69];
270        // [0]  prompt tuple — single array, NOT double-wrapped
271        inner[0] = json!([prompt, 0, null, null, null, null, 0]);
272        // [1]  language
273        inner[1] = json!(["en"]);
274        // [2]  conversation continuation ids (empty strings for new convo)
275        inner[2] = json!(["", "", "", null, null, null, null, null, null, ""]);
276        // [3]-[5] null
277        inner[6] = json!([1]);
278        inner[7] = json!(1);
279        // [8]-[9] null
280        inner[10] = json!(1);
281        inner[11] = json!(0);
282        // [12] null
283        // [13]-[16] null
284        inner[17] = json!([[0]]);
285        inner[18] = json!(0);
286        // [19]-[26] null
287        inner[27] = json!(1);
288        // [28]-[29] null
289        inner[30] = json!([4]);
290        // [31]-[52] null
291        inner[53] = json!(0);
292        // [54]-[58] null
293        inner[59] = json!("CD1035A5-0E0E-4B68-B744-23C2D8960DF5");
294        // [60] null
295        inner[61] = json!([]);
296        // [62]-[65] null
297        inner[66] = json!([ts, 0]);
298        // [67] null
299        inner[68] = json!(2);
300
301        debug_assert_eq!(
302            inner.len(),
303            69,
304            "f.req inner list must be exactly 69 elements"
305        );
306
307        let inner_json = serde_json::to_string(&inner).unwrap_or_default();
308        serde_json::to_string(&json!([null, inner_json])).unwrap_or_default()
309    }
310
311    /// Walk streaming lines and return the longest parseable answer text.
312    ///
313    /// Wire format: each line is a JSON array of events:
314    ///   `[["wrb.fr", key_or_null, inner_json_str, ...], ...]`
315    /// The inner JSON has the response text at `inner[4][0][1][0]`.
316    /// Gemini sends cumulative chunks; we take the longest (= most complete).
317    fn extract_text(raw: &str) -> String {
318        let mut best = String::new();
319        for line in raw.lines() {
320            let line = line.trim();
321            if line.is_empty() || !line.starts_with('[') {
322                continue;
323            }
324            let Ok(outer) = serde_json::from_str::<Value>(line) else {
325                continue;
326            };
327            let Some(events) = outer.as_array() else {
328                continue;
329            };
330            // Each element of the outer array is one event: ["wrb.fr", null, inner_str, ...]
331            for event in events {
332                let Some(ev) = event.as_array() else { continue };
333                let Some(inner_str) = ev.get(2).and_then(Value::as_str) else {
334                    continue;
335                };
336                if !inner_str.starts_with('[') {
337                    continue;
338                }
339                let Ok(inner) = serde_json::from_str::<Value>(inner_str) else {
340                    continue;
341                };
342                if let Some(text) = inner
343                    .get(4)
344                    .and_then(|v| v.get(0))
345                    .and_then(|v| v.get(1))
346                    .and_then(|v| v.get(0))
347                    .and_then(Value::as_str)
348                    && text.len() > best.len()
349                {
350                    best = text.to_string();
351                }
352            }
353        }
354        best
355    }
356
357    /// Extract Gemini protocol-level error code from SSE-like body lines.
358    ///
359    /// Looks for events like: `[["e",5,null,null,469]]`
360    fn extract_protocol_error_code(raw: &str) -> Option<i64> {
361        for line in raw.lines() {
362            let line = line.trim();
363            if line.is_empty() || !line.starts_with('[') {
364                continue;
365            }
366            let Ok(events_val) = serde_json::from_str::<Value>(line) else {
367                continue;
368            };
369            let Some(events) = events_val.as_array() else {
370                continue;
371            };
372            for event in events {
373                let Some(ev) = event.as_array() else { continue };
374                let Some(kind) = ev.first().and_then(Value::as_str) else {
375                    continue;
376                };
377                if kind != "e" {
378                    continue;
379                }
380                if let Some(code) = ev.get(4).and_then(Value::as_i64) {
381                    return Some(code);
382                }
383                if let Some(code) = ev.last().and_then(Value::as_i64) {
384                    return Some(code);
385                }
386            }
387        }
388        None
389    }
390
391    /// Best-effort extraction of Gemini request id tokens like `r_52bc...`
392    /// from protocol frames for diagnostics.
393    fn extract_protocol_request_id(raw: &str) -> Option<String> {
394        static RE_REQ_ID: OnceLock<Regex> = OnceLock::new();
395        let re = RE_REQ_ID.get_or_init(|| Regex::new(r"(r_[A-Za-z0-9]+)").unwrap());
396        re.captures(raw)
397            .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))
398    }
399
400    fn compact_body_snippet(raw: &str, max_chars: usize) -> String {
401        raw.chars()
402            .take(max_chars)
403            .collect::<String>()
404            .replace(['\n', '\r'], " ")
405            .trim()
406            .to_string()
407    }
408
409    fn format_protocol_error(code: i64, model: &str, raw: &str) -> String {
410        let req_id = Self::extract_protocol_request_id(raw)
411            .map(|id| format!(" request_id={id}."))
412            .unwrap_or_default();
413        let snippet = Self::compact_body_snippet(raw, 240);
414
415        match code {
416            469 => format!(
417                "Gemini Web backend rejected the request (protocol code 469) for model `{model}`.{req_id} \
418                 This is usually a transient web-backend/model-route issue or account entitlement mismatch. \
419                 Try again, or switch to `gemini-web-thinking` / `gemini-web-fast`. Payload snippet: {snippet}"
420            ),
421            _ => format!(
422                "Gemini Web backend returned protocol status code {code} for model `{model}`.{req_id} \
423                 Payload snippet: {snippet}"
424            ),
425        }
426    }
427
428    /// Parse `<tool_call>{...}</tool_call>` JSON blocks from model text.
429    ///
430    /// Returns:
431    /// - cleaned_text: original text with tool-call blocks removed
432    /// - calls: parsed `(name, arguments_json_string)` tuples
433    fn extract_tool_calls(text: &str) -> (String, Vec<(String, String)>) {
434        fn normalize_tool_markup(input: &str) -> String {
435            input
436                // HTML-escaped tags from some markdown renderers
437                .replace("&lt;", "<")
438                .replace("&gt;", ">")
439                // Backslash-escaped XML markers and markdown escapes
440                .replace("\\<", "<")
441                .replace("\\>", ">")
442                .replace("\\_", "_")
443        }
444
445        static RE_TOOL_CALL_BLOCK: OnceLock<Regex> = OnceLock::new();
446        static RE_TOOL_RESULT_BLOCK: OnceLock<Regex> = OnceLock::new();
447
448        let re = RE_TOOL_CALL_BLOCK.get_or_init(|| {
449            Regex::new(r"(?s)<tool_call>\s*(?:```(?:json)?\s*)?(\{.*?\})(?:\s*```)?\s*</tool_call>")
450                .unwrap()
451        });
452        let re_tool_result = RE_TOOL_RESULT_BLOCK
453            .get_or_init(|| Regex::new(r"(?s)<tool_result>.*?</tool_result>").unwrap());
454
455        let normalized = normalize_tool_markup(text);
456
457        let mut calls: Vec<(String, String)> = Vec::new();
458        for captures in re.captures_iter(&normalized) {
459            let Some(block_json) = captures.get(1).map(|m| m.as_str()) else {
460                continue;
461            };
462            let Ok(value) = serde_json::from_str::<Value>(block_json) else {
463                continue;
464            };
465            let Some(name) = value.get("name").and_then(Value::as_str) else {
466                continue;
467            };
468            let name = name.trim();
469            if name.is_empty() {
470                continue;
471            }
472            let arguments = value.get("arguments").cloned().unwrap_or_else(|| json!({}));
473            let args_json = serde_json::to_string(&arguments).unwrap_or_else(|_| "{}".to_string());
474            calls.push((name.to_string(), args_json));
475        }
476
477        if calls.is_empty() {
478            return (text.to_string(), Vec::new());
479        }
480
481        let without_calls = re.replace_all(&normalized, "").to_string();
482        let cleaned = re_tool_result
483            .replace_all(&without_calls, "")
484            .trim()
485            .to_string();
486        (cleaned, calls)
487    }
488
489    /// Look up the `mode_id` string for a given model identifier.
490    fn mode_id(model: &str) -> &'static str {
491        MODELS
492            .iter()
493            .find(|(id, _, _, _)| *id == model)
494            .map(|(_, mid, _, _)| *mid)
495            .unwrap_or("fbb127bbb056c959")
496    }
497
498    /// Build a `RequestBuilder` for the StreamGenerate endpoint.
499    async fn build_request(&self, prompt: &str, model: &str) -> Result<reqwest::RequestBuilder> {
500        let tokens = self
501            .get_or_refresh_tokens()
502            .await
503            .context("Failed to obtain Gemini session tokens")?;
504
505        let cookie_hdr = self.cookie_header();
506        let freq = Self::build_freq(prompt);
507        let mode_id = Self::mode_id(model);
508
509        let ext_header = {
510            let v: Value = json!([
511                1,
512                null,
513                null,
514                null,
515                mode_id,
516                null,
517                null,
518                0,
519                [4],
520                null,
521                null,
522                3
523            ]);
524            serde_json::to_string(&v).unwrap_or_default()
525        };
526
527        let reqid = (SystemTime::now()
528            .duration_since(UNIX_EPOCH)
529            .unwrap_or_default()
530            .as_millis()
531            % 900_000
532            + 100_000)
533            .to_string();
534
535        let endpoint = format!(
536            "https://gemini.google.com{}{}",
537            tokens.acct_prefix, GEMINI_STREAM_PATH
538        );
539        tracing::debug!(endpoint = %endpoint, "Gemini StreamGenerate endpoint");
540        let url = reqwest::Url::parse_with_params(
541            &endpoint,
542            &[
543                ("bl", tokens.bl.as_str()),
544                ("f.sid", tokens.f_sid.as_str()),
545                ("hl", "en"),
546                ("pageId", "none"),
547                ("_reqid", reqid.as_str()),
548                ("rt", "c"),
549            ],
550        )
551        .context("Failed to build Gemini endpoint URL")?;
552
553        Ok(self
554            .client
555            .post(url)
556            .header("Cookie", cookie_hdr)
557            .header("X-Same-Domain", "1")
558            .header("Origin", GEMINI_ORIGIN)
559            .header("Referer", format!("{}/app", GEMINI_ORIGIN))
560            .header("Accept", "*/*")
561            .header("Accept-Language", "en-US,en;q=0.9")
562            .header("Cache-Control", "no-cache")
563            .header("Pragma", "no-cache")
564            .header("sec-fetch-dest", "empty")
565            .header("sec-fetch-mode", "cors")
566            .header("sec-fetch-site", "same-origin")
567            .header("x-goog-ext-525001261-jspb", ext_header)
568            .form(&[("f.req", freq), ("at", tokens.at_token)]))
569    }
570
571    /// Core blocking request: POST and collect the complete response.
572    async fn ask(&self, prompt: &str, model: &str) -> Result<String> {
573        for attempt in 0..=1 {
574            let resp = self
575                .build_request(prompt, model)
576                .await?
577                .send()
578                .await
579                .context("Failed to send request to Gemini StreamGenerate")?;
580
581            if !resp.status().is_success() {
582                let status = resp.status();
583                let body = resp.text().await.unwrap_or_default();
584                if attempt == 0 {
585                    tracing::warn!(
586                        status = %status,
587                        body_prefix = %util::truncate_bytes_safe(&body, 200),
588                        "Gemini request failed; invalidating cached tokens and retrying once"
589                    );
590                    self.invalidate_tokens().await;
591                    continue;
592                }
593                anyhow::bail!(
594                    "Gemini StreamGenerate returned HTTP {}: {}",
595                    status,
596                    util::truncate_bytes_safe(&body, 500)
597                );
598            }
599
600            let body = resp
601                .text()
602                .await
603                .context("Failed to read Gemini response body")?;
604            let text = Self::extract_text(&body);
605            if text.is_empty() {
606                let protocol_code = Self::extract_protocol_error_code(&body);
607                if attempt == 0 {
608                    tracing::warn!(
609                        body_prefix = %util::truncate_bytes_safe(&body, 200),
610                        "Gemini response had no parseable text; invalidating cached tokens and retrying once"
611                    );
612                    self.invalidate_tokens().await;
613                    continue;
614                }
615                if let Some(code) = protocol_code {
616                    anyhow::bail!(Self::format_protocol_error(code, model, &body));
617                }
618                anyhow::bail!(
619                    "No text found in Gemini response for model `{}`. Payload snippet: {}",
620                    model,
621                    Self::compact_body_snippet(&body, 240)
622                );
623            }
624            return Ok(text);
625        }
626
627        anyhow::bail!("Gemini request retry exhausted without a successful response")
628    }
629}
630
631#[async_trait]
632impl Provider for GeminiWebProvider {
633    fn name(&self) -> &str {
634        "gemini-web"
635    }
636
637    async fn list_models(&self) -> Result<Vec<ModelInfo>> {
638        Ok(MODELS
639            .iter()
640            .map(|(id, _, label, ctx)| ModelInfo {
641                id: id.to_string(),
642                name: label.to_string(),
643                provider: "gemini-web".to_string(),
644                context_window: *ctx,
645                max_output_tokens: Some(65_536),
646                supports_vision: false,
647                supports_tools: false,
648                supports_streaming: true,
649                input_cost_per_million: Some(0.0),
650                output_cost_per_million: Some(0.0),
651            })
652            .collect())
653    }
654
655    async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
656        let prompt = request
657            .messages
658            .iter()
659            .map(|m| {
660                let role = match m.role {
661                    Role::System => "System",
662                    Role::User => "User",
663                    Role::Assistant => "Assistant",
664                    Role::Tool => "Tool",
665                };
666                let text = m
667                    .content
668                    .iter()
669                    .filter_map(|p| match p {
670                        ContentPart::Text { text } => Some(text.clone()),
671                        ContentPart::ToolCall {
672                            name, arguments, ..
673                        } => Some(format!("[Called tool: {name}({arguments})]")),
674                        ContentPart::ToolResult { content, .. } => {
675                            Some(format!("[Tool result]\n{content}"))
676                        }
677                        _ => None,
678                    })
679                    .collect::<Vec<_>>()
680                    .join("\n");
681                format!("{role}: {text}")
682            })
683            .collect::<Vec<_>>()
684            .join("\n");
685
686        let text = self
687            .ask(&prompt, &request.model)
688            .await
689            .context("Gemini Web completion failed")?;
690
691        let (cleaned_text, parsed_tool_calls) = Self::extract_tool_calls(&text);
692        let mut content: Vec<ContentPart> = Vec::new();
693        if !cleaned_text.is_empty() {
694            content.push(ContentPart::Text { text: cleaned_text });
695        }
696
697        for (idx, (name, arguments)) in parsed_tool_calls.iter().enumerate() {
698            let ts = SystemTime::now()
699                .duration_since(UNIX_EPOCH)
700                .unwrap_or_default()
701                .as_millis();
702            content.push(ContentPart::ToolCall {
703                id: format!("gwc_{ts}_{idx}"),
704                name: name.clone(),
705                arguments: arguments.clone(),
706                thought_signature: None,
707            });
708        }
709
710        if content.is_empty() {
711            content.push(ContentPart::Text { text });
712        }
713
714        let finish_reason = if parsed_tool_calls.is_empty() {
715            FinishReason::Stop
716        } else {
717            tracing::info!(
718                model = %request.model,
719                num_calls = parsed_tool_calls.len(),
720                "Parsed tool calls from Gemini web text response"
721            );
722            FinishReason::ToolCalls
723        };
724
725        Ok(CompletionResponse {
726            message: Message {
727                role: Role::Assistant,
728                content,
729            },
730            usage: Usage {
731                prompt_tokens: 0,
732                completion_tokens: 0,
733                total_tokens: 0,
734                cache_read_tokens: None,
735                cache_write_tokens: None,
736            },
737            finish_reason,
738        })
739    }
740
741    async fn complete_stream(
742        &self,
743        request: CompletionRequest,
744    ) -> Result<futures::stream::BoxStream<'static, StreamChunk>> {
745        let prompt = request
746            .messages
747            .iter()
748            .map(|m| {
749                let role = match m.role {
750                    Role::System => "System",
751                    Role::User => "User",
752                    Role::Assistant => "Assistant",
753                    Role::Tool => "Tool",
754                };
755                let text = m
756                    .content
757                    .iter()
758                    .filter_map(|p| match p {
759                        ContentPart::Text { text } => Some(text.clone()),
760                        ContentPart::ToolCall {
761                            name, arguments, ..
762                        } => Some(format!("[Called tool: {name}({arguments})]")),
763                        ContentPart::ToolResult { content, .. } => {
764                            Some(format!("[Tool result]\n{content}"))
765                        }
766                        _ => None,
767                    })
768                    .collect::<Vec<_>>()
769                    .join("\n");
770                format!("{role}: {text}")
771            })
772            .collect::<Vec<_>>()
773            .join("\n");
774
775        let resp = self
776            .build_request(&prompt, &request.model)
777            .await?
778            .send()
779            .await
780            .context("Failed to send streaming request to Gemini")?;
781
782        if !resp.status().is_success() {
783            let status = resp.status();
784            let body = resp.text().await.unwrap_or_default();
785            anyhow::bail!(
786                "Gemini StreamGenerate returned HTTP {}: {}",
787                status,
788                util::truncate_bytes_safe(&body, 500)
789            );
790        }
791
792        // Process the byte stream and emit text deltas as chunks arrive.
793        // Gemini sends cumulative text, so we track prev_len and emit only
794        // the newly-arrived portion on each iteration.
795        let byte_stream = resp.bytes_stream();
796        let model_for_errors = request.model.clone();
797        let (tx, rx) = futures::channel::mpsc::channel::<StreamChunk>(32);
798
799        tokio::spawn(async move {
800            futures::pin_mut!(byte_stream);
801            let mut buf = String::new();
802            let mut prev_len: usize = 0;
803            let mut tx = tx;
804
805            while let Some(chunk_result) = byte_stream.next().await {
806                let Ok(bytes) = chunk_result else { break };
807                let Ok(s) = std::str::from_utf8(&bytes) else {
808                    continue;
809                };
810                buf.push_str(s);
811
812                let current_text = Self::extract_text(&buf);
813                if current_text.len() > prev_len {
814                    let delta = current_text[prev_len..].to_string();
815                    prev_len = current_text.len();
816                    if tx.try_send(StreamChunk::Text(delta)).is_err() {
817                        return; // receiver dropped
818                    }
819                }
820            }
821
822            // Final pass for any remaining delta in the last chunk
823            let final_text = Self::extract_text(&buf);
824            if final_text.len() > prev_len {
825                let _ = tx.try_send(StreamChunk::Text(final_text[prev_len..].to_string()));
826                prev_len = final_text.len();
827            }
828
829            if prev_len == 0 {
830                if let Some(code) = Self::extract_protocol_error_code(&buf) {
831                    let _ = tx.try_send(StreamChunk::Error(Self::format_protocol_error(
832                        code,
833                        &model_for_errors,
834                        &buf,
835                    )));
836                    return;
837                }
838                if !buf.trim().is_empty() {
839                    let _ = tx.try_send(StreamChunk::Error(format!(
840                        "Gemini returned no text payload for model `{}`. Payload snippet: {}",
841                        model_for_errors,
842                        Self::compact_body_snippet(&buf, 240)
843                    )));
844                    return;
845                }
846            }
847            let _ = tx.try_send(StreamChunk::Done { usage: None });
848        });
849
850        let stream = futures::stream::unfold(rx, |mut rx| async {
851            use futures::StreamExt as _;
852            rx.next().await.map(|chunk| (chunk, rx))
853        });
854
855        Ok(Box::pin(stream))
856    }
857}
858
859#[cfg(test)]
860mod tests {
861    use super::GeminiWebProvider;
862
863    #[test]
864    fn extract_tool_calls_returns_calls_and_cleaned_text() {
865        let text = r#"I will inspect the tree first.
866<tool_call>
867{"name":"tree","arguments":{"depth":3,"path":"."}}
868</tool_call>
869Then grep for key strings.
870<tool_call>
871{"name":"grep","arguments":{"pattern":"nextdoor","is_regex":false}}
872</tool_call>"#;
873
874        let (cleaned, calls) = GeminiWebProvider::extract_tool_calls(text);
875        assert_eq!(calls.len(), 2);
876        assert_eq!(calls[0].0, "tree");
877        assert!(calls[0].1.contains("\"depth\":3"));
878        assert_eq!(calls[1].0, "grep");
879        assert!(cleaned.contains("I will inspect the tree first."));
880        assert!(cleaned.contains("Then grep for key strings."));
881        assert!(!cleaned.contains("<tool_call>"));
882    }
883
884    #[test]
885    fn extract_tool_calls_preserves_text_when_no_valid_blocks() {
886        let text = "<tool_call>{not-json}</tool_call>";
887        let (cleaned, calls) = GeminiWebProvider::extract_tool_calls(text);
888        assert!(calls.is_empty());
889        assert_eq!(cleaned, text);
890    }
891
892    #[test]
893    fn extract_tool_calls_accepts_escaped_tool_markup() {
894        let text = r#"I will invoke LSP now.
895\<tool\_call\>
896{"name":"lsp","arguments":{"action":"hover","file\_path":"api/src/a.ts","line":1,"column":1}}
897\</tool\_call\>"#;
898
899        let (cleaned, calls) = GeminiWebProvider::extract_tool_calls(text);
900        assert_eq!(calls.len(), 1);
901        assert_eq!(calls[0].0, "lsp");
902        assert!(calls[0].1.contains("\"file_path\":\"api/src/a.ts\""));
903        assert!(cleaned.contains("I will invoke LSP now."));
904        assert!(!cleaned.contains("tool_call"));
905    }
906
907    #[test]
908    fn extract_tool_calls_strips_tool_result_blocks_when_calls_present() {
909        let text = r#"<tool_call>{"name":"bash","arguments":{"command":"pwd"}}</tool_call>
910<tool_result>{"bash":"fake"}</tool_result>"#;
911        let (cleaned, calls) = GeminiWebProvider::extract_tool_calls(text);
912        assert_eq!(calls.len(), 1);
913        assert!(cleaned.is_empty());
914    }
915
916    #[test]
917    fn extract_protocol_error_code_reads_error_event() {
918        let raw = r#"
919)]}'
92025
921[["e",5,null,null,469]]
922"#;
923        assert_eq!(
924            GeminiWebProvider::extract_protocol_error_code(raw),
925            Some(469)
926        );
927    }
928
929    #[test]
930    fn extract_protocol_request_id_reads_wrapped_id() {
931        let raw = r#"[["wrb.fr",null,"[null,[null,\"r_52bc718fbddfc769\"],null]"]]"#;
932        assert_eq!(
933            GeminiWebProvider::extract_protocol_request_id(raw).as_deref(),
934            Some("r_52bc718fbddfc769")
935        );
936    }
937}