1use crate::output::{Cell, Report, Table};
4use crate::store::Scanner;
5
6use super::{by_weight_desc, count, rollup, scan, ReportCtx, ReportError};
7
8const UNKNOWN: &str = "(no model)";
9
10pub fn build(scanner: &Scanner, ctx: &ReportCtx) -> Result<Report, ReportError> {
11 let scanned = scan(scanner, ctx)?;
12 let by_model = rollup(&scanned.events, &ctx.pricing, |event| {
16 event
17 .has_usage()
18 .then(|| event.model.clone().unwrap_or_else(|| UNKNOWN.to_string()))
19 });
20
21 let mut table = Table::new([
22 "model",
23 "requests",
24 "sessions",
25 "in",
26 "out",
27 "cache r",
28 "est. cost",
29 ]);
30 let mut rows = Vec::new();
31
32 for (model, totals) in by_weight_desc(by_model) {
33 let mut row = vec![
34 Cell::text(&model),
35 count(totals.requests),
36 count(totals.sessions.len() as u64),
37 ];
38 row.extend(totals.tail_cells());
39 table.push(row);
40
41 let mut json = serde_json::Map::new();
42 json.insert(
43 "model".into(),
44 if model == UNKNOWN {
45 serde_json::Value::Null
46 } else {
47 serde_json::json!(model)
48 },
49 );
50 json.insert("sessions".into(), serde_json::json!(totals.sessions.len()));
51 totals.write_json(&mut json);
52 rows.push(serde_json::Value::Object(json));
53 }
54
55 let mut notes = scanned.notes;
56 notes.push(
57 "rows cover records that carry usage; prompts and tool results name no model and are \
58 excluded here rather than pooled into a fictitious row",
59 );
60 Ok(Report::new("models", ctx.window, table)
61 .with_json_rows(rows)
62 .with_notes(notes.finish()))
63}
64
65#[cfg(test)]
66mod tests {
67 use super::super::testkit::*;
68 use super::*;
69 use crate::cli::TimeWindow;
70 use crate::output::Style;
71 use crate::reports::SYNTHETIC_MODEL;
72 use crate::store::Event;
73
74 fn report() -> Report {
75 let prompt = Event::new("p", ms(2026, 8, 4, 8), "claude-code", "anthropic", "user");
76 let (_dir, paths) = store(&[
77 priced(
78 used("a", ms(2026, 8, 4, 8), "acme", "opus", 1_000, 100),
79 5.0,
80 ),
81 priced(used("b", ms(2026, 8, 4, 9), "acme", "sonnet", 10, 1), 0.1),
82 priced(
83 used("c", ms(2026, 8, 4, 9), "acme", SYNTHETIC_MODEL, 5, 1),
84 9.0,
85 ),
86 prompt,
87 ]);
88 build(
89 &Scanner::new(paths),
90 &ReportCtx::new(TimeWindow::all(), None, true),
91 )
92 .unwrap()
93 }
94
95 #[test]
96 fn splits_usage_by_model_heaviest_first() {
97 let report = report();
98 let models: Vec<&str> = report
99 .json_rows
100 .iter()
101 .map(|row| row["model"].as_str().unwrap())
102 .collect();
103 assert_eq!(models, ["opus", "sonnet", SYNTHETIC_MODEL]);
104 assert_eq!(report.json_rows[0]["requests"], 1);
105 }
106
107 #[test]
108 fn prompts_do_not_become_a_no_model_row() {
109 assert_eq!(report().json_rows.len(), 3);
110 }
111
112 #[test]
113 fn synthetic_shows_its_tokens_but_no_cost() {
114 let report = report();
115 let synthetic = &report.json_rows[2];
116 assert_eq!(synthetic["input_tok"], 5);
117 assert!(
118 synthetic["cost_est"].is_null(),
119 "nobody is billed for {SYNTHETIC_MODEL}"
120 );
121 assert!(report
122 .notes
123 .iter()
124 .any(|n| n.contains(SYNTHETIC_MODEL) && n.contains("cost is not")));
125 assert!(report
126 .table
127 .render(Style::plain())
128 .contains(SYNTHETIC_MODEL));
129 }
130}