use std::path::{Path, PathBuf};
use reqwest::{header, Client, ClientBuilder, StatusCode};
use serde_json::{json, Value};
use tail_fin_common::TailFinError;
use crate::cookies::{load_netscape, Cookies};
use crate::parsing::{
extract_response_text, extract_session_tokens, extract_turn_ids, SessionTokens,
};
use crate::signing::{current_sapisidhash, ORIGIN};
use crate::types::GeminiResponse;
const APP_URL: &str = "https://gemini.google.com/app";
const STREAM_URL: &str =
"https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate";
const UPLOAD_URL: &str = "https://push.clients6.google.com/upload/";
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
pub struct GeminiClient {
http: Client,
cookies: Cookies,
}
impl GeminiClient {
pub fn new(cookies: Cookies) -> Result<Self, TailFinError> {
let http = ClientBuilder::new()
.user_agent(USER_AGENT)
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| TailFinError::Api(format!("build HTTP client: {e}")))?;
Ok(Self { http, cookies })
}
pub fn from_cookie_file(path: &Path) -> Result<Self, TailFinError> {
let cookies = load_netscape(path)?;
Self::new(cookies)
}
pub async fn ask(
&self,
prompt: &str,
attach: Option<&Path>,
) -> Result<GeminiResponse, TailFinError> {
self.ask_inner(prompt, attach, None).await
}
pub async fn ask_continue(
&self,
prompt: &str,
attach: Option<&Path>,
prev: &GeminiResponse,
) -> Result<GeminiResponse, TailFinError> {
let (cid, rid, rcid) = prev.require_continuation()?;
self.ask_continue_with_ids(prompt, attach, cid, rid, rcid)
.await
}
pub async fn ask_continue_with_ids(
&self,
prompt: &str,
attach: Option<&Path>,
cid: &str,
rid: &str,
rcid: &str,
) -> Result<GeminiResponse, TailFinError> {
validate_continuation_ids(cid, rid, rcid)?;
self.ask_inner(
prompt,
attach,
Some((cid.to_string(), rid.to_string(), rcid.to_string())),
)
.await
}
async fn ask_inner(
&self,
prompt: &str,
attach: Option<&Path>,
continuation: Option<(String, String, String)>,
) -> Result<GeminiResponse, TailFinError> {
let tokens = self.fetch_session_tokens().await?;
let files = if let Some(path) = attach {
let filename = filename_of(path)?;
let url = self.upload_file(path, &filename, &tokens.push_id).await?;
vec![(url, filename)]
} else {
vec![]
};
let body = self
.post_stream_generate(prompt, &tokens, &files, continuation.as_ref())
.await?;
let response = extract_response_text(&body)?;
let ids = extract_turn_ids(&body);
if let Some((sent_cid, _, _)) = continuation.as_ref() {
let sent_trim = sent_cid.trim();
match ids.conversation_id.as_deref().map(str::trim) {
Some(got) if got == sent_trim => {}
Some(got) => {
return Err(TailFinError::Api(format!(
"continuation silently dropped: sent conversation_id={sent_cid} but \
server returned {got}. The prior turn may have expired — start a \
new conversation with `ask` instead of `ask_continue`."
)));
}
None => {
return Err(TailFinError::Api(format!(
"continuation silently dropped: sent conversation_id={sent_cid} but \
server response carried no conversation_id. Retry or start fresh."
)));
}
}
}
Ok(GeminiResponse {
response,
conversation_id: ids.conversation_id,
response_id: ids.response_id,
choice_id: ids.choice_id,
})
}
async fn fetch_session_tokens(&self) -> Result<SessionTokens, TailFinError> {
let resp = self
.http
.get(APP_URL)
.header(header::COOKIE, self.cookies.to_header())
.header(header::USER_AGENT, USER_AGENT)
.send()
.await
.map_err(|e| TailFinError::Api(format!("fetch /app: {e}")))?;
let status = resp.status();
if !status.is_success() {
return Err(classify_status(status, "fetch /app"));
}
let html = resp
.text()
.await
.map_err(|e| TailFinError::Api(format!("/app body: {e}")))?;
extract_session_tokens(&html)
}
async fn upload_file(
&self,
path: &Path,
filename: &str,
push_id: &str,
) -> Result<String, TailFinError> {
let bytes = tokio::fs::read(path)
.await
.map_err(|e| TailFinError::Io(format!("read {}: {e}", path.display())))?;
let size = bytes.len();
let init_body = format!("File name={}", urlencoding::encode(filename));
let init = self
.http
.post(UPLOAD_URL)
.header("Push-ID", push_id)
.header("X-Tenant-Id", "bard-storage")
.header("X-Goog-Upload-Command", "start")
.header("X-Goog-Upload-Protocol", "resumable")
.header("X-Goog-Upload-Header-Content-Length", size.to_string())
.header(header::USER_AGENT, USER_AGENT)
.header(header::ORIGIN, ORIGIN)
.header(header::REFERER, "https://gemini.google.com/")
.header(header::COOKIE, self.cookies.to_header())
.header(
header::CONTENT_TYPE,
"application/x-www-form-urlencoded;charset=UTF-8",
)
.body(init_body)
.send()
.await
.map_err(|e| TailFinError::Api(format!("upload init: {e}")))?;
let init_status = init.status();
let session_url = init
.headers()
.get("X-Goog-Upload-URL")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
if !init_status.is_success() || session_url.is_none() {
if !init_status.is_success() {
return Err(classify_status(init_status, "upload init"));
}
let body = init.text().await.unwrap_or_default();
return Err(TailFinError::Api(format!(
"upload init succeeded but response lacked X-Goog-Upload-URL \
(Gemini protocol may have changed): {}",
truncate(&body, 300)
)));
}
let session_url = session_url.unwrap();
let resp = self
.http
.post(&session_url)
.header("X-Goog-Upload-Command", "upload, finalize")
.header("X-Goog-Upload-Offset", "0")
.header(header::USER_AGENT, USER_AGENT)
.body(bytes)
.send()
.await
.map_err(|e| TailFinError::Api(format!("upload finalize: {e}")))?;
let status = resp.status();
if !status.is_success() {
return Err(classify_status(status, "upload finalize"));
}
let body = resp
.text()
.await
.map_err(|e| TailFinError::Api(format!("upload finalize body: {e}")))?;
let url = body.trim().to_string();
if url.is_empty() {
return Err(TailFinError::Parse(
"upload finalize returned empty body".into(),
));
}
Ok(url)
}
async fn post_stream_generate(
&self,
prompt: &str,
tokens: &SessionTokens,
files: &[(String, String)],
continuation: Option<&(String, String, String)>,
) -> Result<String, TailFinError> {
let sapisid = self.cookies.require("SAPISID")?;
let auth = current_sapisidhash(sapisid);
let files_data: Vec<Value> = files
.iter()
.map(|(url, name)| json!([[url, 1], name]))
.collect();
let turn_ids: Value = match continuation {
Some((cid, rid, rcid)) => json!([cid, rid, rcid]),
None => json!([null, null, null]),
};
let inner = json!([
[prompt, 0, null, files_data, null, null, 0],
["en"],
turn_ids,
null,
null,
null,
[1],
0,
[],
[],
1,
0,
]);
let inner_str = serde_json::to_string(&inner)
.map_err(|e| TailFinError::Parse(format!("encode f.req inner: {e}")))?;
let f_req = json!([null, inner_str]).to_string();
let form = [("at", tokens.snlm0e.as_str()), ("f.req", f_req.as_str())];
let resp = self
.http
.post(STREAM_URL)
.query(&[
("bl", tokens.build_label.as_str()),
("_reqid", "0"),
("rt", "c"),
])
.header(header::COOKIE, self.cookies.to_header())
.header(header::USER_AGENT, USER_AGENT)
.header(header::ORIGIN, ORIGIN)
.header(header::REFERER, APP_URL)
.header(header::AUTHORIZATION, auth)
.header("X-Same-Domain", "1")
.form(&form)
.send()
.await
.map_err(|e| TailFinError::Api(format!("StreamGenerate: {e}")))?;
let status = resp.status();
if !status.is_success() {
return Err(classify_status(status, "StreamGenerate"));
}
let body = resp
.text()
.await
.map_err(|e| TailFinError::Api(format!("StreamGenerate body: {e}")))?;
Ok(body)
}
}
fn classify_status(status: StatusCode, op: &str) -> TailFinError {
match status {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => TailFinError::Api(format!(
"{op}: {status} — cookies are stale or the account lacks access. \
Re-export Gemini cookies from a logged-in browser."
)),
StatusCode::TOO_MANY_REQUESTS => TailFinError::Api(format!(
"{op}: {status} — rate-limited by Gemini. Back off and retry in a few minutes."
)),
StatusCode::NOT_FOUND => TailFinError::Api(format!(
"{op}: {status} — endpoint URL may have drifted. \
Check STREAM_URL / UPLOAD_URL / APP_URL in crates/tail-fin-gemini/src/client.rs."
)),
s if s.is_server_error() => TailFinError::Api(format!(
"{op}: {status} — Google-side error, usually transient. Retry."
)),
_ => TailFinError::Api(format!("{op}: {status}")),
}
}
fn validate_continuation_ids(cid: &str, rid: &str, rcid: &str) -> Result<(), TailFinError> {
let mut bad = Vec::new();
if !is_valid_id(cid, "c_") {
bad.push("conversation_id (expected `c_…`)");
}
if !is_valid_id(rid, "r_") {
bad.push("response_id (expected `r_…`)");
}
if !is_valid_id(rcid, "rc_") {
bad.push("choice_id (expected `rc_…`)");
}
if bad.is_empty() {
Ok(())
} else {
Err(TailFinError::Api(format!(
"continuation ids invalid: {}",
bad.join(", ")
)))
}
}
fn is_valid_id(s: &str, prefix: &str) -> bool {
let t = s.trim();
!t.is_empty() && t != "null" && t.starts_with(prefix)
}
fn filename_of(path: &Path) -> Result<String, TailFinError> {
path.file_name()
.and_then(|n| n.to_str())
.map(str::to_string)
.ok_or_else(|| {
TailFinError::Api(format!(
"attachment filename is not valid UTF-8: {}",
path.display()
))
})
}
pub fn default_cookies_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".tail-fin/gemini-cookies.txt")
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}… (truncated)", &s[..max])
}
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::StatusCode;
#[test]
fn classify_401_hints_at_cookies() {
let err = classify_status(StatusCode::UNAUTHORIZED, "op");
let msg = format!("{err}");
assert!(msg.contains("stale"), "got: {msg}");
assert!(msg.contains("Re-export"), "got: {msg}");
}
#[test]
fn classify_429_hints_at_rate_limit() {
let err = classify_status(StatusCode::TOO_MANY_REQUESTS, "op");
assert!(format!("{err}").contains("rate-limited"));
}
#[test]
fn classify_500_hints_at_google_side() {
let err = classify_status(StatusCode::INTERNAL_SERVER_ERROR, "op");
assert!(format!("{err}").contains("Google-side"));
}
#[test]
fn classify_404_hints_at_endpoint_drift() {
let err = classify_status(StatusCode::NOT_FOUND, "op");
let msg = format!("{err}");
assert!(msg.contains("endpoint URL"), "got: {msg}");
assert!(msg.contains("client.rs"), "got: {msg}");
}
#[cfg(unix)]
#[test]
fn filename_of_rejects_non_utf8_name() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
use std::path::PathBuf;
let mut p = PathBuf::from("/tmp");
p.push(OsStr::from_bytes(&[0xff, 0xfe, b'.', b'j', b'p', b'g']));
let err = filename_of(&p).unwrap_err();
assert!(format!("{err}").contains("not valid UTF-8"));
}
#[test]
fn filename_of_accepts_normal_path() {
assert_eq!(
filename_of(Path::new("/tmp/image.jpg")).unwrap(),
"image.jpg"
);
}
#[test]
fn default_cookies_path_ends_with_expected_file() {
let p = default_cookies_path();
assert!(p.ends_with("gemini-cookies.txt"));
}
#[test]
fn validate_continuation_ids_accepts_well_shaped() {
assert!(validate_continuation_ids("c_abc", "r_def", "rc_ghi").is_ok());
}
#[test]
fn validate_continuation_ids_rejects_literal_null() {
let err = validate_continuation_ids("null", "r_ok", "rc_ok").unwrap_err();
assert!(format!("{err}").contains("conversation_id"));
}
#[test]
fn validate_continuation_ids_rejects_wrong_prefix() {
let err = validate_continuation_ids("r_wrongslot", "r_ok", "rc_ok").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("conversation_id"), "got: {msg}");
}
#[test]
fn validate_continuation_ids_lists_all_bad_fields() {
let err = validate_continuation_ids("", "null", "wrong").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("conversation_id"));
assert!(msg.contains("response_id"));
assert!(msg.contains("choice_id"));
}
#[test]
fn validate_continuation_ids_trims_whitespace() {
assert!(validate_continuation_ids(" c_abc ", "r_def\n", "\trc_ghi").is_ok());
}
#[test]
fn validate_continuation_ids_rejects_whitespace_only() {
let err = validate_continuation_ids(" ", "r_ok", "rc_ok").unwrap_err();
assert!(format!("{err}").contains("conversation_id"));
}
}