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
56#[derive(Debug, Clone)]
57pub struct Report {
58 pub period_label: String,
60 pub now_ms: i64,
61 pub since_ms: i64,
62 pub month_inferred: bool,
63 pub five_hour: Totals,
64 pub weekly: Totals,
65 pub month: Totals,
66 pub models: Vec<ModelUsage>,
68}
69
70fn day_of_month(epoch_secs: i64) -> u32 {
73 civil(epoch_secs).2 as u32
74}
75
76pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
77 let y = if m <= 2 { y - 1 } else { y };
78 let era = y.div_euclid(400);
79 let yoe = y - era * 400;
80 let mp = if m > 2 { m - 3 } else { m + 9 };
81 let doy = (153 * mp + 2) / 5 + d - 1;
82 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
83 era * 146_097 + doe - 719_468
84}
85
86fn civil(epoch_secs: i64) -> (i64, i64, i64) {
87 let days = epoch_secs.div_euclid(86_400);
88 let z = days + 719_468;
89 let era = z.div_euclid(146_097);
90 let doe = z.rem_euclid(146_097);
91 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
92 let y = yoe + era * 400;
93 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
94 let mp = (5 * doy + 2) / 153;
95 let d = doy - (153 * mp + 2) / 5 + 1;
96 let m = if mp < 10 { mp + 3 } else { mp - 9 };
97 (if m <= 2 { y + 1 } else { y }, m, d)
98}
99
100pub fn now_ms() -> i64 {
102 std::time::SystemTime::now()
103 .duration_since(std::time::UNIX_EPOCH)
104 .map(|d| d.as_millis() as i64)
105 .unwrap_or(0)
106}
107
108pub fn calendar_month_start(now_ms: i64) -> i64 {
111 let (year, month, _) = civil(now_ms / 1000);
112 days_from_civil(year, month, 1) * 86_400_000
113}
114
115pub fn month_start(rows: &[Row], now_ms: i64) -> (i64, bool) {
118 let now_secs = now_ms / 1000;
119 let (year, month, _day) = civil(now_secs);
122 let earliest_go = rows
123 .iter()
124 .filter(|r| r.provider == "opencode-go")
125 .map(|r| r.at_ms)
126 .min();
127 let anniversary = earliest_go
128 .or_else(|| rows.iter().map(|r| r.at_ms).min())
129 .map(|ms| day_of_month(ms / 1000));
130 if let Some(day_wanted) = anniversary {
131 let candidate = days_from_civil(year, month, day_wanted.min(28) as i64) * 86_400;
132 if candidate <= now_secs {
133 return (candidate * 1000, true);
134 }
135 let (py, pm) = if month == 1 {
136 (year - 1, 12)
137 } else {
138 (year, month - 1)
139 };
140 return (
141 days_from_civil(py, pm, day_wanted.min(28) as i64) * 86_400 * 1000,
142 true,
143 );
144 }
145 let current = civil(now_secs);
147 let days = days_from_civil(current.0, current.1, 1);
148 (days * 86_400 * 1000, false)
149}
150
151pub fn build(
159 rows: &[Row],
160 catalog: &Catalog,
161 plan_id: &str,
162 now_ms: i64,
163 since_override: Option<i64>,
164) -> Report {
165 let (inferred_start, month_inferred) = month_start(rows, now_ms);
166 let since_ms = since_override.unwrap_or(inferred_start);
167 let five_cut = now_ms - FIVE_HOUR_SECS * 1000;
168 let week_cut = now_ms - WEEKLY_SECS * 1000;
169
170 let mut report = Report {
171 period_label: "calendar month".into(),
172 now_ms,
173 since_ms,
174 month_inferred: since_override.is_none() && month_inferred,
175 five_hour: Totals::default(),
176 weekly: Totals::default(),
177 month: Totals::default(),
178 models: Vec::new(),
179 };
180 let mut by_model: std::collections::BTreeMap<String, ModelUsage> = Default::default();
181
182 for row in rows {
183 if row.at_ms < since_ms {
184 continue; }
186 report.month.add(row);
187 if row.at_ms >= week_cut {
188 report.weekly.add(row);
189 }
190 if row.at_ms >= five_cut {
191 report.five_hour.add(row);
192 }
193 let entry = by_model
194 .entry(row.model.clone())
195 .or_insert_with(|| ModelUsage {
196 id: row.model.clone(),
197 provider: row.provider.clone(),
198 limit: catalog.go_limit(plan_id, &row.model),
199 month: Totals::default(),
200 five_hour: Totals::default(),
201 weekly: Totals::default(),
202 });
203 entry.month.add(row);
204 if row.at_ms >= week_cut {
205 entry.weekly.add(row);
206 }
207 if row.at_ms >= five_cut {
208 entry.five_hour.add(row);
209 }
210 }
211
212 report.models = by_model.into_values().collect();
213 report.models.sort_by(|a, b| {
214 b.month
215 .cost
216 .partial_cmp(&a.month.cost)
217 .unwrap_or(std::cmp::Ordering::Equal)
218 .then_with(|| a.id.cmp(&b.id))
219 });
220 report
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use crate::ocuse::db::Row;
227
228 fn row(at_s: i64, model: &str, cost: f64) -> Row {
229 Row {
230 at_ms: at_s * 1000,
231 provider: "opencode-go".into(),
232 model: model.into(),
233 cost_usd: cost,
234 ..Default::default()
235 }
236 }
237
238 #[test]
239 fn civil_helpers_round_trip() {
240 let secs = days_from_civil(2026, 9, 27) * 86_400;
242 assert_eq!(civil(secs), (2026, 9, 27));
243 assert_eq!(day_of_month(secs), 27);
244 }
245
246 #[test]
247 fn windows_are_rolling_and_the_month_is_inferred() {
248 let now = days_from_civil(2026, 9, 27) * 86_400 + 12 * 3600;
249 let rows = vec![
250 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), ];
255 let catalog = crate::ocuse::zen::catalog();
256 let report = build(
257 &rows,
258 &catalog,
259 crate::ocuse::zen::DEFAULT_PLAN,
260 now * 1000,
261 None,
262 );
263 assert!(
264 report.month_inferred,
265 "anniversary inferred from the earliest row"
266 );
267 assert!((report.five_hour.cost - 3.0).abs() < 1e-9);
268 assert!((report.weekly.cost - 7.0).abs() < 1e-9);
269 assert!(
271 (report.month.cost - 7.0).abs() < 1e-9,
272 "month cost {}",
273 report.month.cost
274 );
275 let flash = report
276 .models
277 .iter()
278 .find(|m| m.id == "glm-5.3-flash")
279 .unwrap();
280 assert_eq!(flash.limit, Some(60.0));
281 assert!((flash.five_hour.cost - 3.0).abs() < 1e-9);
282 }
283
284 #[test]
285 fn models_sort_by_month_spend() {
286 let now = days_from_civil(2026, 9, 27) * 86_400;
287 let rows = vec![row(now - 60, "cheap", 0.1), row(now - 60, "pricey", 5.0)];
288 let report = build(
289 &rows,
290 &crate::ocuse::zen::catalog(),
291 crate::ocuse::zen::DEFAULT_PLAN,
292 now * 1000,
293 None,
294 );
295 assert_eq!(report.models[0].id, "pricey");
296 }
297}