1use crate::arkuse::window;
12use serde::Deserialize;
13use std::collections::BTreeMap;
14
15const RAW: &str = cmduse_core::ARK_JSON;
16
17pub const DEFAULT_PLAN: &str = "medium";
19
20#[derive(Debug, Clone, Deserialize)]
21pub struct WindowShare {
22 #[serde(rename = "fiveHour")]
23 pub five_hour: f64,
24 pub daily: f64,
25 pub weekly: f64,
26 pub monthly: f64,
27}
28
29#[derive(Debug, Clone, Deserialize)]
30pub struct Promo {
31 pub price: f64,
32 pub until: String,
34 pub months: Option<u32>,
35}
36
37#[derive(Debug, Clone, Deserialize)]
38pub struct Plan {
39 pub id: String,
40 pub label: String,
41 pub scope: String,
42 pub rank: u8,
43 pub price: f64,
45 #[serde(rename = "listPrice")]
46 pub list_price: f64,
47 pub afp: f64,
49 pub promo: Option<Promo>,
50}
51
52#[derive(Debug, Clone, Deserialize)]
53pub struct Enterprise {
54 pub id: String,
55 pub label: String,
56 pub price: f64,
57}
58
59#[derive(Debug, Clone, Deserialize)]
60pub struct Model {
61 pub id: String,
62 pub name: String,
63 pub kind: String,
64 pub coef: f64,
66 #[serde(rename = "promoCoef")]
67 pub promo_coef: Option<f64>,
68 #[serde(rename = "promoUntil")]
69 pub promo_until: Option<String>,
70 #[serde(rename = "minPlan")]
71 pub min_plan: String,
72 pub note: Option<String>,
73}
74
75#[derive(Debug, Clone, Deserialize)]
76pub struct Media {
77 pub id: String,
78 pub name: String,
79 pub kind: String,
80 #[serde(rename = "minPlan")]
81 pub min_plan: String,
82 pub unit: String,
83 pub coef: f64,
84 #[serde(rename = "coefHigh")]
85 pub coef_high: Option<f64>,
86 #[serde(rename = "promoCoef")]
87 pub promo_coef: Option<f64>,
88 pub free: Option<f64>,
89 pub note: Option<String>,
90}
91
92#[derive(Debug, Clone, Deserialize)]
93pub struct Catalog {
94 #[serde(rename = "extractedAt")]
95 pub extracted_at: String,
96 pub source: String,
97 pub currency: String,
98 #[serde(rename = "afpYuan")]
100 pub afp_yuan: f64,
101 pub windows: WindowShare,
102 pub plans: Vec<Plan>,
103 pub enterprise: Vec<Enterprise>,
104 pub models: Vec<Model>,
105 pub media: Vec<Media>,
106 pub harness: Vec<Media>,
107 pub notes: BTreeMap<String, String>,
108}
109
110pub fn catalog() -> Catalog {
112 serde_json::from_str(RAW).expect("core/ark.json is hand-maintained; check its JSON")
113}
114
115impl Catalog {
116 pub fn plan(&self, plan_id: &str) -> Option<&Plan> {
117 self.plans.iter().find(|p| p.id == plan_id)
118 }
119
120 pub fn plan_ids(&self) -> Vec<&str> {
121 self.plans.iter().map(|p| p.id.as_str()).collect()
122 }
123
124 pub fn grants(&self, plan_id: &str, min_plan: &str) -> bool {
127 match (self.plan(plan_id), self.plan(min_plan)) {
128 (Some(have), Some(need)) => have.rank >= need.rank,
129 _ => true,
131 }
132 }
133
134 pub fn coef_at(&self, model: &Model, at_ms: i64) -> f64 {
136 match (model.promo_coef, model.promo_until.as_deref()) {
137 (Some(promo), Some(until)) if window::before_day_end(at_ms, until) => promo,
138 _ => model.coef,
139 }
140 }
141
142 pub fn afp(
149 &self,
150 model_id: &str,
151 at_ms: i64,
152 input: u64,
153 cache_read: u64,
154 output: u64,
155 ) -> Option<f64> {
156 let model = self.models.iter().find(|m| m.id == model_id)?;
157 let coef = self.coef_at(model, at_ms);
158 let tokens = input as f64 + cache_read as f64 + output as f64;
159 Some(tokens * coef / 10_000.0)
160 }
161
162 pub fn quota(&self, plan_id: &str) -> Option<f64> {
164 self.plan(plan_id).map(|p| p.afp)
165 }
166
167 pub fn yuan(&self, afp: f64) -> f64 {
169 afp * self.afp_yuan
170 }
171}
172
173pub fn effective_coef(model: &Model, at_ms: i64) -> f64 {
176 match (model.promo_coef, model.promo_until.as_deref()) {
177 (Some(promo), Some(until)) if window::before_day_end(at_ms, until) => promo,
178 _ => model.coef,
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn the_catalogue_has_every_personal_tier() {
188 let c = catalog();
189 assert_eq!(c.plan_ids(), ["small", "medium", "large", "max"]);
190 assert_eq!(c.plan("medium").unwrap().afp, 100_000.0);
191 assert!((c.yuan(100_000.0) - 200.0).abs() < 1e-9, "flat ¥0.002/AFP");
192 }
193
194 #[test]
195 fn cache_read_is_inside_the_afp_formula() {
196 let c = catalog();
197 let at = window::days_from_civil(2026, 10, 19) * 86_400_000;
198 let afp = c.afp("deepseek-v4.1-flash", at, 10_000, 10_000, 0).unwrap();
201 assert!((afp - 2.5).abs() < 1e-9, "afp {afp}");
202 let later = window::days_from_civil(2027, 1, 2) * 86_400_000;
204 let afp = c
205 .afp("deepseek-v4.1-flash", later, 10_000, 10_000, 0)
206 .unwrap();
207 assert!((afp - 5.0).abs() < 1e-9, "afp {afp}");
208 }
209
210 #[test]
211 fn tiers_gate_models_by_rank() {
212 let c = catalog();
213 assert!(!c.grants("small", "medium"), "kimi-k3 needs Medium");
214 assert!(c.grants("medium", "medium"));
215 assert!(c.grants("max", "medium"));
216 assert!(!c.grants("small", "large"), "video needs Large");
217 assert!(c.grants("large", "large"));
218 }
219}