Skip to main content

cmd_usage/arkuse/
render.rs

1// arkuse's presentation: a watch frame, a one-shot dashboard, a status line, and
2// JSON.
3//
4// Reuses cmduse-core's pure formatters (compact/pct/duration) and cmduse's own
5// palette, gauge and sparkline, so all three binaries read the same. ARK's unit
6// is AFP — the plan's own currency — so no row prints `money()`'s `$`; yuan
7// appears only where a price is meant (1 AFP = ¥0.002 at every tier).
8use crate::arkuse::ark;
9use crate::arkuse::window::{Report, Totals};
10use crate::report_render::Fmt;
11use cmduse_core::{compact, duration, pct};
12
13/// Gauge width for the window bars when neither config nor `--bar-width` says
14/// otherwise — cmduse's own default.
15pub const DEFAULT_BAR_WIDTH: usize = 20;
16
17/// ANSI styles — cmduse's palette, or empty strings when colour is off, so a
18/// piped run never leaks SGR escapes.
19pub struct Ink {
20    pub colour: bool,
21    /// Gauge width for the window bars (config / `--bar-width`).
22    pub bar_width: usize,
23    pub bold: &'static str,
24    pub dim: &'static str,
25    pub reset: &'static str,
26    pub cyan: &'static str,
27    pub green: &'static str,
28    pub yellow: &'static str,
29    pub red: &'static str,
30}
31
32impl Ink {
33    pub fn new(colour: bool) -> Self {
34        if colour {
35            Self {
36                colour,
37                bar_width: DEFAULT_BAR_WIDTH,
38                bold: crate::render::BOLD,
39                dim: crate::render::DIM,
40                reset: crate::render::RESET,
41                cyan: crate::render::CYAN,
42                green: crate::render::GREEN,
43                yellow: crate::render::YELLOW,
44                red: crate::render::RED,
45            }
46        } else {
47            Self {
48                colour,
49                bar_width: DEFAULT_BAR_WIDTH,
50                bold: "",
51                dim: "",
52                reset: "",
53                cyan: "",
54                green: "",
55                yellow: "",
56                red: "",
57            }
58        }
59    }
60
61    /// Override the gauge width (config / `--bar-width`); chains after `new`.
62    pub fn width(mut self, width: usize) -> Self {
63        self.bar_width = width;
64        self
65    }
66
67    fn cyan_on(&self, text: String) -> String {
68        format!("{}{text}{}", self.cyan, self.reset)
69    }
70
71    fn dim_on(&self, text: String) -> String {
72        format!("{}{text}{}", self.dim, self.reset)
73    }
74
75    fn bold_on(&self, text: String) -> String {
76        format!("{}{text}{}", self.bold, self.reset)
77    }
78
79    /// Severity for a used/limit percentage — cmduse's thresholds.
80    fn for_pct(&self, pct_used: f64) -> &'static str {
81        if !self.colour {
82            ""
83        } else if pct_used >= 90.0 {
84            self.red
85        } else if pct_used >= 70.0 {
86            self.yellow
87        } else {
88            self.green
89        }
90    }
91
92    /// The percentage plus cmduse's gauge, and the row's only percentage in
93    /// colour mode — the body drops its own `x% of limit` so the figure is not
94    /// printed twice. The label is passed in because ARK's meter is the control
95    /// plane's own figure (`given_pct`, so `53` never becomes `53.0`) while a
96    /// local estimate carries our one decimal. Empty when colour is off, so the
97    /// plain frame keeps its script-friendly column layout and its own share.
98    fn bar_label(&self, pct_used: f64, label: &str) -> String {
99        if !self.colour {
100            return String::new();
101        }
102        format!(
103            "{}{label:>5}%{} {}",
104            self.for_pct(pct_used),
105            self.reset,
106            crate::render::gauge(pct_used, 100.0, self.bar_width),
107        )
108    }
109}
110
111/// AFP with thousands separators and no decimals. AFP is a countable quota, not
112/// a currency: a tenth of a point is below the control plane's own noise (it
113/// reports fractions, the console floors them).
114pub fn afp(value: f64) -> String {
115    let rounded = value.round() as i64;
116    let digits = rounded.unsigned_abs().to_string();
117    let mut out = String::new();
118    for (i, ch) in digits.chars().enumerate() {
119        if i > 0 && (digits.len() - i).is_multiple_of(3) {
120            out.push(',');
121        }
122        out.push(ch);
123    }
124    if rounded < 0 {
125        format!("-{out}")
126    } else {
127        out
128    }
129}
130
131/// Yuan, for prices only — never for metered usage, which is AFP.
132pub fn yuan(value: f64) -> String {
133    format!("¥{:.2}", (value * 100.0).round() / 100.0)
134}
135
136/// A percentage exactly as the control plane published it. ARK serves fractions
137/// (`29.756678`), so this keeps one decimal; local percentages keep `pct`'s own
138/// formatting.
139fn given_pct(value: f64) -> String {
140    if (value - value.round()).abs() < 1e-9 {
141        format!("{}", value.round() as i64)
142    } else {
143        format!("{value:.1}")
144    }
145}
146
147/// `YYYY-MM-DD` for a Unix day number — `cmduse_core::dates::civil_from_days`,
148/// so the three binaries cannot drift on the calendar.
149pub fn date_of(day: i64) -> String {
150    cmduse_core::dates::civil_from_days(day)
151}
152
153fn shorten(path: &str) -> String {
154    match std::env::var("HOME") {
155        Ok(home) if path.starts_with(&home) => format!("~{}", &path[home.len()..]),
156        _ => path.to_string(),
157    }
158}
159
160/// The account meter's window boundary: its reset instant and the window's
161/// length, so the row can measure elapsed share and pace on the server's clock.
162fn meter_reset(meter: &crate::arkuse::api::Meter, dur_secs: Option<u64>) -> Option<(i64, u64)> {
163    match (meter.reset_ms(), dur_secs) {
164        (Some(at), Some(dur)) if dur > 0 => Some((at, dur)),
165        _ => None,
166    }
167}
168
169/// A locally cut window's boundary: the oldest row still inside the cut plus the
170/// window's length, matching the Go side's rolling reset. `None` when the window
171/// holds no rows, because `now + length` is not a reset the data supports.
172fn rolling(report: &Report, totals: &Totals, dur_secs: i64) -> Option<(i64, u64)> {
173    report
174        .rolling_reset(totals, dur_secs)
175        .map(|at| (at, dur_secs as u64))
176}
177
178#[allow(clippy::too_many_arguments)]
179/// One window row's inputs. The control plane's meters and the local estimate
180/// both go through this one builder, so the two branches cannot drift apart in
181/// which fields a row carries or in the order it carries them.
182///
183/// Unlike Go, ARK publishes the quota itself (`total`), so the row can print a
184/// true `used / quota` pair and a pace against it. The local branch has no
185/// quota — it shows AFP alone, never a denominator this store cannot know.
186struct RowSpec<'a> {
187    label: &'a str,
188    /// This store's own totals for the window.
189    used: &'a Totals,
190    /// The account's meter, when it answered.
191    meter: Option<&'a crate::arkuse::api::Meter>,
192    /// The window's reset instant and its length in seconds.
193    reset: Option<(i64, u64)>,
194    /// The plan's published quota for this window, used only when no meter
195    /// answered — ARK publishes the split, so the local estimate can print the
196    /// same `used / quota` pair the console does instead of AFP alone.
197    quota: Option<f64>,
198    /// Whether an even-burn figure belongs on this row: the monthly window alone
199    /// is a budget, where "what even burn would have spent by now" is a fair
200    /// comparison; the throttles above it would read as a spend target.
201    pro_rata: bool,
202}
203
204fn window_line(ink: &Ink, spec: &RowSpec<'_>, now_ms: i64, catalog: &ark::Catalog) -> String {
205    let now_secs = (now_ms / 1000).max(0) as u64;
206    let reset_at = spec.reset.map(|(at, _)| at as f64);
207    let dur_secs = spec.reset.map(|(_, dur)| dur);
208    let elapsed = dur_secs.and_then(|d| cmduse_core::elapsed_pct(reset_at, d, Some(now_secs)));
209    let reset = spec
210        .reset
211        .map(|(at, _)| duration(((at - now_ms) / 1000).max(0) as u64))
212        .unwrap_or_default();
213    let reset_text = if reset.is_empty() {
214        String::new()
215    } else if ink.colour {
216        format!(" · resets in {reset}")
217    } else {
218        format!(" · resets {reset}")
219    };
220    let thru = elapsed
221        .map(|p| format!(" · {}window {p:.1}% elapsed{}", ink.dim, ink.reset))
222        .unwrap_or_default();
223    // This store's AFP against the plan's published quota: the local branch's
224    // percentage. It under-reads the console (the meter counts every client and
225    // bills at its own coefficient), which is why the row says `est.`.
226    let local_pct = spec
227        .quota
228        .filter(|quota| *quota > 0.0)
229        .map(|quota| spec.used.afp / quota * 100.0);
230
231    // Server branch: the quota is the server's own, so the pair and the pace are
232    // exact. Local branch: the catalogue's quota, labelled as the estimate it is.
233    let (body, tone, pct) = match spec.meter {
234        Some(meter) => {
235            let pace = spec
236                .reset
237                .and_then(|(at, d)| {
238                    cmduse_core::pace_eta(Some(at as f64), d, meter.used, meter.quota, now_secs)
239                        .map(|secs| duration(secs as u64))
240                })
241                .map(|eta| format!(" · {}on pace to hit cap in {eta}{}", ink.yellow, ink.reset))
242                .unwrap_or_default();
243            let by_now = match (spec.pro_rata, elapsed) {
244                (true, Some(p)) => format!(
245                    " · {}{} by now{}",
246                    ink.dim,
247                    afp(meter.quota * p / 100.0),
248                    ink.reset
249                ),
250                _ => String::new(),
251            };
252            let flag = if meter.exceeded() {
253                format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
254            } else if !meter.status.eq_ignore_ascii_case("ok") {
255                format!(" {}{}{}{}", ink.red, meter.status, ink.reset, "")
256            } else {
257                String::new()
258            };
259            let pct_tone = ink.for_pct(meter.percent);
260            let share = if ink.colour {
261                // The gauge already carries the share; printing it again here is
262                // the duplication this row used to read as `28.5% ━━━ 28.5% of limit`.
263                String::new()
264            } else {
265                format!(
266                    " · {}{}% of limit{}",
267                    pct_tone,
268                    given_pct(meter.percent),
269                    ink.reset
270                )
271            };
272            let tail = format!("{reset_text}{flag}{pace}{thru}{by_now}");
273            let body = format!(
274                "{} / {} AFP{share} · {}{} local{}{tail}",
275                afp(meter.used),
276                afp(meter.quota),
277                ink.dim,
278                afp(spec.used.afp),
279                ink.reset,
280            );
281            (body, pct_tone, Some(meter.percent))
282        }
283        None => {
284            let quota = spec.quota.filter(|quota| *quota > 0.0);
285            let head = match (quota, local_pct) {
286                (Some(quota), Some(p)) => {
287                    let share = if ink.colour {
288                        format!("{}(est.){}", ink.dim, ink.reset)
289                    } else {
290                        format!(" · {}{p:.1}% of limit (est.){}", ink.for_pct(p), ink.reset)
291                    };
292                    format!("{} / {} AFP{share}", afp(spec.used.afp), afp(quota),)
293                }
294                _ => format!("{} AFP", afp(spec.used.afp)),
295            };
296            let pace = match (spec.reset, quota, local_pct) {
297                (Some((at, d)), Some(quota), Some(_)) => {
298                    cmduse_core::pace_eta(Some(at as f64), d, spec.used.afp, quota, now_secs)
299                        .map(|secs| {
300                            format!(
301                                " · {}on pace to hit cap in {}{}",
302                                ink.yellow,
303                                duration(secs as u64),
304                                ink.reset
305                            )
306                        })
307                        .unwrap_or_default()
308                }
309                _ => String::new(),
310            };
311            let by_now = match (spec.pro_rata, quota, elapsed) {
312                (true, Some(quota), Some(p)) => {
313                    format!(
314                        " · {}{} by now{}",
315                        ink.dim,
316                        afp(quota * p / 100.0),
317                        ink.reset
318                    )
319                }
320                _ => String::new(),
321            };
322            let body = format!(
323                "{head} · {}{} · {} requests{}{pace}{reset_text}{thru}{by_now}",
324                ink.dim,
325                yuan(catalog.yuan(spec.used.afp)),
326                compact(spec.used.requests),
327                ink.reset,
328            );
329            (body, ink.for_pct(local_pct.unwrap_or(0.0)), local_pct)
330        }
331    };
332    if ink.colour {
333        let bar = pct
334            .map(|p| {
335                let label = match spec.meter {
336                    Some(meter) => given_pct(meter.percent),
337                    None => format!("{p:.1}"),
338                };
339                format!("{} ", ink.bar_label(p, &label))
340            })
341            .unwrap_or_default();
342        format!(
343            " {}{:<8}{} {}{}{body}{}\n",
344            ink.bold, spec.label, ink.reset, bar, tone, ink.reset,
345        )
346    } else {
347        format!("  {:<8} {body}\n", spec.label)
348    }
349}
350
351/// One model's row: id, coefficient in force, AFP and requests this period. No
352/// per-model limit exists on ARK — the quota is plan-level — so there is no USE
353/// column to invent. The share of *period* AFP moved to `top_models`, so this
354/// row states only what this model itself did.
355fn model_row(ink: &Ink, model: &crate::arkuse::window::ModelUsage) -> String {
356    let coef = format!("{:>5.2}", model.coef);
357    let coef_cell = if ink.colour {
358        format!("{}{coef}{}", ink.cyan, ink.reset)
359    } else {
360        coef
361    };
362    format!(
363        "{:<28} {coef_cell} {:>9} {:>7}\n",
364        model.id,
365        afp(model.month.afp),
366        compact(model.month.requests),
367    )
368}
369
370/// The period's three biggest models, with their share of *all* use — month AFP
371/// in one column pair, tokens (input + output) in the other.
372///
373/// The share used to hang off each model row as a single trailing `%` of AFP.
374/// It moved here because a share only means something against the whole, and
375/// the whole is what this block states; the row table below is per model with no
376/// limit to divide by (the quota is plan-level, not per model). Tokens are
377/// counted without cache reads: those run an order of magnitude larger and
378/// would make every AFP share look like a rounding error.
379fn top_models(ink: &Ink, report: &Report, count: usize) -> String {
380    let tokens = |model: &crate::arkuse::window::ModelUsage| model.month.input + model.month.output;
381    let total_afp = report.month.afp;
382    let total_tokens: u64 = report.models.iter().map(tokens).sum();
383    if report.models.is_empty() || (total_afp <= 0.0 && total_tokens == 0) {
384        return String::new();
385    }
386    let mut out = format!(
387        "\n{}TOP {} MODELS{} {} {} {} {}\n",
388        ink.bold,
389        count,
390        ink.reset,
391        ink.dim_on(format!("{:<15}", "by month AFP")),
392        ink.dim_on(format!("{:>9}", "AFP")),
393        ink.dim_on(format!("{:>8}", "SHARE")),
394        ink.dim_on(format!("{:>10} {:>8}", "TOKENS", "SHARE")),
395    );
396    for model in report.models.iter().take(count) {
397        let afp_cell = ink.cyan_on(format!(
398            "{:>9} {:>8}",
399            afp(model.month.afp),
400            pct(model.month.afp, total_afp),
401        ));
402        out.push_str(&format!(
403            "{:<28} {afp_cell} {:>10} {:>8}\n",
404            model.id,
405            compact(tokens(model)),
406            pct(tokens(model) as f64, total_tokens as f64),
407        ));
408    }
409    out
410}
411
412/// `limit` rows to show; `usize::MAX` for all.
413pub fn render_text(
414    report: &Report,
415    db_path: &str,
416    rows: usize,
417    colour: bool,
418    bar_width: usize,
419) -> String {
420    let ink = Ink::new(colour).width(bar_width);
421    let catalog = ark::catalog();
422    let c = |text: String| ink.cyan_on(text);
423    let mut out = String::new();
424    let tier_label = catalog
425        .plan(&report.tier)
426        .map(|p| p.label.clone())
427        .unwrap_or_else(|| report.tier.clone());
428    // The extra local clients on the same plan, named beside the store: their
429    // calls are in the AFP below, so the header has to say so.
430    let extra: Vec<&str> = report
431        .clients
432        .iter()
433        .map(String::as_str)
434        .filter(|client| *client != crate::stores::OPENCODE)
435        .collect();
436    let extra = if extra.is_empty() {
437        String::new()
438    } else {
439        format!(" + {}", extra.join(", "))
440    };
441    out.push_str(&format!(
442        "{}Volcengine ARK usage{} — agent plan {}, from {}{}{}{}\n",
443        ink.bold,
444        ink.reset,
445        tier_label,
446        ink.dim,
447        shorten(db_path),
448        extra,
449        ink.reset,
450    ));
451    let end = report
452        .period_end_ms
453        .map(|ms| {
454            format!(
455                " · ends {}",
456                date_of((ms + report.offset_secs * 1000).div_euclid(86_400_000))
457            )
458        })
459        .unwrap_or_default();
460    let start = date_of((report.since_ms + report.offset_secs * 1000).div_euclid(86_400_000));
461    // The period line states the one share the whole dashboard is about: how
462    // much of the plan's monthly quota is gone. It is the control plane's own
463    // number, so it prints as it arrived (`53`, never `53.0`) and is absent —
464    // not estimated — when no account answered, because the local cut has no
465    // quota of its own to divide by.
466    let used = report
467        .account
468        .as_ref()
469        .map(|account| format!(" · {}% of limit", c(given_pct(account.monthly.percent))))
470        .unwrap_or_default();
471    out.push_str(&format!(
472        "period: {start}{end} · {} requests · {} AFP ({}) · {} in / {} out tokens{used}\n",
473        c(compact(report.month.requests)),
474        c(afp(report.month.afp)),
475        c(yuan(catalog.yuan(report.month.afp))),
476        c(compact(report.month.input)),
477        c(compact(report.month.output)),
478    ));
479    if let Some(plan) = catalog.plan(&report.tier) {
480        out.push_str(&format!(
481            "{}plan: {} {} · {} AFP/mo · {} · {} windows{}",
482            ink.dim,
483            plan.label,
484            yuan(plan.price),
485            afp(plan.afp),
486            catalog.currency,
487            if report.account.is_some() {
488                "metered by the control plane"
489            } else {
490                "local estimate"
491            },
492            ink.reset,
493        ));
494        out.push('\n');
495    }
496
497    match &report.account {
498        Some(account) => {
499            out.push_str(&format!(
500                "\n{}AGENT PLAN{} {}\n",
501                ink.bold,
502                ink.reset,
503                ink.dim_on(
504                    "(account meters — what the Volcengine console reports; AFP beside them is this store's own estimate)"
505                        .into()
506                ),
507            ));
508            let monthly_dur = report
509                .period_end_ms
510                .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
511            let daily_local = report.daily.clone().unwrap_or_default();
512            let mut windows: Vec<(&str, &crate::arkuse::api::Meter, &Totals, Option<u64>, bool)> =
513                vec![(
514                    "5-hour",
515                    &account.five_hour,
516                    &report.five_hour,
517                    Some(crate::arkuse::window::FIVE_HOUR_SECS as u64),
518                    false,
519                )];
520            if let Some(daily) = &account.daily {
521                windows.push((
522                    "Daily",
523                    daily,
524                    &daily_local,
525                    Some(crate::arkuse::window::DAY_SECS as u64),
526                    false,
527                ));
528            }
529            windows.push((
530                "Weekly",
531                &account.weekly,
532                &report.weekly,
533                Some(crate::arkuse::window::WEEKLY_SECS as u64),
534                false,
535            ));
536            windows.push((
537                "Monthly",
538                &account.monthly,
539                &report.month,
540                monthly_dur,
541                true,
542            ));
543            for (label, meter, used, dur, pro_rata) in windows {
544                // A just-reset block answers without `reset_at` (measured
545                // 2026-10-20: `{"label":"5h","total":10000,"percent":0}`), so
546                // fall back to the store's own rolling boundary rather than
547                // drop the countdown the row is asked for.
548                let reset = meter_reset(meter, dur)
549                    .or_else(|| dur.and_then(|dur| rolling(report, used, dur as i64)));
550                out.push_str(&window_line(
551                    &ink,
552                    &RowSpec {
553                        label,
554                        used,
555                        meter: Some(meter),
556                        reset,
557                        quota: None,
558                        pro_rata,
559                    },
560                    report.now_ms,
561                    &catalog,
562                ));
563            }
564            // Reconciliation is the point of holding both sources: the meter is
565            // account-wide, the store is this machine, so a gap is either another
566            // client or an unmodelled item — worth printing, never hidden.
567            let mut notes: Vec<String> = Vec::new();
568            if let Some(delta) = report.reconcile() {
569                notes.push(format!("local vs meter {:+.1}%", delta));
570            }
571            if let Some(per) = report.afp_per_request() {
572                notes.push(format!("{} AFP/request", afp(per)));
573            }
574            if let Some(burn) = report.burn_per_day() {
575                notes.push(format!("{} AFP/day at this pace", afp(burn)));
576            }
577            if !notes.is_empty() {
578                out.push_str(&format!(
579                    "{}    {} · {}{}\n",
580                    ink.dim,
581                    account.profile,
582                    notes.join(" · "),
583                    ink.reset,
584                ));
585            }
586        }
587        None => {
588            out.push_str(&format!(
589                "\n{}AGENT PLAN{} {}\n",
590                ink.bold,
591                ink.reset,
592                ink.dim_on("(estimated locally: no control-plane answer, so AFP alone)".into()),
593            ));
594            if let Some(why) = &report.account_error {
595                out.push_str(&format!("{}   usage API: {why}{}\n", ink.dim, ink.reset));
596            }
597            let monthly_dur = report
598                .period_end_ms
599                .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
600            // No meter: the catalogue's published split stands in for the
601            // quota, and the reset comes from the oldest row still inside the
602            // rolling cut — never a fabricated `now + length`.
603            let five_reset = rolling(
604                report,
605                &report.five_hour,
606                crate::arkuse::window::FIVE_HOUR_SECS,
607            );
608            let week_reset = rolling(report, &report.weekly, crate::arkuse::window::WEEKLY_SECS);
609            out.push_str(&window_line(
610                &ink,
611                &RowSpec {
612                    label: "5-hour",
613                    used: &report.five_hour,
614                    meter: None,
615                    reset: five_reset,
616                    quota: report.window_quota(&catalog, catalog.windows.five_hour),
617                    pro_rata: false,
618                },
619                report.now_ms,
620                &catalog,
621            ));
622            out.push_str(&window_line(
623                &ink,
624                &RowSpec {
625                    label: "Weekly",
626                    used: &report.weekly,
627                    meter: None,
628                    reset: week_reset,
629                    quota: report.window_quota(&catalog, catalog.windows.weekly),
630                    pro_rata: false,
631                },
632                report.now_ms,
633                &catalog,
634            ));
635            out.push_str(&window_line(
636                &ink,
637                &RowSpec {
638                    label: "Monthly",
639                    used: &report.month,
640                    meter: None,
641                    reset: report.period_end_ms.zip(monthly_dur),
642                    quota: report.window_quota(&catalog, catalog.windows.monthly),
643                    pro_rata: true,
644                },
645                report.now_ms,
646                &catalog,
647            ));
648        }
649    }
650
651    out.push_str(&top_models(&ink, report, 3));
652    // The header carries the row's own widths so the two cannot drift: every
653    // label is right-aligned in the field its cell occupies, and the name field
654    // is left-aligned to the model id.
655    out.push_str(&format!(
656        "\n{}{:<28} {:>5} {:>9} {:>7}{}\n",
657        ink.bold, "MODEL", "COEF", "AFP", "REQ", ink.reset
658    ));
659    for model in report.models.iter().take(rows) {
660        out.push_str(&model_row(&ink, model));
661    }
662    if report.models.len() > rows {
663        out.push_str(&format!(
664            "{}\n",
665            ink.dim_on(format!("… {} more", report.models.len() - rows))
666        ));
667    }
668    // The subtlety that makes local AFP disagree with the meter: composition.
669    out.push_str(&format!(
670        "\n{}\n",
671        ink.dim_on(
672            "AFP = (input + cache_read + output) × coefficient / 10,000; the coefficient is the plan's own and a promo halves it until its date."
673                .into()
674        ),
675    ));
676    if report.account.is_some() {
677        out.push_str(&format!(
678            "{}\n",
679            ink.dim_on(
680                "The windows are the account's own cuts (5h block, Monday week, subscription month); the model AFP is this machine inside them."
681                    .into()
682            )
683        ));
684    } else {
685        out.push_str(&format!(
686            "{}\n",
687            ink.dim_on(
688                "Windows are rolling 5h / 7d / calendar month cuts, local only — not the control plane's anchors."
689                    .into()
690            )
691        ));
692    }
693    out
694}
695
696/// One watch frame: the dashboard, then an AFP-burst sparkline when the 5-hour
697/// figure has moved since the previous refresh. No trailing newline — the caller
698/// appends its own status line and `redraw_frame` (cmduse's) parks the cursor
699/// there, which is what lets the countdown rewrite a single line per second.
700pub fn watch_frame(
701    report: &Report,
702    db_path: &str,
703    rows: usize,
704    colour: bool,
705    history: &[f64],
706    interval_secs: u64,
707    bar_width: usize,
708) -> String {
709    let ink = Ink::new(colour);
710    let mut out = render_text(report, db_path, rows, colour, bar_width);
711    if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
712        out.push_str(&format!(
713            "{}AFP bursts ({}s samples){} {}\n",
714            ink.dim,
715            interval_secs,
716            ink.reset,
717            crate::render::sparkline(history),
718        ));
719    }
720    out.trim_end_matches('\n').to_string()
721}
722
723/// Minimal one-line dashboard for terminals too small for the full frame.
724pub fn compact_line(report: &Report, history: &[f64], colour: bool) -> String {
725    let ink = Ink::new(colour);
726    let catalog = ark::catalog();
727    let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
728        format!(
729            " {}{}{}",
730            ink.dim,
731            crate::render::sparkline(history),
732            ink.reset
733        )
734    } else {
735        String::new()
736    };
737    let window_share = match &report.account {
738        Some(account) => format!(
739            " · 5h {} · wk {} · mo {}",
740            ink.cyan_on(format!("{}%", given_pct(account.five_hour.percent))),
741            ink.cyan_on(format!("{}%", given_pct(account.weekly.percent))),
742            ink.cyan_on(format!("{}%", given_pct(account.monthly.percent))),
743        ),
744        None => format!(" · 5h {}", ink.cyan_on(afp(report.five_hour.afp))),
745    };
746    format!(
747        "{}ARK{} {} AFP ({}) month{window_share}{spark}",
748        ink.bold,
749        ink.reset,
750        ink.cyan_on(afp(report.month.afp)),
751        ink.cyan_on(yuan(catalog.yuan(report.month.afp))),
752    )
753}
754
755/// arkuse's statusline: cmduse's `{key}` template engine
756/// (`report_render::render_template`) resolved against this report's own
757/// placeholders.
758///
759/// Placeholders (case-insensitive):
760///   {plan} {tier} {period}                 tier id / period label
761///   {month} {month_pct} {month_bar}
762///   {five_hour} {five_hour_pct} {five_hour_bar}   (aliases {5h} {5h_pct} {5h_bar})
763///   {weekly} {weekly_pct} {weekly_bar}            (aliases {wk} {wk_pct} {wk_bar})
764///   {yuan} {yuan_month}                    this store's AFP in yuan
765///   {models}                               top model by period AFP
766pub fn statusline_template(
767    report: &Report,
768    plan_id: &str,
769    tpl: &str,
770    colours: bool,
771    ascii: bool,
772    bar_width: usize,
773) -> String {
774    let ink = Ink::new(colours);
775    let catalog = ark::catalog();
776    let account = report.account.as_ref();
777    let quota_of = |share: f64| report.window_quota(&catalog, share);
778    // The console's own percentage when it answered, else this store's AFP
779    // against the plan's published split — never a dash for a window we can
780    // measure ourselves. The plan's quota is public, so this is not a guess.
781    let pct_of = |meter: Option<&crate::arkuse::api::Meter>, used: f64, share: f64| match meter {
782        Some(meter) => Some(meter.percent),
783        None => quota_of(share)
784            .filter(|quota| *quota > 0.0)
785            .map(|quota| used / quota * 100.0),
786    };
787    let share_of = |pct: Option<f64>| match pct {
788        Some(pct) => format!("{}%", given_pct(pct)),
789        None => "—".into(),
790    };
791    let share_bar = |pct: Option<f64>| match pct {
792        Some(pct) => crate::report_render::bar(pct, bar_width, ascii, colours),
793        None => String::new(),
794    };
795    // A window's boundary: the server's reset, else the oldest row still inside
796    // the rolling cut plus the window's length.
797    let reset_of =
798        |meter: Option<&crate::arkuse::api::Meter>, totals: &Totals, dur: i64| match meter
799            .and_then(|m| m.reset_ms())
800        {
801            Some(at) => Some((at, dur as u64)),
802            None => report.rolling_reset(totals, dur).map(|at| (at, dur as u64)),
803        };
804    let eta_of = |reset: Option<(i64, u64)>, used: f64, quota: Option<f64>| -> Option<String> {
805        let (at, dur) = reset?;
806        let quota = quota?;
807        let secs = cmduse_core::pace_eta(
808            Some(at as f64),
809            dur,
810            used,
811            quota,
812            (report.now_ms / 1000).max(0) as u64,
813        )?;
814        Some(format!("on pace to hit cap in {}", duration(secs as u64)))
815    };
816    let h5_pct = pct_of(
817        account.map(|a| &a.five_hour),
818        report.five_hour.afp,
819        catalog.windows.five_hour,
820    );
821    let wk_pct = pct_of(
822        account.map(|a| &a.weekly),
823        report.weekly.afp,
824        catalog.windows.weekly,
825    );
826    let month_pct = pct_of(
827        account.map(|a| &a.monthly),
828        report.month.afp,
829        catalog.windows.monthly,
830    );
831    let h5_eta = eta_of(
832        reset_of(
833            account.map(|a| &a.five_hour),
834            &report.five_hour,
835            crate::arkuse::window::FIVE_HOUR_SECS,
836        ),
837        report.five_hour.afp,
838        quota_of(catalog.windows.five_hour),
839    )
840    .unwrap_or_default();
841    let wk_eta = eta_of(
842        reset_of(
843            account.map(|a| &a.weekly),
844            &report.weekly,
845            crate::arkuse::window::WEEKLY_SECS,
846        ),
847        report.weekly.afp,
848        quota_of(catalog.windows.weekly),
849    )
850    .unwrap_or_default();
851    let cash = |value: f64| ink.cyan_on(afp(value));
852    let top = report
853        .models
854        .first()
855        .map(|m| m.id.clone())
856        .unwrap_or_else(|| "—".into());
857    let replacements: Vec<(&str, String)> = vec![
858        ("plan", plan_id.to_string()),
859        ("tier", plan_id.to_string()),
860        ("period", report.period_label.clone()),
861        ("month", cash(report.month.afp)),
862        ("month_pct", share_of(month_pct)),
863        ("month_bar", share_bar(month_pct)),
864        ("five_hour", cash(report.five_hour.afp)),
865        ("5h", cash(report.five_hour.afp)),
866        ("five_hour_pct", share_of(h5_pct)),
867        ("5h_pct", share_of(h5_pct)),
868        ("five_hour_bar", share_bar(h5_pct)),
869        ("5h_bar", share_bar(h5_pct)),
870        ("5h_eta", h5_eta),
871        ("weekly", cash(report.weekly.afp)),
872        ("wk", cash(report.weekly.afp)),
873        ("weekly_pct", share_of(wk_pct)),
874        ("wk_pct", share_of(wk_pct)),
875        ("weekly_bar", share_bar(wk_pct)),
876        ("wk_bar", share_bar(wk_pct)),
877        ("wk_eta", wk_eta),
878        ("yuan", yuan(catalog.yuan(report.month.afp))),
879        ("yuan_month", yuan(catalog.yuan(report.month.afp))),
880        ("models", top),
881    ];
882    crate::report_render::render_template(tpl, |key| {
883        let lower = key.to_ascii_lowercase();
884        replacements
885            .iter()
886            .find(|(name, _)| *name == lower)
887            .map(|(_, value)| value.clone())
888            .unwrap_or_default()
889    })
890}
891
892pub fn statusline_json(report: &Report, plan_id: &str) -> String {
893    let catalog = ark::catalog();
894    let quota_of = |share: f64| report.window_quota(&catalog, share);
895    serde_json::json!({
896        "plan": plan_id,
897        "period": report.period_label,
898        "source": "opencode",
899        // The shared names a statusline consumer already reads; here they carry
900        // AFP, because AFP is this plan's currency. The caps are the plan's
901        // published split of its monthly AFP, so a consumer never has to hold a
902        // table of its own.
903        "monthSpent": report.month.afp,
904        "monthCap": quota_of(catalog.windows.monthly),
905        "fiveHourSpent": report.five_hour.afp,
906        "fiveHourCap": quota_of(catalog.windows.five_hour),
907        "weeklySpent": report.weekly.afp,
908        "weeklyCap": quota_of(catalog.windows.weekly),
909        "model": report.models.first().map(|m| m.id.clone()),
910        "afp": {
911            "fiveHour": report.five_hour.afp,
912            "weekly": report.weekly.afp,
913            "monthly": report.month.afp,
914            "yuan": catalog.yuan(report.month.afp),
915        },
916        "account": report.account.as_ref().map(usage_json),
917        "accountError": report.account_error,
918        "periodEnd": report.period_end_ms,
919    })
920    .to_string()
921}
922
923/// One meter as JSON, with the server's own figures preserved — the percentage
924/// is the console's, not a recomputation.
925/// One window as this store measures it: AFP, the plan's published quota, the
926/// percentage against it, and the boundary its row counts down to.
927fn window_json(totals: &Totals, quota: Option<f64>, reset_ms: Option<i64>) -> serde_json::Value {
928    serde_json::json!({
929        "afp": totals.afp,
930        "requests": totals.requests,
931        "quota": quota,
932        "percent": quota
933            .filter(|quota| *quota > 0.0)
934            .map(|quota| totals.afp / quota * 100.0),
935        "resetAt": reset_ms,
936    })
937}
938
939fn meter_json(meter: &crate::arkuse::api::Meter) -> serde_json::Value {
940    serde_json::json!({
941        "used": meter.used,
942        "total": meter.quota,
943        "percent": meter.percent,
944        "status": meter.status,
945        "resetsAt": meter.resets_at,
946        "resetAt": meter.reset_ms(),
947    })
948}
949
950/// Every window the control plane answered, keeping the plan's own shape — a
951/// text-only workload has no `daily` period, so that key is simply absent.
952fn usage_json(usage: &crate::arkuse::api::Usage) -> serde_json::Value {
953    serde_json::json!({
954        "profile": usage.profile,
955        "tier": usage.tier,
956        "fiveHour": meter_json(&usage.five_hour),
957        "daily": usage.daily.as_ref().map(meter_json),
958        "weekly": meter_json(&usage.weekly),
959        "monthly": meter_json(&usage.monthly),
960    })
961}
962
963pub fn render_json(report: &Report) -> String {
964    let catalog = ark::catalog();
965    let totals = |t: &Totals| {
966        serde_json::json!({
967            "afp": t.afp,
968            "requests": t.requests,
969            "tokensIn": t.input,
970            "tokensOut": t.output,
971            "cacheRead": t.cache_read,
972            "cacheWrite": t.cache_write,
973        })
974    };
975    let models: serde_json::Map<String, serde_json::Value> = report
976        .models
977        .iter()
978        .map(|m| {
979            (
980                m.id.clone(),
981                serde_json::json!({
982                    "coef": m.coef,
983                    "month": totals(&m.month),
984                    "fiveHour": totals(&m.five_hour),
985                    "weekly": totals(&m.weekly),
986                }),
987            )
988        })
989        .collect();
990    serde_json::json!({
991        "source": "opencode",
992        "scope": "local",
993        // Every local client the figures came from — opencode's own store plus
994        // any of `dsh` / `pi` that ran this plan's traffic. Additive.
995        "clients": &report.clients,
996        "plan": report.tier,
997        "profile": report.profile,
998        "generatedAt": report.now_ms,
999        "periodStart": report.since_ms,
1000        // Period bounds in epoch ms, mirroring ocuse/cmduse (`periodStartAt`/
1001        // `periodEndAt`); `periodStart` stays for existing readers.
1002        "periodStartAt": report.since_ms,
1003        "periodEndAt": report.period_end_ms,
1004        "periodEnd": report.period_end_ms
1005            .map(|ms| date_of((ms + report.offset_secs * 1000).div_euclid(86_400_000))),
1006        "monthInferred": report.month_inferred,
1007        "summary": {
1008            "afp": report.month.afp,
1009            "yuan": ark::catalog().yuan(report.month.afp),
1010            "requests": report.month.requests,
1011            "tokensIn": report.month.input,
1012            "tokensOut": report.month.output,
1013            "cacheRead": report.month.cache_read,
1014        },
1015        "fiveHour": totals(&report.five_hour),
1016        "weekly": totals(&report.weekly),
1017        "monthly": totals(&report.month),
1018        "daily": report.daily.as_ref().map(totals),
1019        // This store's view of each window against the plan's published quota —
1020        // the same pair the dashboard prints, so a consumer without the account
1021        // answer still gets a percentage and a boundary.
1022        "windows": {
1023            "fiveHour": window_json(
1024                &report.five_hour,
1025                report.window_quota(&catalog, catalog.windows.five_hour),
1026                report.rolling_reset(&report.five_hour, crate::arkuse::window::FIVE_HOUR_SECS),
1027            ),
1028            "weekly": window_json(
1029                &report.weekly,
1030                report.window_quota(&catalog, catalog.windows.weekly),
1031                report.rolling_reset(&report.weekly, crate::arkuse::window::WEEKLY_SECS),
1032            ),
1033            "monthly": window_json(
1034                &report.month,
1035                report.window_quota(&catalog, catalog.windows.monthly),
1036                report.period_end_ms,
1037            ),
1038        },
1039        "account": report.account.as_ref().map(|account| {
1040            let mut body = serde_json::json!({
1041                "profile": account.profile,
1042                "tier": account.tier,
1043                "fiveHour": meter_json(&account.five_hour),
1044                "weekly": meter_json(&account.weekly),
1045                "monthly": meter_json(&account.monthly),
1046            });
1047            if let Some(daily) = &account.daily {
1048                body["daily"] = meter_json(daily);
1049            }
1050            body
1051        }),
1052        "accountError": report.account_error,
1053        "reconciledPct": report.reconcile(),
1054        "afpPerRequest": report.afp_per_request(),
1055        "models": models,
1056    })
1057    .to_string()
1058}
1059
1060/// The tier catalogue: what each plan costs, what quota it grants, and how that
1061/// quota is split across the windows. The window figures are the plan's own
1062/// fractions of its monthly AFP — the same table the console shows per tier.
1063pub fn plans_text(colour: bool) -> String {
1064    let ink = Ink::new(colour);
1065    let catalog = ark::catalog();
1066    let mut out = format!(
1067        "{}{:<8} {:>9} {:>9} {:>10} {:>10} {:>10}{}\n",
1068        ink.bold, "TIER", "YUAN/MO", "AFP/MO", "5H AFP", "DAILY AFP", "WK AFP", ink.reset
1069    );
1070    for plan in &catalog.plans {
1071        out.push_str(&format!(
1072            "{:<8} {:>9} {:>9} {:>10} {:>10} {:>10}\n",
1073            plan.label,
1074            ink.cyan_on(yuan(plan.price)),
1075            ink.cyan_on(afp(plan.afp)),
1076            afp(plan.afp * catalog.windows.five_hour),
1077            afp(plan.afp * catalog.windows.daily),
1078            afp(plan.afp * catalog.windows.weekly),
1079        ));
1080        if let Some(promo) = &plan.promo {
1081            out.push_str(&format!(
1082                "{}{} for {} months, until {}{}\n",
1083                ink.dim,
1084                yuan(promo.price),
1085                promo.months.unwrap_or(1),
1086                promo.until,
1087                ink.reset,
1088            ));
1089        }
1090    }
1091    out.push_str(&format!(
1092        "\n{}1 AFP = ¥{:.3} at every tier · 1,000 AFP = {}{}\n",
1093        ink.dim,
1094        catalog.afp_yuan,
1095        yuan(catalog.yuan(1000.0)),
1096        ink.reset,
1097    ));
1098    if !catalog.enterprise.is_empty() {
1099        out.push_str(&format!(
1100            "\n{}ENTERPRISE{} {}\n",
1101            ink.bold,
1102            ink.reset,
1103            ink.dim_on(
1104                "(seat-based; AFP is metered per seat and the personal usage API does not expose it)"
1105                    .into()
1106            ),
1107        ));
1108        for plan in &catalog.enterprise {
1109            out.push_str(&format!(
1110                "  {:<22} {}\n",
1111                plan.label,
1112                ink.cyan_on(format!("{}/seat/mo", yuan(plan.price))),
1113            ));
1114        }
1115    }
1116    if let Some(note) = catalog.notes.get("windows") {
1117        out.push_str(&format!("\n{}{}{}\n", ink.dim, note, ink.reset));
1118    }
1119    out
1120}
1121
1122pub fn plans_json() -> String {
1123    let catalog = ark::catalog();
1124    serde_json::json!({
1125        "source": catalog.source,
1126        "extractedAt": catalog.extracted_at,
1127        "currency": catalog.currency,
1128        "afpYuan": catalog.afp_yuan,
1129        "windows": {
1130            "fiveHour": catalog.windows.five_hour,
1131            "daily": catalog.windows.daily,
1132            "weekly": catalog.windows.weekly,
1133            "monthly": catalog.windows.monthly,
1134        },
1135        "plans": catalog.plans.iter().map(|p| serde_json::json!({
1136            "id": p.id,
1137            "label": p.label,
1138            "price": p.price,
1139            "listPrice": p.list_price,
1140            "afp": p.afp,
1141            "promo": p.promo.as_ref().map(|promo| serde_json::json!({
1142                "price": promo.price,
1143                "until": promo.until,
1144                "months": promo.months,
1145            })),
1146        })).collect::<Vec<_>>(),
1147        "enterprise": catalog.enterprise.iter().map(|p| serde_json::json!({
1148            "id": p.id,
1149            "label": p.label,
1150            "price": p.price,
1151        })).collect::<Vec<_>>(),
1152        "modelCount": catalog.models.len(),
1153    })
1154    .to_string()
1155}
1156
1157/// `y` yuan per million tokens at a coefficient — the same quantity the docs
1158/// publish per model, so a reader can check a coefficient without arithmetic:
1159/// 1M tokens × coef / 10,000 = 100·coef AFP, and 1 AFP = ¥0.002.
1160fn yuan_per_million(coef: f64, afp_yuan: f64) -> f64 {
1161    1_000_000.0 * coef / 10_000.0 * afp_yuan
1162}
1163
1164/// The model catalogue with its coefficient in force today. `gated` keeps only
1165/// what `plan_id` grants; the table always says which tier a model needs, because
1166/// the plan you hold changes what you can call.
1167pub fn models_text(plan_id: &str, gated: bool, colour: bool) -> String {
1168    let ink = Ink::new(colour);
1169    let catalog = ark::catalog();
1170    let now = crate::arkuse::window::now_ms();
1171    let mut out = format!(
1172        "{}{:<30} {:>6} {:>7} {:>10} {:>8} {:>8}{}\n",
1173        ink.bold, "MODEL", "KIND", "COEF", "YUAN/1M", "MIN", "GRANTED", ink.reset
1174    );
1175    for model in &catalog.models {
1176        let granted = catalog.grants(plan_id, &model.min_plan);
1177        if gated && !granted {
1178            continue;
1179        }
1180        let coef = ark::effective_coef(model, now);
1181        let promo = model.promo_coef.map(|_| {
1182            format!(
1183                " (promo until {})",
1184                model.promo_until.as_deref().unwrap_or("?")
1185            )
1186        });
1187        let coef_cell = ink.cyan_on(format!("{coef:.2}"));
1188        out.push_str(&format!(
1189            "{:<30} {:>6} {:>7} {:>10} {:>8} {:>8}",
1190            model.id,
1191            model.kind,
1192            coef_cell,
1193            format!("{:.2}", yuan_per_million(coef, catalog.afp_yuan)),
1194            model.min_plan,
1195            if granted { "yes" } else { "no" },
1196        ));
1197        if let Some(promo) = promo {
1198            out.push_str(&format!("{}{promo}{}", ink.dim, ink.reset));
1199        }
1200        out.push('\n');
1201    }
1202    out
1203}
1204
1205/// One JSON object per line, mirroring `models_text` — the shape cmduse's own
1206/// `models --json` uses, so a consumer pipes either binary the same way.
1207pub fn models_json(plan_id: &str, gated: bool) -> String {
1208    let catalog = ark::catalog();
1209    let now = crate::arkuse::window::now_ms();
1210    let mut lines: Vec<String> = Vec::new();
1211    for model in &catalog.models {
1212        let granted = catalog.grants(plan_id, &model.min_plan);
1213        if gated && !granted {
1214            continue;
1215        }
1216        let coef = ark::effective_coef(model, now);
1217        lines.push(
1218            serde_json::json!({
1219                "id": model.id,
1220                "name": model.name,
1221                "kind": model.kind,
1222                "coef": coef,
1223                "baseCoef": model.coef,
1224                "promoCoef": model.promo_coef,
1225                "promoUntil": model.promo_until,
1226                "yuanPerMillion": yuan_per_million(coef, catalog.afp_yuan),
1227                "minPlan": model.min_plan,
1228                "granted": granted,
1229            })
1230            .to_string(),
1231        );
1232    }
1233    lines.join("\n")
1234}
1235
1236/// One model's period record: what it cost in AFP, where that AFP sits in each
1237/// window, and how much of the period it accounts for.
1238pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
1239    let ink = Ink::new(colour);
1240    let Some(model) = report.models.iter().find(|m| m.id == id) else {
1241        return format!("{id}: no usage in the period\n");
1242    };
1243    let row =
1244        |label: &str, value: String| format!("  {}{value}\n", ink.dim_on(format!("{label:<10}")));
1245    let mut out = format!(
1246        "{} {}\n",
1247        ink.bold_on(model.id.clone()),
1248        ink.dim_on(format!("({})", model.provider)),
1249    );
1250    out.push_str(&row(
1251        "coef",
1252        ink.cyan_on(format!(
1253            "{:.2} · {} per million tokens",
1254            model.coef,
1255            yuan(yuan_per_million(model.coef, ark::catalog().afp_yuan))
1256        )),
1257    ));
1258    out.push_str(&row(
1259        "month",
1260        ink.cyan_on(format!(
1261            "{} AFP ({}) · {} requests",
1262            afp(model.month.afp),
1263            yuan(ark::catalog().yuan(model.month.afp)),
1264            model.month.requests
1265        )),
1266    ));
1267    out.push_str(&row(
1268        "5-hour",
1269        ink.cyan_on(format!("{} AFP", afp(model.five_hour.afp))),
1270    ));
1271    out.push_str(&row(
1272        "weekly",
1273        ink.cyan_on(format!("{} AFP", afp(model.weekly.afp))),
1274    ));
1275    if report.month.afp > 0.0 {
1276        out.push_str(&row(
1277            "share",
1278            ink.dim_on(format!(
1279                "{:.1}% of the period's AFP",
1280                model.month.afp / report.month.afp * 100.0
1281            )),
1282        ));
1283    }
1284    out
1285}
1286
1287/// Per-model report: `Json` mirrors cmduse's `model --json` envelope with AFP in
1288/// place of dollars; `Csv` is one row per model. `Table` is the caller's.
1289pub fn model_report(report: &Report, id: Option<&str>, fmt: Fmt) -> String {
1290    let models: Vec<&crate::arkuse::window::ModelUsage> = report
1291        .models
1292        .iter()
1293        .filter(|m| id.is_none_or(|want| m.id == want))
1294        .collect();
1295    match fmt {
1296        Fmt::Json => {
1297            let map: serde_json::Map<String, serde_json::Value> = models
1298                .iter()
1299                .map(|m| {
1300                    (
1301                        m.id.clone(),
1302                        serde_json::json!({
1303                            "coef": m.coef,
1304                            "requests": m.month.requests,
1305                            "tokensIn": m.month.input,
1306                            "tokensOut": m.month.output,
1307                            "cacheRead": m.month.cache_read,
1308                            "afp": m.month.afp,
1309                            "yuan": ark::catalog().yuan(m.month.afp),
1310                        }),
1311                    )
1312                })
1313                .collect();
1314            serde_json::json!({
1315                "scope": "local",
1316                "source": "opencode",
1317                "sinceMs": report.since_ms,
1318                "models": map,
1319            })
1320            .to_string()
1321        }
1322        Fmt::Csv => {
1323            let mut out = String::from("model,requests,tokens_in,tokens_out,cache_read,afp\n");
1324            for m in models {
1325                out.push_str(&format!(
1326                    "{},{},{},{},{},{:.2}\n",
1327                    crate::report_render::csv_field(&m.id),
1328                    m.month.requests,
1329                    m.month.input,
1330                    m.month.output,
1331                    m.month.cache_read,
1332                    m.month.afp,
1333                ));
1334            }
1335            out
1336        }
1337        Fmt::Table => String::new(),
1338    }
1339}
1340
1341/// The period's per-model breakdown as a table — what `arkuse model` with no id
1342/// prints. Columns mirror the dashboard's model block so a reader can put the
1343/// two side by side.
1344pub fn models_period_text(report: &Report, limit: usize, colour: bool) -> String {
1345    let ink = Ink::new(colour);
1346    let mut out = format!(
1347        "{}{:<28} {:>5} {:>9} {:>7} {:>6}{}\n",
1348        ink.bold, "MODEL", "COEF", "AFP", "REQ", "SHARE", ink.reset
1349    );
1350    let total = report.month.afp;
1351    for model in report.models.iter().take(limit.min(report.models.len())) {
1352        let share = if total > 0.0 {
1353            ink.dim_on(format!("{:>5.1}%", model.month.afp / total * 100.0))
1354        } else {
1355            "     -".to_string()
1356        };
1357        out.push_str(&format!(
1358            "{:<28} {} {:>9} {:>7} {}\n",
1359            model.id,
1360            ink.cyan_on(format!("{:>5.2}", model.coef)),
1361            ink.cyan_on(format!("{:>9}", afp(model.month.afp))),
1362            ink.cyan_on(format!("{:>7}", compact(model.month.requests))),
1363            share,
1364        ));
1365    }
1366    if report.models.is_empty() {
1367        out.push_str(&format!(
1368            "{}\n",
1369            ink.dim_on("(no agent-plan usage in the period)".into())
1370        ));
1371    } else if total > 0.0 {
1372        if report.models.len() > limit {
1373            out.push_str(&format!(
1374                "{}\n",
1375                ink.dim_on(format!("… {} more", report.models.len() - limit))
1376            ));
1377        }
1378        out.push_str(&format!(
1379            "{:<28} {:>5} {:>9} {:>7} {:>6}\n",
1380            ink.bold_on("TOTAL".into()),
1381            "",
1382            afp(total),
1383            compact(report.month.requests),
1384            "100.0%",
1385        ));
1386    }
1387    out
1388}
1389
1390/// The store's numeric fields, in the shape every machine format in this repo
1391/// uses, with AFP standing where the other binaries put dollars.
1392fn totals_json(
1393    afp: f64,
1394    requests: u64,
1395    input: u64,
1396    output: u64,
1397    cache_read: u64,
1398) -> serde_json::Value {
1399    serde_json::json!({
1400        "requests": requests,
1401        "tokensIn": input,
1402        "tokensOut": output,
1403        "cacheRead": cache_read,
1404        "afp": afp,
1405    })
1406}
1407
1408/// One day/hour bucket. AFP is accumulated per row, because the coefficient is
1409/// a property of the model and of the day it ran (a promo can expire mid-window).
1410#[derive(Default, Clone, Copy)]
1411struct Bucket {
1412    afp: f64,
1413    requests: u64,
1414    input: u64,
1415    output: u64,
1416    cache_read: u64,
1417}
1418
1419impl Bucket {
1420    fn add(&mut self, catalog: &ark::Catalog, row: &crate::arkuse::db::Row) {
1421        self.requests += 1;
1422        self.input += row.input;
1423        self.output += row.output;
1424        self.cache_read += row.cache_read;
1425        self.afp += catalog
1426            .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1427            .unwrap_or(0.0);
1428    }
1429
1430    fn merge(&mut self, other: &Bucket) {
1431        self.afp += other.afp;
1432        self.requests += other.requests;
1433        self.input += other.input;
1434        self.output += other.output;
1435        self.cache_read += other.cache_read;
1436    }
1437
1438    fn json(&self) -> serde_json::Value {
1439        totals_json(
1440            self.afp,
1441            self.requests,
1442            self.input,
1443            self.output,
1444            self.cache_read,
1445        )
1446    }
1447}
1448
1449/// AFP per bucket (day or hour), oldest first — `arkuse daily` / `arkuse hourly`.
1450#[allow(clippy::too_many_arguments)]
1451pub fn bucket_text(
1452    rows: &[crate::arkuse::db::Row],
1453    catalog: &ark::Catalog,
1454    now_ms: i64,
1455    lookback_ms: i64,
1456    size_ms: i64,
1457    tz_secs: i64,
1458    colour: bool,
1459    fmt: Fmt,
1460) -> String {
1461    // Buckets are cut in the display timezone: shift, bucket, then label.
1462    let shift = tz_secs * 1000;
1463    let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
1464    for row in rows {
1465        let bucket = (row.at_ms + shift).div_euclid(size_ms);
1466        buckets.entry(bucket).or_default().add(catalog, row);
1467    }
1468    let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
1469    let daily = size_ms >= crate::arkuse::window::DAY_SECS * 1000;
1470    let label = if daily { "day" } else { "hour" };
1471    let stamp = |bucket: i64| {
1472        if daily {
1473            date_of(bucket)
1474        } else {
1475            format!(
1476                "{} {:02}h",
1477                date_of(bucket.div_euclid(24)),
1478                bucket.rem_euclid(24)
1479            )
1480        }
1481    };
1482    let window: Vec<(i64, Bucket)> = buckets
1483        .range(start..)
1484        .map(|(bucket, b)| (*bucket, *b))
1485        .collect();
1486
1487    match fmt {
1488        Fmt::Json => {
1489            let mut keyed = serde_json::Map::new();
1490            let mut total = Bucket::default();
1491            for (bucket, b) in &window {
1492                keyed.insert(stamp(*bucket), b.json());
1493                total.merge(b);
1494            }
1495            let key = if daily { "days" } else { "hours" };
1496            serde_json::json!({
1497                "scope": "local",
1498                "source": "opencode",
1499                "total": total.json(),
1500                key: keyed,
1501            })
1502            .to_string()
1503        }
1504        Fmt::Csv => {
1505            let mut out = format!("{label},requests,tokens_in,tokens_out,cache_read,afp\n");
1506            let mut total = Bucket::default();
1507            for (bucket, b) in &window {
1508                out.push_str(&format!(
1509                    "{},{},{},{},{},{:.2}\n",
1510                    crate::report_render::csv_field(&stamp(*bucket)),
1511                    b.requests,
1512                    b.input,
1513                    b.output,
1514                    b.cache_read,
1515                    b.afp,
1516                ));
1517                total.merge(b);
1518            }
1519            out.push_str(&format!(
1520                "total,{},{},{},{},{:.2}\n",
1521                total.requests, total.input, total.output, total.cache_read, total.afp,
1522            ));
1523            out
1524        }
1525        Fmt::Table => {
1526            let ink = Ink::new(colour);
1527            let mut out = format!(
1528                "{}{label:<12} AFP      requests   tokens{}\n",
1529                ink.bold, ink.reset
1530            );
1531            for (bucket, b) in &window {
1532                out.push_str(&format!(
1533                    "{:<12} {} {} {}\n",
1534                    stamp(*bucket),
1535                    ink.cyan_on(format!("{:>9}", afp(b.afp))),
1536                    ink.cyan_on(format!("{:>12}", compact(b.requests))),
1537                    ink.cyan_on(format!("{:>9}", compact(b.input + b.output + b.cache_read))),
1538                ));
1539            }
1540            if out.lines().count() == 1 {
1541                out.push_str(&format!(
1542                    "{}\n",
1543                    ink.dim_on("(no agent-plan usage in the window)".into())
1544                ));
1545            }
1546            out
1547        }
1548    }
1549}
1550
1551/// One session's accumulating totals: `(afp, requests, tokens_in, tokens_out,
1552/// cache_read, last_ms, distinct models)`.
1553type SessionTotals<'a> = (
1554    f64,
1555    u64,
1556    u64,
1557    u64,
1558    u64,
1559    i64,
1560    std::collections::BTreeSet<&'a str>,
1561);
1562
1563/// AFP per session, newest first — the same columns as `ocuse session`, with AFP
1564/// in place of USD.
1565pub fn session_text(
1566    rows: &[crate::arkuse::db::Row],
1567    catalog: &ark::Catalog,
1568    limit: usize,
1569    colour: bool,
1570    fmt: Fmt,
1571) -> String {
1572    let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1573    for row in rows {
1574        let entry = by_session
1575            .entry(row.session.as_str())
1576            .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1577        entry.0 += catalog
1578            .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1579            .unwrap_or(0.0);
1580        entry.1 += 1;
1581        entry.2 += row.input;
1582        entry.3 += row.output;
1583        entry.4 += row.cache_read;
1584        entry.5 = entry.5.max(row.at_ms);
1585        entry.6.insert(row.model.as_str());
1586    }
1587    let mut sessions: Vec<_> = by_session.into_iter().collect();
1588    sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1589
1590    match fmt {
1591        Fmt::Json => {
1592            let mut keyed = serde_json::Map::new();
1593            for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1594                let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1595                body["lastMs"] = serde_json::json!(*last);
1596                body["models"] = serde_json::json!(models.len());
1597                keyed.insert((*id).to_string(), body);
1598            }
1599            serde_json::json!({
1600                "scope": "local",
1601                "source": "opencode",
1602                "sessions": keyed,
1603            })
1604            .to_string()
1605        }
1606        Fmt::Csv => {
1607            let mut out = String::from("session,requests,tokens_in,tokens_out,cache_read,afp\n");
1608            for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1609                out.push_str(&format!(
1610                    "{},{},{},{},{},{:.2}\n",
1611                    crate::report_render::csv_field(id),
1612                    requests,
1613                    input,
1614                    output,
1615                    cache_read,
1616                    cost,
1617                ));
1618            }
1619            out
1620        }
1621        Fmt::Table => {
1622            let ink = Ink::new(colour);
1623            let mut out = format!(
1624                "{}session                          last        models   AFP       req{}\n",
1625                ink.bold, ink.reset
1626            );
1627            for (id, (cost, requests, _, _, _, last, models)) in sessions.iter().take(limit) {
1628                let short = id.rsplit('_').next().unwrap_or(*id);
1629                out.push_str(&format!(
1630                    "{:<32} {:<11} {:>6} {} {:>6}\n",
1631                    short,
1632                    ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1633                    models.len(),
1634                    ink.cyan_on(format!("{:>9}", afp(*cost))),
1635                    requests,
1636                ));
1637            }
1638            if sessions.len() > limit {
1639                out.push_str(&format!(
1640                    "{}\n",
1641                    ink.dim_on(format!("… {} more", sessions.len() - limit))
1642                ));
1643            }
1644            if out.lines().count() == 1 {
1645                out.push_str(&format!(
1646                    "{}\n",
1647                    ink.dim_on("(no sessions in the window)".into())
1648                ));
1649            }
1650            out
1651        }
1652    }
1653}
1654
1655#[cfg(test)]
1656mod tests {
1657    use super::*;
1658    use crate::arkuse::db::Row;
1659
1660    fn report() -> Report {
1661        let now = 1_800_000_000_000;
1662        let rows = vec![Row {
1663            at_ms: now - 60_000,
1664            session: "ses_a".into(),
1665            provider: "agent-plan".into(),
1666            model: "deepseek-v4.1-flash".into(),
1667            input: 10_000,
1668            output: 2_000,
1669            ..Default::default()
1670        }];
1671        crate::arkuse::window::build(&rows, &ark::catalog(), ark::DEFAULT_PLAN, now, None, None)
1672    }
1673
1674    #[test]
1675    fn text_names_the_model_and_its_afp() {
1676        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1677        assert!(text.contains("deepseek-v4.1-flash"), "{text}");
1678        assert!(text.contains("AFP"), "the plan's own unit: {text}");
1679        assert!(!text.contains('$'), "ARK never prices in dollars: {text}");
1680    }
1681
1682    /// The right edge (1-based, inclusive) of every whitespace-separated cell.
1683    fn cell_ends(line: &str) -> Vec<usize> {
1684        let mut ends = Vec::new();
1685        let mut cursor = 0;
1686        for cell in line.split_whitespace() {
1687            let start = line[cursor..].find(cell).expect("cell in line") + cursor;
1688            cursor = start + cell.len();
1689            ends.push(cursor);
1690        }
1691        ends
1692    }
1693
1694    /// The header labels sit in the columns their cells do: every numeric column
1695    /// is right-aligned, so its label must end where the value ends.
1696    #[test]
1697    fn the_model_table_header_aligns_with_its_rows() {
1698        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1699        let mut lines = text.lines().skip_while(|line| !line.starts_with("MODEL "));
1700        let header = lines.next().expect("the table header");
1701        let row = lines
1702            .find(|line| line.starts_with("deepseek-v4.1-flash "))
1703            .expect("the model row");
1704        let header_ends = cell_ends(header);
1705        assert_eq!(
1706            header_ends[1..],
1707            cell_ends(row)[1..header_ends.len()],
1708            "header columns must end where their cells do:\n{header}\n{row}"
1709        );
1710    }
1711
1712    #[test]
1713    fn the_tier_table_carries_every_personal_plan() {
1714        let text = plans_text(false);
1715        for plan in ark::catalog().plans {
1716            assert!(text.contains(&plan.label), "{} missing: {text}", plan.label);
1717        }
1718        let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
1719        assert_eq!(
1720            json["plans"].as_array().unwrap().len(),
1721            ark::catalog().plans.len()
1722        );
1723    }
1724
1725    #[test]
1726    fn gated_models_are_filtered_by_the_tier() {
1727        let all = models_text("small", false, false);
1728        let gated = models_text("small", true, false);
1729        assert!(all.lines().count() >= gated.lines().count());
1730        assert!(all.contains("kimi-k3"), "{all}");
1731        assert!(!gated.contains("kimi-k3"), "{gated}");
1732    }
1733
1734    /// A report measured against the control plane's own meters.
1735    fn metered(account: &crate::arkuse::api::Usage) -> Report {
1736        let now = 1_800_000_000_000;
1737        let rows = vec![Row {
1738            at_ms: now - 60_000,
1739            session: "ses_a".into(),
1740            provider: "agent-plan".into(),
1741            model: "deepseek-v4.1-flash".into(),
1742            input: 10_000,
1743            output: 2_000,
1744            ..Default::default()
1745        }];
1746        crate::arkuse::window::build(
1747            &rows,
1748            &ark::catalog(),
1749            ark::DEFAULT_PLAN,
1750            now,
1751            None,
1752            Some(account),
1753        )
1754    }
1755
1756    fn meter(used: f64, quota: f64, reset: Option<&str>) -> crate::arkuse::api::Meter {
1757        let percent = if quota > 0.0 {
1758            used / quota * 100.0
1759        } else {
1760            0.0
1761        };
1762        crate::arkuse::api::Meter::from_parts("ok", percent, used, quota, reset, None)
1763    }
1764
1765    /// The shape `cli.rs` assembles once the control plane answered: `build`
1766    /// cuts the windows from the meters, then the account itself rides on the
1767    /// report for the header and the AGENT PLAN rows.
1768    fn metered_account(account: &crate::arkuse::api::Usage) -> Report {
1769        let mut report = metered(account);
1770        report.account = Some(account.clone());
1771        report.period_end_ms = account.monthly.reset_ms();
1772        report
1773    }
1774
1775    #[test]
1776    fn a_local_row_still_reads_used_over_quota_and_a_reset() {
1777        // Without the control plane the plan's published split stands in for the
1778        // quota, so the row is never AFP alone — the parity the user expects.
1779        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1780        let five = text
1781            .lines()
1782            .find(|line| line.contains("5-hour"))
1783            .expect("a 5-hour row");
1784        assert!(
1785            five.contains("/ 10,000 AFP"),
1786            "the plan's own quota: {five}"
1787        );
1788        assert!(five.contains("% of limit (est.)"), "a percentage: {five}");
1789        let weekly = text
1790            .lines()
1791            .find(|line| line.contains("Weekly"))
1792            .expect("a weekly row");
1793        assert!(weekly.contains("resets"), "a countdown: {weekly}");
1794        assert!(weekly.contains("window "), "an elapsed share: {weekly}");
1795        let json: serde_json::Value = serde_json::from_str(&render_json(&report())).unwrap();
1796        assert_eq!(json["windows"]["fiveHour"]["quota"], 10_000.0);
1797        assert!(json["windows"]["weekly"]["resetAt"].is_i64());
1798    }
1799
1800    #[test]
1801    fn a_meter_without_its_reset_falls_back_to_the_stores_boundary() {
1802        // The measured shape: a just-reset 5-hour block answers 0% with no
1803        // `reset_at`, and the row still owes the countdown.
1804        let account = crate::arkuse::api::Usage {
1805            five_hour: meter(0.0, 10_000.0, None),
1806            daily: None,
1807            weekly: meter(2_975.67, 35_000.0, Some("2026-10-26T00:00:00+08:00")),
1808            monthly: meter(2_975.67, 100_000.0, Some("2026-11-19T23:59:59+08:00")),
1809            tier: Some("medium".into()),
1810            profile: "agent-plan_cn-beijing_medium".into(),
1811        };
1812        let text = render_text(
1813            &metered(&account),
1814            "/tmp/opencode.db",
1815            10,
1816            false,
1817            DEFAULT_BAR_WIDTH,
1818        );
1819        let five = text
1820            .lines()
1821            .find(|line| line.contains("5-hour"))
1822            .expect("a 5-hour row");
1823        assert!(
1824            five.contains("0% of limit"),
1825            "the server's own share: {five}"
1826        );
1827        assert!(five.contains("resets"), "a derived countdown: {five}");
1828    }
1829
1830    /// The period line states the one share the whole dashboard is about — the
1831    /// control plane's own monthly percentage — and states nothing when no
1832    /// account answered (the local rows carry their own labelled estimate).
1833    #[test]
1834    fn period_line_carries_the_account_monthly_share() {
1835        // 53,000 of 100,000 AFP: the server's own figure, printed as a whole
1836        // percent — `53`, never `53.0`.
1837        let account = crate::arkuse::api::Usage {
1838            five_hour: meter(0.0, 10_000.0, None),
1839            daily: None,
1840            weekly: meter(2_975.67, 35_000.0, Some("2026-10-26T00:00:00+08:00")),
1841            monthly: meter(53_000.0, 100_000.0, Some("2026-11-19T23:59:59+08:00")),
1842            tier: Some("medium".into()),
1843            profile: "agent-plan_cn-beijing_medium".into(),
1844        };
1845        let with = render_text(
1846            &metered_account(&account),
1847            "/tmp/opencode.db",
1848            10,
1849            false,
1850            DEFAULT_BAR_WIDTH,
1851        );
1852        let header = with
1853            .lines()
1854            .find(|line| line.starts_with("period:"))
1855            .expect("the period line");
1856        assert!(header.contains("53% of limit"), "{header}");
1857        assert!(!header.contains("53.0"), "no invented decimal: {header}");
1858
1859        let without = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1860        let header = without
1861            .lines()
1862            .find(|line| line.starts_with("period:"))
1863            .expect("the period line");
1864        assert!(
1865            !header.contains("of limit"),
1866            "no account, no share: {header}"
1867        );
1868    }
1869
1870    /// The TOP 3 block splits the period between AFP and tokens, and both shares
1871    /// are of the whole — not of one model's own window.
1872    #[test]
1873    fn top_models_state_their_share_of_afp_and_tokens() {
1874        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1875        let block = text
1876            .lines()
1877            .skip_while(|line| !line.starts_with("TOP 3 MODELS"))
1878            .take_while(|line| !line.starts_with("MODEL "))
1879            .collect::<Vec<_>>()
1880            .join("\n");
1881        assert!(block.contains("deepseek-v4.1-flash"), "{block}");
1882        assert!(block.contains("AFP") && block.contains("TOKENS"), "{block}");
1883        // One model takes the whole period, so both shares are 100%.
1884        assert_eq!(block.matches("100.0%").count(), 2, "{block}");
1885        // The row under the table header ends on its requests: the share of
1886        // period AFP moved into the block above it.
1887        let row = text
1888            .lines()
1889            .skip_while(|line| !line.starts_with("MODEL "))
1890            .find(|line| line.starts_with("deepseek-v4.1-flash"))
1891            .expect("the model row");
1892        assert!(!row.trim_end().ends_with('%'), "no trailing share: {row}");
1893        assert!(row.trim_end().ends_with(char::is_numeric), "{row}");
1894    }
1895
1896    #[test]
1897    fn the_bar_carries_the_share_so_the_body_does_not_repeat_it() {
1898        // The row used to read `28.5% ━━━━━━━ 9,976 / 35,000 AFP · 28.5% of
1899        // limit`: one figure, twice, because the gauge and the body each printed
1900        // it. The gauge owns the percentage; the body prints the quota pair.
1901        let text = render_text(&report(), "/tmp/opencode.db", 10, true, DEFAULT_BAR_WIDTH);
1902        let weekly = text
1903            .lines()
1904            .find(|line| line.contains("Weekly"))
1905            .expect("a weekly row");
1906        assert!(!weekly.contains("of limit"), "no repeated share: {weekly}");
1907        assert!(weekly.contains("/ 35,000 AFP"), "the pair: {weekly}");
1908        assert!(
1909            weekly.contains("(est.)"),
1910            "still marked an estimate: {weekly}"
1911        );
1912    }
1913
1914    #[test]
1915    fn the_machine_formats_are_not_coloured() {
1916        let rows = vec![Row {
1917            at_ms: 1_800_000_000_000,
1918            session: "ses_a".into(),
1919            provider: "agent-plan".into(),
1920            model: "deepseek-v4.1-flash".into(),
1921            input: 1_000,
1922            ..Default::default()
1923        }];
1924        let catalog = ark::catalog();
1925        for fmt in [Fmt::Json, Fmt::Csv] {
1926            let daily = bucket_text(
1927                &rows,
1928                &catalog,
1929                1_800_000_000_000,
1930                86_400_000,
1931                86_400_000,
1932                0,
1933                true,
1934                fmt,
1935            );
1936            assert!(!daily.contains('\u{1b}'), "{fmt:?} leaked colour: {daily}");
1937        }
1938        let report = report();
1939        let json = model_report(&report, None, Fmt::Json);
1940        assert!(!json.contains('\u{1b}'));
1941        assert!(json.contains("deepseek-v4.1-flash"));
1942        let csv = model_report(&report, None, Fmt::Csv);
1943        assert!(csv.starts_with("model,requests,tokens_in,tokens_out,cache_read,afp"));
1944    }
1945
1946    #[test]
1947    fn json_carries_the_period_bounds() {
1948        let parsed: serde_json::Value = serde_json::from_str(&render_json(&report())).unwrap();
1949        assert_eq!(parsed["periodStart"], report().since_ms);
1950        assert_eq!(parsed["periodStartAt"], report().since_ms);
1951        assert_eq!(parsed["periodStartAt"], parsed["periodStart"]);
1952        assert!(parsed.get("generatedAt").is_some());
1953        assert_ne!(parsed["generatedAt"], 0);
1954        // `summary` is the period's own aggregate under the name cmduse and
1955        // ocuse use; its AFP/tokens mirror the `monthly` block.
1956        assert_eq!(parsed["summary"]["afp"], parsed["monthly"]["afp"]);
1957        assert_eq!(parsed["summary"]["requests"], parsed["monthly"]["requests"]);
1958        assert_eq!(parsed["summary"]["tokensIn"], parsed["monthly"]["tokensIn"]);
1959        assert_eq!(
1960            parsed["summary"]["tokensOut"],
1961            parsed["monthly"]["tokensOut"]
1962        );
1963        assert_eq!(
1964            parsed["summary"]["cacheRead"],
1965            parsed["monthly"]["cacheRead"]
1966        );
1967    }
1968}