//! What the agents have used: tokens and cost read from Claude Code's and //! Pi's session logs and from usage hubs, and the plan limits Claude reports. //! Everything here blocks, so run it on a background executor. use std::{ collections::{BTreeSet, HashMap}, fs::File, io::{BufRead as _, BufReader}, path::{Path, PathBuf}, time::{Duration, SystemTime}, }; use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, TimeDelta, Timelike as _, Utc}; use serde::Deserialize; use serde_json::Value; use crate::settings::UsageHub; /// Where usage is read from. Each directory comes with the label it's shown /// under, which is also its provider in the report. #[derive(Clone, Debug, Default)] pub struct UsageSources { /// Claude config directories, such as `~/.claude`. pub claude_dirs: Vec<(String, PathBuf)>, /// Pi agent directories, such as `~/.pi/agent`. pub pi_dirs: Vec<(String, PathBuf)>, pub hubs: Vec, } /// Token counts, split the way the APIs bill them. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Tokens { /// Uncached input. pub input: u64, pub output: u64, pub cache_write: u64, pub cache_read: u64, } impl Tokens { pub fn total(&self) -> u64 { self.input + self.output + self.cache_write + self.cache_read } fn add(&mut self, other: &Tokens) { self.input += other.input; self.output += other.output; self.cache_write += other.cache_write; self.cache_read += other.cache_read; } } /// Dollars, split like [`Tokens`]. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Costs { pub input: f64, pub output: f64, pub cache_write: f64, pub cache_read: f64, } impl Costs { pub fn total(&self) -> f64 { self.input + self.output + self.cache_write + self.cache_read } fn add(&mut self, other: &Costs) { self.input += other.input; self.output += other.output; self.cache_write += other.cache_write; self.cache_read += other.cache_read; } } /// One provider's use of one model in one local hour. #[derive(Clone, Debug)] pub struct Bucket { pub provider: String, pub model: String, /// The local hour it started in. pub hour: NaiveDateTime, pub tokens: Tokens, /// `None` when the model has no known price. pub cost: Option, /// What the cache reads saved over paying full input price. pub savings: f64, /// The sessions it came from, numbered across the report. pub sessions: BTreeSet, } impl Bucket { pub fn day(&self) -> NaiveDate { self.hour.date() } } #[derive(Clone, Debug, Default)] pub struct UsageReport { pub buckets: Vec, /// Hubs that couldn't be read, and the like. Shown, never fatal. pub errors: Vec, pub collected_at: Option>, } impl UsageReport { /// The provider labels that show up in the report, in the order sources were given. pub fn providers(&self) -> Vec { let mut labels: Vec = Vec::new(); for bucket in &self.buckets { if !labels.contains(&bucket.provider) { labels.push(bucket.provider.clone()); } } labels } } /// One normalized usage record, before it's folded into a bucket. struct Row { provider: String, model: String, at: DateTime, session: Option, tokens: Tokens, /// A cost the source worked out itself, such as Pi's. cost: Option, } /// Reads every source for usage since `since`. pub fn collect(sources: &UsageSources, since: SystemTime) -> UsageReport { let since: DateTime = since.into(); let mut errors = Vec::new(); let mut rows = Vec::new(); for (label, dir) in &sources.claude_dirs { let files = jsonl_files(&dir.join("projects"), since); rows.extend(dedup_claude(parse_parallel(files, |path| read_claude_file(path, label, since)))); } for (label, dir) in &sources.pi_dirs { let files = jsonl_files(&dir.join("sessions"), since); rows.extend(parse_parallel(files, |path| read_pi_file(path, label, since))); } for hub in &sources.hubs { match read_hub(hub, since) { Ok(hub_rows) => rows.extend(hub_rows), Err(err) => errors.push(format!("{}: {err}", hub_label(hub))), } } let mut report = fold(rows); report.errors = errors; report.collected_at = Some(Local::now()); report } fn hub_label(hub: &UsageHub) -> String { if hub.name.trim().is_empty() { hub.url.clone() } else { hub.name.clone() } } /// Folds rows into hourly buckets per provider and model. fn fold(rows: Vec) -> UsageReport { let mut session_ix: HashMap<(String, String), u32> = HashMap::new(); let mut buckets: HashMap<(String, String, NaiveDateTime), Bucket> = HashMap::new(); for row in rows { let Some(hour) = local_hour(row.at) else { continue }; let price = price(&row.model); let cost = row.cost.or_else(|| price.map(|price| price.cost(&row.tokens))); let savings = price.map(|price| price.savings(row.tokens.cache_read)).unwrap_or(0.); let bucket = buckets .entry((row.provider.clone(), row.model.clone(), hour)) .or_insert_with(|| Bucket { provider: row.provider.clone(), model: row.model.clone(), hour, tokens: Tokens::default(), cost: None, savings: 0., sessions: BTreeSet::new(), }); bucket.tokens.add(&row.tokens); if let Some(cost) = cost { bucket.cost.get_or_insert_with(Costs::default).add(&cost); } bucket.savings += savings; if let Some(session) = row.session { let next = session_ix.len() as u32; let ix = *session_ix.entry((row.provider, session)).or_insert(next); bucket.sessions.insert(ix); } } let mut buckets: Vec = buckets.into_values().collect(); buckets.sort_by(|a, b| (a.hour, &a.provider, &a.model).cmp(&(b.hour, &b.provider, &b.model))); UsageReport { buckets, errors: Vec::new(), collected_at: None } } fn local_hour(at: DateTime) -> Option { let local = at.with_timezone(&Local).naive_local(); local.with_minute(0)?.with_second(0)?.with_nanosecond(0) } /// Every `.jsonl` file under `dir` changed since `since`. fn jsonl_files(dir: &Path, since: DateTime) -> Vec { let since: SystemTime = since.into(); let mut files = Vec::new(); let mut stack = vec![dir.to_path_buf()]; while let Some(dir) = stack.pop() { let Ok(entries) = std::fs::read_dir(&dir) else { continue }; for entry in entries.flatten() { let Ok(kind) = entry.file_type() else { continue }; let path = entry.path(); if kind.is_dir() { stack.push(path); } else if path.extension().is_some_and(|ext| ext == "jsonl") && entry.metadata().and_then(|meta| meta.modified()).is_ok_and(|modified| modified >= since) { files.push(path); } } } files } /// Runs `read` over the files on a few threads. fn parse_parallel(files: Vec, read: impl Fn(&Path) -> Vec + Sync) -> Vec { let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8); let chunk = files.len().div_ceil(threads).max(1); std::thread::scope(|scope| { let handles: Vec<_> = files .chunks(chunk) .map(|chunk| scope.spawn(|| chunk.iter().flat_map(|path| read(path)).collect::>())) .collect(); handles.into_iter().flat_map(|handle| handle.join().unwrap_or_default()).collect() }) } /// Calls `line` for each line of a file that contains every one of `needles`. fn scan_lines(path: &Path, needles: &[&str], mut line: impl FnMut(&str)) { let Ok(file) = File::open(path) else { return }; let mut reader = BufReader::with_capacity(256 * 1024, file); let mut buf = Vec::new(); loop { buf.clear(); match reader.read_until(b'\n', &mut buf) { Ok(0) | Err(_) => break, Ok(_) => {} } let Ok(text) = std::str::from_utf8(&buf) else { continue }; if needles.iter().all(|needle| text.contains(needle)) { line(text); } } } fn parse_time(value: &Value) -> Option> { match value { Value::String(text) => DateTime::parse_from_rfc3339(text).ok().map(|at| at.with_timezone(&Utc)), Value::Number(millis) => DateTime::from_timestamp_millis(millis.as_i64()?), _ => None, } } // ---- Claude Code --------------------------------------------------------------- #[derive(Deserialize)] struct ClaudeLine { #[serde(rename = "type")] kind: Option, timestamp: Option, #[serde(rename = "sessionId")] session_id: Option, #[serde(rename = "requestId")] request_id: Option, message: Option, } #[derive(Deserialize)] struct ClaudeMessage { id: Option, model: Option, usage: Option, } #[derive(Deserialize)] struct ClaudeUsage { input_tokens: Option, output_tokens: Option, cache_creation_input_tokens: Option, cache_read_input_tokens: Option, } /// A Claude row with its (message id, request id), for deduplication. type KeyedRow = (Option<(String, String)>, Row); fn read_claude_file(path: &Path, label: &str, since: DateTime) -> Vec { let mut rows = Vec::new(); scan_lines(path, &["\"usage\"", "\"assistant\""], |text| { let Ok(line) = serde_json::from_str::(text) else { return }; if line.kind.as_deref() != Some("assistant") { return; } let Some(message) = line.message else { return }; let Some(usage) = message.usage else { return }; let Some(at) = line.timestamp.as_ref().and_then(parse_time).filter(|at| *at >= since) else { return }; let tokens = Tokens { input: usage.input_tokens.unwrap_or(0), output: usage.output_tokens.unwrap_or(0), cache_write: usage.cache_creation_input_tokens.unwrap_or(0), cache_read: usage.cache_read_input_tokens.unwrap_or(0), }; let model = message.model.unwrap_or_default(); // Claude Code writes `` for messages it made up itself. if tokens.total() == 0 || model.is_empty() { return; } let key = message.id.zip(line.request_id); rows.push(( key, Row { provider: label.to_string(), model, at, session: line.session_id, tokens, cost: None }, )); }); rows } /// Claude Code writes the same response on several lines, one per content /// block. Keeps one row per response: the one with the most output, since /// early lines can be written before the output count is final. fn dedup_claude(rows: Vec) -> Vec { let mut seen: HashMap<(String, String), usize> = HashMap::new(); let mut kept: Vec = Vec::with_capacity(rows.len()); for (key, row) in rows { match key { Some(key) => match seen.get(&key) { Some(&ix) => { if row.tokens.output > kept[ix].tokens.output { kept[ix] = row; } } None => { seen.insert(key, kept.len()); kept.push(row); } }, None => kept.push(row), } } kept } // ---- Pi -------------------------------------------------------------------------- fn read_pi_file(path: &Path, label: &str, since: DateTime) -> Vec { // Pi names its session files after the session. let session = path.file_stem().map(|stem| stem.to_string_lossy().into_owned()); let mut rows = Vec::new(); scan_lines(path, &["\"usage\"", "\"assistant\""], |text| { let Ok(line) = serde_json::from_str::(text) else { return }; if let Some(row) = pi_row(&line, label, session.clone()).filter(|row| row.at >= since) { rows.push(row); } }); rows } fn pi_row(line: &Value, label: &str, session: Option) -> Option { if line.get("type")?.as_str()? != "message" { return None; } let message = line.get("message")?; if message.get("role")?.as_str()? != "assistant" { return None; } let usage = message.get("usage")?; let count = |key: &str| usage.get(key).and_then(Value::as_u64).unwrap_or(0); let tokens = Tokens { input: count("input"), output: count("output"), cache_write: count("cacheWrite"), cache_read: count("cacheRead"), }; if tokens.total() == 0 { return None; } let at = line.get("timestamp").and_then(parse_time).or_else(|| message.get("timestamp").and_then(parse_time))?; let cost = usage.get("cost").and_then(|cost| { let part = |key: &str| cost.get(key).and_then(Value::as_f64); let total = part("total")?; Some(match (part("input"), part("output"), part("cacheWrite"), part("cacheRead")) { (Some(input), Some(output), cache_write, cache_read) => Costs { input, output, cache_write: cache_write.unwrap_or(0.), cache_read: cache_read.unwrap_or(0.), }, _ => Costs { input: total, ..Costs::default() }, }) }); let model = message.get("model").and_then(Value::as_str).unwrap_or("unknown").to_string(); Some(Row { provider: label.to_string(), model, at, session, tokens, cost }) } // ---- usage hubs ------------------------------------------------------------------ fn read_hub(hub: &UsageHub, since: DateTime) -> anyhow::Result> { let base = hub.url.trim().trim_end_matches('/'); if base.is_empty() { anyhow::bail!("no URL"); } let base = if base.contains("://") { base.to_string() } else { format!("http://{base}") }; let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(10)).build(); let response = agent .get(&format!("{base}/v0/management/usage")) .set("Authorization", &format!("Bearer {}", hub.key.trim())) .call()?; // Read it whole: the reply lists every request, past ureq's 10 MB string cap. let reply: Value = serde_json::from_reader(response.into_reader())?; Ok(hub_rows(&reply, &hub_label(hub), since)) } /// Reads CLIProxyAPI's `/v0/management/usage` reply, ignoring anything unexpected. fn hub_rows(reply: &Value, label: &str, since: DateTime) -> Vec { let mut rows = Vec::new(); let apis = reply.pointer("/usage/apis").and_then(Value::as_object); for api in apis.into_iter().flat_map(|apis| apis.values()) { let models = api.get("models").and_then(Value::as_object); for (model, entry) in models.into_iter().flatten() { let details = entry.get("details").and_then(Value::as_array); for detail in details.into_iter().flatten() { let Some(at) = detail.get("timestamp").and_then(parse_time).filter(|at| *at >= since) else { continue; }; let Some(counts) = detail.get("tokens") else { continue }; let count = |key: &str| counts.get(key).and_then(Value::as_u64).unwrap_or(0); // Cached tokens are part of the input count, as in OpenAI's usage. let cached = count("cached_tokens"); let input = count("input_tokens"); let mut output = count("output_tokens"); let reasoning = count("reasoning_tokens"); if reasoning > 0 && input + output + reasoning <= count("total_tokens") { output += reasoning; } let tokens = Tokens { input: input.saturating_sub(cached), output, cache_write: 0, cache_read: cached }; if tokens.total() == 0 { continue; } rows.push(Row { provider: label.to_string(), model: model.clone(), at, session: None, tokens, cost: None, }); } } } rows } // ---- pricing ----------------------------------------------------------------------- /// USD per million tokens. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Price { pub input: f64, pub output: f64, pub cache_read: f64, /// Five-minute cache writes: 1.25 × input. pub cache_write: f64, } impl Price { const fn new(input: f64, output: f64, cache_read: f64) -> Self { Self { input, output, cache_read, cache_write: input * 1.25 } } pub fn cost(&self, tokens: &Tokens) -> Costs { let dollars = |count: u64, rate: f64| count as f64 * rate / 1_000_000.; Costs { input: dollars(tokens.input, self.input), output: dollars(tokens.output, self.output), cache_write: dollars(tokens.cache_write, self.cache_write), cache_read: dollars(tokens.cache_read, self.cache_read), } } /// What `cache_read` tokens would have cost at full input price, minus what they cost. pub fn savings(&self, cache_read: u64) -> f64 { cache_read as f64 * (self.input - self.cache_read) / 1_000_000. } } /// Claude's prices by model id prefix. Longer prefixes come first. const PRICES: &[(&str, Price)] = &[ ("claude-fable-5-1", Price::new(10., 50., 0.25)), ("claude-fable-5", Price::new(10., 50., 1.)), ("claude-mythos", Price::new(10., 50., 1.)), ("claude-opus-5-5", Price::new(4., 20., 0.2)), ("claude-opus-5", Price::new(5., 25., 0.5)), ("claude-opus-4-8", Price::new(5., 25., 0.5)), ("claude-opus-4-7", Price::new(5., 25., 0.5)), ("claude-opus-4-6", Price::new(5., 25., 0.5)), ("claude-opus-4-5", Price::new(5., 25., 0.5)), ("claude-opus-4-1", Price::new(15., 75., 1.5)), ("claude-opus-4", Price::new(15., 75., 1.5)), ("claude-sonnet-5-5", Price::new(2., 10., 0.2)), ("claude-sonnet-5", Price::new(2., 10., 0.2)), ("claude-sonnet-4-6", Price::new(3., 15., 0.3)), ("claude-sonnet-4-5", Price::new(3., 15., 0.3)), ("claude-sonnet-4", Price::new(3., 15., 0.3)), ("claude-3-7-sonnet", Price::new(3., 15., 0.3)), ("claude-haiku-5-5", Price::new(0.1, 0.5, 0.01)), ("claude-haiku-4-5", Price::new(1., 5., 0.1)), ("claude-3-5-haiku", Price::new(0.8, 4., 0.08)), ]; /// The price of a Claude model, from ids like `claude-opus-5-5`, /// `claude-haiku-4-5-20251001`, `anthropic/claude-sonnet-5` or /// `us.anthropic.claude-opus-4-1-v1:0`. `None` for anything else. pub fn price(model: &str) -> Option { let start = model.find("claude-")?; let mut id = &model[start..]; if let Some(cut) = id.find(['[', ':', '@']) { id = &id[..cut]; } let id = strip_date(id); PRICES .iter() .find(|(prefix, _)| id.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with('-'))) .map(|(_, price)| *price) } /// Drops a release date suffix: `claude-haiku-4-5-20251001` → `claude-haiku-4-5`. fn strip_date(id: &str) -> &str { match id.rsplit_once('-') { Some((base, date)) if date.len() == 8 && date.bytes().all(|b| b.is_ascii_digit()) => base, _ => id, } } // ---- summaries --------------------------------------------------------------------- /// The time ranges the Usage page offers. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Span { Day, Week, Month, Quarter, } impl Span { pub const ALL: [Span; 4] = [Span::Day, Span::Week, Span::Month, Span::Quarter]; pub fn label(self) -> &'static str { match self { Span::Day => "Past 24h", Span::Week => "7 days", Span::Month => "30 days", Span::Quarter => "90 days", } } pub fn days(self) -> i64 { match self { Span::Day => 1, Span::Week => 7, Span::Month => 30, Span::Quarter => 90, } } /// The past 24 hours chart by the hour, the rest by the day. pub fn hourly(self) -> bool { self == Span::Day } /// When the range starts, as a moment to collect from. pub fn since(self, now: DateTime) -> SystemTime { let start = self.first_slot(now.naive_local()); start.and_local_timezone(Local).earliest().map(SystemTime::from).unwrap_or_else(|| { SystemTime::from(now) - Duration::from_secs(self.days() as u64 * 86_400) }) } fn first_slot(self, now: NaiveDateTime) -> NaiveDateTime { if self.hourly() { let hour = now.with_minute(0).and_then(|t| t.with_second(0)).and_then(|t| t.with_nanosecond(0)); hour.unwrap_or(now) - TimeDelta::hours(23) } else { (now.date() - TimeDelta::days(self.days() - 1)).and_hms_opt(0, 0, 0).unwrap_or(now) } } } /// Totals for one provider. #[derive(Clone, Debug, Default)] pub struct ProviderTotal { pub label: String, pub sessions: usize, pub tokens: u64, pub cost: f64, } /// One point of the chart: an hour or a day. #[derive(Clone, Debug)] pub struct Point { /// The axis label: `14:00` or `Oct 9`. pub label: String, /// The tooltip's title: `Thu, Oct 9, 14:00` or `Thu, Oct 9`. pub title: String, pub tokens: u64, pub cost: f64, } /// One row of the breakdown table. #[derive(Clone, Debug)] pub struct Line { pub label: String, pub tokens: u64, /// `None` when none of it has a price. pub cost: Option, } /// The report narrowed to a range and maybe one provider, in the shapes the page shows. #[derive(Clone, Debug, Default)] pub struct Summary { pub tokens: Tokens, pub cost: Costs, pub savings: f64, /// Tokens of models without a known price. pub unpriced: u64, pub sessions: usize, pub providers: Vec, /// Every hour or day in the range, oldest first, zeros included. pub series: Vec, /// Most tokens first. pub models: Vec, /// Newest first, days with use only. pub days: Vec, } pub fn summarize(report: &UsageReport, span: Span, provider: Option<&str>, now: NaiveDateTime) -> Summary { let start = span.first_slot(now); let mut summary = Summary::default(); let mut providers: Vec<(ProviderTotal, BTreeSet)> = Vec::new(); let mut all_sessions = BTreeSet::new(); let mut models: Vec = Vec::new(); let mut days: Vec<(NaiveDate, Line)> = Vec::new(); let slots: Vec = if span.hourly() { (0..24).map(|ix| start + TimeDelta::hours(ix)).collect() } else { (0..span.days()).map(|ix| start + TimeDelta::days(ix)).collect() }; summary.series = slots .iter() .map(|slot| { let (label, title) = if span.hourly() { (slot.format("%H:%M").to_string(), slot.format("%a, %b %-d, %H:%M").to_string()) } else { (slot.format("%b %-d").to_string(), slot.format("%a, %b %-d").to_string()) }; Point { label, title, tokens: 0, cost: 0. } }) .collect(); for bucket in &report.buckets { if bucket.hour < start || provider.is_some_and(|provider| provider != bucket.provider) { continue; } let tokens = bucket.tokens.total(); let cost = bucket.cost.map(|cost| cost.total()); summary.tokens.add(&bucket.tokens); if let Some(costs) = &bucket.cost { summary.cost.add(costs); } else { summary.unpriced += tokens; } summary.savings += bucket.savings; all_sessions.extend(bucket.sessions.iter().copied()); let ix = match providers.iter().position(|(total, _)| total.label == bucket.provider) { Some(ix) => ix, None => { let total = ProviderTotal { label: bucket.provider.clone(), ..ProviderTotal::default() }; providers.push((total, BTreeSet::new())); providers.len() - 1 } }; let (total, sessions) = &mut providers[ix]; total.tokens += tokens; total.cost += cost.unwrap_or(0.); sessions.extend(bucket.sessions.iter().copied()); add_line(&mut models, |line| line.label == bucket.model, &bucket.model, tokens, cost); let day = bucket.day(); match days.iter_mut().find(|(date, _)| *date == day) { Some((_, line)) => add_to(line, tokens, cost), None => days.push((day, Line { label: day.format("%a, %b %-d").to_string(), tokens, cost })), } let slot = if span.hourly() { (bucket.hour - start).num_hours() } else { (bucket.day() - start.date()).num_days() }; if let Some(point) = usize::try_from(slot).ok().and_then(|slot| summary.series.get_mut(slot)) { point.tokens += tokens; point.cost += cost.unwrap_or(0.); } } summary.sessions = all_sessions.len(); summary.providers = providers .into_iter() .map(|(mut total, sessions)| { total.sessions = sessions.len(); total }) .collect(); summary.providers.sort_by(|a, b| b.tokens.cmp(&a.tokens)); models.sort_by(|a, b| b.tokens.cmp(&a.tokens)); summary.models = models; days.sort_by(|a, b| b.0.cmp(&a.0)); summary.days = days.into_iter().map(|(_, line)| line).collect(); summary } fn add_line(lines: &mut Vec, is: impl Fn(&Line) -> bool, label: &str, tokens: u64, cost: Option) { match lines.iter_mut().find(|line| is(line)) { Some(line) => add_to(line, tokens, cost), None => lines.push(Line { label: label.to_string(), tokens, cost }), } } fn add_to(line: &mut Line, tokens: u64, cost: Option) { line.tokens += tokens; if let Some(cost) = cost { *line.cost.get_or_insert(0.) += cost; } } // ---- formatting -------------------------------------------------------------------- /// Token counts the way T3 Code writes them: `532`, `14.2K`, `4.63M`, `1.08B`. pub fn format_tokens(count: u64) -> String { let count = count as f64; let (value, unit) = if count >= 1e9 { (count / 1e9, "B") } else if count >= 1e6 { (count / 1e6, "M") } else if count >= 1e3 { (count / 1e3, "K") } else { return format!("{count}"); }; let digits = if value >= 100. { 0 } else if value >= 10. { 1 } else { 2 }; format!("{value:.digits$}{unit}") } /// Dollars with cents and thousands separators: `$1,405.02`. pub fn format_cost(dollars: f64) -> String { if dollars > 0. && dollars < 0.01 { return "<$0.01".to_string(); } let cents = (dollars * 100.).round() as u64; let whole = (cents / 100).to_string(); let mut grouped = String::new(); for (ix, digit) in whole.chars().enumerate() { if ix > 0 && (whole.len() - ix).is_multiple_of(3) { grouped.push(','); } grouped.push(digit); } format!("${grouped}.{:02}", cents % 100) } // ---- plan limits ------------------------------------------------------------------- /// One plan limit, such as Claude's five-hour session window. #[derive(Clone, Debug, PartialEq)] pub struct Limit { pub label: String, /// What the bar under the composer calls it (`5h`, `Week`), for the /// limits it shows. pub short: Option, /// How much of it is used, 0–100. pub percent: f64, pub resets_at: Option>, /// `normal`, `warning` or `critical`, as Claude reports it. pub severity: String, /// Whether this is the limit currently holding the account back the most. pub active: bool, } /// Reads the limits from the reply to the Claude CLI's `get_usage` control request. pub fn limits_from_claude(reply: &Value) -> Vec { let rate_limits = reply.get("rate_limits").unwrap_or(reply); if let Some(limits) = rate_limits.get("limits").and_then(Value::as_array) { return limits.iter().filter_map(claude_limit).collect(); } // Older replies only have the named windows. const WINDOWS: [(&str, &str); 4] = [ ("five_hour", "Current session"), ("seven_day", "Weekly · all models"), ("seven_day_opus", "Weekly · Opus"), ("seven_day_sonnet", "Weekly · Sonnet"), ]; let Some(windows) = rate_limits.as_object() else { return Vec::new() }; WINDOWS .iter() .filter_map(|(key, label)| { let window = windows.get(*key)?; let percent = window.get("utilization").and_then(Value::as_f64)?; Some(Limit { label: label.to_string(), short: short_claude_label(key), percent, resets_at: window.get("resets_at").and_then(parse_time).map(|at| at.with_timezone(&Local)), severity: severity_of(percent).to_string(), active: false, }) }) .collect() } fn claude_limit(limit: &Value) -> Option { let percent = limit.get("percent").and_then(Value::as_f64)?; let kind = limit.get("kind").and_then(Value::as_str).unwrap_or_default(); let scoped_to = limit .pointer("/scope/model/display_name") .and_then(Value::as_str) .or_else(|| limit.pointer("/scope/surface").and_then(Value::as_str)); let label = match (kind, scoped_to) { ("session", _) => "Current session".to_string(), ("weekly_all", _) => "Weekly · all models".to_string(), (_, Some(scope)) if kind.starts_with("weekly") => format!("Weekly · {scope}"), (_, Some(scope)) => format!("{} · {scope}", humanize(kind)), _ => humanize(kind), }; Some(Limit { label, short: short_claude_label(kind), percent, resets_at: limit.get("resets_at").and_then(parse_time).map(|at| at.with_timezone(&Local)), severity: limit .get("severity") .and_then(Value::as_str) .map(str::to_string) .unwrap_or_else(|| severity_of(percent).to_string()), active: limit.get("is_active").and_then(Value::as_bool).unwrap_or(false), }) } /// The session and all-models weekly limits are the ones the bar shows. fn short_claude_label(kind: &str) -> Option { match kind { "session" | "five_hour" => Some("5h".into()), "weekly_all" | "seven_day" => Some("Week".into()), _ => None, } } fn severity_of(percent: f64) -> &'static str { if percent >= 90. { "critical" } else if percent >= 75. { "warning" } else { "normal" } } /// `weekly_scoped` → `Weekly scoped`. fn humanize(key: &str) -> String { let words = key.replace('_', " "); let mut chars = words.chars(); match chars.next() { Some(first) => first.to_uppercase().chain(chars).collect(), None => "Limit".to_string(), } } // ---- Codex limits ------------------------------------------------------------------ /// Where Codex's limits are read from: the ChatGPT logins of the Codex CLI /// and Pi, and the Codex CLI's session logs. #[derive(Clone, Debug, Default)] pub struct CodexSources { /// Codex CLI homes, such as `~/.codex`. pub codex_homes: Vec, /// Pi agent directories, whose `auth.json` may hold an `openai-codex` login. pub pi_dirs: Vec, } impl CodexSources { /// `$CODEX_HOME` or `~/.codex`, and the given Pi directories. pub fn with_pi(pi_dirs: Vec) -> Self { let codex_home = std::env::var_os("CODEX_HOME") .filter(|home| !home.is_empty()) .map(PathBuf::from) .unwrap_or_else(|| crate::settings::expand_home("~/.codex")); Self { codex_homes: vec![codex_home], pi_dirs } } } /// A ChatGPT login Codex's usage can be asked with. struct CodexLogin { access_token: String, account_id: Option, } const CODEX_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage"; /// Asks ChatGPT how much of the Codex plan's limits is used, with the first /// login that works; failing that, takes the newest limits the Codex CLI /// logged. Blocks. pub fn codex_limits(sources: &CodexSources) -> Result, String> { let mut error = None; for login in codex_logins(sources) { let mut headers = vec![("Authorization", format!("Bearer {}", login.access_token))]; if let Some(account) = &login.account_id { headers.push(("ChatGPT-Account-Id", account.clone())); } let headers: Vec<(&str, &str)> = headers.iter().map(|(name, value)| (*name, value.as_str())).collect(); match crate::http::get(CODEX_USAGE_URL, &headers, Duration::from_secs(15)) { Ok(body) => match serde_json::from_str::(&body) { Ok(reply) => return Ok(limits_from_codex(&reply, Local::now())), Err(err) => error = Some(err.to_string()), }, Err(err) => error = Some(err), } } let now = Local::now(); for home in &sources.codex_homes { if let Some(limits) = codex_logged_limits(&home.join("sessions"), now) { return Ok(limits); } } Err(error.unwrap_or_else(|| "No Codex login found. Sign in with the Codex CLI or Pi.".into())) } fn codex_logins(sources: &CodexSources) -> Vec { let read = |path: PathBuf| std::fs::read_to_string(path).ok().and_then(|text| serde_json::from_str::(&text).ok()); let mut logins = Vec::new(); for home in &sources.codex_homes { let Some(auth) = read(home.join("auth.json")) else { continue }; let Some(token) = auth.pointer("/tokens/access_token").and_then(Value::as_str) else { continue }; let account_id = auth.pointer("/tokens/account_id").and_then(Value::as_str).map(str::to_string); logins.push(CodexLogin { access_token: token.to_string(), account_id: account_id.or_else(|| jwt_account_id(token)) }); } for dir in &sources.pi_dirs { let Some(auth) = read(dir.join("auth.json")) else { continue }; let Some(login) = auth.get("openai-codex") else { continue }; let Some(token) = login.get("access").and_then(Value::as_str) else { continue }; // Pi refreshes its token itself; an expired one would only be refused. let expires = login.get("expires").and_then(Value::as_i64); if expires.is_some_and(|expires| expires < Utc::now().timestamp_millis()) { continue; } let account_id = login.get("accountId").and_then(Value::as_str).map(str::to_string); logins.push(CodexLogin { access_token: token.to_string(), account_id: account_id.or_else(|| jwt_account_id(token)) }); } logins } /// The ChatGPT account an OpenAI access token is for. fn jwt_account_id(token: &str) -> Option { use base64::Engine as _; let payload = token.split('.').nth(1)?; let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload.trim_end_matches('=')).ok()?; let claims: Value = serde_json::from_slice(&bytes).ok()?; claims.pointer("/https:~1~1api.openai.com~1auth/chatgpt_account_id").and_then(Value::as_str).map(str::to_string) } /// Reads ChatGPT's usage reply: a primary (five-hour) and a secondary /// (weekly) window. pub fn limits_from_codex(reply: &Value, now: DateTime) -> Vec { let Some(rate_limit) = reply.get("rate_limit") else { return Vec::new() }; ["primary_window", "secondary_window"] .iter() .filter_map(|key| { let window = rate_limit.get(*key)?; let percent = window.get("used_percent").and_then(Value::as_f64)?; let minutes = window.get("limit_window_seconds").and_then(Value::as_i64).map(|secs| secs / 60); let resets_at = window .get("reset_at") .and_then(Value::as_i64) .and_then(|secs| DateTime::from_timestamp(secs, 0)) .map(|at| at.with_timezone(&Local)) .or_else(|| { let secs = window.get("reset_after_seconds").and_then(Value::as_i64)?; Some(now + TimeDelta::seconds(secs)) }); Some(codex_limit(percent, minutes, resets_at)) }) .collect() } /// The limits in the newest `token_count` event of the Codex CLI's session /// logs, with windows that have reset since shown empty. fn codex_logged_limits(sessions: &Path, now: DateTime) -> Option> { let mut files: Vec<(SystemTime, PathBuf)> = jsonl_files(sessions, DateTime::UNIX_EPOCH) .into_iter() .filter_map(|path| Some((std::fs::metadata(&path).ok()?.modified().ok()?, path))) .collect(); files.sort_by_key(|(modified, _)| std::cmp::Reverse(*modified)); files.into_iter().take(5).find_map(|(_, path)| { let mut newest = None; scan_lines(&path, &["\"token_count\"", "\"rate_limits\""], |text| { if let Ok(line) = serde_json::from_str::(text) { newest = Some(line); } }); newest.and_then(|line| limits_from_codex_event(&line, now)) }) } /// Reads an `event_msg` line's `token_count` payload, whose `rate_limits` /// have a `primary` and a `secondary` window. fn limits_from_codex_event(line: &Value, now: DateTime) -> Option> { let rate_limits = line.pointer("/payload/rate_limits").filter(|limits| limits.is_object())?; let logged_at = line.get("timestamp").and_then(parse_time).map(|at| at.with_timezone(&Local)).unwrap_or(now); let limits: Vec = ["primary", "secondary"] .iter() .filter_map(|key| { let window = rate_limits.get(*key)?; let mut percent = window.get("used_percent").and_then(Value::as_f64)?; let minutes = window.get("window_minutes").and_then(Value::as_i64); let resets_at = match window.get("resets_at").and_then(Value::as_i64) { Some(secs) => DateTime::from_timestamp(secs, 0).map(|at| at.with_timezone(&Local)), None => window.get("resets_in_seconds").and_then(Value::as_i64).map(|secs| logged_at + TimeDelta::seconds(secs)), }; if resets_at.is_some_and(|at| at <= now) { percent = 0.; } Some(codex_limit(percent, minutes, resets_at)) }) .collect(); (!limits.is_empty()).then_some(limits) } fn codex_limit(percent: f64, minutes: Option, resets_at: Option>) -> Limit { let short = match minutes { Some(300) => "5h".to_string(), Some(10080) => "Week".to_string(), Some(minutes) if minutes > 0 && minutes % 1440 == 0 => format!("{}d", minutes / 1440), Some(minutes) if minutes > 0 && minutes % 60 == 0 => format!("{}h", minutes / 60), Some(minutes) if minutes > 0 => format!("{minutes}m"), _ => "Limit".to_string(), }; let label = match short.as_str() { "5h" => "5-hour limit".to_string(), "Week" => "Weekly limit".to_string(), "Limit" => "Limit".to_string(), other => format!("{other} limit"), }; Limit { label, short: Some(short), percent, resets_at, severity: severity_of(percent).to_string(), active: false } } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn prices_by_prefix() { assert_eq!(price("claude-opus-5-5"), Some(Price::new(4., 20., 0.2))); assert_eq!(price("claude-haiku-4-5-20251001"), Some(Price::new(1., 5., 0.1))); assert_eq!(price("claude-opus-4-20250514"), Some(Price::new(15., 75., 1.5))); assert_eq!(price("claude-opus-4-1-20250805"), Some(Price::new(15., 75., 1.5))); assert_eq!(price("claude-opus-4-6"), Some(Price::new(5., 25., 0.5))); assert_eq!(price("claude-fable-5-1"), Some(Price::new(10., 50., 0.25))); assert_eq!(price("claude-fable-5"), Some(Price::new(10., 50., 1.))); assert_eq!(price("claude-mythos-1"), Some(Price::new(10., 50., 1.))); assert_eq!(price("anthropic/claude-sonnet-5"), Some(Price::new(2., 10., 0.2))); assert_eq!(price("claude-opus-5-5[1m]"), Some(Price::new(4., 20., 0.2))); assert_eq!(price("claude-3-5-haiku-20241022"), Some(Price::new(0.8, 4., 0.08))); assert_eq!(price(""), None); assert_eq!(price("gpt-5"), None); assert_eq!(Price::new(4., 20., 0.2).cache_write, 5.); } #[test] fn opus_5_5_month_costs_about_405() { let tokens = Tokens { input: 0, output: 4_600_000, cache_write: 20_600_000, cache_read: 1_050_000_000 }; let cost = price("claude-opus-5-5").unwrap().cost(&tokens).total(); assert!((cost - 405.).abs() < 1., "{cost}"); assert!((price("claude-opus-5-5").unwrap().savings(1_000_000) - 3.8).abs() < 1e-9); } #[test] fn formats_like_t3() { assert_eq!(format_tokens(1_080_000_000), "1.08B"); assert_eq!(format_tokens(4_630_000), "4.63M"); assert_eq!(format_tokens(14_200), "14.2K"); assert_eq!(format_tokens(532), "532"); assert_eq!(format_cost(405.019), "$405.02"); assert_eq!(format_cost(1405.5), "$1,405.50"); assert_eq!(format_cost(0.), "$0.00"); } #[test] fn claude_lines_dedup_by_response() { let line = |output: u64| { json!({ "type": "assistant", "timestamp": "2026-10-01T10:00:00.000Z", "sessionId": "s1", "requestId": "req_1", "message": {"id": "msg_1", "model": "claude-opus-5-5", "usage": { "input_tokens": 2, "output_tokens": output, "cache_creation_input_tokens": 100, "cache_read_input_tokens": 1000}} }) .to_string() }; let dir = std::env::temp_dir().join(format!("vectrum-usage-test-{}", std::process::id())); let file = dir.join("projects/p/s1.jsonl"); std::fs::create_dir_all(file.parent().unwrap()).unwrap(); std::fs::write(&file, format!("{}\n{}\n{{\"type\":\"user\"}}\n", line(5), line(40))).unwrap(); let sources = UsageSources { claude_dirs: vec![("Claude Code".into(), dir.clone())], ..Default::default() }; let report = collect(&sources, SystemTime::UNIX_EPOCH); std::fs::remove_dir_all(&dir).ok(); assert_eq!(report.buckets.len(), 1); assert_eq!(report.buckets[0].tokens, Tokens { input: 2, output: 40, cache_write: 100, cache_read: 1000 }); assert_eq!(report.buckets[0].sessions.len(), 1); } #[test] fn pi_rows_use_pis_cost() { let line = json!({ "type": "message", "timestamp": "2026-10-01T10:00:00.000Z", "message": {"role": "assistant", "model": "gpt-5", "provider": "openai", "usage": { "input": 10, "output": 5, "cacheRead": 0, "cacheWrite": 0, "cost": {"input": 0.01, "output": 0.02, "cacheRead": 0, "cacheWrite": 0, "total": 0.03}}} }); let row = pi_row(&line, "Pi", Some("s".into())).unwrap(); assert_eq!(row.tokens.total(), 15); assert!((row.cost.unwrap().total() - 0.03).abs() < 1e-9); } #[test] fn hub_rows_split_cached_input() { let reply = json!({"usage": {"apis": {"key": {"models": {"claude-sonnet-5": {"details": [ {"timestamp": "2026-10-01T10:00:00Z", "failed": false, "tokens": { "input_tokens": 100, "output_tokens": 10, "reasoning_tokens": 0, "cached_tokens": 60, "total_tokens": 110}} ]}}}}}}); let rows = hub_rows(&reply, "Hub", DateTime::::UNIX_EPOCH); assert_eq!(rows.len(), 1); assert_eq!(rows[0].tokens, Tokens { input: 40, output: 10, cache_write: 0, cache_read: 60 }); } #[test] fn summary_fills_missing_days() { let now = NaiveDate::from_ymd_opt(2026, 10, 9).unwrap().and_hms_opt(12, 30, 0).unwrap(); let bucket = |day: u32, model: &str, output: u64| Bucket { provider: "Claude Code".into(), model: model.into(), hour: NaiveDate::from_ymd_opt(2026, 10, day).unwrap().and_hms_opt(9, 0, 0).unwrap(), tokens: Tokens { output, ..Tokens::default() }, cost: price(model).map(|price| price.cost(&Tokens { output, ..Tokens::default() })), savings: 0., sessions: BTreeSet::from([day]), }; let report = UsageReport { buckets: vec![bucket(1, "claude-opus-5-5", 1_000_000), bucket(8, "mystery", 500), bucket(9, "claude-opus-5-5", 10)], ..UsageReport::default() }; let week = summarize(&report, Span::Week, None, now); assert_eq!(week.series.len(), 7); assert_eq!(week.series[0].label, "Oct 3"); assert_eq!(week.series[5].tokens, 500); assert_eq!(week.series[6].tokens, 10); assert_eq!(week.tokens.total(), 510); assert_eq!(week.unpriced, 500); assert_eq!(week.sessions, 2); assert_eq!(week.models[0].label, "mystery"); assert_eq!(week.models[0].cost, None); assert_eq!(week.days.len(), 2); let month = summarize(&report, Span::Month, Some("Claude Code"), now); assert_eq!(month.series.len(), 30); assert!((month.cost.total() - 20.0002).abs() < 1e-6); assert_eq!(month.providers[0].sessions, 3); let day = summarize(&report, Span::Day, None, now); assert_eq!(day.series.len(), 24); assert_eq!(day.series.last().unwrap().label, "12:00"); assert_eq!(day.tokens.total(), 10); } #[test] fn limits_from_the_limits_list() { let reply = json!({ "subscription_type": "team", "rate_limits_available": true, "rate_limits": { "five_hour": {"utilization": 7, "resets_at": "2026-10-10T01:40:00.386310+00:00"}, "seven_day": {"utilization": 22, "resets_at": "2026-10-12T17:00:00.386332+00:00"}, "seven_day_opus": null, "extra_usage": {"is_enabled": false}, "limits": [ {"kind": "session", "group": "session", "percent": 7, "severity": "normal", "resets_at": "2026-10-10T01:40:00.386310+00:00", "scope": null, "is_active": false}, {"kind": "weekly_all", "group": "weekly", "percent": 22, "severity": "normal", "resets_at": "2026-10-12T17:00:00.386332+00:00", "scope": null, "is_active": true}, {"kind": "weekly_scoped", "group": "weekly", "percent": 6, "severity": "normal", "resets_at": "2026-10-12T17:00:00.386332+00:00", "scope": {"model": {"id": null, "display_name": "Fable"}, "surface": null}, "is_active": false} ] } }); let limits = limits_from_claude(&reply); let labels: Vec<&str> = limits.iter().map(|limit| limit.label.as_str()).collect(); assert_eq!(labels, ["Current session", "Weekly · all models", "Weekly · Fable"]); assert_eq!(limits[1].percent, 22.); assert!(limits[1].active); assert!(!limits[0].active); assert_eq!( limits[0].resets_at.map(|at| at.with_timezone(&Utc).to_rfc3339()), Some("2026-10-10T01:40:00.386310+00:00".to_string()) ); } #[test] fn limits_from_chatgpt_usage() { let now = Local::now(); let reply = json!({"plan_type": "plus", "rate_limit": { "primary_window": {"used_percent": 12, "limit_window_seconds": 18000, "reset_after_seconds": 600, "reset_at": 1_900_000_000}, "secondary_window": {"used_percent": 62.5, "limit_window_seconds": 604800, "reset_after_seconds": 3600}, }}); let limits = limits_from_codex(&reply, now); assert_eq!(limits.len(), 2); assert_eq!(limits[0].short.as_deref(), Some("5h")); assert_eq!(limits[0].percent, 12.); assert_eq!(limits[0].resets_at.map(|at| at.timestamp()), Some(1_900_000_000)); assert_eq!(limits[1].short.as_deref(), Some("Week")); assert_eq!(limits[1].label, "Weekly limit"); assert_eq!(limits[1].resets_at.map(|at| at.timestamp()), Some((now + TimeDelta::seconds(3600)).timestamp())); assert!(limits_from_codex(&json!({}), now).is_empty()); } #[test] fn limits_from_codex_logs() { let line = json!({"timestamp": "2026-10-11T10:00:00.000Z", "type": "event_msg", "payload": { "type": "token_count", "rate_limits": { "primary": {"used_percent": 40.0, "window_minutes": 300, "resets_in_seconds": 3600}, "secondary": {"used_percent": 70.0, "window_minutes": 10080, "resets_at": 1_900_000_000}, }, }}); let before = DateTime::parse_from_rfc3339("2026-10-11T10:30:00Z").unwrap().with_timezone(&Local); let limits = limits_from_codex_event(&line, before).unwrap(); assert_eq!((limits[0].percent, limits[1].percent), (40., 70.)); // Once the five-hour window has reset, nothing of it is used. let after = DateTime::parse_from_rfc3339("2026-10-11T11:30:00Z").unwrap().with_timezone(&Local); let limits = limits_from_codex_event(&line, after).unwrap(); assert_eq!((limits[0].percent, limits[1].percent), (0., 70.)); assert!(limits_from_codex_event(&json!({"payload": {"rate_limits": null}}), after).is_none()); } #[test] fn account_id_from_access_token() { use base64::Engine as _; let claims = json!({"https://api.openai.com/auth": {"chatgpt_account_id": "acct-1"}}); let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string()); assert_eq!(jwt_account_id(&format!("header.{payload}.sig")).as_deref(), Some("acct-1")); assert_eq!(jwt_account_id("not-a-token"), None); } #[test] fn limits_from_named_windows() { let reply = json!({"rate_limits": { "five_hour": {"utilization": 95, "resets_at": "2026-10-10T01:40:00+00:00"}, "seven_day": {"utilization": 22, "resets_at": null}, "seven_day_opus": null }}); let limits = limits_from_claude(&reply); assert_eq!(limits.len(), 2); assert_eq!(limits[0].label, "Current session"); assert_eq!(limits[0].short.as_deref(), Some("5h")); assert_eq!(limits[0].severity, "critical"); assert_eq!(limits[1].label, "Weekly · all models"); assert_eq!(limits[1].resets_at, None); assert!(limits_from_claude(&json!({})).is_empty()); } /// Prints the last 30 days of this machine's Claude Code usage by model: /// `cargo test real_claude_usage -- --ignored --nocapture`. #[test] #[ignore] fn real_claude_usage() { let home = PathBuf::from(std::env::var_os("HOME").unwrap()); let sources = UsageSources { claude_dirs: vec![("Claude Code".into(), home.join(".claude"))], pi_dirs: vec![("Pi".into(), home.join(".pi/agent"))], hubs: Vec::new(), }; let started = std::time::Instant::now(); let report = collect(&sources, Span::Month.since(Local::now())); let elapsed = started.elapsed(); let summary = summarize(&report, Span::Month, None, Local::now().naive_local()); println!("collected in {elapsed:?}, {} buckets", report.buckets.len()); for line in &summary.models { let cost = line.cost.map(format_cost).unwrap_or_else(|| "unpriced".into()); println!("{:<28} {:>8} {:>12}", line.label, format_tokens(line.tokens), cost); } let t = summary.tokens; println!( "total {} ({} sessions) {} · input {} output {} cache write {} cache read {} · savings {}", format_tokens(t.total()), summary.sessions, format_cost(summary.cost.total()), format_tokens(t.input), format_tokens(t.output), format_tokens(t.cache_write), format_tokens(t.cache_read), format_cost(summary.savings), ); println!("errors: {:?}", report.errors); } }