Skip to main content

obol_core/transcript/
gemini.rs

1//! Gemini CLI chat transcript -> Vec<MessageUsage>.
2//! Reconciled with AgentsView internal/parser/gemini.go (MIT, © 2026 Kenn Software LLC).
3//! On-disk form is a `$set`-mutation JSONL: each `$set.messages` is a full snapshot of
4//! the conversation; the latest one wins. Usage lives on `type:"gemini"` messages;
5//! `tokens.thoughts` (thinking) is billed as output.
6
7use crate::error::ObolError;
8use crate::model::{MessageUsage, Provider};
9use serde_json::Value;
10
11pub fn parse(bytes: &[u8]) -> Result<Vec<MessageUsage>, ObolError> {
12    let text = std::str::from_utf8(bytes).map_err(|e| ObolError::MalformedTranscript {
13        line: 0,
14        msg: e.to_string(),
15    })?;
16    // Latest non-empty messages snapshot (`$set.messages`, or a bare top-level
17    // `messages` for single-doc safety). Last write wins.
18    let mut latest: Option<Value> = None;
19    for line in text.lines() {
20        let line = line.trim();
21        if line.is_empty() {
22            continue;
23        }
24        let v: Value = match serde_json::from_str(line) {
25            Ok(v) => v,
26            Err(_) => continue,
27        };
28        let msgs = v.pointer("/$set/messages").or_else(|| v.get("messages"));
29        if let Some(m) = msgs {
30            if m.as_array().is_some_and(|a| !a.is_empty()) {
31                latest = Some(m.clone());
32            }
33        }
34    }
35    let mut out = Vec::new();
36    let msgs = match latest {
37        Some(Value::Array(a)) => a,
38        _ => return Ok(out),
39    };
40    for msg in &msgs {
41        if msg.get("type").and_then(Value::as_str) != Some("gemini") {
42            continue;
43        }
44        let tok = match msg.get("tokens") {
45            Some(t) if t.is_object() => t,
46            _ => continue,
47        };
48        let g = |k: &str| tok.get(k).and_then(Value::as_u64).unwrap_or(0);
49        let input = g("input");
50        let cached = g("cached");
51        let output = g("output") + g("thoughts");
52        out.push(MessageUsage {
53            model: msg
54                .get("model")
55                .and_then(Value::as_str)
56                .unwrap_or("")
57                .to_string(),
58            provider: Provider::Other("google".into()),
59            namespace: "litellm".into(),
60            input_uncached: input,
61            cache_read: cached,
62            cache_write_5m: 0,
63            cache_write_1h: 0,
64            output,
65            request_input_tokens: input + cached,
66            service_tier: None,
67        });
68    }
69    Ok(out)
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    #[test]
77    fn reads_latest_snapshot_and_folds_thoughts_into_output() {
78        let u = parse(include_bytes!("../../tests/fixtures/gemini-mini.jsonl")).unwrap();
79        assert_eq!(u.len(), 1, "{u:?}");
80        assert_eq!(u[0].model, "gemini-3-flash-preview");
81        assert_eq!(u[0].input_uncached, 9431);
82        assert_eq!(u[0].cache_read, 0);
83        assert_eq!(u[0].output, 106); // 12 output + 94 thoughts
84        assert_eq!(u[0].request_input_tokens, 9431);
85    }
86}