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 five_hour: Totals,
93 pub weekly: Totals,
94 pub month: Totals,
95 pub models: Vec<ModelUsage>,
97}
98
99fn day_of_month(epoch_secs: i64) -> u32 {
102 civil(epoch_secs).2 as u32
103}
104
105pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
106 let y = if m <= 2 { y - 1 } else { y };
107 let era = y.div_euclid(400);
108 let yoe = y - era * 400;
109 let mp = if m > 2 { m - 3 } else { m + 9 };
110 let doy = (153 * mp + 2) / 5 + d - 1;
111 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
112 era * 146_097 + doe - 719_468
113}
114
115fn civil(epoch_secs: i64) -> (i64, i64, i64) {
116 let days = epoch_secs.div_euclid(86_400);
117 let z = days + 719_468;
118 let era = z.div_euclid(146_097);
119 let doe = z.rem_euclid(146_097);
120 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
121 let y = yoe + era * 400;
122 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
123 let mp = (5 * doy + 2) / 153;
124 let d = doy - (153 * mp + 2) / 5 + 1;
125 let m = if mp < 10 { mp + 3 } else { mp - 9 };
126 (if m <= 2 { y + 1 } else { y }, m, d)
127}
128
129pub fn now_ms() -> i64 {
131 std::time::SystemTime::now()
132 .duration_since(std::time::UNIX_EPOCH)
133 .map(|d| d.as_millis() as i64)
134 .unwrap_or(0)
135}
136
137pub fn calendar_month_start(now_ms: i64) -> i64 {
140 let (year, month, _) = civil(now_ms / 1000);
141 days_from_civil(year, month, 1) * 86_400_000
142}
143
144pub fn next_month(ms: i64) -> i64 {
149 let (year, month, day) = civil(ms / 1000);
150 let (ny, nm) = if month == 12 {
151 (year + 1, 1)
152 } else {
153 (year, month + 1)
154 };
155 days_from_civil(ny, nm, day.min(28)) * 86_400_000
156}
157
158pub fn prev_month(ms: i64) -> i64 {
163 let (year, month, day) = civil(ms / 1000);
164 let (py, pm) = if month == 1 {
165 (year - 1, 12)
166 } else {
167 (year, month - 1)
168 };
169 days_from_civil(py, pm, day.min(28)) * 86_400_000 + ms.rem_euclid(86_400_000)
170}
171
172pub fn month_start(rows: &[Row], now_ms: i64) -> (i64, bool) {
175 let now_secs = now_ms / 1000;
176 let (year, month, _day) = civil(now_secs);
179 let earliest_go = rows
180 .iter()
181 .filter(|r| r.provider == "opencode-go")
182 .map(|r| r.at_ms)
183 .min();
184 let anniversary = earliest_go
185 .or_else(|| rows.iter().map(|r| r.at_ms).min())
186 .map(|ms| day_of_month(ms / 1000));
187 if let Some(day_wanted) = anniversary {
188 let candidate = days_from_civil(year, month, day_wanted.min(28) as i64) * 86_400;
189 if candidate <= now_secs {
190 return (candidate * 1000, true);
191 }
192 let (py, pm) = if month == 1 {
193 (year - 1, 12)
194 } else {
195 (year, month - 1)
196 };
197 return (
198 days_from_civil(py, pm, day_wanted.min(28) as i64) * 86_400 * 1000,
199 true,
200 );
201 }
202 let current = civil(now_secs);
204 let days = days_from_civil(current.0, current.1, 1);
205 (days * 86_400 * 1000, false)
206}
207
208pub fn build(
216 rows: &[Row],
217 catalog: &Catalog,
218 plan_id: &str,
219 now_ms: i64,
220 since_override: Option<i64>,
221) -> Report {
222 let (inferred_start, month_inferred) = month_start(rows, now_ms);
223 let since_ms = since_override.unwrap_or(inferred_start);
224 let five_cut = now_ms - FIVE_HOUR_SECS * 1000;
225 let week_cut = now_ms - WEEKLY_SECS * 1000;
226
227 let mut report = Report {
228 period_label: "calendar month".into(),
229 now_ms,
230 since_ms,
231 period_end_ms: None,
232 month_inferred: since_override.is_none() && month_inferred,
233 account: None,
234 five_hour: Totals::default(),
235 weekly: Totals::default(),
236 month: Totals::default(),
237 models: Vec::new(),
238 };
239 let mut by_model: std::collections::BTreeMap<String, ModelUsage> = Default::default();
240
241 for row in rows {
242 if row.at_ms < since_ms {
243 continue; }
245 report.month.add(row);
246 if row.at_ms >= week_cut {
247 report.weekly.add(row);
248 }
249 if row.at_ms >= five_cut {
250 report.five_hour.add(row);
251 }
252 let entry = by_model
253 .entry(row.model.clone())
254 .or_insert_with(|| ModelUsage {
255 id: row.model.clone(),
256 provider: row.provider.clone(),
257 limit: catalog.go_limit(plan_id, &row.model),
258 month: Totals::default(),
259 five_hour: Totals::default(),
260 weekly: Totals::default(),
261 });
262 entry.month.add(row);
263 if row.at_ms >= week_cut {
264 entry.weekly.add(row);
265 }
266 if row.at_ms >= five_cut {
267 entry.five_hour.add(row);
268 }
269 }
270
271 report.models = by_model.into_values().collect();
272 report.models.sort_by(|a, b| {
273 b.month
274 .cost
275 .partial_cmp(&a.month.cost)
276 .unwrap_or(std::cmp::Ordering::Equal)
277 .then_with(|| a.id.cmp(&b.id))
278 });
279 report
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::ocuse::db::Row;
286
287 fn row(at_s: i64, model: &str, cost: f64) -> Row {
288 Row {
289 at_ms: at_s * 1000,
290 provider: "opencode-go".into(),
291 model: model.into(),
292 cost_usd: cost,
293 ..Default::default()
294 }
295 }
296
297 #[test]
298 fn civil_helpers_round_trip() {
299 let secs = days_from_civil(2026, 9, 27) * 86_400;
301 assert_eq!(civil(secs), (2026, 9, 27));
302 assert_eq!(day_of_month(secs), 27);
303 }
304
305 #[test]
306 fn next_month_keeps_the_day_and_wraps_the_year() {
307 let dec15 = days_from_civil(2026, 12, 15) * 86_400_000;
309 assert_eq!(civil(next_month(dec15) / 1000), (2027, 1, 15));
310 let jan1 = days_from_civil(2026, 1, 1) * 86_400_000;
312 assert_eq!(civil(next_month(jan1) / 1000), (2026, 2, 1));
313 let jan31 = days_from_civil(2026, 1, 31) * 86_400_000;
315 assert_eq!(civil(next_month(jan31) / 1000), (2026, 2, 28));
316 }
317
318 #[test]
319 fn prev_month_inverts_next_month_and_keeps_the_clock() {
320 let reset = 1_794_604_475_000; let start = prev_month(reset);
323 assert_eq!(civil(start / 1000), (2026, 10, 13));
324 assert_eq!(start % 86_400_000, reset % 86_400_000, "time of day kept");
325 assert_eq!(civil(next_month(start) / 1000), (2026, 11, 13));
327 let dec13 = days_from_civil(2026, 12, 13) * 86_400_000;
329 assert_eq!(civil(prev_month(dec13) / 1000), (2026, 11, 13));
330 let mar31 = days_from_civil(2026, 3, 31) * 86_400_000;
331 assert_eq!(civil(prev_month(mar31) / 1000), (2026, 2, 28));
332 }
333
334 #[test]
335 fn windows_are_rolling_and_the_month_is_inferred() {
336 let now = days_from_civil(2026, 9, 27) * 86_400 + 12 * 3600;
337 let rows = vec![
338 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), ];
343 let catalog = crate::ocuse::zen::catalog();
344 let report = build(
345 &rows,
346 &catalog,
347 crate::ocuse::zen::DEFAULT_PLAN,
348 now * 1000,
349 None,
350 );
351 assert!(
352 report.month_inferred,
353 "anniversary inferred from the earliest row"
354 );
355 assert!((report.five_hour.cost - 3.0).abs() < 1e-9);
356 assert!((report.weekly.cost - 7.0).abs() < 1e-9);
357 assert!(
359 (report.month.cost - 7.0).abs() < 1e-9,
360 "month cost {}",
361 report.month.cost
362 );
363 let flash = report
364 .models
365 .iter()
366 .find(|m| m.id == "glm-5.3-flash")
367 .unwrap();
368 assert_eq!(flash.limit, Some(60.0));
369 assert!((flash.five_hour.cost - 3.0).abs() < 1e-9);
370 }
371
372 #[test]
373 fn models_sort_by_month_spend() {
374 let now = days_from_civil(2026, 9, 27) * 86_400;
375 let rows = vec![row(now - 60, "cheap", 0.1), row(now - 60, "pricey", 5.0)];
376 let report = build(
377 &rows,
378 &crate::ocuse::zen::catalog(),
379 crate::ocuse::zen::DEFAULT_PLAN,
380 now * 1000,
381 None,
382 );
383 assert_eq!(report.models[0].id, "pricey");
384 }
385}