Skip to main content

warden/reports/
models.rs

1//! `warden report models` — usage split by model.
2
3use 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    // Only records that carry usage identify a model in a meaningful way; a
13    // user prompt has no model and would otherwise invent a `(no model)` row
14    // the size of the transcript.
15    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}