1use crate::ocuse::window::{ModelUsage, Report, Totals};
9use crate::ocuse::zen;
10use cmduse_core::{compact, duration, money, pct};
11
12pub 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
64fn 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
95pub 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
158pub 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
176pub 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
211pub 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
263pub 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 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
307pub 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
344pub 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}