cmd-usage 0.7.19

Live Command Code (commandcode.ai) usage dashboard: credits, 5-hour and weekly windows, plan limits. Watch mode or one-shot.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
// Aggregation: rows -> per-model totals -> the documented windows.
//
// Go's limits are per model and per period: 5-hour = 20% of the monthly limit,
// weekly = 50%, monthly = 100%. The monthly *period* is a subscription
// anniversary that nothing local records, so we infer it from the earliest Go
// row we have ever seen (its day-of-month) and fall back to the calendar month
// when there is no evidence. The inference is reported, never silent.
use crate::ocuse::db::Row;
use crate::ocuse::zen::Catalog;

pub const FIVE_HOUR_SECS: i64 = 5 * 3600;
pub const WEEKLY_SECS: i64 = 7 * 86_400;
/// How far back to read when inferring the anniversary.
pub const LOOKBACK_SECS: i64 = 120 * 86_400;

#[derive(Debug, Clone, Default)]
pub struct Totals {
    pub cost: f64,
    pub requests: u64,
    pub input: u64,
    pub output: u64,
    pub cache_read: u64,
}

impl Totals {
    /// Add one turn at `cost` — priced by the caller, which is the whole point:
    /// `row.cost_usd` is opencode's figure, always the off-peak sheet, so the
    /// dashboard prices each turn on the documented schedule instead and falls
    /// back to the recorded cost when the model carries no rates.
    pub fn add(&mut self, row: &Row, cost: f64) {
        self.cost += cost;
        self.requests += 1;
        self.input += row.input;
        self.output += row.output + row.reasoning;
        self.cache_read += row.cache_read;
    }

    pub fn tokens(&self) -> u64 {
        self.input + self.output + self.cache_read
    }
}

#[derive(Debug, Clone)]
pub struct ModelUsage {
    pub id: String,
    pub provider: String,
    pub limit: Option<f64>,
    pub month: Totals,
    pub five_hour: Totals,
    pub weekly: Totals,
}

impl ModelUsage {
    /// Fraction of the monthly limit used, when the docs give one.
    pub fn month_use(&self) -> Option<f64> {
        self.limit.filter(|l| *l > 0.0).map(|l| self.month.cost / l)
    }
}

/// Which of this model's windows is over its share of the monthly limit, if
/// any — the verdict the sidebar's flag, the watch frame and the cap
/// notification all read from. Go meters per model AND per window (5-hour =
/// 20%, weekly = 50%, monthly = 100%), so any one of them counts; the shortest
/// window wins, being the most actionable. `None` for a free/unlimited model.
pub fn over_window(model: &ModelUsage) -> Option<&'static str> {
    let limit = model.limit.filter(|l| *l > 0.0)?;
    let shares = &crate::ocuse::zen::catalog().go.window_share;
    if model.five_hour.cost > limit * shares.five_hour {
        Some("5-hour")
    } else if model.weekly.cost > limit * shares.weekly {
        Some("weekly")
    } else if model.month.cost > limit * shares.monthly {
        Some("monthly")
    } else {
        None
    }
}

#[derive(Debug, Clone)]
pub struct Report {
    /// What the caller called this period ("calendar month", "all", "30d") for display.
    pub period_label: String,
    pub now_ms: i64,
    pub since_ms: i64,
    /// End of the period (epoch ms), resolved by the caller from its own start
    /// — see `next_month`. `None` when the period has no boundary (`--window
    /// all`); the rolling 5h/weekly windows never carry one.
    pub period_end_ms: Option<i64>,
    pub month_inferred: bool,
    /// The account's own three meters, when OpenCode Go's usage endpoint
    /// answered (`ocuse::api`). The dashboard's window rows read from here
    /// rather than from [`Self::five_hour`]/[`Self::weekly`]/[`Self::month`],
    /// because only the server's numbers agree with the console; the store's are
    /// the fallback. `None` also keeps every existing caller working.
    pub account: Option<crate::ocuse::api::Usage>,
    /// Why the account's meters are absent, when that is worth saying — a refused
    /// key, a missing one, a dead network. `None` when the endpoint answered, and
    /// also for `--no-api` (a deliberate choice is not a fault). Without it the
    /// dashboard's "estimated locally" says nothing about which it was, and only
    /// one of those is something to act on.
    pub account_error: Option<String>,
    pub five_hour: Totals,
    pub weekly: Totals,
    pub month: Totals,
    /// Sorted by month cost, descending.
    pub models: Vec<ModelUsage>,
}

/// Civil date from a Unix day number (Howard Hinnant's algorithm) — avoids a
/// date dependency for the one thing we need: a day-of-month.
fn day_of_month(epoch_secs: i64) -> u32 {
    civil(epoch_secs).2 as u32
}

pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
    let y = if m <= 2 { y - 1 } else { y };
    let era = y.div_euclid(400);
    let yoe = y - era * 400;
    let mp = if m > 2 { m - 3 } else { m + 9 };
    let doy = (153 * mp + 2) / 5 + d - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    era * 146_097 + doe - 719_468
}

fn civil(epoch_secs: i64) -> (i64, i64, i64) {
    let days = epoch_secs.div_euclid(86_400);
    let z = days + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z.rem_euclid(146_097);
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    (if m <= 2 { y + 1 } else { y }, m, d)
}

/// Wall-clock now in epoch ms.
pub fn now_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as i64)
        .unwrap_or(0)
}

/// Start of the calendar month, 00:00 UTC — the predictable default, since
/// nothing local records a subscription anniversary (the console holds it).
pub fn calendar_month_start(now_ms: i64) -> i64 {
    let (year, month, _) = civil(now_ms / 1000);
    days_from_civil(year, month, 1) * 86_400_000
}

/// The same day-of-month one month later, 00:00 UTC, in epoch ms; the year
/// wraps. The day is clamped to 28 so an anniversary on the 29th–31st cannot
/// skip a month into the wrong one — the same clamp `month_start` applies, so a
/// period start it produced always has a matching end.
pub fn next_month(ms: i64) -> i64 {
    let (year, month, day) = civil(ms / 1000);
    let (ny, nm) = if month == 12 {
        (year + 1, 1)
    } else {
        (year, month + 1)
    };
    days_from_civil(ny, nm, day.min(28)) * 86_400_000
}

/// The same day-of-month one month *earlier*, keeping the time of day — the
/// inverse of [`next_month`] for the instants it can produce. The account API's
/// monthly `resetsAt` is an anniversary time (09:14:35, not midnight), so its
/// period start has to carry that time of day back to the previous month.
pub fn prev_month(ms: i64) -> i64 {
    let (year, month, day) = civil(ms / 1000);
    let (py, pm) = if month == 1 {
        (year - 1, 12)
    } else {
        (year, month - 1)
    };
    days_from_civil(py, pm, day.min(28)) * 86_400_000 + ms.rem_euclid(86_400_000)
}

/// Start of the billing month: the most recent occurrence of the inferred
/// anniversary day, else the first of the calendar month. Returns (start, inferred).
pub fn month_start(rows: &[Row], now_ms: i64) -> (i64, bool) {
    let now_secs = now_ms / 1000;
    // Anniversary evidence: the oldest Go row we know about (its day-of-month),
    // else the oldest row of any kind.
    let (year, month, _day) = civil(now_secs);
    let earliest_go = rows
        .iter()
        .filter(|r| r.provider == "opencode-go")
        .map(|r| r.at_ms)
        .min();
    let anniversary = earliest_go
        .or_else(|| rows.iter().map(|r| r.at_ms).min())
        .map(|ms| day_of_month(ms / 1000));
    if let Some(day_wanted) = anniversary {
        let candidate = days_from_civil(year, month, day_wanted.min(28) as i64) * 86_400;
        if candidate <= now_secs {
            return (candidate * 1000, true);
        }
        let (py, pm) = if month == 1 {
            (year - 1, 12)
        } else {
            (year, month - 1)
        };
        return (
            days_from_civil(py, pm, day_wanted.min(28) as i64) * 86_400 * 1000,
            true,
        );
    }
    // No evidence at all: calendar month.
    let current = civil(now_secs);
    let days = days_from_civil(current.0, current.1, 1);
    (days * 86_400 * 1000, false)
}

/// Build the report: window totals plus a per-model breakdown for the period.
///
/// `plan_id` selects which Go plan's per-model allowances the figures are
/// measured against (the store records no subscription, so the caller chooses);
/// `since_override` pins the period start (from `--period-start`); otherwise the
/// anniversary heuristic in [`month_start`] applies. Rows before the period are
/// anniversary evidence only — never listed — so the table and the totals agree.
pub fn build(
    rows: &[Row],
    catalog: &Catalog,
    plan_id: &str,
    now_ms: i64,
    since_override: Option<i64>,
    account: Option<&crate::ocuse::api::Usage>,
) -> Report {
    let (inferred_start, month_inferred) = month_start(rows, now_ms);
    let since_ms = since_override.unwrap_or(inferred_start);
    // The windows are cut at the *account's* bounds when it answered. Go meters a
    // 5-hour block with its own reset, a week anchored to Monday 00:00 UTC, and
    // the billing anniversary — none of which a rolling `now - length` cut
    // reproduces. Cutting locally and pairing those dollars with the server's
    // percentage put two different windows on one row: on real data the weekly
    // row read `$20.45 (0.0% of limit)`, because the store's rolling week still
    // held the previous week's spend while the server's week had just started.
    // Without an answer the rolling cuts stay — the only ones this machine knows.
    let (five_start, five_end) = match account.and_then(|a| a.rolling.reset_ms()) {
        Some(reset) => (reset - FIVE_HOUR_SECS * 1000, reset),
        None => (now_ms - FIVE_HOUR_SECS * 1000, i64::MAX),
    };
    let (week_start, week_end) = match account.and_then(|a| a.weekly.reset_ms()) {
        Some(reset) => (reset - WEEKLY_SECS * 1000, reset),
        None => (now_ms - WEEKLY_SECS * 1000, i64::MAX),
    };

    let mut report = Report {
        period_label: "calendar month".into(),
        now_ms,
        since_ms,
        period_end_ms: None,
        month_inferred: since_override.is_none() && month_inferred,
        account: None,
        account_error: None,
        five_hour: Totals::default(),
        weekly: Totals::default(),
        month: Totals::default(),
        models: Vec::new(),
    };
    let mut by_model: std::collections::BTreeMap<String, ModelUsage> = Default::default();

    for row in rows {
        // The month cut applies to the month, not to the loop: the account's week
        // is anchored to Monday, which can start before a mid-week billing
        // anniversary, and dropping those rows would undercount the very window
        // the server's percentage describes.
        let in_month = row.at_ms >= since_ms;
        let in_week = row.at_ms >= week_start && row.at_ms < week_end;
        let in_five = row.at_ms >= five_start && row.at_ms < five_end;
        if !in_month && !in_week && !in_five {
            continue;
        }
        // Priced on the documented schedule, not by opencode's recorded `cost`
        // (always the off-peak sheet). Falls back to the recorded figure for a
        // model with no rate rows, so a free or unknown model still counts zero
        // and an unpriced one still counts its own cost.
        let cost = catalog
            .price(
                &row.model,
                row.at_ms,
                row.input,
                row.output,
                row.reasoning,
                row.cache_read,
                row.cache_write,
            )
            .unwrap_or(row.cost_usd);
        if in_month {
            report.month.add(row, cost);
        }
        if in_week {
            report.weekly.add(row, cost);
        }
        if in_five {
            report.five_hour.add(row, cost);
        }
        let entry = by_model
            .entry(row.model.clone())
            .or_insert_with(|| ModelUsage {
                id: row.model.clone(),
                provider: row.provider.clone(),
                limit: catalog.go_limit(plan_id, &row.model),
                month: Totals::default(),
                five_hour: Totals::default(),
                weekly: Totals::default(),
            });
        if in_month {
            entry.month.add(row, cost);
        }
        if in_week {
            entry.weekly.add(row, cost);
        }
        if in_five {
            entry.five_hour.add(row, cost);
        }
    }

    report.models = by_model.into_values().collect();
    report.models.sort_by(|a, b| {
        b.month
            .cost
            .partial_cmp(&a.month.cost)
            .unwrap_or(std::cmp::Ordering::Equal)
            .then_with(|| a.id.cmp(&b.id))
    });
    report
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ocuse::db::Row;

    fn row(at_s: i64, model: &str, cost: f64) -> Row {
        Row {
            at_ms: at_s * 1000,
            provider: "opencode-go".into(),
            model: model.into(),
            cost_usd: cost,
            ..Default::default()
        }
    }

    /// An account meter with a reset instant, for the window-bound tests.
    fn meter(status: &str, percent: f64, resets_at: &str) -> crate::ocuse::api::Meter {
        crate::ocuse::api::Meter {
            status: status.into(),
            percent,
            resets_at: Some(resets_at.into()),
        }
    }

    /// ISO-8601 UTC for an epoch-seconds instant, so the meter's wire shape
    /// (`resetsAt`) is exercised rather than a pre-parsed value.
    fn iso(secs: i64) -> String {
        let rem = secs.rem_euclid(86_400);
        let (y, m, d) = civil(secs - rem);
        format!(
            "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.000Z",
            rem / 3600,
            (rem % 3600) / 60,
            rem % 60
        )
    }

    #[test]
    fn civil_helpers_round_trip() {
        // 2026-09-27T00:00:00Z
        let secs = days_from_civil(2026, 9, 27) * 86_400;
        assert_eq!(civil(secs), (2026, 9, 27));
        assert_eq!(day_of_month(secs), 27);
    }

    #[test]
    fn next_month_keeps_the_day_and_wraps_the_year() {
        // Same day-of-month, one month later, across a year boundary.
        let dec15 = days_from_civil(2026, 12, 15) * 86_400_000;
        assert_eq!(civil(next_month(dec15) / 1000), (2027, 1, 15));
        // A calendar-month start (day 1) lands on day 1 of the next month.
        let jan1 = days_from_civil(2026, 1, 1) * 86_400_000;
        assert_eq!(civil(next_month(jan1) / 1000), (2026, 2, 1));
        // A 31st start is clamped to 28, so it cannot skip a short month.
        let jan31 = days_from_civil(2026, 1, 31) * 86_400_000;
        assert_eq!(civil(next_month(jan31) / 1000), (2026, 2, 28));
    }

    #[test]
    fn prev_month_inverts_next_month_and_keeps_the_clock() {
        // The account API's monthly reset for this subscription.
        let reset = 1_794_604_475_000; // 2026-11-13T09:14:35Z
        let start = prev_month(reset);
        assert_eq!(civil(start / 1000), (2026, 10, 13));
        assert_eq!(start % 86_400_000, reset % 86_400_000, "time of day kept");
        // One month on from the derived start is the reset's date again.
        assert_eq!(civil(next_month(start) / 1000), (2026, 11, 13));
        // Year wrap, and the day clamp that keeps a 31st from skipping a month.
        let dec13 = days_from_civil(2026, 12, 13) * 86_400_000;
        assert_eq!(civil(prev_month(dec13) / 1000), (2026, 11, 13));
        let mar31 = days_from_civil(2026, 3, 31) * 86_400_000;
        assert_eq!(civil(prev_month(mar31) / 1000), (2026, 2, 28));
    }

    #[test]
    fn windows_are_rolling_and_the_month_is_inferred() {
        let now = days_from_civil(2026, 9, 27) * 86_400 + 12 * 3600;
        let rows = vec![
            row(now - 60, "glm-5.3-flash", 1.0),          // inside 5h
            row(now - 2 * 3600, "glm-5.3-flash", 2.0),    // inside 5h + week
            row(now - 3 * 86_400, "kimi-k3", 4.0),        // inside week only
            row(now - 40 * 86_400, "glm-5.3-flash", 8.0), // previous cycle: anniversary evidence
        ];
        let catalog = crate::ocuse::zen::catalog();
        let report = build(
            &rows,
            &catalog,
            crate::ocuse::zen::DEFAULT_PLAN,
            now * 1000,
            None,
            None,
        );
        assert!(
            report.month_inferred,
            "anniversary inferred from the earliest row"
        );
        assert!((report.five_hour.cost - 3.0).abs() < 1e-9);
        assert!((report.weekly.cost - 7.0).abs() < 1e-9);
        // anniversary day = 18 (40 days before the 27th), so this cycle starts Sep 18
        assert!(
            (report.month.cost - 7.0).abs() < 1e-9,
            "month cost {}",
            report.month.cost
        );
        let flash = report
            .models
            .iter()
            .find(|m| m.id == "glm-5.3-flash")
            .unwrap();
        assert_eq!(flash.limit, Some(60.0));
        assert!((flash.five_hour.cost - 3.0).abs() < 1e-9);
    }

    /// The account's own bounds decide the windows when it answered. Go's 5-hour
    /// window is a *block* with its own reset and its week starts Monday 00:00
    /// UTC, so a rolling `now - length` cut describes a different span — the bug
    /// that printed `$20.45 (0.0% of limit)` on a row whose console share had
    /// just started counting.
    #[test]
    fn the_accounts_bounds_cut_the_windows_when_it_answered() {
        let now = days_from_civil(2026, 10, 19) * 86_400 + 12 * 3600; // Mon noon UTC
        let week_reset = now + 6 * 86_400 + 12 * 3600; // next Monday 00:00
        let five_reset = now + 3 * 3600;
        let five_start = five_reset - FIVE_HOUR_SECS;
        let rows = vec![
            // Before the server's week (so outside it) but inside a rolling 7d.
            row(now - 6 * 86_400, "glm-5.3-flash", 9.0),
            // Inside the server's week but outside its 5-hour block (which
            // starts two hours back), so the two cuts are distinguishable.
            row(now - 3 * 3600, "glm-5.3-flash", 2.0),
            // Inside the server's 5-hour block.
            row(five_start + 60, "glm-5.3-flash", 1.0),
        ];
        let account = crate::ocuse::api::Usage {
            rolling: meter("ok", 6.0, &iso(five_reset)),
            weekly: meter("ok", 3.0, &iso(week_reset)),
            monthly: meter("ok", 51.0, &iso(now + 20 * 86_400)),
        };
        let report = build(
            &rows,
            &crate::ocuse::zen::catalog(),
            crate::ocuse::zen::DEFAULT_PLAN,
            now * 1000,
            None,
            Some(&account),
        );
        // The rolling 7d cut would have counted the 6-day-old row ($9, so 12.0
        // here); the server's Monday week starts 12h ago and must not.
        assert!(
            (report.weekly.cost - 3.0).abs() < 1e-9,
            "weekly {}",
            report.weekly.cost
        );
        assert!(
            (report.five_hour.cost - 1.0).abs() < 1e-9,
            "5h {}",
            report.five_hour.cost
        );
        // …and the same cut applies to the per-model rows the panel pairs with.
        let flash = report
            .models
            .iter()
            .find(|m| m.id == "glm-5.3-flash")
            .unwrap();
        assert!((flash.weekly.cost - 3.0).abs() < 1e-9);
        assert!((flash.five_hour.cost - 1.0).abs() < 1e-9);
        // The month is untouched by the window cuts: it still sees every row in
        // the period, including the one the server's week excluded.
        assert!((report.month.cost - 12.0).abs() < 1e-9);
    }

    #[test]
    fn models_sort_by_month_spend() {
        let now = days_from_civil(2026, 9, 27) * 86_400;
        let rows = vec![row(now - 60, "cheap", 0.1), row(now - 60, "pricey", 5.0)];
        let report = build(
            &rows,
            &crate::ocuse::zen::catalog(),
            crate::ocuse::zen::DEFAULT_PLAN,
            now * 1000,
            None,
            None,
        );
        assert_eq!(report.models[0].id, "pricey");
    }
}