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