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: there is no OpenCode usage API and
10// the local store records no subscription, so `ocuse` takes a `--plan` flag and
11// defaults to [`DEFAULT_PLAN`].
12use serde::Deserialize;
13use std::collections::BTreeMap;
14
15// Re-exported from `cmduse-core` so the path stays inside its own crate (a
16// `include_str!` reaching outside is fine locally and fatal at `cargo publish`).
17const RAW: &str = cmduse_core::ZEN_JSON;
18
19/// The plan `ocuse` measures against when `--plan` is not given.
20pub const DEFAULT_PLAN: &str = "go";
21
22#[derive(Debug, Clone, Deserialize)]
23pub struct RateVariant {
24    pub peak: Option<String>,
25    #[serde(rename = "tierMaxTokens")]
26    pub tier_max_tokens: Option<u64>,
27    #[serde(rename = "tierMinTokens")]
28    pub tier_min_tokens: Option<u64>,
29    pub input: f64,
30    pub output: f64,
31    #[serde(rename = "cacheRead")]
32    pub cache_read: f64,
33    #[serde(rename = "cacheWrite")]
34    pub cache_write: f64,
35}
36
37#[derive(Debug, Clone, Deserialize)]
38pub struct Model {
39    pub id: String,
40    pub name: String,
41    pub variants: Vec<RateVariant>,
42}
43
44#[derive(Debug, Clone, Deserialize)]
45pub struct WindowShare {
46    #[serde(rename = "fiveHour")]
47    pub five_hour: f64,
48    pub weekly: f64,
49    pub monthly: f64,
50}
51
52/// One Go plan: a price plus a monthly dollar limit per model id.
53#[derive(Debug, Clone, Deserialize)]
54pub struct GoPlan {
55    pub label: String,
56    #[serde(rename = "priceUsd")]
57    pub price_usd: f64,
58    /// model id -> monthly dollar limit; a missing id is free/unlimited.
59    #[serde(rename = "allowanceByModel")]
60    pub allowance_by_model: BTreeMap<String, f64>,
61}
62
63#[derive(Debug, Clone, Deserialize)]
64pub struct Go {
65    #[serde(rename = "windowShare")]
66    pub window_share: WindowShare,
67    /// Token rates, shared across plans (pricing is the same for both).
68    pub models: Vec<Model>,
69    /// Keyed by plan slug ("go", "go-plus"); only the allowances differ.
70    pub plans: BTreeMap<String, GoPlan>,
71}
72
73#[derive(Debug, Clone, Deserialize)]
74pub struct Zen {
75    pub models: Vec<Model>,
76}
77
78#[derive(Debug, Clone, Deserialize)]
79pub struct Catalog {
80    #[serde(rename = "extractedAt")]
81    pub extracted_at: String,
82    pub go: Go,
83    pub zen: Zen,
84}
85
86/// Parsed once; the file is a compile-time constant.
87pub fn catalog() -> Catalog {
88    serde_json::from_str(RAW)
89        .expect("core/zen.json is generated; regenerate with scripts/extract-zen.ts")
90}
91
92impl Catalog {
93    /// The plan ids the docs define, in the map's (alphabetical) order — "go"
94    /// sorts before "go-plus", which is the order they read in the docs.
95    pub fn plan_ids(&self) -> Vec<&str> {
96        self.go.plans.keys().map(String::as_str).collect()
97    }
98
99    pub fn plan(&self, plan_id: &str) -> Option<&GoPlan> {
100        self.go.plans.get(plan_id)
101    }
102
103    /// Monthly dollar limit for a model under one plan, if the docs list one.
104    /// None for a free/unlimited model, an unknown model, or an unknown plan.
105    pub fn go_limit(&self, plan_id: &str, model_id: &str) -> Option<f64> {
106        self.plan(plan_id)
107            .and_then(|plan| plan.allowance_by_model.get(model_id).copied())
108    }
109}
110
111/// Suggested cap for a rolling window, from the documented share of the limit.
112pub fn window_cap(limit: f64, share: f64) -> f64 {
113    limit * share
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn catalogue_parses_and_carries_both_plans() {
122        let c = catalog();
123        assert!(c.go.models.len() > 20, "go models: {}", c.go.models.len());
124        assert!(!c.zen.models.is_empty());
125        assert_eq!(c.plan_ids(), vec!["go", "go-plus"]);
126        assert_eq!(c.plan("go").map(|p| p.price_usd), Some(10.0));
127        assert_eq!(c.plan("go-plus").map(|p| p.price_usd), Some(40.0));
128        assert_eq!(c.go_limit("go", "glm-5.3-flash"), Some(60.0));
129        assert_eq!(c.go_limit("go-plus", "glm-5.3-flash"), Some(180.0));
130        assert_eq!(c.go_limit("go", "nope-not-a-model"), None);
131        assert_eq!(c.go_limit("nope-not-a-plan", "glm-5.3-flash"), None);
132    }
133
134    #[test]
135    fn plans_share_the_model_set_and_differ_on_allowance() {
136        let c = catalog();
137        let go = c.plan("go").expect("go");
138        let plus = c.plan("go-plus").expect("go-plus");
139        assert_eq!(go.label, "Go");
140        assert_eq!(plus.label, "Go Plus");
141        assert_eq!(go.allowance_by_model.len(), plus.allowance_by_model.len());
142        // Rates are shared, so every allowance model is in the other plan too,
143        // and Go Plus grants more on at least one of them.
144        assert!(plus
145            .allowance_by_model
146            .iter()
147            .all(|(id, _)| go.allowance_by_model.contains_key(id)));
148        assert!(plus
149            .allowance_by_model
150            .iter()
151            .any(|(id, limit)| go.allowance_by_model.get(id).is_some_and(|g| limit > g)));
152    }
153
154    #[test]
155    fn window_split_matches_the_docs() {
156        let c = catalog();
157        assert_eq!(c.go.window_share.five_hour, 0.2);
158        assert_eq!(c.go.window_share.weekly, 0.5);
159        assert_eq!(c.go.window_share.monthly, 1.0);
160        assert_eq!(window_cap(60.0, c.go.window_share.five_hour), 12.0);
161    }
162
163    #[test]
164    fn tiered_and_peak_variants_are_kept() {
165        let c = catalog();
166        let qwen =
167            c.go.models
168                .iter()
169                .find(|m| m.id == "qwen3.7-plus")
170                .expect("qwen3.7-plus");
171        assert_eq!(qwen.variants.len(), 2);
172        assert!(qwen
173            .variants
174            .iter()
175            .any(|v| v.tier_max_tokens == Some(256_000)));
176        let deepseek =
177            c.go.models
178                .iter()
179                .find(|m| m.id == "deepseek-v4-flash")
180                .expect("deepseek");
181        assert!(deepseek
182            .variants
183            .iter()
184            .any(|v| v.peak.as_deref() == Some("peak")));
185    }
186}