1use 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
36const TOKEN_TTL: Duration = Duration::from_secs(20 * 60);
38
39const 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 acct_prefix: String,
75}
76
77pub struct GeminiWebProvider {
78 client: Client,
79 cookies: String,
81 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 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 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 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 (parts[5].trim(), parts[6].trim())
146 } else if parts.len() >= 2 {
147 (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 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 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 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 async fn invalidate_tokens(&self) {
255 let mut cache = self.token_cache.lock().await;
256 *cache = None;
257 }
258
259 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 inner[0] = json!([prompt, 0, null, null, null, null, 0]);
272 inner[1] = json!(["en"]);
274 inner[2] = json!(["", "", "", null, null, null, null, null, null, ""]);
276 inner[6] = json!([1]);
278 inner[7] = json!(1);
279 inner[10] = json!(1);
281 inner[11] = json!(0);
282 inner[17] = json!([[0]]);
285 inner[18] = json!(0);
286 inner[27] = json!(1);
288 inner[30] = json!([4]);
290 inner[53] = json!(0);
292 inner[59] = json!("CD1035A5-0E0E-4B68-B744-23C2D8960DF5");
294 inner[61] = json!([]);
296 inner[66] = json!([ts, 0]);
298 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 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 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 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 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 fn extract_tool_calls(text: &str) -> (String, Vec<(String, String)>) {
434 fn normalize_tool_markup(input: &str) -> String {
435 input
436 .replace("<", "<")
438 .replace(">", ">")
439 .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 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 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 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 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; }
819 }
820 }
821
822 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}