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 out.push_str(&format!(
429 "{}Volcengine ARK usage{} — agent plan {}, from {}{}{}\n",
430 ink.bold,
431 ink.reset,
432 tier_label,
433 ink.dim,
434 shorten(db_path),
435 ink.reset,
436 ));
437 let end = report
438 .period_end_ms
439 .map(|ms| {
440 format!(
441 " · ends {}",
442 date_of((ms + report.offset_secs * 1000).div_euclid(86_400_000))
443 )
444 })
445 .unwrap_or_default();
446 let start = date_of((report.since_ms + report.offset_secs * 1000).div_euclid(86_400_000));
447 let used = report
453 .account
454 .as_ref()
455 .map(|account| format!(" · {}% of limit", c(given_pct(account.monthly.percent))))
456 .unwrap_or_default();
457 out.push_str(&format!(
458 "period: {start}{end} · {} requests · {} AFP ({}) · {} in / {} out tokens{used}\n",
459 c(compact(report.month.requests)),
460 c(afp(report.month.afp)),
461 c(yuan(catalog.yuan(report.month.afp))),
462 c(compact(report.month.input)),
463 c(compact(report.month.output)),
464 ));
465 if let Some(plan) = catalog.plan(&report.tier) {
466 out.push_str(&format!(
467 "{}plan: {} {} · {} AFP/mo · {} · {} windows{}",
468 ink.dim,
469 plan.label,
470 yuan(plan.price),
471 afp(plan.afp),
472 catalog.currency,
473 if report.account.is_some() {
474 "metered by the control plane"
475 } else {
476 "local estimate"
477 },
478 ink.reset,
479 ));
480 out.push('\n');
481 }
482
483 match &report.account {
484 Some(account) => {
485 out.push_str(&format!(
486 "\n{}AGENT PLAN{} {}\n",
487 ink.bold,
488 ink.reset,
489 ink.dim_on(
490 "(account meters — what the Volcengine console reports; AFP beside them is this store's own estimate)"
491 .into()
492 ),
493 ));
494 let monthly_dur = report
495 .period_end_ms
496 .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
497 let daily_local = report.daily.clone().unwrap_or_default();
498 let mut windows: Vec<(&str, &crate::arkuse::api::Meter, &Totals, Option<u64>, bool)> =
499 vec![(
500 "5-hour",
501 &account.five_hour,
502 &report.five_hour,
503 Some(crate::arkuse::window::FIVE_HOUR_SECS as u64),
504 false,
505 )];
506 if let Some(daily) = &account.daily {
507 windows.push((
508 "Daily",
509 daily,
510 &daily_local,
511 Some(crate::arkuse::window::DAY_SECS as u64),
512 false,
513 ));
514 }
515 windows.push((
516 "Weekly",
517 &account.weekly,
518 &report.weekly,
519 Some(crate::arkuse::window::WEEKLY_SECS as u64),
520 false,
521 ));
522 windows.push((
523 "Monthly",
524 &account.monthly,
525 &report.month,
526 monthly_dur,
527 true,
528 ));
529 for (label, meter, used, dur, pro_rata) in windows {
530 let reset = meter_reset(meter, dur)
535 .or_else(|| dur.and_then(|dur| rolling(report, used, dur as i64)));
536 out.push_str(&window_line(
537 &ink,
538 &RowSpec {
539 label,
540 used,
541 meter: Some(meter),
542 reset,
543 quota: None,
544 pro_rata,
545 },
546 report.now_ms,
547 &catalog,
548 ));
549 }
550 let mut notes: Vec<String> = Vec::new();
554 if let Some(delta) = report.reconcile() {
555 notes.push(format!("local vs meter {:+.1}%", delta));
556 }
557 if let Some(per) = report.afp_per_request() {
558 notes.push(format!("{} AFP/request", afp(per)));
559 }
560 if let Some(burn) = report.burn_per_day() {
561 notes.push(format!("{} AFP/day at this pace", afp(burn)));
562 }
563 if !notes.is_empty() {
564 out.push_str(&format!(
565 "{} {} · {}{}\n",
566 ink.dim,
567 account.profile,
568 notes.join(" · "),
569 ink.reset,
570 ));
571 }
572 }
573 None => {
574 out.push_str(&format!(
575 "\n{}AGENT PLAN{} {}\n",
576 ink.bold,
577 ink.reset,
578 ink.dim_on("(estimated locally: no control-plane answer, so AFP alone)".into()),
579 ));
580 if let Some(why) = &report.account_error {
581 out.push_str(&format!("{} usage API: {why}{}\n", ink.dim, ink.reset));
582 }
583 let monthly_dur = report
584 .period_end_ms
585 .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
586 let five_reset = rolling(
590 report,
591 &report.five_hour,
592 crate::arkuse::window::FIVE_HOUR_SECS,
593 );
594 let week_reset = rolling(report, &report.weekly, crate::arkuse::window::WEEKLY_SECS);
595 out.push_str(&window_line(
596 &ink,
597 &RowSpec {
598 label: "5-hour",
599 used: &report.five_hour,
600 meter: None,
601 reset: five_reset,
602 quota: report.window_quota(&catalog, catalog.windows.five_hour),
603 pro_rata: false,
604 },
605 report.now_ms,
606 &catalog,
607 ));
608 out.push_str(&window_line(
609 &ink,
610 &RowSpec {
611 label: "Weekly",
612 used: &report.weekly,
613 meter: None,
614 reset: week_reset,
615 quota: report.window_quota(&catalog, catalog.windows.weekly),
616 pro_rata: false,
617 },
618 report.now_ms,
619 &catalog,
620 ));
621 out.push_str(&window_line(
622 &ink,
623 &RowSpec {
624 label: "Monthly",
625 used: &report.month,
626 meter: None,
627 reset: report.period_end_ms.zip(monthly_dur),
628 quota: report.window_quota(&catalog, catalog.windows.monthly),
629 pro_rata: true,
630 },
631 report.now_ms,
632 &catalog,
633 ));
634 }
635 }
636
637 out.push_str(&top_models(&ink, report, 3));
638 out.push_str(&format!(
642 "\n{}{:<28} {:>5} {:>9} {:>7}{}\n",
643 ink.bold, "MODEL", "COEF", "AFP", "REQ", ink.reset
644 ));
645 for model in report.models.iter().take(rows) {
646 out.push_str(&model_row(&ink, model));
647 }
648 if report.models.len() > rows {
649 out.push_str(&format!(
650 "{}\n",
651 ink.dim_on(format!("… {} more", report.models.len() - rows))
652 ));
653 }
654 out.push_str(&format!(
656 "\n{}\n",
657 ink.dim_on(
658 "AFP = (input + cache_read + output) × coefficient / 10,000; the coefficient is the plan's own and a promo halves it until its date."
659 .into()
660 ),
661 ));
662 if report.account.is_some() {
663 out.push_str(&format!(
664 "{}\n",
665 ink.dim_on(
666 "The windows are the account's own cuts (5h block, Monday week, subscription month); the model AFP is this machine inside them."
667 .into()
668 )
669 ));
670 } else {
671 out.push_str(&format!(
672 "{}\n",
673 ink.dim_on(
674 "Windows are rolling 5h / 7d / calendar month cuts, local only — not the control plane's anchors."
675 .into()
676 )
677 ));
678 }
679 out
680}
681
682pub fn watch_frame(
687 report: &Report,
688 db_path: &str,
689 rows: usize,
690 colour: bool,
691 history: &[f64],
692 interval_secs: u64,
693 bar_width: usize,
694) -> String {
695 let ink = Ink::new(colour);
696 let mut out = render_text(report, db_path, rows, colour, bar_width);
697 if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
698 out.push_str(&format!(
699 "{}AFP bursts ({}s samples){} {}\n",
700 ink.dim,
701 interval_secs,
702 ink.reset,
703 crate::render::sparkline(history),
704 ));
705 }
706 out.trim_end_matches('\n').to_string()
707}
708
709pub fn compact_line(report: &Report, history: &[f64], colour: bool) -> String {
711 let ink = Ink::new(colour);
712 let catalog = ark::catalog();
713 let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
714 format!(
715 " {}{}{}",
716 ink.dim,
717 crate::render::sparkline(history),
718 ink.reset
719 )
720 } else {
721 String::new()
722 };
723 let window_share = match &report.account {
724 Some(account) => format!(
725 " · 5h {} · wk {} · mo {}",
726 ink.cyan_on(format!("{}%", given_pct(account.five_hour.percent))),
727 ink.cyan_on(format!("{}%", given_pct(account.weekly.percent))),
728 ink.cyan_on(format!("{}%", given_pct(account.monthly.percent))),
729 ),
730 None => format!(" · 5h {}", ink.cyan_on(afp(report.five_hour.afp))),
731 };
732 format!(
733 "{}ARK{} {} AFP ({}) month{window_share}{spark}",
734 ink.bold,
735 ink.reset,
736 ink.cyan_on(afp(report.month.afp)),
737 ink.cyan_on(yuan(catalog.yuan(report.month.afp))),
738 )
739}
740
741pub fn statusline_template(
753 report: &Report,
754 plan_id: &str,
755 tpl: &str,
756 colours: bool,
757 ascii: bool,
758 bar_width: usize,
759) -> String {
760 let ink = Ink::new(colours);
761 let catalog = ark::catalog();
762 let account = report.account.as_ref();
763 let quota_of = |share: f64| report.window_quota(&catalog, share);
764 let pct_of = |meter: Option<&crate::arkuse::api::Meter>, used: f64, share: f64| match meter {
768 Some(meter) => Some(meter.percent),
769 None => quota_of(share)
770 .filter(|quota| *quota > 0.0)
771 .map(|quota| used / quota * 100.0),
772 };
773 let share_of = |pct: Option<f64>| match pct {
774 Some(pct) => format!("{}%", given_pct(pct)),
775 None => "—".into(),
776 };
777 let share_bar = |pct: Option<f64>| match pct {
778 Some(pct) => crate::report_render::bar(pct, bar_width, ascii, colours),
779 None => String::new(),
780 };
781 let reset_of =
784 |meter: Option<&crate::arkuse::api::Meter>, totals: &Totals, dur: i64| match meter
785 .and_then(|m| m.reset_ms())
786 {
787 Some(at) => Some((at, dur as u64)),
788 None => report.rolling_reset(totals, dur).map(|at| (at, dur as u64)),
789 };
790 let eta_of = |reset: Option<(i64, u64)>, used: f64, quota: Option<f64>| -> Option<String> {
791 let (at, dur) = reset?;
792 let quota = quota?;
793 let secs = cmduse_core::pace_eta(
794 Some(at as f64),
795 dur,
796 used,
797 quota,
798 (report.now_ms / 1000).max(0) as u64,
799 )?;
800 Some(format!("on pace to hit cap in {}", duration(secs as u64)))
801 };
802 let h5_pct = pct_of(
803 account.map(|a| &a.five_hour),
804 report.five_hour.afp,
805 catalog.windows.five_hour,
806 );
807 let wk_pct = pct_of(
808 account.map(|a| &a.weekly),
809 report.weekly.afp,
810 catalog.windows.weekly,
811 );
812 let month_pct = pct_of(
813 account.map(|a| &a.monthly),
814 report.month.afp,
815 catalog.windows.monthly,
816 );
817 let h5_eta = eta_of(
818 reset_of(
819 account.map(|a| &a.five_hour),
820 &report.five_hour,
821 crate::arkuse::window::FIVE_HOUR_SECS,
822 ),
823 report.five_hour.afp,
824 quota_of(catalog.windows.five_hour),
825 )
826 .unwrap_or_default();
827 let wk_eta = eta_of(
828 reset_of(
829 account.map(|a| &a.weekly),
830 &report.weekly,
831 crate::arkuse::window::WEEKLY_SECS,
832 ),
833 report.weekly.afp,
834 quota_of(catalog.windows.weekly),
835 )
836 .unwrap_or_default();
837 let cash = |value: f64| ink.cyan_on(afp(value));
838 let top = report
839 .models
840 .first()
841 .map(|m| m.id.clone())
842 .unwrap_or_else(|| "—".into());
843 let replacements: Vec<(&str, String)> = vec![
844 ("plan", plan_id.to_string()),
845 ("tier", plan_id.to_string()),
846 ("period", report.period_label.clone()),
847 ("month", cash(report.month.afp)),
848 ("month_pct", share_of(month_pct)),
849 ("month_bar", share_bar(month_pct)),
850 ("five_hour", cash(report.five_hour.afp)),
851 ("5h", cash(report.five_hour.afp)),
852 ("five_hour_pct", share_of(h5_pct)),
853 ("5h_pct", share_of(h5_pct)),
854 ("five_hour_bar", share_bar(h5_pct)),
855 ("5h_bar", share_bar(h5_pct)),
856 ("5h_eta", h5_eta),
857 ("weekly", cash(report.weekly.afp)),
858 ("wk", cash(report.weekly.afp)),
859 ("weekly_pct", share_of(wk_pct)),
860 ("wk_pct", share_of(wk_pct)),
861 ("weekly_bar", share_bar(wk_pct)),
862 ("wk_bar", share_bar(wk_pct)),
863 ("wk_eta", wk_eta),
864 ("yuan", yuan(catalog.yuan(report.month.afp))),
865 ("yuan_month", yuan(catalog.yuan(report.month.afp))),
866 ("models", top),
867 ];
868 crate::report_render::render_template(tpl, |key| {
869 let lower = key.to_ascii_lowercase();
870 replacements
871 .iter()
872 .find(|(name, _)| *name == lower)
873 .map(|(_, value)| value.clone())
874 .unwrap_or_default()
875 })
876}
877
878pub fn statusline_json(report: &Report, plan_id: &str) -> String {
879 let catalog = ark::catalog();
880 let quota_of = |share: f64| report.window_quota(&catalog, share);
881 serde_json::json!({
882 "plan": plan_id,
883 "period": report.period_label,
884 "source": "opencode",
885 "monthSpent": report.month.afp,
890 "monthCap": quota_of(catalog.windows.monthly),
891 "fiveHourSpent": report.five_hour.afp,
892 "fiveHourCap": quota_of(catalog.windows.five_hour),
893 "weeklySpent": report.weekly.afp,
894 "weeklyCap": quota_of(catalog.windows.weekly),
895 "model": report.models.first().map(|m| m.id.clone()),
896 "afp": {
897 "fiveHour": report.five_hour.afp,
898 "weekly": report.weekly.afp,
899 "monthly": report.month.afp,
900 "yuan": catalog.yuan(report.month.afp),
901 },
902 "account": report.account.as_ref().map(usage_json),
903 "accountError": report.account_error,
904 "periodEnd": report.period_end_ms,
905 })
906 .to_string()
907}
908
909fn window_json(totals: &Totals, quota: Option<f64>, reset_ms: Option<i64>) -> serde_json::Value {
914 serde_json::json!({
915 "afp": totals.afp,
916 "requests": totals.requests,
917 "quota": quota,
918 "percent": quota
919 .filter(|quota| *quota > 0.0)
920 .map(|quota| totals.afp / quota * 100.0),
921 "resetAt": reset_ms,
922 })
923}
924
925fn meter_json(meter: &crate::arkuse::api::Meter) -> serde_json::Value {
926 serde_json::json!({
927 "used": meter.used,
928 "total": meter.quota,
929 "percent": meter.percent,
930 "status": meter.status,
931 "resetsAt": meter.resets_at,
932 "resetAt": meter.reset_ms(),
933 })
934}
935
936fn usage_json(usage: &crate::arkuse::api::Usage) -> serde_json::Value {
939 serde_json::json!({
940 "profile": usage.profile,
941 "tier": usage.tier,
942 "fiveHour": meter_json(&usage.five_hour),
943 "daily": usage.daily.as_ref().map(meter_json),
944 "weekly": meter_json(&usage.weekly),
945 "monthly": meter_json(&usage.monthly),
946 })
947}
948
949pub fn render_json(report: &Report) -> String {
950 let catalog = ark::catalog();
951 let totals = |t: &Totals| {
952 serde_json::json!({
953 "afp": t.afp,
954 "requests": t.requests,
955 "tokensIn": t.input,
956 "tokensOut": t.output,
957 "cacheRead": t.cache_read,
958 "cacheWrite": t.cache_write,
959 })
960 };
961 let models: serde_json::Map<String, serde_json::Value> = report
962 .models
963 .iter()
964 .map(|m| {
965 (
966 m.id.clone(),
967 serde_json::json!({
968 "coef": m.coef,
969 "month": totals(&m.month),
970 "fiveHour": totals(&m.five_hour),
971 "weekly": totals(&m.weekly),
972 }),
973 )
974 })
975 .collect();
976 serde_json::json!({
977 "source": "opencode",
978 "scope": "local",
979 "plan": report.tier,
980 "profile": report.profile,
981 "periodStart": report.since_ms,
982 "periodEndAt": report.period_end_ms,
983 "periodEnd": report.period_end_ms
984 .map(|ms| date_of((ms + report.offset_secs * 1000).div_euclid(86_400_000))),
985 "monthInferred": report.month_inferred,
986 "summary": {
987 "afp": report.month.afp,
988 "yuan": ark::catalog().yuan(report.month.afp),
989 "requests": report.month.requests,
990 "tokensIn": report.month.input,
991 "tokensOut": report.month.output,
992 "cacheRead": report.month.cache_read,
993 },
994 "fiveHour": totals(&report.five_hour),
995 "weekly": totals(&report.weekly),
996 "monthly": totals(&report.month),
997 "daily": report.daily.as_ref().map(totals),
998 "windows": {
1002 "fiveHour": window_json(
1003 &report.five_hour,
1004 report.window_quota(&catalog, catalog.windows.five_hour),
1005 report.rolling_reset(&report.five_hour, crate::arkuse::window::FIVE_HOUR_SECS),
1006 ),
1007 "weekly": window_json(
1008 &report.weekly,
1009 report.window_quota(&catalog, catalog.windows.weekly),
1010 report.rolling_reset(&report.weekly, crate::arkuse::window::WEEKLY_SECS),
1011 ),
1012 "monthly": window_json(
1013 &report.month,
1014 report.window_quota(&catalog, catalog.windows.monthly),
1015 report.period_end_ms,
1016 ),
1017 },
1018 "account": report.account.as_ref().map(|account| {
1019 let mut body = serde_json::json!({
1020 "profile": account.profile,
1021 "tier": account.tier,
1022 "fiveHour": meter_json(&account.five_hour),
1023 "weekly": meter_json(&account.weekly),
1024 "monthly": meter_json(&account.monthly),
1025 });
1026 if let Some(daily) = &account.daily {
1027 body["daily"] = meter_json(daily);
1028 }
1029 body
1030 }),
1031 "accountError": report.account_error,
1032 "reconciledPct": report.reconcile(),
1033 "afpPerRequest": report.afp_per_request(),
1034 "models": models,
1035 })
1036 .to_string()
1037}
1038
1039pub fn plans_text(colour: bool) -> String {
1043 let ink = Ink::new(colour);
1044 let catalog = ark::catalog();
1045 let mut out = format!(
1046 "{}{:<8} {:>9} {:>9} {:>10} {:>10} {:>10}{}\n",
1047 ink.bold, "TIER", "YUAN/MO", "AFP/MO", "5H AFP", "DAILY AFP", "WK AFP", ink.reset
1048 );
1049 for plan in &catalog.plans {
1050 out.push_str(&format!(
1051 "{:<8} {:>9} {:>9} {:>10} {:>10} {:>10}\n",
1052 plan.label,
1053 ink.cyan_on(yuan(plan.price)),
1054 ink.cyan_on(afp(plan.afp)),
1055 afp(plan.afp * catalog.windows.five_hour),
1056 afp(plan.afp * catalog.windows.daily),
1057 afp(plan.afp * catalog.windows.weekly),
1058 ));
1059 if let Some(promo) = &plan.promo {
1060 out.push_str(&format!(
1061 "{}{} for {} months, until {}{}\n",
1062 ink.dim,
1063 yuan(promo.price),
1064 promo.months.unwrap_or(1),
1065 promo.until,
1066 ink.reset,
1067 ));
1068 }
1069 }
1070 out.push_str(&format!(
1071 "\n{}1 AFP = ¥{:.3} at every tier · 1,000 AFP = {}{}\n",
1072 ink.dim,
1073 catalog.afp_yuan,
1074 yuan(catalog.yuan(1000.0)),
1075 ink.reset,
1076 ));
1077 if !catalog.enterprise.is_empty() {
1078 out.push_str(&format!(
1079 "\n{}ENTERPRISE{} {}\n",
1080 ink.bold,
1081 ink.reset,
1082 ink.dim_on(
1083 "(seat-based; AFP is metered per seat and the personal usage API does not expose it)"
1084 .into()
1085 ),
1086 ));
1087 for plan in &catalog.enterprise {
1088 out.push_str(&format!(
1089 " {:<22} {}\n",
1090 plan.label,
1091 ink.cyan_on(format!("{}/seat/mo", yuan(plan.price))),
1092 ));
1093 }
1094 }
1095 if let Some(note) = catalog.notes.get("windows") {
1096 out.push_str(&format!("\n{}{}{}\n", ink.dim, note, ink.reset));
1097 }
1098 out
1099}
1100
1101pub fn plans_json() -> String {
1102 let catalog = ark::catalog();
1103 serde_json::json!({
1104 "source": catalog.source,
1105 "extractedAt": catalog.extracted_at,
1106 "currency": catalog.currency,
1107 "afpYuan": catalog.afp_yuan,
1108 "windows": {
1109 "fiveHour": catalog.windows.five_hour,
1110 "daily": catalog.windows.daily,
1111 "weekly": catalog.windows.weekly,
1112 "monthly": catalog.windows.monthly,
1113 },
1114 "plans": catalog.plans.iter().map(|p| serde_json::json!({
1115 "id": p.id,
1116 "label": p.label,
1117 "price": p.price,
1118 "listPrice": p.list_price,
1119 "afp": p.afp,
1120 "promo": p.promo.as_ref().map(|promo| serde_json::json!({
1121 "price": promo.price,
1122 "until": promo.until,
1123 "months": promo.months,
1124 })),
1125 })).collect::<Vec<_>>(),
1126 "enterprise": catalog.enterprise.iter().map(|p| serde_json::json!({
1127 "id": p.id,
1128 "label": p.label,
1129 "price": p.price,
1130 })).collect::<Vec<_>>(),
1131 "modelCount": catalog.models.len(),
1132 })
1133 .to_string()
1134}
1135
1136fn yuan_per_million(coef: f64, afp_yuan: f64) -> f64 {
1140 1_000_000.0 * coef / 10_000.0 * afp_yuan
1141}
1142
1143pub fn models_text(plan_id: &str, gated: bool, colour: bool) -> String {
1147 let ink = Ink::new(colour);
1148 let catalog = ark::catalog();
1149 let now = crate::arkuse::window::now_ms();
1150 let mut out = format!(
1151 "{}{:<30} {:>6} {:>7} {:>10} {:>8} {:>8}{}\n",
1152 ink.bold, "MODEL", "KIND", "COEF", "YUAN/1M", "MIN", "GRANTED", ink.reset
1153 );
1154 for model in &catalog.models {
1155 let granted = catalog.grants(plan_id, &model.min_plan);
1156 if gated && !granted {
1157 continue;
1158 }
1159 let coef = ark::effective_coef(model, now);
1160 let promo = model.promo_coef.map(|_| {
1161 format!(
1162 " (promo until {})",
1163 model.promo_until.as_deref().unwrap_or("?")
1164 )
1165 });
1166 let coef_cell = ink.cyan_on(format!("{coef:.2}"));
1167 out.push_str(&format!(
1168 "{:<30} {:>6} {:>7} {:>10} {:>8} {:>8}",
1169 model.id,
1170 model.kind,
1171 coef_cell,
1172 format!("{:.2}", yuan_per_million(coef, catalog.afp_yuan)),
1173 model.min_plan,
1174 if granted { "yes" } else { "no" },
1175 ));
1176 if let Some(promo) = promo {
1177 out.push_str(&format!("{}{promo}{}", ink.dim, ink.reset));
1178 }
1179 out.push('\n');
1180 }
1181 out
1182}
1183
1184pub fn models_json(plan_id: &str, gated: bool) -> String {
1187 let catalog = ark::catalog();
1188 let now = crate::arkuse::window::now_ms();
1189 let mut lines: Vec<String> = Vec::new();
1190 for model in &catalog.models {
1191 let granted = catalog.grants(plan_id, &model.min_plan);
1192 if gated && !granted {
1193 continue;
1194 }
1195 let coef = ark::effective_coef(model, now);
1196 lines.push(
1197 serde_json::json!({
1198 "id": model.id,
1199 "name": model.name,
1200 "kind": model.kind,
1201 "coef": coef,
1202 "baseCoef": model.coef,
1203 "promoCoef": model.promo_coef,
1204 "promoUntil": model.promo_until,
1205 "yuanPerMillion": yuan_per_million(coef, catalog.afp_yuan),
1206 "minPlan": model.min_plan,
1207 "granted": granted,
1208 })
1209 .to_string(),
1210 );
1211 }
1212 lines.join("\n")
1213}
1214
1215pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
1218 let ink = Ink::new(colour);
1219 let Some(model) = report.models.iter().find(|m| m.id == id) else {
1220 return format!("{id}: no usage in the period\n");
1221 };
1222 let row =
1223 |label: &str, value: String| format!(" {}{value}\n", ink.dim_on(format!("{label:<10}")));
1224 let mut out = format!(
1225 "{} {}\n",
1226 ink.bold_on(model.id.clone()),
1227 ink.dim_on(format!("({})", model.provider)),
1228 );
1229 out.push_str(&row(
1230 "coef",
1231 ink.cyan_on(format!(
1232 "{:.2} · {} per million tokens",
1233 model.coef,
1234 yuan(yuan_per_million(model.coef, ark::catalog().afp_yuan))
1235 )),
1236 ));
1237 out.push_str(&row(
1238 "month",
1239 ink.cyan_on(format!(
1240 "{} AFP ({}) · {} requests",
1241 afp(model.month.afp),
1242 yuan(ark::catalog().yuan(model.month.afp)),
1243 model.month.requests
1244 )),
1245 ));
1246 out.push_str(&row(
1247 "5-hour",
1248 ink.cyan_on(format!("{} AFP", afp(model.five_hour.afp))),
1249 ));
1250 out.push_str(&row(
1251 "weekly",
1252 ink.cyan_on(format!("{} AFP", afp(model.weekly.afp))),
1253 ));
1254 if report.month.afp > 0.0 {
1255 out.push_str(&row(
1256 "share",
1257 ink.dim_on(format!(
1258 "{:.1}% of the period's AFP",
1259 model.month.afp / report.month.afp * 100.0
1260 )),
1261 ));
1262 }
1263 out
1264}
1265
1266pub fn model_report(report: &Report, id: Option<&str>, fmt: Fmt) -> String {
1269 let models: Vec<&crate::arkuse::window::ModelUsage> = report
1270 .models
1271 .iter()
1272 .filter(|m| id.is_none_or(|want| m.id == want))
1273 .collect();
1274 match fmt {
1275 Fmt::Json => {
1276 let map: serde_json::Map<String, serde_json::Value> = models
1277 .iter()
1278 .map(|m| {
1279 (
1280 m.id.clone(),
1281 serde_json::json!({
1282 "coef": m.coef,
1283 "requests": m.month.requests,
1284 "tokensIn": m.month.input,
1285 "tokensOut": m.month.output,
1286 "cacheRead": m.month.cache_read,
1287 "afp": m.month.afp,
1288 "yuan": ark::catalog().yuan(m.month.afp),
1289 }),
1290 )
1291 })
1292 .collect();
1293 serde_json::json!({
1294 "scope": "local",
1295 "source": "opencode",
1296 "sinceMs": report.since_ms,
1297 "models": map,
1298 })
1299 .to_string()
1300 }
1301 Fmt::Csv => {
1302 let mut out = String::from("model,requests,tokens_in,tokens_out,cache_read,afp\n");
1303 for m in models {
1304 out.push_str(&format!(
1305 "{},{},{},{},{},{:.2}\n",
1306 crate::report_render::csv_field(&m.id),
1307 m.month.requests,
1308 m.month.input,
1309 m.month.output,
1310 m.month.cache_read,
1311 m.month.afp,
1312 ));
1313 }
1314 out
1315 }
1316 Fmt::Table => String::new(),
1317 }
1318}
1319
1320pub fn models_period_text(report: &Report, limit: usize, colour: bool) -> String {
1324 let ink = Ink::new(colour);
1325 let mut out = format!(
1326 "{}{:<28} {:>5} {:>9} {:>7} {:>6}{}\n",
1327 ink.bold, "MODEL", "COEF", "AFP", "REQ", "SHARE", ink.reset
1328 );
1329 let total = report.month.afp;
1330 for model in report.models.iter().take(limit.min(report.models.len())) {
1331 let share = if total > 0.0 {
1332 ink.dim_on(format!("{:>5.1}%", model.month.afp / total * 100.0))
1333 } else {
1334 " -".to_string()
1335 };
1336 out.push_str(&format!(
1337 "{:<28} {} {:>9} {:>7} {}\n",
1338 model.id,
1339 ink.cyan_on(format!("{:>5.2}", model.coef)),
1340 ink.cyan_on(format!("{:>9}", afp(model.month.afp))),
1341 ink.cyan_on(format!("{:>7}", compact(model.month.requests))),
1342 share,
1343 ));
1344 }
1345 if report.models.is_empty() {
1346 out.push_str(&format!(
1347 "{}\n",
1348 ink.dim_on("(no agent-plan usage in the period)".into())
1349 ));
1350 } else if total > 0.0 {
1351 if report.models.len() > limit {
1352 out.push_str(&format!(
1353 "{}\n",
1354 ink.dim_on(format!("… {} more", report.models.len() - limit))
1355 ));
1356 }
1357 out.push_str(&format!(
1358 "{:<28} {:>5} {:>9} {:>7} {:>6}\n",
1359 ink.bold_on("TOTAL".into()),
1360 "",
1361 afp(total),
1362 compact(report.month.requests),
1363 "100.0%",
1364 ));
1365 }
1366 out
1367}
1368
1369fn totals_json(
1372 afp: f64,
1373 requests: u64,
1374 input: u64,
1375 output: u64,
1376 cache_read: u64,
1377) -> serde_json::Value {
1378 serde_json::json!({
1379 "requests": requests,
1380 "tokensIn": input,
1381 "tokensOut": output,
1382 "cacheRead": cache_read,
1383 "afp": afp,
1384 })
1385}
1386
1387#[derive(Default, Clone, Copy)]
1390struct Bucket {
1391 afp: f64,
1392 requests: u64,
1393 input: u64,
1394 output: u64,
1395 cache_read: u64,
1396}
1397
1398impl Bucket {
1399 fn add(&mut self, catalog: &ark::Catalog, row: &crate::arkuse::db::Row) {
1400 self.requests += 1;
1401 self.input += row.input;
1402 self.output += row.output;
1403 self.cache_read += row.cache_read;
1404 self.afp += catalog
1405 .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1406 .unwrap_or(0.0);
1407 }
1408
1409 fn merge(&mut self, other: &Bucket) {
1410 self.afp += other.afp;
1411 self.requests += other.requests;
1412 self.input += other.input;
1413 self.output += other.output;
1414 self.cache_read += other.cache_read;
1415 }
1416
1417 fn json(&self) -> serde_json::Value {
1418 totals_json(
1419 self.afp,
1420 self.requests,
1421 self.input,
1422 self.output,
1423 self.cache_read,
1424 )
1425 }
1426}
1427
1428#[allow(clippy::too_many_arguments)]
1430pub fn bucket_text(
1431 rows: &[crate::arkuse::db::Row],
1432 catalog: &ark::Catalog,
1433 now_ms: i64,
1434 lookback_ms: i64,
1435 size_ms: i64,
1436 tz_secs: i64,
1437 colour: bool,
1438 fmt: Fmt,
1439) -> String {
1440 let shift = tz_secs * 1000;
1442 let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
1443 for row in rows {
1444 let bucket = (row.at_ms + shift).div_euclid(size_ms);
1445 buckets.entry(bucket).or_default().add(catalog, row);
1446 }
1447 let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
1448 let daily = size_ms >= crate::arkuse::window::DAY_SECS * 1000;
1449 let label = if daily { "day" } else { "hour" };
1450 let stamp = |bucket: i64| {
1451 if daily {
1452 date_of(bucket)
1453 } else {
1454 format!(
1455 "{} {:02}h",
1456 date_of(bucket.div_euclid(24)),
1457 bucket.rem_euclid(24)
1458 )
1459 }
1460 };
1461 let window: Vec<(i64, Bucket)> = buckets
1462 .range(start..)
1463 .map(|(bucket, b)| (*bucket, *b))
1464 .collect();
1465
1466 match fmt {
1467 Fmt::Json => {
1468 let mut keyed = serde_json::Map::new();
1469 let mut total = Bucket::default();
1470 for (bucket, b) in &window {
1471 keyed.insert(stamp(*bucket), b.json());
1472 total.merge(b);
1473 }
1474 let key = if daily { "days" } else { "hours" };
1475 serde_json::json!({
1476 "scope": "local",
1477 "source": "opencode",
1478 "total": total.json(),
1479 key: keyed,
1480 })
1481 .to_string()
1482 }
1483 Fmt::Csv => {
1484 let mut out = format!("{label},requests,tokens_in,tokens_out,cache_read,afp\n");
1485 let mut total = Bucket::default();
1486 for (bucket, b) in &window {
1487 out.push_str(&format!(
1488 "{},{},{},{},{},{:.2}\n",
1489 crate::report_render::csv_field(&stamp(*bucket)),
1490 b.requests,
1491 b.input,
1492 b.output,
1493 b.cache_read,
1494 b.afp,
1495 ));
1496 total.merge(b);
1497 }
1498 out.push_str(&format!(
1499 "total,{},{},{},{},{:.2}\n",
1500 total.requests, total.input, total.output, total.cache_read, total.afp,
1501 ));
1502 out
1503 }
1504 Fmt::Table => {
1505 let ink = Ink::new(colour);
1506 let mut out = format!(
1507 "{}{label:<12} AFP requests tokens{}\n",
1508 ink.bold, ink.reset
1509 );
1510 for (bucket, b) in &window {
1511 out.push_str(&format!(
1512 "{:<12} {} {} {}\n",
1513 stamp(*bucket),
1514 ink.cyan_on(format!("{:>9}", afp(b.afp))),
1515 ink.cyan_on(format!("{:>12}", compact(b.requests))),
1516 ink.cyan_on(format!("{:>9}", compact(b.input + b.output + b.cache_read))),
1517 ));
1518 }
1519 if out.lines().count() == 1 {
1520 out.push_str(&format!(
1521 "{}\n",
1522 ink.dim_on("(no agent-plan usage in the window)".into())
1523 ));
1524 }
1525 out
1526 }
1527 }
1528}
1529
1530type SessionTotals<'a> = (
1533 f64,
1534 u64,
1535 u64,
1536 u64,
1537 u64,
1538 i64,
1539 std::collections::BTreeSet<&'a str>,
1540);
1541
1542pub fn session_text(
1545 rows: &[crate::arkuse::db::Row],
1546 catalog: &ark::Catalog,
1547 limit: usize,
1548 colour: bool,
1549 fmt: Fmt,
1550) -> String {
1551 let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1552 for row in rows {
1553 let entry = by_session
1554 .entry(row.session.as_str())
1555 .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1556 entry.0 += catalog
1557 .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1558 .unwrap_or(0.0);
1559 entry.1 += 1;
1560 entry.2 += row.input;
1561 entry.3 += row.output;
1562 entry.4 += row.cache_read;
1563 entry.5 = entry.5.max(row.at_ms);
1564 entry.6.insert(row.model.as_str());
1565 }
1566 let mut sessions: Vec<_> = by_session.into_iter().collect();
1567 sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1568
1569 match fmt {
1570 Fmt::Json => {
1571 let mut keyed = serde_json::Map::new();
1572 for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1573 let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1574 body["lastMs"] = serde_json::json!(*last);
1575 body["models"] = serde_json::json!(models.len());
1576 keyed.insert((*id).to_string(), body);
1577 }
1578 serde_json::json!({
1579 "scope": "local",
1580 "source": "opencode",
1581 "sessions": keyed,
1582 })
1583 .to_string()
1584 }
1585 Fmt::Csv => {
1586 let mut out = String::from("session,requests,tokens_in,tokens_out,cache_read,afp\n");
1587 for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1588 out.push_str(&format!(
1589 "{},{},{},{},{},{:.2}\n",
1590 crate::report_render::csv_field(id),
1591 requests,
1592 input,
1593 output,
1594 cache_read,
1595 cost,
1596 ));
1597 }
1598 out
1599 }
1600 Fmt::Table => {
1601 let ink = Ink::new(colour);
1602 let mut out = format!(
1603 "{}session last models AFP req{}\n",
1604 ink.bold, ink.reset
1605 );
1606 for (id, (cost, requests, _, _, _, last, models)) in sessions.iter().take(limit) {
1607 let short = id.rsplit('_').next().unwrap_or(*id);
1608 out.push_str(&format!(
1609 "{:<32} {:<11} {:>6} {} {:>6}\n",
1610 short,
1611 ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1612 models.len(),
1613 ink.cyan_on(format!("{:>9}", afp(*cost))),
1614 requests,
1615 ));
1616 }
1617 if sessions.len() > limit {
1618 out.push_str(&format!(
1619 "{}\n",
1620 ink.dim_on(format!("… {} more", sessions.len() - limit))
1621 ));
1622 }
1623 if out.lines().count() == 1 {
1624 out.push_str(&format!(
1625 "{}\n",
1626 ink.dim_on("(no sessions in the window)".into())
1627 ));
1628 }
1629 out
1630 }
1631 }
1632}
1633
1634#[cfg(test)]
1635mod tests {
1636 use super::*;
1637 use crate::arkuse::db::Row;
1638
1639 fn report() -> Report {
1640 let now = 1_800_000_000_000;
1641 let rows = vec![Row {
1642 at_ms: now - 60_000,
1643 session: "ses_a".into(),
1644 provider: "agent-plan".into(),
1645 model: "deepseek-v4.1-flash".into(),
1646 input: 10_000,
1647 output: 2_000,
1648 ..Default::default()
1649 }];
1650 crate::arkuse::window::build(&rows, &ark::catalog(), ark::DEFAULT_PLAN, now, None, None)
1651 }
1652
1653 #[test]
1654 fn text_names_the_model_and_its_afp() {
1655 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1656 assert!(text.contains("deepseek-v4.1-flash"), "{text}");
1657 assert!(text.contains("AFP"), "the plan's own unit: {text}");
1658 assert!(!text.contains('$'), "ARK never prices in dollars: {text}");
1659 }
1660
1661 fn cell_ends(line: &str) -> Vec<usize> {
1663 let mut ends = Vec::new();
1664 let mut cursor = 0;
1665 for cell in line.split_whitespace() {
1666 let start = line[cursor..].find(cell).expect("cell in line") + cursor;
1667 cursor = start + cell.len();
1668 ends.push(cursor);
1669 }
1670 ends
1671 }
1672
1673 #[test]
1676 fn the_model_table_header_aligns_with_its_rows() {
1677 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1678 let mut lines = text.lines().skip_while(|line| !line.starts_with("MODEL "));
1679 let header = lines.next().expect("the table header");
1680 let row = lines
1681 .find(|line| line.starts_with("deepseek-v4.1-flash "))
1682 .expect("the model row");
1683 let header_ends = cell_ends(header);
1684 assert_eq!(
1685 header_ends[1..],
1686 cell_ends(row)[1..header_ends.len()],
1687 "header columns must end where their cells do:\n{header}\n{row}"
1688 );
1689 }
1690
1691 #[test]
1692 fn the_tier_table_carries_every_personal_plan() {
1693 let text = plans_text(false);
1694 for plan in ark::catalog().plans {
1695 assert!(text.contains(&plan.label), "{} missing: {text}", plan.label);
1696 }
1697 let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
1698 assert_eq!(
1699 json["plans"].as_array().unwrap().len(),
1700 ark::catalog().plans.len()
1701 );
1702 }
1703
1704 #[test]
1705 fn gated_models_are_filtered_by_the_tier() {
1706 let all = models_text("small", false, false);
1707 let gated = models_text("small", true, false);
1708 assert!(all.lines().count() >= gated.lines().count());
1709 assert!(all.contains("kimi-k3"), "{all}");
1710 assert!(!gated.contains("kimi-k3"), "{gated}");
1711 }
1712
1713 fn metered(account: &crate::arkuse::api::Usage) -> Report {
1715 let now = 1_800_000_000_000;
1716 let rows = vec![Row {
1717 at_ms: now - 60_000,
1718 session: "ses_a".into(),
1719 provider: "agent-plan".into(),
1720 model: "deepseek-v4.1-flash".into(),
1721 input: 10_000,
1722 output: 2_000,
1723 ..Default::default()
1724 }];
1725 crate::arkuse::window::build(
1726 &rows,
1727 &ark::catalog(),
1728 ark::DEFAULT_PLAN,
1729 now,
1730 None,
1731 Some(account),
1732 )
1733 }
1734
1735 fn meter(used: f64, quota: f64, reset: Option<&str>) -> crate::arkuse::api::Meter {
1736 let percent = if quota > 0.0 {
1737 used / quota * 100.0
1738 } else {
1739 0.0
1740 };
1741 crate::arkuse::api::Meter::from_parts("ok", percent, used, quota, reset, None)
1742 }
1743
1744 fn metered_account(account: &crate::arkuse::api::Usage) -> Report {
1748 let mut report = metered(account);
1749 report.account = Some(account.clone());
1750 report.period_end_ms = account.monthly.reset_ms();
1751 report
1752 }
1753
1754 #[test]
1755 fn a_local_row_still_reads_used_over_quota_and_a_reset() {
1756 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1759 let five = text
1760 .lines()
1761 .find(|line| line.contains("5-hour"))
1762 .expect("a 5-hour row");
1763 assert!(
1764 five.contains("/ 10,000 AFP"),
1765 "the plan's own quota: {five}"
1766 );
1767 assert!(five.contains("% of limit (est.)"), "a percentage: {five}");
1768 let weekly = text
1769 .lines()
1770 .find(|line| line.contains("Weekly"))
1771 .expect("a weekly row");
1772 assert!(weekly.contains("resets"), "a countdown: {weekly}");
1773 assert!(weekly.contains("window "), "an elapsed share: {weekly}");
1774 let json: serde_json::Value = serde_json::from_str(&render_json(&report())).unwrap();
1775 assert_eq!(json["windows"]["fiveHour"]["quota"], 10_000.0);
1776 assert!(json["windows"]["weekly"]["resetAt"].is_i64());
1777 }
1778
1779 #[test]
1780 fn a_meter_without_its_reset_falls_back_to_the_stores_boundary() {
1781 let account = crate::arkuse::api::Usage {
1784 five_hour: meter(0.0, 10_000.0, None),
1785 daily: None,
1786 weekly: meter(2_975.67, 35_000.0, Some("2026-10-26T00:00:00+08:00")),
1787 monthly: meter(2_975.67, 100_000.0, Some("2026-11-19T23:59:59+08:00")),
1788 tier: Some("medium".into()),
1789 profile: "agent-plan_cn-beijing_medium".into(),
1790 };
1791 let text = render_text(
1792 &metered(&account),
1793 "/tmp/opencode.db",
1794 10,
1795 false,
1796 DEFAULT_BAR_WIDTH,
1797 );
1798 let five = text
1799 .lines()
1800 .find(|line| line.contains("5-hour"))
1801 .expect("a 5-hour row");
1802 assert!(
1803 five.contains("0% of limit"),
1804 "the server's own share: {five}"
1805 );
1806 assert!(five.contains("resets"), "a derived countdown: {five}");
1807 }
1808
1809 #[test]
1813 fn period_line_carries_the_account_monthly_share() {
1814 let account = crate::arkuse::api::Usage {
1817 five_hour: meter(0.0, 10_000.0, None),
1818 daily: None,
1819 weekly: meter(2_975.67, 35_000.0, Some("2026-10-26T00:00:00+08:00")),
1820 monthly: meter(53_000.0, 100_000.0, Some("2026-11-19T23:59:59+08:00")),
1821 tier: Some("medium".into()),
1822 profile: "agent-plan_cn-beijing_medium".into(),
1823 };
1824 let with = render_text(
1825 &metered_account(&account),
1826 "/tmp/opencode.db",
1827 10,
1828 false,
1829 DEFAULT_BAR_WIDTH,
1830 );
1831 let header = with
1832 .lines()
1833 .find(|line| line.starts_with("period:"))
1834 .expect("the period line");
1835 assert!(header.contains("53% of limit"), "{header}");
1836 assert!(!header.contains("53.0"), "no invented decimal: {header}");
1837
1838 let without = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1839 let header = without
1840 .lines()
1841 .find(|line| line.starts_with("period:"))
1842 .expect("the period line");
1843 assert!(
1844 !header.contains("of limit"),
1845 "no account, no share: {header}"
1846 );
1847 }
1848
1849 #[test]
1852 fn top_models_state_their_share_of_afp_and_tokens() {
1853 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1854 let block = text
1855 .lines()
1856 .skip_while(|line| !line.starts_with("TOP 3 MODELS"))
1857 .take_while(|line| !line.starts_with("MODEL "))
1858 .collect::<Vec<_>>()
1859 .join("\n");
1860 assert!(block.contains("deepseek-v4.1-flash"), "{block}");
1861 assert!(block.contains("AFP") && block.contains("TOKENS"), "{block}");
1862 assert_eq!(block.matches("100.0%").count(), 2, "{block}");
1864 let row = text
1867 .lines()
1868 .skip_while(|line| !line.starts_with("MODEL "))
1869 .find(|line| line.starts_with("deepseek-v4.1-flash"))
1870 .expect("the model row");
1871 assert!(!row.trim_end().ends_with('%'), "no trailing share: {row}");
1872 assert!(row.trim_end().ends_with(char::is_numeric), "{row}");
1873 }
1874
1875 #[test]
1876 fn the_bar_carries_the_share_so_the_body_does_not_repeat_it() {
1877 let text = render_text(&report(), "/tmp/opencode.db", 10, true, DEFAULT_BAR_WIDTH);
1881 let weekly = text
1882 .lines()
1883 .find(|line| line.contains("Weekly"))
1884 .expect("a weekly row");
1885 assert!(!weekly.contains("of limit"), "no repeated share: {weekly}");
1886 assert!(weekly.contains("/ 35,000 AFP"), "the pair: {weekly}");
1887 assert!(
1888 weekly.contains("(est.)"),
1889 "still marked an estimate: {weekly}"
1890 );
1891 }
1892
1893 #[test]
1894 fn the_machine_formats_are_not_coloured() {
1895 let rows = vec![Row {
1896 at_ms: 1_800_000_000_000,
1897 session: "ses_a".into(),
1898 provider: "agent-plan".into(),
1899 model: "deepseek-v4.1-flash".into(),
1900 input: 1_000,
1901 ..Default::default()
1902 }];
1903 let catalog = ark::catalog();
1904 for fmt in [Fmt::Json, Fmt::Csv] {
1905 let daily = bucket_text(
1906 &rows,
1907 &catalog,
1908 1_800_000_000_000,
1909 86_400_000,
1910 86_400_000,
1911 0,
1912 true,
1913 fmt,
1914 );
1915 assert!(!daily.contains('\u{1b}'), "{fmt:?} leaked colour: {daily}");
1916 }
1917 let report = report();
1918 let json = model_report(&report, None, Fmt::Json);
1919 assert!(!json.contains('\u{1b}'));
1920 assert!(json.contains("deepseek-v4.1-flash"));
1921 let csv = model_report(&report, None, Fmt::Csv);
1922 assert!(csv.starts_with("model,requests,tokens_in,tokens_out,cache_read,afp"));
1923 }
1924}