Skip to main content

cmd_usage/ocuse/
zen.rs

1// The docs catalogue: what a model costs and what each *plan* allows.
2//
3// Generated by `scripts/extract-zen.ts` (run `bun scripts/extract-zen.ts`) from
4// OpenCode's own docs sources, so the numbers move with upstream instead of
5// living in code. Go ships two plans (Go $10/mo, Go Plus $40/mo) that share one
6// token price list but grant each model a different monthly dollar limit; Zen is
7// pay-as-you-go, so it has rates but no limits.
8//
9// Which plan you are on is not discoverable: the usage endpoint reports the
10// meters' percentages, not a plan id, and the local store records no
11// subscription, so `ocuse` takes a `--plan` flag and defaults to
12// [`DEFAULT_PLAN`].
13use serde::Deserialize;
14use std::collections::BTreeMap;
15
16// Re-exported from `cmduse-core` so the path stays inside its own crate (a
17// `include_str!` reaching outside is fine locally and fatal at `cargo publish`).
18const RAW: &str = cmduse_core::ZEN_JSON;
19
20/// The plan `ocuse` measures against when `--plan` is not given.
21pub const DEFAULT_PLAN: &str = "go";
22
23#[derive(Debug, Clone, Deserialize)]
24pub struct RateVariant {
25    pub peak: Option<String>,
26    #[serde(rename = "tierMaxTokens")]
27    pub tier_max_tokens: Option<u64>,
28    #[serde(rename = "tierMinTokens")]
29    pub tier_min_tokens: Option<u64>,
30    pub input: f64,
31    pub output: f64,
32    #[serde(rename = "cacheRead")]
33    pub cache_read: f64,
34    #[serde(rename = "cacheWrite")]
35    pub cache_write: f64,
36}
37
38#[derive(Debug, Clone, Deserialize)]
39pub struct Model {
40    pub id: String,
41    pub name: String,
42    pub variants: Vec<RateVariant>,
43}
44
45#[derive(Debug, Clone, Deserialize)]
46pub struct WindowShare {
47    #[serde(rename = "fiveHour")]
48    pub five_hour: f64,
49    pub weekly: f64,
50    pub monthly: f64,
51}
52
53/// One Go plan: a price plus a monthly dollar limit per model id.
54#[derive(Debug, Clone, Deserialize)]
55pub struct GoPlan {
56    pub label: String,
57    #[serde(rename = "priceUsd")]
58    pub price_usd: f64,
59    /// model id -> monthly dollar limit; a missing id is free/unlimited.
60    #[serde(rename = "allowanceByModel")]
61    pub allowance_by_model: BTreeMap<String, f64>,
62}
63
64#[derive(Debug, Clone, Deserialize)]
65pub struct Go {
66    #[serde(rename = "windowShare")]
67    pub window_share: WindowShare,
68    /// Token rates, shared across plans (pricing is the same for both).
69    pub models: Vec<Model>,
70    /// Keyed by plan slug ("go", "go-plus"); only the allowances differ.
71    pub plans: BTreeMap<String, GoPlan>,
72}
73
74#[derive(Debug, Clone, Deserialize)]
75pub struct Zen {
76    pub models: Vec<Model>,
77}
78
79#[derive(Debug, Clone, Deserialize)]
80pub struct Catalog {
81    #[serde(rename = "extractedAt")]
82    pub extracted_at: String,
83    pub go: Go,
84    pub zen: Zen,
85}
86
87/// Parsed once; the file is a compile-time constant.
88pub fn catalog() -> Catalog {
89    serde_json::from_str(RAW)
90        .expect("core/zen.json is generated; regenerate with scripts/extract-zen.ts")
91}
92
93impl Catalog {
94    /// The plan ids the docs define, in the map's (alphabetical) order — "go"
95    /// sorts before "go-plus", which is the order they read in the docs.
96    pub fn plan_ids(&self) -> Vec<&str> {
97        self.go.plans.keys().map(String::as_str).collect()
98    }
99
100    pub fn plan(&self, plan_id: &str) -> Option<&GoPlan> {
101        self.go.plans.get(plan_id)
102    }
103
104    /// Monthly dollar limit for a model under one plan, if the docs list one.
105    /// None for a free/unlimited model, an unknown model, or an unknown plan.
106    pub fn go_limit(&self, plan_id: &str, model_id: &str) -> Option<f64> {
107        self.plan(plan_id)
108            .and_then(|plan| plan.allowance_by_model.get(model_id).copied())
109    }
110}
111
112/// Suggested cap for a rolling window, from the documented share of the limit.
113pub fn window_cap(limit: f64, share: f64) -> f64 {
114    limit * share
115}
116
117/// Is this instant inside a DeepSeek peak window?
118///
119/// The docs give it exactly: 01:00–04:00 and 06:00–10:00 UTC, Monday–Friday;
120/// every other hour, weekends included, is off-peak. The console applies it
121/// (its per-request cost for a peak-hour turn is the peak sheet), while opencode
122/// records the off-peak sheet for **every** turn — which is why this store reads
123/// ~40% under the console on peak-heavy days and why the total is priced here
124/// instead of trusting `cost`.
125pub fn is_peak(at_ms: i64) -> bool {
126    let secs = at_ms.div_euclid(1000);
127    // Sunday = 0 (`+ 4` because 1970-01-01 was a Thursday), so the weekend is
128    // 0 and 6 — not `>= 5`, which is Friday and Saturday under this base and
129    // silently priced every Friday turn at the off-peak sheet (and every Sunday
130    // at the peak one).
131    let weekday = (secs.div_euclid(86_400) + 4).rem_euclid(7);
132    if weekday == 0 || weekday == 6 {
133        return false;
134    }
135    let hour = secs.rem_euclid(86_400) / 3600;
136    (1..4).contains(&hour) || (6..10).contains(&hour)
137}
138
139impl Catalog {
140    /// What one turn costs on the documented sheets, or `None` when the model is
141    /// unknown or free (no rate rows), so the caller can fall back to `cost`.
142    ///
143    /// `reasoning` is billed at the output rate — opencode does the same in its
144    /// own `cost`, verified to the cent on real rows.
145    #[allow(clippy::too_many_arguments)]
146    pub fn price(
147        &self,
148        model_id: &str,
149        at_ms: i64,
150        input: u64,
151        output: u64,
152        reasoning: u64,
153        cache_read: u64,
154        cache_write: u64,
155    ) -> Option<f64> {
156        let model = self.go.models.iter().find(|m| m.id == model_id)?;
157        let peak = model
158            .variants
159            .iter()
160            .find(|v| v.peak.as_deref() == Some("peak"));
161        let off_peak = model
162            .variants
163            .iter()
164            .find(|v| v.peak.as_deref() == Some("off-peak"));
165        let rates = match (peak, off_peak) {
166            (Some(peak), Some(_)) if is_peak(at_ms) => peak,
167            (Some(_), Some(off)) => off,
168            // No time-of-day split (or a single sheet): the only variant wins.
169            _ => model.variants.first()?,
170        };
171        let cost = input as f64 * rates.input
172            + (output + reasoning) as f64 * rates.output
173            + cache_read as f64 * rates.cache_read
174            + cache_write as f64 * rates.cache_write;
175        (cost > 0.0).then_some(cost / 1e6)
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn peak_window_is_weekday_utc_hours_only() {
185        let at = |s: &str| cmduse_core::dates::parse_iso_utc(s).expect(s) as i64;
186        // Friday 2026-10-16 is a weekday: both windows apply, edges exclusive.
187        assert!(is_peak(at("2026-10-16T02:00:00Z")));
188        assert!(is_peak(at("2026-10-16T09:59:00Z")));
189        assert!(!is_peak(at("2026-10-16T10:00:00Z")));
190        assert!(!is_peak(at("2026-10-16T05:00:00Z")), "gap between windows");
191        assert!(!is_peak(at("2026-10-16T00:59:00Z")));
192        // Weekends are off-peak whatever the hour — Sunday included, which a
193        // `weekday >= 5` test left inside the peak branch.
194        assert!(!is_peak(at("2026-10-17T02:00:00Z")), "Saturday");
195        assert!(!is_peak(at("2026-10-18T02:00:00Z")), "Sunday");
196        // Monday is a weekday again.
197        assert!(is_peak(at("2026-10-19T02:00:00Z")));
198
199        // And the price follows it: 1M input tokens on Flash is the peak sheet
200        // ($0.30) on Friday, the off-peak one ($0.15) on Sunday.
201        let c = catalog();
202        let flash = |ts: &str| c.price("deepseek-v4.1-flash", at(ts), 1_000_000, 0, 0, 0, 0);
203        assert_eq!(flash("2026-10-16T02:00:00Z"), Some(0.3));
204        assert_eq!(flash("2026-10-18T02:00:00Z"), Some(0.15));
205    }
206
207    #[test]
208    fn catalogue_parses_and_carries_both_plans() {
209        let c = catalog();
210        assert!(c.go.models.len() > 20, "go models: {}", c.go.models.len());
211        assert!(!c.zen.models.is_empty());
212        assert_eq!(c.plan_ids(), vec!["go", "go-plus"]);
213        assert_eq!(c.plan("go").map(|p| p.price_usd), Some(10.0));
214        assert_eq!(c.plan("go-plus").map(|p| p.price_usd), Some(40.0));
215        assert_eq!(c.go_limit("go", "glm-5.3-flash"), Some(60.0));
216        assert_eq!(c.go_limit("go-plus", "glm-5.3-flash"), Some(180.0));
217        assert_eq!(c.go_limit("go", "nope-not-a-model"), None);
218        assert_eq!(c.go_limit("nope-not-a-plan", "glm-5.3-flash"), None);
219    }
220
221    #[test]
222    fn plans_share_the_model_set_and_differ_on_allowance() {
223        let c = catalog();
224        let go = c.plan("go").expect("go");
225        let plus = c.plan("go-plus").expect("go-plus");
226        assert_eq!(go.label, "Go");
227        assert_eq!(plus.label, "Go Plus");
228        assert_eq!(go.allowance_by_model.len(), plus.allowance_by_model.len());
229        // Rates are shared, so every allowance model is in the other plan too,
230        // and Go Plus grants more on at least one of them.
231        assert!(plus
232            .allowance_by_model
233            .iter()
234            .all(|(id, _)| go.allowance_by_model.contains_key(id)));
235        assert!(plus
236            .allowance_by_model
237            .iter()
238            .any(|(id, limit)| go.allowance_by_model.get(id).is_some_and(|g| limit > g)));
239    }
240
241    #[test]
242    fn window_split_matches_the_docs() {
243        let c = catalog();
244        assert_eq!(c.go.window_share.five_hour, 0.2);
245        assert_eq!(c.go.window_share.weekly, 0.5);
246        assert_eq!(c.go.window_share.monthly, 1.0);
247        assert_eq!(window_cap(60.0, c.go.window_share.five_hour), 12.0);
248    }
249
250    #[test]
251    fn tiered_and_peak_variants_are_kept() {
252        let c = catalog();
253        let qwen =
254            c.go.models
255                .iter()
256                .find(|m| m.id == "qwen3.7-plus")
257                .expect("qwen3.7-plus");
258        assert_eq!(qwen.variants.len(), 2);
259        assert!(qwen
260            .variants
261            .iter()
262            .any(|v| v.tier_max_tokens == Some(256_000)));
263        let deepseek =
264            c.go.models
265                .iter()
266                .find(|m| m.id == "deepseek-v4-flash")
267                .expect("deepseek");
268        assert!(deepseek
269            .variants
270            .iter()
271            .any(|v| v.peak.as_deref() == Some("peak")));
272    }
273}