1use crate::ocuse::db::Row;
9use crate::ocuse::zen::Catalog;
10
11pub const FIVE_HOUR_SECS: i64 = 5 * 3600;
12pub const WEEKLY_SECS: i64 = 7 * 86_400;
13pub const LOOKBACK_SECS: i64 = 120 * 86_400;
15
16#[derive(Debug, Clone, Default)]
17pub struct Totals {
18 pub cost: f64,
19 pub requests: u64,
20 pub input: u64,
21 pub output: u64,
22 pub cache_read: u64,
23}
24
25impl Totals {
26 pub fn add(&mut self, row: &Row, cost: f64) {
31 self.cost += cost;
32 self.requests += 1;
33 self.input += row.input;
34 self.output += row.output + row.reasoning;
35 self.cache_read += row.cache_read;
36 }
37
38 pub fn tokens(&self) -> u64 {
39 self.input + self.output + self.cache_read
40 }
41}
42
43#[derive(Debug, Clone)]
44pub struct ModelUsage {
45 pub id: String,
46 pub provider: String,
47 pub limit: Option<f64>,
48 pub month: Totals,
49 pub five_hour: Totals,
50 pub weekly: Totals,
51}
52
53impl ModelUsage {
54 pub fn month_use(&self) -> Option<f64> {
56 self.limit.filter(|l| *l > 0.0).map(|l| self.month.cost / l)
57 }
58}
59
60pub fn over_window(model: &ModelUsage) -> Option<&'static str> {
66 let limit = model.limit.filter(|l| *l > 0.0)?;
67 let shares = &crate::ocuse::zen::catalog().go.window_share;
68 if model.five_hour.cost > limit * shares.five_hour {
69 Some("5-hour")
70 } else if model.weekly.cost > limit * shares.weekly {
71 Some("weekly")
72 } else if model.month.cost > limit * shares.monthly {
73 Some("monthly")
74 } else {
75 None
76 }
77}
78
79#[derive(Debug, Clone)]
80pub struct Report {
81 pub period_label: String,
83 pub now_ms: i64,
84 pub since_ms: i64,
85 pub period_end_ms: Option<i64>,
89 pub month_inferred: bool,
90 pub account: Option<crate::ocuse::api::Usage>,
96 pub account_error: Option<String>,
102 pub clients: Vec<String>,
106 pub five_hour: Totals,
107 pub weekly: Totals,
108 pub month: Totals,
109 pub models: Vec<ModelUsage>,
111}
112
113fn day_of_month(epoch_secs: i64) -> u32 {
116 civil(epoch_secs).2 as u32
117}
118
119pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
120 let y = if m <= 2 { y - 1 } else { y };
121 let era = y.div_euclid(400);
122 let yoe = y - era * 400;
123 let mp = if m > 2 { m - 3 } else { m + 9 };
124 let doy = (153 * mp + 2) / 5 + d - 1;
125 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
126 era * 146_097 + doe - 719_468
127}
128
129fn civil(epoch_secs: i64) -> (i64, i64, i64) {
130 let days = epoch_secs.div_euclid(86_400);
131 let z = days + 719_468;
132 let era = z.div_euclid(146_097);
133 let doe = z.rem_euclid(146_097);
134 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
135 let y = yoe + era * 400;
136 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
137 let mp = (5 * doy + 2) / 153;
138 let d = doy - (153 * mp + 2) / 5 + 1;
139 let m = if mp < 10 { mp + 3 } else { mp - 9 };
140 (if m <= 2 { y + 1 } else { y }, m, d)
141}
142
143pub fn now_ms() -> i64 {
145 std::time::SystemTime::now()
146 .duration_since(std::time::UNIX_EPOCH)
147 .map(|d| d.as_millis() as i64)
148 .unwrap_or(0)
149}
150
151pub fn calendar_month_start(now_ms: i64) -> i64 {
154 let (year, month, _) = civil(now_ms / 1000);
155 days_from_civil(year, month, 1) * 86_400_000
156}
157
158pub fn next_month(ms: i64) -> i64 {
163 let (year, month, day) = civil(ms / 1000);
164 let (ny, nm) = if month == 12 {
165 (year + 1, 1)
166 } else {
167 (year, month + 1)
168 };
169 days_from_civil(ny, nm, day.min(28)) * 86_400_000
170}
171
172pub fn prev_month(ms: i64) -> i64 {
177 let (year, month, day) = civil(ms / 1000);
178 let (py, pm) = if month == 1 {
179 (year - 1, 12)
180 } else {
181 (year, month - 1)
182 };
183 days_from_civil(py, pm, day.min(28)) * 86_400_000 + ms.rem_euclid(86_400_000)
184}
185
186pub fn month_start(rows: &[Row], now_ms: i64) -> (i64, bool) {
189 let now_secs = now_ms / 1000;
190 let (year, month, _day) = civil(now_secs);
193 let earliest_go = rows
194 .iter()
195 .filter(|r| r.provider == "opencode-go")
196 .map(|r| r.at_ms)
197 .min();
198 let anniversary = earliest_go
199 .or_else(|| rows.iter().map(|r| r.at_ms).min())
200 .map(|ms| day_of_month(ms / 1000));
201 if let Some(day_wanted) = anniversary {
202 let candidate = days_from_civil(year, month, day_wanted.min(28) as i64) * 86_400;
203 if candidate <= now_secs {
204 return (candidate * 1000, true);
205 }
206 let (py, pm) = if month == 1 {
207 (year - 1, 12)
208 } else {
209 (year, month - 1)
210 };
211 return (
212 days_from_civil(py, pm, day_wanted.min(28) as i64) * 86_400 * 1000,
213 true,
214 );
215 }
216 let current = civil(now_secs);
218 let days = days_from_civil(current.0, current.1, 1);
219 (days * 86_400 * 1000, false)
220}
221
222pub fn build(
230 rows: &[Row],
231 catalog: &Catalog,
232 plan_id: &str,
233 now_ms: i64,
234 since_override: Option<i64>,
235 account: Option<&crate::ocuse::api::Usage>,
236) -> Report {
237 let (inferred_start, month_inferred) = month_start(rows, now_ms);
238 let since_ms = since_override.unwrap_or(inferred_start);
239 let (five_start, five_end) = match account.and_then(|a| a.rolling.reset_ms()) {
248 Some(reset) => (reset - FIVE_HOUR_SECS * 1000, reset),
249 None => (now_ms - FIVE_HOUR_SECS * 1000, i64::MAX),
250 };
251 let (week_start, week_end) = match account.and_then(|a| a.weekly.reset_ms()) {
252 Some(reset) => (reset - WEEKLY_SECS * 1000, reset),
253 None => (now_ms - WEEKLY_SECS * 1000, i64::MAX),
254 };
255
256 let mut report = Report {
257 period_label: "calendar month".into(),
258 now_ms,
259 since_ms,
260 period_end_ms: None,
261 month_inferred: since_override.is_none() && month_inferred,
262 account: None,
263 account_error: None,
264 clients: Vec::new(),
265 five_hour: Totals::default(),
266 weekly: Totals::default(),
267 month: Totals::default(),
268 models: Vec::new(),
269 };
270 let mut by_model: std::collections::BTreeMap<String, ModelUsage> = Default::default();
271
272 for row in rows {
276 if row.client.is_empty() {
277 continue; }
279 if !report.clients.iter().any(|client| client == row.client) {
280 report.clients.push(row.client.to_string());
281 }
282 }
283
284 for row in rows {
285 let in_month = row.at_ms >= since_ms;
290 let in_week = row.at_ms >= week_start && row.at_ms < week_end;
291 let in_five = row.at_ms >= five_start && row.at_ms < five_end;
292 if !in_month && !in_week && !in_five {
293 continue;
294 }
295 let cost = catalog
300 .price(
301 &row.model,
302 row.at_ms,
303 row.input,
304 row.output,
305 row.reasoning,
306 row.cache_read,
307 row.cache_write,
308 )
309 .unwrap_or(row.cost_usd);
310 if in_month {
311 report.month.add(row, cost);
312 }
313 if in_week {
314 report.weekly.add(row, cost);
315 }
316 if in_five {
317 report.five_hour.add(row, cost);
318 }
319 let entry = by_model
320 .entry(row.model.clone())
321 .or_insert_with(|| ModelUsage {
322 id: row.model.clone(),
323 provider: row.provider.clone(),
324 limit: catalog.go_limit(plan_id, &row.model),
325 month: Totals::default(),
326 five_hour: Totals::default(),
327 weekly: Totals::default(),
328 });
329 if in_month {
330 entry.month.add(row, cost);
331 }
332 if in_week {
333 entry.weekly.add(row, cost);
334 }
335 if in_five {
336 entry.five_hour.add(row, cost);
337 }
338 }
339
340 report.models = by_model.into_values().collect();
341 report.models.sort_by(|a, b| {
342 b.month
343 .cost
344 .partial_cmp(&a.month.cost)
345 .unwrap_or(std::cmp::Ordering::Equal)
346 .then_with(|| a.id.cmp(&b.id))
347 });
348 report
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use crate::ocuse::db::Row;
355
356 fn row(at_s: i64, model: &str, cost: f64) -> Row {
357 Row {
358 at_ms: at_s * 1000,
359 provider: "opencode-go".into(),
360 model: model.into(),
361 cost_usd: cost,
362 ..Default::default()
363 }
364 }
365
366 fn meter(status: &str, percent: f64, resets_at: &str) -> crate::ocuse::api::Meter {
368 crate::ocuse::api::Meter {
369 status: status.into(),
370 percent,
371 resets_at: Some(resets_at.into()),
372 }
373 }
374
375 fn iso(secs: i64) -> String {
378 let rem = secs.rem_euclid(86_400);
379 let (y, m, d) = civil(secs - rem);
380 format!(
381 "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.000Z",
382 rem / 3600,
383 (rem % 3600) / 60,
384 rem % 60
385 )
386 }
387
388 #[test]
389 fn civil_helpers_round_trip() {
390 let secs = days_from_civil(2026, 9, 27) * 86_400;
392 assert_eq!(civil(secs), (2026, 9, 27));
393 assert_eq!(day_of_month(secs), 27);
394 }
395
396 #[test]
397 fn next_month_keeps_the_day_and_wraps_the_year() {
398 let dec15 = days_from_civil(2026, 12, 15) * 86_400_000;
400 assert_eq!(civil(next_month(dec15) / 1000), (2027, 1, 15));
401 let jan1 = days_from_civil(2026, 1, 1) * 86_400_000;
403 assert_eq!(civil(next_month(jan1) / 1000), (2026, 2, 1));
404 let jan31 = days_from_civil(2026, 1, 31) * 86_400_000;
406 assert_eq!(civil(next_month(jan31) / 1000), (2026, 2, 28));
407 }
408
409 #[test]
410 fn prev_month_inverts_next_month_and_keeps_the_clock() {
411 let reset = 1_794_604_475_000; let start = prev_month(reset);
414 assert_eq!(civil(start / 1000), (2026, 10, 13));
415 assert_eq!(start % 86_400_000, reset % 86_400_000, "time of day kept");
416 assert_eq!(civil(next_month(start) / 1000), (2026, 11, 13));
418 let dec13 = days_from_civil(2026, 12, 13) * 86_400_000;
420 assert_eq!(civil(prev_month(dec13) / 1000), (2026, 11, 13));
421 let mar31 = days_from_civil(2026, 3, 31) * 86_400_000;
422 assert_eq!(civil(prev_month(mar31) / 1000), (2026, 2, 28));
423 }
424
425 #[test]
426 fn windows_are_rolling_and_the_month_is_inferred() {
427 let now = days_from_civil(2026, 9, 27) * 86_400 + 12 * 3600;
428 let rows = vec![
429 row(now - 60, "glm-5.3-flash", 1.0), row(now - 2 * 3600, "glm-5.3-flash", 2.0), row(now - 3 * 86_400, "kimi-k3", 4.0), row(now - 40 * 86_400, "glm-5.3-flash", 8.0), ];
434 let catalog = crate::ocuse::zen::catalog();
435 let report = build(
436 &rows,
437 &catalog,
438 crate::ocuse::zen::DEFAULT_PLAN,
439 now * 1000,
440 None,
441 None,
442 );
443 assert!(
444 report.month_inferred,
445 "anniversary inferred from the earliest row"
446 );
447 assert!((report.five_hour.cost - 3.0).abs() < 1e-9);
448 assert!((report.weekly.cost - 7.0).abs() < 1e-9);
449 assert!(
451 (report.month.cost - 7.0).abs() < 1e-9,
452 "month cost {}",
453 report.month.cost
454 );
455 let flash = report
456 .models
457 .iter()
458 .find(|m| m.id == "glm-5.3-flash")
459 .unwrap();
460 assert_eq!(flash.limit, Some(60.0));
461 assert!((flash.five_hour.cost - 3.0).abs() < 1e-9);
462 }
463
464 #[test]
470 fn the_accounts_bounds_cut_the_windows_when_it_answered() {
471 let now = days_from_civil(2026, 10, 19) * 86_400 + 12 * 3600; let week_reset = now + 6 * 86_400 + 12 * 3600; let five_reset = now + 3 * 3600;
474 let five_start = five_reset - FIVE_HOUR_SECS;
475 let rows = vec![
476 row(now - 6 * 86_400, "glm-5.3-flash", 9.0),
478 row(now - 3 * 3600, "glm-5.3-flash", 2.0),
481 row(five_start + 60, "glm-5.3-flash", 1.0),
483 ];
484 let account = crate::ocuse::api::Usage {
485 rolling: meter("ok", 6.0, &iso(five_reset)),
486 weekly: meter("ok", 3.0, &iso(week_reset)),
487 monthly: meter("ok", 51.0, &iso(now + 20 * 86_400)),
488 };
489 let report = build(
490 &rows,
491 &crate::ocuse::zen::catalog(),
492 crate::ocuse::zen::DEFAULT_PLAN,
493 now * 1000,
494 None,
495 Some(&account),
496 );
497 assert!(
500 (report.weekly.cost - 3.0).abs() < 1e-9,
501 "weekly {}",
502 report.weekly.cost
503 );
504 assert!(
505 (report.five_hour.cost - 1.0).abs() < 1e-9,
506 "5h {}",
507 report.five_hour.cost
508 );
509 let flash = report
511 .models
512 .iter()
513 .find(|m| m.id == "glm-5.3-flash")
514 .unwrap();
515 assert!((flash.weekly.cost - 3.0).abs() < 1e-9);
516 assert!((flash.five_hour.cost - 1.0).abs() < 1e-9);
517 assert!((report.month.cost - 12.0).abs() < 1e-9);
520 }
521
522 #[test]
523 fn models_sort_by_month_spend() {
524 let now = days_from_civil(2026, 9, 27) * 86_400;
525 let rows = vec![row(now - 60, "cheap", 0.1), row(now - 60, "pricey", 5.0)];
526 let report = build(
527 &rows,
528 &crate::ocuse::zen::catalog(),
529 crate::ocuse::zen::DEFAULT_PLAN,
530 now * 1000,
531 None,
532 None,
533 );
534 assert_eq!(report.models[0].id, "pricey");
535 }
536
537 #[test]
540 fn the_report_names_every_client_it_read() {
541 let now = days_from_civil(2026, 9, 27) * 86_400;
542 let mut rows = vec![row(now - 60, "cheap", 0.1)];
543 rows[0].client = crate::stores::OPENCODE;
544 let mut dsh = row(now - 60, "cheap", 0.2);
545 dsh.client = crate::stores::DSH;
546 let mut again = row(now - 60, "cheap", 0.3);
547 again.client = crate::stores::DSH;
548 rows.extend([dsh, again]);
549 let report = build(
550 &rows,
551 &crate::ocuse::zen::catalog(),
552 crate::ocuse::zen::DEFAULT_PLAN,
553 now * 1000,
554 None,
555 None,
556 );
557 assert_eq!(report.clients, ["opencode", "dsh"]);
558 }
559}