//! 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<UsageHub>,
}
/// 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<Costs>,
/// 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<u32>,
}
impl Bucket {
pub fn day(&self) -> NaiveDate {
self.hour.date()
}
}
#[derive(Clone, Debug, Default)]
pub struct UsageReport {
pub buckets: Vec<Bucket>,
/// Hubs that couldn't be read, and the like. Shown, never fatal.
pub errors: Vec<String>,
pub collected_at: Option<DateTime<Local>>,
}
impl UsageReport {
/// The provider labels that show up in the report, in the order sources were given.
pub fn providers(&self) -> Vec<String> {
let mut labels: Vec<String> = 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<Utc>,
session: Option<String>,
tokens: Tokens,
/// A cost the source worked out itself, such as Pi's.
cost: Option<Costs>,
}
/// Reads every source for usage since `since`.
pub fn collect(sources: &UsageSources, since: SystemTime) -> UsageReport {
let since: DateTime<Utc> = 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<Row>) -> 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<Bucket> = 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<Utc>) -> Option<NaiveDateTime> {
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<Utc>) -> Vec<PathBuf> {
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<T: Send>(files: Vec<PathBuf>, read: impl Fn(&Path) -> Vec<T> + Sync) -> Vec<T> {
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::<Vec<T>>()))
.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<DateTime<Utc>> {
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<String>,
timestamp: Option<Value>,
#[serde(rename = "sessionId")]
session_id: Option<String>,
#[serde(rename = "requestId")]
request_id: Option<String>,
message: Option<ClaudeMessage>,
}
#[derive(Deserialize)]
struct ClaudeMessage {
id: Option<String>,
model: Option<String>,
usage: Option<ClaudeUsage>,
}
#[derive(Deserialize)]
struct ClaudeUsage {
input_tokens: Option<u64>,
output_tokens: Option<u64>,
cache_creation_input_tokens: Option<u64>,
cache_read_input_tokens: Option<u64>,
}
/// 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<Utc>) -> Vec<KeyedRow> {
let mut rows = Vec::new();
scan_lines(path, &["\"usage\"", "\"assistant\""], |text| {
let Ok(line) = serde_json::from_str::<ClaudeLine>(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 `<synthetic>` 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<KeyedRow>) -> Vec<Row> {
let mut seen: HashMap<(String, String), usize> = HashMap::new();
let mut kept: Vec<Row> = 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<Utc>) -> Vec<Row> {
// 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::<Value>(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<String>) -> Option<Row> {
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<Utc>) -> anyhow::Result<Vec<Row>> {
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<Utc>) -> Vec<Row> {
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<Price> {
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<Local>) -> 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<f64>,
}
/// 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<ProviderTotal>,
/// Every hour or day in the range, oldest first, zeros included.
pub series: Vec<Point>,
/// Most tokens first.
pub models: Vec<Line>,
/// Newest first, days with use only.
pub days: Vec<Line>,
}
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<u32>)> = Vec::new();
let mut all_sessions = BTreeSet::new();
let mut models: Vec<Line> = Vec::new();
let mut days: Vec<(NaiveDate, Line)> = Vec::new();
let slots: Vec<NaiveDateTime> = 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<Line>, is: impl Fn(&Line) -> bool, label: &str, tokens: u64, cost: Option<f64>) {
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<f64>) {
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<String>,
/// How much of it is used, 0–100.
pub percent: f64,
pub resets_at: Option<DateTime<Local>>,
/// `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<Limit> {
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<Limit> {
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<String> {
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<PathBuf>,
/// Pi agent directories, whose `auth.json` may hold an `openai-codex` login.
pub pi_dirs: Vec<PathBuf>,
}
impl CodexSources {
/// `$CODEX_HOME` or `~/.codex`, and the given Pi directories.
pub fn with_pi(pi_dirs: Vec<PathBuf>) -> 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<String>,
}
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<Vec<Limit>, 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::<Value>(&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<CodexLogin> {
let read = |path: PathBuf| std::fs::read_to_string(path).ok().and_then(|text| serde_json::from_str::<Value>(&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<String> {
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<Local>) -> Vec<Limit> {
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<Local>) -> Option<Vec<Limit>> {
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::<Value>(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<Local>) -> Option<Vec<Limit>> {
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<Limit> = ["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<i64>, resets_at: Option<DateTime<Local>>) -> 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("<synthetic>"), 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::<Utc>::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);
}
}