Vectrum
Sign In
1086 lines · 40.6 KB · Rust
//! 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,
    /// 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(),
                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,
        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),
    })
}

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(),
    }
}

#[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_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].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);
    }
}