Skip to main content

cmd_usage/ocuse/
window.rs

1// Aggregation: rows -> per-model totals -> the documented windows.
2//
3// Go's limits are per model and per period: 5-hour = 20% of the monthly limit,
4// weekly = 50%, monthly = 100%. The monthly *period* is a subscription
5// anniversary that nothing local records, so we infer it from the earliest Go
6// row we have ever seen (its day-of-month) and fall back to the calendar month
7// when there is no evidence. The inference is reported, never silent.
8use 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;
13/// How far back to read when inferring the anniversary.
14pub 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    /// Fraction of the monthly limit used, when the docs give one.
51    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    /// What the caller called this period ("calendar month", "all", "30d") for display.
59    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    /// Sorted by month cost, descending.
67    pub models: Vec<ModelUsage>,
68}
69
70/// Civil date from a Unix day number (Howard Hinnant's algorithm) — avoids a
71/// date dependency for the one thing we need: a day-of-month.
72fn 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
100/// Wall-clock now in epoch ms.
101pub 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
108/// Start of the calendar month, 00:00 UTC — the predictable default, since
109/// nothing local records a subscription anniversary (the console holds it).
110pub 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
115/// Start of the billing month: the most recent occurrence of the inferred
116/// anniversary day, else the first of the calendar month. Returns (start, inferred).
117pub fn month_start(rows: &[Row], now_ms: i64) -> (i64, bool) {
118    let now_secs = now_ms / 1000;
119    // Anniversary evidence: the oldest Go row we know about (its day-of-month),
120    // else the oldest row of any kind.
121    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    // No evidence at all: calendar month.
146    let current = civil(now_secs);
147    let days = days_from_civil(current.0, current.1, 1);
148    (days * 86_400 * 1000, false)
149}
150
151/// Build the report: window totals plus a per-model breakdown for the period.
152///
153/// `plan_id` selects which Go plan's per-model allowances the figures are
154/// measured against (the store records no subscription, so the caller chooses);
155/// `since_override` pins the period start (from `--period-start`); otherwise the
156/// anniversary heuristic in [`month_start`] applies. Rows before the period are
157/// anniversary evidence only — never listed — so the table and the totals agree.
158pub 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; // before the period: evidence for the anniversary only
185        }
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        // 2026-09-27T00:00:00Z
241        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),          // inside 5h
251            row(now - 2 * 3600, "glm-5.3-flash", 2.0),    // inside 5h + week
252            row(now - 3 * 86_400, "kimi-k3", 4.0),        // inside week only
253            row(now - 40 * 86_400, "glm-5.3-flash", 8.0), // previous cycle: anniversary evidence
254        ];
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        // anniversary day = 18 (40 days before the 27th), so this cycle starts Sep 18
270        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}