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
170#[allow(clippy::too_many_arguments)]
171struct RowSpec<'a> {
179 label: &'a str,
180 used: &'a Totals,
182 meter: Option<&'a crate::arkuse::api::Meter>,
184 reset: Option<(i64, u64)>,
186 pro_rata: bool,
190}
191
192fn window_line(ink: &Ink, spec: &RowSpec<'_>, now_ms: i64, catalog: &ark::Catalog) -> String {
193 let now_secs = (now_ms / 1000).max(0) as u64;
194 let reset_at = spec.reset.map(|(at, _)| at as f64);
195 let dur_secs = spec.reset.map(|(_, dur)| dur);
196 let elapsed = dur_secs.and_then(|d| cmduse_core::elapsed_pct(reset_at, d, Some(now_secs)));
197 let reset = spec
198 .reset
199 .map(|(at, _)| duration(((at - now_ms) / 1000).max(0) as u64))
200 .unwrap_or_default();
201 let reset_text = if reset.is_empty() {
202 String::new()
203 } else if ink.colour {
204 format!(" · resets in {reset}")
205 } else {
206 format!(" · resets {reset}")
207 };
208 let thru = elapsed
209 .map(|p| format!(" · {}window {p:.1}% elapsed{}", ink.dim, ink.reset))
210 .unwrap_or_default();
211
212 let (body, tone) = match spec.meter {
215 Some(meter) => {
216 let pace = spec
217 .reset
218 .and_then(|(at, d)| {
219 cmduse_core::pace_eta(Some(at as f64), d, meter.used, meter.quota, now_secs)
220 .map(|secs| duration(secs as u64))
221 })
222 .map(|eta| format!(" · {}on pace to hit cap in {eta}{}", ink.yellow, ink.reset))
223 .unwrap_or_default();
224 let by_now = match (spec.pro_rata, elapsed) {
225 (true, Some(p)) => format!(
226 " · {}{} by now{}",
227 ink.dim,
228 afp(meter.quota * p / 100.0),
229 ink.reset
230 ),
231 _ => String::new(),
232 };
233 let flag = if meter.exceeded() {
234 format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
235 } else if !meter.status.eq_ignore_ascii_case("ok") {
236 format!(" {}{}{}{}", ink.red, meter.status, ink.reset, "")
237 } else {
238 String::new()
239 };
240 let pct_tone = ink.for_pct(meter.percent);
241 let tail = format!("{reset_text}{flag}{pace}{thru}{by_now}");
242 let body = format!(
243 "{} / {} AFP · {}{}% of limit{} · {}{} local{}{tail}",
244 afp(meter.used),
245 afp(meter.quota),
246 pct_tone,
247 given_pct(meter.percent),
248 ink.reset,
249 ink.dim,
250 afp(spec.used.afp),
251 ink.reset,
252 );
253 (body, pct_tone)
254 }
255 None => {
256 let body = format!(
257 "{} AFP · {}{} · {} requests{}",
258 afp(spec.used.afp),
259 ink.dim,
260 yuan(catalog.yuan(spec.used.afp)),
261 compact(spec.used.requests),
262 ink.reset,
263 );
264 (format!("{body}{reset_text}{thru}"), ink.for_pct(0.0))
265 }
266 };
267 if ink.colour {
268 let bar = match spec.meter {
269 Some(meter) => format!("{} ", ink.bar(meter.percent, 100.0)),
270 None => String::new(),
271 };
272 format!(
273 " {}{:<8}{} {}{}{body}{}\n",
274 ink.bold, spec.label, ink.reset, bar, tone, ink.reset,
275 )
276 } else {
277 format!(" {:<8} {body}\n", spec.label)
278 }
279}
280
281fn model_row(ink: &Ink, model: &crate::arkuse::window::ModelUsage, total_afp: f64) -> String {
285 let coef = format!("{:>5.2}", model.coef);
286 let coef_cell = if ink.colour {
287 format!("{}{coef}{}", ink.cyan, ink.reset)
288 } else {
289 coef
290 };
291 let share = if total_afp > 0.0 {
292 ink.dim_on(format!(" {:>5.1}%", model.month.afp / total_afp * 100.0))
293 } else {
294 String::new()
295 };
296 format!(
297 "{:<28} {coef_cell} {:>9} {:>7}{share}\n",
298 model.id,
299 afp(model.month.afp),
300 compact(model.month.requests),
301 )
302}
303
304pub fn render_text(
306 report: &Report,
307 db_path: &str,
308 rows: usize,
309 colour: bool,
310 bar_width: usize,
311) -> String {
312 let ink = Ink::new(colour).width(bar_width);
313 let catalog = ark::catalog();
314 let c = |text: String| ink.cyan_on(text);
315 let mut out = String::new();
316 let tier_label = catalog
317 .plan(&report.tier)
318 .map(|p| p.label.clone())
319 .unwrap_or_else(|| report.tier.clone());
320 out.push_str(&format!(
321 "{}Volcengine ARK usage{} — agent plan {}, from {}{}{}\n",
322 ink.bold,
323 ink.reset,
324 tier_label,
325 ink.dim,
326 shorten(db_path),
327 ink.reset,
328 ));
329 let end = report
330 .period_end_ms
331 .map(|ms| {
332 format!(
333 " · ends {}",
334 date_of((ms + report.offset_secs * 1000).div_euclid(86_400_000))
335 )
336 })
337 .unwrap_or_default();
338 let start = date_of((report.since_ms + report.offset_secs * 1000).div_euclid(86_400_000));
339 out.push_str(&format!(
340 "period: {start}{end} · {} requests · {} AFP ({}) · {} in / {} out tokens\n",
341 c(compact(report.month.requests)),
342 c(afp(report.month.afp)),
343 c(yuan(catalog.yuan(report.month.afp))),
344 c(compact(report.month.input)),
345 c(compact(report.month.output)),
346 ));
347 if let Some(plan) = catalog.plan(&report.tier) {
348 out.push_str(&format!(
349 "{}plan: {} {} · {} AFP/mo · {} · {} windows{}",
350 ink.dim,
351 plan.label,
352 yuan(plan.price),
353 afp(plan.afp),
354 catalog.currency,
355 if report.account.is_some() {
356 "metered by the control plane"
357 } else {
358 "local estimate"
359 },
360 ink.reset,
361 ));
362 out.push('\n');
363 }
364
365 match &report.account {
366 Some(account) => {
367 out.push_str(&format!(
368 "\n{}AGENT PLAN{} {}\n",
369 ink.bold,
370 ink.reset,
371 ink.dim_on(
372 "(account meters — what the Volcengine console reports; AFP beside them is this store's own estimate)"
373 .into()
374 ),
375 ));
376 let monthly_dur = report
377 .period_end_ms
378 .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
379 let daily_local = report.daily.clone().unwrap_or_default();
380 let mut windows: Vec<(&str, &crate::arkuse::api::Meter, &Totals, Option<u64>, bool)> =
381 vec![(
382 "5-hour",
383 &account.five_hour,
384 &report.five_hour,
385 Some(crate::arkuse::window::FIVE_HOUR_SECS as u64),
386 false,
387 )];
388 if let Some(daily) = &account.daily {
389 windows.push((
390 "Daily",
391 daily,
392 &daily_local,
393 Some(crate::arkuse::window::DAY_SECS as u64),
394 false,
395 ));
396 }
397 windows.push((
398 "Weekly",
399 &account.weekly,
400 &report.weekly,
401 Some(crate::arkuse::window::WEEKLY_SECS as u64),
402 false,
403 ));
404 windows.push((
405 "Monthly",
406 &account.monthly,
407 &report.month,
408 monthly_dur,
409 true,
410 ));
411 for (label, meter, used, dur, pro_rata) in windows {
412 out.push_str(&window_line(
413 &ink,
414 &RowSpec {
415 label,
416 used,
417 meter: Some(meter),
418 reset: meter_reset(meter, dur),
419 pro_rata,
420 },
421 report.now_ms,
422 &catalog,
423 ));
424 }
425 let mut notes: Vec<String> = Vec::new();
429 if let Some(delta) = report.reconcile() {
430 notes.push(format!("local vs meter {:+.1}%", delta));
431 }
432 if let Some(per) = report.afp_per_request() {
433 notes.push(format!("{} AFP/request", afp(per)));
434 }
435 if let Some(burn) = report.burn_per_day() {
436 notes.push(format!("{} AFP/day at this pace", afp(burn)));
437 }
438 if !notes.is_empty() {
439 out.push_str(&format!(
440 "{} {} · {}{}\n",
441 ink.dim,
442 account.profile,
443 notes.join(" · "),
444 ink.reset,
445 ));
446 }
447 }
448 None => {
449 out.push_str(&format!(
450 "\n{}AGENT PLAN{} {}\n",
451 ink.bold,
452 ink.reset,
453 ink.dim_on("(estimated locally: no control-plane answer, so AFP alone)".into()),
454 ));
455 if let Some(why) = &report.account_error {
456 out.push_str(&format!("{} usage API: {why}{}\n", ink.dim, ink.reset));
457 }
458 let monthly_dur = report
459 .period_end_ms
460 .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
461 out.push_str(&window_line(
462 &ink,
463 &RowSpec {
464 label: "5-hour",
465 used: &report.five_hour,
466 meter: None,
467 reset: None,
468 pro_rata: false,
469 },
470 report.now_ms,
471 &catalog,
472 ));
473 out.push_str(&window_line(
474 &ink,
475 &RowSpec {
476 label: "Weekly",
477 used: &report.weekly,
478 meter: None,
479 reset: None,
480 pro_rata: false,
481 },
482 report.now_ms,
483 &catalog,
484 ));
485 out.push_str(&window_line(
486 &ink,
487 &RowSpec {
488 label: "Monthly",
489 used: &report.month,
490 meter: None,
491 reset: report.period_end_ms.zip(monthly_dur),
492 pro_rata: false,
493 },
494 report.now_ms,
495 &catalog,
496 ));
497 }
498 }
499
500 out.push_str(&format!(
501 "\n{}MODEL COEF AFP REQ SHARE{}\n",
502 ink.bold, ink.reset
503 ));
504 let total_afp = report.month.afp;
505 for model in report.models.iter().take(rows) {
506 out.push_str(&model_row(&ink, model, total_afp));
507 }
508 if report.models.len() > rows {
509 out.push_str(&format!(
510 "{}\n",
511 ink.dim_on(format!("… {} more", report.models.len() - rows))
512 ));
513 }
514 out.push_str(&format!(
516 "\n{}\n",
517 ink.dim_on(
518 "AFP = (input + cache_read + output) × coefficient / 10,000; the coefficient is the plan's own and a promo halves it until its date."
519 .into()
520 ),
521 ));
522 if report.account.is_some() {
523 out.push_str(&format!(
524 "{}\n",
525 ink.dim_on(
526 "The windows are the account's own cuts (5h block, Monday week, subscription month); the model AFP is this machine inside them."
527 .into()
528 )
529 ));
530 } else {
531 out.push_str(&format!(
532 "{}\n",
533 ink.dim_on(
534 "Windows are rolling 5h / 7d / calendar month cuts, local only — not the control plane's anchors."
535 .into()
536 )
537 ));
538 }
539 out
540}
541
542pub fn watch_frame(
547 report: &Report,
548 db_path: &str,
549 rows: usize,
550 colour: bool,
551 history: &[f64],
552 interval_secs: u64,
553 bar_width: usize,
554) -> String {
555 let ink = Ink::new(colour);
556 let mut out = render_text(report, db_path, rows, colour, bar_width);
557 if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
558 out.push_str(&format!(
559 "{}AFP bursts ({}s samples){} {}\n",
560 ink.dim,
561 interval_secs,
562 ink.reset,
563 crate::render::sparkline(history),
564 ));
565 }
566 out.trim_end_matches('\n').to_string()
567}
568
569pub fn compact_line(report: &Report, history: &[f64], colour: bool) -> String {
571 let ink = Ink::new(colour);
572 let catalog = ark::catalog();
573 let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
574 format!(
575 " {}{}{}",
576 ink.dim,
577 crate::render::sparkline(history),
578 ink.reset
579 )
580 } else {
581 String::new()
582 };
583 let window_share = match &report.account {
584 Some(account) => format!(
585 " · 5h {} · wk {} · mo {}",
586 ink.cyan_on(format!("{}%", given_pct(account.five_hour.percent))),
587 ink.cyan_on(format!("{}%", given_pct(account.weekly.percent))),
588 ink.cyan_on(format!("{}%", given_pct(account.monthly.percent))),
589 ),
590 None => format!(" · 5h {}", ink.cyan_on(afp(report.five_hour.afp))),
591 };
592 format!(
593 "{}ARK{} {} AFP ({}) month{window_share}{spark}",
594 ink.bold,
595 ink.reset,
596 ink.cyan_on(afp(report.month.afp)),
597 ink.cyan_on(yuan(catalog.yuan(report.month.afp))),
598 )
599}
600
601pub fn statusline_template(
613 report: &Report,
614 plan_id: &str,
615 tpl: &str,
616 colours: bool,
617 ascii: bool,
618 bar_width: usize,
619) -> String {
620 let ink = Ink::new(colours);
621 let catalog = ark::catalog();
622 let account = report.account.as_ref();
623 let share_of = |meter: Option<&crate::arkuse::api::Meter>| match meter {
624 Some(meter) => format!("{}%", given_pct(meter.percent)),
625 None => "—".into(),
626 };
627 let share_bar = |meter: Option<&crate::arkuse::api::Meter>| match meter {
628 Some(meter) => crate::report_render::bar(meter.percent, bar_width, ascii, colours),
629 None => String::new(),
630 };
631 let cash = |value: f64| ink.cyan_on(afp(value));
632 let top = report
633 .models
634 .first()
635 .map(|m| m.id.clone())
636 .unwrap_or_else(|| "—".into());
637 let replacements: Vec<(&str, String)> = vec![
638 ("plan", plan_id.to_string()),
639 ("tier", plan_id.to_string()),
640 ("period", report.period_label.clone()),
641 ("month", cash(report.month.afp)),
642 ("month_pct", share_of(account.map(|a| &a.monthly))),
643 ("month_bar", share_bar(account.map(|a| &a.monthly))),
644 ("five_hour", cash(report.five_hour.afp)),
645 ("5h", cash(report.five_hour.afp)),
646 ("five_hour_pct", share_of(account.map(|a| &a.five_hour))),
647 ("5h_pct", share_of(account.map(|a| &a.five_hour))),
648 ("five_hour_bar", share_bar(account.map(|a| &a.five_hour))),
649 ("5h_bar", share_bar(account.map(|a| &a.five_hour))),
650 ("weekly", cash(report.weekly.afp)),
651 ("wk", cash(report.weekly.afp)),
652 ("weekly_pct", share_of(account.map(|a| &a.weekly))),
653 ("wk_pct", share_of(account.map(|a| &a.weekly))),
654 ("weekly_bar", share_bar(account.map(|a| &a.weekly))),
655 ("wk_bar", share_bar(account.map(|a| &a.weekly))),
656 ("yuan", yuan(catalog.yuan(report.month.afp))),
657 ("yuan_month", yuan(catalog.yuan(report.month.afp))),
658 ("models", top),
659 ];
660 crate::report_render::render_template(tpl, |key| {
661 let lower = key.to_ascii_lowercase();
662 replacements
663 .iter()
664 .find(|(name, _)| *name == lower)
665 .map(|(_, value)| value.clone())
666 .unwrap_or_default()
667 })
668}
669
670pub fn statusline_json(report: &Report, plan_id: &str) -> String {
671 let catalog = ark::catalog();
672 serde_json::json!({
673 "plan": plan_id,
674 "period": report.period_label,
675 "source": "opencode",
676 "monthSpent": report.month.afp,
679 "monthCap": serde_json::Value::Null,
680 "fiveHourSpent": report.five_hour.afp,
681 "fiveHourCap": serde_json::Value::Null,
682 "weeklySpent": report.weekly.afp,
683 "weeklyCap": serde_json::Value::Null,
684 "model": report.models.first().map(|m| m.id.clone()),
685 "afp": {
686 "fiveHour": report.five_hour.afp,
687 "weekly": report.weekly.afp,
688 "monthly": report.month.afp,
689 "yuan": catalog.yuan(report.month.afp),
690 },
691 "account": report.account.as_ref().map(usage_json),
692 "accountError": report.account_error,
693 "periodEnd": report.period_end_ms,
694 })
695 .to_string()
696}
697
698fn meter_json(meter: &crate::arkuse::api::Meter) -> serde_json::Value {
701 serde_json::json!({
702 "used": meter.used,
703 "total": meter.quota,
704 "percent": meter.percent,
705 "status": meter.status,
706 "resetsAt": meter.resets_at,
707 "resetAt": meter.reset_ms(),
708 })
709}
710
711fn usage_json(usage: &crate::arkuse::api::Usage) -> serde_json::Value {
714 serde_json::json!({
715 "profile": usage.profile,
716 "tier": usage.tier,
717 "fiveHour": meter_json(&usage.five_hour),
718 "daily": usage.daily.as_ref().map(meter_json),
719 "weekly": meter_json(&usage.weekly),
720 "monthly": meter_json(&usage.monthly),
721 })
722}
723
724pub fn render_json(report: &Report) -> String {
725 let totals = |t: &Totals| {
726 serde_json::json!({
727 "afp": t.afp,
728 "requests": t.requests,
729 "tokensIn": t.input,
730 "tokensOut": t.output,
731 "cacheRead": t.cache_read,
732 "cacheWrite": t.cache_write,
733 })
734 };
735 let models: serde_json::Map<String, serde_json::Value> = report
736 .models
737 .iter()
738 .map(|m| {
739 (
740 m.id.clone(),
741 serde_json::json!({
742 "coef": m.coef,
743 "month": totals(&m.month),
744 "fiveHour": totals(&m.five_hour),
745 "weekly": totals(&m.weekly),
746 }),
747 )
748 })
749 .collect();
750 serde_json::json!({
751 "source": "opencode",
752 "scope": "local",
753 "plan": report.tier,
754 "profile": report.profile,
755 "periodStart": report.since_ms,
756 "periodEndAt": report.period_end_ms,
757 "periodEnd": report.period_end_ms
758 .map(|ms| date_of((ms + report.offset_secs * 1000).div_euclid(86_400_000))),
759 "monthInferred": report.month_inferred,
760 "summary": {
761 "afp": report.month.afp,
762 "yuan": ark::catalog().yuan(report.month.afp),
763 "requests": report.month.requests,
764 "tokensIn": report.month.input,
765 "tokensOut": report.month.output,
766 "cacheRead": report.month.cache_read,
767 },
768 "fiveHour": totals(&report.five_hour),
769 "weekly": totals(&report.weekly),
770 "monthly": totals(&report.month),
771 "daily": report.daily.as_ref().map(totals),
772 "account": report.account.as_ref().map(|account| {
773 let mut body = serde_json::json!({
774 "profile": account.profile,
775 "tier": account.tier,
776 "fiveHour": meter_json(&account.five_hour),
777 "weekly": meter_json(&account.weekly),
778 "monthly": meter_json(&account.monthly),
779 });
780 if let Some(daily) = &account.daily {
781 body["daily"] = meter_json(daily);
782 }
783 body
784 }),
785 "accountError": report.account_error,
786 "reconciledPct": report.reconcile(),
787 "afpPerRequest": report.afp_per_request(),
788 "models": models,
789 })
790 .to_string()
791}
792
793pub fn plans_text(colour: bool) -> String {
797 let ink = Ink::new(colour);
798 let catalog = ark::catalog();
799 let mut out = format!(
800 "{}{:<8} {:>9} {:>9} {:>10} {:>10} {:>10}{}\n",
801 ink.bold, "TIER", "YUAN/MO", "AFP/MO", "5H AFP", "DAILY AFP", "WK AFP", ink.reset
802 );
803 for plan in &catalog.plans {
804 out.push_str(&format!(
805 "{:<8} {:>9} {:>9} {:>10} {:>10} {:>10}\n",
806 plan.label,
807 ink.cyan_on(yuan(plan.price)),
808 ink.cyan_on(afp(plan.afp)),
809 afp(plan.afp * catalog.windows.five_hour),
810 afp(plan.afp * catalog.windows.daily),
811 afp(plan.afp * catalog.windows.weekly),
812 ));
813 if let Some(promo) = &plan.promo {
814 out.push_str(&format!(
815 "{}{} for {} months, until {}{}\n",
816 ink.dim,
817 yuan(promo.price),
818 promo.months.unwrap_or(1),
819 promo.until,
820 ink.reset,
821 ));
822 }
823 }
824 out.push_str(&format!(
825 "\n{}1 AFP = ¥{:.3} at every tier · 1,000 AFP = {}{}\n",
826 ink.dim,
827 catalog.afp_yuan,
828 yuan(catalog.yuan(1000.0)),
829 ink.reset,
830 ));
831 if !catalog.enterprise.is_empty() {
832 out.push_str(&format!(
833 "\n{}ENTERPRISE{} {}\n",
834 ink.bold,
835 ink.reset,
836 ink.dim_on(
837 "(seat-based; AFP is metered per seat and the personal usage API does not expose it)"
838 .into()
839 ),
840 ));
841 for plan in &catalog.enterprise {
842 out.push_str(&format!(
843 " {:<22} {}\n",
844 plan.label,
845 ink.cyan_on(format!("{}/seat/mo", yuan(plan.price))),
846 ));
847 }
848 }
849 if let Some(note) = catalog.notes.get("windows") {
850 out.push_str(&format!("\n{}{}{}\n", ink.dim, note, ink.reset));
851 }
852 out
853}
854
855pub fn plans_json() -> String {
856 let catalog = ark::catalog();
857 serde_json::json!({
858 "source": catalog.source,
859 "extractedAt": catalog.extracted_at,
860 "currency": catalog.currency,
861 "afpYuan": catalog.afp_yuan,
862 "windows": {
863 "fiveHour": catalog.windows.five_hour,
864 "daily": catalog.windows.daily,
865 "weekly": catalog.windows.weekly,
866 "monthly": catalog.windows.monthly,
867 },
868 "plans": catalog.plans.iter().map(|p| serde_json::json!({
869 "id": p.id,
870 "label": p.label,
871 "price": p.price,
872 "listPrice": p.list_price,
873 "afp": p.afp,
874 "promo": p.promo.as_ref().map(|promo| serde_json::json!({
875 "price": promo.price,
876 "until": promo.until,
877 "months": promo.months,
878 })),
879 })).collect::<Vec<_>>(),
880 "enterprise": catalog.enterprise.iter().map(|p| serde_json::json!({
881 "id": p.id,
882 "label": p.label,
883 "price": p.price,
884 })).collect::<Vec<_>>(),
885 "modelCount": catalog.models.len(),
886 })
887 .to_string()
888}
889
890fn yuan_per_million(coef: f64, afp_yuan: f64) -> f64 {
894 1_000_000.0 * coef / 10_000.0 * afp_yuan
895}
896
897pub fn models_text(plan_id: &str, gated: bool, colour: bool) -> String {
901 let ink = Ink::new(colour);
902 let catalog = ark::catalog();
903 let now = crate::arkuse::window::now_ms();
904 let mut out = format!(
905 "{}{:<30} {:>6} {:>7} {:>10} {:>8} {:>8}{}\n",
906 ink.bold, "MODEL", "KIND", "COEF", "YUAN/1M", "MIN", "GRANTED", ink.reset
907 );
908 for model in &catalog.models {
909 let granted = catalog.grants(plan_id, &model.min_plan);
910 if gated && !granted {
911 continue;
912 }
913 let coef = ark::effective_coef(model, now);
914 let promo = model.promo_coef.map(|_| {
915 format!(
916 " (promo until {})",
917 model.promo_until.as_deref().unwrap_or("?")
918 )
919 });
920 let coef_cell = ink.cyan_on(format!("{coef:.2}"));
921 out.push_str(&format!(
922 "{:<30} {:>6} {:>7} {:>10} {:>8} {:>8}",
923 model.id,
924 model.kind,
925 coef_cell,
926 format!("{:.2}", yuan_per_million(coef, catalog.afp_yuan)),
927 model.min_plan,
928 if granted { "yes" } else { "no" },
929 ));
930 if let Some(promo) = promo {
931 out.push_str(&format!("{}{promo}{}", ink.dim, ink.reset));
932 }
933 out.push('\n');
934 }
935 out
936}
937
938pub fn models_json(plan_id: &str, gated: bool) -> String {
941 let catalog = ark::catalog();
942 let now = crate::arkuse::window::now_ms();
943 let mut lines: Vec<String> = Vec::new();
944 for model in &catalog.models {
945 let granted = catalog.grants(plan_id, &model.min_plan);
946 if gated && !granted {
947 continue;
948 }
949 let coef = ark::effective_coef(model, now);
950 lines.push(
951 serde_json::json!({
952 "id": model.id,
953 "name": model.name,
954 "kind": model.kind,
955 "coef": coef,
956 "baseCoef": model.coef,
957 "promoCoef": model.promo_coef,
958 "promoUntil": model.promo_until,
959 "yuanPerMillion": yuan_per_million(coef, catalog.afp_yuan),
960 "minPlan": model.min_plan,
961 "granted": granted,
962 })
963 .to_string(),
964 );
965 }
966 lines.join("\n")
967}
968
969pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
972 let ink = Ink::new(colour);
973 let Some(model) = report.models.iter().find(|m| m.id == id) else {
974 return format!("{id}: no usage in the period\n");
975 };
976 let row =
977 |label: &str, value: String| format!(" {}{value}\n", ink.dim_on(format!("{label:<10}")));
978 let mut out = format!(
979 "{} {}\n",
980 ink.bold_on(model.id.clone()),
981 ink.dim_on(format!("({})", model.provider)),
982 );
983 out.push_str(&row(
984 "coef",
985 ink.cyan_on(format!(
986 "{:.2} · {} per million tokens",
987 model.coef,
988 yuan(yuan_per_million(model.coef, ark::catalog().afp_yuan))
989 )),
990 ));
991 out.push_str(&row(
992 "month",
993 ink.cyan_on(format!(
994 "{} AFP ({}) · {} requests",
995 afp(model.month.afp),
996 yuan(ark::catalog().yuan(model.month.afp)),
997 model.month.requests
998 )),
999 ));
1000 out.push_str(&row(
1001 "5-hour",
1002 ink.cyan_on(format!("{} AFP", afp(model.five_hour.afp))),
1003 ));
1004 out.push_str(&row(
1005 "weekly",
1006 ink.cyan_on(format!("{} AFP", afp(model.weekly.afp))),
1007 ));
1008 if report.month.afp > 0.0 {
1009 out.push_str(&row(
1010 "share",
1011 ink.dim_on(format!(
1012 "{:.1}% of the period's AFP",
1013 model.month.afp / report.month.afp * 100.0
1014 )),
1015 ));
1016 }
1017 out
1018}
1019
1020pub fn model_report(report: &Report, id: Option<&str>, fmt: Fmt) -> String {
1023 let models: Vec<&crate::arkuse::window::ModelUsage> = report
1024 .models
1025 .iter()
1026 .filter(|m| id.is_none_or(|want| m.id == want))
1027 .collect();
1028 match fmt {
1029 Fmt::Json => {
1030 let map: serde_json::Map<String, serde_json::Value> = models
1031 .iter()
1032 .map(|m| {
1033 (
1034 m.id.clone(),
1035 serde_json::json!({
1036 "coef": m.coef,
1037 "requests": m.month.requests,
1038 "tokensIn": m.month.input,
1039 "tokensOut": m.month.output,
1040 "cacheRead": m.month.cache_read,
1041 "afp": m.month.afp,
1042 "yuan": ark::catalog().yuan(m.month.afp),
1043 }),
1044 )
1045 })
1046 .collect();
1047 serde_json::json!({
1048 "scope": "local",
1049 "source": "opencode",
1050 "sinceMs": report.since_ms,
1051 "models": map,
1052 })
1053 .to_string()
1054 }
1055 Fmt::Csv => {
1056 let mut out = String::from("model,requests,tokens_in,tokens_out,cache_read,afp\n");
1057 for m in models {
1058 out.push_str(&format!(
1059 "{},{},{},{},{},{:.2}\n",
1060 crate::report_render::csv_field(&m.id),
1061 m.month.requests,
1062 m.month.input,
1063 m.month.output,
1064 m.month.cache_read,
1065 m.month.afp,
1066 ));
1067 }
1068 out
1069 }
1070 Fmt::Table => String::new(),
1071 }
1072}
1073
1074pub fn models_period_text(report: &Report, limit: usize, colour: bool) -> String {
1078 let ink = Ink::new(colour);
1079 let mut out = format!(
1080 "{}{:<28} {:>5} {:>9} {:>7} {:>6}{}\n",
1081 ink.bold, "MODEL", "COEF", "AFP", "REQ", "SHARE", ink.reset
1082 );
1083 let total = report.month.afp;
1084 for model in report.models.iter().take(limit.min(report.models.len())) {
1085 let share = if total > 0.0 {
1086 ink.dim_on(format!("{:>5.1}%", model.month.afp / total * 100.0))
1087 } else {
1088 " -".to_string()
1089 };
1090 out.push_str(&format!(
1091 "{:<28} {} {:>9} {:>7} {}\n",
1092 model.id,
1093 ink.cyan_on(format!("{:>5.2}", model.coef)),
1094 ink.cyan_on(format!("{:>9}", afp(model.month.afp))),
1095 ink.cyan_on(format!("{:>7}", compact(model.month.requests))),
1096 share,
1097 ));
1098 }
1099 if report.models.is_empty() {
1100 out.push_str(&format!(
1101 "{}\n",
1102 ink.dim_on("(no agent-plan usage in the period)".into())
1103 ));
1104 } else if total > 0.0 {
1105 out.push_str(&format!(
1106 "{:<28} {:>5} {:>9} {:>7} {:>6}\n",
1107 ink.bold_on("TOTAL".into()),
1108 "",
1109 afp(total),
1110 compact(report.month.requests),
1111 "100.0%",
1112 ));
1113 }
1114 out
1115}
1116
1117fn totals_json(
1120 afp: f64,
1121 requests: u64,
1122 input: u64,
1123 output: u64,
1124 cache_read: u64,
1125) -> serde_json::Value {
1126 serde_json::json!({
1127 "requests": requests,
1128 "tokensIn": input,
1129 "tokensOut": output,
1130 "cacheRead": cache_read,
1131 "afp": afp,
1132 })
1133}
1134
1135#[derive(Default, Clone, Copy)]
1138struct Bucket {
1139 afp: f64,
1140 requests: u64,
1141 input: u64,
1142 output: u64,
1143 cache_read: u64,
1144}
1145
1146impl Bucket {
1147 fn add(&mut self, catalog: &ark::Catalog, row: &crate::arkuse::db::Row) {
1148 self.requests += 1;
1149 self.input += row.input;
1150 self.output += row.output;
1151 self.cache_read += row.cache_read;
1152 self.afp += catalog
1153 .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1154 .unwrap_or(0.0);
1155 }
1156
1157 fn merge(&mut self, other: &Bucket) {
1158 self.afp += other.afp;
1159 self.requests += other.requests;
1160 self.input += other.input;
1161 self.output += other.output;
1162 self.cache_read += other.cache_read;
1163 }
1164
1165 fn json(&self) -> serde_json::Value {
1166 totals_json(
1167 self.afp,
1168 self.requests,
1169 self.input,
1170 self.output,
1171 self.cache_read,
1172 )
1173 }
1174}
1175
1176#[allow(clippy::too_many_arguments)]
1178pub fn bucket_text(
1179 rows: &[crate::arkuse::db::Row],
1180 catalog: &ark::Catalog,
1181 now_ms: i64,
1182 lookback_ms: i64,
1183 size_ms: i64,
1184 tz_secs: i64,
1185 colour: bool,
1186 fmt: Fmt,
1187) -> String {
1188 let shift = tz_secs * 1000;
1190 let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
1191 for row in rows {
1192 let bucket = (row.at_ms + shift).div_euclid(size_ms);
1193 buckets.entry(bucket).or_default().add(catalog, row);
1194 }
1195 let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
1196 let daily = size_ms >= crate::arkuse::window::DAY_SECS * 1000;
1197 let label = if daily { "day" } else { "hour" };
1198 let stamp = |bucket: i64| {
1199 if daily {
1200 date_of(bucket)
1201 } else {
1202 format!(
1203 "{} {:02}h",
1204 date_of(bucket.div_euclid(24)),
1205 bucket.rem_euclid(24)
1206 )
1207 }
1208 };
1209 let window: Vec<(i64, Bucket)> = buckets
1210 .range(start..)
1211 .map(|(bucket, b)| (*bucket, *b))
1212 .collect();
1213
1214 match fmt {
1215 Fmt::Json => {
1216 let mut keyed = serde_json::Map::new();
1217 let mut total = Bucket::default();
1218 for (bucket, b) in &window {
1219 keyed.insert(stamp(*bucket), b.json());
1220 total.merge(b);
1221 }
1222 let key = if daily { "days" } else { "hours" };
1223 serde_json::json!({
1224 "scope": "local",
1225 "source": "opencode",
1226 "total": total.json(),
1227 key: keyed,
1228 })
1229 .to_string()
1230 }
1231 Fmt::Csv => {
1232 let mut out = format!("{label},requests,tokens_in,tokens_out,cache_read,afp\n");
1233 let mut total = Bucket::default();
1234 for (bucket, b) in &window {
1235 out.push_str(&format!(
1236 "{},{},{},{},{},{:.2}\n",
1237 crate::report_render::csv_field(&stamp(*bucket)),
1238 b.requests,
1239 b.input,
1240 b.output,
1241 b.cache_read,
1242 b.afp,
1243 ));
1244 total.merge(b);
1245 }
1246 out.push_str(&format!(
1247 "total,{},{},{},{},{:.2}\n",
1248 total.requests, total.input, total.output, total.cache_read, total.afp,
1249 ));
1250 out
1251 }
1252 Fmt::Table => {
1253 let ink = Ink::new(colour);
1254 let mut out = format!(
1255 "{}{label:<12} AFP requests tokens{}\n",
1256 ink.bold, ink.reset
1257 );
1258 for (bucket, b) in &window {
1259 out.push_str(&format!(
1260 "{:<12} {} {} {}\n",
1261 stamp(*bucket),
1262 ink.cyan_on(format!("{:>9}", afp(b.afp))),
1263 ink.cyan_on(format!("{:>12}", compact(b.requests))),
1264 ink.cyan_on(format!("{:>9}", compact(b.input + b.output + b.cache_read))),
1265 ));
1266 }
1267 if out.lines().count() == 1 {
1268 out.push_str(&format!(
1269 "{}\n",
1270 ink.dim_on("(no agent-plan usage in the window)".into())
1271 ));
1272 }
1273 out
1274 }
1275 }
1276}
1277
1278type SessionTotals<'a> = (
1281 f64,
1282 u64,
1283 u64,
1284 u64,
1285 u64,
1286 i64,
1287 std::collections::BTreeSet<&'a str>,
1288);
1289
1290pub fn session_text(
1293 rows: &[crate::arkuse::db::Row],
1294 catalog: &ark::Catalog,
1295 limit: usize,
1296 colour: bool,
1297 fmt: Fmt,
1298) -> String {
1299 let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1300 for row in rows {
1301 let entry = by_session
1302 .entry(row.session.as_str())
1303 .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1304 entry.0 += catalog
1305 .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1306 .unwrap_or(0.0);
1307 entry.1 += 1;
1308 entry.2 += row.input;
1309 entry.3 += row.output;
1310 entry.4 += row.cache_read;
1311 entry.5 = entry.5.max(row.at_ms);
1312 entry.6.insert(row.model.as_str());
1313 }
1314 let mut sessions: Vec<_> = by_session.into_iter().collect();
1315 sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1316 sessions.truncate(limit);
1317
1318 match fmt {
1319 Fmt::Json => {
1320 let mut keyed = serde_json::Map::new();
1321 for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1322 let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1323 body["lastMs"] = serde_json::json!(*last);
1324 body["models"] = serde_json::json!(models.len());
1325 keyed.insert((*id).to_string(), body);
1326 }
1327 serde_json::json!({
1328 "scope": "local",
1329 "source": "opencode",
1330 "sessions": keyed,
1331 })
1332 .to_string()
1333 }
1334 Fmt::Csv => {
1335 let mut out = String::from("session,requests,tokens_in,tokens_out,cache_read,afp\n");
1336 for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1337 out.push_str(&format!(
1338 "{},{},{},{},{},{:.2}\n",
1339 crate::report_render::csv_field(id),
1340 requests,
1341 input,
1342 output,
1343 cache_read,
1344 cost,
1345 ));
1346 }
1347 out
1348 }
1349 Fmt::Table => {
1350 let ink = Ink::new(colour);
1351 let mut out = format!(
1352 "{}session last models AFP req{}\n",
1353 ink.bold, ink.reset
1354 );
1355 for (id, (cost, requests, _, _, _, last, models)) in sessions {
1356 let short = id.rsplit('_').next().unwrap_or(id);
1357 out.push_str(&format!(
1358 "{:<32} {:<11} {:>6} {} {:>6}\n",
1359 short,
1360 ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1361 models.len(),
1362 ink.cyan_on(format!("{:>9}", afp(cost))),
1363 requests,
1364 ));
1365 }
1366 if out.lines().count() == 1 {
1367 out.push_str(&format!(
1368 "{}\n",
1369 ink.dim_on("(no sessions in the window)".into())
1370 ));
1371 }
1372 out
1373 }
1374 }
1375}
1376
1377#[cfg(test)]
1378mod tests {
1379 use super::*;
1380 use crate::arkuse::db::Row;
1381
1382 fn report() -> Report {
1383 let now = 1_800_000_000_000;
1384 let rows = vec![Row {
1385 at_ms: now - 60_000,
1386 session: "ses_a".into(),
1387 provider: "agent-plan".into(),
1388 model: "deepseek-v4.1-flash".into(),
1389 input: 10_000,
1390 output: 2_000,
1391 ..Default::default()
1392 }];
1393 crate::arkuse::window::build(&rows, &ark::catalog(), ark::DEFAULT_PLAN, now, None, None)
1394 }
1395
1396 #[test]
1397 fn text_names_the_model_and_its_afp() {
1398 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1399 assert!(text.contains("deepseek-v4.1-flash"), "{text}");
1400 assert!(text.contains("AFP"), "the plan's own unit: {text}");
1401 assert!(!text.contains('$'), "ARK never prices in dollars: {text}");
1402 }
1403
1404 #[test]
1405 fn the_tier_table_carries_every_personal_plan() {
1406 let text = plans_text(false);
1407 for plan in ark::catalog().plans {
1408 assert!(text.contains(&plan.label), "{} missing: {text}", plan.label);
1409 }
1410 let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
1411 assert_eq!(
1412 json["plans"].as_array().unwrap().len(),
1413 ark::catalog().plans.len()
1414 );
1415 }
1416
1417 #[test]
1418 fn gated_models_are_filtered_by_the_tier() {
1419 let all = models_text("small", false, false);
1420 let gated = models_text("small", true, false);
1421 assert!(all.lines().count() >= gated.lines().count());
1422 assert!(all.contains("kimi-k3"), "{all}");
1423 assert!(!gated.contains("kimi-k3"), "{gated}");
1424 }
1425
1426 #[test]
1427 fn the_machine_formats_are_not_coloured() {
1428 let rows = vec![Row {
1429 at_ms: 1_800_000_000_000,
1430 session: "ses_a".into(),
1431 provider: "agent-plan".into(),
1432 model: "deepseek-v4.1-flash".into(),
1433 input: 1_000,
1434 ..Default::default()
1435 }];
1436 let catalog = ark::catalog();
1437 for fmt in [Fmt::Json, Fmt::Csv] {
1438 let daily = bucket_text(
1439 &rows,
1440 &catalog,
1441 1_800_000_000_000,
1442 86_400_000,
1443 86_400_000,
1444 0,
1445 true,
1446 fmt,
1447 );
1448 assert!(!daily.contains('\u{1b}'), "{fmt:?} leaked colour: {daily}");
1449 }
1450 let report = report();
1451 let json = model_report(&report, None, Fmt::Json);
1452 assert!(!json.contains('\u{1b}'));
1453 assert!(json.contains("deepseek-v4.1-flash"));
1454 let csv = model_report(&report, None, Fmt::Csv);
1455 assert!(csv.starts_with("model,requests,tokens_in,tokens_out,cache_read,afp"));
1456 }
1457}