use std::collections::HashMap;
use std::io::BufRead as _;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::Deserialize;
use crate::usage::{epoch_secs_to_iso, iso_to_epoch_secs, now_epoch_secs};
const REFRESH_INTERVAL: Duration = Duration::from_secs(90);
#[derive(Debug, Clone, Default)]
pub(crate) struct ModelTokens {
pub(crate) model: String,
pub(crate) input: u64,
pub(crate) output: u64,
pub(crate) cache_read: u64,
pub(crate) cache_create: u64,
}
impl ModelTokens {
pub(crate) fn in_out(&self) -> u64 {
self.input.saturating_add(self.output)
}
pub(crate) fn total(&self) -> u64 {
self.input
.saturating_add(self.output)
.saturating_add(self.cache_read)
.saturating_add(self.cache_create)
}
}
#[derive(Debug, Clone)]
pub(crate) struct DayTokens {
pub(crate) date: String, pub(crate) tokens: u64,
}
#[derive(Debug, Clone)]
pub(crate) struct DayActivity {
pub(crate) date: String,
pub(crate) messages: u64,
pub(crate) sessions: u64,
pub(crate) tool_calls: u64,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct DaySummary {
pub(crate) date: String,
pub(crate) input: u64,
pub(crate) output: u64,
pub(crate) cache_read: u64,
pub(crate) cache_create: u64,
pub(crate) messages: u64,
pub(crate) models: Vec<ModelTokens>,
}
impl DaySummary {
pub(crate) fn in_out(&self) -> u64 {
self.input.saturating_add(self.output)
}
pub(crate) fn total(&self) -> u64 {
self.in_out()
.saturating_add(self.cache_read)
.saturating_add(self.cache_create)
}
}
#[derive(Debug, Clone)]
pub(crate) struct TokenStats {
pub(crate) models: Vec<ModelTokens>,
pub(crate) daily: Vec<DayTokens>, pub(crate) activity: Vec<DayActivity>, pub(crate) hour_counts: [u64; 24], pub(crate) total_input: u64,
pub(crate) total_output: u64,
pub(crate) total_cache_read: u64,
pub(crate) total_cache_create: u64,
pub(crate) total_sessions: u64,
pub(crate) total_messages: u64,
pub(crate) first_session_date: Option<String>, pub(crate) last_computed_date: Option<String>, pub(crate) topped_up_through: Option<String>, pub(crate) today: Option<DaySummary>,
}
impl TokenStats {
pub(crate) fn total_in_out(&self) -> u64 {
self.total_input.saturating_add(self.total_output)
}
pub(crate) fn total_tokens(&self) -> u64 {
self.total_input
.saturating_add(self.total_output)
.saturating_add(self.total_cache_read)
.saturating_add(self.total_cache_create)
}
pub(crate) fn cache_hit_ratio(&self) -> f64 {
let denom = self
.total_cache_read
.saturating_add(self.total_cache_create)
.saturating_add(self.total_input);
if denom == 0 {
return 0.0;
}
self.total_cache_read as f64 / denom as f64
}
}
const OTHERS_THRESHOLD: u64 = 1_000_000;
pub(crate) fn group_models(models: &[ModelTokens]) -> Vec<ModelTokens> {
let mut out: Vec<ModelTokens> = Vec::new();
let mut others = ModelTokens {
model: "others".to_owned(),
..Default::default()
};
for m in models {
if is_anthropic(&m.model) || m.total() > OTHERS_THRESHOLD {
out.push(m.clone());
} else {
others.input = others.input.saturating_add(m.input);
others.output = others.output.saturating_add(m.output);
others.cache_read = others.cache_read.saturating_add(m.cache_read);
others.cache_create = others.cache_create.saturating_add(m.cache_create);
}
}
if others.total() > 0 {
out.push(others);
}
out.sort_unstable_by_key(|m| std::cmp::Reverse(m.in_out()));
out
}
pub(crate) fn is_anthropic(model: &str) -> bool {
model.starts_with("claude")
}
pub(crate) fn model_display_name(model: &str) -> String {
if model == "others" {
return "others".to_string();
}
let base = match model.rsplit_once('-') {
Some((head, tail)) if tail.len() == 8 && tail.bytes().all(|b| b.is_ascii_digit()) => head,
_ => model,
};
let Some(rest) = base.strip_prefix("claude-") else {
return base.to_string();
};
let mut parts = rest.split('-');
let Some(family) = parts.next() else {
return base.to_string();
};
let mut version: Vec<&str> = Vec::new();
let mut extras: Vec<&str> = Vec::new();
for p in parts {
if extras.is_empty() && !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit() || b == b'.')
{
version.push(p);
} else {
extras.push(p);
}
}
let mut out = family.to_string();
if !version.is_empty() {
out.push(' ');
out.push_str(&version.join("."));
}
for e in extras {
out.push(' ');
out.push_str(e);
}
out
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
struct StatsCacheFile {
last_computed_date: Option<String>,
first_session_date: Option<String>,
total_sessions: u64,
total_messages: u64,
daily_activity: Vec<WireActivity>,
daily_model_tokens: Vec<WireDayTokens>,
model_usage: HashMap<String, WireModelUsage>,
hour_counts: HashMap<String, u64>,
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
struct WireActivity {
date: String,
message_count: u64,
session_count: u64,
tool_call_count: u64,
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
struct WireDayTokens {
date: String,
tokens_by_model: HashMap<String, u64>,
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
struct WireModelUsage {
input_tokens: u64,
output_tokens: u64,
cache_read_input_tokens: u64,
cache_creation_input_tokens: u64,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct TranscriptLine {
timestamp: Option<String>,
message: Option<TranscriptMsg>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct TranscriptMsg {
model: Option<String>,
usage: Option<TranscriptUsage>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct TranscriptUsage {
input_tokens: u64,
output_tokens: u64,
cache_read_input_tokens: u64,
cache_creation_input_tokens: u64,
}
pub(crate) fn load(claude_dir: &Path) -> Option<TokenStats> {
let cache_path = claude_dir.join("stats-cache.json");
let raw = std::fs::read_to_string(&cache_path).ok()?;
let wire: StatsCacheFile = serde_json::from_str(&raw).ok()?;
let mut models: Vec<ModelTokens> = wire
.model_usage
.iter()
.map(|(name, u)| ModelTokens {
model: name.clone(),
input: u.input_tokens,
output: u.output_tokens,
cache_read: u.cache_read_input_tokens,
cache_create: u.cache_creation_input_tokens,
})
.collect();
models.sort_unstable_by_key(|m| std::cmp::Reverse(m.total()));
let mut daily: Vec<DayTokens> = wire
.daily_model_tokens
.iter()
.map(|d| DayTokens {
date: d.date.clone(),
tokens: d.tokens_by_model.values().copied().sum(),
})
.collect();
daily.sort_unstable_by_key(|d| d.date.clone());
let mut activity: Vec<DayActivity> = wire
.daily_activity
.iter()
.map(|a| DayActivity {
date: a.date.clone(),
messages: a.message_count,
sessions: a.session_count,
tool_calls: a.tool_call_count,
})
.collect();
activity.sort_unstable_by_key(|d| d.date.clone());
let mut hour_counts = [0u64; 24];
for (k, v) in &wire.hour_counts {
if let Ok(h) = k.parse::<usize>()
&& h < 24
{
hour_counts[h] = *v;
}
}
let total_input: u64 = wire.model_usage.values().map(|u| u.input_tokens).sum();
let total_output: u64 = wire.model_usage.values().map(|u| u.output_tokens).sum();
let total_cache_read: u64 = wire
.model_usage
.values()
.map(|u| u.cache_read_input_tokens)
.sum();
let total_cache_create: u64 = wire
.model_usage
.values()
.map(|u| u.cache_creation_input_tokens)
.sum();
let mut stats = TokenStats {
models,
daily,
activity,
hour_counts,
total_input,
total_output,
total_cache_read,
total_cache_create,
total_sessions: wire.total_sessions,
total_messages: wire.total_messages,
first_session_date: wire.first_session_date,
last_computed_date: wire.last_computed_date.clone(),
topped_up_through: None,
today: None,
};
let today = today_date();
top_up(
claude_dir,
wire.last_computed_date.as_deref(),
&today,
&mut stats,
);
Some(stats)
}
fn today_date() -> String {
let iso = epoch_secs_to_iso(now_epoch_secs());
iso.get(..10).map(str::to_owned).unwrap_or(iso)
}
fn date_to_cutoff(date: &str) -> Option<SystemTime> {
let ts = format!("{date}T00:00:00+00:00");
let secs = iso_to_epoch_secs(&ts)?;
if secs < 0 {
return None;
}
Some(UNIX_EPOCH + Duration::from_secs(secs as u64))
}
fn top_up(
claude_dir: &Path,
last_computed_date: Option<&str>,
today_date: &str,
stats: &mut TokenStats,
) {
let cutoff_date = match last_computed_date {
Some(d) => d,
None => return,
};
let cutoff_st = match date_to_cutoff(cutoff_date) {
Some(st) => st,
None => return,
};
let projects_dir = claude_dir.join("projects");
let proj_entries = match std::fs::read_dir(&projects_dir) {
Ok(it) => it,
Err(_) => return,
};
let mut daily_map: HashMap<String, u64> = stats
.daily
.iter()
.map(|d| (d.date.clone(), d.tokens))
.collect();
let mut model_map: HashMap<String, ModelTokens> = stats
.models
.iter()
.cloned()
.map(|m| (m.model.clone(), m))
.collect();
let mut max_date: Option<String> = None;
let mut today_acc = DaySummary {
date: today_date.to_owned(),
..Default::default()
};
let mut today_models: HashMap<String, ModelTokens> = HashMap::new();
for proj in proj_entries {
let proj = match proj {
Ok(e) => e,
Err(_) => continue,
};
let jsonl_entries = match std::fs::read_dir(proj.path()) {
Ok(it) => it,
Err(_) => continue,
};
for jsonl in jsonl_entries {
let jsonl = match jsonl {
Ok(e) => e,
Err(_) => continue,
};
let path = jsonl.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let mtime = match std::fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok())
{
Some(t) => t,
None => continue,
};
if mtime <= cutoff_st {
continue;
}
process_jsonl(
&path,
cutoff_date,
today_date,
&mut daily_map,
&mut model_map,
&mut today_acc,
&mut today_models,
&mut max_date,
);
}
}
if today_acc.messages > 0 || today_acc.total() > 0 {
today_acc.models = today_models.into_values().collect();
today_acc
.models
.sort_unstable_by_key(|m| std::cmp::Reverse(m.total()));
stats.today = Some(today_acc);
}
if max_date.is_none() {
return;
}
for (date, tokens) in &daily_map {
if let Some(existing) = stats.daily.iter_mut().find(|d| &d.date == date) {
existing.tokens = *tokens;
} else {
stats.daily.push(DayTokens {
date: date.clone(),
tokens: *tokens,
});
}
}
stats.daily.sort_unstable_by_key(|d| d.date.clone());
stats.models = model_map.into_values().collect();
stats
.models
.sort_unstable_by_key(|m| std::cmp::Reverse(m.total()));
stats.total_input = stats.models.iter().map(|m| m.input).sum();
stats.total_output = stats.models.iter().map(|m| m.output).sum();
stats.total_cache_read = stats.models.iter().map(|m| m.cache_read).sum();
stats.total_cache_create = stats.models.iter().map(|m| m.cache_create).sum();
stats.topped_up_through = max_date;
}
#[allow(clippy::too_many_arguments)]
fn process_jsonl(
path: &Path,
cutoff_date: &str,
today_date: &str,
daily_map: &mut HashMap<String, u64>,
model_map: &mut HashMap<String, ModelTokens>,
today: &mut DaySummary,
today_models: &mut HashMap<String, ModelTokens>,
max_date: &mut Option<String>,
) {
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return,
};
let reader = std::io::BufReader::new(file);
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
let parsed: TranscriptLine = match serde_json::from_str(&line) {
Ok(p) => p,
Err(_) => continue,
};
let timestamp = match &parsed.timestamp {
Some(t) => t,
None => continue,
};
if timestamp.len() < 10 {
continue;
}
let date = ×tamp[..10];
let msg = match &parsed.message {
Some(m) => m,
None => continue,
};
let usage = match &msg.usage {
Some(u) => u,
None => continue,
};
let model_name = msg.model.as_deref().unwrap_or("unknown").to_owned();
if date == today_date {
today.input = today.input.saturating_add(usage.input_tokens);
today.output = today.output.saturating_add(usage.output_tokens);
today.cache_read = today
.cache_read
.saturating_add(usage.cache_read_input_tokens);
today.cache_create = today
.cache_create
.saturating_add(usage.cache_creation_input_tokens);
today.messages = today.messages.saturating_add(1);
let tm = today_models
.entry(model_name.clone())
.or_insert_with(|| ModelTokens {
model: model_name.clone(),
..Default::default()
});
tm.input = tm.input.saturating_add(usage.input_tokens);
tm.output = tm.output.saturating_add(usage.output_tokens);
tm.cache_read = tm.cache_read.saturating_add(usage.cache_read_input_tokens);
tm.cache_create = tm
.cache_create
.saturating_add(usage.cache_creation_input_tokens);
}
if date <= cutoff_date {
continue;
}
let in_out = usage.input_tokens.saturating_add(usage.output_tokens);
*daily_map.entry(date.to_owned()).or_insert(0) += in_out;
let entry = model_map
.entry(model_name.clone())
.or_insert_with(|| ModelTokens {
model: model_name,
..Default::default()
});
entry.input = entry.input.saturating_add(usage.input_tokens);
entry.output = entry.output.saturating_add(usage.output_tokens);
entry.cache_read = entry
.cache_read
.saturating_add(usage.cache_read_input_tokens);
entry.cache_create = entry
.cache_create
.saturating_add(usage.cache_creation_input_tokens);
if max_date.as_deref().is_none_or(|prev| date > prev) {
*max_date = Some(date.to_owned());
}
}
}
pub(crate) enum TokensEvent {
Loaded(Box<TokenStats>),
Failed,
}
pub(crate) fn spawn(tx: Sender<TokensEvent>, refresh_rx: Receiver<()>, claude_dir: PathBuf) {
std::thread::spawn(move || {
let send = |stats: Option<TokenStats>| {
let event = match stats {
Some(s) => TokensEvent::Loaded(Box::new(s)),
None => TokensEvent::Failed,
};
let _ = tx.send(event);
};
send(load(&claude_dir));
loop {
match refresh_rx.recv_timeout(REFRESH_INTERVAL) {
Ok(()) => while refresh_rx.try_recv().is_ok() {},
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => return,
}
send(load(&claude_dir));
}
});
}
#[cfg(test)]
#[path = "../tests/inline/tokens.rs"]
mod tests;