1use serde::Deserialize;
14use std::collections::BTreeMap;
15
16const RAW: &str = cmduse_core::ZEN_JSON;
19
20pub 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#[derive(Debug, Clone, Deserialize)]
55pub struct GoPlan {
56 pub label: String,
57 #[serde(rename = "priceUsd")]
58 pub price_usd: f64,
59 #[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 pub models: Vec<Model>,
70 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
87pub 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 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 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
112pub fn window_cap(limit: f64, share: f64) -> f64 {
114 limit * share
115}
116
117pub fn is_peak(at_ms: i64) -> bool {
126 let secs = at_ms.div_euclid(1000);
127 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 #[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 _ => 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 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 assert!(!is_peak(at("2026-10-17T02:00:00Z")), "Saturday");
195 assert!(!is_peak(at("2026-10-18T02:00:00Z")), "Sunday");
196 assert!(is_peak(at("2026-10-19T02:00:00Z")));
198
199 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 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}