Skip to main content

leviath_cli/commands/
stages.rs

1//! `lev stages <run-id>` - the per-stage ledger a staged agent's cost lives in.
2//!
3//! `stages.json` has carried per-stage token counts for a while and nothing on
4//! the CLI read it: `lev context` renders the context history, `lev result`
5//! prints the answer, and the only readers of the ledger were the dashboard,
6//! telemetry, and `serve`. For a staged agent the per-stage split *is* the
7//! diagnosis - it is how an output stage billing 252,848 prompt tokens to emit
8//! three characters was found, and how a profile stage that had run away to
9//! 1.1M was. Read-only; sources everything from disk.
10
11use clap::Args;
12use leviath_core::run_meta::StageRecord;
13
14/// Arguments for `lev stages`.
15#[derive(Args, Debug)]
16pub struct StagesArgs {
17    /// The run id whose stage ledger to show.
18    pub run_id: String,
19    /// Print the ledger as JSON instead of a table.
20    #[arg(long)]
21    pub json: bool,
22    /// Include each stage's per-region token high-water marks.
23    #[arg(long)]
24    pub regions: bool,
25}
26
27/// Execute `lev stages`.
28pub async fn execute(args: StagesArgs) -> anyhow::Result<()> {
29    let stages = crate::runstate::read_stages_index(&args.run_id);
30    if stages.is_empty() {
31        anyhow::bail!(
32            "no stage ledger for run '{}' (no readable stages.json)",
33            args.run_id
34        );
35    }
36    match args.json {
37        true => println!(
38            "{}",
39            serde_json::to_string_pretty(&stages).expect("a stage ledger serializes")
40        ),
41        false => print_ledger(&stages, args.regions),
42    }
43    Ok(())
44}
45
46/// The table `lev stages` prints. Split out so its shape is assertable without
47/// capturing stdout.
48fn print_ledger(stages: &[StageRecord], with_regions: bool) {
49    println!(
50        "{:<20} {:<10} {:>10} {:>10} {:>10} {:>10}",
51        "STAGE", "STATUS", "PROMPT", "OUTPUT", "CACHE RD", "CACHE WR"
52    );
53    for stage in stages {
54        println!(
55            "{:<20} {:<10} {:>10} {:>10} {:>10} {:>10}",
56            truncate(&stage.name, 20),
57            format!("{:?}", stage.status).to_lowercase(),
58            stage.prompt_tokens,
59            stage.completion_tokens,
60            stage.cached_tokens,
61            stage.cache_write_tokens,
62        );
63        if with_regions {
64            // Largest first: the question this answers is which region is worth
65            // its place, and that is decided at the top of the list.
66            let mut regions: Vec<(&String, &usize)> = stage.region_tokens.iter().collect();
67            regions.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
68            for (name, tokens) in regions {
69                println!("  {:<18} {:>42}", truncate(name, 18), tokens);
70            }
71        }
72    }
73
74    let prompt: usize = stages.iter().map(|s| s.prompt_tokens).sum();
75    let output: usize = stages.iter().map(|s| s.completion_tokens).sum();
76    let read: usize = stages.iter().map(|s| s.cached_tokens).sum();
77    let written: usize = stages.iter().map(|s| s.cache_write_tokens).sum();
78    println!(
79        "{:<20} {:<10} {:>10} {:>10} {:>10} {:>10}",
80        "TOTAL", "", prompt, output, read, written
81    );
82}
83
84/// `s` cut to `width` **characters**.
85///
86/// Counted in characters rather than bytes because the width is a column count
87/// in a table, and a byte cut would both misalign the column and land
88/// mid-codepoint on any non-ASCII stage or region name.
89fn truncate(s: &str, width: usize) -> String {
90    match s.chars().count() > width {
91        true => s.chars().take(width).collect(),
92        false => s.to_string(),
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use leviath_core::run_meta::{StageRecord, StageRunStatus};
100
101    fn record(name: &str, prompt: usize) -> StageRecord {
102        let mut r = StageRecord::new(name.to_string(), 0);
103        r.prompt_tokens = prompt;
104        r.completion_tokens = 10;
105        r.cached_tokens = 5;
106        r.cache_write_tokens = 7;
107        r.status = StageRunStatus::Complete;
108        r
109    }
110
111    #[test]
112    fn the_ledger_prints_without_regions() {
113        print_ledger(&[record("ingest", 100)], false);
114    }
115
116    #[test]
117    fn the_ledger_prints_region_sizes_largest_first() {
118        let mut r = record("compute", 100);
119        r.region_tokens.insert("small".to_string(), 10);
120        r.region_tokens.insert("data_preview".to_string(), 6692);
121        // Two the same size, so the name tie-break is a real comparison rather
122        // than a branch that can never run: without it the row order would
123        // shuffle between runs of the same command.
124        r.region_tokens.insert("alpha".to_string(), 42);
125        r.region_tokens.insert("beta".to_string(), 42);
126        print_ledger(&[r], true);
127    }
128
129    /// A long stage name is cut rather than breaking the columns, and cut on a
130    /// char boundary - region and stage names are author-supplied text.
131    #[test]
132    fn a_long_name_is_truncated_on_a_char_boundary() {
133        assert_eq!(truncate("short", 20), "short");
134        assert_eq!(truncate(&"é".repeat(30), 5).chars().count(), 5);
135    }
136
137    /// A run whose ledger is on disk, in an isolated runs dir.
138    async fn with_ledger<R, Fut>(unique: &str, f: impl FnOnce(String) -> Fut) -> R
139    where
140        Fut: std::future::Future<Output = R>,
141    {
142        crate::runstate::with_isolated_runs_dir_async(unique, |base| async move {
143            let run_id = "run-1";
144            let dir = base.join("runs").join(run_id);
145            std::fs::create_dir_all(&dir).expect("runs dir");
146            let mut rec = record("ingest", 16_832);
147            rec.region_tokens.insert("data_preview".to_string(), 6692);
148            let json = serde_json::to_string(&[rec]).expect("serializes");
149            std::fs::write(dir.join("stages.json"), json).expect("write");
150            f(run_id.to_string()).await
151        })
152        .await
153    }
154
155    #[tokio::test]
156    async fn the_table_reads_a_real_ledger() {
157        with_ledger("stages-table", |run_id| async move {
158            execute(StagesArgs {
159                run_id,
160                json: false,
161                regions: true,
162            })
163            .await
164            .expect("a ledger on disk is readable");
165        })
166        .await;
167    }
168
169    #[tokio::test]
170    async fn the_json_form_reads_the_same_ledger() {
171        with_ledger("stages-json", |run_id| async move {
172            execute(StagesArgs {
173                run_id,
174                json: true,
175                regions: false,
176            })
177            .await
178            .expect("json is the same read, printed differently");
179        })
180        .await;
181    }
182
183    #[tokio::test]
184    async fn a_run_with_no_ledger_is_an_error_rather_than_an_empty_table() {
185        let err = execute(StagesArgs {
186            run_id: "no-such-run".to_string(),
187            json: false,
188            regions: false,
189        })
190        .await
191        .expect_err("a missing ledger is worth saying");
192        assert!(err.to_string().contains("no stage ledger"), "{err}");
193    }
194}