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 "periodStart": report.since_ms,
999 "periodEndAt": report.period_end_ms,
1000 "periodEnd": report.period_end_ms
1001 .map(|ms| date_of((ms + report.offset_secs * 1000).div_euclid(86_400_000))),
1002 "monthInferred": report.month_inferred,
1003 "summary": {
1004 "afp": report.month.afp,
1005 "yuan": ark::catalog().yuan(report.month.afp),
1006 "requests": report.month.requests,
1007 "tokensIn": report.month.input,
1008 "tokensOut": report.month.output,
1009 "cacheRead": report.month.cache_read,
1010 },
1011 "fiveHour": totals(&report.five_hour),
1012 "weekly": totals(&report.weekly),
1013 "monthly": totals(&report.month),
1014 "daily": report.daily.as_ref().map(totals),
1015 "windows": {
1019 "fiveHour": window_json(
1020 &report.five_hour,
1021 report.window_quota(&catalog, catalog.windows.five_hour),
1022 report.rolling_reset(&report.five_hour, crate::arkuse::window::FIVE_HOUR_SECS),
1023 ),
1024 "weekly": window_json(
1025 &report.weekly,
1026 report.window_quota(&catalog, catalog.windows.weekly),
1027 report.rolling_reset(&report.weekly, crate::arkuse::window::WEEKLY_SECS),
1028 ),
1029 "monthly": window_json(
1030 &report.month,
1031 report.window_quota(&catalog, catalog.windows.monthly),
1032 report.period_end_ms,
1033 ),
1034 },
1035 "account": report.account.as_ref().map(|account| {
1036 let mut body = serde_json::json!({
1037 "profile": account.profile,
1038 "tier": account.tier,
1039 "fiveHour": meter_json(&account.five_hour),
1040 "weekly": meter_json(&account.weekly),
1041 "monthly": meter_json(&account.monthly),
1042 });
1043 if let Some(daily) = &account.daily {
1044 body["daily"] = meter_json(daily);
1045 }
1046 body
1047 }),
1048 "accountError": report.account_error,
1049 "reconciledPct": report.reconcile(),
1050 "afpPerRequest": report.afp_per_request(),
1051 "models": models,
1052 })
1053 .to_string()
1054}
1055
1056pub fn plans_text(colour: bool) -> String {
1060 let ink = Ink::new(colour);
1061 let catalog = ark::catalog();
1062 let mut out = format!(
1063 "{}{:<8} {:>9} {:>9} {:>10} {:>10} {:>10}{}\n",
1064 ink.bold, "TIER", "YUAN/MO", "AFP/MO", "5H AFP", "DAILY AFP", "WK AFP", ink.reset
1065 );
1066 for plan in &catalog.plans {
1067 out.push_str(&format!(
1068 "{:<8} {:>9} {:>9} {:>10} {:>10} {:>10}\n",
1069 plan.label,
1070 ink.cyan_on(yuan(plan.price)),
1071 ink.cyan_on(afp(plan.afp)),
1072 afp(plan.afp * catalog.windows.five_hour),
1073 afp(plan.afp * catalog.windows.daily),
1074 afp(plan.afp * catalog.windows.weekly),
1075 ));
1076 if let Some(promo) = &plan.promo {
1077 out.push_str(&format!(
1078 "{}{} for {} months, until {}{}\n",
1079 ink.dim,
1080 yuan(promo.price),
1081 promo.months.unwrap_or(1),
1082 promo.until,
1083 ink.reset,
1084 ));
1085 }
1086 }
1087 out.push_str(&format!(
1088 "\n{}1 AFP = ¥{:.3} at every tier · 1,000 AFP = {}{}\n",
1089 ink.dim,
1090 catalog.afp_yuan,
1091 yuan(catalog.yuan(1000.0)),
1092 ink.reset,
1093 ));
1094 if !catalog.enterprise.is_empty() {
1095 out.push_str(&format!(
1096 "\n{}ENTERPRISE{} {}\n",
1097 ink.bold,
1098 ink.reset,
1099 ink.dim_on(
1100 "(seat-based; AFP is metered per seat and the personal usage API does not expose it)"
1101 .into()
1102 ),
1103 ));
1104 for plan in &catalog.enterprise {
1105 out.push_str(&format!(
1106 " {:<22} {}\n",
1107 plan.label,
1108 ink.cyan_on(format!("{}/seat/mo", yuan(plan.price))),
1109 ));
1110 }
1111 }
1112 if let Some(note) = catalog.notes.get("windows") {
1113 out.push_str(&format!("\n{}{}{}\n", ink.dim, note, ink.reset));
1114 }
1115 out
1116}
1117
1118pub fn plans_json() -> String {
1119 let catalog = ark::catalog();
1120 serde_json::json!({
1121 "source": catalog.source,
1122 "extractedAt": catalog.extracted_at,
1123 "currency": catalog.currency,
1124 "afpYuan": catalog.afp_yuan,
1125 "windows": {
1126 "fiveHour": catalog.windows.five_hour,
1127 "daily": catalog.windows.daily,
1128 "weekly": catalog.windows.weekly,
1129 "monthly": catalog.windows.monthly,
1130 },
1131 "plans": catalog.plans.iter().map(|p| serde_json::json!({
1132 "id": p.id,
1133 "label": p.label,
1134 "price": p.price,
1135 "listPrice": p.list_price,
1136 "afp": p.afp,
1137 "promo": p.promo.as_ref().map(|promo| serde_json::json!({
1138 "price": promo.price,
1139 "until": promo.until,
1140 "months": promo.months,
1141 })),
1142 })).collect::<Vec<_>>(),
1143 "enterprise": catalog.enterprise.iter().map(|p| serde_json::json!({
1144 "id": p.id,
1145 "label": p.label,
1146 "price": p.price,
1147 })).collect::<Vec<_>>(),
1148 "modelCount": catalog.models.len(),
1149 })
1150 .to_string()
1151}
1152
1153fn yuan_per_million(coef: f64, afp_yuan: f64) -> f64 {
1157 1_000_000.0 * coef / 10_000.0 * afp_yuan
1158}
1159
1160pub fn models_text(plan_id: &str, gated: bool, colour: bool) -> String {
1164 let ink = Ink::new(colour);
1165 let catalog = ark::catalog();
1166 let now = crate::arkuse::window::now_ms();
1167 let mut out = format!(
1168 "{}{:<30} {:>6} {:>7} {:>10} {:>8} {:>8}{}\n",
1169 ink.bold, "MODEL", "KIND", "COEF", "YUAN/1M", "MIN", "GRANTED", ink.reset
1170 );
1171 for model in &catalog.models {
1172 let granted = catalog.grants(plan_id, &model.min_plan);
1173 if gated && !granted {
1174 continue;
1175 }
1176 let coef = ark::effective_coef(model, now);
1177 let promo = model.promo_coef.map(|_| {
1178 format!(
1179 " (promo until {})",
1180 model.promo_until.as_deref().unwrap_or("?")
1181 )
1182 });
1183 let coef_cell = ink.cyan_on(format!("{coef:.2}"));
1184 out.push_str(&format!(
1185 "{:<30} {:>6} {:>7} {:>10} {:>8} {:>8}",
1186 model.id,
1187 model.kind,
1188 coef_cell,
1189 format!("{:.2}", yuan_per_million(coef, catalog.afp_yuan)),
1190 model.min_plan,
1191 if granted { "yes" } else { "no" },
1192 ));
1193 if let Some(promo) = promo {
1194 out.push_str(&format!("{}{promo}{}", ink.dim, ink.reset));
1195 }
1196 out.push('\n');
1197 }
1198 out
1199}
1200
1201pub fn models_json(plan_id: &str, gated: bool) -> String {
1204 let catalog = ark::catalog();
1205 let now = crate::arkuse::window::now_ms();
1206 let mut lines: Vec<String> = Vec::new();
1207 for model in &catalog.models {
1208 let granted = catalog.grants(plan_id, &model.min_plan);
1209 if gated && !granted {
1210 continue;
1211 }
1212 let coef = ark::effective_coef(model, now);
1213 lines.push(
1214 serde_json::json!({
1215 "id": model.id,
1216 "name": model.name,
1217 "kind": model.kind,
1218 "coef": coef,
1219 "baseCoef": model.coef,
1220 "promoCoef": model.promo_coef,
1221 "promoUntil": model.promo_until,
1222 "yuanPerMillion": yuan_per_million(coef, catalog.afp_yuan),
1223 "minPlan": model.min_plan,
1224 "granted": granted,
1225 })
1226 .to_string(),
1227 );
1228 }
1229 lines.join("\n")
1230}
1231
1232pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
1235 let ink = Ink::new(colour);
1236 let Some(model) = report.models.iter().find(|m| m.id == id) else {
1237 return format!("{id}: no usage in the period\n");
1238 };
1239 let row =
1240 |label: &str, value: String| format!(" {}{value}\n", ink.dim_on(format!("{label:<10}")));
1241 let mut out = format!(
1242 "{} {}\n",
1243 ink.bold_on(model.id.clone()),
1244 ink.dim_on(format!("({})", model.provider)),
1245 );
1246 out.push_str(&row(
1247 "coef",
1248 ink.cyan_on(format!(
1249 "{:.2} · {} per million tokens",
1250 model.coef,
1251 yuan(yuan_per_million(model.coef, ark::catalog().afp_yuan))
1252 )),
1253 ));
1254 out.push_str(&row(
1255 "month",
1256 ink.cyan_on(format!(
1257 "{} AFP ({}) · {} requests",
1258 afp(model.month.afp),
1259 yuan(ark::catalog().yuan(model.month.afp)),
1260 model.month.requests
1261 )),
1262 ));
1263 out.push_str(&row(
1264 "5-hour",
1265 ink.cyan_on(format!("{} AFP", afp(model.five_hour.afp))),
1266 ));
1267 out.push_str(&row(
1268 "weekly",
1269 ink.cyan_on(format!("{} AFP", afp(model.weekly.afp))),
1270 ));
1271 if report.month.afp > 0.0 {
1272 out.push_str(&row(
1273 "share",
1274 ink.dim_on(format!(
1275 "{:.1}% of the period's AFP",
1276 model.month.afp / report.month.afp * 100.0
1277 )),
1278 ));
1279 }
1280 out
1281}
1282
1283pub fn model_report(report: &Report, id: Option<&str>, fmt: Fmt) -> String {
1286 let models: Vec<&crate::arkuse::window::ModelUsage> = report
1287 .models
1288 .iter()
1289 .filter(|m| id.is_none_or(|want| m.id == want))
1290 .collect();
1291 match fmt {
1292 Fmt::Json => {
1293 let map: serde_json::Map<String, serde_json::Value> = models
1294 .iter()
1295 .map(|m| {
1296 (
1297 m.id.clone(),
1298 serde_json::json!({
1299 "coef": m.coef,
1300 "requests": m.month.requests,
1301 "tokensIn": m.month.input,
1302 "tokensOut": m.month.output,
1303 "cacheRead": m.month.cache_read,
1304 "afp": m.month.afp,
1305 "yuan": ark::catalog().yuan(m.month.afp),
1306 }),
1307 )
1308 })
1309 .collect();
1310 serde_json::json!({
1311 "scope": "local",
1312 "source": "opencode",
1313 "sinceMs": report.since_ms,
1314 "models": map,
1315 })
1316 .to_string()
1317 }
1318 Fmt::Csv => {
1319 let mut out = String::from("model,requests,tokens_in,tokens_out,cache_read,afp\n");
1320 for m in models {
1321 out.push_str(&format!(
1322 "{},{},{},{},{},{:.2}\n",
1323 crate::report_render::csv_field(&m.id),
1324 m.month.requests,
1325 m.month.input,
1326 m.month.output,
1327 m.month.cache_read,
1328 m.month.afp,
1329 ));
1330 }
1331 out
1332 }
1333 Fmt::Table => String::new(),
1334 }
1335}
1336
1337pub fn models_period_text(report: &Report, limit: usize, colour: bool) -> String {
1341 let ink = Ink::new(colour);
1342 let mut out = format!(
1343 "{}{:<28} {:>5} {:>9} {:>7} {:>6}{}\n",
1344 ink.bold, "MODEL", "COEF", "AFP", "REQ", "SHARE", ink.reset
1345 );
1346 let total = report.month.afp;
1347 for model in report.models.iter().take(limit.min(report.models.len())) {
1348 let share = if total > 0.0 {
1349 ink.dim_on(format!("{:>5.1}%", model.month.afp / total * 100.0))
1350 } else {
1351 " -".to_string()
1352 };
1353 out.push_str(&format!(
1354 "{:<28} {} {:>9} {:>7} {}\n",
1355 model.id,
1356 ink.cyan_on(format!("{:>5.2}", model.coef)),
1357 ink.cyan_on(format!("{:>9}", afp(model.month.afp))),
1358 ink.cyan_on(format!("{:>7}", compact(model.month.requests))),
1359 share,
1360 ));
1361 }
1362 if report.models.is_empty() {
1363 out.push_str(&format!(
1364 "{}\n",
1365 ink.dim_on("(no agent-plan usage in the period)".into())
1366 ));
1367 } else if total > 0.0 {
1368 if report.models.len() > limit {
1369 out.push_str(&format!(
1370 "{}\n",
1371 ink.dim_on(format!("… {} more", report.models.len() - limit))
1372 ));
1373 }
1374 out.push_str(&format!(
1375 "{:<28} {:>5} {:>9} {:>7} {:>6}\n",
1376 ink.bold_on("TOTAL".into()),
1377 "",
1378 afp(total),
1379 compact(report.month.requests),
1380 "100.0%",
1381 ));
1382 }
1383 out
1384}
1385
1386fn totals_json(
1389 afp: f64,
1390 requests: u64,
1391 input: u64,
1392 output: u64,
1393 cache_read: u64,
1394) -> serde_json::Value {
1395 serde_json::json!({
1396 "requests": requests,
1397 "tokensIn": input,
1398 "tokensOut": output,
1399 "cacheRead": cache_read,
1400 "afp": afp,
1401 })
1402}
1403
1404#[derive(Default, Clone, Copy)]
1407struct Bucket {
1408 afp: f64,
1409 requests: u64,
1410 input: u64,
1411 output: u64,
1412 cache_read: u64,
1413}
1414
1415impl Bucket {
1416 fn add(&mut self, catalog: &ark::Catalog, row: &crate::arkuse::db::Row) {
1417 self.requests += 1;
1418 self.input += row.input;
1419 self.output += row.output;
1420 self.cache_read += row.cache_read;
1421 self.afp += catalog
1422 .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1423 .unwrap_or(0.0);
1424 }
1425
1426 fn merge(&mut self, other: &Bucket) {
1427 self.afp += other.afp;
1428 self.requests += other.requests;
1429 self.input += other.input;
1430 self.output += other.output;
1431 self.cache_read += other.cache_read;
1432 }
1433
1434 fn json(&self) -> serde_json::Value {
1435 totals_json(
1436 self.afp,
1437 self.requests,
1438 self.input,
1439 self.output,
1440 self.cache_read,
1441 )
1442 }
1443}
1444
1445#[allow(clippy::too_many_arguments)]
1447pub fn bucket_text(
1448 rows: &[crate::arkuse::db::Row],
1449 catalog: &ark::Catalog,
1450 now_ms: i64,
1451 lookback_ms: i64,
1452 size_ms: i64,
1453 tz_secs: i64,
1454 colour: bool,
1455 fmt: Fmt,
1456) -> String {
1457 let shift = tz_secs * 1000;
1459 let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
1460 for row in rows {
1461 let bucket = (row.at_ms + shift).div_euclid(size_ms);
1462 buckets.entry(bucket).or_default().add(catalog, row);
1463 }
1464 let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
1465 let daily = size_ms >= crate::arkuse::window::DAY_SECS * 1000;
1466 let label = if daily { "day" } else { "hour" };
1467 let stamp = |bucket: i64| {
1468 if daily {
1469 date_of(bucket)
1470 } else {
1471 format!(
1472 "{} {:02}h",
1473 date_of(bucket.div_euclid(24)),
1474 bucket.rem_euclid(24)
1475 )
1476 }
1477 };
1478 let window: Vec<(i64, Bucket)> = buckets
1479 .range(start..)
1480 .map(|(bucket, b)| (*bucket, *b))
1481 .collect();
1482
1483 match fmt {
1484 Fmt::Json => {
1485 let mut keyed = serde_json::Map::new();
1486 let mut total = Bucket::default();
1487 for (bucket, b) in &window {
1488 keyed.insert(stamp(*bucket), b.json());
1489 total.merge(b);
1490 }
1491 let key = if daily { "days" } else { "hours" };
1492 serde_json::json!({
1493 "scope": "local",
1494 "source": "opencode",
1495 "total": total.json(),
1496 key: keyed,
1497 })
1498 .to_string()
1499 }
1500 Fmt::Csv => {
1501 let mut out = format!("{label},requests,tokens_in,tokens_out,cache_read,afp\n");
1502 let mut total = Bucket::default();
1503 for (bucket, b) in &window {
1504 out.push_str(&format!(
1505 "{},{},{},{},{},{:.2}\n",
1506 crate::report_render::csv_field(&stamp(*bucket)),
1507 b.requests,
1508 b.input,
1509 b.output,
1510 b.cache_read,
1511 b.afp,
1512 ));
1513 total.merge(b);
1514 }
1515 out.push_str(&format!(
1516 "total,{},{},{},{},{:.2}\n",
1517 total.requests, total.input, total.output, total.cache_read, total.afp,
1518 ));
1519 out
1520 }
1521 Fmt::Table => {
1522 let ink = Ink::new(colour);
1523 let mut out = format!(
1524 "{}{label:<12} AFP requests tokens{}\n",
1525 ink.bold, ink.reset
1526 );
1527 for (bucket, b) in &window {
1528 out.push_str(&format!(
1529 "{:<12} {} {} {}\n",
1530 stamp(*bucket),
1531 ink.cyan_on(format!("{:>9}", afp(b.afp))),
1532 ink.cyan_on(format!("{:>12}", compact(b.requests))),
1533 ink.cyan_on(format!("{:>9}", compact(b.input + b.output + b.cache_read))),
1534 ));
1535 }
1536 if out.lines().count() == 1 {
1537 out.push_str(&format!(
1538 "{}\n",
1539 ink.dim_on("(no agent-plan usage in the window)".into())
1540 ));
1541 }
1542 out
1543 }
1544 }
1545}
1546
1547type SessionTotals<'a> = (
1550 f64,
1551 u64,
1552 u64,
1553 u64,
1554 u64,
1555 i64,
1556 std::collections::BTreeSet<&'a str>,
1557);
1558
1559pub fn session_text(
1562 rows: &[crate::arkuse::db::Row],
1563 catalog: &ark::Catalog,
1564 limit: usize,
1565 colour: bool,
1566 fmt: Fmt,
1567) -> String {
1568 let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1569 for row in rows {
1570 let entry = by_session
1571 .entry(row.session.as_str())
1572 .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1573 entry.0 += catalog
1574 .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1575 .unwrap_or(0.0);
1576 entry.1 += 1;
1577 entry.2 += row.input;
1578 entry.3 += row.output;
1579 entry.4 += row.cache_read;
1580 entry.5 = entry.5.max(row.at_ms);
1581 entry.6.insert(row.model.as_str());
1582 }
1583 let mut sessions: Vec<_> = by_session.into_iter().collect();
1584 sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1585
1586 match fmt {
1587 Fmt::Json => {
1588 let mut keyed = serde_json::Map::new();
1589 for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1590 let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1591 body["lastMs"] = serde_json::json!(*last);
1592 body["models"] = serde_json::json!(models.len());
1593 keyed.insert((*id).to_string(), body);
1594 }
1595 serde_json::json!({
1596 "scope": "local",
1597 "source": "opencode",
1598 "sessions": keyed,
1599 })
1600 .to_string()
1601 }
1602 Fmt::Csv => {
1603 let mut out = String::from("session,requests,tokens_in,tokens_out,cache_read,afp\n");
1604 for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1605 out.push_str(&format!(
1606 "{},{},{},{},{},{:.2}\n",
1607 crate::report_render::csv_field(id),
1608 requests,
1609 input,
1610 output,
1611 cache_read,
1612 cost,
1613 ));
1614 }
1615 out
1616 }
1617 Fmt::Table => {
1618 let ink = Ink::new(colour);
1619 let mut out = format!(
1620 "{}session last models AFP req{}\n",
1621 ink.bold, ink.reset
1622 );
1623 for (id, (cost, requests, _, _, _, last, models)) in sessions.iter().take(limit) {
1624 let short = id.rsplit('_').next().unwrap_or(*id);
1625 out.push_str(&format!(
1626 "{:<32} {:<11} {:>6} {} {:>6}\n",
1627 short,
1628 ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1629 models.len(),
1630 ink.cyan_on(format!("{:>9}", afp(*cost))),
1631 requests,
1632 ));
1633 }
1634 if sessions.len() > limit {
1635 out.push_str(&format!(
1636 "{}\n",
1637 ink.dim_on(format!("… {} more", sessions.len() - limit))
1638 ));
1639 }
1640 if out.lines().count() == 1 {
1641 out.push_str(&format!(
1642 "{}\n",
1643 ink.dim_on("(no sessions in the window)".into())
1644 ));
1645 }
1646 out
1647 }
1648 }
1649}
1650
1651#[cfg(test)]
1652mod tests {
1653 use super::*;
1654 use crate::arkuse::db::Row;
1655
1656 fn report() -> Report {
1657 let now = 1_800_000_000_000;
1658 let rows = vec![Row {
1659 at_ms: now - 60_000,
1660 session: "ses_a".into(),
1661 provider: "agent-plan".into(),
1662 model: "deepseek-v4.1-flash".into(),
1663 input: 10_000,
1664 output: 2_000,
1665 ..Default::default()
1666 }];
1667 crate::arkuse::window::build(&rows, &ark::catalog(), ark::DEFAULT_PLAN, now, None, None)
1668 }
1669
1670 #[test]
1671 fn text_names_the_model_and_its_afp() {
1672 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1673 assert!(text.contains("deepseek-v4.1-flash"), "{text}");
1674 assert!(text.contains("AFP"), "the plan's own unit: {text}");
1675 assert!(!text.contains('$'), "ARK never prices in dollars: {text}");
1676 }
1677
1678 fn cell_ends(line: &str) -> Vec<usize> {
1680 let mut ends = Vec::new();
1681 let mut cursor = 0;
1682 for cell in line.split_whitespace() {
1683 let start = line[cursor..].find(cell).expect("cell in line") + cursor;
1684 cursor = start + cell.len();
1685 ends.push(cursor);
1686 }
1687 ends
1688 }
1689
1690 #[test]
1693 fn the_model_table_header_aligns_with_its_rows() {
1694 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1695 let mut lines = text.lines().skip_while(|line| !line.starts_with("MODEL "));
1696 let header = lines.next().expect("the table header");
1697 let row = lines
1698 .find(|line| line.starts_with("deepseek-v4.1-flash "))
1699 .expect("the model row");
1700 let header_ends = cell_ends(header);
1701 assert_eq!(
1702 header_ends[1..],
1703 cell_ends(row)[1..header_ends.len()],
1704 "header columns must end where their cells do:\n{header}\n{row}"
1705 );
1706 }
1707
1708 #[test]
1709 fn the_tier_table_carries_every_personal_plan() {
1710 let text = plans_text(false);
1711 for plan in ark::catalog().plans {
1712 assert!(text.contains(&plan.label), "{} missing: {text}", plan.label);
1713 }
1714 let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
1715 assert_eq!(
1716 json["plans"].as_array().unwrap().len(),
1717 ark::catalog().plans.len()
1718 );
1719 }
1720
1721 #[test]
1722 fn gated_models_are_filtered_by_the_tier() {
1723 let all = models_text("small", false, false);
1724 let gated = models_text("small", true, false);
1725 assert!(all.lines().count() >= gated.lines().count());
1726 assert!(all.contains("kimi-k3"), "{all}");
1727 assert!(!gated.contains("kimi-k3"), "{gated}");
1728 }
1729
1730 fn metered(account: &crate::arkuse::api::Usage) -> Report {
1732 let now = 1_800_000_000_000;
1733 let rows = vec![Row {
1734 at_ms: now - 60_000,
1735 session: "ses_a".into(),
1736 provider: "agent-plan".into(),
1737 model: "deepseek-v4.1-flash".into(),
1738 input: 10_000,
1739 output: 2_000,
1740 ..Default::default()
1741 }];
1742 crate::arkuse::window::build(
1743 &rows,
1744 &ark::catalog(),
1745 ark::DEFAULT_PLAN,
1746 now,
1747 None,
1748 Some(account),
1749 )
1750 }
1751
1752 fn meter(used: f64, quota: f64, reset: Option<&str>) -> crate::arkuse::api::Meter {
1753 let percent = if quota > 0.0 {
1754 used / quota * 100.0
1755 } else {
1756 0.0
1757 };
1758 crate::arkuse::api::Meter::from_parts("ok", percent, used, quota, reset, None)
1759 }
1760
1761 fn metered_account(account: &crate::arkuse::api::Usage) -> Report {
1765 let mut report = metered(account);
1766 report.account = Some(account.clone());
1767 report.period_end_ms = account.monthly.reset_ms();
1768 report
1769 }
1770
1771 #[test]
1772 fn a_local_row_still_reads_used_over_quota_and_a_reset() {
1773 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1776 let five = text
1777 .lines()
1778 .find(|line| line.contains("5-hour"))
1779 .expect("a 5-hour row");
1780 assert!(
1781 five.contains("/ 10,000 AFP"),
1782 "the plan's own quota: {five}"
1783 );
1784 assert!(five.contains("% of limit (est.)"), "a percentage: {five}");
1785 let weekly = text
1786 .lines()
1787 .find(|line| line.contains("Weekly"))
1788 .expect("a weekly row");
1789 assert!(weekly.contains("resets"), "a countdown: {weekly}");
1790 assert!(weekly.contains("window "), "an elapsed share: {weekly}");
1791 let json: serde_json::Value = serde_json::from_str(&render_json(&report())).unwrap();
1792 assert_eq!(json["windows"]["fiveHour"]["quota"], 10_000.0);
1793 assert!(json["windows"]["weekly"]["resetAt"].is_i64());
1794 }
1795
1796 #[test]
1797 fn a_meter_without_its_reset_falls_back_to_the_stores_boundary() {
1798 let account = crate::arkuse::api::Usage {
1801 five_hour: meter(0.0, 10_000.0, None),
1802 daily: None,
1803 weekly: meter(2_975.67, 35_000.0, Some("2026-10-26T00:00:00+08:00")),
1804 monthly: meter(2_975.67, 100_000.0, Some("2026-11-19T23:59:59+08:00")),
1805 tier: Some("medium".into()),
1806 profile: "agent-plan_cn-beijing_medium".into(),
1807 };
1808 let text = render_text(
1809 &metered(&account),
1810 "/tmp/opencode.db",
1811 10,
1812 false,
1813 DEFAULT_BAR_WIDTH,
1814 );
1815 let five = text
1816 .lines()
1817 .find(|line| line.contains("5-hour"))
1818 .expect("a 5-hour row");
1819 assert!(
1820 five.contains("0% of limit"),
1821 "the server's own share: {five}"
1822 );
1823 assert!(five.contains("resets"), "a derived countdown: {five}");
1824 }
1825
1826 #[test]
1830 fn period_line_carries_the_account_monthly_share() {
1831 let account = crate::arkuse::api::Usage {
1834 five_hour: meter(0.0, 10_000.0, None),
1835 daily: None,
1836 weekly: meter(2_975.67, 35_000.0, Some("2026-10-26T00:00:00+08:00")),
1837 monthly: meter(53_000.0, 100_000.0, Some("2026-11-19T23:59:59+08:00")),
1838 tier: Some("medium".into()),
1839 profile: "agent-plan_cn-beijing_medium".into(),
1840 };
1841 let with = render_text(
1842 &metered_account(&account),
1843 "/tmp/opencode.db",
1844 10,
1845 false,
1846 DEFAULT_BAR_WIDTH,
1847 );
1848 let header = with
1849 .lines()
1850 .find(|line| line.starts_with("period:"))
1851 .expect("the period line");
1852 assert!(header.contains("53% of limit"), "{header}");
1853 assert!(!header.contains("53.0"), "no invented decimal: {header}");
1854
1855 let without = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1856 let header = without
1857 .lines()
1858 .find(|line| line.starts_with("period:"))
1859 .expect("the period line");
1860 assert!(
1861 !header.contains("of limit"),
1862 "no account, no share: {header}"
1863 );
1864 }
1865
1866 #[test]
1869 fn top_models_state_their_share_of_afp_and_tokens() {
1870 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1871 let block = text
1872 .lines()
1873 .skip_while(|line| !line.starts_with("TOP 3 MODELS"))
1874 .take_while(|line| !line.starts_with("MODEL "))
1875 .collect::<Vec<_>>()
1876 .join("\n");
1877 assert!(block.contains("deepseek-v4.1-flash"), "{block}");
1878 assert!(block.contains("AFP") && block.contains("TOKENS"), "{block}");
1879 assert_eq!(block.matches("100.0%").count(), 2, "{block}");
1881 let row = text
1884 .lines()
1885 .skip_while(|line| !line.starts_with("MODEL "))
1886 .find(|line| line.starts_with("deepseek-v4.1-flash"))
1887 .expect("the model row");
1888 assert!(!row.trim_end().ends_with('%'), "no trailing share: {row}");
1889 assert!(row.trim_end().ends_with(char::is_numeric), "{row}");
1890 }
1891
1892 #[test]
1893 fn the_bar_carries_the_share_so_the_body_does_not_repeat_it() {
1894 let text = render_text(&report(), "/tmp/opencode.db", 10, true, DEFAULT_BAR_WIDTH);
1898 let weekly = text
1899 .lines()
1900 .find(|line| line.contains("Weekly"))
1901 .expect("a weekly row");
1902 assert!(!weekly.contains("of limit"), "no repeated share: {weekly}");
1903 assert!(weekly.contains("/ 35,000 AFP"), "the pair: {weekly}");
1904 assert!(
1905 weekly.contains("(est.)"),
1906 "still marked an estimate: {weekly}"
1907 );
1908 }
1909
1910 #[test]
1911 fn the_machine_formats_are_not_coloured() {
1912 let rows = vec![Row {
1913 at_ms: 1_800_000_000_000,
1914 session: "ses_a".into(),
1915 provider: "agent-plan".into(),
1916 model: "deepseek-v4.1-flash".into(),
1917 input: 1_000,
1918 ..Default::default()
1919 }];
1920 let catalog = ark::catalog();
1921 for fmt in [Fmt::Json, Fmt::Csv] {
1922 let daily = bucket_text(
1923 &rows,
1924 &catalog,
1925 1_800_000_000_000,
1926 86_400_000,
1927 86_400_000,
1928 0,
1929 true,
1930 fmt,
1931 );
1932 assert!(!daily.contains('\u{1b}'), "{fmt:?} leaked colour: {daily}");
1933 }
1934 let report = report();
1935 let json = model_report(&report, None, Fmt::Json);
1936 assert!(!json.contains('\u{1b}'));
1937 assert!(json.contains("deepseek-v4.1-flash"));
1938 let csv = model_report(&report, None, Fmt::Csv);
1939 assert!(csv.starts_with("model,requests,tokens_in,tokens_out,cache_read,afp"));
1940 }
1941}