1use crate::arkuse::ark;
9use crate::arkuse::window::{Report, Totals};
10use crate::report_render::Fmt;
11use cmduse_core::{compact, duration, 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 {
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 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 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
111pub 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
131pub fn yuan(value: f64) -> String {
133 format!("¥{:.2}", (value * 100.0).round() / 100.0)
134}
135
136fn 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
147pub 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
160fn 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
169fn 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)]
179struct RowSpec<'a> {
187 label: &'a str,
188 used: &'a Totals,
190 meter: Option<&'a crate::arkuse::api::Meter>,
192 reset: Option<(i64, u64)>,
194 quota: Option<f64>,
198 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 let local_pct = spec
227 .quota
228 .filter(|quota| *quota > 0.0)
229 .map(|quota| spec.used.afp / quota * 100.0);
230
231 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 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
351fn 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
370fn 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
412pub 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 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 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 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 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 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 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 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
696pub 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
723pub 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
755pub 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 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 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 "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
923fn 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
950fn 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 "clients": &report.clients,
996 "plan": report.tier,
997 "profile": report.profile,
998 "generatedAt": report.now_ms,
999 "periodStart": report.since_ms,
1000 "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 "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
1060pub 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
1157fn yuan_per_million(coef: f64, afp_yuan: f64) -> f64 {
1161 1_000_000.0 * coef / 10_000.0 * afp_yuan
1162}
1163
1164pub 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
1205pub 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
1236pub 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
1287pub 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
1341pub 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
1390fn 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#[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#[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 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
1551type SessionTotals<'a> = (
1554 f64,
1555 u64,
1556 u64,
1557 u64,
1558 u64,
1559 i64,
1560 std::collections::BTreeSet<&'a str>,
1561);
1562
1563pub 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 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 #[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 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 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 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 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 #[test]
1834 fn period_line_carries_the_account_monthly_share() {
1835 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 #[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 assert_eq!(block.matches("100.0%").count(), 2, "{block}");
1885 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 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 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}