Skip to main content

cmd_usage/arkuse/
ark.rs

1// The ARK Agent Plan catalogue: tiers, AFP quota, model coefficients, gates.
2//
3// Source is `core/ark.json`, hand-maintained from the docs (see the file's own
4// `source` URL and `~/dev/rdm/volcengine/research/agent-plan-matrix.md`). Unlike
5// `zen.json` this is not generated: the docs publish coefficients as prose
6// tables, and a scraper for them would be more fragile than the five fields
7// that change.
8//
9// AFP is the plan's own currency and 1 AFP = ¥0.002 at every tier, so the
10// catalogue holds AFP and converts to yuan only for display.
11use crate::arkuse::window;
12use serde::Deserialize;
13use std::collections::BTreeMap;
14
15const RAW: &str = cmduse_core::ARK_JSON;
16
17/// Tier used when nothing names one and no profile answers.
18pub 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    /// `YYYY-MM-DD`, inclusive.
33    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    /// List price in yuan per month.
44    pub price: f64,
45    #[serde(rename = "listPrice")]
46    pub list_price: f64,
47    /// Monthly AFP quota.
48    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    /// AFP coefficient: `(input + cache_read + output) × coef / 10,000`.
65    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    /// Yuan per AFP — 0.002 at every tier.
99    #[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
110/// Parsed once; the file is a compile-time constant.
111pub 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    /// The tier a model needs, compared by rank so `--plan large` grants a
125    /// Medium-only model too.
126    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            // An unknown minimum is not a gate.
130            _ => true,
131        }
132    }
133
134    /// The coefficient in force at `at_ms`: a promo halves it until its own date.
135    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    /// AFP for one text/embedding turn, or `None` for a model with no
143    /// coefficient (unknown, or media which meters in its own unit).
144    ///
145    /// Cache-read tokens are billed: measured on this account, cache-excluded
146    /// arithmetic gave 40.9 AFP against a 706.5 meter, cache-inclusive gave
147    /// 705.2 — 0.2% apart.
148    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    /// The plan's monthly AFP quota.
163    pub fn quota(&self, plan_id: &str) -> Option<f64> {
164        self.plan(plan_id).map(|p| p.afp)
165    }
166
167    /// Yuan for an AFP amount.
168    pub fn yuan(&self, afp: f64) -> f64 {
169        afp * self.afp_yuan
170    }
171}
172
173/// The coefficient a model carries right now, with the promo already applied.
174/// Public because the model table and the reconciliation both show it.
175pub 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        // deepseek-v4.1-flash is on its 1.25 promo until 2026-12-30, so
199        // 10,000 input + 10,000 cache + 0 output = 20,000 × 1.25 / 10^4.
200        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        // After the promo it is the full 2.5 coefficient.
203        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}