tail-fin-gemini 0.7.3

Gemini (Google) adapter for tail-fin: cookie-authenticated HTTP client — text + file attach + multi-turn; no browser
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
//! `reqwest`-backed Gemini client — no Chrome, no DOM. Talks directly to
//! the same HTTP endpoints `gemini.google.com` calls from the browser.

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 })
    }

    /// Convenience constructor: load Netscape-format cookie file and wrap.
    pub fn from_cookie_file(path: &Path) -> Result<Self, TailFinError> {
        let cookies = load_netscape(path)?;
        Self::new(cookies)
    }

    /// Start a fresh conversation: send a prompt (optionally with a
    /// file attachment) and return the reply. Use [`ask_continue`] with
    /// the returned `GeminiResponse` to send follow-up turns.
    ///
    /// [`ask_continue`]: Self::ask_continue
    pub async fn ask(
        &self,
        prompt: &str,
        attach: Option<&Path>,
    ) -> Result<GeminiResponse, TailFinError> {
        self.ask_inner(prompt, attach, None).await
    }

    /// Continue an existing conversation. `prev` must carry all three
    /// ids (`conversation_id`, `response_id`, `choice_id`) — typically
    /// the `GeminiResponse` from a prior call. If any id is missing or
    /// malformed (wrong prefix, literal "null", empty), errors *before*
    /// sending the request.
    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
    }

    /// Same as `ask_continue` but takes the three ids as strings —
    /// convenience for callers that persist ids elsewhere (e.g. CLI
    /// flags or a database) instead of holding a `GeminiResponse`.
    ///
    /// Validates prefixes and common foot-guns (empty, literal "null")
    /// before dispatching.
    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);

        // Stale-id guard: if we sent continuation ids but the server
        // returned a DIFFERENT conversation_id (or none at all), it
        // silently dropped our continuation and started a new session.
        // The caller almost certainly doesn't want that — they asked
        // a follow-up expecting prior context, now their prompt is
        // talking to a stranger. Fail loud.
        if let Some((sent_cid, _, _)) = continuation.as_ref() {
            // Trim before compare — Gemini has never returned padded
            // ids in observation, but byte-equality on a
            // defensive-check is a classic flake source.
            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)
    }

    /// Resumable upload, two-step — see docs/sites/gemini.md for the
    /// protocol trace. Caller supplies the already-validated UTF-8
    /// filename (same value is later threaded into the f.req payload).
    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();

        // Step 1 — initiate.
        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();

        // Step 2 — upload + finalize.
        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);

        // `files_data` in f.req inner: each attachment is `[[<url>,1],<name>]`.
        let files_data: Vec<Value> = files
            .iter()
            .map(|(url, name)| json!([[url, 1], name]))
            .collect();

        // Thread previous turn's ids when continuing a conversation;
        // `[null, null, null]` starts fresh.
        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)
    }
}

/// Turn a non-success HTTP status into an actionable `TailFinError`.
/// The messages hint at the specific fix (re-export cookies, back off,
/// retry) rather than just echoing the status code.
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}")),
    }
}

/// Same validation as `GeminiResponse::require_continuation`, but for
/// loose id strings (CLI / persisted state).
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)
}

/// Extract the filename as a UTF-8 string, erroring cleanly instead of
/// silently renaming to `"upload"` — users need to know their file's
/// original name made it into the upload.
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()
            ))
        })
}

/// Default session file location: `~/.tail-fin/gemini-cookies.txt`.
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;

        // 0xff is invalid UTF-8 — constructs a real non-UTF-8 filename,
        // not an empty-path stand-in.
        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() {
        // A shell variable might carry trailing space; we accept if the
        // trimmed value is well-shaped.
        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"));
    }
}