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    /// Add one turn at `cost` — priced by the caller, which is the whole point:
27    /// `row.cost_usd` is opencode's figure, always the off-peak sheet, so the
28    /// dashboard prices each turn on the documented schedule instead and falls
29    /// back to the recorded cost when the model carries no rates.
30    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    /// Fraction of the monthly limit used, when the docs give one.
55    pub fn month_use(&self) -> Option<f64> {
56        self.limit.filter(|l| *l > 0.0).map(|l| self.month.cost / l)
57    }
58}
59
60/// Which of this model's windows is over its share of the monthly limit, if
61/// any — the verdict the sidebar's flag, the watch frame and the cap
62/// notification all read from. Go meters per model AND per window (5-hour =
63/// 20%, weekly = 50%, monthly = 100%), so any one of them counts; the shortest
64/// window wins, being the most actionable. `None` for a free/unlimited model.
65pub 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    /// What the caller called this period ("calendar month", "all", "30d") for display.
82    pub period_label: String,
83    pub now_ms: i64,
84    pub since_ms: i64,
85    /// End of the period (epoch ms), resolved by the caller from its own start
86    /// — see `next_month`. `None` when the period has no boundary (`--window
87    /// all`); the rolling 5h/weekly windows never carry one.
88    pub period_end_ms: Option<i64>,
89    pub month_inferred: bool,
90    /// The account's own three meters, when OpenCode Go's usage endpoint
91    /// answered (`ocuse::api`). The dashboard's window rows read from here
92    /// rather than from [`Self::five_hour`]/[`Self::weekly`]/[`Self::month`],
93    /// because only the server's numbers agree with the console; the store's are
94    /// the fallback. `None` also keeps every existing caller working.
95    pub account: Option<crate::ocuse::api::Usage>,
96    /// Why the account's meters are absent, when that is worth saying — a refused
97    /// key, a missing one, a dead network. `None` when the endpoint answered, and
98    /// also for `--no-api` (a deliberate choice is not a fault). Without it the
99    /// dashboard's "estimated locally" says nothing about which it was, and only
100    /// one of those is something to act on.
101    pub account_error: Option<String>,
102    /// Every local client the rows came from, in read order — `opencode`, `dsh`,
103    /// `pi`. The dashboard names them, because a total that grew when another
104    /// harness spent the same allowance must be visible, not mysterious.
105    pub clients: Vec<String>,
106    pub five_hour: Totals,
107    pub weekly: Totals,
108    pub month: Totals,
109    /// Sorted by month cost, descending.
110    pub models: Vec<ModelUsage>,
111}
112
113/// Civil date from a Unix day number (Howard Hinnant's algorithm) — avoids a
114/// date dependency for the one thing we need: a day-of-month.
115fn 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
143/// Wall-clock now in epoch ms.
144pub 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
151/// Start of the calendar month, 00:00 UTC — the predictable default, since
152/// nothing local records a subscription anniversary (the console holds it).
153pub 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
158/// The same day-of-month one month later, 00:00 UTC, in epoch ms; the year
159/// wraps. The day is clamped to 28 so an anniversary on the 29th–31st cannot
160/// skip a month into the wrong one — the same clamp `month_start` applies, so a
161/// period start it produced always has a matching end.
162pub 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
172/// The same day-of-month one month *earlier*, keeping the time of day — the
173/// inverse of [`next_month`] for the instants it can produce. The account API's
174/// monthly `resetsAt` is an anniversary time (09:14:35, not midnight), so its
175/// period start has to carry that time of day back to the previous month.
176pub 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
186/// Start of the billing month: the most recent occurrence of the inferred
187/// anniversary day, else the first of the calendar month. Returns (start, inferred).
188pub fn month_start(rows: &[Row], now_ms: i64) -> (i64, bool) {
189    let now_secs = now_ms / 1000;
190    // Anniversary evidence: the oldest Go row we know about (its day-of-month),
191    // else the oldest row of any kind.
192    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    // No evidence at all: calendar month.
217    let current = civil(now_secs);
218    let days = days_from_civil(current.0, current.1, 1);
219    (days * 86_400 * 1000, false)
220}
221
222/// Build the report: window totals plus a per-model breakdown for the period.
223///
224/// `plan_id` selects which Go plan's per-model allowances the figures are
225/// measured against (the store records no subscription, so the caller chooses);
226/// `since_override` pins the period start (from `--period-start`); otherwise the
227/// anniversary heuristic in [`month_start`] applies. Rows before the period are
228/// anniversary evidence only — never listed — so the table and the totals agree.
229pub 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    // The windows are cut at the *account's* bounds when it answered. Go meters a
240    // 5-hour block with its own reset, a week anchored to Monday 00:00 UTC, and
241    // the billing anniversary — none of which a rolling `now - length` cut
242    // reproduces. Cutting locally and pairing those dollars with the server's
243    // percentage put two different windows on one row: on real data the weekly
244    // row read `$20.45 (0.0% of limit)`, because the store's rolling week still
245    // held the previous week's spend while the server's week had just started.
246    // Without an answer the rolling cuts stay — the only ones this machine knows.
247    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    // Which local clients these rows came from. Read before the cutting loop so a
273    // client whose rows all fall outside the window is still named — the store was
274    // read, and saying so beats a silently different total.
275    for row in rows {
276        if row.client.is_empty() {
277            continue; // a hand-built row in a test names no client
278        }
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        // The month cut applies to the month, not to the loop: the account's week
286        // is anchored to Monday, which can start before a mid-week billing
287        // anniversary, and dropping those rows would undercount the very window
288        // the server's percentage describes.
289        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        // Priced on the documented schedule, not by opencode's recorded `cost`
296        // (always the off-peak sheet). Falls back to the recorded figure for a
297        // model with no rate rows, so a free or unknown model still counts zero
298        // and an unpriced one still counts its own cost.
299        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    /// An account meter with a reset instant, for the window-bound tests.
367    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    /// ISO-8601 UTC for an epoch-seconds instant, so the meter's wire shape
376    /// (`resetsAt`) is exercised rather than a pre-parsed value.
377    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        // 2026-09-27T00:00:00Z
391        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        // Same day-of-month, one month later, across a year boundary.
399        let dec15 = days_from_civil(2026, 12, 15) * 86_400_000;
400        assert_eq!(civil(next_month(dec15) / 1000), (2027, 1, 15));
401        // A calendar-month start (day 1) lands on day 1 of the next month.
402        let jan1 = days_from_civil(2026, 1, 1) * 86_400_000;
403        assert_eq!(civil(next_month(jan1) / 1000), (2026, 2, 1));
404        // A 31st start is clamped to 28, so it cannot skip a short month.
405        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        // The account API's monthly reset for this subscription.
412        let reset = 1_794_604_475_000; // 2026-11-13T09:14:35Z
413        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        // One month on from the derived start is the reset's date again.
417        assert_eq!(civil(next_month(start) / 1000), (2026, 11, 13));
418        // Year wrap, and the day clamp that keeps a 31st from skipping a month.
419        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),          // inside 5h
430            row(now - 2 * 3600, "glm-5.3-flash", 2.0),    // inside 5h + week
431            row(now - 3 * 86_400, "kimi-k3", 4.0),        // inside week only
432            row(now - 40 * 86_400, "glm-5.3-flash", 8.0), // previous cycle: anniversary evidence
433        ];
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        // anniversary day = 18 (40 days before the 27th), so this cycle starts Sep 18
450        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    /// The account's own bounds decide the windows when it answered. Go's 5-hour
465    /// window is a *block* with its own reset and its week starts Monday 00:00
466    /// UTC, so a rolling `now - length` cut describes a different span — the bug
467    /// that printed `$20.45 (0.0% of limit)` on a row whose console share had
468    /// just started counting.
469    #[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; // Mon noon UTC
472        let week_reset = now + 6 * 86_400 + 12 * 3600; // next Monday 00:00
473        let five_reset = now + 3 * 3600;
474        let five_start = five_reset - FIVE_HOUR_SECS;
475        let rows = vec![
476            // Before the server's week (so outside it) but inside a rolling 7d.
477            row(now - 6 * 86_400, "glm-5.3-flash", 9.0),
478            // Inside the server's week but outside its 5-hour block (which
479            // starts two hours back), so the two cuts are distinguishable.
480            row(now - 3 * 3600, "glm-5.3-flash", 2.0),
481            // Inside the server's 5-hour block.
482            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        // The rolling 7d cut would have counted the 6-day-old row ($9, so 12.0
498        // here); the server's Monday week starts 12h ago and must not.
499        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        // …and the same cut applies to the per-model rows the panel pairs with.
510        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        // The month is untouched by the window cuts: it still sees every row in
518        // the period, including the one the server's week excluded.
519        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    /// The report names every client it read, so a total that grew because `pi`
538    /// or `dsh` spent the same allowance is visible in the frame, not inferred.
539    #[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}