Skip to main content

cmduse_core/
lib.rs

1pub mod dates;
2pub mod wire;
3pub use dates::parse_iso_utc;
4pub use wire::{
5    Credits, CreditsResp, SubData, SubscriptionsResp, UsageSummary, Window, WindowLimits,
6};
7
8// Plan table, name rules, and monthly caps generated from plans.json
9// (single source shared with the TypeScript opencode plugin).
10include!(concat!(env!("OUT_DIR"), "/plans.rs"));
11
12/// Plan display name from the API's planId (e.g. "individual-goat" → "GOAT").
13/// First rule whose needles are all contained in the lowercased id wins.
14pub fn plan_name(plan_id: &str) -> &'static str {
15    let id = plan_id.to_lowercase();
16    for (needles, name) in NAME_RULES {
17        if needles.iter().all(|n| id.contains(n)) {
18            return name;
19        }
20    }
21    DEFAULT_NAME
22}
23
24/// True when `plan_id` matched a NAME_RULES entry rather than falling through
25/// to DEFAULT_NAME. Callers warn on false: the API returned a plan we don't
26/// know, so its name/cap may be wrong until `plans.json` is updated.
27pub fn plan_rule_matched(plan_id: &str) -> bool {
28    let id = plan_id.to_lowercase();
29    NAME_RULES
30        .iter()
31        .any(|(needles, _)| needles.iter().all(|n| id.contains(n)))
32}
33
34/// Monthly credit pool per plan. One shared pool per plan (verified — the
35/// docs' per-model allowances are not what the API meters). None = PAYG.
36pub fn plan_monthly_cap(plan_id: &str) -> Option<f64> {
37    let name = plan_name(plan_id);
38    CAPS.iter().find(|(n, _)| *n == name).and_then(|(_, c)| *c)
39}
40
41pub fn money(v: f64) -> String {
42    // Round to cents half-away-from-zero before formatting: `{:.2}` alone is
43    // round-half-to-even, JS `toFixed` is half-away, so a .x5 tie (0.125)
44    // disagreed ("$0.12" vs "$0.13"). Matches the opencode port.
45    format!("${:.2}", (v * 100.0).round() / 100.0)
46}
47
48/// Rolling-window lengths — the API serves only `resetAt`, the length is
49/// implied by the window name.
50pub const FIVE_HOUR_SECS: u64 = 5 * 3600;
51pub const WEEKLY_SECS: u64 = 7 * 86400;
52
53/// Assemble the monthly usage window from the plan cap and subscription
54/// period: used = cap − remaining (clamped to [0, cap]), reset = period end,
55/// duration = period length. Returns (window, duration secs). Single source
56/// for cli + zed; the opencode TS port mirrors it via conformance vectors.
57pub fn monthly_window(
58    cap: f64,
59    remaining: f64,
60    period_start: Option<&str>,
61    period_end: Option<&str>,
62) -> (Window, Option<u64>) {
63    let used = (cap - remaining).clamp(0.0, cap);
64    let reset_at = period_end.and_then(parse_iso_utc);
65    let dur = match (period_start, period_end) {
66        (Some(st), Some(en)) => parse_iso_utc(st).zip(parse_iso_utc(en)).and_then(|(a, b)| {
67            // A period that ends before it starts (API glitch) must not wrap
68            // the u64 cast; equal timestamps keep the 1s floor the vectors pin.
69            if b < a {
70                None
71            } else {
72                Some(((b - a) as u64 / 1000).max(1))
73            }
74        }),
75        _ => None,
76    };
77    (
78        Window {
79            used,
80            cap,
81            exceeded: false,
82            reset_at,
83        },
84        dur,
85    )
86}
87
88/// Compact thousands/millions. Rounds half-away-from-zero explicitly before
89/// formatting: `{:.1}` alone is round-half-to-even, which disagrees with JS
90/// `Math.round` at exact `.x5` ties (1_250_000 → "1.2M" vs "1.3M").
91pub fn compact(n: u64) -> String {
92    let round1 = |v: f64| (v * 10.0).round() / 10.0;
93    // >=999_950 rounds to 1000.0K under the K bucket, so promote to M first.
94    if n >= 999_950 {
95        format!("{:.1}M", round1(n as f64 / 1_000_000.0))
96    } else if n >= 1_000 {
97        format!("{:.1}K", round1(n as f64 / 1_000.0))
98    } else {
99        format!("{n}")
100    }
101}
102
103/// Percent string of used/cap: "34%" or "—" when cap unknown. Rounds
104/// half-away-from-zero (`.round()`), matching JS `Math.round` in the opencode
105/// port; `format!("{:.0}")` alone is half-to-even and drifts at exact .5.
106pub fn pct(used: f64, cap: f64) -> String {
107    if cap > 0.0 {
108        format!("{:.0}%", ((used / cap) * 100.0).round())
109    } else {
110        "—".into()
111    }
112}
113
114/// Compact "Xh Ym" / "Xd Yh" for a span of seconds. Pure duration — no
115/// absolute time involved; use this (not `rel_time`) for ETAs/countdowns.
116pub fn duration(secs: u64) -> String {
117    let d = secs / 86400;
118    let h = (secs % 86400) / 3600;
119    let m = (secs % 3600) / 60;
120    if d > 0 {
121        format!("{d}d {h}h")
122    } else if h > 0 {
123        format!("{h}h {m}m")
124    } else if m > 0 {
125        format!("{m}m")
126    } else {
127        "<1m".into()
128    }
129}
130
131/// Compact "Xh Ym" human time until `reset_at` (epoch ms). `now` may be None
132/// (zed ext without a clock) → falls back to a raw epoch stamp.
133pub fn rel_time(reset_at: Option<f64>, now: Option<u64>) -> String {
134    let Some(reset_ms) = reset_at else {
135        return "unknown".into();
136    };
137    let Some(now_s) = now else {
138        return format!("epoch {}", reset_ms as u64 / 1000);
139    };
140    let reset_s = reset_ms as u64 / 1000;
141    if reset_s <= now_s {
142        // resetAt already passed (clock skew or window rolling over);
143        // next fetch will pick up the fresh window
144        return "resetting…".into();
145    }
146    duration(reset_s - now_s)
147}
148
149/// Elapsed % of a rolling window: window length = dur_secs, ends at reset_at.
150pub fn elapsed_pct(reset_at: Option<f64>, dur_secs: u64, now: Option<u64>) -> Option<u8> {
151    let reset_ms = reset_at?;
152    let now_s = now?;
153    let reset_s = reset_ms as u64 / 1000;
154    let start = reset_s.checked_sub(dur_secs)?;
155    if now_s < start {
156        return None; // window hasn't started
157    }
158    let elapsed = now_s - start;
159    let pct = (elapsed as f64 / dur_secs as f64 * 100.0).clamp(0.0, 100.0);
160    Some(pct.round() as u8)
161}
162
163/// Seconds until spend hits cap at the current rate, if that lands before the
164/// window resets. None = no warning. Suppressed before 10% of the window has
165/// elapsed — the flat-rate projection is unreliable that early.
166pub fn pace_eta(
167    reset_at: Option<f64>,
168    dur_secs: u64,
169    used: f64,
170    cap: f64,
171    now: u64,
172) -> Option<f64> {
173    let reset_ms = reset_at?;
174    let reset_s = reset_ms as u64 / 1000;
175    let start = reset_s.checked_sub(dur_secs)?;
176    if now <= start || now >= reset_s {
177        return None; // window not started or already rolling over
178    }
179    let elapsed = (now - start) as f64;
180    if elapsed / (dur_secs as f64) < 0.10 {
181        return None; // too early in window: flat-rate ETA unreliable
182    }
183    let rate = used / elapsed; // $/sec
184    if rate <= 0.0 || used >= cap {
185        return None;
186    }
187    let secs_to_cap = (cap - used) / rate;
188    if secs_to_cap >= (reset_s - now) as f64 {
189        return None; // won't hit cap before reset
190    }
191    Some(secs_to_cap)
192}
193
194// ---- plan-based model gating (tables from gating.json) ----
195// Mirrors the opencode plugin's evaluateModelAccess; conformance vectors pin
196// the two ports together. Unknown plan/model default to allowed — the API
197// enforces the real gate.
198
199fn strip_date(s: &str) -> String {
200    let b = s.as_bytes();
201    if b.len() > 9 {
202        let sep = b[b.len() - 9];
203        if (sep == b'-' || sep == b'@') && b[b.len() - 8..].iter().all(u8::is_ascii_digit) {
204            return s[..s.len() - 9].to_string();
205        }
206    }
207    s.to_string()
208}
209
210/// Exact known id (case-insensitive), else alias target, else date-stripped.
211pub fn canonical_model(model: &str) -> String {
212    let lower = model.to_lowercase();
213    if let Some(k) = GATE_KNOWN.iter().find(|m| m.to_lowercase() == lower) {
214        return (*k).to_string();
215    }
216    if let Some((_, to)) = GATE_ALIASES.iter().find(|(from, _)| *from == lower) {
217        return GATE_KNOWN
218            .iter()
219            .find(|m| m.to_lowercase() == to.to_lowercase())
220            .map(|m| (*m).to_string())
221            .unwrap_or_else(|| model.to_string());
222    }
223    let stripped = strip_date(model).to_lowercase();
224    GATE_KNOWN
225        .iter()
226        .find(|m| m.to_lowercase() == stripped)
227        .map(|m| (*m).to_string())
228        .unwrap_or_else(|| model.to_string())
229}
230
231/// Table category, else the longest known id that prefixes it (a version bump
232/// of a known model inherits its category; otherwise None).
233fn model_category(model: &str) -> Option<&'static str> {
234    let lower = canonical_model(bare_model(model)).to_lowercase();
235    if let Some((_, c)) = GATE_CATEGORIES
236        .iter()
237        .find(|(m, _)| m.to_lowercase() == lower)
238    {
239        return Some(c);
240    }
241    let mut best: Option<(&str, &str)> = None;
242    for (m, c) in GATE_CATEGORIES {
243        if lower.starts_with(&m.to_lowercase()) && best.is_none_or(|(bm, _)| m.len() > bm.len()) {
244            best = Some((m, c));
245        }
246    }
247    best.map(|(_, c)| c)
248}
249
250pub fn gate_allowed(model: &str, plan_id: &str, unlocked: bool) -> bool {
251    gate(model, plan_id, unlocked).0
252}
253
254/// Bare model id with any provider qualifier stripped: everything after the
255/// FIRST colon. blockedModels entries are provider-qualified
256/// ("anthropic:claude-opus-5"); we only serve via command-code lanes, so match
257/// on the id portion. A model id that itself contains ':' keeps it.
258pub fn bare_model(blocked: &str) -> &str {
259    blocked
260        .split_once(':')
261        .map(|(_, rest)| rest)
262        .unwrap_or(blocked)
263}
264
265/// Access decision plus a short human reason (for `models --gated --json`).
266pub fn gate(model: &str, plan_id: &str, unlocked: bool) -> (bool, &'static str) {
267    if unlocked {
268        return (true, "credits unlock all models");
269    }
270    if plan_id.is_empty() {
271        return (true, "unknown plan");
272    }
273    // Strip any provider qualifier first: a caller may pass
274    // "anthropic:claude-opus-5" while the block tables store both forms.
275    let canonical = canonical_model(bare_model(model));
276    let cl = canonical.to_lowercase();
277    if let Some((_, list)) = GATE_HARD_BLOCKED.iter().find(|(p, _)| *p == plan_id) {
278        if list.iter().any(|m| m.to_lowercase() == cl) {
279            return (false, "blocked for this plan");
280        }
281    }
282    let Some((_, allowed, blocked)) = GATE_PLANS.iter().find(|(p, _, _)| *p == plan_id) else {
283        return (true, "plan has no restrictions");
284    };
285    let Some(cat) = model_category(model) else {
286        return (true, "unknown model category");
287    };
288    if blocked.iter().any(|b| bare_model(b).to_lowercase() == cl) {
289        return (false, "blocked for this plan");
290    }
291    if !allowed.contains(&cat) {
292        return (false, "premium model, plan is open-models-only");
293    }
294    (true, "allowed")
295}
296
297/// Age of the gating snapshot in days, from `gating.json`'s `extractedAt`.
298/// None when the metadata is absent/unparseable (pre-metadata snapshots).
299pub fn gate_age_days(now_secs: u64) -> Option<u64> {
300    let ms = dates::parse_iso_utc(GATE_EXTRACTED_AT)?;
301    Some(now_secs.saturating_sub((ms / 1000.0) as u64) / 86400)
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn plan_names() {
310        assert_eq!(plan_name("individual-goat"), "GOAT");
311        assert_eq!(plan_name("individual-max-20"), "Max 20x");
312        assert_eq!(plan_name("individual-max-10"), "Max 10x");
313        assert_eq!(plan_name("individual-go"), "Go");
314        assert_eq!(plan_name("teams-pro"), "Team Pro");
315        assert_eq!(plan_name("individual-provider"), "Provider");
316        assert_eq!(plan_name("bogus"), "Free");
317    }
318
319    #[test]
320    fn plan_rule_match_and_gate_age() {
321        assert!(plan_rule_matched("individual-goat"));
322        assert!(plan_rule_matched("teams-pro"));
323        assert!(!plan_rule_matched("free"));
324        assert!(!plan_rule_matched("bogus"));
325        let secs = (parse_iso_utc(GATE_EXTRACTED_AT).expect("extractedAt ISO") / 1000.0) as u64;
326        assert_eq!(gate_age_days(secs), Some(0));
327        assert_eq!(gate_age_days(secs + 40 * 86400), Some(40));
328    }
329
330    #[test]
331    fn monthly_caps() {
332        assert_eq!(plan_monthly_cap("individual-goat"), Some(70.0));
333        assert_eq!(plan_monthly_cap("individual-go"), Some(10.0));
334        assert_eq!(plan_monthly_cap("individual-pro"), Some(80.0));
335        assert_eq!(plan_monthly_cap("individual-max-10"), Some(150.0));
336        assert_eq!(plan_monthly_cap("individual-max-20"), Some(300.0));
337        assert_eq!(plan_monthly_cap("teams-pro"), Some(40.0));
338        assert_eq!(plan_monthly_cap("individual-provider"), None);
339    }
340
341    #[test]
342    fn money_and_compact() {
343        assert_eq!(money(0.0), "$0.00");
344        assert_eq!(money(45.689), "$45.69");
345        assert_eq!(compact(999), "999");
346        assert_eq!(compact(2_300), "2.3K");
347        assert_eq!(compact(1_250), "1.3K"); // half-away, not half-even
348        assert_eq!(compact(1_250_000), "1.3M");
349        assert_eq!(compact(316_700_000), "316.7M");
350    }
351
352    #[test]
353    fn rel_time_human() {
354        assert_eq!(rel_time(None, Some(0)), "unknown");
355        assert_eq!(rel_time(Some(59_000.0), Some(0)), "<1m");
356        assert_eq!(rel_time(Some(120_000.0), Some(60)), "1m");
357        assert_eq!(rel_time(Some(7_200_000.0), Some(0)), "2h 0m");
358        assert_eq!(rel_time(Some(136_800_000.0), Some(0)), "1d 14h");
359        // reset already passed
360        assert_eq!(rel_time(Some(1_000.0), Some(100)), "resetting…");
361        // no clock → raw epoch
362        assert_eq!(rel_time(Some(1_000.0), None), "epoch 1");
363    }
364
365    #[test]
366    fn elapsed_pct_windows() {
367        let now = 1_000_000u64;
368        let reset = (now as f64 + 2.5 * 3600.0) * 1000.0;
369        assert_eq!(elapsed_pct(Some(reset), 5 * 3600, Some(now)), Some(50));
370        assert_eq!(elapsed_pct(Some(1.0), 3600, Some(now)), None);
371        assert_eq!(elapsed_pct(None, 3600, Some(now)), None);
372    }
373
374    #[test]
375    fn iso_offsets_convert_to_utc() {
376        // same civil instant expressed with explicit offsets == Z epoch.
377        let base = parse_iso_utc("2026-09-27T12:00:00.000Z").unwrap();
378        assert_eq!(parse_iso_utc("2026-09-27T12:00:00+00:00").unwrap(), base);
379        assert_eq!(parse_iso_utc("2026-09-27T07:00:00-05:00").unwrap(), base);
380        assert_eq!(parse_iso_utc("2026-09-27T19:30:00+07:30").unwrap(), base);
381        assert_eq!(parse_iso_utc("2026-09-27T07:00:00-0500").unwrap(), base);
382        // bad offsets → None
383        assert!(parse_iso_utc("2026-09-27T12:00:00X").is_none());
384    }
385
386    #[test]
387    fn pace_warns_only_after_10pct_elapsed() {
388        let now = 1_000_000u64;
389        let d = 5 * 3600;
390        // 5% elapsed, spend rate would hit cap → suppressed (too early).
391        let start5 = now - d / 20;
392        let early = pace_eta(Some((start5 + d) as f64 * 1000.0), d, 5.0, 10.0, now);
393        assert!(early.is_none(), "must not warn at 5% elapsed: {early:?}");
394        // 10% elapsed, same spend rate → pace warning shown.
395        let start10 = now - d / 10;
396        let at10 = pace_eta(Some((start10 + d) as f64 * 1000.0), d, 5.0, 10.0, now);
397        assert_eq!(at10, Some(1800.0));
398        // won't hit cap before reset → suppressed.
399        let start50 = now - d / 2;
400        let fine = pace_eta(Some((start50 + d) as f64 * 1000.0), d, 1.0, 10.0, now);
401        assert!(fine.is_none());
402        // already over cap → no negative ETA, no warning next to LIMIT EXCEEDED.
403        let over = pace_eta(Some((start50 + d) as f64 * 1000.0), d, 12.0, 10.0, now);
404        assert!(over.is_none(), "over-cap window must not project: {over:?}");
405    }
406
407    /// Shared vectors (conformance.json) that the opencode TypeScript port must
408    /// also satisfy — keeps the two implementations from drifting.
409    #[test]
410    fn conformance_vectors() {
411        let v: serde_json::Value =
412            serde_json::from_str(include_str!("../conformance.json")).unwrap();
413        for c in v["money"].as_array().unwrap() {
414            assert_eq!(money(c["in"].as_f64().unwrap()), c["out"].as_str().unwrap());
415        }
416        for c in v["compact"].as_array().unwrap() {
417            assert_eq!(
418                compact(c["in"].as_u64().unwrap()),
419                c["out"].as_str().unwrap()
420            );
421        }
422        for c in v["pct"].as_array().unwrap() {
423            assert_eq!(
424                pct(c["used"].as_f64().unwrap(), c["cap"].as_f64().unwrap()),
425                c["out"].as_str().unwrap(),
426                "pct {}/{}",
427                c["used"],
428                c["cap"]
429            );
430        }
431        for c in v["bareModel"].as_array().unwrap() {
432            assert_eq!(
433                bare_model(c["in"].as_str().unwrap()),
434                c["out"].as_str().unwrap(),
435                "bareModel {}",
436                c["in"]
437            );
438        }
439        for c in v["canonicalize"].as_array().unwrap() {
440            assert_eq!(
441                canonical_model(c["in"].as_str().unwrap()),
442                c["out"].as_str().unwrap(),
443                "canonicalize {}",
444                c["in"]
445            );
446        }
447        for c in v["elapsedPct"].as_array().unwrap() {
448            let reset = if c["resetAtMs"].is_null() {
449                None
450            } else {
451                Some(c["resetAtMs"].as_f64().unwrap())
452            };
453            let got = elapsed_pct(
454                reset,
455                c["durSecs"].as_u64().unwrap(),
456                Some(c["now"].as_u64().unwrap()),
457            );
458            let want = if c["out"].is_null() {
459                None
460            } else {
461                Some(c["out"].as_u64().unwrap() as u8)
462            };
463            assert_eq!(got, want, "elapsedPct {}", c["durSecs"]);
464        }
465        for c in v["paceEta"].as_array().unwrap() {
466            let reset = if c["resetAtMs"].is_null() {
467                None
468            } else {
469                Some(c["resetAtMs"].as_f64().unwrap())
470            };
471            let got = pace_eta(
472                reset,
473                c["durSecs"].as_u64().unwrap(),
474                c["used"].as_f64().unwrap(),
475                c["cap"].as_f64().unwrap(),
476                c["now"].as_u64().unwrap(),
477            );
478            let want = c["outSecs"].as_f64();
479            assert_eq!(got, want, "paceEta used={} cap={}", c["used"], c["cap"]);
480        }
481        for c in v["relTime"].as_array().unwrap() {
482            let reset = if c["resetAtMs"].is_null() {
483                None
484            } else {
485                Some(c["resetAtMs"].as_f64().unwrap())
486            };
487            assert_eq!(
488                rel_time(reset, Some(c["now"].as_u64().unwrap())),
489                c["out"].as_str().unwrap()
490            );
491        }
492        for c in v["parseIso"].as_array().unwrap() {
493            let got = parse_iso_utc(c["in"].as_str().unwrap());
494            let want = if c["outMs"].is_null() {
495                None
496            } else {
497                Some(c["outMs"].as_f64().unwrap())
498            };
499            assert_eq!(got, want, "parse {}", c["in"]);
500        }
501        for c in v["duration"].as_array().unwrap() {
502            assert_eq!(
503                duration(c["in"].as_u64().unwrap()),
504                c["out"].as_str().unwrap()
505            );
506        }
507        for c in v["monthlyWindow"].as_array().unwrap() {
508            let (w, dur) = monthly_window(
509                c["cap"].as_f64().unwrap(),
510                c["remaining"].as_f64().unwrap(),
511                c["periodStart"].as_str(),
512                c["periodEnd"].as_str(),
513            );
514            assert_eq!(w.used, c["used"].as_f64().unwrap(), "monthlyWindow used");
515            let reset = if c["resetAtMs"].is_null() {
516                None
517            } else {
518                Some(c["resetAtMs"].as_f64().unwrap())
519            };
520            assert_eq!(w.reset_at, reset, "monthlyWindow resetAtMs");
521            let want_dur = if c["durSecs"].is_null() {
522                None
523            } else {
524                Some(c["durSecs"].as_u64().unwrap())
525            };
526            assert_eq!(dur, want_dur, "monthlyWindow durSecs");
527        }
528        for c in v["plan"].as_array().unwrap() {
529            let id = c["id"].as_str().unwrap();
530            assert_eq!(plan_name(id), c["name"].as_str().unwrap(), "name {id}");
531            assert_eq!(plan_monthly_cap(id), c["cap"].as_f64(), "cap {id}");
532            assert_eq!(
533                plan_rule_matched(id),
534                c["matched"].as_bool().unwrap(),
535                "matched {id}"
536            );
537        }
538        for c in v["gating"].as_array().unwrap() {
539            let model = c["model"].as_str().unwrap();
540            let plan = c["plan"].as_str().unwrap();
541            let unlocked = c["unlocked"].as_bool().unwrap();
542            assert_eq!(
543                gate_allowed(model, plan, unlocked),
544                c["allowed"].as_bool().unwrap(),
545                "gate {model} / {plan} / unlocked={unlocked}"
546            );
547        }
548    }
549}