1pub mod compare;
18pub mod files;
19pub mod models;
20pub mod projects;
21pub mod query;
22pub mod sessions;
23pub mod summary;
24pub mod tools;
25
26use std::collections::{BTreeMap, BTreeSet};
27use std::fmt;
28use std::io;
29
30use chrono::{TimeZone, Utc};
31use serde_json::{Map, Value};
32
33use crate::cli::TimeWindow;
34use crate::config::{Pricing, TokenCounts};
35use crate::output::{Cell, Report};
36use crate::store::{Event, ScanQuery, Scanner};
37
38pub const SYNTHETIC_MODEL: &str = "<synthetic>";
42
43pub const NAMES: [&str; 7] = [
45 "summary", "projects", "models", "sessions", "tools", "compare", "files",
46];
47
48#[derive(Debug, Clone)]
50pub struct ReportCtx {
51 pub window: TimeWindow,
52 pub project: Option<String>,
53 pub include_sidechain: bool,
55 pub pricing: Pricing,
59}
60
61impl ReportCtx {
62 pub fn new(window: TimeWindow, project: Option<String>, include_sidechain: bool) -> Self {
63 Self {
64 window,
65 project,
66 include_sidechain,
67 pricing: Pricing::default(),
68 }
69 }
70
71 pub fn with_pricing(mut self, pricing: Pricing) -> Self {
74 self.pricing = pricing;
75 self
76 }
77
78 pub fn with_window(&self, window: TimeWindow) -> Self {
80 Self {
81 window,
82 ..self.clone()
83 }
84 }
85
86 fn scan_query(&self) -> ScanQuery {
87 ScanQuery::new(self.window).with_project(self.project.clone())
88 }
89}
90
91pub type Builder = fn(&Scanner, &ReportCtx) -> Result<Report, ReportError>;
93
94pub fn resolve(name: &str) -> Result<Builder, ReportError> {
96 match name {
97 "summary" => Ok(summary::build),
98 "projects" => Ok(projects::build),
99 "models" => Ok(models::build),
100 "sessions" => Ok(sessions::build),
101 "tools" => Ok(tools::build),
102 "compare" => Ok(compare::build),
103 "files" => Ok(files::build),
104 other => Err(ReportError::Unknown(other.to_string())),
105 }
106}
107
108pub fn run(scanner: &Scanner, name: &str, ctx: &ReportCtx) -> Result<Report, ReportError> {
110 resolve(name)?(scanner, ctx)
111}
112
113#[derive(Debug)]
114pub enum ReportError {
115 Unknown(String),
117 NeedsWindow(&'static str),
119 UnknownDimension(String),
121 Io(io::Error),
122}
123
124impl fmt::Display for ReportError {
125 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126 match self {
127 ReportError::Unknown(name) => write!(
128 f,
129 "unknown report {:?}: expected one of {}",
130 name,
131 NAMES.join(", ")
132 ),
133 ReportError::NeedsWindow(name) => write!(
134 f,
135 "report {name} compares a period against the one before it, so it needs a bounded \
136 period: pass --since (e.g. --since 7d)"
137 ),
138 ReportError::UnknownDimension(dim) => write!(
139 f,
140 "unknown --group-by dimension {:?}: expected one of {}",
141 dim,
142 query::DIMENSIONS.join(", ")
143 ),
144 ReportError::Io(err) => write!(f, "{err}"),
145 }
146 }
147}
148
149impl std::error::Error for ReportError {}
150
151impl From<io::Error> for ReportError {
152 fn from(err: io::Error) -> Self {
153 ReportError::Io(err)
154 }
155}
156
157pub struct Scanned {
159 pub events: Vec<Event>,
160 pub notes: Notes,
161}
162
163pub fn scan(scanner: &Scanner, ctx: &ReportCtx) -> Result<Scanned, ReportError> {
165 let mut events = Vec::new();
166 let mut notes = Notes::new(ctx.include_sidechain, ctx.pricing.clone());
167 let stats = scanner.scan_with(&ctx.scan_query(), |event| {
168 if event.is_sidechain == Some(true) {
169 notes.sidechain_events += 1;
170 if !ctx.include_sidechain {
171 return;
172 }
173 }
174 notes.observe(&event);
175 events.push(event);
176 })?;
177 notes.lines_skipped = stats.lines_skipped;
178 Ok(Scanned { events, notes })
179}
180
181#[derive(Debug, Clone, Copy, Default, PartialEq)]
186pub struct Cost {
187 pub total: f64,
188 pub priced: u64,
190 pub unpriced: u64,
192}
193
194impl Cost {
195 fn add(&mut self, event: &Event, pricing: &Pricing) {
196 self.add_share(event, pricing, 1.0);
197 }
198
199 fn add_share(&mut self, event: &Event, pricing: &Pricing, share: f64) {
204 if !event.has_usage() || event.model.as_deref() == Some(SYNTHETIC_MODEL) {
205 return;
206 }
207 match event_cost(event, pricing) {
208 Some(cost) => {
209 self.total += cost * share;
210 self.priced += 1;
211 }
212 None => self.unpriced += 1,
213 }
214 }
215
216 fn merge(&mut self, other: Cost) {
217 self.total += other.total;
218 self.priced += other.priced;
219 self.unpriced += other.unpriced;
220 }
221
222 pub fn is_partial(&self) -> bool {
225 self.priced > 0 && self.unpriced > 0
226 }
227
228 pub fn cell(&self) -> Cell {
231 if self.priced == 0 {
232 Cell::Unsupported
233 } else if self.is_partial() {
234 Cell::money_partial(self.total)
235 } else {
236 Cell::money_est(self.total)
237 }
238 }
239
240 pub fn json(&self) -> Value {
242 if self.priced == 0 {
243 Value::Null
244 } else {
245 serde_json::json!(round_money(self.total))
246 }
247 }
248}
249
250pub fn event_cost(event: &Event, pricing: &Pricing) -> Option<f64> {
257 let model = event.model.as_deref()?;
258 pricing
259 .estimate_cost(
260 &event.provider,
261 model,
262 TokenCounts {
263 input: event.input_tok,
264 output: event.output_tok,
265 cache_read: event.cache_read_tok,
266 cache_write: event.cache_write_tok,
267 },
268 )
269 .or(event.cost_est)
270}
271
272#[derive(Debug, Clone, Default)]
274pub struct Totals {
275 pub events: u64,
277 pub requests: u64,
279 pub input: u64,
280 pub output: u64,
281 pub cache_read: u64,
282 pub cache_write: u64,
283 pub cost: Cost,
284 pub sessions: BTreeSet<String>,
285 pub first_ts: Option<i64>,
286 pub last_ts: Option<i64>,
287}
288
289impl Totals {
290 pub fn add(&mut self, event: &Event, pricing: &Pricing) {
291 self.events += 1;
292 if event.has_usage() {
293 self.requests += 1;
294 }
295 self.input += event.input_tok.unwrap_or(0);
296 self.output += event.output_tok.unwrap_or(0);
297 self.cache_read += event.cache_read_tok.unwrap_or(0);
298 self.cache_write += event.cache_write_tok.unwrap_or(0);
299 self.cost.add(event, pricing);
300 if let Some(session) = &event.session_id {
301 self.sessions.insert(session.clone());
302 }
303 self.first_ts = Some(self.first_ts.map_or(event.ts, |ts| ts.min(event.ts)));
304 self.last_ts = Some(self.last_ts.map_or(event.ts, |ts| ts.max(event.ts)));
305 }
306
307 pub fn merge(&mut self, other: &Totals) {
308 self.events += other.events;
309 self.requests += other.requests;
310 self.input += other.input;
311 self.output += other.output;
312 self.cache_read += other.cache_read;
313 self.cache_write += other.cache_write;
314 self.cost.merge(other.cost);
315 self.sessions.extend(other.sessions.iter().cloned());
316 self.first_ts = min_opt(self.first_ts, other.first_ts);
317 self.last_ts = max_opt(self.last_ts, other.last_ts);
318 }
319
320 pub fn total_tokens(&self) -> u64 {
321 self.input + self.output + self.cache_read + self.cache_write
322 }
323
324 pub fn tail_cells(&self) -> Vec<Cell> {
326 vec![
327 count(self.input),
328 count(self.output),
329 count(self.cache_read),
330 self.cost.cell(),
331 ]
332 }
333
334 pub fn write_json(&self, row: &mut Map<String, Value>) {
336 row.insert("requests".into(), serde_json::json!(self.requests));
337 row.insert("input_tok".into(), serde_json::json!(self.input));
338 row.insert("output_tok".into(), serde_json::json!(self.output));
339 row.insert("cache_read_tok".into(), serde_json::json!(self.cache_read));
340 row.insert(
341 "cache_write_tok".into(),
342 serde_json::json!(self.cache_write),
343 );
344 row.insert("cost_est".into(), self.cost.json());
345 row.insert(
349 "cost_partial".into(),
350 serde_json::json!(self.cost.is_partial()),
351 );
352 row.insert(
353 "cost_priced_requests".into(),
354 serde_json::json!(self.cost.priced),
355 );
356 row.insert(
357 "cost_unpriced_requests".into(),
358 serde_json::json!(self.cost.unpriced),
359 );
360 }
361}
362
363fn min_opt(a: Option<i64>, b: Option<i64>) -> Option<i64> {
364 match (a, b) {
365 (Some(a), Some(b)) => Some(a.min(b)),
366 (a, b) => a.or(b),
367 }
368}
369
370fn max_opt(a: Option<i64>, b: Option<i64>) -> Option<i64> {
371 match (a, b) {
372 (Some(a), Some(b)) => Some(a.max(b)),
373 (a, b) => a.or(b),
374 }
375}
376
377pub fn rollup<K, F>(events: &[Event], pricing: &Pricing, key: F) -> BTreeMap<K, Totals>
379where
380 K: Ord,
381 F: Fn(&Event) -> Option<K>,
382{
383 let mut buckets: BTreeMap<K, Totals> = BTreeMap::new();
384 for event in events {
385 if let Some(k) = key(event) {
386 buckets.entry(k).or_default().add(event, pricing);
387 }
388 }
389 buckets
390}
391
392pub const NO_PROJECT: &str = "(no project)";
397
398pub fn desc(a: f64, b: f64) -> std::cmp::Ordering {
401 b.partial_cmp(&a).unwrap_or(std::cmp::Ordering::Equal)
402}
403
404pub fn by_weight_desc<K: Ord + Clone>(buckets: BTreeMap<K, Totals>) -> Vec<(K, Totals)> {
406 let mut rows: Vec<(K, Totals)> = buckets.into_iter().collect();
407 rows.sort_by(|a, b| {
408 b.1.total_tokens()
409 .cmp(&a.1.total_tokens())
410 .then_with(|| a.0.cmp(&b.0))
411 });
412 rows
413}
414
415pub fn count(n: u64) -> Cell {
417 Cell::Int(i64::try_from(n).unwrap_or(i64::MAX))
418}
419
420pub fn day_of(ts_ms: i64) -> Option<String> {
422 Utc.timestamp_millis_opt(ts_ms)
423 .single()
424 .map(|dt| dt.format("%Y-%m-%d").to_string())
425}
426
427pub fn format_span(ms: i64) -> String {
429 let secs = ms.max(0) / 1000;
430 let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
431 if h > 0 {
432 format!("{h}h{m:02}m")
433 } else if m > 0 {
434 format!("{m}m{s:02}s")
435 } else {
436 format!("{s}s")
437 }
438}
439
440pub fn round_money(amount: f64) -> f64 {
442 (amount * 100.0).round() / 100.0
443}
444
445pub fn short_id(id: &str) -> String {
447 match id.char_indices().nth(8) {
448 Some((idx, _)) => format!("{}…", &id[..idx]),
449 None => id.to_string(),
450 }
451}
452
453#[derive(Debug, Clone)]
458pub struct Notes {
459 include_sidechain: bool,
460 pub sidechain_events: u64,
461 pub lines_skipped: u64,
462 pricing: Pricing,
463 unpriced_models: BTreeSet<String>,
464 synthetic_events: u64,
465 events: u64,
466 requests: u64,
467 extra: Vec<String>,
468}
469
470impl Notes {
471 fn new(include_sidechain: bool, pricing: Pricing) -> Self {
472 Self {
473 include_sidechain,
474 sidechain_events: 0,
475 lines_skipped: 0,
476 pricing,
477 unpriced_models: BTreeSet::new(),
478 synthetic_events: 0,
479 events: 0,
480 requests: 0,
481 extra: Vec::new(),
482 }
483 }
484
485 fn observe(&mut self, event: &Event) {
486 self.events += 1;
487 if !event.has_usage() {
488 return;
489 }
490 self.requests += 1;
491 if event.model.as_deref() == Some(SYNTHETIC_MODEL) {
492 self.synthetic_events += 1;
493 } else if event_cost(event, &self.pricing).is_none() {
494 self.unpriced_models.insert(
495 event
496 .model
497 .clone()
498 .unwrap_or_else(|| "(unknown model)".into()),
499 );
500 }
501 }
502
503 pub fn push(&mut self, note: impl Into<String>) {
505 self.extra.push(note.into());
506 }
507
508 pub fn merge(&mut self, other: &Notes) {
514 let Notes {
515 include_sidechain: _,
518 pricing: _,
519 extra: _,
522 sidechain_events,
523 lines_skipped,
524 unpriced_models,
525 synthetic_events,
526 events,
527 requests,
528 } = other;
529 self.sidechain_events += sidechain_events;
530 self.lines_skipped += lines_skipped;
531 self.unpriced_models.extend(unpriced_models.iter().cloned());
532 self.synthetic_events += synthetic_events;
533 self.events += events;
534 self.requests += requests;
535 }
536
537 pub fn finish(&self) -> Vec<String> {
539 let mut notes = self.extra.clone();
540
541 if self.events > 0 {
542 notes.push(format!(
543 "usage is recorded once per request: {} of {} events carry token counts, and the \
544 rest contribute nothing rather than zero",
545 self.requests, self.events
546 ));
547 }
548
549 if self.sidechain_events > 0 {
550 notes.push(if self.include_sidechain {
551 format!(
552 "includes {} sidechain (subagent) events — real spend, but counted in no \
553 per-session figure your agent shows you, so these totals will read higher; \
554 pass --no-sidechain to exclude them",
555 self.sidechain_events
556 )
557 } else {
558 format!(
559 "excludes {} sidechain (subagent) events (--no-sidechain); they are real \
560 spend, so these totals understate it",
561 self.sidechain_events
562 )
563 });
564 }
565
566 if !self.unpriced_models.is_empty() {
567 notes.push(format!(
568 "no configured price for {} — their spend is in no est. cost figure here: a row \
569 with nothing else in it is blank rather than 0, and a row that also has priced \
570 models is marked ~+ and understates its cost; add rates under \
571 [pricing.<provider>] in config.toml and rerun (no re-ingest needed)",
572 self.unpriced_models
573 .iter()
574 .cloned()
575 .collect::<Vec<_>>()
576 .join(", ")
577 ));
578 }
579
580 if self.synthetic_events > 0 {
581 notes.push(format!(
582 "{} events report the model as {SYNTHETIC_MODEL}, a placeholder the agent writes \
583 for records it generated itself; their tokens are counted and their cost is not, \
584 because nobody is billed for them",
585 self.synthetic_events
586 ));
587 }
588
589 if self.lines_skipped > 0 {
590 notes.push(format!(
591 "skipped {} unreadable line(s) in the store (a torn final line, or schema drift)",
592 self.lines_skipped
593 ));
594 }
595
596 notes.push("cost figures are estimates".into());
597 notes
598 }
599}
600
601#[cfg(test)]
602pub(crate) mod testkit {
603 use crate::store::{Event, StorePaths, StoreWriter, ToolCall};
604 use chrono::{TimeZone, Utc};
605
606 pub fn ms(y: i32, mo: u32, d: u32, h: u32) -> i64 {
607 Utc.with_ymd_and_hms(y, mo, d, h, 0, 0)
608 .unwrap()
609 .timestamp_millis()
610 }
611
612 pub fn used(id: &str, ts: i64, project: &str, model: &str, input: u64, output: u64) -> Event {
614 let mut event = Event::new(id, ts, "claude-code", "anthropic", "assistant");
615 event.project = Some(project.into());
616 event.model = Some(model.into());
617 event.session_id = Some(format!("session-{project}"));
618 event.input_tok = Some(input);
619 event.output_tok = Some(output);
620 event.cache_read_tok = Some(input * 10);
621 event.cache_write_tok = Some(0);
622 event
623 }
624
625 pub fn priced(mut event: Event, cost: f64) -> Event {
626 event.cost_est = Some(cost);
627 event
628 }
629
630 pub fn with_tools(mut event: Event, targets: &[(&str, &str)]) -> Event {
631 event.tool_calls = targets
632 .iter()
633 .map(|(name, target)| ToolCall::new(*name, Some((*target).to_string())))
634 .collect();
635 event
636 }
637
638 pub fn store(events: &[Event]) -> (tempfile::TempDir, StorePaths) {
639 let dir = tempfile::tempdir().unwrap();
640 let paths = StorePaths::new(dir.path());
641 let mut writer = StoreWriter::open(paths.clone()).unwrap();
642 for event in events {
643 writer.append_event(event).unwrap();
644 }
645 (dir, paths)
646 }
647}
648
649#[cfg(test)]
650mod tests {
651 use super::testkit::*;
652 use super::*;
653
654 fn ctx() -> ReportCtx {
655 ReportCtx::new(TimeWindow::all(), None, true)
656 }
657
658 #[test]
659 fn unknown_report_lists_the_valid_names() {
660 let err = resolve("costs").unwrap_err();
661 let msg = err.to_string();
662 assert!(msg.contains("unknown report \"costs\""), "{msg}");
663 for name in NAMES {
664 assert!(msg.contains(name), "{msg} is missing {name}");
665 }
666 }
667
668 #[test]
669 fn every_documented_name_resolves() {
670 for name in NAMES {
671 assert!(resolve(name).is_ok(), "{name}");
672 }
673 }
674
675 #[test]
676 fn an_unpriced_bucket_is_unsupported_not_zero() {
677 let mut totals = Totals::default();
678 totals.add(
679 &used("a", 0, "p", "claude-opus-5", 10, 10),
680 &Pricing::default(),
681 );
682 assert_eq!(totals.cost.cell(), Cell::Unsupported);
683 assert_eq!(totals.cost.json(), Value::Null);
684
685 totals.add(
688 &priced(used("b", 0, "p", "claude-opus-5", 10, 10), 0.5),
689 &Pricing::default(),
690 );
691 assert!(totals.cost.is_partial());
692 assert_eq!(totals.cost.cell(), Cell::money_partial(0.5));
693 assert_eq!(totals.cost.json(), serde_json::json!(0.5));
694
695 let mut row = Map::new();
696 totals.write_json(&mut row);
697 assert_eq!(row["cost_partial"], serde_json::json!(true));
698 assert_eq!(row["cost_priced_requests"], serde_json::json!(1));
699 assert_eq!(row["cost_unpriced_requests"], serde_json::json!(1));
700
701 let mut whole = Totals::default();
703 whole.add(
704 &priced(used("c", 0, "p", "claude-opus-5", 10, 10), 0.5),
705 &Pricing::default(),
706 );
707 assert!(!whole.cost.is_partial());
708 assert_eq!(whole.cost.cell(), Cell::money_est(0.5));
709 }
710
711 #[test]
715 fn config_pricing_wins_over_the_cached_cost_est() {
716 let config: crate::config::Config = toml::from_str(
717 r#"
718[pricing.anthropic]
719"claude-opus-5" = { input = 15.0, output = 75.0, cache_read = 1.5 }
720"#,
721 )
722 .unwrap();
723 let pricing = config.pricing();
724
725 let event = used("a", 0, "p", "claude-opus-5", 1_000_000, 1_000_000);
727 let cost = event_cost(&event, &pricing).unwrap();
728 assert!((cost - (15.0 + 75.0 + 15.0)).abs() < 1e-9, "got {cost}");
729
730 let stale = priced(event.clone(), 999.0);
732 assert_eq!(event_cost(&stale, &pricing), Some(cost));
733
734 let empty = Pricing::default();
736 assert_eq!(event_cost(&stale, &empty), Some(999.0));
737 assert_eq!(event_cost(&event, &empty), None);
738 }
739
740 #[test]
741 fn a_model_the_config_cannot_price_stays_none_not_zero() {
742 let config: crate::config::Config = toml::from_str(
743 r#"
744[pricing.anthropic]
745"claude-sonnet-5" = { input = 3.0, output = 15.0, cache_read = 0.3 }
746"#,
747 )
748 .unwrap();
749 let mut totals = Totals::default();
750 totals.add(
751 &used("a", 0, "p", "claude-opus-5", 10, 10),
752 &config.pricing(),
753 );
754 assert_eq!(totals.cost.priced, 0);
755 assert_eq!(totals.cost.unpriced, 1);
756 assert_eq!(totals.cost.cell(), Cell::Unsupported);
757 assert_eq!(totals.cost.json(), Value::Null);
758 }
759
760 #[test]
761 fn records_without_usage_contribute_nothing_but_are_still_counted() {
762 let mut totals = Totals::default();
763 totals.add(&used("a", 0, "p", "m", 100, 20), &Pricing::default());
764 let mut sibling = Event::new("b", 0, "claude-code", "anthropic", "assistant");
765 sibling.session_id = Some("session-p".into());
766 totals.add(&sibling, &Pricing::default());
767
768 assert_eq!(totals.events, 2);
769 assert_eq!(totals.requests, 1, "usage is counted once per request");
770 assert_eq!(totals.input, 100);
771 assert_eq!(totals.sessions.len(), 1);
772 }
773
774 #[test]
775 fn synthetic_is_counted_in_tokens_and_excluded_from_cost() {
776 let mut totals = Totals::default();
777 totals.add(
778 &priced(used("a", 0, "p", SYNTHETIC_MODEL, 10, 5), 9.99),
779 &Pricing::default(),
780 );
781 assert_eq!(totals.input, 10);
782 assert_eq!(
783 totals.cost,
784 Cost::default(),
785 "no cost accrues to {SYNTHETIC_MODEL}"
786 );
787 assert_eq!(totals.cost.cell(), Cell::Unsupported);
788 }
789
790 #[test]
791 fn sidechain_events_are_included_by_default_and_always_noted() {
792 let mut sidechain = used("s", ms(2026, 8, 4, 9), "p", "m", 5, 5);
793 sidechain.is_sidechain = Some(true);
794 let (_dir, paths) = store(&[used("a", ms(2026, 8, 4, 8), "p", "m", 10, 10), sidechain]);
795 let scanner = Scanner::new(paths);
796
797 let scanned = scan(&scanner, &ctx()).unwrap();
798 assert_eq!(scanned.events.len(), 2);
799 assert!(scanned
800 .notes
801 .finish()
802 .iter()
803 .any(|n| n.contains("includes 1 sidechain")));
804
805 let excluded = scan(&scanner, &ReportCtx::new(TimeWindow::all(), None, false)).unwrap();
806 assert_eq!(excluded.events.len(), 1);
807 assert!(excluded
808 .notes
809 .finish()
810 .iter()
811 .any(|n| n.contains("excludes 1 sidechain")));
812 }
813
814 #[test]
815 fn unpriced_models_are_named_in_the_notes() {
816 let (_dir, paths) = store(&[used("a", ms(2026, 8, 4, 8), "p", "claude-opus-5", 10, 10)]);
817 let notes = scan(&Scanner::new(paths), &ctx()).unwrap().notes.finish();
818 assert!(
819 notes
820 .iter()
821 .any(|n| n.contains("no configured price for claude-opus-5")),
822 "{notes:?}"
823 );
824 assert!(notes.iter().any(|n| n == "cost figures are estimates"));
825 }
826
827 #[test]
828 fn spans_and_ids_are_readable() {
829 assert_eq!(format_span(0), "0s");
830 assert_eq!(format_span(45_000), "45s");
831 assert_eq!(format_span(4 * 60_000 + 5_000), "4m05s");
832 assert_eq!(format_span(72 * 60_000), "1h12m");
833 assert_eq!(short_id("0123456789abcdef"), "01234567…");
834 assert_eq!(short_id("short"), "short");
835 }
836
837 #[test]
838 fn days_are_utc() {
839 assert_eq!(day_of(ms(2026, 8, 4, 23)).unwrap(), "2026-08-04");
840 }
841}