1use crate::ocuse::window::{ModelUsage, Report, Totals};
9use crate::ocuse::zen;
10use crate::report_render::Fmt;
11use cmduse_core::{compact, duration, money, pct};
12
13pub const DEFAULT_BAR_WIDTH: usize = 20;
16
17pub struct Ink {
20 pub colour: bool,
21 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 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 {
70 format!("{}{text}{}", self.cyan, self.reset)
71 }
72
73 fn dim_on(&self, text: String) -> String {
75 format!("{}{text}{}", self.dim, self.reset)
76 }
77
78 fn bold_on(&self, text: String) -> String {
79 format!("{}{text}{}", self.bold, self.reset)
80 }
81
82 fn for_pct(&self, pct_used: f64) -> &'static str {
84 if !self.colour {
85 ""
86 } else if pct_used >= 90.0 {
87 self.red
88 } else if pct_used >= 70.0 {
89 self.yellow
90 } else {
91 self.green
92 }
93 }
94
95 fn bar(&self, used: f64, cap: f64) -> String {
98 if self.colour {
99 crate::render::bar(used, cap, self.bar_width)
100 } else {
101 String::new()
102 }
103 }
104}
105
106#[allow(clippy::too_many_arguments)]
107fn window_line(
108 ink: &Ink,
109 label: &str,
110 used: &Totals,
111 cap: Option<f64>,
112 reset_ms: Option<i64>,
113 dur_secs: Option<u64>,
117 pro_rata: bool,
121 now_ms: i64,
122) -> String {
123 let reset = reset_ms
124 .map(|at| duration(((at - now_ms) / 1000).max(0) as u64))
125 .unwrap_or_default();
126 let reset_text = if reset.is_empty() {
127 String::new()
128 } else if ink.colour {
129 format!(" · resets in {reset}")
130 } else {
131 format!(" · resets {reset}")
132 };
133 let now_secs = (now_ms / 1000).max(0) as u64;
138 let reset_at = reset_ms.map(|ms| ms as f64);
139 let elapsed = dur_secs.and_then(|d| cmduse_core::elapsed_pct(reset_at, d, Some(now_secs)));
140 let pace = dur_secs
141 .and_then(|d| {
142 cmduse_core::pace_eta(reset_at, d, used.cost, cap.unwrap_or(0.0), now_secs)
143 .map(|secs| duration(secs as u64))
144 })
145 .map(|eta| format!(" · {}on pace to hit cap in {eta}{}", ink.yellow, ink.reset))
146 .unwrap_or_default();
147 let thru = elapsed
148 .map(|p| format!(" · {}window {p:.1}% elapsed{}", ink.dim, ink.reset))
149 .unwrap_or_default();
150 let by_now = match (pro_rata, elapsed) {
151 (true, Some(p)) => format!(
152 " · {}{} by now{}",
153 ink.dim,
154 money(cap.unwrap_or(0.0) * p / 100.0),
155 ink.reset
156 ),
157 _ => String::new(),
158 };
159 match cap {
160 Some(cap) if cap > 0.0 => {
161 let flag = if used.cost > cap {
163 format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
164 } else {
165 String::new()
166 };
167 if ink.colour {
168 format!(
169 " {}{label:<8}{} {} {}{} / {}{}{}{}{}{}{}\n",
170 ink.bold,
171 ink.reset,
172 ink.bar(used.cost, cap),
173 ink.dim,
174 money(used.cost),
175 money(cap),
176 reset_text,
177 flag,
178 pace,
179 thru,
180 by_now,
181 ink.reset,
182 )
183 } else {
184 format!(
187 " {label:<8} {} / {} ({}){reset_text}{flag}{pace}{thru}{by_now}\n",
188 money(used.cost),
189 money(cap),
190 pct(used.cost, cap),
191 )
192 }
193 }
194 _ => format!(
197 " {label:<8} {}\n",
198 ink.dim_on(format!("{}{}", money(used.cost), reset_text))
199 ),
200 }
201}
202
203fn meter_line(ink: &Ink, label: &str, meter: &crate::ocuse::api::Meter, now_ms: i64) -> String {
208 let reset = meter
209 .reset_ms()
210 .map(|at| duration(((at - now_ms) / 1000).max(0) as u64))
211 .unwrap_or_default();
212 let reset_text = if reset.is_empty() {
213 String::new()
214 } else {
215 format!(" · resets in {reset}")
216 };
217 let flag = if meter.exceeded() {
220 format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
221 } else if !meter.status.eq_ignore_ascii_case("ok") {
222 format!(" {}{}{}{}", ink.red, meter.status, ink.reset, "")
223 } else {
224 String::new()
225 };
226 let share = pct(meter.percent, 100.0);
227 if ink.colour {
228 let tone = ink.for_pct(meter.percent);
229 format!(
230 " {}{label:<8}{} {} {tone}{share:>7}{}{reset_text}{flag}\n",
231 ink.bold,
232 ink.reset,
233 ink.bar(meter.percent, 100.0),
234 ink.reset,
235 )
236 } else {
237 format!(" {label:<8} {share:>7}{reset_text}{flag}\n")
238 }
239}
240
241fn aggregate_cap(report: &Report, share: f64) -> Option<f64> {
244 let total: f64 = report
245 .models
246 .iter()
247 .filter(|m| m.month.cost > 0.0)
248 .filter_map(|m| m.limit)
249 .map(|limit| limit * share)
250 .sum();
251 (total > 0.0).then_some(total)
252}
253
254fn exceeded(model: &ModelUsage) -> bool {
258 crate::ocuse::window::over_window(model).is_some()
259}
260
261fn model_row(ink: &Ink, model: &ModelUsage, total_cost: f64) -> String {
262 let limit = match model.limit {
263 Some(limit) => money(limit),
264 None => ink.dim_on("—".into()),
265 };
266 let use_fraction = model.month_use();
267 let use_text = use_fraction
268 .map(|fraction| pct(fraction * 100.0, 100.0))
269 .unwrap_or_else(|| "—".into());
270 let spent = ink.cyan_on(format!("{:>10}", money(model.month.cost)));
272 let use_cell = match use_fraction {
273 Some(fraction) => {
274 let tone = ink.for_pct(fraction * 100.0);
275 if tone.is_empty() {
276 format!("{use_text:>7}")
277 } else {
278 format!("{tone}{use_text:>7}{}", ink.reset)
279 }
280 }
281 None => ink.dim_on(format!("{use_text:>7}")),
282 };
283 let flag = if exceeded(model) {
286 format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
287 } else {
288 String::new()
289 };
290 let share = if total_cost > 0.0 {
291 ink.dim_on(format!(" {:>5.1}%", model.month.cost / total_cost * 100.0))
292 } else {
293 String::new()
294 };
295 format!(
296 "{:<28}{flag} {:>7} {spent} {use_cell} {} {} {:>7}{share}\n",
297 model.id,
298 limit,
299 window_cell(ink, model, model.five_hour.cost, 0.20),
301 window_cell(ink, model, model.weekly.cost, 0.50),
302 compact(model.month.requests),
303 )
304}
305
306fn window_cell(ink: &Ink, model: &ModelUsage, cost: f64, share: f64) -> String {
309 let text = format!("{:>8}", money(cost));
310 match model.limit.filter(|limit| *limit > 0.0) {
311 Some(limit) => {
312 let tone = ink.for_pct(cost / (limit * share) * 100.0);
313 if tone.is_empty() {
314 text
315 } else {
316 format!("{tone}{text}{}", ink.reset)
317 }
318 }
319 None => ink.dim_on(text),
320 }
321}
322
323pub fn render_text(
325 report: &Report,
326 db_path: &str,
327 rows: usize,
328 colour: bool,
329 bar_width: usize,
330) -> String {
331 let ink = Ink::new(colour).width(bar_width);
332 let c = |text: String| ink.cyan_on(text);
333 let shares = &zen::catalog().go.window_share;
334 let mut out = String::new();
335 out.push_str(&format!(
336 "{}OpenCode usage{} — go + zen, from {}{}{}\n",
337 ink.bold,
338 ink.reset,
339 ink.dim,
340 shorten(db_path),
341 ink.reset,
342 ));
343 let end = report
347 .period_end_ms
348 .map(|ms| format!(" · ends {}", date_of(ms / 86_400_000)))
349 .unwrap_or_default();
350 out.push_str(&format!(
351 "period: {}{end} · {} requests · {} · {} in / {} out tokens\n",
352 report.period_label,
353 c(compact(report.month.requests)),
354 c(money(report.month.cost)),
355 c(compact(report.month.input)),
356 c(compact(report.month.output)),
357 ));
358
359 match &report.account {
364 Some(account) => {
365 out.push_str(&format!(
366 "\n{}GO{} {}\n",
367 ink.bold,
368 ink.reset,
369 ink.dim_on("(account meters — what opencode.ai/console reports)".into()),
370 ));
371 out.push_str(&meter_line(&ink, "5-hour", &account.rolling, report.now_ms));
372 out.push_str(&meter_line(&ink, "Weekly", &account.weekly, report.now_ms));
373 out.push_str(&meter_line(
374 &ink,
375 "Monthly",
376 &account.monthly,
377 report.now_ms,
378 ));
379 }
380 None if report.month.requests > 0 => {
384 let five_reset = report.now_ms + crate::ocuse::window::FIVE_HOUR_SECS * 1000;
385 let week_reset = report.now_ms + crate::ocuse::window::WEEKLY_SECS * 1000;
386 let monthly_dur = report
390 .period_end_ms
391 .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
392 out.push_str(&format!(
393 "\n{}GO{} {}\n",
394 ink.bold,
395 ink.reset,
396 ink.dim_on(
397 "(estimated locally — limits are per model: 5h = 20%, weekly = 50%, monthly = 100%)"
398 .into()
399 ),
400 ));
401 out.push_str(&window_line(
402 &ink,
403 "5-hour",
404 &report.five_hour,
405 aggregate_cap(report, shares.five_hour),
406 Some(five_reset),
407 None,
408 false,
409 report.now_ms,
410 ));
411 out.push_str(&window_line(
412 &ink,
413 "Weekly",
414 &report.weekly,
415 aggregate_cap(report, shares.weekly),
416 Some(week_reset),
417 None,
418 false,
419 report.now_ms,
420 ));
421 out.push_str(&window_line(
422 &ink,
423 "Monthly",
424 &report.month,
425 aggregate_cap(report, shares.monthly),
426 report.period_end_ms,
427 monthly_dur,
428 true,
429 report.now_ms,
430 ));
431 }
432 None => {}
433 }
434
435 out.push_str(&format!(
436 "\n{}MODEL LIMIT SPENT USE 5H WK REQ{}\n",
437 ink.bold, ink.reset
438 ));
439 let total_cost = report.month.cost;
440 for model in report.models.iter().take(rows) {
441 out.push_str(&model_row(&ink, model, total_cost));
442 }
443 if report.models.len() > rows {
444 out.push_str(&format!(
445 "{}\n",
446 ink.dim_on(format!("… {} more", report.models.len() - rows))
447 ));
448 }
449 out.push_str(&format!(
450 "\n{}\n",
451 ink.dim_on(
452 "ZEN is pay-as-you-go: the same rows carry its spend, but the docs give no limit."
453 .into()
454 )
455 ));
456 if report.account.is_some() {
460 out.push_str(&format!(
461 "{}\n",
462 ink.dim_on(
463 "5H/WK columns: this model's own spend over the last 5h / 7 days (local, not the account's windows)."
464 .into()
465 )
466 ));
467 }
468 out
469}
470
471pub fn watch_frame(
476 report: &Report,
477 db_path: &str,
478 rows: usize,
479 colour: bool,
480 history: &[f64],
481 interval_secs: u64,
482 bar_width: usize,
483) -> String {
484 let ink = Ink::new(colour);
485 let mut out = render_text(report, db_path, rows, colour, bar_width);
486 if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
487 out.push_str(&format!(
488 "{}spend bursts ({}s samples){} {}\n",
489 ink.dim,
490 interval_secs,
491 ink.reset,
492 crate::render::sparkline(history),
493 ));
494 }
495 out.trim_end_matches('\n').to_string()
496}
497
498pub fn compact_line(report: &Report, history: &[f64], colour: bool) -> String {
503 let ink = Ink::new(colour);
504 let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
505 format!(
506 " {}{}{}",
507 ink.dim,
508 crate::render::sparkline(history),
509 ink.reset
510 )
511 } else {
512 String::new()
513 };
514 let window_share = match &report.account {
519 Some(account) => format!(
520 " · 5h {} · wk {}",
521 ink.cyan_on(pct(account.rolling.percent, 100.0)),
522 ink.cyan_on(pct(account.weekly.percent, 100.0)),
523 ),
524 None => format!(" · 5h {}", ink.cyan_on(money(report.five_hour.cost))),
525 };
526 format!(
527 "{}oc{} {} month{window_share}{spark}",
528 ink.bold,
529 ink.reset,
530 ink.cyan_on(money(report.month.cost)),
531 )
532}
533
534pub fn statusline_template(
548 report: &Report,
549 plan_id: &str,
550 tpl: &str,
551 colours: bool,
552 ascii: bool,
553 bar_width: usize,
554) -> String {
555 let ink = Ink::new(colours);
556 let shares = &zen::catalog().go.window_share;
557 let month_cap = aggregate_cap(report, shares.monthly);
558 let five_cap = aggregate_cap(report, shares.five_hour);
559 let week_cap = aggregate_cap(report, shares.weekly);
560 let cash = |v: f64| ink.cyan_on(money(v));
561 let pct_of = |used: f64, cap: Option<f64>| match cap {
562 Some(cap) => pct(used, cap),
563 None => "—".into(),
564 };
565 let bar_of = |used: f64, cap: Option<f64>| match cap.filter(|c| *c > 0.0) {
566 Some(cap) => crate::report_render::bar(used / cap * 100.0, bar_width, ascii, colours),
567 None => String::new(),
568 };
569 let account = report.account.as_ref();
574 let share_of =
575 |meter: Option<&crate::ocuse::api::Meter>, used: f64, cap: Option<f64>| match meter {
576 Some(meter) => pct(meter.percent, 100.0),
577 None => pct_of(used, cap),
578 };
579 let share_bar =
580 |meter: Option<&crate::ocuse::api::Meter>, used: f64, cap: Option<f64>| match meter {
581 Some(meter) => crate::report_render::bar(meter.percent, bar_width, ascii, colours),
582 None => bar_of(used, cap),
583 };
584 let top_model = || {
585 report
586 .models
587 .first()
588 .map(|m| m.id.clone())
589 .unwrap_or_else(|| "—".into())
590 };
591 crate::report_render::render_template(tpl, |key| match key {
592 "plan" => plan_id.to_string(),
593 "period" => report.period_label.clone(),
594 "month" => cash(report.month.cost),
595 "month_pct" => share_of(account.map(|a| &a.monthly), report.month.cost, month_cap),
596 "month_bar" => share_bar(account.map(|a| &a.monthly), report.month.cost, month_cap),
597 "five_hour" | "5h" => cash(report.five_hour.cost),
598 "five_hour_pct" | "5h_pct" => {
599 share_of(account.map(|a| &a.rolling), report.five_hour.cost, five_cap)
600 }
601 "five_hour_bar" | "5h_bar" => {
602 share_bar(account.map(|a| &a.rolling), report.five_hour.cost, five_cap)
603 }
604 "weekly" | "wk" => cash(report.weekly.cost),
605 "weekly_pct" | "wk_pct" => {
606 share_of(account.map(|a| &a.weekly), report.weekly.cost, week_cap)
607 }
608 "weekly_bar" | "wk_bar" => {
609 share_bar(account.map(|a| &a.weekly), report.weekly.cost, week_cap)
610 }
611 "models" => top_model(),
612 _ => String::new(),
613 })
614}
615
616pub fn statusline_json(report: &Report, plan_id: &str) -> String {
621 let shares = &zen::catalog().go.window_share;
622 serde_json::json!({
623 "plan": plan_id,
624 "period": report.period_label,
625 "monthSpent": report.month.cost,
626 "monthCap": aggregate_cap(report, shares.monthly),
627 "fiveHourSpent": report.five_hour.cost,
628 "fiveHourCap": aggregate_cap(report, shares.five_hour),
629 "weeklySpent": report.weekly.cost,
630 "weeklyCap": aggregate_cap(report, shares.weekly),
631 "model": report.models.first().map(|m| m.id.clone()),
632 })
633 .to_string()
634}
635
636pub fn render_json(report: &Report) -> String {
638 let totals = |t: &Totals| serde_json::json!({ "cost": t.cost, "requests": t.requests, "tokens": t.tokens() });
639 let model = |m: &ModelUsage| {
640 serde_json::json!({
641 "id": m.id,
642 "provider": m.provider,
643 "limit": m.limit,
644 "month": totals(&m.month),
645 "fiveHour": totals(&m.five_hour),
646 "weekly": totals(&m.weekly),
647 })
648 };
649 let meter = |m: &crate::ocuse::api::Meter| {
653 serde_json::json!({
654 "status": m.status,
655 "percent": m.percent,
656 "resetsAt": m.reset_ms(),
657 })
658 };
659 serde_json::to_string_pretty(&serde_json::json!({
660 "source": "opencode.db",
661 "generatedAt": report.now_ms,
662 "periodStart": report.since_ms,
663 "periodStartAt": report.since_ms,
668 "periodEndAt": report.period_end_ms,
669 "periodInferred": report.month_inferred,
670 "totals": {
671 "month": totals(&report.month),
672 "fiveHour": totals(&report.five_hour),
673 "weekly": totals(&report.weekly),
674 },
675 "account": report.account.as_ref().map(|a| serde_json::json!({
678 "rolling": meter(&a.rolling),
679 "weekly": meter(&a.weekly),
680 "monthly": meter(&a.monthly),
681 })),
682 "models": report.models.iter().map(model).collect::<Vec<_>>(),
683 }))
684 .expect("json")
685}
686
687fn shorten(path: &str) -> String {
688 match std::env::var("HOME") {
689 Ok(home) if !home.is_empty() => path.replacen(&home, "~", 1),
690 _ => path.to_string(),
691 }
692}
693
694pub fn plans_text(colour: bool) -> String {
701 let ink = Ink::new(colour);
702 let catalog = crate::ocuse::zen::catalog();
703 let mut out = format!(
704 "{}OpenCode plans{} {}\n",
705 ink.bold,
706 ink.reset,
707 ink.dim_on(format!("(docs, extracted {})", catalog.extracted_at)),
708 );
709 out.push_str(&format!(
710 "{}windows (both plans): 5h {} · weekly {} · monthly {}{}\n",
711 ink.dim,
712 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.five_hour * 100.0)),
713 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.weekly * 100.0)),
714 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.monthly * 100.0)),
715 ink.reset,
716 ));
717 for plan in catalog.go.plans.values() {
718 out.push_str(&format!(
719 "\n{}{}{} — {}/month\n",
720 ink.bold,
721 plan.label,
722 ink.reset,
723 ink.cyan_on(format!("${:.2}", plan.price_usd)),
724 ));
725 out.push_str(&format!(
726 "{}MODEL LIMIT IN OUT CACHE{}\n",
727 ink.bold, ink.reset
728 ));
729 for model in &catalog.go.models {
730 let rate = model.variants.first();
731 let limit = plan
732 .allowance_by_model
733 .get(&model.id)
734 .map(|limit| cmduse_core::money(*limit))
735 .unwrap_or_else(|| "—".into());
736 out.push_str(&format!(
737 "{:<28} {} {} {} {}\n",
738 model.id,
739 ink.cyan_on(format!("{limit:>7}")),
740 ink.cyan_on(format!(
741 "{:>6}",
742 rate.map(|r| format!("${}", r.input)).unwrap_or_default()
743 )),
744 ink.cyan_on(format!(
745 "{:>6}",
746 rate.map(|r| format!("${}", r.output)).unwrap_or_default()
747 )),
748 ink.cyan_on(format!(
749 "{:>8}",
750 rate.map(|r| format!("${}", r.cache_read))
751 .unwrap_or_default()
752 )),
753 ));
754 }
755 }
756 out.push_str(&format!(
757 "\n{}\n",
758 ink.dim_on(format!(
759 "ZEN — pay-as-you-go, {} models priced per 1M tokens",
760 catalog.zen.models.len()
761 ))
762 ));
763 out
764}
765
766pub fn plans_json() -> String {
770 let catalog = crate::ocuse::zen::catalog();
771 let plans: Vec<serde_json::Value> = catalog
772 .go
773 .plans
774 .iter()
775 .map(|(id, plan)| {
776 serde_json::json!({
777 "id": id,
778 "label": plan.label,
779 "priceUsd": plan.price_usd,
780 "allowanceByModel": &plan.allowance_by_model,
781 })
782 })
783 .collect();
784 serde_json::json!({
785 "extractedAt": catalog.extracted_at,
786 "windows": {
787 "fiveHour": catalog.go.window_share.five_hour,
788 "weekly": catalog.go.window_share.weekly,
789 "monthly": catalog.go.window_share.monthly,
790 },
791 "plans": plans,
792 "models": catalog.go.models.iter().map(rate_json).collect::<Vec<_>>(),
793 "zen": catalog.zen.models.iter().map(rate_json).collect::<Vec<_>>(),
794 })
795 .to_string()
796}
797
798fn rate_json(model: &zen::Model) -> serde_json::Value {
800 let rate = model.variants.first();
801 serde_json::json!({
802 "id": model.id,
803 "name": model.name,
804 "input": rate.map(|r| r.input),
805 "output": rate.map(|r| r.output),
806 "cacheRead": rate.map(|r| r.cache_read),
807 "cacheWrite": rate.map(|r| r.cache_write),
808 })
809}
810
811struct ModelRow {
814 id: String,
815 name: String,
816 input: Option<f64>,
817 output: Option<f64>,
818 cache_read: Option<f64>,
819 limit: Option<f64>,
820}
821
822fn model_rows(plan_id: &str, gated: bool) -> Vec<ModelRow> {
825 let catalog = crate::ocuse::zen::catalog();
826 let plan = catalog.plan(plan_id);
827 let mut rows: Vec<ModelRow> = catalog
828 .go
829 .models
830 .iter()
831 .map(|m| {
832 let rate = m.variants.first();
833 ModelRow {
834 id: m.id.clone(),
835 name: m.name.clone(),
836 input: rate.map(|r| r.input),
837 output: rate.map(|r| r.output),
838 cache_read: rate.map(|r| r.cache_read),
839 limit: plan.and_then(|p| p.allowance_by_model.get(&m.id).copied()),
840 }
841 })
842 .collect();
843 if gated {
844 rows.retain(|r| r.limit.is_some());
845 }
846 rows
847}
848
849pub fn models_text(plan_id: &str, gated: bool, colour: bool) -> String {
853 let ink = Ink::new(colour);
854 let catalog = crate::ocuse::zen::catalog();
855 let label = catalog
856 .plan(plan_id)
857 .map(|p| p.label.clone())
858 .unwrap_or_else(|| plan_id.to_string());
859 let granted = catalog
860 .plan(plan_id)
861 .map(|p| p.allowance_by_model.len())
862 .unwrap_or(0);
863 let rows = model_rows(plan_id, gated);
864 let mut out = format!(
865 "{}{label} models{} {}\n",
866 ink.bold,
867 ink.reset,
868 ink.dim_on(format!(
869 "(plan {plan_id} · {granted}/{} granted)",
870 catalog.go.models.len()
871 )),
872 );
873 out.push_str(&format!(
874 "{}{:<30} {:<28} {:>7} {:>7} {:>7} {:>8} {:>7}{}\n",
875 ink.bold, "MODEL", "ID", "IN", "OUT", "CACHE", "LIMIT", "GRANTED", ink.reset
876 ));
877 let rate_cell = |v: Option<f64>| match v {
878 Some(v) => ink.cyan_on(format!("{:>7}", format!("${v}"))),
879 None => ink.dim_on(format!("{:>7}", "—")),
880 };
881 for r in &rows {
882 let limit = match r.limit {
883 Some(l) => ink.cyan_on(format!("{:>8}", cmduse_core::money(l))),
884 None => ink.dim_on(format!("{:>8}", "—")),
885 };
886 let granted = if r.limit.is_some() { "yes" } else { "no" };
887 out.push_str(&format!(
888 "{:<30} {:<28} {} {} {} {} {:>7}\n",
889 r.name,
890 r.id,
891 rate_cell(r.input),
892 rate_cell(r.output),
893 rate_cell(r.cache_read),
894 limit,
895 granted,
896 ));
897 }
898 out
899}
900
901pub fn models_json(plan_id: &str, gated: bool) -> String {
905 let mut out = String::new();
906 for r in model_rows(plan_id, gated) {
907 out.push_str(
908 &serde_json::json!({
909 "id": r.id,
910 "name": r.name,
911 "input": r.input,
912 "output": r.output,
913 "cacheRead": r.cache_read,
914 "limit": r.limit,
915 "granted": r.limit.is_some(),
916 })
917 .to_string(),
918 );
919 out.push('\n');
920 }
921 out
922}
923
924pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
925 let ink = Ink::new(colour);
926 let Some(model) = report.models.iter().find(|m| m.id == id) else {
927 return format!("{id}: no usage in the period\n");
928 };
929 let limit = model
930 .limit
931 .map(cmduse_core::money)
932 .unwrap_or_else(|| "—".into());
933 let row =
934 |label: &str, value: String| format!(" {}{value}\n", ink.dim_on(format!("{label:<10}")));
935 let mut out = format!(
936 "{} {}\n",
937 ink.bold_on(model.id.clone()),
938 ink.dim_on(format!("({})", model.provider)),
939 );
940 out.push_str(&row("limit", ink.cyan_on(limit)));
941 out.push_str(&row(
942 "month",
943 ink.cyan_on(format!(
944 "{} · {} requests",
945 cmduse_core::money(model.month.cost),
946 model.month.requests
947 )),
948 ));
949 out.push_str(&row(
950 "5-hour",
951 ink.cyan_on(cmduse_core::money(model.five_hour.cost)),
952 ));
953 out.push_str(&row(
954 "weekly",
955 ink.cyan_on(cmduse_core::money(model.weekly.cost)),
956 ));
957 if report.month.cost > 0.0 {
960 out.push_str(&row(
961 "share",
962 ink.dim_on(format!(
963 "{:.1}% of period spend",
964 model.month.cost / report.month.cost * 100.0
965 )),
966 ));
967 }
968 out
969}
970
971fn totals_json(
976 cost: f64,
977 requests: u64,
978 input: u64,
979 output: u64,
980 cache_read: u64,
981) -> serde_json::Value {
982 serde_json::json!({
983 "requests": requests,
984 "tokensIn": input,
985 "tokensOut": output,
986 "cacheRead": cache_read,
987 "costUsd": cost,
988 })
989}
990
991#[derive(Default, Clone, Copy)]
994struct Bucket {
995 cost: f64,
996 requests: u64,
997 input: u64,
998 output: u64,
999 cache_read: u64,
1000}
1001
1002impl Bucket {
1003 fn add(&mut self, row: &crate::ocuse::db::Row) {
1004 self.cost += row.cost_usd;
1005 self.requests += 1;
1006 self.input += row.input;
1007 self.output += row.output;
1008 self.cache_read += row.cache_read;
1009 }
1010
1011 fn json(&self) -> serde_json::Value {
1012 totals_json(
1013 self.cost,
1014 self.requests,
1015 self.input,
1016 self.output,
1017 self.cache_read,
1018 )
1019 }
1020}
1021
1022pub fn model_report(report: &Report, id: Option<&str>, fmt: Fmt) -> String {
1030 let models: Vec<&ModelUsage> = report
1031 .models
1032 .iter()
1033 .filter(|m| id.is_none_or(|want| m.id == want))
1034 .collect();
1035 match fmt {
1036 Fmt::Json => {
1037 let map: serde_json::Map<String, serde_json::Value> = models
1038 .iter()
1039 .map(|m| {
1040 let mut body = totals_json(
1041 m.month.cost,
1042 m.month.requests,
1043 m.month.input,
1044 m.month.output,
1045 m.month.cache_read,
1046 );
1047 body["limit"] = serde_json::json!(m.limit);
1048 (m.id.clone(), body)
1049 })
1050 .collect();
1051 serde_json::json!({
1052 "scope": "local",
1053 "source": "opencode",
1054 "sinceMs": report.since_ms,
1055 "models": map,
1056 })
1057 .to_string()
1058 }
1059 Fmt::Csv => {
1060 let mut out = String::from("model,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1061 for m in models {
1062 out.push_str(&format!(
1063 "{},{},{},{},{},{:.2}\n",
1064 crate::report_render::csv_field(&m.id),
1065 m.month.requests,
1066 m.month.input,
1067 m.month.output,
1068 m.month.cache_read,
1069 m.month.cost,
1070 ));
1071 }
1072 out
1073 }
1074 Fmt::Table => String::new(), }
1076}
1077
1078pub fn bucket_text(
1086 rows: &[crate::ocuse::db::Row],
1087 now_ms: i64,
1088 lookback_ms: i64,
1089 size_ms: i64,
1090 tz_secs: i64,
1091 colour: bool,
1092 fmt: Fmt,
1093) -> String {
1094 let shift = tz_secs * 1000;
1096 let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
1097 for row in rows {
1098 let bucket = (row.at_ms + shift).div_euclid(size_ms);
1099 buckets.entry(bucket).or_default().add(row);
1100 }
1101 let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
1102 let daily = size_ms >= 86_400_000;
1103 let label = if daily { "day" } else { "hour" };
1104 let stamp = |bucket: i64| {
1105 if daily {
1106 date_of(bucket)
1107 } else {
1108 format!(
1109 "{} {:02}h",
1110 date_of(bucket.div_euclid(24)),
1111 bucket.rem_euclid(24)
1112 )
1113 }
1114 };
1115 let window: Vec<(i64, Bucket)> = buckets
1116 .range(start..)
1117 .map(|(bucket, b)| (*bucket, *b))
1118 .collect();
1119
1120 match fmt {
1121 Fmt::Json => {
1122 let mut keyed = serde_json::Map::new();
1123 let mut total = Bucket::default();
1124 for (bucket, b) in &window {
1125 keyed.insert(stamp(*bucket), b.json());
1126 total.add_bucket(b);
1127 }
1128 let key = if daily { "days" } else { "hours" };
1129 serde_json::json!({
1130 "scope": "local",
1131 "source": "opencode",
1132 "total": total.json(),
1133 key: keyed,
1134 })
1135 .to_string()
1136 }
1137 Fmt::Csv => {
1138 let head = if daily { "day" } else { "hour" };
1139 let mut out = format!("{head},requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1140 let mut total = Bucket::default();
1141 for (bucket, b) in &window {
1142 out.push_str(&format!(
1143 "{},{},{},{},{},{:.2}\n",
1144 crate::report_render::csv_field(&stamp(*bucket)),
1145 b.requests,
1146 b.input,
1147 b.output,
1148 b.cache_read,
1149 b.cost,
1150 ));
1151 total.add_bucket(b);
1152 }
1153 out.push_str(&format!(
1154 "total,{},{},{},{},{:.2}\n",
1155 total.requests, total.input, total.output, total.cache_read, total.cost,
1156 ));
1157 out
1158 }
1159 Fmt::Table => {
1160 let ink = Ink::new(colour);
1161 let mut out = format!(
1162 "{}{label:<12} spend requests tokens{}\n",
1163 ink.bold, ink.reset
1164 );
1165 for (bucket, b) in &window {
1166 out.push_str(&format!(
1167 "{:<12} {} {} {}\n",
1168 stamp(*bucket),
1169 ink.cyan_on(format!("{:>9}", cmduse_core::money(b.cost))),
1170 ink.cyan_on(format!("{:>12}", cmduse_core::compact(b.requests))),
1171 ink.cyan_on(format!(
1172 "{:>9}",
1173 cmduse_core::compact(b.input + b.output + b.cache_read)
1174 )),
1175 ));
1176 }
1177 if out.lines().count() == 1 {
1178 out.push_str(&format!(
1179 "{}\n",
1180 ink.dim_on("(no OpenCode usage in the window)".into())
1181 ));
1182 }
1183 out
1184 }
1185 }
1186}
1187
1188type SessionTotals<'a> = (
1191 f64,
1192 u64,
1193 u64,
1194 u64,
1195 u64,
1196 i64,
1197 std::collections::BTreeSet<&'a str>,
1198);
1199
1200pub fn session_text(
1205 rows: &[crate::ocuse::db::Row],
1206 limit: usize,
1207 colour: bool,
1208 fmt: Fmt,
1209) -> String {
1210 let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1211 for row in rows {
1212 let entry = by_session
1213 .entry(row.session.as_str())
1214 .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1215 entry.0 += row.cost_usd;
1216 entry.1 += 1;
1217 entry.2 += row.input;
1218 entry.3 += row.output;
1219 entry.4 += row.cache_read;
1220 entry.5 = entry.5.max(row.at_ms);
1221 entry.6.insert(row.model.as_str());
1222 }
1223 let mut sessions: Vec<_> = by_session.into_iter().collect();
1224 sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1225 sessions.truncate(limit);
1226
1227 match fmt {
1228 Fmt::Json => {
1229 let mut keyed = serde_json::Map::new();
1230 for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1231 let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1232 body["lastMs"] = serde_json::json!(*last);
1233 body["models"] = serde_json::json!(models.len());
1234 keyed.insert((*id).to_string(), body);
1235 }
1236 serde_json::json!({
1237 "scope": "local",
1238 "source": "opencode",
1239 "sessions": keyed,
1240 })
1241 .to_string()
1242 }
1243 Fmt::Csv => {
1244 let mut out =
1245 String::from("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1246 for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1247 out.push_str(&format!(
1248 "{},{},{},{},{},{:.2}\n",
1249 crate::report_render::csv_field(id),
1250 requests,
1251 input,
1252 output,
1253 cache_read,
1254 cost,
1255 ));
1256 }
1257 out
1258 }
1259 Fmt::Table => {
1260 let ink = Ink::new(colour);
1261 let mut out = format!(
1262 "{}session last models spend req{}\n",
1263 ink.bold, ink.reset
1264 );
1265 for (id, (cost, requests, _, _, _, last, models)) in sessions {
1266 let short = id.rsplit('_').next().unwrap_or(id);
1267 out.push_str(&format!(
1268 "{:<32} {:<11} {:>6} {} {:>6}\n",
1269 short,
1270 ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1271 models.len(),
1272 ink.cyan_on(format!("{:>9}", cmduse_core::money(cost))),
1273 requests,
1274 ));
1275 }
1276 if out.lines().count() == 1 {
1277 out.push_str(&format!(
1278 "{}\n",
1279 ink.dim_on("(no sessions in the window)".into())
1280 ));
1281 }
1282 out
1283 }
1284 }
1285}
1286
1287impl Bucket {
1288 fn add_bucket(&mut self, other: &Bucket) {
1290 self.cost += other.cost;
1291 self.requests += other.requests;
1292 self.input += other.input;
1293 self.output += other.output;
1294 self.cache_read += other.cache_read;
1295 }
1296}
1297
1298pub fn date_of(day: i64) -> String {
1301 let z = day + 719_468;
1302 let era = z.div_euclid(146_097);
1303 let doe = z.rem_euclid(146_097);
1304 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1305 let y = yoe + era * 400;
1306 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1307 let mp = (5 * doy + 2) / 153;
1308 let d = doy - (153 * mp + 2) / 5 + 1;
1309 let m = if mp < 10 { mp + 3 } else { mp - 9 };
1310 let year = if m <= 2 { y + 1 } else { y };
1311 format!("{year:04}-{m:02}-{d:02}")
1312}
1313
1314#[cfg(test)]
1315mod tests {
1316 use super::*;
1317 use crate::ocuse::db::Row;
1318
1319 fn report() -> Report {
1320 let now = 1_800_000_000_000;
1321 let rows = vec![Row {
1322 at_ms: now - 60_000,
1323 provider: "opencode-go".into(),
1324 model: "glm-5.3-flash".into(),
1325 cost_usd: 30.0,
1326 ..Default::default()
1327 }];
1328 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None)
1329 }
1330
1331 #[test]
1332 fn text_shows_the_model_against_its_limit() {
1333 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1334 assert!(text.contains("glm-5.3-flash"), "{text}");
1335 assert!(text.contains("$60"), "the model's monthly limit: {text}");
1336 assert!(text.contains("50%"), "30 of 60 is half: {text}");
1337 }
1338
1339 fn meter(status: &str, percent: f64, resets_at: &str) -> crate::ocuse::api::Meter {
1340 crate::ocuse::api::Meter {
1341 status: status.into(),
1342 percent,
1343 resets_at: Some(resets_at.into()),
1344 }
1345 }
1346
1347 fn report_with_account() -> Report {
1351 let mut report = report();
1352 report.account = Some(crate::ocuse::api::Usage {
1353 rolling: meter("ok", 5.0, "2027-01-15T12:31:00.000Z"),
1354 weekly: meter("ok", 59.0, "2027-01-18T00:00:00.000Z"),
1355 monthly: meter("ok", 29.0, "2027-02-15T08:00:00.000Z"),
1356 });
1357 report
1358 }
1359
1360 #[test]
1361 fn the_window_rows_come_from_the_account_when_it_answered() {
1362 let text = render_text(
1363 &report_with_account(),
1364 "/tmp/opencode.db",
1365 10,
1366 false,
1367 DEFAULT_BAR_WIDTH,
1368 );
1369 assert!(text.contains("5.0%"), "{text}");
1371 assert!(text.contains("59.0%"), "{text}");
1372 assert!(text.contains("29.0%"), "{text}");
1373 assert!(text.contains("account meters"), "{text}");
1374 assert!(
1375 text.contains("resets in 4h 31m"),
1376 "the 5h block's own end: {text}"
1377 );
1378 assert!(!text.contains("$16.00"), "the local 5h cap: {text}");
1381 assert!(!text.contains("$40.00"), "the local weekly cap: {text}");
1382 assert!(text.contains("not the account's windows"), "{text}");
1384 }
1385
1386 #[test]
1387 fn without_an_account_the_rows_say_they_are_local() {
1388 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1389 assert!(text.contains("estimated locally"), "{text}");
1390 assert!(text.contains("$60.00"), "the local monthly cap: {text}");
1391 assert!(!text.contains("account meters"), "{text}");
1393 assert!(!text.contains("not the account's windows"), "{text}");
1394 }
1395
1396 #[test]
1397 fn an_over_limit_meter_says_so_before_its_countdown() {
1398 let mut report = report_with_account();
1399 if let Some(account) = &mut report.account {
1400 account.rolling = meter("rate_limited", 100.0, "2027-01-15T12:31:00.000Z");
1401 }
1402 let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1403 assert!(text.contains("LIMIT EXCEEDED"), "{text}");
1404 }
1405
1406 #[test]
1407 fn json_carries_the_account_meters_and_null_without_them() {
1408 let parsed: serde_json::Value =
1409 serde_json::from_str(&render_json(&report_with_account())).unwrap();
1410 assert_eq!(parsed["account"]["rolling"]["percent"], 5.0);
1411 assert_eq!(parsed["account"]["weekly"]["percent"], 59.0);
1412 assert_eq!(parsed["account"]["monthly"]["status"], "ok");
1413 let monthly = cmduse_core::dates::parse_iso_utc("2027-02-15T08:00:00.000Z").unwrap();
1416 assert_eq!(parsed["account"]["monthly"]["resetsAt"], monthly as i64);
1417
1418 let parsed: serde_json::Value = serde_json::from_str(&render_json(&report())).unwrap();
1421 assert_eq!(parsed["account"], serde_json::Value::Null);
1422 }
1423
1424 #[test]
1425 fn statusline_prefers_the_accounts_share_over_the_local_estimate() {
1426 let tpl = "{five_hour_pct} {weekly_pct} {month_pct} {five_hour}";
1427 let account = statusline_template(
1428 &report_with_account(),
1429 "go",
1430 tpl,
1431 false,
1432 false,
1433 DEFAULT_BAR_WIDTH,
1434 );
1435 assert!(account.starts_with("5.0% 59.0% 29.0%"), "{account}");
1436 assert!(account.contains("$30.00"), "{account}");
1438
1439 let local = statusline_template(&report(), "go", tpl, false, false, DEFAULT_BAR_WIDTH);
1442 assert!(!local.starts_with("5.0%"), "{local}");
1443 }
1444
1445 #[test]
1446 fn json_and_statusline_carry_the_totals() {
1447 let json = render_json(&report());
1448 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1449 assert_eq!(parsed["totals"]["month"]["cost"], 30.0);
1450 assert_eq!(parsed["models"][0]["id"], "glm-5.3-flash");
1451 let line = statusline_template(&report(), "go", "{plan} {models}", false, false, 20);
1452 assert_eq!(line, "go glm-5.3-flash");
1453 }
1454
1455 #[test]
1456 fn json_carries_the_period_bounds() {
1457 let mut r = report();
1460 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1461 let parsed: serde_json::Value = serde_json::from_str(&render_json(&r)).unwrap();
1462 assert_eq!(parsed["periodStart"], r.since_ms);
1463 assert_eq!(parsed["periodStartAt"], r.since_ms);
1464 assert_eq!(parsed["periodEndAt"], r.period_end_ms.unwrap());
1465
1466 let mut all = report();
1469 all.period_end_ms = None;
1470 let parsed: serde_json::Value = serde_json::from_str(&render_json(&all)).unwrap();
1471 assert_eq!(parsed["periodEndAt"], serde_json::Value::Null);
1472 assert_eq!(parsed["periodStart"], all.since_ms);
1473 assert!(parsed.get("periodInferred").is_some());
1474 }
1475
1476 #[test]
1482 fn every_renderer_respects_the_colour_flag() {
1483 let mut report = report();
1484 report.period_end_ms = Some(crate::ocuse::window::next_month(report.since_ms));
1487 let rows = vec![Row {
1488 at_ms: report.now_ms - 60_000,
1489 session: "ses_fixture".into(),
1490 provider: "opencode-go".into(),
1491 model: "glm-5.3-flash".into(),
1492 cost_usd: 30.0,
1493 ..Default::default()
1494 }];
1495 let now = report.now_ms;
1496
1497 let cases: Vec<(&str, String, String)> = vec![
1498 (
1499 "render_text",
1500 render_text(&report, "/tmp/oc.db", 10, false, DEFAULT_BAR_WIDTH),
1501 render_text(&report, "/tmp/oc.db", 10, true, DEFAULT_BAR_WIDTH),
1502 ),
1503 (
1504 "statusline_template",
1505 statusline_template(
1506 &report,
1507 "go",
1508 "{period} {month} {month_pct}",
1509 false,
1510 false,
1511 20,
1512 ),
1513 statusline_template(
1514 &report,
1515 "go",
1516 "{period} {month} {month_pct}",
1517 true,
1518 false,
1519 20,
1520 ),
1521 ),
1522 (
1523 "compact_line",
1524 compact_line(&report, &[], false),
1525 compact_line(&report, &[], true),
1526 ),
1527 ("plans_text", plans_text(false), plans_text(true)),
1528 (
1529 "model_text",
1530 model_text(&report, "glm-5.3-flash", false),
1531 model_text(&report, "glm-5.3-flash", true),
1532 ),
1533 (
1534 "bucket_text/day",
1535 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Table),
1536 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Table),
1537 ),
1538 (
1539 "bucket_text/hour",
1540 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Table),
1541 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Table),
1542 ),
1543 (
1544 "session_text",
1545 session_text(&rows, 20, false, Fmt::Table),
1546 session_text(&rows, 20, true, Fmt::Table),
1547 ),
1548 (
1549 "models_text",
1550 models_text("go", false, false),
1551 models_text("go", false, true),
1552 ),
1553 ];
1554
1555 for (name, plain, coloured) in cases {
1556 assert!(
1557 !plain.contains('\x1b'),
1558 "{name} leaked escapes when off: {plain}"
1559 );
1560 assert!(
1561 coloured.contains('\x1b'),
1562 "{name} rendered nothing coloured when on: {coloured}"
1563 );
1564 }
1565
1566 let flagged: Vec<(&str, String, String)> = vec![
1569 (
1570 "bucket_text/day csv",
1571 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv),
1572 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Csv),
1573 ),
1574 (
1575 "bucket_text/hour json",
1576 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Json),
1577 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Json),
1578 ),
1579 (
1580 "session_text csv",
1581 session_text(&rows, 20, false, Fmt::Csv),
1582 session_text(&rows, 20, true, Fmt::Csv),
1583 ),
1584 (
1585 "session_text json",
1586 session_text(&rows, 20, false, Fmt::Json),
1587 session_text(&rows, 20, true, Fmt::Json),
1588 ),
1589 ];
1590 for (name, off, on) in flagged {
1591 assert!(
1592 !off.contains('\x1b'),
1593 "{name} leaked escapes with colour off: {off}"
1594 );
1595 assert!(
1596 !on.contains('\x1b'),
1597 "{name} must stay uncoloured with colour on: {on}"
1598 );
1599 }
1600 for (name, out) in [
1601 ("model_report json", model_report(&report, None, Fmt::Json)),
1602 ("model_report csv", model_report(&report, None, Fmt::Csv)),
1603 ("plans_json", plans_json()),
1604 ("statusline_json", statusline_json(&report, "go")),
1605 ("models_json", models_json("go", false)),
1606 ] {
1607 assert!(
1608 !out.contains('\x1b'),
1609 "{name} must never be coloured: {out}"
1610 );
1611 }
1612 }
1613
1614 #[test]
1615 fn bucket_reports_carry_the_documented_columns() {
1616 let now = 1_800_000_000_000;
1617 let rows = vec![Row {
1618 at_ms: now - 60_000,
1619 provider: "opencode-go".into(),
1620 model: "glm-5.3-flash".into(),
1621 cost_usd: 1.5,
1622 input: 10,
1623 output: 20,
1624 cache_read: 30,
1625 cache_write: 40,
1626 ..Default::default()
1627 }];
1628 let csv = bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv);
1629 assert!(
1630 csv.starts_with("day,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
1631 "{csv}"
1632 );
1633 assert!(!csv.contains("cache_write"), "{csv}");
1635 assert!(csv.contains(",10,20,30,1.50\n"), "{csv}");
1636 assert!(csv.trim_end().ends_with("total,1,10,20,30,1.50"), "{csv}");
1637
1638 let json: serde_json::Value = serde_json::from_str(&bucket_text(
1639 &rows,
1640 now,
1641 7 * 86_400_000,
1642 86_400_000,
1643 0,
1644 false,
1645 Fmt::Json,
1646 ))
1647 .unwrap();
1648 assert_eq!(json["total"]["requests"], 1);
1649 assert_eq!(json["total"]["cacheRead"], 30);
1650 assert_eq!(json["days"].as_object().unwrap().len(), 1);
1651 }
1652
1653 #[test]
1654 fn session_report_json_and_csv_agree_with_the_store() {
1655 let now = 1_800_000_000_000;
1656 let rows = vec![
1657 Row {
1658 at_ms: now - 60_000,
1659 session: "ses_first".into(),
1660 model: "glm-5.3-flash".into(),
1661 cost_usd: 2.0,
1662 input: 5,
1663 output: 6,
1664 cache_read: 7,
1665 ..Default::default()
1666 },
1667 Row {
1668 at_ms: now - 30_000,
1669 session: "ses_second".into(),
1670 model: "kimi-k3".into(),
1671 cost_usd: 1.0,
1672 ..Default::default()
1673 },
1674 ];
1675 let json: serde_json::Value =
1676 serde_json::from_str(&session_text(&rows, 20, false, Fmt::Json)).unwrap();
1677 assert_eq!(json["sessions"]["ses_first"]["tokensIn"], 5);
1678 assert_eq!(json["sessions"]["ses_first"]["models"], 1);
1679 assert_eq!(json["sessions"]["ses_second"]["costUsd"], 1.0);
1680 let csv = session_text(&rows, 20, false, Fmt::Csv);
1681 assert!(
1682 csv.starts_with("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
1683 "{csv}"
1684 );
1685 assert!(csv.contains("ses_second,1,0,0,0,1.00\n"), "{csv}");
1686 }
1687
1688 #[test]
1689 fn model_report_projects_the_report_models() {
1690 let json: serde_json::Value =
1691 serde_json::from_str(&model_report(&report(), None, Fmt::Json)).unwrap();
1692 assert_eq!(json["source"], "opencode");
1693 assert_eq!(json["models"]["glm-5.3-flash"]["costUsd"], 30.0);
1694 assert_eq!(json["models"]["glm-5.3-flash"]["limit"], 60.0);
1695 let csv = model_report(&report(), None, Fmt::Csv);
1696 assert!(csv.contains("glm-5.3-flash,1,0,0,0,30.00\n"), "{csv}");
1697 assert_eq!(
1699 model_report(&report(), Some("nope"), Fmt::Csv),
1700 "model,requests,tokens_in,tokens_out,cache_read,cost_usd\n"
1701 );
1702 }
1703
1704 #[test]
1705 fn catalogue_json_carries_both_plans_and_windows() {
1706 let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
1707 assert_eq!(json["windows"]["fiveHour"], 0.2);
1708 assert_eq!(json["windows"]["monthly"], 1.0);
1709 assert_eq!(json["plans"][0]["id"], "go");
1710 assert_eq!(json["plans"][0]["allowanceByModel"]["glm-5.3-flash"], 60.0);
1711 assert_eq!(json["plans"][1]["priceUsd"], 40.0);
1712 assert!(!json["models"].as_array().unwrap().is_empty());
1713 assert!(!json["zen"].as_array().unwrap().is_empty());
1714 }
1715
1716 #[test]
1717 fn models_lists_the_plan_and_marks_granted() {
1718 let all = models_text("go", false, false);
1720 assert!(all.contains("glm-5.3-flash"), "{all}");
1721 assert!(all.contains("GRANTED"), "{all}");
1722 let gated = models_text("go", true, false);
1724 for line in gated.lines().skip(2) {
1725 assert!(
1726 line.trim_end().ends_with("yes"),
1727 "ungranted row kept: {line}"
1728 );
1729 }
1730 let every: Vec<serde_json::Value> = models_json("go", false)
1732 .lines()
1733 .map(|l| serde_json::from_str(l).unwrap())
1734 .collect();
1735 assert!(every.iter().any(|m| m["granted"] == false));
1736 assert!(every
1737 .iter()
1738 .any(|m| m["id"] == "glm-5.3-flash" && m["limit"] == 60.0));
1739 let only: Vec<serde_json::Value> = models_json("go", true)
1740 .lines()
1741 .map(|l| serde_json::from_str(l).unwrap())
1742 .collect();
1743 assert!(!only.is_empty());
1744 assert!(only.iter().all(|m| m["granted"] == true));
1745 }
1746
1747 #[test]
1748 fn statusline_json_mirrors_the_template_numbers() {
1749 let json: serde_json::Value =
1750 serde_json::from_str(&statusline_json(&report(), "go")).unwrap();
1751 assert_eq!(json["plan"], "go");
1752 assert_eq!(json["period"], "calendar month");
1753 assert_eq!(json["monthSpent"], 30.0);
1754 assert_eq!(json["monthCap"], 60.0);
1755 assert_eq!(json["model"], "glm-5.3-flash");
1756 }
1757
1758 #[test]
1759 fn plain_frames_carry_no_escapes() {
1760 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1761 assert!(
1762 !text.contains('\x1b'),
1763 "piped output must stay plain: {text}"
1764 );
1765 assert!(text.contains("50%"), "{text}");
1767 }
1768
1769 #[test]
1773 fn monthly_row_carries_the_real_period_metrics() {
1774 let mut r = report();
1775 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1776 let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1777
1778 let monthly = text
1779 .lines()
1780 .find(|line| line.trim_start().starts_with("Monthly"))
1781 .expect("the monthly row");
1782 assert!(monthly.contains("resets"), "{monthly}");
1783 assert!(
1784 monthly.contains("window") && monthly.contains("% elapsed"),
1785 "the real period gives an elapsed share: {monthly}"
1786 );
1787 assert!(
1788 monthly.contains("by now"),
1789 "monthly is the pro-rata budget: {monthly}"
1790 );
1791
1792 for label in ["5-hour", "Weekly"] {
1793 let row = text
1794 .lines()
1795 .find(|line| line.trim_start().starts_with(label))
1796 .unwrap_or_else(|| panic!("the {label} row"));
1797 assert!(!row.contains("elapsed"), "{label} has no boundary: {row}");
1798 assert!(!row.contains("by now"), "{label} is a throttle: {row}");
1799 }
1800 }
1801
1802 #[test]
1805 fn period_line_splits_tokens_and_shows_the_end_date() {
1806 let mut r = report();
1807 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1808 let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1809 let header = text
1810 .lines()
1811 .find(|line| line.starts_with("period:"))
1812 .expect("the period line");
1813 assert!(
1814 header.contains(" in / "),
1815 "token split like cmduse: {header}"
1816 );
1817 assert!(header.contains(" out tokens"), "{header}");
1818 assert!(
1819 header.contains(&date_of(r.period_end_ms.unwrap() / 86_400_000)),
1820 "the resolved end date: {header}"
1821 );
1822
1823 let no_end = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1825 let header = no_end
1826 .lines()
1827 .find(|line| line.starts_with("period:"))
1828 .expect("the period line");
1829 assert!(!header.contains("ends"), "{header}");
1830 }
1831
1832 #[test]
1833 fn an_over_cap_window_is_flagged_in_both_modes() {
1834 let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1836 assert!(plain.contains("LIMIT EXCEEDED"), "{plain}");
1837 let coloured = render_text(&report(), "/tmp/opencode.db", 10, true, DEFAULT_BAR_WIDTH);
1838 assert!(coloured.contains("LIMIT EXCEEDED"), "{coloured}");
1839 assert!(
1840 coloured.contains(crate::render::RED),
1841 "the flag and the gauge are red: {coloured}"
1842 );
1843 }
1844
1845 #[test]
1846 fn a_model_over_any_of_its_own_windows_is_flagged() {
1847 let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1850 let row = plain
1851 .lines()
1852 .find(|line| line.starts_with("glm-5.3-flash"))
1853 .expect("the model row");
1854 assert!(row.contains("LIMIT EXCEEDED"), "{row}");
1855 assert!(row.trim_end().ends_with('%'), "share ends the row: {row}");
1857 }
1858
1859 #[test]
1860 fn a_model_within_its_limits_is_not_flagged() {
1861 let now = 1_800_000_000_000;
1862 let rows = vec![Row {
1864 at_ms: now - 60_000,
1865 provider: "opencode-go".into(),
1866 model: "glm-5.3-flash".into(),
1867 cost_usd: 5.0,
1868 ..Default::default()
1869 }];
1870 let report =
1871 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None);
1872 for colour in [false, true] {
1873 let text = render_text(&report, "/tmp/opencode.db", 10, colour, DEFAULT_BAR_WIDTH);
1874 assert!(!text.contains("LIMIT EXCEEDED"), "colour={colour}: {text}");
1875 }
1876 }
1877
1878 #[test]
1879 fn the_model_detail_spells_out_the_share() {
1880 let detail = model_text(&report(), "glm-5.3-flash", false);
1881 assert!(detail.contains("100.0% of period spend"), "{detail}");
1882 let empty = crate::ocuse::window::build(
1884 &[],
1885 &zen::catalog(),
1886 zen::DEFAULT_PLAN,
1887 1_800_000_000_000,
1888 None,
1889 );
1890 assert!(!model_text(&empty, "glm-5.3-flash", false).contains("share"));
1891 }
1892
1893 #[test]
1894 fn colour_frames_get_the_palette_and_a_gauge() {
1895 let text = render_text(&report(), "/tmp/opencode.db", 10, true, DEFAULT_BAR_WIDTH);
1896 assert!(text.contains(crate::render::BOLD), "{text}");
1897 assert!(text.contains(crate::render::CYAN), "{text}");
1898 assert!(
1899 text.contains(crate::render::GREEN),
1900 "the 50% monthly gauge and USE cell are green: {text}"
1901 );
1902 assert!(
1903 text.contains(crate::render::RED),
1904 "the over-cap 5-hour gauge (30 of 12) is red: {text}"
1905 );
1906 assert!(
1907 text.contains('━') && text.contains('╱'),
1908 "gauge bar: {text}"
1909 );
1910 assert!(text.contains("resets in"), "{text}");
1911 }
1912
1913 #[test]
1914 fn watch_frame_ends_on_the_status_line_and_shows_bursts() {
1915 let frame = watch_frame(
1916 &report(),
1917 "/tmp/opencode.db",
1918 10,
1919 true,
1920 &[0.0, 1.5, 0.0],
1921 10,
1922 DEFAULT_BAR_WIDTH,
1923 );
1924 assert!(
1925 !frame.ends_with('\n'),
1926 "the caller's status line owns the last row"
1927 );
1928 assert!(frame.contains("spend bursts (10s samples)"), "{frame}");
1929 assert!(
1930 frame.contains('█'),
1931 "a nonzero delta shows as a tall bar: {frame}"
1932 );
1933 }
1934
1935 #[test]
1936 fn idle_history_omits_the_sparkline() {
1937 let all_idle = watch_frame(
1938 &report(),
1939 "/tmp/opencode.db",
1940 10,
1941 true,
1942 &[0.0, 0.0],
1943 10,
1944 DEFAULT_BAR_WIDTH,
1945 );
1946 assert!(!all_idle.contains("spend bursts"), "{all_idle}");
1947 let first_frame = watch_frame(
1948 &report(),
1949 "/tmp/opencode.db",
1950 10,
1951 true,
1952 &[],
1953 10,
1954 DEFAULT_BAR_WIDTH,
1955 );
1956 assert!(!first_frame.contains("spend bursts"), "{first_frame}");
1957 }
1958
1959 #[test]
1963 fn statusline_resolves_every_placeholder() {
1964 let r = report();
1965 let line = |tpl: &str| statusline_template(&r, "go", tpl, false, false, 20);
1966
1967 assert_eq!(line("{plan}"), "go");
1969 assert_eq!(line("{period}"), "calendar month");
1970 assert_eq!(line("{month}"), "$30.00");
1972 assert_eq!(line("{five_hour}"), "$30.00");
1973 assert_eq!(line("{weekly}"), "$30.00");
1974 assert_eq!(line("{models}"), "glm-5.3-flash");
1976 assert_eq!(line("{month_pct}"), "50.0%");
1978 let bar = line("{month_bar}");
1980 assert!(bar.contains("50.0%") && bar.contains('━'), "{bar}");
1981
1982 let default = line("{period} {month} · 5h {five_hour} · wk {weekly}");
1984 assert_eq!(default, "calendar month $30.00 · 5h $30.00 · wk $30.00");
1985
1986 assert_eq!(line("{plan} {bogus} | tail"), "go | tail");
1988 assert_eq!(line("{plan} {oops"), "go {oops");
1990 assert!(!line("{plan} {month} {month_pct} {month_bar}").contains('\x1b'));
1992 }
1993
1994 #[test]
1997 fn statusline_without_caps_degrades_gracefully() {
1998 let empty = crate::ocuse::window::build(
1999 &[],
2000 &zen::catalog(),
2001 zen::DEFAULT_PLAN,
2002 1_800_000_000_000,
2003 None,
2004 );
2005 let line = statusline_template(&empty, "go", "{month_pct} {models}", false, false, 20);
2006 assert_eq!(line, "— —");
2007 }
2008
2009 #[test]
2011 fn compact_line_is_one_line_with_inline_sparkline() {
2012 let plain = compact_line(&report(), &[], true);
2013 assert_eq!(plain.lines().count(), 1, "{plain}");
2014 assert!(!plain.ends_with('\n'), "{plain}");
2015 let bursty = compact_line(&report(), &[0.0, 1.5], true);
2016 assert!(bursty.contains('█'), "nonzero delta shows: {bursty}");
2017 assert!(!compact_line(&report(), &[], false).contains('\x1b'));
2019 assert!(compact_line(&report(), &[], true).contains(crate::render::BOLD));
2020 }
2021}