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) {
27 self.cost += row.cost_usd;
28 self.requests += 1;
29 self.input += row.input;
30 self.output += row.output;
31 self.cache_read += row.cache_read;
32 }
33
34 pub fn tokens(&self) -> u64 {
35 self.input + self.output + self.cache_read
36 }
37}
38
39#[derive(Debug, Clone)]
40pub struct ModelUsage {
41 pub id: String,
42 pub provider: String,
43 pub limit: Option<f64>,
44 pub month: Totals,
45 pub five_hour: Totals,
46 pub weekly: Totals,
47}
48
49impl ModelUsage {
50 pub fn month_use(&self) -> Option<f64> {
52 self.limit.filter(|l| *l > 0.0).map(|l| self.month.cost / l)
53 }
54}
55
56pub fn over_window(model: &ModelUsage) -> Option<&'static str> {
62 let limit = model.limit.filter(|l| *l > 0.0)?;
63 let shares = &crate::ocuse::zen::catalog().go.window_share;
64 if model.five_hour.cost > limit * shares.five_hour {
65 Some("5-hour")
66 } else if model.weekly.cost > limit * shares.weekly {
67 Some("weekly")
68 } else if model.month.cost > limit * shares.monthly {
69 Some("monthly")
70 } else {
71 None
72 }
73}
74
75#[derive(Debug, Clone)]
76pub struct Report {
77 pub period_label: String,
79 pub now_ms: i64,
80 pub since_ms: i64,
81 pub period_end_ms: Option<i64>,
85 pub month_inferred: bool,
86 pub account: Option<crate::ocuse::api::Usage>,
92 pub account_error: Option<String>,
98 pub five_hour: Totals,
99 pub weekly: Totals,
100 pub month: Totals,
101 pub models: Vec<ModelUsage>,
103}
104
105fn day_of_month(epoch_secs: i64) -> u32 {
108 civil(epoch_secs).2 as u32
109}
110
111pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
112 let y = if m <= 2 { y - 1 } else { y };
113 let era = y.div_euclid(400);
114 let yoe = y - era * 400;
115 let mp = if m > 2 { m - 3 } else { m + 9 };
116 let doy = (153 * mp + 2) / 5 + d - 1;
117 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
118 era * 146_097 + doe - 719_468
119}
120
121fn civil(epoch_secs: i64) -> (i64, i64, i64) {
122 let days = epoch_secs.div_euclid(86_400);
123 let z = days + 719_468;
124 let era = z.div_euclid(146_097);
125 let doe = z.rem_euclid(146_097);
126 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
127 let y = yoe + era * 400;
128 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
129 let mp = (5 * doy + 2) / 153;
130 let d = doy - (153 * mp + 2) / 5 + 1;
131 let m = if mp < 10 { mp + 3 } else { mp - 9 };
132 (if m <= 2 { y + 1 } else { y }, m, d)
133}
134
135pub fn now_ms() -> i64 {
137 std::time::SystemTime::now()
138 .duration_since(std::time::UNIX_EPOCH)
139 .map(|d| d.as_millis() as i64)
140 .unwrap_or(0)
141}
142
143pub fn calendar_month_start(now_ms: i64) -> i64 {
146 let (year, month, _) = civil(now_ms / 1000);
147 days_from_civil(year, month, 1) * 86_400_000
148}
149
150pub fn next_month(ms: i64) -> i64 {
155 let (year, month, day) = civil(ms / 1000);
156 let (ny, nm) = if month == 12 {
157 (year + 1, 1)
158 } else {
159 (year, month + 1)
160 };
161 days_from_civil(ny, nm, day.min(28)) * 86_400_000
162}
163
164pub fn prev_month(ms: i64) -> i64 {
169 let (year, month, day) = civil(ms / 1000);
170 let (py, pm) = if month == 1 {
171 (year - 1, 12)
172 } else {
173 (year, month - 1)
174 };
175 days_from_civil(py, pm, day.min(28)) * 86_400_000 + ms.rem_euclid(86_400_000)
176}
177
178pub fn month_start(rows: &[Row], now_ms: i64) -> (i64, bool) {
181 let now_secs = now_ms / 1000;
182 let (year, month, _day) = civil(now_secs);
185 let earliest_go = rows
186 .iter()
187 .filter(|r| r.provider == "opencode-go")
188 .map(|r| r.at_ms)
189 .min();
190 let anniversary = earliest_go
191 .or_else(|| rows.iter().map(|r| r.at_ms).min())
192 .map(|ms| day_of_month(ms / 1000));
193 if let Some(day_wanted) = anniversary {
194 let candidate = days_from_civil(year, month, day_wanted.min(28) as i64) * 86_400;
195 if candidate <= now_secs {
196 return (candidate * 1000, true);
197 }
198 let (py, pm) = if month == 1 {
199 (year - 1, 12)
200 } else {
201 (year, month - 1)
202 };
203 return (
204 days_from_civil(py, pm, day_wanted.min(28) as i64) * 86_400 * 1000,
205 true,
206 );
207 }
208 let current = civil(now_secs);
210 let days = days_from_civil(current.0, current.1, 1);
211 (days * 86_400 * 1000, false)
212}
213
214pub fn build(
222 rows: &[Row],
223 catalog: &Catalog,
224 plan_id: &str,
225 now_ms: i64,
226 since_override: Option<i64>,
227 account: Option<&crate::ocuse::api::Usage>,
228) -> Report {
229 let (inferred_start, month_inferred) = month_start(rows, now_ms);
230 let since_ms = since_override.unwrap_or(inferred_start);
231 let (five_start, five_end) = match account.and_then(|a| a.rolling.reset_ms()) {
240 Some(reset) => (reset - FIVE_HOUR_SECS * 1000, reset),
241 None => (now_ms - FIVE_HOUR_SECS * 1000, i64::MAX),
242 };
243 let (week_start, week_end) = match account.and_then(|a| a.weekly.reset_ms()) {
244 Some(reset) => (reset - WEEKLY_SECS * 1000, reset),
245 None => (now_ms - WEEKLY_SECS * 1000, i64::MAX),
246 };
247
248 let mut report = Report {
249 period_label: "calendar month".into(),
250 now_ms,
251 since_ms,
252 period_end_ms: None,
253 month_inferred: since_override.is_none() && month_inferred,
254 account: None,
255 account_error: None,
256 five_hour: Totals::default(),
257 weekly: Totals::default(),
258 month: Totals::default(),
259 models: Vec::new(),
260 };
261 let mut by_model: std::collections::BTreeMap<String, ModelUsage> = Default::default();
262
263 for row in rows {
264 let in_month = row.at_ms >= since_ms;
269 let in_week = row.at_ms >= week_start && row.at_ms < week_end;
270 let in_five = row.at_ms >= five_start && row.at_ms < five_end;
271 if !in_month && !in_week && !in_five {
272 continue;
273 }
274 if in_month {
275 report.month.add(row);
276 }
277 if in_week {
278 report.weekly.add(row);
279 }
280 if in_five {
281 report.five_hour.add(row);
282 }
283 let entry = by_model
284 .entry(row.model.clone())
285 .or_insert_with(|| ModelUsage {
286 id: row.model.clone(),
287 provider: row.provider.clone(),
288 limit: catalog.go_limit(plan_id, &row.model),
289 month: Totals::default(),
290 five_hour: Totals::default(),
291 weekly: Totals::default(),
292 });
293 if in_month {
294 entry.month.add(row);
295 }
296 if in_week {
297 entry.weekly.add(row);
298 }
299 if in_five {
300 entry.five_hour.add(row);
301 }
302 }
303
304 report.models = by_model.into_values().collect();
305 report.models.sort_by(|a, b| {
306 b.month
307 .cost
308 .partial_cmp(&a.month.cost)
309 .unwrap_or(std::cmp::Ordering::Equal)
310 .then_with(|| a.id.cmp(&b.id))
311 });
312 report
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::ocuse::db::Row;
319
320 fn row(at_s: i64, model: &str, cost: f64) -> Row {
321 Row {
322 at_ms: at_s * 1000,
323 provider: "opencode-go".into(),
324 model: model.into(),
325 cost_usd: cost,
326 ..Default::default()
327 }
328 }
329
330 fn meter(status: &str, percent: f64, resets_at: &str) -> crate::ocuse::api::Meter {
332 crate::ocuse::api::Meter {
333 status: status.into(),
334 percent,
335 resets_at: Some(resets_at.into()),
336 }
337 }
338
339 fn iso(secs: i64) -> String {
342 let rem = secs.rem_euclid(86_400);
343 let (y, m, d) = civil(secs - rem);
344 format!(
345 "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.000Z",
346 rem / 3600,
347 (rem % 3600) / 60,
348 rem % 60
349 )
350 }
351
352 #[test]
353 fn civil_helpers_round_trip() {
354 let secs = days_from_civil(2026, 9, 27) * 86_400;
356 assert_eq!(civil(secs), (2026, 9, 27));
357 assert_eq!(day_of_month(secs), 27);
358 }
359
360 #[test]
361 fn next_month_keeps_the_day_and_wraps_the_year() {
362 let dec15 = days_from_civil(2026, 12, 15) * 86_400_000;
364 assert_eq!(civil(next_month(dec15) / 1000), (2027, 1, 15));
365 let jan1 = days_from_civil(2026, 1, 1) * 86_400_000;
367 assert_eq!(civil(next_month(jan1) / 1000), (2026, 2, 1));
368 let jan31 = days_from_civil(2026, 1, 31) * 86_400_000;
370 assert_eq!(civil(next_month(jan31) / 1000), (2026, 2, 28));
371 }
372
373 #[test]
374 fn prev_month_inverts_next_month_and_keeps_the_clock() {
375 let reset = 1_794_604_475_000; let start = prev_month(reset);
378 assert_eq!(civil(start / 1000), (2026, 10, 13));
379 assert_eq!(start % 86_400_000, reset % 86_400_000, "time of day kept");
380 assert_eq!(civil(next_month(start) / 1000), (2026, 11, 13));
382 let dec13 = days_from_civil(2026, 12, 13) * 86_400_000;
384 assert_eq!(civil(prev_month(dec13) / 1000), (2026, 11, 13));
385 let mar31 = days_from_civil(2026, 3, 31) * 86_400_000;
386 assert_eq!(civil(prev_month(mar31) / 1000), (2026, 2, 28));
387 }
388
389 #[test]
390 fn windows_are_rolling_and_the_month_is_inferred() {
391 let now = days_from_civil(2026, 9, 27) * 86_400 + 12 * 3600;
392 let rows = vec![
393 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), ];
398 let catalog = crate::ocuse::zen::catalog();
399 let report = build(
400 &rows,
401 &catalog,
402 crate::ocuse::zen::DEFAULT_PLAN,
403 now * 1000,
404 None,
405 None,
406 );
407 assert!(
408 report.month_inferred,
409 "anniversary inferred from the earliest row"
410 );
411 assert!((report.five_hour.cost - 3.0).abs() < 1e-9);
412 assert!((report.weekly.cost - 7.0).abs() < 1e-9);
413 assert!(
415 (report.month.cost - 7.0).abs() < 1e-9,
416 "month cost {}",
417 report.month.cost
418 );
419 let flash = report
420 .models
421 .iter()
422 .find(|m| m.id == "glm-5.3-flash")
423 .unwrap();
424 assert_eq!(flash.limit, Some(60.0));
425 assert!((flash.five_hour.cost - 3.0).abs() < 1e-9);
426 }
427
428 #[test]
434 fn the_accounts_bounds_cut_the_windows_when_it_answered() {
435 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;
438 let five_start = five_reset - FIVE_HOUR_SECS;
439 let rows = vec![
440 row(now - 6 * 86_400, "glm-5.3-flash", 9.0),
442 row(now - 3 * 3600, "glm-5.3-flash", 2.0),
445 row(five_start + 60, "glm-5.3-flash", 1.0),
447 ];
448 let account = crate::ocuse::api::Usage {
449 rolling: meter("ok", 6.0, &iso(five_reset)),
450 weekly: meter("ok", 3.0, &iso(week_reset)),
451 monthly: meter("ok", 51.0, &iso(now + 20 * 86_400)),
452 };
453 let report = build(
454 &rows,
455 &crate::ocuse::zen::catalog(),
456 crate::ocuse::zen::DEFAULT_PLAN,
457 now * 1000,
458 None,
459 Some(&account),
460 );
461 assert!(
464 (report.weekly.cost - 3.0).abs() < 1e-9,
465 "weekly {}",
466 report.weekly.cost
467 );
468 assert!(
469 (report.five_hour.cost - 1.0).abs() < 1e-9,
470 "5h {}",
471 report.five_hour.cost
472 );
473 let flash = report
475 .models
476 .iter()
477 .find(|m| m.id == "glm-5.3-flash")
478 .unwrap();
479 assert!((flash.weekly.cost - 3.0).abs() < 1e-9);
480 assert!((flash.five_hour.cost - 1.0).abs() < 1e-9);
481 assert!((report.month.cost - 12.0).abs() < 1e-9);
484 }
485
486 #[test]
487 fn models_sort_by_month_spend() {
488 let now = days_from_civil(2026, 9, 27) * 86_400;
489 let rows = vec![row(now - 60, "cheap", 0.1), row(now - 60, "pricey", 5.0)];
490 let report = build(
491 &rows,
492 &crate::ocuse::zen::catalog(),
493 crate::ocuse::zen::DEFAULT_PLAN,
494 now * 1000,
495 None,
496 None,
497 );
498 assert_eq!(report.models[0].id, "pricey");
499 }
500}