Vectrum
Sign In

Usage page: cost, tokens and limits from Claude Code and Pi logs, usage hubs, and Claude's plan limits

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 35e9d52commit9f24103

Showing 5 changed files with 2070 additions and 3 deletions.

+1−0src/main.rsView File
@@ -14,6 +14,7 @@ mod settings;
1414 mod store;
1515 mod textgen;
1616 mod ui;
17+ mod usage;
1718 mod worktree;
1819
1920 use std::{
+1−0src/ui/mod.rsView File
@@ -6,4 +6,5 @@ pub mod markdown_input;
66 pub mod metal;
77 pub mod pickers;
88 pub mod settings;
9+ pub mod usage;
910 pub mod workspace;
+57−3src/ui/settings.rsView File
@@ -32,6 +32,8 @@ use crate::ui::chat::display_path;
3232 use crate::ui::icons::Lucide;
3333 use crate::ui::metal::{self, BONE, Colorway, GRAPHITE, Led, MetalButton, MetalStyled as _, Palette};
3434 use crate::ui::pickers;
35+ use crate::ui::usage::{HubSettings, UsageView};
36+ use crate::usage::UsageSources;
3537 use crate::ui::workspace::{self, SHEET_INSET, SHEET_RADIUS, SIDEBAR_WIDTH, Workspace};
3638
3739 const PAGE_WIDTH: f32 = 760.;
@@ -42,6 +44,7 @@ pub enum Page {
4244 Behavior,
4345 Appearance,
4446 Providers,
47+ Usage,
4548 Connections,
4649 Keybindings,
4750 Archive,
@@ -49,11 +52,12 @@ pub enum Page {
4952 }
5053
5154 impl Page {
52- const ALL: [Page; 8] = [
55+ const ALL: [Page; 9] = [
5356 Page::General,
5457 Page::Behavior,
5558 Page::Appearance,
5659 Page::Providers,
60+ Page::Usage,
5761 Page::Connections,
5862 Page::Keybindings,
5963 Page::Archive,
@@ -66,6 +70,7 @@ impl Page {
6670 Page::Behavior => "Behavior",
6771 Page::Appearance => "Appearance",
6872 Page::Providers => "Providers",
73+ Page::Usage => "Usage",
6974 Page::Connections => "Connections",
7075 Page::Keybindings => "Keybindings",
7176 Page::Archive => "Archive",
@@ -79,6 +84,7 @@ impl Page {
7984 Page::Behavior => Icon::new(Lucide::Bell),
8085 Page::Appearance => Icon::new(IconName::Palette),
8186 Page::Providers => Icon::new(Lucide::Plug),
87+ Page::Usage => Icon::new(Lucide::Gauge),
8288 Page::Connections => Icon::new(Lucide::Network),
8389 Page::Keybindings => Icon::new(Lucide::Keyboard),
8490 Page::Archive => Icon::new(Lucide::Archive),
@@ -107,6 +113,8 @@ pub struct SettingsView {
107113 page: Page,
108114 add_project_dir: Entity<InputState>,
109115 provider_form: Option<ProviderForm>,
116+ usage: Entity<UsageView>,
117+ hubs: Entity<HubSettings>,
110118 remote_port: Entity<InputState>,
111119 /// The "Add connection" form: name, address or pairing code, key.
112120 connect_name: Entity<InputState>,
@@ -185,6 +193,8 @@ impl SettingsView {
185193 page: Page::General,
186194 add_project_dir,
187195 provider_form: None,
196+ usage: cx.new(|cx| UsageView::new(window, cx)),
197+ hubs: cx.new(|cx| HubSettings::new(window, cx)),
188198 remote_port,
189199 connect_name,
190200 connect_address,
@@ -289,6 +299,9 @@ impl SettingsView {
289299 })
290300 .on_click(cx.listener(move |this, _, _, cx| {
291301 this.page = page;
302+ if page == Page::Usage {
303+ this.refresh_usage(cx);
304+ }
292305 cx.notify();
293306 }))
294307 .child(page.icon().size(px(15.)).text_color(if selected { p.ink } else { p.ink2 }))
@@ -374,6 +387,7 @@ impl SettingsView {
374387 Page::Behavior => self.render_behavior(cx),
375388 Page::Appearance => self.render_appearance(cx),
376389 Page::Providers => self.render_providers(window, cx),
390+ Page::Usage => vec![self.usage.clone().into_any_element()],
377391 Page::Connections => self.render_connections(window, cx),
378392 Page::Keybindings => self.render_keybindings(cx),
379393 Page::Archive => self.render_archive(cx),
@@ -1281,7 +1295,7 @@ impl SettingsView {
12811295 p,
12821296 );
12831297
1284- vec![providers, advanced]
1298+ vec![providers, self.hubs.clone().into_any_element(), advanced]
12851299 }
12861300
12871301 fn render_provider_detail(
@@ -1444,6 +1458,42 @@ impl SettingsView {
14441458 .into_any_element()
14451459 }
14461460
1461+ // ---- usage -------------------------------------------------------------
1462+
1463+ /// Reads usage from every provider instance's home folder and the usage
1464+ /// hubs, and shows each Claude instance's plan limits from its last
1465+ /// health check.
1466+ fn refresh_usage(&mut self, cx: &mut Context<Self>) {
1467+ let catalog = Catalog::global(cx);
1468+ let mut sources = UsageSources { hubs: settings::get(|settings| settings.usage_hubs.clone()), ..Default::default() };
1469+ let mut limits = Vec::new();
1470+ for state in &catalog.instances {
1471+ let home = match (state.config.home.trim(), state.kind()) {
1472+ ("", Provider::Claude) => settings::expand_home("~/.claude"),
1473+ ("", Provider::Pi) => settings::expand_home("~/.pi/agent"),
1474+ (home, _) => settings::expand_home(home),
1475+ };
1476+ let label = match state.kind() {
1477+ Provider::Claude if state.label() == "Claude" => "Claude Code".to_string(),
1478+ _ => state.label(),
1479+ };
1480+ match state.kind() {
1481+ Provider::Claude => sources.claude_dirs.push((label.clone(), home)),
1482+ Provider::Pi => sources.pi_dirs.push((label.clone(), home)),
1483+ }
1484+ if let Some(usage) = &state.usage {
1485+ limits.push((SharedString::from(label), usage.clone()));
1486+ }
1487+ }
1488+ // Instances sharing a home folder share their logs; read them once.
1489+ sources.claude_dirs.dedup_by(|a, b| a.1 == b.1);
1490+ sources.pi_dirs.dedup_by(|a, b| a.1 == b.1);
1491+ self.usage.update(cx, |usage, cx| {
1492+ usage.refresh(sources, cx);
1493+ usage.set_limits(limits, cx);
1494+ });
1495+ }
1496+
14471497 // ---- connections -------------------------------------------------------
14481498
14491499 /// Saves the "Add connection" form as a new connection.
@@ -1985,7 +2035,11 @@ impl Render for SettingsView {
19852035 .child(
19862036 div().id(("settings-scroll", self.page as usize)).flex_1().min_h_0().overflow_y_scroll().child(
19872037 h_flex().w_full().justify_center().px(px(32.)).py(px(28.)).child(
1988- v_flex().w_full().max_w(px(PAGE_WIDTH)).gap(px(30.)).children(page),
2038+ v_flex()
2039+ .w_full()
2040+ .max_w(px(if self.page == Page::Usage { 880. } else { PAGE_WIDTH }))
2041+ .gap(px(30.))
2042+ .children(page),
19892043 ),
19902044 ),
19912045 ),
+925−0src/ui/usage.rsaddedView File
@@ -0,0 +1,925 @@
1+ //! The Usage page, laid out like T3 Code's: tokens and cost over a range with
2+ //! a daily chart and a breakdown by model or day, and the plan's limits. Also
3+ //! the "Usage providers" section of the Providers page, for adding hubs.
4+
5+ use std::rc::Rc;
6+
7+ use chrono::{DateTime, Local};
8+ use gpui_kit::component::{
9+ Icon, IconName, Sizable as _, chart::AreaChart, h_flex,
10+ input::{Input, InputState},
11+ menu::{DropdownMenu as _, PopupMenuItem},
12+ v_flex,
13+ };
14+ use gpui_kit::prelude::FluentBuilder as _;
15+ use gpui_kit::*;
16+ use serde_json::Value;
17+
18+ use crate::settings::{self, UsageHub};
19+ use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
20+ use crate::ui::pickers::CLAUDE_ORANGE;
21+ use crate::ui::settings::{card, section, section_title};
22+ use crate::usage::{self, Limit, Line, Span, Summary, UsageReport, UsageSources, format_cost, format_tokens};
23+
24+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
25+ enum Tab {
26+ Cost,
27+ Tokens,
28+ Limits,
29+ }
30+
31+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
32+ enum Breakdown {
33+ Model,
34+ Day,
35+ }
36+
37+ /// One Claude instance's limits, from its `get_usage` reply.
38+ struct PlanLimits {
39+ label: SharedString,
40+ plan: Option<String>,
41+ limits: Vec<Limit>,
42+ }
43+
44+ pub struct UsageView {
45+ sources: Option<UsageSources>,
46+ report: Option<Rc<UsageReport>>,
47+ loading: bool,
48+ tab: Tab,
49+ span: Span,
50+ /// `None` shows all providers.
51+ provider: Option<String>,
52+ breakdown: Breakdown,
53+ limits: Vec<PlanLimits>,
54+ /// The summary of the current report, range and provider, until one changes.
55+ summary: Option<Rc<Summary>>,
56+ _collect: Option<Task<()>>,
57+ }
58+
59+ impl UsageView {
60+ pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
61+ Self {
62+ sources: None,
63+ report: None,
64+ loading: false,
65+ tab: Tab::Tokens,
66+ span: Span::Month,
67+ provider: None,
68+ breakdown: Breakdown::Model,
69+ limits: Vec::new(),
70+ summary: None,
71+ _collect: None,
72+ }
73+ }
74+
75+ /// Reads usage again from `sources` in the background. The last report
76+ /// stays up until the new one is in.
77+ pub fn refresh(&mut self, sources: UsageSources, cx: &mut Context<Self>) {
78+ self.sources = Some(sources.clone());
79+ self.loading = true;
80+ // Always read the longest range, so switching ranges needs no reload.
81+ let since = Span::Quarter.since(Local::now());
82+ let task = cx.background_executor().spawn(async move { usage::collect(&sources, since) });
83+ self._collect = Some(cx.spawn(async move |this, cx| {
84+ let report = task.await;
85+ let _ = this.update(cx, |this, cx| {
86+ this.loading = false;
87+ if this.provider.as_ref().is_some_and(|provider| !report.providers().contains(provider)) {
88+ this.provider = None;
89+ }
90+ this.report = Some(Rc::new(report));
91+ this.summary = None;
92+ cx.notify();
93+ });
94+ }));
95+ cx.notify();
96+ }
97+
98+ /// Takes each Claude instance's reply to `get_usage`, labelled.
99+ pub fn set_limits(&mut self, limits: Vec<(SharedString, Value)>, cx: &mut Context<Self>) {
100+ self.limits = limits
101+ .into_iter()
102+ .map(|(label, reply)| PlanLimits {
103+ label,
104+ plan: reply.get("subscription_type").and_then(Value::as_str).map(plan_name),
105+ limits: usage::limits_from_claude(&reply),
106+ })
107+ .collect();
108+ cx.notify();
109+ }
110+
111+ fn reload(&mut self, cx: &mut Context<Self>) {
112+ if let Some(sources) = self.sources.clone() {
113+ self.refresh(sources, cx);
114+ }
115+ }
116+
117+ fn summary(&mut self) -> Rc<Summary> {
118+ let report = self.report.clone().unwrap_or_default();
119+ self.summary
120+ .get_or_insert_with(|| {
121+ Rc::new(usage::summarize(&report, self.span, self.provider.as_deref(), Local::now().naive_local()))
122+ })
123+ .clone()
124+ }
125+
126+ // ---- header --------------------------------------------------------------
127+
128+ fn render_header(&self, cx: &mut Context<Self>) -> impl IntoElement {
129+ let p = metal::palette(cx);
130+ let view = cx.weak_entity();
131+ let providers = self.report.as_ref().map(|report| report.providers()).unwrap_or_default();
132+ let current = self.provider.clone();
133+ let provider_menu = MetalButton::new("usage-provider")
134+ .ghost()
135+ .compact()
136+ .label(current.clone().unwrap_or_else(|| "All providers".to_string()))
137+ .caret()
138+ .dropdown_menu_with_anchor(Anchor::TopLeft, move |mut menu, _, _| {
139+ let pick = |provider: Option<String>| {
140+ let view = view.clone();
141+ move |_: &ClickEvent, _: &mut Window, cx: &mut App| {
142+ let provider = provider.clone();
143+ let _ = view.update(cx, |this, cx| {
144+ this.provider = provider;
145+ this.summary = None;
146+ cx.notify();
147+ });
148+ }
149+ };
150+ menu = menu
151+ .min_w(px(200.))
152+ .item(PopupMenuItem::new("All providers").checked(current.is_none()).on_click(pick(None)));
153+ if !providers.is_empty() {
154+ menu = menu.separator();
155+ }
156+ for provider in providers.clone() {
157+ let checked = current.as_deref() == Some(provider.as_str());
158+ menu = menu.item(PopupMenuItem::new(provider.clone()).checked(checked).on_click(pick(Some(provider))));
159+ }
160+ menu
161+ });
162+
163+ let view = cx.weak_entity();
164+ let tabs = metal::segmented(
165+ "usage-tab",
166+ &[(Tab::Cost, "Cost"), (Tab::Tokens, "Tokens"), (Tab::Limits, "Limits")],
167+ self.tab,
168+ p,
169+ move |tab, _, cx| {
170+ let _ = view.update(cx, |this, cx| {
171+ this.tab = tab;
172+ cx.notify();
173+ });
174+ },
175+ );
176+ let view = cx.weak_entity();
177+ let spans: Vec<(Span, &'static str)> = Span::ALL.iter().map(|span| (*span, span.label())).collect();
178+ let ranges = metal::segmented("usage-range", &spans, self.span, p, move |span, _, cx| {
179+ let _ = view.update(cx, |this, cx| {
180+ this.span = span;
181+ this.summary = None;
182+ cx.notify();
183+ });
184+ });
185+
186+ let updated = self.report.as_ref().and_then(|report| report.collected_at).filter(|_| !self.loading);
187+ let breadcrumb = h_flex()
188+ .gap(px(8.))
189+ .child(div().pl(px(4.)).text_size(px(14.)).font_weight(FontWeight::SEMIBOLD).child("Usage"))
190+ .child(div().text_size(px(14.)).text_color(p.ink3).child("/"))
191+ .map(|this| {
192+ if self.tab == Tab::Limits {
193+ this.child(div().text_size(px(14.)).text_color(p.ink2).child("Plan limits"))
194+ } else {
195+ this.child(provider_menu)
196+ }
197+ })
198+ .child(div().flex_1())
199+ .when(self.loading, |this| {
200+ this.child(
201+ h_flex()
202+ .gap(px(6.))
203+ .child(metal::led("usage-loading", Led::Waiting, true, p))
204+ .child(metal::engraved("Reading", p)),
205+ )
206+ })
207+ .when_some(updated, |this, at| this.child(metal::engraved(format!("Updated {}", at.format("%H:%M")), p)))
208+ .child(
209+ MetalButton::new("usage-refresh")
210+ .compact()
211+ .icon(IconName::RefreshCw)
212+ .label("Refresh")
213+ .disabled(self.loading || self.sources.is_none())
214+ .on_click(cx.listener(|this, _, _, cx| this.reload(cx))),
215+ );
216+
217+ v_flex()
218+ .gap(px(14.))
219+ .child(breadcrumb)
220+ .child(
221+ h_flex()
222+ .flex_wrap()
223+ .gap(px(10.))
224+ .child(tabs)
225+ .child(div().flex_1())
226+ .when(self.tab != Tab::Limits, |this| this.child(ranges)),
227+ )
228+ }
229+
230+ // ---- cost and tokens -------------------------------------------------------
231+
232+ fn render_totals_page(&mut self, cx: &mut Context<Self>) -> Vec<AnyElement> {
233+ let p = metal::palette(cx);
234+ let summary = self.summary();
235+ let cost_tab = self.tab == Tab::Cost;
236+ let s = &*summary;
237+
238+ if self.report.is_none() {
239+ let note = if self.loading { "Reading the session logs…" } else { "Nothing read yet." };
240+ return vec![card(vec![empty_row(note, p)], p).into_any_element()];
241+ }
242+
243+ let total_cost = s.cost.total();
244+ let total_tokens = s.tokens.total();
245+ let headline = if cost_tab { format_cost(total_cost) } else { format_tokens(total_tokens) };
246+ let sessions = match s.sessions {
247+ 1 => "1 session".to_string(),
248+ n => format!("{n} sessions"),
249+ };
250+ let mut caption = format!("{sessions} · {}", self.span.label().to_lowercase());
251+ if cost_tab && s.unpriced > 0 {
252+ caption.push_str(&format!(" · {} tokens without a price", format_tokens(s.unpriced)));
253+ }
254+
255+ let legend: Vec<AnyElement> = s
256+ .providers
257+ .iter()
258+ .enumerate()
259+ .map(|(ix, provider)| {
260+ let share = if cost_tab {
261+ ratio(provider.cost, total_cost)
262+ } else {
263+ ratio(provider.tokens as f64, total_tokens as f64)
264+ };
265+ let detail = if cost_tab {
266+ format!("{:.1}% of cost · {} tokens", share * 100., format_tokens(provider.tokens))
267+ } else {
268+ format!("{:.1}% of tokens · {}", share * 100., format_cost(provider.cost))
269+ };
270+ h_flex()
271+ .gap(px(10.))
272+ .child(div().flex_none().size(px(8.)).rounded_full().bg(provider_color(ix, &provider.label, p)))
273+ .child(div().text_size(px(13.5)).font_weight(FontWeight::MEDIUM).child(provider.label.clone()))
274+ .child(div().text_size(px(12.5)).text_color(p.ink3).child(match provider.sessions {
275+ 1 => "1 session".to_string(),
276+ n => format!("{n} sessions"),
277+ }))
278+ .child(div().flex_1())
279+ .child(div().font_family(metal::MONO).text_size(px(12.5)).child(if cost_tab {
280+ format_cost(provider.cost)
281+ } else {
282+ format_tokens(provider.tokens)
283+ }))
284+ .child(div().w(px(200.)).text_size(px(12.5)).text_color(p.ink3).text_right().child(detail))
285+ .into_any_element()
286+ })
287+ .collect();
288+
289+ let hero = v_flex()
290+ .px(px(22.))
291+ .py(px(20.))
292+ .gap(px(18.))
293+ .rounded(px(18.))
294+ .raised(p)
295+ .child(
296+ v_flex()
297+ .gap(px(6.))
298+ .child(metal::engraved(if cost_tab { "Total cost" } else { "Processed tokens" }, p))
299+ .child(div().font_family(metal::PIXEL).text_size(px(40.)).line_height(px(44.)).child(headline))
300+ .child(div().text_size(px(12.5)).text_color(p.ink3).child(caption)),
301+ )
302+ .map(|this| {
303+ if legend.is_empty() {
304+ this.child(div().text_size(px(13.)).text_color(p.ink3).child("No usage in this range."))
305+ } else {
306+ this.child(v_flex().gap(px(10.)).children(legend))
307+ }
308+ });
309+
310+ let chart_title = match (self.span.hourly(), cost_tab) {
311+ (true, true) => "Hourly cost",
312+ (true, false) => "Hourly processed tokens",
313+ (false, true) => "Daily cost",
314+ (false, false) => "Daily processed tokens",
315+ };
316+ let chart = v_flex()
317+ .px(px(18.))
318+ .pt(px(16.))
319+ .pb(px(12.))
320+ .gap(px(12.))
321+ .rounded(px(18.))
322+ .raised(p)
323+ .child(
324+ h_flex()
325+ .child(div().flex_1().text_size(px(13.5)).font_weight(FontWeight::MEDIUM).child(chart_title))
326+ .child(metal::engraved(self.span.label(), p)),
327+ )
328+ .child(div().h(px(200.)).child(self.chart(&summary, cost_tab)));
329+
330+ let totals = if cost_tab {
331+ vec![
332+ stat("Total cost", format_cost(total_cost), p),
333+ stat("Input", format_cost(s.cost.input), p),
334+ stat("Cache write", format_cost(s.cost.cache_write), p),
335+ stat("Cache read", format_cost(s.cost.cache_read), p),
336+ stat("Output", format_cost(s.cost.output), p),
337+ ]
338+ } else {
339+ vec![
340+ stat("Processed tokens", format_tokens(total_tokens), p),
341+ stat("Cached input", format_tokens(s.tokens.cache_read), p),
342+ stat("Uncached input", format_tokens(s.tokens.input + s.tokens.cache_write), p),
343+ stat("Output", format_tokens(s.tokens.output), p),
344+ stat("Cache savings", format_cost(s.savings), p),
345+ ]
346+ };
347+ let totals = v_flex()
348+ .gap(px(10.))
349+ .child(div().px(px(4.)).child(section_title("Totals", p)))
350+ .child(
351+ h_flex()
352+ .rounded(px(18.))
353+ .raised(p)
354+ .children(totals.into_iter().enumerate().flat_map(|(ix, stat)| {
355+ let rule = (ix > 0).then(|| div().flex_none().w(px(1.)).h(px(36.)).bg(p.rule).into_any_element());
356+ rule.into_iter().chain(std::iter::once(stat))
357+ })),
358+ );
359+
360+ let parts = if cost_tab {
361+ [
362+ ("Input", s.cost.input),
363+ ("Cache read", s.cost.cache_read),
364+ ("Cache write", s.cost.cache_write),
365+ ("Output", s.cost.output),
366+ ]
367+ } else {
368+ [
369+ ("Input", s.tokens.input as f64),
370+ ("Cache read", s.tokens.cache_read as f64),
371+ ("Cache write", s.tokens.cache_write as f64),
372+ ("Output", s.tokens.output as f64),
373+ ]
374+ };
375+ let by_type = section(
376+ if cost_tab { "Cost by type" } else { "Tokens by type" },
377+ None,
378+ vec![type_bar(&parts, cost_tab, p)],
379+ p,
380+ );
381+
382+ vec![
383+ hero.into_any_element(),
384+ chart.into_any_element(),
385+ totals.into_any_element(),
386+ by_type,
387+ self.render_breakdown(&summary, cx),
388+ ]
389+ }
390+
391+ fn chart(&self, summary: &Summary, cost_tab: bool) -> AnyElement {
392+ let orange: Hsla = rgba(CLAUDE_ORANGE).into();
393+ let ticks = match self.span {
394+ Span::Day => 6,
395+ Span::Week => 7,
396+ Span::Month => 6,
397+ Span::Quarter => 7,
398+ };
399+ let value = move |point: &usage::Point| if cost_tab { point.cost } else { point.tokens as f64 };
400+ let format = move |value: f64| -> SharedString {
401+ if cost_tab { format_cost(value).into() } else { format_tokens(value.max(0.) as u64).into() }
402+ };
403+ AreaChart::new(summary.series.clone())
404+ .id("usage-chart")
405+ .appear_key((self.tab, self.span))
406+ .x(|point| SharedString::from(point.label.clone()))
407+ .y(value)
408+ .name(if cost_tab { "Cost" } else { "Tokens" })
409+ .stroke(orange)
410+ .fill(linear_gradient(
411+ 180.,
412+ linear_color_stop(orange.opacity(0.22), 0.),
413+ linear_color_stop(orange.opacity(0.02), 1.),
414+ ))
415+ .natural()
416+ .x_tick_count(ticks)
417+ .y_axis(true)
418+ .y_tick_count(4)
419+ .y_tick_format(format)
420+ .tooltip_title(|point| point.title.clone().into())
421+ .tooltip_value(move |_, _, value| format(value))
422+ .into_any_element()
423+ }
424+
425+ fn render_breakdown(&self, summary: &Summary, cx: &mut Context<Self>) -> AnyElement {
426+ let p = metal::palette(cx);
427+ let cost_tab = self.tab == Tab::Cost;
428+ let lines: &[Line] = match self.breakdown {
429+ Breakdown::Model => &summary.models,
430+ Breakdown::Day => &summary.days,
431+ };
432+ let total = if cost_tab { summary.cost.total() } else { summary.tokens.total() as f64 };
433+ let orange: Hsla = rgba(CLAUDE_ORANGE).into();
434+ let view = cx.weak_entity();
435+
436+ let head = h_flex()
437+ .px(px(18.))
438+ .pt(px(12.))
439+ .pb(px(8.))
440+ .gap(px(12.))
441+ .child(metal::engraved("#", p).w(px(20.)))
442+ .child(metal::engraved(if self.breakdown == Breakdown::Model { "Model" } else { "Day" }, p).flex_1())
443+ .child(metal::engraved("Cost", p).w(px(84.)).text_right())
444+ .child(metal::engraved("Share", p).w(px(60.)).text_right())
445+ .child(metal::engraved("Tokens", p).w(px(70.)).text_right());
446+
447+ let mut rows = vec![head.into_any_element()];
448+ if lines.is_empty() {
449+ rows.push(empty_row("No usage in this range.", p));
450+ }
451+ rows.extend(lines.iter().enumerate().map(|(ix, line)| {
452+ let share = if cost_tab {
453+ ratio(line.cost.unwrap_or(0.), total)
454+ } else {
455+ ratio(line.tokens as f64, total)
456+ };
457+ h_flex()
458+ .px(px(18.))
459+ .py(px(10.))
460+ .gap(px(12.))
461+ .text_size(px(13.))
462+ .child(div().w(px(20.)).font_family(metal::MONO).text_size(px(11.5)).text_color(p.ink3).child((ix + 1).to_string()))
463+ .child(
464+ v_flex()
465+ .flex_1()
466+ .min_w_0()
467+ .gap(px(5.))
468+ .child(div().truncate().font_weight(FontWeight::MEDIUM).child(line.label.clone()))
469+ .child(
470+ div()
471+ .h(px(3.))
472+ .w_full()
473+ .rounded_full()
474+ .bg(p.rule)
475+ .child(div().h_full().rounded_full().bg(orange).w(relative(share as f32))),
476+ ),
477+ )
478+ .child(
479+ div()
480+ .w(px(84.))
481+ .text_right()
482+ .font_family(metal::MONO)
483+ .text_size(px(12.))
484+ .text_color(if line.cost.is_some() { p.ink } else { p.ink3 })
485+ .child(line.cost.map(format_cost).unwrap_or_else(|| "Unpriced".to_string())),
486+ )
487+ .child(
488+ div()
489+ .w(px(60.))
490+ .text_right()
491+ .font_family(metal::MONO)
492+ .text_size(px(12.))
493+ .text_color(p.ink2)
494+ .child(format!("{:.1}%", share * 100.)),
495+ )
496+ .child(
497+ div()
498+ .w(px(70.))
499+ .text_right()
500+ .font_family(metal::MONO)
501+ .text_size(px(12.))
502+ .child(format_tokens(line.tokens)),
503+ )
504+ .into_any_element()
505+ }));
506+
507+ v_flex()
508+ .gap(px(10.))
509+ .child(
510+ h_flex()
511+ .px(px(4.))
512+ .child(div().flex_1().child(section_title("Breakdown", p)))
513+ .child(metal::segmented(
514+ "usage-breakdown",
515+ &[(Breakdown::Model, "Model"), (Breakdown::Day, "Day")],
516+ self.breakdown,
517+ p,
518+ move |breakdown, _, cx| {
519+ let _ = view.update(cx, |this, cx| {
520+ this.breakdown = breakdown;
521+ cx.notify();
522+ });
523+ },
524+ )),
525+ )
526+ .child(card(rows, p))
527+ .into_any_element()
528+ }
529+
530+ // ---- limits ----------------------------------------------------------------
531+
532+ fn render_limits(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
533+ let p = metal::palette(cx);
534+ if self.limits.is_empty() {
535+ return vec![section(
536+ "Plan limits",
537+ None,
538+ vec![empty_row("Limits show once Claude Code has reported them.", p)],
539+ p,
540+ )];
541+ }
542+ let now = Local::now();
543+ self.limits
544+ .iter()
545+ .map(|plan| {
546+ let rows = if plan.limits.is_empty() {
547+ vec![empty_row("This account reports no limits.", p)]
548+ } else {
549+ plan.limits.iter().map(|limit| limit_row(limit, now, p)).collect()
550+ };
551+ v_flex()
552+ .gap(px(10.))
553+ .child(
554+ h_flex()
555+ .px(px(4.))
556+ .gap(px(8.))
557+ .child(
558+ div()
559+ .text_size(px(14.))
560+ .font_weight(FontWeight::MEDIUM)
561+ .text_color(p.ink2)
562+ .child(plan.label.clone()),
563+ )
564+ .when_some(plan.plan.clone(), |this, plan| this.child(metal::tag(plan, p))),
565+ )
566+ .child(card(rows, p))
567+ .into_any_element()
568+ })
569+ .collect()
570+ }
571+ }
572+
573+ impl Render for UsageView {
574+ fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
575+ let p = metal::palette(cx);
576+ let errors = self.report.as_ref().map(|report| report.errors.clone()).unwrap_or_default();
577+ let body = match self.tab {
578+ Tab::Limits => self.render_limits(cx),
579+ Tab::Cost | Tab::Tokens => self.render_totals_page(cx),
580+ };
581+ v_flex()
582+ .w_full()
583+ .gap(px(24.))
584+ .child(self.render_header(cx))
585+ .when(!errors.is_empty() && self.tab != Tab::Limits, |this| {
586+ this.child(card(
587+ errors
588+ .into_iter()
589+ .map(|error| {
590+ h_flex()
591+ .px(px(18.))
592+ .py(px(12.))
593+ .gap(px(10.))
594+ .child(Icon::new(IconName::TriangleAlert).size(px(14.)).text_color(p.red))
595+ .child(div().flex_1().min_w_0().text_size(px(12.5)).text_color(p.ink2).child(error))
596+ .into_any_element()
597+ })
598+ .collect(),
599+ p,
600+ ))
601+ })
602+ .children(body)
603+ }
604+ }
605+
606+ // ---- building blocks ---------------------------------------------------------
607+
608+ fn ratio(part: f64, whole: f64) -> f64 {
609+ if whole > 0. { (part / whole).clamp(0., 1.) } else { 0. }
610+ }
611+
612+ /// Claude Code in Claude's orange, everything else in MetalUI's tints.
613+ fn provider_color(ix: usize, label: &str, p: &Palette) -> Hsla {
614+ if label.starts_with("Claude") {
615+ rgba(CLAUDE_ORANGE).into()
616+ } else {
617+ p.tints[(ix + 2) % p.tints.len()]
618+ }
619+ }
620+
621+ /// `team` → `Team plan`.
622+ fn plan_name(kind: &str) -> String {
623+ let mut chars = kind.chars();
624+ match chars.next() {
625+ Some(first) => format!("{}{} plan", first.to_uppercase(), chars.as_str()),
626+ None => "Plan".to_string(),
627+ }
628+ }
629+
630+ fn empty_row(text: &'static str, p: &Palette) -> AnyElement {
631+ div().px(px(18.)).py(px(22.)).text_size(px(13.)).text_color(p.ink3).child(text).into_any_element()
632+ }
633+
634+ /// One figure in the totals strip.
635+ fn stat(label: &'static str, value: String, p: &Palette) -> AnyElement {
636+ v_flex()
637+ .flex_1()
638+ .min_w_0()
639+ .px(px(18.))
640+ .py(px(14.))
641+ .gap(px(6.))
642+ .child(metal::engraved(label, p))
643+ .child(div().font_family(metal::MONO).text_size(px(16.)).font_weight(FontWeight::MEDIUM).child(value))
644+ .into_any_element()
645+ }
646+
647+ /// The stacked bar of input, cache reads, cache writes and output, with its legend.
648+ fn type_bar(parts: &[(&'static str, f64); 4], cost: bool, p: &Palette) -> AnyElement {
649+ let total: f64 = parts.iter().map(|(_, value)| value).sum();
650+ let colors = [p.tints[4], p.tints[2], p.tints[5], rgba(CLAUDE_ORANGE).into()];
651+ v_flex()
652+ .px(px(18.))
653+ .py(px(16.))
654+ .gap(px(14.))
655+ .child(
656+ h_flex()
657+ .h(px(10.))
658+ .w_full()
659+ .rounded_full()
660+ .overflow_hidden()
661+ .well(p)
662+ .children(parts.iter().zip(colors).filter(|((_, value), _)| *value > 0.).map(|((_, value), color)| {
663+ div().h_full().w(relative(ratio(*value, total) as f32)).bg(color)
664+ })),
665+ )
666+ .child(h_flex().flex_wrap().gap_x(px(22.)).gap_y(px(8.)).children(parts.iter().zip(colors).map(
667+ |((label, value), color)| {
668+ h_flex()
669+ .gap(px(7.))
670+ .text_size(px(12.5))
671+ .child(div().size(px(8.)).rounded_full().bg(color))
672+ .child(div().text_color(p.ink2).child(*label))
673+ .child(div().font_family(metal::MONO).text_size(px(12.)).child(if cost {
674+ format_cost(*value)
675+ } else {
676+ format_tokens(*value as u64)
677+ }))
678+ .child(div().text_color(p.ink3).child(format!("{:.1}%", ratio(*value, total) * 100.)))
679+ },
680+ )))
681+ .into_any_element()
682+ }
683+
684+ fn limit_row(limit: &Limit, now: DateTime<Local>, p: &Palette) -> AnyElement {
685+ let color = match limit.severity.as_str() {
686+ "critical" => p.red,
687+ "warning" => p.amber,
688+ _ => p.green_deep,
689+ };
690+ let resets = limit.resets_at.map(|at| {
691+ let left = at.signed_duration_since(now);
692+ let when = at.format("%a, %b %-d, %H:%M");
693+ if left.num_seconds() <= 0 {
694+ format!("Reset {when}")
695+ } else if left.num_hours() >= 24 {
696+ format!("Resets in {}d {}h · {when}", left.num_days(), left.num_hours() % 24)
697+ } else if left.num_minutes() >= 60 {
698+ format!("Resets in {}h {}m · {when}", left.num_hours(), left.num_minutes() % 60)
699+ } else {
700+ format!("Resets in {}m · {when}", left.num_minutes().max(1))
701+ }
702+ });
703+ v_flex()
704+ .px(px(18.))
705+ .py(px(14.))
706+ .gap(px(9.))
707+ .child(
708+ h_flex()
709+ .gap(px(8.))
710+ .child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).child(limit.label.clone()))
711+ .when(limit.active, |this| this.child(metal::tag("Active", p)))
712+ .child(div().flex_1())
713+ .child(
714+ div()
715+ .font_family(metal::MONO)
716+ .text_size(px(12.5))
717+ .child(format!("{}% used", limit.percent.round())),
718+ ),
719+ )
720+ .child(
721+ div().h(px(8.)).w_full().rounded_full().well(p).child(
722+ div().h_full().rounded_full().bg(color).w(relative((limit.percent / 100.).clamp(0., 1.) as f32)),
723+ ),
724+ )
725+ .when_some(resets, |this, resets| {
726+ this.child(div().text_size(px(12.5)).text_color(p.ink3).child(resets))
727+ })
728+ .into_any_element()
729+ }
730+
731+ // ---- usage hubs -----------------------------------------------------------------
732+
733+ /// The "Usage providers" section of the Providers page: hubs the Usage page
734+ /// also reads, and a form to add one.
735+ pub struct HubSettings {
736+ adding: bool,
737+ name: Entity<InputState>,
738+ url: Entity<InputState>,
739+ key: Entity<InputState>,
740+ error: Option<SharedString>,
741+ }
742+
743+ impl HubSettings {
744+ pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
745+ Self {
746+ adding: false,
747+ name: cx.new(|cx| InputState::new(window, cx).placeholder("CLIProxyAPI")),
748+ url: cx.new(|cx| InputState::new(window, cx).placeholder("http://localhost:8317")),
749+ key: cx.new(|cx| InputState::new(window, cx).placeholder("Management key").masked(true)),
750+ error: None,
751+ }
752+ }
753+
754+ fn start_adding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
755+ for input in [&self.name, &self.url, &self.key] {
756+ input.update(cx, |input, cx| input.set_value("", window, cx));
757+ }
758+ self.adding = true;
759+ self.error = None;
760+ self.name.update(cx, |input, cx| input.focus(window, cx));
761+ cx.notify();
762+ }
763+
764+ fn save(&mut self, cx: &mut Context<Self>) {
765+ let value = |input: &Entity<InputState>| input.read(cx).value().trim().to_string();
766+ let (name, url, key) = (value(&self.name), value(&self.url), value(&self.key));
767+ if url.is_empty() {
768+ self.error = Some("The hub needs a URL.".into());
769+ cx.notify();
770+ return;
771+ }
772+ let name = if name.is_empty() { "CLIProxyAPI".to_string() } else { name };
773+ settings::update(|settings| {
774+ settings.usage_hubs.push(UsageHub { name, kind: "cliproxy".into(), url, key });
775+ });
776+ self.adding = false;
777+ self.error = None;
778+ cx.notify();
779+ }
780+
781+ fn remove(&mut self, ix: usize, cx: &mut Context<Self>) {
782+ settings::update(|settings| {
783+ if ix < settings.usage_hubs.len() {
784+ settings.usage_hubs.remove(ix);
785+ }
786+ });
787+ cx.notify();
788+ }
789+
790+ fn render_form(&self, cx: &mut Context<Self>) -> AnyElement {
791+ let p = metal::palette(cx);
792+ let field = |label: &'static str, input: &Entity<InputState>| {
793+ h_flex()
794+ .gap(px(16.))
795+ .child(div().w(px(120.)).text_size(px(13.)).font_weight(FontWeight::MEDIUM).child(label))
796+ .child(
797+ h_flex()
798+ .flex_1()
799+ .h(px(32.))
800+ .px(px(11.))
801+ .rounded(px(12.))
802+ .well(p)
803+ .text_size(px(12.5))
804+ .text_color(p.ink)
805+ .child(div().flex_1().min_w_0().child(Input::new(input).appearance(false).small())),
806+ )
807+ };
808+ v_flex()
809+ .px(px(18.))
810+ .py(px(16.))
811+ .gap(px(10.))
812+ .child(field("Name", &self.name))
813+ .child(field("URL", &self.url))
814+ .child(field("Management key", &self.key))
815+ .child(
816+ h_flex()
817+ .gap(px(16.))
818+ .child(div().w(px(120.)).text_size(px(13.)).font_weight(FontWeight::MEDIUM).child("Kind"))
819+ .child(metal::tag("CLI Proxy", p)),
820+ )
821+ .child(
822+ h_flex()
823+ .pt(px(4.))
824+ .gap(px(8.))
825+ .when_some(self.error.clone(), |this, error| {
826+ this.child(div().text_size(px(12.5)).text_color(p.red).child(error))
827+ })
828+ .child(div().flex_1())
829+ .child(
830+ MetalButton::new("hub-cancel")
831+ .ghost()
832+ .compact()
833+ .label("Cancel")
834+ .on_click(cx.listener(|this, _, _, cx| {
835+ this.adding = false;
836+ this.error = None;
837+ cx.notify();
838+ })),
839+ )
840+ .child(
841+ MetalButton::new("hub-save")
842+ .primary()
843+ .compact()
844+ .label("Save")
845+ .on_click(cx.listener(|this, _, _, cx| this.save(cx))),
846+ ),
847+ )
848+ .into_any_element()
849+ }
850+ }
851+
852+ impl Render for HubSettings {
853+ fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
854+ let p = metal::palette(cx);
855+ let hubs = settings::get(|settings| settings.usage_hubs.clone());
856+ let mut rows: Vec<AnyElement> = hubs
857+ .iter()
858+ .enumerate()
859+ .map(|(ix, hub)| {
860+ let kind = if hub.kind == "cliproxy" || hub.kind.is_empty() { "CLI Proxy" } else { hub.kind.as_str() };
861+ h_flex()
862+ .px(px(18.))
863+ .py(px(14.))
864+ .gap(px(12.))
865+ .child(Icon::new(IconName::Network).size(px(16.)).text_color(p.ink2))
866+ .child(
867+ v_flex()
868+ .flex_1()
869+ .min_w_0()
870+ .gap(px(3.))
871+ .child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).child(hub.name.clone()))
872+ .child(
873+ div()
874+ .truncate()
875+ .text_size(px(12.5))
876+ .text_color(p.ink3)
877+ .child(format!("{kind} · {}", hub.url)),
878+ ),
879+ )
880+ .child(
881+ MetalButton::new(("remove-hub", ix))
882+ .ghost()
883+ .compact()
884+ .label("Remove")
885+ .on_click(cx.listener(move |this, _, _, cx| this.remove(ix, cx))),
886+ )
887+ .into_any_element()
888+ })
889+ .collect();
890+ if self.adding {
891+ rows.push(self.render_form(cx));
892+ } else if rows.is_empty() {
893+ rows.push(empty_row("No hubs. Add a CLIProxyAPI server to count its traffic on the Usage page.", p));
894+ }
895+
896+ v_flex()
897+ .gap(px(10.))
898+ .child(
899+ h_flex()
900+ .px(px(4.))
901+ .gap(px(8.))
902+ .child(
903+ v_flex()
904+ .flex_1()
905+ .gap(px(3.))
906+ .child(section_title("Usage providers", p))
907+ .child(
908+ div()
909+ .text_size(px(12.5))
910+ .text_color(p.ink3)
911+ .child("Hubs whose usage the Usage page adds to the session logs."),
912+ ),
913+ )
914+ .child(
915+ MetalButton::new("add-hub")
916+ .compact()
917+ .icon(IconName::Plus)
918+ .label("Add hub")
919+ .disabled(self.adding)
920+ .on_click(cx.listener(|this, _, window, cx| this.start_adding(window, cx))),
921+ ),
922+ )
923+ .child(card(rows, p))
924+ }
925+ }
+1086−0src/usage.rsaddedView File
@@ -0,0 +1,1086 @@
1+ //! What the agents have used: tokens and cost read from Claude Code's and
2+ //! Pi's session logs and from usage hubs, and the plan limits Claude reports.
3+ //! Everything here blocks, so run it on a background executor.
4+
5+ use std::{
6+ collections::{BTreeSet, HashMap},
7+ fs::File,
8+ io::{BufRead as _, BufReader},
9+ path::{Path, PathBuf},
10+ time::{Duration, SystemTime},
11+ };
12+
13+ use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, TimeDelta, Timelike as _, Utc};
14+ use serde::Deserialize;
15+ use serde_json::Value;
16+
17+ use crate::settings::UsageHub;
18+
19+ /// Where usage is read from. Each directory comes with the label it's shown
20+ /// under, which is also its provider in the report.
21+ #[derive(Clone, Debug, Default)]
22+ pub struct UsageSources {
23+ /// Claude config directories, such as `~/.claude`.
24+ pub claude_dirs: Vec<(String, PathBuf)>,
25+ /// Pi agent directories, such as `~/.pi/agent`.
26+ pub pi_dirs: Vec<(String, PathBuf)>,
27+ pub hubs: Vec<UsageHub>,
28+ }
29+
30+ /// Token counts, split the way the APIs bill them.
31+ #[derive(Clone, Copy, Debug, Default, PartialEq)]
32+ pub struct Tokens {
33+ /// Uncached input.
34+ pub input: u64,
35+ pub output: u64,
36+ pub cache_write: u64,
37+ pub cache_read: u64,
38+ }
39+
40+ impl Tokens {
41+ pub fn total(&self) -> u64 {
42+ self.input + self.output + self.cache_write + self.cache_read
43+ }
44+
45+ fn add(&mut self, other: &Tokens) {
46+ self.input += other.input;
47+ self.output += other.output;
48+ self.cache_write += other.cache_write;
49+ self.cache_read += other.cache_read;
50+ }
51+ }
52+
53+ /// Dollars, split like [`Tokens`].
54+ #[derive(Clone, Copy, Debug, Default, PartialEq)]
55+ pub struct Costs {
56+ pub input: f64,
57+ pub output: f64,
58+ pub cache_write: f64,
59+ pub cache_read: f64,
60+ }
61+
62+ impl Costs {
63+ pub fn total(&self) -> f64 {
64+ self.input + self.output + self.cache_write + self.cache_read
65+ }
66+
67+ fn add(&mut self, other: &Costs) {
68+ self.input += other.input;
69+ self.output += other.output;
70+ self.cache_write += other.cache_write;
71+ self.cache_read += other.cache_read;
72+ }
73+ }
74+
75+ /// One provider's use of one model in one local hour.
76+ #[derive(Clone, Debug)]
77+ pub struct Bucket {
78+ pub provider: String,
79+ pub model: String,
80+ /// The local hour it started in.
81+ pub hour: NaiveDateTime,
82+ pub tokens: Tokens,
83+ /// `None` when the model has no known price.
84+ pub cost: Option<Costs>,
85+ /// What the cache reads saved over paying full input price.
86+ pub savings: f64,
87+ /// The sessions it came from, numbered across the report.
88+ pub sessions: BTreeSet<u32>,
89+ }
90+
91+ impl Bucket {
92+ pub fn day(&self) -> NaiveDate {
93+ self.hour.date()
94+ }
95+ }
96+
97+ #[derive(Clone, Debug, Default)]
98+ pub struct UsageReport {
99+ pub buckets: Vec<Bucket>,
100+ /// Hubs that couldn't be read, and the like. Shown, never fatal.
101+ pub errors: Vec<String>,
102+ pub collected_at: Option<DateTime<Local>>,
103+ }
104+
105+ impl UsageReport {
106+ /// The provider labels that show up in the report, in the order sources were given.
107+ pub fn providers(&self) -> Vec<String> {
108+ let mut labels: Vec<String> = Vec::new();
109+ for bucket in &self.buckets {
110+ if !labels.contains(&bucket.provider) {
111+ labels.push(bucket.provider.clone());
112+ }
113+ }
114+ labels
115+ }
116+ }
117+
118+ /// One normalized usage record, before it's folded into a bucket.
119+ struct Row {
120+ provider: String,
121+ model: String,
122+ at: DateTime<Utc>,
123+ session: Option<String>,
124+ tokens: Tokens,
125+ /// A cost the source worked out itself, such as Pi's.
126+ cost: Option<Costs>,
127+ }
128+
129+ /// Reads every source for usage since `since`.
130+ pub fn collect(sources: &UsageSources, since: SystemTime) -> UsageReport {
131+ let since: DateTime<Utc> = since.into();
132+ let mut errors = Vec::new();
133+ let mut rows = Vec::new();
134+
135+ for (label, dir) in &sources.claude_dirs {
136+ let files = jsonl_files(&dir.join("projects"), since);
137+ rows.extend(dedup_claude(parse_parallel(files, |path| read_claude_file(path, label, since))));
138+ }
139+ for (label, dir) in &sources.pi_dirs {
140+ let files = jsonl_files(&dir.join("sessions"), since);
141+ rows.extend(parse_parallel(files, |path| read_pi_file(path, label, since)));
142+ }
143+ for hub in &sources.hubs {
144+ match read_hub(hub, since) {
145+ Ok(hub_rows) => rows.extend(hub_rows),
146+ Err(err) => errors.push(format!("{}: {err}", hub_label(hub))),
147+ }
148+ }
149+
150+ let mut report = fold(rows);
151+ report.errors = errors;
152+ report.collected_at = Some(Local::now());
153+ report
154+ }
155+
156+ fn hub_label(hub: &UsageHub) -> String {
157+ if hub.name.trim().is_empty() { hub.url.clone() } else { hub.name.clone() }
158+ }
159+
160+ /// Folds rows into hourly buckets per provider and model.
161+ fn fold(rows: Vec<Row>) -> UsageReport {
162+ let mut session_ix: HashMap<(String, String), u32> = HashMap::new();
163+ let mut buckets: HashMap<(String, String, NaiveDateTime), Bucket> = HashMap::new();
164+
165+ for row in rows {
166+ let Some(hour) = local_hour(row.at) else { continue };
167+ let price = price(&row.model);
168+ let cost = row.cost.or_else(|| price.map(|price| price.cost(&row.tokens)));
169+ let savings = price.map(|price| price.savings(row.tokens.cache_read)).unwrap_or(0.);
170+ let bucket = buckets
171+ .entry((row.provider.clone(), row.model.clone(), hour))
172+ .or_insert_with(|| Bucket {
173+ provider: row.provider.clone(),
174+ model: row.model.clone(),
175+ hour,
176+ tokens: Tokens::default(),
177+ cost: None,
178+ savings: 0.,
179+ sessions: BTreeSet::new(),
180+ });
181+ bucket.tokens.add(&row.tokens);
182+ if let Some(cost) = cost {
183+ bucket.cost.get_or_insert_with(Costs::default).add(&cost);
184+ }
185+ bucket.savings += savings;
186+ if let Some(session) = row.session {
187+ let next = session_ix.len() as u32;
188+ let ix = *session_ix.entry((row.provider, session)).or_insert(next);
189+ bucket.sessions.insert(ix);
190+ }
191+ }
192+
193+ let mut buckets: Vec<Bucket> = buckets.into_values().collect();
194+ buckets.sort_by(|a, b| (a.hour, &a.provider, &a.model).cmp(&(b.hour, &b.provider, &b.model)));
195+ UsageReport { buckets, errors: Vec::new(), collected_at: None }
196+ }
197+
198+ fn local_hour(at: DateTime<Utc>) -> Option<NaiveDateTime> {
199+ let local = at.with_timezone(&Local).naive_local();
200+ local.with_minute(0)?.with_second(0)?.with_nanosecond(0)
201+ }
202+
203+ /// Every `.jsonl` file under `dir` changed since `since`.
204+ fn jsonl_files(dir: &Path, since: DateTime<Utc>) -> Vec<PathBuf> {
205+ let since: SystemTime = since.into();
206+ let mut files = Vec::new();
207+ let mut stack = vec![dir.to_path_buf()];
208+ while let Some(dir) = stack.pop() {
209+ let Ok(entries) = std::fs::read_dir(&dir) else { continue };
210+ for entry in entries.flatten() {
211+ let Ok(kind) = entry.file_type() else { continue };
212+ let path = entry.path();
213+ if kind.is_dir() {
214+ stack.push(path);
215+ } else if path.extension().is_some_and(|ext| ext == "jsonl")
216+ && entry.metadata().and_then(|meta| meta.modified()).is_ok_and(|modified| modified >= since)
217+ {
218+ files.push(path);
219+ }
220+ }
221+ }
222+ files
223+ }
224+
225+ /// Runs `read` over the files on a few threads.
226+ fn parse_parallel<T: Send>(files: Vec<PathBuf>, read: impl Fn(&Path) -> Vec<T> + Sync) -> Vec<T> {
227+ let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8);
228+ let chunk = files.len().div_ceil(threads).max(1);
229+ std::thread::scope(|scope| {
230+ let handles: Vec<_> = files
231+ .chunks(chunk)
232+ .map(|chunk| scope.spawn(|| chunk.iter().flat_map(|path| read(path)).collect::<Vec<T>>()))
233+ .collect();
234+ handles.into_iter().flat_map(|handle| handle.join().unwrap_or_default()).collect()
235+ })
236+ }
237+
238+ /// Calls `line` for each line of a file that contains every one of `needles`.
239+ fn scan_lines(path: &Path, needles: &[&str], mut line: impl FnMut(&str)) {
240+ let Ok(file) = File::open(path) else { return };
241+ let mut reader = BufReader::with_capacity(256 * 1024, file);
242+ let mut buf = Vec::new();
243+ loop {
244+ buf.clear();
245+ match reader.read_until(b'\n', &mut buf) {
246+ Ok(0) | Err(_) => break,
247+ Ok(_) => {}
248+ }
249+ let Ok(text) = std::str::from_utf8(&buf) else { continue };
250+ if needles.iter().all(|needle| text.contains(needle)) {
251+ line(text);
252+ }
253+ }
254+ }
255+
256+ fn parse_time(value: &Value) -> Option<DateTime<Utc>> {
257+ match value {
258+ Value::String(text) => DateTime::parse_from_rfc3339(text).ok().map(|at| at.with_timezone(&Utc)),
259+ Value::Number(millis) => DateTime::from_timestamp_millis(millis.as_i64()?),
260+ _ => None,
261+ }
262+ }
263+
264+ // ---- Claude Code ---------------------------------------------------------------
265+
266+ #[derive(Deserialize)]
267+ struct ClaudeLine {
268+ #[serde(rename = "type")]
269+ kind: Option<String>,
270+ timestamp: Option<Value>,
271+ #[serde(rename = "sessionId")]
272+ session_id: Option<String>,
273+ #[serde(rename = "requestId")]
274+ request_id: Option<String>,
275+ message: Option<ClaudeMessage>,
276+ }
277+
278+ #[derive(Deserialize)]
279+ struct ClaudeMessage {
280+ id: Option<String>,
281+ model: Option<String>,
282+ usage: Option<ClaudeUsage>,
283+ }
284+
285+ #[derive(Deserialize)]
286+ struct ClaudeUsage {
287+ input_tokens: Option<u64>,
288+ output_tokens: Option<u64>,
289+ cache_creation_input_tokens: Option<u64>,
290+ cache_read_input_tokens: Option<u64>,
291+ }
292+
293+ /// A Claude row with its (message id, request id), for deduplication.
294+ type KeyedRow = (Option<(String, String)>, Row);
295+
296+ fn read_claude_file(path: &Path, label: &str, since: DateTime<Utc>) -> Vec<KeyedRow> {
297+ let mut rows = Vec::new();
298+ scan_lines(path, &["\"usage\"", "\"assistant\""], |text| {
299+ let Ok(line) = serde_json::from_str::<ClaudeLine>(text) else { return };
300+ if line.kind.as_deref() != Some("assistant") {
301+ return;
302+ }
303+ let Some(message) = line.message else { return };
304+ let Some(usage) = message.usage else { return };
305+ let Some(at) = line.timestamp.as_ref().and_then(parse_time).filter(|at| *at >= since) else { return };
306+ let tokens = Tokens {
307+ input: usage.input_tokens.unwrap_or(0),
308+ output: usage.output_tokens.unwrap_or(0),
309+ cache_write: usage.cache_creation_input_tokens.unwrap_or(0),
310+ cache_read: usage.cache_read_input_tokens.unwrap_or(0),
311+ };
312+ let model = message.model.unwrap_or_default();
313+ // Claude Code writes `<synthetic>` for messages it made up itself.
314+ if tokens.total() == 0 || model.is_empty() {
315+ return;
316+ }
317+ let key = message.id.zip(line.request_id);
318+ rows.push((
319+ key,
320+ Row { provider: label.to_string(), model, at, session: line.session_id, tokens, cost: None },
321+ ));
322+ });
323+ rows
324+ }
325+
326+ /// Claude Code writes the same response on several lines, one per content
327+ /// block. Keeps one row per response: the one with the most output, since
328+ /// early lines can be written before the output count is final.
329+ fn dedup_claude(rows: Vec<KeyedRow>) -> Vec<Row> {
330+ let mut seen: HashMap<(String, String), usize> = HashMap::new();
331+ let mut kept: Vec<Row> = Vec::with_capacity(rows.len());
332+ for (key, row) in rows {
333+ match key {
334+ Some(key) => match seen.get(&key) {
335+ Some(&ix) => {
336+ if row.tokens.output > kept[ix].tokens.output {
337+ kept[ix] = row;
338+ }
339+ }
340+ None => {
341+ seen.insert(key, kept.len());
342+ kept.push(row);
343+ }
344+ },
345+ None => kept.push(row),
346+ }
347+ }
348+ kept
349+ }
350+
351+ // ---- Pi --------------------------------------------------------------------------
352+
353+ fn read_pi_file(path: &Path, label: &str, since: DateTime<Utc>) -> Vec<Row> {
354+ // Pi names its session files after the session.
355+ let session = path.file_stem().map(|stem| stem.to_string_lossy().into_owned());
356+ let mut rows = Vec::new();
357+ scan_lines(path, &["\"usage\"", "\"assistant\""], |text| {
358+ let Ok(line) = serde_json::from_str::<Value>(text) else { return };
359+ if let Some(row) = pi_row(&line, label, session.clone()).filter(|row| row.at >= since) {
360+ rows.push(row);
361+ }
362+ });
363+ rows
364+ }
365+
366+ fn pi_row(line: &Value, label: &str, session: Option<String>) -> Option<Row> {
367+ if line.get("type")?.as_str()? != "message" {
368+ return None;
369+ }
370+ let message = line.get("message")?;
371+ if message.get("role")?.as_str()? != "assistant" {
372+ return None;
373+ }
374+ let usage = message.get("usage")?;
375+ let count = |key: &str| usage.get(key).and_then(Value::as_u64).unwrap_or(0);
376+ let tokens = Tokens {
377+ input: count("input"),
378+ output: count("output"),
379+ cache_write: count("cacheWrite"),
380+ cache_read: count("cacheRead"),
381+ };
382+ if tokens.total() == 0 {
383+ return None;
384+ }
385+ let at = line.get("timestamp").and_then(parse_time).or_else(|| message.get("timestamp").and_then(parse_time))?;
386+ let cost = usage.get("cost").and_then(|cost| {
387+ let part = |key: &str| cost.get(key).and_then(Value::as_f64);
388+ let total = part("total")?;
389+ Some(match (part("input"), part("output"), part("cacheWrite"), part("cacheRead")) {
390+ (Some(input), Some(output), cache_write, cache_read) => Costs {
391+ input,
392+ output,
393+ cache_write: cache_write.unwrap_or(0.),
394+ cache_read: cache_read.unwrap_or(0.),
395+ },
396+ _ => Costs { input: total, ..Costs::default() },
397+ })
398+ });
399+ let model = message.get("model").and_then(Value::as_str).unwrap_or("unknown").to_string();
400+ Some(Row { provider: label.to_string(), model, at, session, tokens, cost })
401+ }
402+
403+ // ---- usage hubs ------------------------------------------------------------------
404+
405+ fn read_hub(hub: &UsageHub, since: DateTime<Utc>) -> anyhow::Result<Vec<Row>> {
406+ let base = hub.url.trim().trim_end_matches('/');
407+ if base.is_empty() {
408+ anyhow::bail!("no URL");
409+ }
410+ let base = if base.contains("://") { base.to_string() } else { format!("http://{base}") };
411+ let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(10)).build();
412+ let response = agent
413+ .get(&format!("{base}/v0/management/usage"))
414+ .set("Authorization", &format!("Bearer {}", hub.key.trim()))
415+ .call()?;
416+ // Read it whole: the reply lists every request, past ureq's 10 MB string cap.
417+ let reply: Value = serde_json::from_reader(response.into_reader())?;
418+ Ok(hub_rows(&reply, &hub_label(hub), since))
419+ }
420+
421+ /// Reads CLIProxyAPI's `/v0/management/usage` reply, ignoring anything unexpected.
422+ fn hub_rows(reply: &Value, label: &str, since: DateTime<Utc>) -> Vec<Row> {
423+ let mut rows = Vec::new();
424+ let apis = reply.pointer("/usage/apis").and_then(Value::as_object);
425+ for api in apis.into_iter().flat_map(|apis| apis.values()) {
426+ let models = api.get("models").and_then(Value::as_object);
427+ for (model, entry) in models.into_iter().flatten() {
428+ let details = entry.get("details").and_then(Value::as_array);
429+ for detail in details.into_iter().flatten() {
430+ let Some(at) = detail.get("timestamp").and_then(parse_time).filter(|at| *at >= since) else {
431+ continue;
432+ };
433+ let Some(counts) = detail.get("tokens") else { continue };
434+ let count = |key: &str| counts.get(key).and_then(Value::as_u64).unwrap_or(0);
435+ // Cached tokens are part of the input count, as in OpenAI's usage.
436+ let cached = count("cached_tokens");
437+ let input = count("input_tokens");
438+ let mut output = count("output_tokens");
439+ let reasoning = count("reasoning_tokens");
440+ if reasoning > 0 && input + output + reasoning <= count("total_tokens") {
441+ output += reasoning;
442+ }
443+ let tokens =
444+ Tokens { input: input.saturating_sub(cached), output, cache_write: 0, cache_read: cached };
445+ if tokens.total() == 0 {
446+ continue;
447+ }
448+ rows.push(Row {
449+ provider: label.to_string(),
450+ model: model.clone(),
451+ at,
452+ session: None,
453+ tokens,
454+ cost: None,
455+ });
456+ }
457+ }
458+ }
459+ rows
460+ }
461+
462+ // ---- pricing -----------------------------------------------------------------------
463+
464+ /// USD per million tokens.
465+ #[derive(Clone, Copy, Debug, PartialEq)]
466+ pub struct Price {
467+ pub input: f64,
468+ pub output: f64,
469+ pub cache_read: f64,
470+ /// Five-minute cache writes: 1.25 × input.
471+ pub cache_write: f64,
472+ }
473+
474+ impl Price {
475+ const fn new(input: f64, output: f64, cache_read: f64) -> Self {
476+ Self { input, output, cache_read, cache_write: input * 1.25 }
477+ }
478+
479+ pub fn cost(&self, tokens: &Tokens) -> Costs {
480+ let dollars = |count: u64, rate: f64| count as f64 * rate / 1_000_000.;
481+ Costs {
482+ input: dollars(tokens.input, self.input),
483+ output: dollars(tokens.output, self.output),
484+ cache_write: dollars(tokens.cache_write, self.cache_write),
485+ cache_read: dollars(tokens.cache_read, self.cache_read),
486+ }
487+ }
488+
489+ /// What `cache_read` tokens would have cost at full input price, minus what they cost.
490+ pub fn savings(&self, cache_read: u64) -> f64 {
491+ cache_read as f64 * (self.input - self.cache_read) / 1_000_000.
492+ }
493+ }
494+
495+ /// Claude's prices by model id prefix. Longer prefixes come first.
496+ const PRICES: &[(&str, Price)] = &[
497+ ("claude-fable-5-1", Price::new(10., 50., 0.25)),
498+ ("claude-fable-5", Price::new(10., 50., 1.)),
499+ ("claude-mythos", Price::new(10., 50., 1.)),
500+ ("claude-opus-5-5", Price::new(4., 20., 0.2)),
501+ ("claude-opus-5", Price::new(5., 25., 0.5)),
502+ ("claude-opus-4-8", Price::new(5., 25., 0.5)),
503+ ("claude-opus-4-7", Price::new(5., 25., 0.5)),
504+ ("claude-opus-4-6", Price::new(5., 25., 0.5)),
505+ ("claude-opus-4-5", Price::new(5., 25., 0.5)),
506+ ("claude-opus-4-1", Price::new(15., 75., 1.5)),
507+ ("claude-opus-4", Price::new(15., 75., 1.5)),
508+ ("claude-sonnet-5-5", Price::new(2., 10., 0.2)),
509+ ("claude-sonnet-5", Price::new(2., 10., 0.2)),
510+ ("claude-sonnet-4-6", Price::new(3., 15., 0.3)),
511+ ("claude-sonnet-4-5", Price::new(3., 15., 0.3)),
512+ ("claude-sonnet-4", Price::new(3., 15., 0.3)),
513+ ("claude-3-7-sonnet", Price::new(3., 15., 0.3)),
514+ ("claude-haiku-5-5", Price::new(0.1, 0.5, 0.01)),
515+ ("claude-haiku-4-5", Price::new(1., 5., 0.1)),
516+ ("claude-3-5-haiku", Price::new(0.8, 4., 0.08)),
517+ ];
518+
519+ /// The price of a Claude model, from ids like `claude-opus-5-5`,
520+ /// `claude-haiku-4-5-20251001`, `anthropic/claude-sonnet-5` or
521+ /// `us.anthropic.claude-opus-4-1-v1:0`. `None` for anything else.
522+ pub fn price(model: &str) -> Option<Price> {
523+ let start = model.find("claude-")?;
524+ let mut id = &model[start..];
525+ if let Some(cut) = id.find(['[', ':', '@']) {
526+ id = &id[..cut];
527+ }
528+ let id = strip_date(id);
529+ PRICES
530+ .iter()
531+ .find(|(prefix, _)| id.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with('-')))
532+ .map(|(_, price)| *price)
533+ }
534+
535+ /// Drops a release date suffix: `claude-haiku-4-5-20251001` → `claude-haiku-4-5`.
536+ fn strip_date(id: &str) -> &str {
537+ match id.rsplit_once('-') {
538+ Some((base, date)) if date.len() == 8 && date.bytes().all(|b| b.is_ascii_digit()) => base,
539+ _ => id,
540+ }
541+ }
542+
543+ // ---- summaries ---------------------------------------------------------------------
544+
545+ /// The time ranges the Usage page offers.
546+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
547+ pub enum Span {
548+ Day,
549+ Week,
550+ Month,
551+ Quarter,
552+ }
553+
554+ impl Span {
555+ pub const ALL: [Span; 4] = [Span::Day, Span::Week, Span::Month, Span::Quarter];
556+
557+ pub fn label(self) -> &'static str {
558+ match self {
559+ Span::Day => "Past 24h",
560+ Span::Week => "7 days",
561+ Span::Month => "30 days",
562+ Span::Quarter => "90 days",
563+ }
564+ }
565+
566+ pub fn days(self) -> i64 {
567+ match self {
568+ Span::Day => 1,
569+ Span::Week => 7,
570+ Span::Month => 30,
571+ Span::Quarter => 90,
572+ }
573+ }
574+
575+ /// The past 24 hours chart by the hour, the rest by the day.
576+ pub fn hourly(self) -> bool {
577+ self == Span::Day
578+ }
579+
580+ /// When the range starts, as a moment to collect from.
581+ pub fn since(self, now: DateTime<Local>) -> SystemTime {
582+ let start = self.first_slot(now.naive_local());
583+ start.and_local_timezone(Local).earliest().map(SystemTime::from).unwrap_or_else(|| {
584+ SystemTime::from(now) - Duration::from_secs(self.days() as u64 * 86_400)
585+ })
586+ }
587+
588+ fn first_slot(self, now: NaiveDateTime) -> NaiveDateTime {
589+ if self.hourly() {
590+ let hour = now.with_minute(0).and_then(|t| t.with_second(0)).and_then(|t| t.with_nanosecond(0));
591+ hour.unwrap_or(now) - TimeDelta::hours(23)
592+ } else {
593+ (now.date() - TimeDelta::days(self.days() - 1)).and_hms_opt(0, 0, 0).unwrap_or(now)
594+ }
595+ }
596+ }
597+
598+ /// Totals for one provider.
599+ #[derive(Clone, Debug, Default)]
600+ pub struct ProviderTotal {
601+ pub label: String,
602+ pub sessions: usize,
603+ pub tokens: u64,
604+ pub cost: f64,
605+ }
606+
607+ /// One point of the chart: an hour or a day.
608+ #[derive(Clone, Debug)]
609+ pub struct Point {
610+ /// The axis label: `14:00` or `Oct 9`.
611+ pub label: String,
612+ /// The tooltip's title: `Thu, Oct 9, 14:00` or `Thu, Oct 9`.
613+ pub title: String,
614+ pub tokens: u64,
615+ pub cost: f64,
616+ }
617+
618+ /// One row of the breakdown table.
619+ #[derive(Clone, Debug)]
620+ pub struct Line {
621+ pub label: String,
622+ pub tokens: u64,
623+ /// `None` when none of it has a price.
624+ pub cost: Option<f64>,
625+ }
626+
627+ /// The report narrowed to a range and maybe one provider, in the shapes the page shows.
628+ #[derive(Clone, Debug, Default)]
629+ pub struct Summary {
630+ pub tokens: Tokens,
631+ pub cost: Costs,
632+ pub savings: f64,
633+ /// Tokens of models without a known price.
634+ pub unpriced: u64,
635+ pub sessions: usize,
636+ pub providers: Vec<ProviderTotal>,
637+ /// Every hour or day in the range, oldest first, zeros included.
638+ pub series: Vec<Point>,
639+ /// Most tokens first.
640+ pub models: Vec<Line>,
641+ /// Newest first, days with use only.
642+ pub days: Vec<Line>,
643+ }
644+
645+ pub fn summarize(report: &UsageReport, span: Span, provider: Option<&str>, now: NaiveDateTime) -> Summary {
646+ let start = span.first_slot(now);
647+ let mut summary = Summary::default();
648+ let mut providers: Vec<(ProviderTotal, BTreeSet<u32>)> = Vec::new();
649+ let mut all_sessions = BTreeSet::new();
650+ let mut models: Vec<Line> = Vec::new();
651+ let mut days: Vec<(NaiveDate, Line)> = Vec::new();
652+
653+ let slots: Vec<NaiveDateTime> = if span.hourly() {
654+ (0..24).map(|ix| start + TimeDelta::hours(ix)).collect()
655+ } else {
656+ (0..span.days()).map(|ix| start + TimeDelta::days(ix)).collect()
657+ };
658+ summary.series = slots
659+ .iter()
660+ .map(|slot| {
661+ let (label, title) = if span.hourly() {
662+ (slot.format("%H:%M").to_string(), slot.format("%a, %b %-d, %H:%M").to_string())
663+ } else {
664+ (slot.format("%b %-d").to_string(), slot.format("%a, %b %-d").to_string())
665+ };
666+ Point { label, title, tokens: 0, cost: 0. }
667+ })
668+ .collect();
669+
670+ for bucket in &report.buckets {
671+ if bucket.hour < start || provider.is_some_and(|provider| provider != bucket.provider) {
672+ continue;
673+ }
674+ let tokens = bucket.tokens.total();
675+ let cost = bucket.cost.map(|cost| cost.total());
676+ summary.tokens.add(&bucket.tokens);
677+ if let Some(costs) = &bucket.cost {
678+ summary.cost.add(costs);
679+ } else {
680+ summary.unpriced += tokens;
681+ }
682+ summary.savings += bucket.savings;
683+ all_sessions.extend(bucket.sessions.iter().copied());
684+
685+ let ix = match providers.iter().position(|(total, _)| total.label == bucket.provider) {
686+ Some(ix) => ix,
687+ None => {
688+ let total = ProviderTotal { label: bucket.provider.clone(), ..ProviderTotal::default() };
689+ providers.push((total, BTreeSet::new()));
690+ providers.len() - 1
691+ }
692+ };
693+ let (total, sessions) = &mut providers[ix];
694+ total.tokens += tokens;
695+ total.cost += cost.unwrap_or(0.);
696+ sessions.extend(bucket.sessions.iter().copied());
697+
698+ add_line(&mut models, |line| line.label == bucket.model, &bucket.model, tokens, cost);
699+ let day = bucket.day();
700+ match days.iter_mut().find(|(date, _)| *date == day) {
701+ Some((_, line)) => add_to(line, tokens, cost),
702+ None => days.push((day, Line { label: day.format("%a, %b %-d").to_string(), tokens, cost })),
703+ }
704+
705+ let slot = if span.hourly() {
706+ (bucket.hour - start).num_hours()
707+ } else {
708+ (bucket.day() - start.date()).num_days()
709+ };
710+ if let Some(point) = usize::try_from(slot).ok().and_then(|slot| summary.series.get_mut(slot)) {
711+ point.tokens += tokens;
712+ point.cost += cost.unwrap_or(0.);
713+ }
714+ }
715+
716+ summary.sessions = all_sessions.len();
717+ summary.providers = providers
718+ .into_iter()
719+ .map(|(mut total, sessions)| {
720+ total.sessions = sessions.len();
721+ total
722+ })
723+ .collect();
724+ summary.providers.sort_by(|a, b| b.tokens.cmp(&a.tokens));
725+ models.sort_by(|a, b| b.tokens.cmp(&a.tokens));
726+ summary.models = models;
727+ days.sort_by(|a, b| b.0.cmp(&a.0));
728+ summary.days = days.into_iter().map(|(_, line)| line).collect();
729+ summary
730+ }
731+
732+ fn add_line(lines: &mut Vec<Line>, is: impl Fn(&Line) -> bool, label: &str, tokens: u64, cost: Option<f64>) {
733+ match lines.iter_mut().find(|line| is(line)) {
734+ Some(line) => add_to(line, tokens, cost),
735+ None => lines.push(Line { label: label.to_string(), tokens, cost }),
736+ }
737+ }
738+
739+ fn add_to(line: &mut Line, tokens: u64, cost: Option<f64>) {
740+ line.tokens += tokens;
741+ if let Some(cost) = cost {
742+ *line.cost.get_or_insert(0.) += cost;
743+ }
744+ }
745+
746+ // ---- formatting --------------------------------------------------------------------
747+
748+ /// Token counts the way T3 Code writes them: `532`, `14.2K`, `4.63M`, `1.08B`.
749+ pub fn format_tokens(count: u64) -> String {
750+ let count = count as f64;
751+ let (value, unit) = if count >= 1e9 {
752+ (count / 1e9, "B")
753+ } else if count >= 1e6 {
754+ (count / 1e6, "M")
755+ } else if count >= 1e3 {
756+ (count / 1e3, "K")
757+ } else {
758+ return format!("{count}");
759+ };
760+ let digits = if value >= 100. { 0 } else if value >= 10. { 1 } else { 2 };
761+ format!("{value:.digits$}{unit}")
762+ }
763+
764+ /// Dollars with cents and thousands separators: `$1,405.02`.
765+ pub fn format_cost(dollars: f64) -> String {
766+ if dollars > 0. && dollars < 0.01 {
767+ return "<$0.01".to_string();
768+ }
769+ let cents = (dollars * 100.).round() as u64;
770+ let whole = (cents / 100).to_string();
771+ let mut grouped = String::new();
772+ for (ix, digit) in whole.chars().enumerate() {
773+ if ix > 0 && (whole.len() - ix).is_multiple_of(3) {
774+ grouped.push(',');
775+ }
776+ grouped.push(digit);
777+ }
778+ format!("${grouped}.{:02}", cents % 100)
779+ }
780+
781+ // ---- plan limits -------------------------------------------------------------------
782+
783+ /// One plan limit, such as Claude's five-hour session window.
784+ #[derive(Clone, Debug, PartialEq)]
785+ pub struct Limit {
786+ pub label: String,
787+ /// How much of it is used, 0–100.
788+ pub percent: f64,
789+ pub resets_at: Option<DateTime<Local>>,
790+ /// `normal`, `warning` or `critical`, as Claude reports it.
791+ pub severity: String,
792+ /// Whether this is the limit currently holding the account back the most.
793+ pub active: bool,
794+ }
795+
796+ /// Reads the limits from the reply to the Claude CLI's `get_usage` control request.
797+ pub fn limits_from_claude(reply: &Value) -> Vec<Limit> {
798+ let rate_limits = reply.get("rate_limits").unwrap_or(reply);
799+ if let Some(limits) = rate_limits.get("limits").and_then(Value::as_array) {
800+ return limits.iter().filter_map(claude_limit).collect();
801+ }
802+ // Older replies only have the named windows.
803+ const WINDOWS: [(&str, &str); 4] = [
804+ ("five_hour", "Current session"),
805+ ("seven_day", "Weekly · all models"),
806+ ("seven_day_opus", "Weekly · Opus"),
807+ ("seven_day_sonnet", "Weekly · Sonnet"),
808+ ];
809+ let Some(windows) = rate_limits.as_object() else { return Vec::new() };
810+ WINDOWS
811+ .iter()
812+ .filter_map(|(key, label)| {
813+ let window = windows.get(*key)?;
814+ let percent = window.get("utilization").and_then(Value::as_f64)?;
815+ Some(Limit {
816+ label: label.to_string(),
817+ percent,
818+ resets_at: window.get("resets_at").and_then(parse_time).map(|at| at.with_timezone(&Local)),
819+ severity: severity_of(percent).to_string(),
820+ active: false,
821+ })
822+ })
823+ .collect()
824+ }
825+
826+ fn claude_limit(limit: &Value) -> Option<Limit> {
827+ let percent = limit.get("percent").and_then(Value::as_f64)?;
828+ let kind = limit.get("kind").and_then(Value::as_str).unwrap_or_default();
829+ let scoped_to = limit
830+ .pointer("/scope/model/display_name")
831+ .and_then(Value::as_str)
832+ .or_else(|| limit.pointer("/scope/surface").and_then(Value::as_str));
833+ let label = match (kind, scoped_to) {
834+ ("session", _) => "Current session".to_string(),
835+ ("weekly_all", _) => "Weekly · all models".to_string(),
836+ (_, Some(scope)) if kind.starts_with("weekly") => format!("Weekly · {scope}"),
837+ (_, Some(scope)) => format!("{} · {scope}", humanize(kind)),
838+ _ => humanize(kind),
839+ };
840+ Some(Limit {
841+ label,
842+ percent,
843+ resets_at: limit.get("resets_at").and_then(parse_time).map(|at| at.with_timezone(&Local)),
844+ severity: limit
845+ .get("severity")
846+ .and_then(Value::as_str)
847+ .map(str::to_string)
848+ .unwrap_or_else(|| severity_of(percent).to_string()),
849+ active: limit.get("is_active").and_then(Value::as_bool).unwrap_or(false),
850+ })
851+ }
852+
853+ fn severity_of(percent: f64) -> &'static str {
854+ if percent >= 90. {
855+ "critical"
856+ } else if percent >= 75. {
857+ "warning"
858+ } else {
859+ "normal"
860+ }
861+ }
862+
863+ /// `weekly_scoped` → `Weekly scoped`.
864+ fn humanize(key: &str) -> String {
865+ let words = key.replace('_', " ");
866+ let mut chars = words.chars();
867+ match chars.next() {
868+ Some(first) => first.to_uppercase().chain(chars).collect(),
869+ None => "Limit".to_string(),
870+ }
871+ }
872+
873+ #[cfg(test)]
874+ mod tests {
875+ use super::*;
876+ use serde_json::json;
877+
878+ #[test]
879+ fn prices_by_prefix() {
880+ assert_eq!(price("claude-opus-5-5"), Some(Price::new(4., 20., 0.2)));
881+ assert_eq!(price("claude-haiku-4-5-20251001"), Some(Price::new(1., 5., 0.1)));
882+ assert_eq!(price("claude-opus-4-20250514"), Some(Price::new(15., 75., 1.5)));
883+ assert_eq!(price("claude-opus-4-1-20250805"), Some(Price::new(15., 75., 1.5)));
884+ assert_eq!(price("claude-opus-4-6"), Some(Price::new(5., 25., 0.5)));
885+ assert_eq!(price("claude-fable-5-1"), Some(Price::new(10., 50., 0.25)));
886+ assert_eq!(price("claude-fable-5"), Some(Price::new(10., 50., 1.)));
887+ assert_eq!(price("claude-mythos-1"), Some(Price::new(10., 50., 1.)));
888+ assert_eq!(price("anthropic/claude-sonnet-5"), Some(Price::new(2., 10., 0.2)));
889+ assert_eq!(price("claude-opus-5-5[1m]"), Some(Price::new(4., 20., 0.2)));
890+ assert_eq!(price("claude-3-5-haiku-20241022"), Some(Price::new(0.8, 4., 0.08)));
891+ assert_eq!(price("<synthetic>"), None);
892+ assert_eq!(price("gpt-5"), None);
893+ assert_eq!(Price::new(4., 20., 0.2).cache_write, 5.);
894+ }
895+
896+ #[test]
897+ fn opus_5_5_month_costs_about_405() {
898+ let tokens = Tokens { input: 0, output: 4_600_000, cache_write: 20_600_000, cache_read: 1_050_000_000 };
899+ let cost = price("claude-opus-5-5").unwrap().cost(&tokens).total();
900+ assert!((cost - 405.).abs() < 1., "{cost}");
901+ assert!((price("claude-opus-5-5").unwrap().savings(1_000_000) - 3.8).abs() < 1e-9);
902+ }
903+
904+ #[test]
905+ fn formats_like_t3() {
906+ assert_eq!(format_tokens(1_080_000_000), "1.08B");
907+ assert_eq!(format_tokens(4_630_000), "4.63M");
908+ assert_eq!(format_tokens(14_200), "14.2K");
909+ assert_eq!(format_tokens(532), "532");
910+ assert_eq!(format_cost(405.019), "$405.02");
911+ assert_eq!(format_cost(1405.5), "$1,405.50");
912+ assert_eq!(format_cost(0.), "$0.00");
913+ }
914+
915+ #[test]
916+ fn claude_lines_dedup_by_response() {
917+ let line = |output: u64| {
918+ json!({
919+ "type": "assistant", "timestamp": "2026-10-01T10:00:00.000Z", "sessionId": "s1",
920+ "requestId": "req_1",
921+ "message": {"id": "msg_1", "model": "claude-opus-5-5", "usage": {
922+ "input_tokens": 2, "output_tokens": output,
923+ "cache_creation_input_tokens": 100, "cache_read_input_tokens": 1000}}
924+ })
925+ .to_string()
926+ };
927+ let dir = std::env::temp_dir().join(format!("vectrum-usage-test-{}", std::process::id()));
928+ let file = dir.join("projects/p/s1.jsonl");
929+ std::fs::create_dir_all(file.parent().unwrap()).unwrap();
930+ std::fs::write(&file, format!("{}\n{}\n{{\"type\":\"user\"}}\n", line(5), line(40))).unwrap();
931+ let sources = UsageSources { claude_dirs: vec![("Claude Code".into(), dir.clone())], ..Default::default() };
932+ let report = collect(&sources, SystemTime::UNIX_EPOCH);
933+ std::fs::remove_dir_all(&dir).ok();
934+ assert_eq!(report.buckets.len(), 1);
935+ assert_eq!(report.buckets[0].tokens, Tokens { input: 2, output: 40, cache_write: 100, cache_read: 1000 });
936+ assert_eq!(report.buckets[0].sessions.len(), 1);
937+ }
938+
939+ #[test]
940+ fn pi_rows_use_pis_cost() {
941+ let line = json!({
942+ "type": "message", "timestamp": "2026-10-01T10:00:00.000Z",
943+ "message": {"role": "assistant", "model": "gpt-5", "provider": "openai", "usage": {
944+ "input": 10, "output": 5, "cacheRead": 0, "cacheWrite": 0,
945+ "cost": {"input": 0.01, "output": 0.02, "cacheRead": 0, "cacheWrite": 0, "total": 0.03}}}
946+ });
947+ let row = pi_row(&line, "Pi", Some("s".into())).unwrap();
948+ assert_eq!(row.tokens.total(), 15);
949+ assert!((row.cost.unwrap().total() - 0.03).abs() < 1e-9);
950+ }
951+
952+ #[test]
953+ fn hub_rows_split_cached_input() {
954+ let reply = json!({"usage": {"apis": {"key": {"models": {"claude-sonnet-5": {"details": [
955+ {"timestamp": "2026-10-01T10:00:00Z", "failed": false, "tokens": {
956+ "input_tokens": 100, "output_tokens": 10, "reasoning_tokens": 0,
957+ "cached_tokens": 60, "total_tokens": 110}}
958+ ]}}}}}});
959+ let rows = hub_rows(&reply, "Hub", DateTime::<Utc>::UNIX_EPOCH);
960+ assert_eq!(rows.len(), 1);
961+ assert_eq!(rows[0].tokens, Tokens { input: 40, output: 10, cache_write: 0, cache_read: 60 });
962+ }
963+
964+ #[test]
965+ fn summary_fills_missing_days() {
966+ let now = NaiveDate::from_ymd_opt(2026, 10, 9).unwrap().and_hms_opt(12, 30, 0).unwrap();
967+ let bucket = |day: u32, model: &str, output: u64| Bucket {
968+ provider: "Claude Code".into(),
969+ model: model.into(),
970+ hour: NaiveDate::from_ymd_opt(2026, 10, day).unwrap().and_hms_opt(9, 0, 0).unwrap(),
971+ tokens: Tokens { output, ..Tokens::default() },
972+ cost: price(model).map(|price| price.cost(&Tokens { output, ..Tokens::default() })),
973+ savings: 0.,
974+ sessions: BTreeSet::from([day]),
975+ };
976+ let report = UsageReport {
977+ buckets: vec![bucket(1, "claude-opus-5-5", 1_000_000), bucket(8, "mystery", 500), bucket(9, "claude-opus-5-5", 10)],
978+ ..UsageReport::default()
979+ };
980+ let week = summarize(&report, Span::Week, None, now);
981+ assert_eq!(week.series.len(), 7);
982+ assert_eq!(week.series[0].label, "Oct 3");
983+ assert_eq!(week.series[5].tokens, 500);
984+ assert_eq!(week.series[6].tokens, 10);
985+ assert_eq!(week.tokens.total(), 510);
986+ assert_eq!(week.unpriced, 500);
987+ assert_eq!(week.sessions, 2);
988+ assert_eq!(week.models[0].label, "mystery");
989+ assert_eq!(week.models[0].cost, None);
990+ assert_eq!(week.days.len(), 2);
991+
992+ let month = summarize(&report, Span::Month, Some("Claude Code"), now);
993+ assert_eq!(month.series.len(), 30);
994+ assert!((month.cost.total() - 20.0002).abs() < 1e-6);
995+ assert_eq!(month.providers[0].sessions, 3);
996+
997+ let day = summarize(&report, Span::Day, None, now);
998+ assert_eq!(day.series.len(), 24);
999+ assert_eq!(day.series.last().unwrap().label, "12:00");
1000+ assert_eq!(day.tokens.total(), 10);
1001+ }
1002+
1003+ #[test]
1004+ fn limits_from_the_limits_list() {
1005+ let reply = json!({
1006+ "subscription_type": "team",
1007+ "rate_limits_available": true,
1008+ "rate_limits": {
1009+ "five_hour": {"utilization": 7, "resets_at": "2026-10-10T01:40:00.386310+00:00"},
1010+ "seven_day": {"utilization": 22, "resets_at": "2026-10-12T17:00:00.386332+00:00"},
1011+ "seven_day_opus": null,
1012+ "extra_usage": {"is_enabled": false},
1013+ "limits": [
1014+ {"kind": "session", "group": "session", "percent": 7, "severity": "normal",
1015+ "resets_at": "2026-10-10T01:40:00.386310+00:00", "scope": null, "is_active": false},
1016+ {"kind": "weekly_all", "group": "weekly", "percent": 22, "severity": "normal",
1017+ "resets_at": "2026-10-12T17:00:00.386332+00:00", "scope": null, "is_active": true},
1018+ {"kind": "weekly_scoped", "group": "weekly", "percent": 6, "severity": "normal",
1019+ "resets_at": "2026-10-12T17:00:00.386332+00:00",
1020+ "scope": {"model": {"id": null, "display_name": "Fable"}, "surface": null}, "is_active": false}
1021+ ]
1022+ }
1023+ });
1024+ let limits = limits_from_claude(&reply);
1025+ let labels: Vec<&str> = limits.iter().map(|limit| limit.label.as_str()).collect();
1026+ assert_eq!(labels, ["Current session", "Weekly · all models", "Weekly · Fable"]);
1027+ assert_eq!(limits[1].percent, 22.);
1028+ assert!(limits[1].active);
1029+ assert!(!limits[0].active);
1030+ assert_eq!(
1031+ limits[0].resets_at.map(|at| at.with_timezone(&Utc).to_rfc3339()),
1032+ Some("2026-10-10T01:40:00.386310+00:00".to_string())
1033+ );
1034+ }
1035+
1036+ #[test]
1037+ fn limits_from_named_windows() {
1038+ let reply = json!({"rate_limits": {
1039+ "five_hour": {"utilization": 95, "resets_at": "2026-10-10T01:40:00+00:00"},
1040+ "seven_day": {"utilization": 22, "resets_at": null},
1041+ "seven_day_opus": null
1042+ }});
1043+ let limits = limits_from_claude(&reply);
1044+ assert_eq!(limits.len(), 2);
1045+ assert_eq!(limits[0].label, "Current session");
1046+ assert_eq!(limits[0].severity, "critical");
1047+ assert_eq!(limits[1].label, "Weekly · all models");
1048+ assert_eq!(limits[1].resets_at, None);
1049+ assert!(limits_from_claude(&json!({})).is_empty());
1050+ }
1051+
1052+ /// Prints the last 30 days of this machine's Claude Code usage by model:
1053+ /// `cargo test real_claude_usage -- --ignored --nocapture`.
1054+ #[test]
1055+ #[ignore]
1056+ fn real_claude_usage() {
1057+ let home = PathBuf::from(std::env::var_os("HOME").unwrap());
1058+ let sources = UsageSources {
1059+ claude_dirs: vec![("Claude Code".into(), home.join(".claude"))],
1060+ pi_dirs: vec![("Pi".into(), home.join(".pi/agent"))],
1061+ hubs: Vec::new(),
1062+ };
1063+ let started = std::time::Instant::now();
1064+ let report = collect(&sources, Span::Month.since(Local::now()));
1065+ let elapsed = started.elapsed();
1066+ let summary = summarize(&report, Span::Month, None, Local::now().naive_local());
1067+ println!("collected in {elapsed:?}, {} buckets", report.buckets.len());
1068+ for line in &summary.models {
1069+ let cost = line.cost.map(format_cost).unwrap_or_else(|| "unpriced".into());
1070+ println!("{:<28} {:>8} {:>12}", line.label, format_tokens(line.tokens), cost);
1071+ }
1072+ let t = summary.tokens;
1073+ println!(
1074+ "total {} ({} sessions) {} · input {} output {} cache write {} cache read {} · savings {}",
1075+ format_tokens(t.total()),
1076+ summary.sessions,
1077+ format_cost(summary.cost.total()),
1078+ format_tokens(t.input),
1079+ format_tokens(t.output),
1080+ format_tokens(t.cache_write),
1081+ format_tokens(t.cache_read),
1082+ format_cost(summary.savings),
1083+ );
1084+ println!("errors: {:?}", report.errors);
1085+ }
1086+ }