Skip to main content

cmd_usage/ocuse/
render.rs

1// ocuse's presentation: a one-shot dashboard, a status line, and JSON.
2//
3// Reuses cmduse-core's pure formatters (money/compact/pct/duration) so both
4// tools read the same. Go's allowance is per model — not per account — so the
5// header's window lines sum the caps of the models actually used this month and
6// the table shows each model against its own limit; that distinction is the
7// whole point of the Go plan.
8use crate::ocuse::window::{ModelUsage, Report, Totals};
9use crate::ocuse::zen;
10use cmduse_core::{compact, duration, money, pct};
11
12/// ANSI styles, the same palette cmduse uses; empty strings when colour is off.
13pub struct Ink {
14    pub bold: &'static str,
15    pub dim: &'static str,
16    pub reset: &'static str,
17}
18
19impl Ink {
20    pub fn new(colour: bool) -> Self {
21        if colour {
22            Self {
23                bold: "\x1b[1m",
24                dim: "\x1b[2m",
25                reset: "\x1b[0m",
26            }
27        } else {
28            Self {
29                bold: "",
30                dim: "",
31                reset: "",
32            }
33        }
34    }
35}
36
37fn window_line(
38    label: &str,
39    used: &Totals,
40    cap: Option<f64>,
41    reset_ms: Option<i64>,
42    now_ms: i64,
43) -> String {
44    let cap_text = match cap {
45        Some(cap) if cap > 0.0 => format!(
46            "{} / {} ({})",
47            money(used.cost),
48            money(cap),
49            pct(used.cost, cap)
50        ),
51        _ => money(used.cost),
52    };
53    let reset = reset_ms
54        .map(|at| {
55            format!(
56                " · resets {}",
57                duration(((at - now_ms) / 1000).max(0) as u64)
58            )
59        })
60        .unwrap_or_default();
61    format!("  {label:<8} {cap_text}{reset}\n")
62}
63
64/// Sum of `limit * share` over the models used this month — an aggregate ceiling
65/// across the models in play, since Go grants limits per model.
66fn aggregate_cap(report: &Report, share: f64) -> Option<f64> {
67    let total: f64 = report
68        .models
69        .iter()
70        .filter(|m| m.month.cost > 0.0)
71        .filter_map(|m| m.limit)
72        .map(|limit| limit * share)
73        .sum();
74    (total > 0.0).then_some(total)
75}
76
77fn model_row(model: &ModelUsage) -> String {
78    let limit = model.limit.map(money).unwrap_or_else(|| "—".into());
79    let use_text = model
80        .month_use()
81        .map(|fraction| pct(fraction * 100.0, 100.0))
82        .unwrap_or_else(|| "—".into());
83    format!(
84        "{:<28} {:>7} {:>10} {:>7} {:>8} {:>8} {:>7}\n",
85        model.id,
86        limit,
87        money(model.month.cost),
88        use_text,
89        money(model.five_hour.cost),
90        money(model.weekly.cost),
91        compact(model.month.requests),
92    )
93}
94
95/// `limit` rows to show; `usize::MAX` for all.
96pub fn render_text(report: &Report, db_path: &str, rows: usize, colour: bool) -> String {
97    let ink = Ink::new(colour);
98    let shares = &zen::catalog().go.window_share;
99    let mut out = String::new();
100    out.push_str(&format!(
101        "OpenCode usage — go + zen, from {}\n",
102        shorten(db_path)
103    ));
104    out.push_str(&format!(
105        "period: {} · {} requests · {} · {} tokens\n",
106        report.period_label,
107        compact(report.month.requests),
108        money(report.month.cost),
109        compact(report.month.tokens()),
110    ));
111
112    if report.month.requests > 0 {
113        let five_reset = report.now_ms + crate::ocuse::window::FIVE_HOUR_SECS * 1000;
114        let week_reset = report.now_ms + crate::ocuse::window::WEEKLY_SECS * 1000;
115        out.push_str(&format!(
116            "\n{}GO{} (limits are per model: 5h = 20%, weekly = 50%, monthly = 100%)\n",
117            ink.bold, ink.reset
118        ));
119        out.push_str(&window_line(
120            "5-hour",
121            &report.five_hour,
122            aggregate_cap(report, shares.five_hour),
123            Some(five_reset),
124            report.now_ms,
125        ));
126        out.push_str(&window_line(
127            "weekly",
128            &report.weekly,
129            aggregate_cap(report, shares.weekly),
130            Some(week_reset),
131            report.now_ms,
132        ));
133        out.push_str(&window_line(
134            "monthly",
135            &report.month,
136            aggregate_cap(report, shares.monthly),
137            None,
138            report.now_ms,
139        ));
140    }
141
142    out.push_str(&format!(
143        "\n{}MODEL                            LIMIT      SPENT     USE       5H       WK     REQ{}\n",
144        ink.bold, ink.reset
145    ));
146    for model in report.models.iter().take(rows) {
147        out.push_str(&model_row(model));
148    }
149    if report.models.len() > rows {
150        out.push_str(&format!("… {} more\n", report.models.len() - rows));
151    }
152    out.push_str(
153        "\nZEN is pay-as-you-go: the same rows carry its spend, but the docs give no limit.\n",
154    );
155    out
156}
157
158/// Compact single line for a status bar.
159pub fn status_line(report: &Report) -> String {
160    match report.models.first() {
161        Some(top) => format!(
162            "oc {} month · {} 5h · top {} {}",
163            money(report.month.cost),
164            money(report.five_hour.cost),
165            top.id,
166            money(top.month.cost)
167        ),
168        None => format!(
169            "oc {} month · {} 5h",
170            money(report.month.cost),
171            money(report.five_hour.cost)
172        ),
173    }
174}
175
176/// Machine-readable projection: totals plus the per-model breakdown.
177pub fn render_json(report: &Report) -> String {
178    let totals = |t: &Totals| serde_json::json!({ "cost": t.cost, "requests": t.requests, "tokens": t.tokens() });
179    let model = |m: &ModelUsage| {
180        serde_json::json!({
181            "id": m.id,
182            "provider": m.provider,
183            "limit": m.limit,
184            "month": totals(&m.month),
185            "fiveHour": totals(&m.five_hour),
186            "weekly": totals(&m.weekly),
187        })
188    };
189    serde_json::to_string_pretty(&serde_json::json!({
190        "source": "opencode.db",
191        "generatedAt": report.now_ms,
192        "periodStart": report.since_ms,
193        "periodInferred": report.month_inferred,
194        "totals": {
195            "month": totals(&report.month),
196            "fiveHour": totals(&report.five_hour),
197            "weekly": totals(&report.weekly),
198        },
199        "models": report.models.iter().map(model).collect::<Vec<_>>(),
200    }))
201    .expect("json")
202}
203
204fn shorten(path: &str) -> String {
205    match std::env::var("HOME") {
206        Ok(home) if !home.is_empty() => path.replacen(&home, "~", 1),
207        _ => path.to_string(),
208    }
209}
210
211// ---- catalogues and history tables (shared with the MCP server) -------------
212
213/// The catalogue: what each product costs and (for Go) what it allows.
214pub fn plans_text() -> String {
215    let catalog = crate::ocuse::zen::catalog();
216    let mut out = format!(
217        "OpenCode plans (docs, extracted {})\n\nGO — ${:.2}/month · windows: 5h {:.0}% · weekly {:.0}% · monthly {:.0}%\n",
218        catalog.extracted_at,
219        catalog.go.price_usd,
220        catalog.go.window_share.five_hour * 100.0,
221        catalog.go.window_share.weekly * 100.0,
222        catalog.go.window_share.monthly * 100.0,
223    );
224    out.push_str("MODEL                            LIMIT    IN     OUT    CACHE\n");
225    for model in &catalog.go.models {
226        let rate = model.variants.first();
227        out.push_str(&format!(
228            "{:<28} {:>7} {:>6} {:>6} {:>8}\n",
229            model.id,
230            model
231                .monthly_limit
232                .map(cmduse_core::money)
233                .unwrap_or_else(|| "—".into()),
234            rate.map(|r| format!("${}", r.input)).unwrap_or_default(),
235            rate.map(|r| format!("${}", r.output)).unwrap_or_default(),
236            rate.map(|r| format!("${}", r.cache_read))
237                .unwrap_or_default(),
238        ));
239    }
240    out.push_str(&format!(
241        "\nZEN — pay-as-you-go, {} models priced per 1M tokens\n",
242        catalog.zen.models.len()
243    ));
244    out
245}
246
247pub fn model_text(report: &Report, id: &str) -> String {
248    match report.models.iter().find(|m| m.id == id) {
249        Some(model) => format!(
250            "{} ({})\n  limit     {}\n  month     {} · {} requests\n  5-hour    {}\n  weekly    {}\n",
251            model.id,
252            model.provider,
253            model.limit.map(cmduse_core::money).unwrap_or_else(|| "—".into()),
254            cmduse_core::money(model.month.cost),
255            model.month.requests,
256            cmduse_core::money(model.five_hour.cost),
257            cmduse_core::money(model.weekly.cost),
258        ),
259        None => format!("{id}: no usage in the period\n"),
260    }
261}
262
263/// Spend per bucket (day or hour), oldest first — `cmduse daily`/`hourly`.
264pub fn bucket_text(
265    rows: &[crate::ocuse::db::Row],
266    now_ms: i64,
267    days: i64,
268    size_ms: i64,
269    label: &str,
270    tz_secs: i64,
271) -> String {
272    // Buckets are cut in the display timezone: shift, bucket, then label.
273    let shift = tz_secs * 1000;
274    let mut buckets: std::collections::BTreeMap<i64, (f64, u64, u64)> = Default::default();
275    for row in rows {
276        let bucket = (row.at_ms + shift).div_euclid(size_ms);
277        let entry = buckets.entry(bucket).or_insert((0.0, 0, 0));
278        entry.0 += row.cost_usd;
279        entry.1 += 1;
280        entry.2 += row.input + row.output + row.cache_read;
281    }
282    let start = (now_ms + shift - days * 86_400_000).div_euclid(size_ms);
283    let mut out = format!("{label:<12} spend     requests   tokens\n");
284    for (bucket, (cost, requests, tokens)) in buckets.range(start..) {
285        let stamp = if size_ms >= 86_400_000 {
286            date_of(*bucket)
287        } else {
288            format!(
289                "{} {:02}h",
290                date_of(bucket.div_euclid(24)),
291                bucket.rem_euclid(24)
292            )
293        };
294        out.push_str(&format!(
295            "{stamp:<12} {:>9} {:>12} {:>9}\n",
296            cmduse_core::money(*cost),
297            cmduse_core::compact(*requests),
298            cmduse_core::compact(*tokens),
299        ));
300    }
301    if out.lines().count() == 1 {
302        out.push_str("(no OpenCode usage in the window)\n");
303    }
304    out
305}
306
307/// Spend per session, newest first.
308pub fn session_text(rows: &[crate::ocuse::db::Row], limit: usize) -> String {
309    let mut by_session: std::collections::BTreeMap<
310        &str,
311        (f64, u64, i64, std::collections::BTreeSet<&str>),
312    > = Default::default();
313    for row in rows {
314        let entry =
315            by_session
316                .entry(row.session.as_str())
317                .or_insert((0.0, 0, 0, Default::default()));
318        entry.0 += row.cost_usd;
319        entry.1 += 1;
320        entry.2 = entry.2.max(row.at_ms);
321        entry.3.insert(row.model.as_str());
322    }
323    let mut sessions: Vec<_> = by_session.into_iter().collect();
324    sessions.sort_by_key(|(_, (_, _, last, _))| std::cmp::Reverse(*last));
325    let mut out =
326        String::from("session                          last        models   spend     req\n");
327    for (id, (cost, requests, last, models)) in sessions.into_iter().take(limit) {
328        let short = id.rsplit('_').next().unwrap_or(id);
329        out.push_str(&format!(
330            "{:<32} {:<11} {:>6} {:>9} {:>6}\n",
331            short,
332            date_of(last.div_euclid(86_400_000)),
333            models.len(),
334            cmduse_core::money(cost),
335            requests,
336        ));
337    }
338    if out.lines().count() == 1 {
339        out.push_str("(no sessions in the window)\n");
340    }
341    out
342}
343
344/// `YYYY-MM-DD` for a Unix day number (civil-from-days, same helper family as
345/// the window module's).
346pub fn date_of(day: i64) -> String {
347    let z = day + 719_468;
348    let era = z.div_euclid(146_097);
349    let doe = z.rem_euclid(146_097);
350    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
351    let y = yoe + era * 400;
352    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
353    let mp = (5 * doy + 2) / 153;
354    let d = doy - (153 * mp + 2) / 5 + 1;
355    let m = if mp < 10 { mp + 3 } else { mp - 9 };
356    let year = if m <= 2 { y + 1 } else { y };
357    format!("{year:04}-{m:02}-{d:02}")
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::ocuse::db::Row;
364
365    fn report() -> Report {
366        let now = 1_800_000_000_000;
367        let rows = vec![Row {
368            at_ms: now - 60_000,
369            provider: "opencode-go".into(),
370            model: "glm-5.3-flash".into(),
371            cost_usd: 30.0,
372            ..Default::default()
373        }];
374        crate::ocuse::window::build(&rows, &zen::catalog(), now, None)
375    }
376
377    #[test]
378    fn text_shows_the_model_against_its_limit() {
379        let text = render_text(&report(), "/tmp/opencode.db", 10, false);
380        assert!(text.contains("glm-5.3-flash"), "{text}");
381        assert!(text.contains("$60"), "the model's monthly limit: {text}");
382        assert!(text.contains("50%"), "30 of 60 is half: {text}");
383    }
384
385    #[test]
386    fn json_and_status_line_carry_the_totals() {
387        let json = render_json(&report());
388        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
389        assert_eq!(parsed["totals"]["month"]["cost"], 30.0);
390        assert_eq!(parsed["models"][0]["id"], "glm-5.3-flash");
391        assert!(status_line(&report()).contains("glm-5.3-flash"));
392    }
393}