1use crate::arkuse::ark;
9use crate::arkuse::window::{Report, Totals};
10use crate::report_render::Fmt;
11use cmduse_core::{compact, duration};
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 {
149 let z = day + 719_468;
150 let era = z.div_euclid(146_097);
151 let doe = z.rem_euclid(146_097);
152 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
153 let y = yoe + era * 400;
154 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
155 let mp = (5 * doy + 2) / 153;
156 let d = doy - (153 * mp + 2) / 5 + 1;
157 let m = if mp < 10 { mp + 3 } else { mp - 9 };
158 let year = if m <= 2 { y + 1 } else { y };
159 format!("{year:04}-{m:02}-{d:02}")
160}
161
162fn shorten(path: &str) -> String {
163 match std::env::var("HOME") {
164 Ok(home) if path.starts_with(&home) => format!("~{}", &path[home.len()..]),
165 _ => path.to_string(),
166 }
167}
168
169fn meter_reset(meter: &crate::arkuse::api::Meter, dur_secs: Option<u64>) -> Option<(i64, u64)> {
172 match (meter.reset_ms(), dur_secs) {
173 (Some(at), Some(dur)) if dur > 0 => Some((at, dur)),
174 _ => None,
175 }
176}
177
178fn rolling(report: &Report, totals: &Totals, dur_secs: i64) -> Option<(i64, u64)> {
182 report
183 .rolling_reset(totals, dur_secs)
184 .map(|at| (at, dur_secs as u64))
185}
186
187#[allow(clippy::too_many_arguments)]
188struct RowSpec<'a> {
196 label: &'a str,
197 used: &'a Totals,
199 meter: Option<&'a crate::arkuse::api::Meter>,
201 reset: Option<(i64, u64)>,
203 quota: Option<f64>,
207 pro_rata: bool,
211}
212
213fn window_line(ink: &Ink, spec: &RowSpec<'_>, now_ms: i64, catalog: &ark::Catalog) -> String {
214 let now_secs = (now_ms / 1000).max(0) as u64;
215 let reset_at = spec.reset.map(|(at, _)| at as f64);
216 let dur_secs = spec.reset.map(|(_, dur)| dur);
217 let elapsed = dur_secs.and_then(|d| cmduse_core::elapsed_pct(reset_at, d, Some(now_secs)));
218 let reset = spec
219 .reset
220 .map(|(at, _)| duration(((at - now_ms) / 1000).max(0) as u64))
221 .unwrap_or_default();
222 let reset_text = if reset.is_empty() {
223 String::new()
224 } else if ink.colour {
225 format!(" · resets in {reset}")
226 } else {
227 format!(" · resets {reset}")
228 };
229 let thru = elapsed
230 .map(|p| format!(" · {}window {p:.1}% elapsed{}", ink.dim, ink.reset))
231 .unwrap_or_default();
232 let local_pct = spec
236 .quota
237 .filter(|quota| *quota > 0.0)
238 .map(|quota| spec.used.afp / quota * 100.0);
239
240 let (body, tone, pct) = match spec.meter {
243 Some(meter) => {
244 let pace = spec
245 .reset
246 .and_then(|(at, d)| {
247 cmduse_core::pace_eta(Some(at as f64), d, meter.used, meter.quota, now_secs)
248 .map(|secs| duration(secs as u64))
249 })
250 .map(|eta| format!(" · {}on pace to hit cap in {eta}{}", ink.yellow, ink.reset))
251 .unwrap_or_default();
252 let by_now = match (spec.pro_rata, elapsed) {
253 (true, Some(p)) => format!(
254 " · {}{} by now{}",
255 ink.dim,
256 afp(meter.quota * p / 100.0),
257 ink.reset
258 ),
259 _ => String::new(),
260 };
261 let flag = if meter.exceeded() {
262 format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
263 } else if !meter.status.eq_ignore_ascii_case("ok") {
264 format!(" {}{}{}{}", ink.red, meter.status, ink.reset, "")
265 } else {
266 String::new()
267 };
268 let pct_tone = ink.for_pct(meter.percent);
269 let share = if ink.colour {
270 String::new()
273 } else {
274 format!(
275 " · {}{}% of limit{}",
276 pct_tone,
277 given_pct(meter.percent),
278 ink.reset
279 )
280 };
281 let tail = format!("{reset_text}{flag}{pace}{thru}{by_now}");
282 let body = format!(
283 "{} / {} AFP{share} · {}{} local{}{tail}",
284 afp(meter.used),
285 afp(meter.quota),
286 ink.dim,
287 afp(spec.used.afp),
288 ink.reset,
289 );
290 (body, pct_tone, Some(meter.percent))
291 }
292 None => {
293 let quota = spec.quota.filter(|quota| *quota > 0.0);
294 let head = match (quota, local_pct) {
295 (Some(quota), Some(p)) => {
296 let share = if ink.colour {
297 format!("{}(est.){}", ink.dim, ink.reset)
298 } else {
299 format!(" · {}{p:.1}% of limit (est.){}", ink.for_pct(p), ink.reset)
300 };
301 format!("{} / {} AFP{share}", afp(spec.used.afp), afp(quota),)
302 }
303 _ => format!("{} AFP", afp(spec.used.afp)),
304 };
305 let pace = match (spec.reset, quota, local_pct) {
306 (Some((at, d)), Some(quota), Some(_)) => {
307 cmduse_core::pace_eta(Some(at as f64), d, spec.used.afp, quota, now_secs)
308 .map(|secs| {
309 format!(
310 " · {}on pace to hit cap in {}{}",
311 ink.yellow,
312 duration(secs as u64),
313 ink.reset
314 )
315 })
316 .unwrap_or_default()
317 }
318 _ => String::new(),
319 };
320 let by_now = match (spec.pro_rata, quota, elapsed) {
321 (true, Some(quota), Some(p)) => {
322 format!(
323 " · {}{} by now{}",
324 ink.dim,
325 afp(quota * p / 100.0),
326 ink.reset
327 )
328 }
329 _ => String::new(),
330 };
331 let body = format!(
332 "{head} · {}{} · {} requests{}{pace}{reset_text}{thru}{by_now}",
333 ink.dim,
334 yuan(catalog.yuan(spec.used.afp)),
335 compact(spec.used.requests),
336 ink.reset,
337 );
338 (body, ink.for_pct(local_pct.unwrap_or(0.0)), local_pct)
339 }
340 };
341 if ink.colour {
342 let bar = pct
343 .map(|p| {
344 let label = match spec.meter {
345 Some(meter) => given_pct(meter.percent),
346 None => format!("{p:.1}"),
347 };
348 format!("{} ", ink.bar_label(p, &label))
349 })
350 .unwrap_or_default();
351 format!(
352 " {}{:<8}{} {}{}{body}{}\n",
353 ink.bold, spec.label, ink.reset, bar, tone, ink.reset,
354 )
355 } else {
356 format!(" {:<8} {body}\n", spec.label)
357 }
358}
359
360fn model_row(ink: &Ink, model: &crate::arkuse::window::ModelUsage, total_afp: f64) -> String {
364 let coef = format!("{:>5.2}", model.coef);
365 let coef_cell = if ink.colour {
366 format!("{}{coef}{}", ink.cyan, ink.reset)
367 } else {
368 coef
369 };
370 let share = if total_afp > 0.0 {
371 ink.dim_on(format!(" {:>5.1}%", model.month.afp / total_afp * 100.0))
372 } else {
373 String::new()
374 };
375 format!(
376 "{:<28} {coef_cell} {:>9} {:>7}{share}\n",
377 model.id,
378 afp(model.month.afp),
379 compact(model.month.requests),
380 )
381}
382
383pub fn render_text(
385 report: &Report,
386 db_path: &str,
387 rows: usize,
388 colour: bool,
389 bar_width: usize,
390) -> String {
391 let ink = Ink::new(colour).width(bar_width);
392 let catalog = ark::catalog();
393 let c = |text: String| ink.cyan_on(text);
394 let mut out = String::new();
395 let tier_label = catalog
396 .plan(&report.tier)
397 .map(|p| p.label.clone())
398 .unwrap_or_else(|| report.tier.clone());
399 out.push_str(&format!(
400 "{}Volcengine ARK usage{} — agent plan {}, from {}{}{}\n",
401 ink.bold,
402 ink.reset,
403 tier_label,
404 ink.dim,
405 shorten(db_path),
406 ink.reset,
407 ));
408 let end = report
409 .period_end_ms
410 .map(|ms| {
411 format!(
412 " · ends {}",
413 date_of((ms + report.offset_secs * 1000).div_euclid(86_400_000))
414 )
415 })
416 .unwrap_or_default();
417 let start = date_of((report.since_ms + report.offset_secs * 1000).div_euclid(86_400_000));
418 out.push_str(&format!(
419 "period: {start}{end} · {} requests · {} AFP ({}) · {} in / {} out tokens\n",
420 c(compact(report.month.requests)),
421 c(afp(report.month.afp)),
422 c(yuan(catalog.yuan(report.month.afp))),
423 c(compact(report.month.input)),
424 c(compact(report.month.output)),
425 ));
426 if let Some(plan) = catalog.plan(&report.tier) {
427 out.push_str(&format!(
428 "{}plan: {} {} · {} AFP/mo · {} · {} windows{}",
429 ink.dim,
430 plan.label,
431 yuan(plan.price),
432 afp(plan.afp),
433 catalog.currency,
434 if report.account.is_some() {
435 "metered by the control plane"
436 } else {
437 "local estimate"
438 },
439 ink.reset,
440 ));
441 out.push('\n');
442 }
443
444 match &report.account {
445 Some(account) => {
446 out.push_str(&format!(
447 "\n{}AGENT PLAN{} {}\n",
448 ink.bold,
449 ink.reset,
450 ink.dim_on(
451 "(account meters — what the Volcengine console reports; AFP beside them is this store's own estimate)"
452 .into()
453 ),
454 ));
455 let monthly_dur = report
456 .period_end_ms
457 .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
458 let daily_local = report.daily.clone().unwrap_or_default();
459 let mut windows: Vec<(&str, &crate::arkuse::api::Meter, &Totals, Option<u64>, bool)> =
460 vec![(
461 "5-hour",
462 &account.five_hour,
463 &report.five_hour,
464 Some(crate::arkuse::window::FIVE_HOUR_SECS as u64),
465 false,
466 )];
467 if let Some(daily) = &account.daily {
468 windows.push((
469 "Daily",
470 daily,
471 &daily_local,
472 Some(crate::arkuse::window::DAY_SECS as u64),
473 false,
474 ));
475 }
476 windows.push((
477 "Weekly",
478 &account.weekly,
479 &report.weekly,
480 Some(crate::arkuse::window::WEEKLY_SECS as u64),
481 false,
482 ));
483 windows.push((
484 "Monthly",
485 &account.monthly,
486 &report.month,
487 monthly_dur,
488 true,
489 ));
490 for (label, meter, used, dur, pro_rata) in windows {
491 let reset = meter_reset(meter, dur)
496 .or_else(|| dur.and_then(|dur| rolling(report, used, dur as i64)));
497 out.push_str(&window_line(
498 &ink,
499 &RowSpec {
500 label,
501 used,
502 meter: Some(meter),
503 reset,
504 quota: None,
505 pro_rata,
506 },
507 report.now_ms,
508 &catalog,
509 ));
510 }
511 let mut notes: Vec<String> = Vec::new();
515 if let Some(delta) = report.reconcile() {
516 notes.push(format!("local vs meter {:+.1}%", delta));
517 }
518 if let Some(per) = report.afp_per_request() {
519 notes.push(format!("{} AFP/request", afp(per)));
520 }
521 if let Some(burn) = report.burn_per_day() {
522 notes.push(format!("{} AFP/day at this pace", afp(burn)));
523 }
524 if !notes.is_empty() {
525 out.push_str(&format!(
526 "{} {} · {}{}\n",
527 ink.dim,
528 account.profile,
529 notes.join(" · "),
530 ink.reset,
531 ));
532 }
533 }
534 None => {
535 out.push_str(&format!(
536 "\n{}AGENT PLAN{} {}\n",
537 ink.bold,
538 ink.reset,
539 ink.dim_on("(estimated locally: no control-plane answer, so AFP alone)".into()),
540 ));
541 if let Some(why) = &report.account_error {
542 out.push_str(&format!("{} usage API: {why}{}\n", ink.dim, ink.reset));
543 }
544 let monthly_dur = report
545 .period_end_ms
546 .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
547 let five_reset = rolling(
551 report,
552 &report.five_hour,
553 crate::arkuse::window::FIVE_HOUR_SECS,
554 );
555 let week_reset = rolling(report, &report.weekly, crate::arkuse::window::WEEKLY_SECS);
556 out.push_str(&window_line(
557 &ink,
558 &RowSpec {
559 label: "5-hour",
560 used: &report.five_hour,
561 meter: None,
562 reset: five_reset,
563 quota: report.window_quota(&catalog, catalog.windows.five_hour),
564 pro_rata: false,
565 },
566 report.now_ms,
567 &catalog,
568 ));
569 out.push_str(&window_line(
570 &ink,
571 &RowSpec {
572 label: "Weekly",
573 used: &report.weekly,
574 meter: None,
575 reset: week_reset,
576 quota: report.window_quota(&catalog, catalog.windows.weekly),
577 pro_rata: false,
578 },
579 report.now_ms,
580 &catalog,
581 ));
582 out.push_str(&window_line(
583 &ink,
584 &RowSpec {
585 label: "Monthly",
586 used: &report.month,
587 meter: None,
588 reset: report.period_end_ms.zip(monthly_dur),
589 quota: report.window_quota(&catalog, catalog.windows.monthly),
590 pro_rata: true,
591 },
592 report.now_ms,
593 &catalog,
594 ));
595 }
596 }
597
598 out.push_str(&format!(
599 "\n{}MODEL COEF AFP REQ SHARE{}\n",
600 ink.bold, ink.reset
601 ));
602 let total_afp = report.month.afp;
603 for model in report.models.iter().take(rows) {
604 out.push_str(&model_row(&ink, model, total_afp));
605 }
606 if report.models.len() > rows {
607 out.push_str(&format!(
608 "{}\n",
609 ink.dim_on(format!("… {} more", report.models.len() - rows))
610 ));
611 }
612 out.push_str(&format!(
614 "\n{}\n",
615 ink.dim_on(
616 "AFP = (input + cache_read + output) × coefficient / 10,000; the coefficient is the plan's own and a promo halves it until its date."
617 .into()
618 ),
619 ));
620 if report.account.is_some() {
621 out.push_str(&format!(
622 "{}\n",
623 ink.dim_on(
624 "The windows are the account's own cuts (5h block, Monday week, subscription month); the model AFP is this machine inside them."
625 .into()
626 )
627 ));
628 } else {
629 out.push_str(&format!(
630 "{}\n",
631 ink.dim_on(
632 "Windows are rolling 5h / 7d / calendar month cuts, local only — not the control plane's anchors."
633 .into()
634 )
635 ));
636 }
637 out
638}
639
640pub fn watch_frame(
645 report: &Report,
646 db_path: &str,
647 rows: usize,
648 colour: bool,
649 history: &[f64],
650 interval_secs: u64,
651 bar_width: usize,
652) -> String {
653 let ink = Ink::new(colour);
654 let mut out = render_text(report, db_path, rows, colour, bar_width);
655 if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
656 out.push_str(&format!(
657 "{}AFP bursts ({}s samples){} {}\n",
658 ink.dim,
659 interval_secs,
660 ink.reset,
661 crate::render::sparkline(history),
662 ));
663 }
664 out.trim_end_matches('\n').to_string()
665}
666
667pub fn compact_line(report: &Report, history: &[f64], colour: bool) -> String {
669 let ink = Ink::new(colour);
670 let catalog = ark::catalog();
671 let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
672 format!(
673 " {}{}{}",
674 ink.dim,
675 crate::render::sparkline(history),
676 ink.reset
677 )
678 } else {
679 String::new()
680 };
681 let window_share = match &report.account {
682 Some(account) => format!(
683 " · 5h {} · wk {} · mo {}",
684 ink.cyan_on(format!("{}%", given_pct(account.five_hour.percent))),
685 ink.cyan_on(format!("{}%", given_pct(account.weekly.percent))),
686 ink.cyan_on(format!("{}%", given_pct(account.monthly.percent))),
687 ),
688 None => format!(" · 5h {}", ink.cyan_on(afp(report.five_hour.afp))),
689 };
690 format!(
691 "{}ARK{} {} AFP ({}) month{window_share}{spark}",
692 ink.bold,
693 ink.reset,
694 ink.cyan_on(afp(report.month.afp)),
695 ink.cyan_on(yuan(catalog.yuan(report.month.afp))),
696 )
697}
698
699pub fn statusline_template(
711 report: &Report,
712 plan_id: &str,
713 tpl: &str,
714 colours: bool,
715 ascii: bool,
716 bar_width: usize,
717) -> String {
718 let ink = Ink::new(colours);
719 let catalog = ark::catalog();
720 let account = report.account.as_ref();
721 let quota_of = |share: f64| report.window_quota(&catalog, share);
722 let pct_of = |meter: Option<&crate::arkuse::api::Meter>, used: f64, share: f64| match meter {
726 Some(meter) => Some(meter.percent),
727 None => quota_of(share)
728 .filter(|quota| *quota > 0.0)
729 .map(|quota| used / quota * 100.0),
730 };
731 let share_of = |pct: Option<f64>| match pct {
732 Some(pct) => format!("{}%", given_pct(pct)),
733 None => "—".into(),
734 };
735 let share_bar = |pct: Option<f64>| match pct {
736 Some(pct) => crate::report_render::bar(pct, bar_width, ascii, colours),
737 None => String::new(),
738 };
739 let reset_of =
742 |meter: Option<&crate::arkuse::api::Meter>, totals: &Totals, dur: i64| match meter
743 .and_then(|m| m.reset_ms())
744 {
745 Some(at) => Some((at, dur as u64)),
746 None => report.rolling_reset(totals, dur).map(|at| (at, dur as u64)),
747 };
748 let eta_of = |reset: Option<(i64, u64)>, used: f64, quota: Option<f64>| -> Option<String> {
749 let (at, dur) = reset?;
750 let quota = quota?;
751 let secs = cmduse_core::pace_eta(
752 Some(at as f64),
753 dur,
754 used,
755 quota,
756 (report.now_ms / 1000).max(0) as u64,
757 )?;
758 Some(format!("on pace to hit cap in {}", duration(secs as u64)))
759 };
760 let h5_pct = pct_of(
761 account.map(|a| &a.five_hour),
762 report.five_hour.afp,
763 catalog.windows.five_hour,
764 );
765 let wk_pct = pct_of(
766 account.map(|a| &a.weekly),
767 report.weekly.afp,
768 catalog.windows.weekly,
769 );
770 let month_pct = pct_of(
771 account.map(|a| &a.monthly),
772 report.month.afp,
773 catalog.windows.monthly,
774 );
775 let h5_eta = eta_of(
776 reset_of(
777 account.map(|a| &a.five_hour),
778 &report.five_hour,
779 crate::arkuse::window::FIVE_HOUR_SECS,
780 ),
781 report.five_hour.afp,
782 quota_of(catalog.windows.five_hour),
783 )
784 .unwrap_or_default();
785 let wk_eta = eta_of(
786 reset_of(
787 account.map(|a| &a.weekly),
788 &report.weekly,
789 crate::arkuse::window::WEEKLY_SECS,
790 ),
791 report.weekly.afp,
792 quota_of(catalog.windows.weekly),
793 )
794 .unwrap_or_default();
795 let cash = |value: f64| ink.cyan_on(afp(value));
796 let top = report
797 .models
798 .first()
799 .map(|m| m.id.clone())
800 .unwrap_or_else(|| "—".into());
801 let replacements: Vec<(&str, String)> = vec![
802 ("plan", plan_id.to_string()),
803 ("tier", plan_id.to_string()),
804 ("period", report.period_label.clone()),
805 ("month", cash(report.month.afp)),
806 ("month_pct", share_of(month_pct)),
807 ("month_bar", share_bar(month_pct)),
808 ("five_hour", cash(report.five_hour.afp)),
809 ("5h", cash(report.five_hour.afp)),
810 ("five_hour_pct", share_of(h5_pct)),
811 ("5h_pct", share_of(h5_pct)),
812 ("five_hour_bar", share_bar(h5_pct)),
813 ("5h_bar", share_bar(h5_pct)),
814 ("5h_eta", h5_eta),
815 ("weekly", cash(report.weekly.afp)),
816 ("wk", cash(report.weekly.afp)),
817 ("weekly_pct", share_of(wk_pct)),
818 ("wk_pct", share_of(wk_pct)),
819 ("weekly_bar", share_bar(wk_pct)),
820 ("wk_bar", share_bar(wk_pct)),
821 ("wk_eta", wk_eta),
822 ("yuan", yuan(catalog.yuan(report.month.afp))),
823 ("yuan_month", yuan(catalog.yuan(report.month.afp))),
824 ("models", top),
825 ];
826 crate::report_render::render_template(tpl, |key| {
827 let lower = key.to_ascii_lowercase();
828 replacements
829 .iter()
830 .find(|(name, _)| *name == lower)
831 .map(|(_, value)| value.clone())
832 .unwrap_or_default()
833 })
834}
835
836pub fn statusline_json(report: &Report, plan_id: &str) -> String {
837 let catalog = ark::catalog();
838 let quota_of = |share: f64| report.window_quota(&catalog, share);
839 serde_json::json!({
840 "plan": plan_id,
841 "period": report.period_label,
842 "source": "opencode",
843 "monthSpent": report.month.afp,
848 "monthCap": quota_of(catalog.windows.monthly),
849 "fiveHourSpent": report.five_hour.afp,
850 "fiveHourCap": quota_of(catalog.windows.five_hour),
851 "weeklySpent": report.weekly.afp,
852 "weeklyCap": quota_of(catalog.windows.weekly),
853 "model": report.models.first().map(|m| m.id.clone()),
854 "afp": {
855 "fiveHour": report.five_hour.afp,
856 "weekly": report.weekly.afp,
857 "monthly": report.month.afp,
858 "yuan": catalog.yuan(report.month.afp),
859 },
860 "account": report.account.as_ref().map(usage_json),
861 "accountError": report.account_error,
862 "periodEnd": report.period_end_ms,
863 })
864 .to_string()
865}
866
867fn window_json(totals: &Totals, quota: Option<f64>, reset_ms: Option<i64>) -> serde_json::Value {
872 serde_json::json!({
873 "afp": totals.afp,
874 "requests": totals.requests,
875 "quota": quota,
876 "percent": quota
877 .filter(|quota| *quota > 0.0)
878 .map(|quota| totals.afp / quota * 100.0),
879 "resetAt": reset_ms,
880 })
881}
882
883fn meter_json(meter: &crate::arkuse::api::Meter) -> serde_json::Value {
884 serde_json::json!({
885 "used": meter.used,
886 "total": meter.quota,
887 "percent": meter.percent,
888 "status": meter.status,
889 "resetsAt": meter.resets_at,
890 "resetAt": meter.reset_ms(),
891 })
892}
893
894fn usage_json(usage: &crate::arkuse::api::Usage) -> serde_json::Value {
897 serde_json::json!({
898 "profile": usage.profile,
899 "tier": usage.tier,
900 "fiveHour": meter_json(&usage.five_hour),
901 "daily": usage.daily.as_ref().map(meter_json),
902 "weekly": meter_json(&usage.weekly),
903 "monthly": meter_json(&usage.monthly),
904 })
905}
906
907pub fn render_json(report: &Report) -> String {
908 let catalog = ark::catalog();
909 let totals = |t: &Totals| {
910 serde_json::json!({
911 "afp": t.afp,
912 "requests": t.requests,
913 "tokensIn": t.input,
914 "tokensOut": t.output,
915 "cacheRead": t.cache_read,
916 "cacheWrite": t.cache_write,
917 })
918 };
919 let models: serde_json::Map<String, serde_json::Value> = report
920 .models
921 .iter()
922 .map(|m| {
923 (
924 m.id.clone(),
925 serde_json::json!({
926 "coef": m.coef,
927 "month": totals(&m.month),
928 "fiveHour": totals(&m.five_hour),
929 "weekly": totals(&m.weekly),
930 }),
931 )
932 })
933 .collect();
934 serde_json::json!({
935 "source": "opencode",
936 "scope": "local",
937 "plan": report.tier,
938 "profile": report.profile,
939 "periodStart": report.since_ms,
940 "periodEndAt": report.period_end_ms,
941 "periodEnd": report.period_end_ms
942 .map(|ms| date_of((ms + report.offset_secs * 1000).div_euclid(86_400_000))),
943 "monthInferred": report.month_inferred,
944 "summary": {
945 "afp": report.month.afp,
946 "yuan": ark::catalog().yuan(report.month.afp),
947 "requests": report.month.requests,
948 "tokensIn": report.month.input,
949 "tokensOut": report.month.output,
950 "cacheRead": report.month.cache_read,
951 },
952 "fiveHour": totals(&report.five_hour),
953 "weekly": totals(&report.weekly),
954 "monthly": totals(&report.month),
955 "daily": report.daily.as_ref().map(totals),
956 "windows": {
960 "fiveHour": window_json(
961 &report.five_hour,
962 report.window_quota(&catalog, catalog.windows.five_hour),
963 report.rolling_reset(&report.five_hour, crate::arkuse::window::FIVE_HOUR_SECS),
964 ),
965 "weekly": window_json(
966 &report.weekly,
967 report.window_quota(&catalog, catalog.windows.weekly),
968 report.rolling_reset(&report.weekly, crate::arkuse::window::WEEKLY_SECS),
969 ),
970 "monthly": window_json(
971 &report.month,
972 report.window_quota(&catalog, catalog.windows.monthly),
973 report.period_end_ms,
974 ),
975 },
976 "account": report.account.as_ref().map(|account| {
977 let mut body = serde_json::json!({
978 "profile": account.profile,
979 "tier": account.tier,
980 "fiveHour": meter_json(&account.five_hour),
981 "weekly": meter_json(&account.weekly),
982 "monthly": meter_json(&account.monthly),
983 });
984 if let Some(daily) = &account.daily {
985 body["daily"] = meter_json(daily);
986 }
987 body
988 }),
989 "accountError": report.account_error,
990 "reconciledPct": report.reconcile(),
991 "afpPerRequest": report.afp_per_request(),
992 "models": models,
993 })
994 .to_string()
995}
996
997pub fn plans_text(colour: bool) -> String {
1001 let ink = Ink::new(colour);
1002 let catalog = ark::catalog();
1003 let mut out = format!(
1004 "{}{:<8} {:>9} {:>9} {:>10} {:>10} {:>10}{}\n",
1005 ink.bold, "TIER", "YUAN/MO", "AFP/MO", "5H AFP", "DAILY AFP", "WK AFP", ink.reset
1006 );
1007 for plan in &catalog.plans {
1008 out.push_str(&format!(
1009 "{:<8} {:>9} {:>9} {:>10} {:>10} {:>10}\n",
1010 plan.label,
1011 ink.cyan_on(yuan(plan.price)),
1012 ink.cyan_on(afp(plan.afp)),
1013 afp(plan.afp * catalog.windows.five_hour),
1014 afp(plan.afp * catalog.windows.daily),
1015 afp(plan.afp * catalog.windows.weekly),
1016 ));
1017 if let Some(promo) = &plan.promo {
1018 out.push_str(&format!(
1019 "{}{} for {} months, until {}{}\n",
1020 ink.dim,
1021 yuan(promo.price),
1022 promo.months.unwrap_or(1),
1023 promo.until,
1024 ink.reset,
1025 ));
1026 }
1027 }
1028 out.push_str(&format!(
1029 "\n{}1 AFP = ¥{:.3} at every tier · 1,000 AFP = {}{}\n",
1030 ink.dim,
1031 catalog.afp_yuan,
1032 yuan(catalog.yuan(1000.0)),
1033 ink.reset,
1034 ));
1035 if !catalog.enterprise.is_empty() {
1036 out.push_str(&format!(
1037 "\n{}ENTERPRISE{} {}\n",
1038 ink.bold,
1039 ink.reset,
1040 ink.dim_on(
1041 "(seat-based; AFP is metered per seat and the personal usage API does not expose it)"
1042 .into()
1043 ),
1044 ));
1045 for plan in &catalog.enterprise {
1046 out.push_str(&format!(
1047 " {:<22} {}\n",
1048 plan.label,
1049 ink.cyan_on(format!("{}/seat/mo", yuan(plan.price))),
1050 ));
1051 }
1052 }
1053 if let Some(note) = catalog.notes.get("windows") {
1054 out.push_str(&format!("\n{}{}{}\n", ink.dim, note, ink.reset));
1055 }
1056 out
1057}
1058
1059pub fn plans_json() -> String {
1060 let catalog = ark::catalog();
1061 serde_json::json!({
1062 "source": catalog.source,
1063 "extractedAt": catalog.extracted_at,
1064 "currency": catalog.currency,
1065 "afpYuan": catalog.afp_yuan,
1066 "windows": {
1067 "fiveHour": catalog.windows.five_hour,
1068 "daily": catalog.windows.daily,
1069 "weekly": catalog.windows.weekly,
1070 "monthly": catalog.windows.monthly,
1071 },
1072 "plans": catalog.plans.iter().map(|p| serde_json::json!({
1073 "id": p.id,
1074 "label": p.label,
1075 "price": p.price,
1076 "listPrice": p.list_price,
1077 "afp": p.afp,
1078 "promo": p.promo.as_ref().map(|promo| serde_json::json!({
1079 "price": promo.price,
1080 "until": promo.until,
1081 "months": promo.months,
1082 })),
1083 })).collect::<Vec<_>>(),
1084 "enterprise": catalog.enterprise.iter().map(|p| serde_json::json!({
1085 "id": p.id,
1086 "label": p.label,
1087 "price": p.price,
1088 })).collect::<Vec<_>>(),
1089 "modelCount": catalog.models.len(),
1090 })
1091 .to_string()
1092}
1093
1094fn yuan_per_million(coef: f64, afp_yuan: f64) -> f64 {
1098 1_000_000.0 * coef / 10_000.0 * afp_yuan
1099}
1100
1101pub fn models_text(plan_id: &str, gated: bool, colour: bool) -> String {
1105 let ink = Ink::new(colour);
1106 let catalog = ark::catalog();
1107 let now = crate::arkuse::window::now_ms();
1108 let mut out = format!(
1109 "{}{:<30} {:>6} {:>7} {:>10} {:>8} {:>8}{}\n",
1110 ink.bold, "MODEL", "KIND", "COEF", "YUAN/1M", "MIN", "GRANTED", ink.reset
1111 );
1112 for model in &catalog.models {
1113 let granted = catalog.grants(plan_id, &model.min_plan);
1114 if gated && !granted {
1115 continue;
1116 }
1117 let coef = ark::effective_coef(model, now);
1118 let promo = model.promo_coef.map(|_| {
1119 format!(
1120 " (promo until {})",
1121 model.promo_until.as_deref().unwrap_or("?")
1122 )
1123 });
1124 let coef_cell = ink.cyan_on(format!("{coef:.2}"));
1125 out.push_str(&format!(
1126 "{:<30} {:>6} {:>7} {:>10} {:>8} {:>8}",
1127 model.id,
1128 model.kind,
1129 coef_cell,
1130 format!("{:.2}", yuan_per_million(coef, catalog.afp_yuan)),
1131 model.min_plan,
1132 if granted { "yes" } else { "no" },
1133 ));
1134 if let Some(promo) = promo {
1135 out.push_str(&format!("{}{promo}{}", ink.dim, ink.reset));
1136 }
1137 out.push('\n');
1138 }
1139 out
1140}
1141
1142pub fn models_json(plan_id: &str, gated: bool) -> String {
1145 let catalog = ark::catalog();
1146 let now = crate::arkuse::window::now_ms();
1147 let mut lines: Vec<String> = Vec::new();
1148 for model in &catalog.models {
1149 let granted = catalog.grants(plan_id, &model.min_plan);
1150 if gated && !granted {
1151 continue;
1152 }
1153 let coef = ark::effective_coef(model, now);
1154 lines.push(
1155 serde_json::json!({
1156 "id": model.id,
1157 "name": model.name,
1158 "kind": model.kind,
1159 "coef": coef,
1160 "baseCoef": model.coef,
1161 "promoCoef": model.promo_coef,
1162 "promoUntil": model.promo_until,
1163 "yuanPerMillion": yuan_per_million(coef, catalog.afp_yuan),
1164 "minPlan": model.min_plan,
1165 "granted": granted,
1166 })
1167 .to_string(),
1168 );
1169 }
1170 lines.join("\n")
1171}
1172
1173pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
1176 let ink = Ink::new(colour);
1177 let Some(model) = report.models.iter().find(|m| m.id == id) else {
1178 return format!("{id}: no usage in the period\n");
1179 };
1180 let row =
1181 |label: &str, value: String| format!(" {}{value}\n", ink.dim_on(format!("{label:<10}")));
1182 let mut out = format!(
1183 "{} {}\n",
1184 ink.bold_on(model.id.clone()),
1185 ink.dim_on(format!("({})", model.provider)),
1186 );
1187 out.push_str(&row(
1188 "coef",
1189 ink.cyan_on(format!(
1190 "{:.2} · {} per million tokens",
1191 model.coef,
1192 yuan(yuan_per_million(model.coef, ark::catalog().afp_yuan))
1193 )),
1194 ));
1195 out.push_str(&row(
1196 "month",
1197 ink.cyan_on(format!(
1198 "{} AFP ({}) · {} requests",
1199 afp(model.month.afp),
1200 yuan(ark::catalog().yuan(model.month.afp)),
1201 model.month.requests
1202 )),
1203 ));
1204 out.push_str(&row(
1205 "5-hour",
1206 ink.cyan_on(format!("{} AFP", afp(model.five_hour.afp))),
1207 ));
1208 out.push_str(&row(
1209 "weekly",
1210 ink.cyan_on(format!("{} AFP", afp(model.weekly.afp))),
1211 ));
1212 if report.month.afp > 0.0 {
1213 out.push_str(&row(
1214 "share",
1215 ink.dim_on(format!(
1216 "{:.1}% of the period's AFP",
1217 model.month.afp / report.month.afp * 100.0
1218 )),
1219 ));
1220 }
1221 out
1222}
1223
1224pub fn model_report(report: &Report, id: Option<&str>, fmt: Fmt) -> String {
1227 let models: Vec<&crate::arkuse::window::ModelUsage> = report
1228 .models
1229 .iter()
1230 .filter(|m| id.is_none_or(|want| m.id == want))
1231 .collect();
1232 match fmt {
1233 Fmt::Json => {
1234 let map: serde_json::Map<String, serde_json::Value> = models
1235 .iter()
1236 .map(|m| {
1237 (
1238 m.id.clone(),
1239 serde_json::json!({
1240 "coef": m.coef,
1241 "requests": m.month.requests,
1242 "tokensIn": m.month.input,
1243 "tokensOut": m.month.output,
1244 "cacheRead": m.month.cache_read,
1245 "afp": m.month.afp,
1246 "yuan": ark::catalog().yuan(m.month.afp),
1247 }),
1248 )
1249 })
1250 .collect();
1251 serde_json::json!({
1252 "scope": "local",
1253 "source": "opencode",
1254 "sinceMs": report.since_ms,
1255 "models": map,
1256 })
1257 .to_string()
1258 }
1259 Fmt::Csv => {
1260 let mut out = String::from("model,requests,tokens_in,tokens_out,cache_read,afp\n");
1261 for m in models {
1262 out.push_str(&format!(
1263 "{},{},{},{},{},{:.2}\n",
1264 crate::report_render::csv_field(&m.id),
1265 m.month.requests,
1266 m.month.input,
1267 m.month.output,
1268 m.month.cache_read,
1269 m.month.afp,
1270 ));
1271 }
1272 out
1273 }
1274 Fmt::Table => String::new(),
1275 }
1276}
1277
1278pub fn models_period_text(report: &Report, limit: usize, colour: bool) -> String {
1282 let ink = Ink::new(colour);
1283 let mut out = format!(
1284 "{}{:<28} {:>5} {:>9} {:>7} {:>6}{}\n",
1285 ink.bold, "MODEL", "COEF", "AFP", "REQ", "SHARE", ink.reset
1286 );
1287 let total = report.month.afp;
1288 for model in report.models.iter().take(limit.min(report.models.len())) {
1289 let share = if total > 0.0 {
1290 ink.dim_on(format!("{:>5.1}%", model.month.afp / total * 100.0))
1291 } else {
1292 " -".to_string()
1293 };
1294 out.push_str(&format!(
1295 "{:<28} {} {:>9} {:>7} {}\n",
1296 model.id,
1297 ink.cyan_on(format!("{:>5.2}", model.coef)),
1298 ink.cyan_on(format!("{:>9}", afp(model.month.afp))),
1299 ink.cyan_on(format!("{:>7}", compact(model.month.requests))),
1300 share,
1301 ));
1302 }
1303 if report.models.is_empty() {
1304 out.push_str(&format!(
1305 "{}\n",
1306 ink.dim_on("(no agent-plan usage in the period)".into())
1307 ));
1308 } else if total > 0.0 {
1309 if report.models.len() > limit {
1310 out.push_str(&format!(
1311 "{}\n",
1312 ink.dim_on(format!("… {} more", report.models.len() - limit))
1313 ));
1314 }
1315 out.push_str(&format!(
1316 "{:<28} {:>5} {:>9} {:>7} {:>6}\n",
1317 ink.bold_on("TOTAL".into()),
1318 "",
1319 afp(total),
1320 compact(report.month.requests),
1321 "100.0%",
1322 ));
1323 }
1324 out
1325}
1326
1327fn totals_json(
1330 afp: f64,
1331 requests: u64,
1332 input: u64,
1333 output: u64,
1334 cache_read: u64,
1335) -> serde_json::Value {
1336 serde_json::json!({
1337 "requests": requests,
1338 "tokensIn": input,
1339 "tokensOut": output,
1340 "cacheRead": cache_read,
1341 "afp": afp,
1342 })
1343}
1344
1345#[derive(Default, Clone, Copy)]
1348struct Bucket {
1349 afp: f64,
1350 requests: u64,
1351 input: u64,
1352 output: u64,
1353 cache_read: u64,
1354}
1355
1356impl Bucket {
1357 fn add(&mut self, catalog: &ark::Catalog, row: &crate::arkuse::db::Row) {
1358 self.requests += 1;
1359 self.input += row.input;
1360 self.output += row.output;
1361 self.cache_read += row.cache_read;
1362 self.afp += catalog
1363 .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1364 .unwrap_or(0.0);
1365 }
1366
1367 fn merge(&mut self, other: &Bucket) {
1368 self.afp += other.afp;
1369 self.requests += other.requests;
1370 self.input += other.input;
1371 self.output += other.output;
1372 self.cache_read += other.cache_read;
1373 }
1374
1375 fn json(&self) -> serde_json::Value {
1376 totals_json(
1377 self.afp,
1378 self.requests,
1379 self.input,
1380 self.output,
1381 self.cache_read,
1382 )
1383 }
1384}
1385
1386#[allow(clippy::too_many_arguments)]
1388pub fn bucket_text(
1389 rows: &[crate::arkuse::db::Row],
1390 catalog: &ark::Catalog,
1391 now_ms: i64,
1392 lookback_ms: i64,
1393 size_ms: i64,
1394 tz_secs: i64,
1395 colour: bool,
1396 fmt: Fmt,
1397) -> String {
1398 let shift = tz_secs * 1000;
1400 let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
1401 for row in rows {
1402 let bucket = (row.at_ms + shift).div_euclid(size_ms);
1403 buckets.entry(bucket).or_default().add(catalog, row);
1404 }
1405 let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
1406 let daily = size_ms >= crate::arkuse::window::DAY_SECS * 1000;
1407 let label = if daily { "day" } else { "hour" };
1408 let stamp = |bucket: i64| {
1409 if daily {
1410 date_of(bucket)
1411 } else {
1412 format!(
1413 "{} {:02}h",
1414 date_of(bucket.div_euclid(24)),
1415 bucket.rem_euclid(24)
1416 )
1417 }
1418 };
1419 let window: Vec<(i64, Bucket)> = buckets
1420 .range(start..)
1421 .map(|(bucket, b)| (*bucket, *b))
1422 .collect();
1423
1424 match fmt {
1425 Fmt::Json => {
1426 let mut keyed = serde_json::Map::new();
1427 let mut total = Bucket::default();
1428 for (bucket, b) in &window {
1429 keyed.insert(stamp(*bucket), b.json());
1430 total.merge(b);
1431 }
1432 let key = if daily { "days" } else { "hours" };
1433 serde_json::json!({
1434 "scope": "local",
1435 "source": "opencode",
1436 "total": total.json(),
1437 key: keyed,
1438 })
1439 .to_string()
1440 }
1441 Fmt::Csv => {
1442 let mut out = format!("{label},requests,tokens_in,tokens_out,cache_read,afp\n");
1443 let mut total = Bucket::default();
1444 for (bucket, b) in &window {
1445 out.push_str(&format!(
1446 "{},{},{},{},{},{:.2}\n",
1447 crate::report_render::csv_field(&stamp(*bucket)),
1448 b.requests,
1449 b.input,
1450 b.output,
1451 b.cache_read,
1452 b.afp,
1453 ));
1454 total.merge(b);
1455 }
1456 out.push_str(&format!(
1457 "total,{},{},{},{},{:.2}\n",
1458 total.requests, total.input, total.output, total.cache_read, total.afp,
1459 ));
1460 out
1461 }
1462 Fmt::Table => {
1463 let ink = Ink::new(colour);
1464 let mut out = format!(
1465 "{}{label:<12} AFP requests tokens{}\n",
1466 ink.bold, ink.reset
1467 );
1468 for (bucket, b) in &window {
1469 out.push_str(&format!(
1470 "{:<12} {} {} {}\n",
1471 stamp(*bucket),
1472 ink.cyan_on(format!("{:>9}", afp(b.afp))),
1473 ink.cyan_on(format!("{:>12}", compact(b.requests))),
1474 ink.cyan_on(format!("{:>9}", compact(b.input + b.output + b.cache_read))),
1475 ));
1476 }
1477 if out.lines().count() == 1 {
1478 out.push_str(&format!(
1479 "{}\n",
1480 ink.dim_on("(no agent-plan usage in the window)".into())
1481 ));
1482 }
1483 out
1484 }
1485 }
1486}
1487
1488type SessionTotals<'a> = (
1491 f64,
1492 u64,
1493 u64,
1494 u64,
1495 u64,
1496 i64,
1497 std::collections::BTreeSet<&'a str>,
1498);
1499
1500pub fn session_text(
1503 rows: &[crate::arkuse::db::Row],
1504 catalog: &ark::Catalog,
1505 limit: usize,
1506 colour: bool,
1507 fmt: Fmt,
1508) -> String {
1509 let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1510 for row in rows {
1511 let entry = by_session
1512 .entry(row.session.as_str())
1513 .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1514 entry.0 += catalog
1515 .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1516 .unwrap_or(0.0);
1517 entry.1 += 1;
1518 entry.2 += row.input;
1519 entry.3 += row.output;
1520 entry.4 += row.cache_read;
1521 entry.5 = entry.5.max(row.at_ms);
1522 entry.6.insert(row.model.as_str());
1523 }
1524 let mut sessions: Vec<_> = by_session.into_iter().collect();
1525 sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1526
1527 match fmt {
1528 Fmt::Json => {
1529 let mut keyed = serde_json::Map::new();
1530 for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1531 let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1532 body["lastMs"] = serde_json::json!(*last);
1533 body["models"] = serde_json::json!(models.len());
1534 keyed.insert((*id).to_string(), body);
1535 }
1536 serde_json::json!({
1537 "scope": "local",
1538 "source": "opencode",
1539 "sessions": keyed,
1540 })
1541 .to_string()
1542 }
1543 Fmt::Csv => {
1544 let mut out = String::from("session,requests,tokens_in,tokens_out,cache_read,afp\n");
1545 for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1546 out.push_str(&format!(
1547 "{},{},{},{},{},{:.2}\n",
1548 crate::report_render::csv_field(id),
1549 requests,
1550 input,
1551 output,
1552 cache_read,
1553 cost,
1554 ));
1555 }
1556 out
1557 }
1558 Fmt::Table => {
1559 let ink = Ink::new(colour);
1560 let mut out = format!(
1561 "{}session last models AFP req{}\n",
1562 ink.bold, ink.reset
1563 );
1564 for (id, (cost, requests, _, _, _, last, models)) in sessions.iter().take(limit) {
1565 let short = id.rsplit('_').next().unwrap_or(*id);
1566 out.push_str(&format!(
1567 "{:<32} {:<11} {:>6} {} {:>6}\n",
1568 short,
1569 ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1570 models.len(),
1571 ink.cyan_on(format!("{:>9}", afp(*cost))),
1572 requests,
1573 ));
1574 }
1575 if sessions.len() > limit {
1576 out.push_str(&format!(
1577 "{}\n",
1578 ink.dim_on(format!("… {} more", sessions.len() - limit))
1579 ));
1580 }
1581 if out.lines().count() == 1 {
1582 out.push_str(&format!(
1583 "{}\n",
1584 ink.dim_on("(no sessions in the window)".into())
1585 ));
1586 }
1587 out
1588 }
1589 }
1590}
1591
1592#[cfg(test)]
1593mod tests {
1594 use super::*;
1595 use crate::arkuse::db::Row;
1596
1597 fn report() -> Report {
1598 let now = 1_800_000_000_000;
1599 let rows = vec![Row {
1600 at_ms: now - 60_000,
1601 session: "ses_a".into(),
1602 provider: "agent-plan".into(),
1603 model: "deepseek-v4.1-flash".into(),
1604 input: 10_000,
1605 output: 2_000,
1606 ..Default::default()
1607 }];
1608 crate::arkuse::window::build(&rows, &ark::catalog(), ark::DEFAULT_PLAN, now, None, None)
1609 }
1610
1611 #[test]
1612 fn text_names_the_model_and_its_afp() {
1613 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1614 assert!(text.contains("deepseek-v4.1-flash"), "{text}");
1615 assert!(text.contains("AFP"), "the plan's own unit: {text}");
1616 assert!(!text.contains('$'), "ARK never prices in dollars: {text}");
1617 }
1618
1619 #[test]
1620 fn the_tier_table_carries_every_personal_plan() {
1621 let text = plans_text(false);
1622 for plan in ark::catalog().plans {
1623 assert!(text.contains(&plan.label), "{} missing: {text}", plan.label);
1624 }
1625 let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
1626 assert_eq!(
1627 json["plans"].as_array().unwrap().len(),
1628 ark::catalog().plans.len()
1629 );
1630 }
1631
1632 #[test]
1633 fn gated_models_are_filtered_by_the_tier() {
1634 let all = models_text("small", false, false);
1635 let gated = models_text("small", true, false);
1636 assert!(all.lines().count() >= gated.lines().count());
1637 assert!(all.contains("kimi-k3"), "{all}");
1638 assert!(!gated.contains("kimi-k3"), "{gated}");
1639 }
1640
1641 fn metered(account: &crate::arkuse::api::Usage) -> Report {
1643 let now = 1_800_000_000_000;
1644 let rows = vec![Row {
1645 at_ms: now - 60_000,
1646 session: "ses_a".into(),
1647 provider: "agent-plan".into(),
1648 model: "deepseek-v4.1-flash".into(),
1649 input: 10_000,
1650 output: 2_000,
1651 ..Default::default()
1652 }];
1653 crate::arkuse::window::build(
1654 &rows,
1655 &ark::catalog(),
1656 ark::DEFAULT_PLAN,
1657 now,
1658 None,
1659 Some(account),
1660 )
1661 }
1662
1663 fn meter(used: f64, quota: f64, reset: Option<&str>) -> crate::arkuse::api::Meter {
1664 let percent = if quota > 0.0 {
1665 used / quota * 100.0
1666 } else {
1667 0.0
1668 };
1669 crate::arkuse::api::Meter::from_parts("ok", percent, used, quota, reset, None)
1670 }
1671
1672 #[test]
1673 fn a_local_row_still_reads_used_over_quota_and_a_reset() {
1674 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1677 let five = text
1678 .lines()
1679 .find(|line| line.contains("5-hour"))
1680 .expect("a 5-hour row");
1681 assert!(
1682 five.contains("/ 10,000 AFP"),
1683 "the plan's own quota: {five}"
1684 );
1685 assert!(five.contains("% of limit (est.)"), "a percentage: {five}");
1686 let weekly = text
1687 .lines()
1688 .find(|line| line.contains("Weekly"))
1689 .expect("a weekly row");
1690 assert!(weekly.contains("resets"), "a countdown: {weekly}");
1691 assert!(weekly.contains("window "), "an elapsed share: {weekly}");
1692 let json: serde_json::Value = serde_json::from_str(&render_json(&report())).unwrap();
1693 assert_eq!(json["windows"]["fiveHour"]["quota"], 10_000.0);
1694 assert!(json["windows"]["weekly"]["resetAt"].is_i64());
1695 }
1696
1697 #[test]
1698 fn a_meter_without_its_reset_falls_back_to_the_stores_boundary() {
1699 let account = crate::arkuse::api::Usage {
1702 five_hour: meter(0.0, 10_000.0, None),
1703 daily: None,
1704 weekly: meter(2_975.67, 35_000.0, Some("2026-10-26T00:00:00+08:00")),
1705 monthly: meter(2_975.67, 100_000.0, Some("2026-11-19T23:59:59+08:00")),
1706 tier: Some("medium".into()),
1707 profile: "agent-plan_cn-beijing_medium".into(),
1708 };
1709 let text = render_text(
1710 &metered(&account),
1711 "/tmp/opencode.db",
1712 10,
1713 false,
1714 DEFAULT_BAR_WIDTH,
1715 );
1716 let five = text
1717 .lines()
1718 .find(|line| line.contains("5-hour"))
1719 .expect("a 5-hour row");
1720 assert!(
1721 five.contains("0% of limit"),
1722 "the server's own share: {five}"
1723 );
1724 assert!(five.contains("resets"), "a derived countdown: {five}");
1725 }
1726
1727 #[test]
1728 fn the_bar_carries_the_share_so_the_body_does_not_repeat_it() {
1729 let text = render_text(&report(), "/tmp/opencode.db", 10, true, DEFAULT_BAR_WIDTH);
1733 let weekly = text
1734 .lines()
1735 .find(|line| line.contains("Weekly"))
1736 .expect("a weekly row");
1737 assert!(!weekly.contains("of limit"), "no repeated share: {weekly}");
1738 assert!(weekly.contains("/ 35,000 AFP"), "the pair: {weekly}");
1739 assert!(
1740 weekly.contains("(est.)"),
1741 "still marked an estimate: {weekly}"
1742 );
1743 }
1744
1745 #[test]
1746 fn the_machine_formats_are_not_coloured() {
1747 let rows = vec![Row {
1748 at_ms: 1_800_000_000_000,
1749 session: "ses_a".into(),
1750 provider: "agent-plan".into(),
1751 model: "deepseek-v4.1-flash".into(),
1752 input: 1_000,
1753 ..Default::default()
1754 }];
1755 let catalog = ark::catalog();
1756 for fmt in [Fmt::Json, Fmt::Csv] {
1757 let daily = bucket_text(
1758 &rows,
1759 &catalog,
1760 1_800_000_000_000,
1761 86_400_000,
1762 86_400_000,
1763 0,
1764 true,
1765 fmt,
1766 );
1767 assert!(!daily.contains('\u{1b}'), "{fmt:?} leaked colour: {daily}");
1768 }
1769 let report = report();
1770 let json = model_report(&report, None, Fmt::Json);
1771 assert!(!json.contains('\u{1b}'));
1772 assert!(json.contains("deepseek-v4.1-flash"));
1773 let csv = model_report(&report, None, Fmt::Csv);
1774 assert!(csv.starts_with("model,requests,tokens_in,tokens_out,cache_read,afp"));
1775 }
1776}