use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
use serde_json::Value;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Usage {
pub model: Option<String>,
pub context_tokens: Option<u64>,
pub output_tokens: u64,
pub offset: u64,
pub start: u64,
}
pub fn read_from(path: &Path, offset: u64) -> std::io::Result<Usage> {
let mut file = std::fs::File::open(path)?;
let len = file.metadata()?.len();
let start = if len < offset { 0 } else { offset };
file.seek(SeekFrom::Start(start))?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
let mut usage = Usage {
offset: start,
start: offset,
..Usage::default()
};
let mut consumed = 0usize;
for chunk in bytes.split_inclusive(|&b| b == b'\n') {
if !chunk.ends_with(b"\n") {
break;
}
consumed += chunk.len();
let line = String::from_utf8_lossy(chunk);
let Ok(value) = serde_json::from_str::<Value>(line.trim_end()) else {
continue;
};
absorb(&mut usage, &value);
}
usage.offset = start + consumed as u64;
Ok(usage)
}
fn absorb(usage: &mut Usage, line: &Value) {
if line.get("type").and_then(Value::as_str) != Some("assistant") {
return;
}
let Some(message) = line.get("message") else {
return;
};
if let Some(model) = message.get("model").and_then(Value::as_str) {
usage.model = Some(model.to_string());
}
let Some(u) = message.get("usage") else {
return;
};
let count = |key: &str| u.get(key).and_then(Value::as_u64).unwrap_or(0);
usage.output_tokens += count("output_tokens");
usage.context_tokens = Some(
count("input_tokens")
+ count("cache_read_input_tokens")
+ count("cache_creation_input_tokens"),
);
}
pub fn short_model(model: &str) -> String {
let stem = model.strip_prefix("claude-").unwrap_or(model);
match stem.rsplit_once('-') {
Some((head, tail)) if tail.len() == 8 && tail.chars().all(|c| c.is_ascii_digit()) => {
head.to_string()
}
_ => stem.to_string(),
}
}
pub fn compact(tokens: u64) -> String {
match tokens {
0..=9_999 => tokens.to_string(),
10_000..=999_999 => format!("{}k", tokens.div_ceil(1_000)),
_ => format!("{:.1}M", tokens as f64 / 1_000_000.0),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn assistant(
model: &str,
input: u64,
cache_read: u64,
cache_write: u64,
output: u64,
) -> String {
format!(
r#"{{"type":"assistant","message":{{"model":"{model}","usage":{{"input_tokens":{input},"cache_read_input_tokens":{cache_read},"cache_creation_input_tokens":{cache_write},"output_tokens":{output}}}}}}}"#
)
}
fn write(dir: &TempDir, name: &str, lines: &[String]) -> std::path::PathBuf {
let path = dir.path().join(name);
let mut file = std::fs::File::create(&path).unwrap();
for line in lines {
writeln!(file, "{line}").unwrap();
}
path
}
#[test]
fn context_is_the_last_messages_level_and_output_is_a_total() {
let dir = TempDir::new().unwrap();
let path = write(
&dir,
"t.jsonl",
&[
assistant("claude-opus-5", 2, 100, 50, 255),
assistant("claude-opus-5", 1, 300, 20, 1051),
],
);
let usage = read_from(&path, 0).unwrap();
assert_eq!(usage.context_tokens, Some(1 + 300 + 20));
assert_eq!(usage.output_tokens, 255 + 1051);
assert_eq!(usage.model.as_deref(), Some("claude-opus-5"));
}
#[test]
fn a_second_read_starts_where_the_first_stopped() {
let dir = TempDir::new().unwrap();
let path = write(
&dir,
"t.jsonl",
&[assistant("claude-opus-5", 2, 100, 0, 255)],
);
let first = read_from(&path, 0).unwrap();
assert_eq!(first.output_tokens, 255);
let mut file = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
writeln!(file, "{}", assistant("claude-opus-5", 1, 300, 0, 40)).unwrap();
let second = read_from(&path, first.offset).unwrap();
assert_eq!(
second.output_tokens, 40,
"only what is new, or totals would double with every hook"
);
assert_eq!(second.context_tokens, Some(301));
}
#[test]
fn a_half_written_line_is_left_for_next_time() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("t.jsonl");
let mut file = std::fs::File::create(&path).unwrap();
writeln!(file, "{}", assistant("claude-opus-5", 2, 100, 0, 255)).unwrap();
write!(file, r#"{{"type":"assistant","message":{{"mod"#).unwrap();
file.flush().unwrap();
let first = read_from(&path, 0).unwrap();
assert_eq!(first.output_tokens, 255);
let mut file = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
writeln!(
file,
r#"el":"claude-opus-5","usage":{{"output_tokens":7}}}}}}"#
)
.unwrap();
let second = read_from(&path, first.offset).unwrap();
assert_eq!(second.output_tokens, 7, "the completed line is not lost");
}
#[test]
fn a_replaced_transcript_is_read_from_the_beginning() {
let dir = TempDir::new().unwrap();
let path = write(
&dir,
"t.jsonl",
&[
assistant("claude-opus-5", 2, 100, 0, 255),
assistant("claude-opus-5", 2, 100, 0, 255),
],
);
let long = read_from(&path, 0).unwrap();
let path = write(&dir, "t.jsonl", &[assistant("claude-opus-5", 1, 5, 0, 9)]);
let after = read_from(&path, long.offset).unwrap();
assert_eq!(after.output_tokens, 9);
assert_eq!(after.context_tokens, Some(6));
}
#[test]
fn lines_that_are_not_assistant_turns_are_ignored() {
let dir = TempDir::new().unwrap();
let path = write(
&dir,
"t.jsonl",
&[
r#"{"type":"user","message":{"content":"do it"}}"#.to_string(),
r#"{"type":"system","subtype":"init"}"#.to_string(),
assistant("claude-opus-5", 2, 100, 0, 255),
r#"{"type":"file-history-snapshot","snapshot":{}}"#.to_string(),
],
);
let usage = read_from(&path, 0).unwrap();
assert_eq!(usage.output_tokens, 255);
}
#[test]
fn nonsense_is_skipped_rather_than_failing_the_read() {
let dir = TempDir::new().unwrap();
let path = write(
&dir,
"t.jsonl",
&[
"not json at all".to_string(),
r#"{"type":"assistant"}"#.to_string(),
r#"{"type":"assistant","message":{"model":"claude-opus-5"}}"#.to_string(),
assistant("claude-opus-5", 2, 100, 0, 255),
"{".to_string(),
],
);
let usage = read_from(&path, 0).unwrap();
assert_eq!(usage.output_tokens, 255);
assert_eq!(usage.model.as_deref(), Some("claude-opus-5"));
}
#[test]
fn a_transcript_with_no_assistant_turns_reports_nothing_rather_than_zero() {
let dir = TempDir::new().unwrap();
let path = write(
&dir,
"t.jsonl",
&[r#"{"type":"user","message":{"content":"do it"}}"#.to_string()],
);
let usage = read_from(&path, 0).unwrap();
assert_eq!(usage.context_tokens, None);
assert_eq!(usage.output_tokens, 0);
assert_eq!(usage.model, None);
}
#[test]
fn a_missing_transcript_is_an_error_the_caller_can_ignore() {
let dir = TempDir::new().unwrap();
assert!(read_from(&dir.path().join("nope.jsonl"), 0).is_err());
}
#[test]
fn model_names_shorten_without_being_guessed_at() {
assert_eq!(short_model("claude-opus-5"), "opus-5");
assert_eq!(short_model("claude-haiku-4-5-20251001"), "haiku-4-5");
assert_eq!(short_model("claude-sonnet-5"), "sonnet-5");
assert_eq!(short_model("something-else"), "something-else");
assert_eq!(short_model("gpt-4"), "gpt-4");
}
#[test]
fn token_counts_shorten_for_a_header() {
assert_eq!(compact(0), "0");
assert_eq!(compact(9_999), "9999");
assert_eq!(compact(10_000), "10k");
assert_eq!(compact(303_650), "304k");
assert_eq!(compact(1_500_000), "1.5M");
}
}