Skip to main content

cmd_usage/arkuse/
window.rs

1// Aggregation: rows -> per-model totals -> the plan's own windows.
2//
3// Two independent sources, and they answer different questions:
4//
5// * the **meters** (`arkcli usage plan`) are authoritative — the control plane's
6//   own used/quota/reset for 5h, weekly and monthly (and daily, on media), and
7//   the only view that can see traffic from another machine;
8// * the **store** (opencode.db) is this machine's rows, which give per-model
9//   attribution and a reconciliation against the coefficients.
10//
11// Window bounds come from the meters when they answered: ARK's 5h is a block
12// that starts at the cycle's first request, the week is Monday 00:00, and the
13// month is the subscription anniversary — a rolling `now - length` cut
14// reproduces none of them. Without an answer the rolling cuts remain, labelled
15// as the local estimate they are.
16use crate::arkuse::api::Usage;
17use crate::arkuse::ark::Catalog;
18use crate::arkuse::db::Row;
19
20pub const FIVE_HOUR_SECS: i64 = 5 * 3600;
21pub const DAY_SECS: i64 = 86_400;
22pub const WEEKLY_SECS: i64 = 7 * 86_400;
23/// How far back to read when inferring the anniversary.
24pub const LOOKBACK_SECS: i64 = 120 * 86_400;
25
26#[derive(Debug, Clone, Default)]
27pub struct Totals {
28    /// AFP — the plan's own currency. Never dollars, and never the store's
29    /// `cost`: opencode records no AFP at all.
30    pub afp: f64,
31    pub requests: u64,
32    pub input: u64,
33    pub output: u64,
34    pub cache_read: u64,
35    pub cache_write: u64,
36    /// The earliest row folded in — what a rolling window's next reset derives
37    /// from (oldest request + window length). Never a fabricated `now + length`.
38    pub oldest_ms: Option<i64>,
39}
40
41impl Totals {
42    /// Add one turn at the AFP the catalogue computed for it. `None` (an
43    /// unknown model) counts the request and its tokens but no AFP, rather than
44    /// inventing a coefficient.
45    pub fn add(&mut self, row: &Row, afp: Option<f64>) {
46        self.afp += afp.unwrap_or(0.0);
47        self.requests += 1;
48        self.input += row.input;
49        self.output += row.output;
50        self.cache_read += row.cache_read;
51        self.cache_write += row.cache_write;
52        self.oldest_ms = Some(self.oldest_ms.map_or(row.at_ms, |at| at.min(row.at_ms)));
53    }
54
55    pub fn tokens(&self) -> u64 {
56        self.input + self.output + self.cache_read
57    }
58}
59
60#[derive(Debug, Clone)]
61pub struct ModelUsage {
62    pub id: String,
63    pub provider: String,
64    /// The coefficient in force for the period (promo applied).
65    pub coef: f64,
66    pub month: Totals,
67    pub five_hour: Totals,
68    pub weekly: Totals,
69}
70
71#[derive(Debug, Clone)]
72pub struct Report {
73    pub period_label: String,
74    pub now_ms: i64,
75    pub since_ms: i64,
76    pub period_end_ms: Option<i64>,
77    pub month_inferred: bool,
78    /// The control plane's meters, when `arkcli` answered.
79    pub account: Option<Usage>,
80    /// Why the meters are absent, when that is worth saying (`--no-api` is a
81    /// deliberate choice, not a fault).
82    pub account_error: Option<String>,
83    /// Every local client the rows came from, in read order — `opencode`, `dsh`,
84    /// `pi`. The tier's meters see every harness on the plan, so the local half
85    /// must name which stores it could actually read.
86    pub clients: Vec<String>,
87    /// The tier the figures are measured against (explicit `--plan`, else the
88    /// profile's own `plan_tier`, else the catalogue default).
89    pub tier: String,
90    /// `arkcli` profile the meters came from, for the header.
91    pub profile: Option<String>,
92    /// The server offset the account's windows are cut in (seconds east of UTC).
93    /// Period bounds print in that offset, never in a guessed local zone.
94    pub offset_secs: i64,
95    pub five_hour: Totals,
96    pub weekly: Totals,
97    pub month: Totals,
98    /// Present only when the endpoint returned a daily window (media workloads;
99    /// a text-only plan exposes 5h/weekly/monthly).
100    pub daily: Option<Totals>,
101    /// Sorted by month AFP, descending.
102    pub models: Vec<ModelUsage>,
103}
104
105impl Report {
106    /// AFP per request over the 5-hour window — the meter's figure divided by
107    /// this store's request count. Fresh enough to be meaningful, and the
108    /// number the capacity questions are built on.
109    pub fn afp_per_request(&self) -> Option<f64> {
110        let account = self.account.as_ref()?;
111        let requests = self.five_hour.requests;
112        (requests > 0).then(|| account.five_hour.used / requests as f64)
113    }
114
115    /// Meter vs computed AFP over the 5-hour window, as a signed percentage of
116    /// the meter. A few percent is normal (a request still in flight, another
117    /// machine on the same key); a large gap means an unmodelled item (media or
118    /// Harness) or traffic this store cannot see.
119    pub fn reconcile(&self) -> Option<f64> {
120        let account = self.account.as_ref()?;
121        if account.five_hour.used <= 0.0 || self.five_hour.afp <= 0.0 {
122            return None;
123        }
124        Some((self.five_hour.afp - account.five_hour.used) / account.five_hour.used * 100.0)
125    }
126
127    /// Burn implied by the 5h window, in AFP/day: what the cycle's current pace
128    /// would consume over a day. `None` before the block has any use.
129    pub fn burn_per_day(&self) -> Option<f64> {
130        let account = self.account.as_ref()?;
131        (account.five_hour.used > 0.0).then(|| account.five_hour.used / 5.0 * 24.0)
132    }
133
134    /// The plan's own quota for a window: its monthly AFP times the catalogue's
135    /// share of it. ARK publishes both, so the local estimate can print the same
136    /// pair the console does — medium's 10,000 / 35,000 / 100,000 AFP are exactly
137    /// what its meters reported on 2026-10-20.
138    pub fn window_quota(&self, catalog: &Catalog, share: f64) -> Option<f64> {
139        catalog.plan(&self.tier).map(|plan| plan.afp * share)
140    }
141
142    /// When a locally cut window turns over: its oldest in-window row plus the
143    /// window's length, the same derivation the Go side uses. `None` before the
144    /// window has rows — `now + length` is not a reset the data supports.
145    pub fn rolling_reset(&self, totals: &Totals, dur_secs: i64) -> Option<i64> {
146        totals.oldest_ms.map(|at| at + dur_secs * 1000)
147    }
148}
149
150/// Civil date from a Unix day number (Howard Hinnant's algorithm) — no date
151/// dependency for the day-of-month the anniversary needs.
152fn day_of_month(epoch_secs: i64) -> u32 {
153    civil(epoch_secs).2 as u32
154}
155
156pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
157    let y = if m <= 2 { y - 1 } else { y };
158    let era = y.div_euclid(400);
159    let yoe = y - era * 400;
160    let mp = if m > 2 { m - 3 } else { m + 9 };
161    let doy = (153 * mp + 2) / 5 + d - 1;
162    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
163    era * 146_097 + doe - 719_468
164}
165
166fn civil(epoch_secs: i64) -> (i64, i64, i64) {
167    let days = epoch_secs.div_euclid(DAY_SECS);
168    let z = days + 719_468;
169    let era = z.div_euclid(146_097);
170    let doe = z.rem_euclid(146_097);
171    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
172    let y = yoe + era * 400;
173    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
174    let mp = (5 * doy + 2) / 153;
175    let d = doy - (153 * mp + 2) / 5 + 1;
176    let m = if mp < 10 { mp + 3 } else { mp - 9 };
177    (if m <= 2 { y + 1 } else { y }, m, d)
178}
179
180/// Wall-clock now in epoch ms.
181pub fn now_ms() -> i64 {
182    std::time::SystemTime::now()
183        .duration_since(std::time::UNIX_EPOCH)
184        .map(|d| d.as_millis() as i64)
185        .unwrap_or(0)
186}
187
188/// Start of the calendar month, 00:00 UTC — the predictable default when no
189/// meter names the anniversary.
190pub fn calendar_month_start(now_ms: i64) -> i64 {
191    let (year, month, _) = civil(now_ms / 1000);
192    days_from_civil(year, month, 1) * 86_400_000
193}
194
195/// The same day-of-month one month later, 00:00 UTC, in epoch ms; the year
196/// wraps and the day is clamped to 28 so an anniversary on the 29th–31st cannot
197/// skip a month.
198pub fn next_month(ms: i64) -> i64 {
199    let (year, month, day) = civil(ms / 1000);
200    let (ny, nm) = if month == 12 {
201        (year + 1, 1)
202    } else {
203        (year, month + 1)
204    };
205    days_from_civil(ny, nm, day.min(28)) * 86_400_000
206}
207
208/// The subscription month's start from the meter's own reset instant.
209///
210/// ARK's monthly reset is the last second of the anniversary day
211/// (`2026-11-19T23:59:59+08:00`), so the cycle starts at 00:00 on that
212/// day-of-month in the previous month, **in the reset's own offset** — the
213/// server speaks +08:00, and bucketing its instant as UTC would put the boundary
214/// eight hours off.
215pub fn month_start_before(reset_ms: i64, offset_secs: i64) -> i64 {
216    let local = reset_ms + offset_secs * 1000;
217    let (year, month, day) = civil(local.div_euclid(1000));
218    let (py, pm) = if month == 1 {
219        (year - 1, 12)
220    } else {
221        (year, month - 1)
222    };
223    days_from_civil(py, pm, day.min(28)) * 86_400_000 - offset_secs * 1000
224}
225
226/// Is `at_ms` on or before the end of the `YYYY-MM-DD` day? Used for promo
227/// coefficients, which the docs publish as dates.
228pub fn before_day_end(at_ms: i64, day: &str) -> bool {
229    let mut parts = day.split('-');
230    let (Some(y), Some(m), Some(d)) = (parts.next(), parts.next(), parts.next()) else {
231        return false;
232    };
233    let (Ok(y), Ok(m), Ok(d)) = (y.parse::<i64>(), m.parse::<i64>(), d.parse::<i64>()) else {
234        return false;
235    };
236    let end = (days_from_civil(y, m, d) + 1) * 86_400_000;
237    at_ms < end
238}
239
240/// Start of the billing month from the first plan request, else the calendar
241/// month. Returns `(start, inferred)`.
242pub fn month_start(rows: &[Row], now_ms: i64) -> (i64, bool) {
243    let now_secs = now_ms / 1000;
244    let (year, month, _) = civil(now_secs);
245    if let Some(ms) = rows.iter().map(|r| r.at_ms).min() {
246        let wanted = day_of_month(ms / 1000).min(28) as i64;
247        let candidate = days_from_civil(year, month, wanted) * DAY_SECS;
248        if candidate <= now_secs {
249            return (candidate * 1000, true);
250        }
251        let (py, pm) = if month == 1 {
252            (year - 1, 12)
253        } else {
254            (year, month - 1)
255        };
256        return (days_from_civil(py, pm, wanted) * DAY_SECS * 1000, true);
257    }
258    (days_from_civil(year, month, 1) * DAY_SECS * 1000, false)
259}
260
261/// Build the report: window totals plus a per-model breakdown for the period.
262pub fn build(
263    rows: &[Row],
264    catalog: &Catalog,
265    plan_id: &str,
266    now_ms: i64,
267    since_override: Option<i64>,
268    account: Option<&Usage>,
269) -> Report {
270    let (inferred_start, month_inferred) = month_start(rows, now_ms);
271    let since_ms = since_override.unwrap_or(inferred_start);
272    // Cut at the meter's own bounds when it answered. ARK's 5-hour window is a
273    // block that begins at the cycle's first request, the week starts Monday
274    // 00:00 in the server's offset, and the month is the anniversary — none of
275    // which a rolling cut reproduces.
276    let (five_start, five_end) = match account.and_then(|a| a.five_hour.reset_ms()) {
277        Some(reset) => (reset - FIVE_HOUR_SECS * 1000, reset),
278        None => (now_ms - FIVE_HOUR_SECS * 1000, i64::MAX),
279    };
280    let (week_start, week_end) = match account.and_then(|a| a.weekly.reset_ms()) {
281        Some(reset) => (reset - WEEKLY_SECS * 1000, reset),
282        None => (now_ms - WEEKLY_SECS * 1000, i64::MAX),
283    };
284    // Only media and Harness plans expose a daily window; text-only plans answer
285    // no `daily` period at all, so the row stays absent rather than reading zero.
286    let day_bounds = account
287        .and_then(|a| a.daily.as_ref())
288        .and_then(|m| m.reset_ms())
289        .map(|reset| (reset - DAY_SECS * 1000, reset));
290
291    let mut report = Report {
292        period_label: "calendar month".into(),
293        now_ms,
294        since_ms,
295        period_end_ms: None,
296        month_inferred: since_override.is_none() && month_inferred,
297        account: None,
298        account_error: None,
299        clients: Vec::new(),
300        tier: plan_id.to_string(),
301        profile: account.map(|a| a.profile.clone()),
302        offset_secs: account.map_or(0, |a| a.monthly.offset_secs()),
303        five_hour: Totals::default(),
304        weekly: Totals::default(),
305        month: Totals::default(),
306        daily: day_bounds.map(|_| Totals::default()),
307        models: Vec::new(),
308    };
309    let mut by_model: std::collections::BTreeMap<String, ModelUsage> = Default::default();
310
311    // Which local clients these rows came from, read before the window cuts so a
312    // client whose rows all fall outside still gets named.
313    for row in rows {
314        if row.client.is_empty() {
315            continue; // a hand-built row in a test names no client
316        }
317        if !report.clients.iter().any(|client| client == row.client) {
318            report.clients.push(row.client.to_string());
319        }
320    }
321
322    for row in rows {
323        let in_month = row.at_ms >= since_ms;
324        let in_week = row.at_ms >= week_start && row.at_ms < week_end;
325        let in_five = row.at_ms >= five_start && row.at_ms < five_end;
326        let in_day = day_bounds.is_some_and(|(start, end)| row.at_ms >= start && row.at_ms < end);
327        if !in_month && !in_week && !in_five && !in_day {
328            continue;
329        }
330        let afp = catalog.afp(&row.model, row.at_ms, row.input, row.cache_read, row.output);
331        if in_month {
332            report.month.add(row, afp);
333        }
334        if in_day {
335            report
336                .daily
337                .get_or_insert_with(Totals::default)
338                .add(row, afp);
339        }
340        if in_week {
341            report.weekly.add(row, afp);
342        }
343        if in_five {
344            report.five_hour.add(row, afp);
345        }
346        let coef = catalog
347            .models
348            .iter()
349            .find(|m| m.id == row.model)
350            .map(|m| crate::arkuse::ark::effective_coef(m, row.at_ms))
351            .unwrap_or(0.0);
352        let entry = by_model
353            .entry(row.model.clone())
354            .or_insert_with(|| ModelUsage {
355                id: row.model.clone(),
356                provider: row.provider.clone(),
357                coef,
358                month: Totals::default(),
359                five_hour: Totals::default(),
360                weekly: Totals::default(),
361            });
362        if in_month {
363            entry.month.add(row, afp);
364        }
365        if in_week {
366            entry.weekly.add(row, afp);
367        }
368        if in_five {
369            entry.five_hour.add(row, afp);
370        }
371    }
372
373    report.models = by_model.into_values().collect();
374    report.models.sort_by(|a, b| {
375        b.month
376            .afp
377            .partial_cmp(&a.month.afp)
378            .unwrap_or(std::cmp::Ordering::Equal)
379            .then_with(|| a.id.cmp(&b.id))
380    });
381    report
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use crate::arkuse::db::Row;
388
389    fn row(at_s: i64, model: &str) -> Row {
390        Row {
391            at_ms: at_s * 1000,
392            provider: "agent-plan".into(),
393            model: model.into(),
394            input: 10_000,
395            ..Default::default()
396        }
397    }
398
399    fn meter(percent: f64, used: f64, resets_at: &str) -> crate::arkuse::api::Meter {
400        crate::arkuse::api::Meter::from_parts("ok", percent, used, 0.0, Some(resets_at), None)
401    }
402
403    #[test]
404    fn civil_helpers_round_trip() {
405        let secs = days_from_civil(2026, 9, 27) * 86_400;
406        assert_eq!(civil(secs), (2026, 9, 27));
407        assert_eq!(day_of_month(secs), 27);
408    }
409
410    /// The server's monthly reset is the last second of the anniversary day in
411    /// +08:00, so the cycle starts at 00:00 on that day-of-month one month
412    /// earlier *in that offset* — not eight hours off, and not 23:59:59.
413    #[test]
414    fn month_start_follows_the_servers_anniversary_and_offset() {
415        // 2026-11-19T23:59:59+08:00
416        let reset = (days_from_civil(2026, 11, 19) * 86_400 + 23 * 3600 + 59 * 60 + 59) * 1000
417            - 8 * 3600 * 1000;
418        let start = month_start_before(reset, 8 * 3600);
419        let local = (start + 8 * 3600 * 1000) / 1000;
420        assert_eq!(civil(local), (2026, 10, 19), "00:00 on the 19th, +08:00");
421        assert_eq!(local.rem_euclid(DAY_SECS), 0);
422    }
423
424    #[test]
425    fn promo_dates_end_at_the_days_close() {
426        let day = days_from_civil(2026, 12, 30) * 86_400_000;
427        assert!(before_day_end(day + 3_600_000, "2026-12-30"));
428        assert!(!before_day_end(day + 86_400_000, "2026-12-30"));
429    }
430
431    #[test]
432    fn the_meters_bounds_cut_the_windows_when_they_answered() {
433        // Mon 2026-10-19 12:00 UTC = 20:00 +08:00.
434        let now = days_from_civil(2026, 10, 19) * 86_400 + 12 * 3600;
435        let account = crate::arkuse::api::Usage {
436            // Reset at 23:00 +08:00 the same evening: the block is now-2h..+3h.
437            five_hour: meter(6.0, 600.0, "2026-10-19T23:00:00+08:00"),
438            daily: None,
439            // Monday 00:00 +08:00 = Sunday 16:00 UTC.
440            weekly: meter(3.0, 1_050.0, "2026-10-26T00:00:00+08:00"),
441            monthly: meter(51.0, 51_000.0, "2026-11-19T23:59:59+08:00"),
442            tier: Some("medium".into()),
443            profile: "agent-plan_cn-beijing_medium".into(),
444        };
445        let rows = vec![
446            // Eight days back: before the server's week, inside a rolling 7d.
447            row(now - 8 * 86_400, "deepseek-v4.1-flash"),
448            // Six hours back: inside the Monday week, before the 5-hour block.
449            row(now - 6 * 3600, "deepseek-v4.1-flash"),
450            // One minute back: inside the block.
451            row(now - 60, "deepseek-v4.1-flash"),
452        ];
453        let catalog = crate::arkuse::ark::catalog();
454        let month_start = days_from_civil(2026, 10, 1) * 86_400;
455        let report = build(
456            &rows,
457            &catalog,
458            "medium",
459            now * 1000,
460            Some(month_start * 1000),
461            Some(&account),
462        );
463        assert_eq!(report.five_hour.requests, 1, "only the in-block row");
464        assert_eq!(report.weekly.requests, 2, "the block plus six hours back");
465        assert_eq!(report.month.requests, 3, "all of them inside October");
466        assert!(report.five_hour.afp > 0.0);
467    }
468
469    #[test]
470    fn a_rolling_window_borrows_its_boundary_from_its_oldest_row() {
471        // Without a meter the cut is `now - length`, so the next turnover is the
472        // oldest row inside it plus the length — the same derivation the Go side
473        // uses. `now + length` would be a fabricated countdown.
474        let now = days_from_civil(2026, 10, 19) * 86_400;
475        let rows = vec![
476            row(now - 4 * 3600, "deepseek-v4.1-flash"),
477            row(now - 60, "deepseek-v4.1-flash"),
478        ];
479        let catalog = crate::arkuse::ark::catalog();
480        let report = build(&rows, &catalog, "medium", now * 1000, None, None);
481        assert_eq!(report.five_hour.requests, 2);
482        assert_eq!(report.five_hour.oldest_ms, Some((now - 4 * 3600) * 1000));
483        assert_eq!(
484            report.rolling_reset(&report.five_hour, FIVE_HOUR_SECS),
485            Some((now + 3600) * 1000),
486            "four hours of the block already spent"
487        );
488        assert_eq!(
489            report.window_quota(&catalog, catalog.windows.five_hour),
490            Some(10_000.0),
491            "medium's published 5-hour split"
492        );
493        assert_eq!(
494            report.rolling_reset(&Totals::default(), FIVE_HOUR_SECS),
495            None
496        );
497    }
498
499    #[test]
500    fn a_daily_meter_gets_its_own_cut_and_a_text_plan_has_none() {
501        // Mon 2026-10-19 12:00 UTC.
502        let now = days_from_civil(2026, 10, 19) * 86_400 + 12 * 3600;
503        let account = crate::arkuse::api::Usage {
504            five_hour: meter(6.0, 600.0, "2026-10-19T23:00:00+08:00"),
505            // 07:00 UTC: the day block is 07:00 yesterday .. 07:00 today.
506            daily: Some(meter(1.0, 100.0, "2026-10-19T15:00:00+08:00")),
507            weekly: meter(3.0, 1_050.0, "2026-10-26T00:00:00+08:00"),
508            monthly: meter(51.0, 51_000.0, "2026-11-19T23:59:59+08:00"),
509            tier: Some("medium".into()),
510            profile: "agent-plan_cn-beijing_medium".into(),
511        };
512        let rows = vec![
513            row(now - 10 * 3600, "deepseek-v4.1-flash"), // 02:00 UTC, inside
514            row(now - 3600, "deepseek-v4.1-flash"),      // 11:00 UTC, after the cut
515        ];
516        let catalog = crate::arkuse::ark::catalog();
517        let report = build(&rows, &catalog, "medium", now * 1000, None, Some(&account));
518        assert_eq!(report.daily.expect("media plan exposes a day").requests, 1);
519        // The same rows without a daily meter: the row stays absent, never zero.
520        let mut text_only = account;
521        text_only.daily = None;
522        let report = build(
523            &rows,
524            &catalog,
525            "medium",
526            now * 1000,
527            None,
528            Some(&text_only),
529        );
530        assert!(report.daily.is_none());
531    }
532
533    #[test]
534    fn models_sort_by_month_afp() {
535        let now = days_from_civil(2026, 9, 27) * 86_400;
536        let rows = vec![
537            row(now - 60, "glm-5.3-flash"),
538            row(now - 60, "deepseek-v4.1-flash"),
539        ];
540        let catalog = crate::arkuse::ark::catalog();
541        let report = build(&rows, &catalog, "medium", now * 1000, None, None);
542        assert_eq!(report.models[0].id, "deepseek-v4.1-flash", "2.5 > 0.5");
543    }
544
545    #[test]
546    fn an_unknown_model_counts_a_request_but_no_afp() {
547        let now = days_from_civil(2026, 9, 27) * 86_400;
548        let rows = vec![row(now - 60, "not-a-model")];
549        let catalog = crate::arkuse::ark::catalog();
550        let report = build(&rows, &catalog, "medium", now * 1000, None, None);
551        assert_eq!(report.month.requests, 1);
552        assert_eq!(report.month.afp, 0.0);
553    }
554}