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