use std::collections::HashMap;
use std::collections::HashSet;
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 DayModelTokens {
pub(crate) date: String, pub(crate) model: String,
pub(crate) in_out: u64,
pub(crate) split: Option<ModelTokens>,
}
#[derive(Debug, Clone)]
pub(crate) struct PeriodModel {
pub(crate) model: String,
pub(crate) in_out: u64,
pub(crate) split: ModelTokens,
pub(crate) split_complete: bool,
}
impl PeriodModel {
pub(crate) fn from_full(m: &ModelTokens) -> Self {
Self {
model: m.model.clone(),
in_out: m.in_out(),
split: m.clone(),
split_complete: true,
}
}
pub(crate) fn metric(&self, count_cache: bool) -> u64 {
if count_cache && self.split_complete {
self.split.total()
} else {
self.in_out
}
}
}
pub(crate) fn effective_cache_basis(rows: &[PeriodModel], count_cache: bool) -> bool {
count_cache && rows.iter().all(|m| m.split_complete)
}
#[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) hours: [u64; 24],
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, Default)]
pub(crate) struct TokenStats {
pub(crate) models: Vec<ModelTokens>,
pub(crate) daily: Vec<DayTokens>, pub(crate) daily_models: Vec<DayModelTokens>,
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>,
uuid: Option<String>,
#[serde(rename = "sessionId")]
session_id: Option<String>,
message: Option<TranscriptMsg>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct TranscriptMsg {
id: Option<String>,
model: Option<String>,
role: Option<String>,
usage: Option<TranscriptUsage>,
content: Option<Vec<ContentBlock>>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct ContentBlock {
#[serde(rename = "type")]
kind: Option<String>,
}
#[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_base(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 daily_models: Vec<DayModelTokens> = wire
.daily_model_tokens
.iter()
.flat_map(|d| {
d.tokens_by_model
.iter()
.map(|(model, &in_out)| DayModelTokens {
date: d.date.clone(),
model: model.clone(),
in_out,
split: None,
})
})
.collect();
daily_models.sort_unstable_by(|a, b| {
(a.date.as_str(), a.model.as_str()).cmp(&(b.date.as_str(), b.model.as_str()))
});
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 stats = TokenStats {
models,
daily,
daily_models,
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,
};
Some(stats)
}
#[cfg(test)]
pub(crate) fn load(claude_dir: &Path) -> Option<TokenStats> {
let mut base = load_base(claude_dir)?;
let today = today_date();
let lcd = base.last_computed_date.clone();
let mut cache = TopUpCache::default();
refresh_topup_cache(claude_dir, lcd.as_deref(), &mut cache, |_, _| {});
merge_topup(&mut base, &cache, lcd.as_deref(), &today);
Some(base)
}
pub(crate) fn today_date() -> String {
let iso = epoch_secs_to_iso(now_epoch_secs());
iso.get(..10).map(str::to_owned).unwrap_or(iso)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Bucket {
Week,
Month,
}
pub(crate) fn bucket_start(date: &str, bucket: Bucket) -> String {
match bucket {
Bucket::Month if date.len() >= 7 => format!("{}-01", &date[..7]),
Bucket::Month => date.to_owned(),
Bucket::Week => {
let Some(secs) = iso_to_epoch_secs(&format!("{date}T00:00:00+00:00")) else {
return date.to_owned();
};
let days = secs.div_euclid(86_400);
let weekday = (days + 3).rem_euclid(7);
let iso = epoch_secs_to_iso((days - weekday) * 86_400);
iso.get(..10).map(str::to_owned).unwrap_or(iso)
}
}
}
pub(crate) fn current_bucket_bounds(today: &str, bucket: Bucket) -> (String, String) {
(bucket_start(today, bucket), today.to_owned())
}
pub(crate) fn bucket_tokens(days: &[DayTokens], bucket: Bucket) -> Vec<DayTokens> {
let mut out: Vec<DayTokens> = Vec::new();
for d in days {
let key = bucket_start(&d.date, bucket);
match out.last_mut() {
Some(last) if last.date == key => {
last.tokens = last.tokens.saturating_add(d.tokens);
}
_ => out.push(DayTokens {
date: key,
tokens: d.tokens,
}),
}
}
out
}
pub(crate) fn bucket_activity(days: &[DayActivity], bucket: Bucket) -> Vec<DayActivity> {
let mut out: Vec<DayActivity> = Vec::new();
for d in days {
let key = bucket_start(&d.date, bucket);
match out.last_mut() {
Some(last) if last.date == key => {
last.messages = last.messages.saturating_add(d.messages);
last.sessions = last.sessions.saturating_add(d.sessions);
last.tool_calls = last.tool_calls.saturating_add(d.tool_calls);
}
_ => out.push(DayActivity {
date: key,
..d.clone()
}),
}
}
out
}
pub(crate) fn period_models(days: &[DayModelTokens], from: &str, to: &str) -> Vec<PeriodModel> {
let mut map: HashMap<&str, PeriodModel> = HashMap::new();
for d in days {
if d.date.as_str() < from || d.date.as_str() > to {
continue;
}
let e = map.entry(d.model.as_str()).or_insert_with(|| PeriodModel {
model: d.model.clone(),
in_out: 0,
split: ModelTokens {
model: d.model.clone(),
..Default::default()
},
split_complete: true,
});
e.in_out = e.in_out.saturating_add(d.in_out);
match &d.split {
Some(s) => {
e.split.input = e.split.input.saturating_add(s.input);
e.split.output = e.split.output.saturating_add(s.output);
e.split.cache_read = e.split.cache_read.saturating_add(s.cache_read);
e.split.cache_create = e.split.cache_create.saturating_add(s.cache_create);
}
None => e.split_complete = false,
}
}
let mut out: Vec<PeriodModel> = map.into_values().collect();
out.sort_unstable_by_key(|m| std::cmp::Reverse(m.in_out));
out
}
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 collect_jsonl(dir: &Path, max_depth: usize, out: &mut Vec<PathBuf>) {
if max_depth == 0 {
return;
}
let entries = match std::fs::read_dir(dir) {
Ok(it) => it,
Err(_) => return,
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
let path = entry.path();
if file_type.is_dir() {
collect_jsonl(&path, max_depth - 1, out);
} else if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
out.push(path);
}
}
}
struct LineRec {
date: String, hour: u8, uuid: String, session: String, is_message: bool,
has_usage: bool,
tok_key: String, model: String,
input: u64,
output: u64,
cache_read: u64,
cache_create: u64,
tool_calls: u64,
}
struct FileContrib {
mtime: SystemTime,
recs: Vec<LineRec>,
}
#[derive(Default)]
struct TopUpCache {
files: HashMap<PathBuf, FileContrib>,
}
fn refresh_topup_cache(
claude_dir: &Path,
last_computed_date: Option<&str>,
cache: &mut TopUpCache,
mut progress: impl FnMut(usize, usize),
) {
let Some(cutoff_date) = last_computed_date else {
cache.files.clear();
return;
};
let Some(cutoff_st) = date_to_cutoff(cutoff_date) else {
return;
};
let projects_dir = claude_dir.join("projects");
let mut paths: Vec<PathBuf> = Vec::new();
collect_jsonl(&projects_dir, 8, &mut paths);
let present: HashSet<&PathBuf> = paths.iter().collect();
cache.files.retain(|p, _| present.contains(p));
let total = paths.len();
for (done, path) in paths.into_iter().enumerate() {
progress(done + 1, total);
let mtime = match std::fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok())
{
Some(t) => t,
None => {
cache.files.remove(&path);
continue;
}
};
if mtime <= cutoff_st {
cache.files.remove(&path);
continue;
}
match cache.files.get(&path) {
Some(fc) if fc.mtime == mtime => {} _ => {
let recs = parse_file(&path);
cache.files.insert(path, FileContrib { mtime, recs });
}
}
}
}
fn merge_topup(
base: &mut TokenStats,
cache: &TopUpCache,
last_computed_date: Option<&str>,
today_date: &str,
) {
let Some(cutoff_date) = last_computed_date else {
return; };
let mut daily_map: HashMap<String, u64> = base
.daily
.iter()
.map(|d| (d.date.clone(), d.tokens))
.collect();
let mut model_map: HashMap<String, ModelTokens> = base
.models
.iter()
.cloned()
.map(|m| (m.model.clone(), m))
.collect();
let mut day_models: HashMap<(String, String), ModelTokens> = HashMap::new();
let mut day_msgs: HashMap<String, u64> = HashMap::new();
let mut day_sessions: HashMap<String, HashSet<String>> = HashMap::new();
let mut day_tools: HashMap<String, u64> = HashMap::new();
let mut post_sessions: HashSet<String> = HashSet::new();
let mut new_hours = [0u64; 24];
let mut today_acc = DaySummary {
date: today_date.to_owned(),
..Default::default()
};
let mut today_models: HashMap<String, ModelTokens> = HashMap::new();
let mut seen_tok: HashSet<&str> = HashSet::new();
let mut seen_uuid: HashSet<&str> = HashSet::new();
let mut max_date: Option<String> = None;
for fc in cache.files.values() {
for r in &fc.recs {
if r.is_message && (r.uuid.is_empty() || seen_uuid.insert(r.uuid.as_str())) {
if r.date == today_date {
today_acc.messages = today_acc.messages.saturating_add(1);
if (r.hour as usize) < 24 {
today_acc.hours[r.hour as usize] =
today_acc.hours[r.hour as usize].saturating_add(1);
}
}
if r.date.as_str() > cutoff_date {
*day_msgs.entry(r.date.clone()).or_insert(0) += 1;
*day_tools.entry(r.date.clone()).or_insert(0) += r.tool_calls;
if (r.hour as usize) < 24 {
new_hours[r.hour as usize] += 1;
}
if !r.session.is_empty() {
day_sessions
.entry(r.date.clone())
.or_default()
.insert(r.session.clone());
post_sessions.insert(r.session.clone());
}
}
}
if r.has_usage && (r.tok_key.is_empty() || seen_tok.insert(r.tok_key.as_str())) {
if r.date == today_date {
today_acc.input = today_acc.input.saturating_add(r.input);
today_acc.output = today_acc.output.saturating_add(r.output);
today_acc.cache_read = today_acc.cache_read.saturating_add(r.cache_read);
today_acc.cache_create = today_acc.cache_create.saturating_add(r.cache_create);
let tm = today_models
.entry(r.model.clone())
.or_insert_with(|| ModelTokens {
model: r.model.clone(),
..Default::default()
});
tm.input = tm.input.saturating_add(r.input);
tm.output = tm.output.saturating_add(r.output);
tm.cache_read = tm.cache_read.saturating_add(r.cache_read);
tm.cache_create = tm.cache_create.saturating_add(r.cache_create);
}
if r.date.as_str() > cutoff_date {
*daily_map.entry(r.date.clone()).or_insert(0) +=
r.input.saturating_add(r.output);
let dm = day_models
.entry((r.date.clone(), r.model.clone()))
.or_insert_with(|| ModelTokens {
model: r.model.clone(),
..Default::default()
});
dm.input = dm.input.saturating_add(r.input);
dm.output = dm.output.saturating_add(r.output);
dm.cache_read = dm.cache_read.saturating_add(r.cache_read);
dm.cache_create = dm.cache_create.saturating_add(r.cache_create);
let e = model_map
.entry(r.model.clone())
.or_insert_with(|| ModelTokens {
model: r.model.clone(),
..Default::default()
});
e.input = e.input.saturating_add(r.input);
e.output = e.output.saturating_add(r.output);
e.cache_read = e.cache_read.saturating_add(r.cache_read);
e.cache_create = e.cache_create.saturating_add(r.cache_create);
if max_date
.as_deref()
.is_none_or(|prev| r.date.as_str() > prev)
{
max_date = Some(r.date.clone());
}
}
}
}
}
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()));
base.today = Some(today_acc);
}
if max_date.is_none() && day_msgs.is_empty() {
return;
}
for (date, tokens) in &daily_map {
if let Some(existing) = base.daily.iter_mut().find(|d| &d.date == date) {
existing.tokens = *tokens;
} else {
base.daily.push(DayTokens {
date: date.clone(),
tokens: *tokens,
});
}
}
base.daily.sort_unstable_by_key(|d| d.date.clone());
base.daily_models.retain(|d| d.date.as_str() <= cutoff_date);
for ((date, _), tokens) in day_models {
base.daily_models.push(DayModelTokens {
date,
model: tokens.model.clone(),
in_out: tokens.in_out(),
split: Some(tokens),
});
}
base.daily_models.sort_unstable_by(|a, b| {
(a.date.as_str(), a.model.as_str()).cmp(&(b.date.as_str(), b.model.as_str()))
});
base.models = model_map.into_values().collect();
base.models
.sort_unstable_by_key(|m| std::cmp::Reverse(m.total()));
base.total_input = base.models.iter().map(|m| m.input).sum();
base.total_output = base.models.iter().map(|m| m.output).sum();
base.total_cache_read = base.models.iter().map(|m| m.cache_read).sum();
base.total_cache_create = base.models.iter().map(|m| m.cache_create).sum();
let mut added_msgs = 0u64;
for (date, msgs) in &day_msgs {
let sessions = day_sessions.get(date).map(|s| s.len() as u64).unwrap_or(0);
let tools = day_tools.get(date).copied().unwrap_or(0);
added_msgs = added_msgs.saturating_add(*msgs);
if let Some(a) = base.activity.iter_mut().find(|a| &a.date == date) {
a.messages = *msgs;
a.sessions = sessions;
a.tool_calls = tools;
} else {
base.activity.push(DayActivity {
date: date.clone(),
messages: *msgs,
sessions,
tool_calls: tools,
});
}
}
base.activity.sort_unstable_by_key(|d| d.date.clone());
base.total_messages = base.total_messages.saturating_add(added_msgs);
base.total_sessions = base
.total_sessions
.saturating_add(post_sessions.len() as u64);
for (h, c) in new_hours.iter().enumerate() {
base.hour_counts[h] = base.hour_counts[h].saturating_add(*c);
}
base.topped_up_through = max_date;
}
fn parse_file(path: &Path) -> Vec<LineRec> {
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return Vec::new(),
};
let reader = std::io::BufReader::new(file);
let mut out = Vec::new();
for line in reader.lines() {
let Ok(line) = line else { continue };
let parsed: TranscriptLine = match serde_json::from_str(&line) {
Ok(p) => p,
Err(_) => continue,
};
let Some(ts) = parsed.timestamp.as_deref() else {
continue;
};
if ts.len() < 10 {
continue;
}
let date = ts[..10].to_owned();
let hour = ts
.get(11..13)
.and_then(|h| h.parse::<u8>().ok())
.filter(|h| *h < 24)
.unwrap_or(0);
let Some(msg) = parsed.message.as_ref() else {
continue;
};
let role = msg.role.as_deref().unwrap_or("");
let usage = msg.usage.as_ref();
let is_message = role == "user" || role == "assistant" || usage.is_some();
if !is_message {
continue;
}
let (has_usage, tok_key, model, input, output, cache_read, cache_create) =
if let Some(u) = usage {
let model = msg.model.clone().unwrap_or_else(|| "unknown".to_owned());
let key = match msg.id.as_deref() {
Some(id) if !id.is_empty() => id.to_owned(),
_ => format!(
"{date}|{model}|{}|{}|{}|{}",
u.input_tokens,
u.output_tokens,
u.cache_read_input_tokens,
u.cache_creation_input_tokens
),
};
(
true,
key,
model,
u.input_tokens,
u.output_tokens,
u.cache_read_input_tokens,
u.cache_creation_input_tokens,
)
} else {
(false, String::new(), String::new(), 0, 0, 0, 0)
};
let tool_calls = msg
.content
.as_ref()
.map(|c| {
c.iter()
.filter(|b| b.kind.as_deref() == Some("tool_use"))
.count() as u64
})
.unwrap_or(0);
out.push(LineRec {
date,
hour,
uuid: parsed.uuid.clone().unwrap_or_default(),
session: parsed.session_id.clone().unwrap_or_default(),
is_message,
has_usage,
tok_key,
model,
input,
output,
cache_read,
cache_create,
tool_calls,
});
}
out
}
pub(crate) enum TokensEvent {
Base(Box<TokenStats>),
Progress {
done: usize,
total: usize,
},
Loaded(Box<TokenStats>),
Failed,
}
pub(crate) fn spawn(tx: Sender<TokensEvent>, refresh_rx: Receiver<()>, claude_dir: PathBuf) {
std::thread::spawn(move || {
let mut cache = TopUpCache::default();
let run = |cache: &mut TopUpCache| {
let Some(mut base) = load_base(&claude_dir) else {
let _ = tx.send(TokensEvent::Failed);
return;
};
let _ = tx.send(TokensEvent::Base(Box::new(base.clone())));
let today = today_date();
let lcd = base.last_computed_date.clone();
refresh_topup_cache(&claude_dir, lcd.as_deref(), cache, |done, total| {
if done % 25 == 0 || done == total {
let _ = tx.send(TokensEvent::Progress { done, total });
}
});
merge_topup(&mut base, cache, lcd.as_deref(), &today);
let _ = tx.send(TokensEvent::Loaded(Box::new(base)));
};
run(&mut cache);
loop {
match refresh_rx.recv_timeout(REFRESH_INTERVAL) {
Ok(()) => while refresh_rx.try_recv().is_ok() {},
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => return,
}
run(&mut cache);
}
});
}
#[cfg(test)]
#[path = "../tests/inline/tokens.rs"]
mod tests;