Skip to main content

cmd_usage/arkuse/
api.rs

1// The ARK control plane, through `arkcli`.
2//
3// Unit of truth: AFP. `arkcli usage plan` answers the rolling windows
4// (5h / daily / weekly / monthly) with `used`, `total`, `percent` and the
5// server's own `reset_at` — the same numbers the Volcengine console shows, and
6// the only view that can see traffic from another machine or another harness.
7//
8// Two design rules, both learned on the OpenCode side of this crate:
9//
10// * the *percent* the server hands us is printed verbatim, never re-rounded;
11// * the local estimate never claims to be the meter. When `arkcli` cannot
12//   answer, `Unavailable::reason` says why and the renderers label the rows
13//   `estimated locally`.
14//
15// Shelling out, not HTTP: `arkcli` already owns the credential chain (SSO,
16// profile pinning, region/project selection), and re-implementing it here would
17// duplicate a signed control-plane client for no gain. One process per poll,
18// bounded by `CLI_TIMEOUT_SECS` so a hung CLI cannot pin a watch frame.
19use crate::arkuse::window::days_from_civil;
20use serde::Deserialize;
21use std::process::{Command, Stdio};
22use std::time::{Duration, Instant};
23
24/// Hard cap on one `arkcli` call. The control plane answers in ~2s; anything
25/// past this is a hang, and the frame must repaint with the local estimate.
26pub const CLI_TIMEOUT_SECS: u64 = 20;
27
28#[derive(Debug, Clone)]
29pub struct Meter {
30    /// `ok` / `exceeded` — ARK reports no status, so it is derived from percent.
31    pub status: String,
32    /// The server's own percentage, as served.
33    pub percent: f64,
34    pub used: f64,
35    pub quota: f64,
36    /// The raw instant, for JSON and the header.
37    pub resets_at: Option<String>,
38    reset_ms: Option<i64>,
39    offset_secs: i64,
40}
41
42impl Meter {
43    pub fn from_parts(
44        status: &str,
45        percent: f64,
46        used: f64,
47        quota: f64,
48        resets_at: Option<&str>,
49        offset_secs: Option<i64>,
50    ) -> Self {
51        let parsed = resets_at.and_then(parse_instant);
52        Self {
53            status: status.into(),
54            percent,
55            used,
56            quota,
57            resets_at: resets_at.map(str::to_string),
58            reset_ms: parsed.map(|(ms, _)| ms),
59            offset_secs: offset_secs
60                .or(parsed.map(|(_, off)| off))
61                .unwrap_or(8 * 3600),
62        }
63    }
64
65    pub fn reset_ms(&self) -> Option<i64> {
66        self.reset_ms
67    }
68
69    /// The server's UTC offset for this window, seconds east-positive.
70    pub fn offset_secs(&self) -> i64 {
71        self.offset_secs
72    }
73
74    pub fn exceeded(&self) -> bool {
75        self.percent >= 100.0
76    }
77
78    /// AFP still available in this window, floored at zero.
79    pub fn remaining(&self) -> f64 {
80        (self.quota - self.used).max(0.0)
81    }
82}
83
84#[derive(Debug, Clone)]
85pub struct Usage {
86    pub five_hour: Meter,
87    /// Present only for plans/workloads that expose a daily window (media and
88    /// Harness items); a text-only workload answers 5h/weekly/monthly.
89    pub daily: Option<Meter>,
90    pub weekly: Meter,
91    pub monthly: Meter,
92    /// The tier the subscription is on, from the control plane itself — the one
93    /// fact no local derivation can know.
94    pub tier: Option<String>,
95    pub profile: String,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum Unavailable {
100    /// `--no-api`: deliberate, not a fault.
101    Disabled,
102    /// No `agent-plan` profile and no `--profile`.
103    NoProfile,
104    /// `arkcli` is not on PATH.
105    CliMissing,
106    Failed(String),
107}
108
109impl Unavailable {
110    pub fn reason(&self) -> String {
111        match self {
112            Unavailable::Disabled => "API disabled (--no-api)".into(),
113            Unavailable::NoProfile => "no agent-plan arkcli profile".into(),
114            Unavailable::CliMissing => "arkcli not found on PATH".into(),
115            Unavailable::Failed(why) => format!("arkcli: {why}"),
116        }
117    }
118}
119
120#[derive(Deserialize)]
121struct ProfileList {
122    profiles: Vec<ProfileEntry>,
123}
124
125#[derive(Deserialize)]
126struct ProfileEntry {
127    name: String,
128    #[serde(rename = "type")]
129    kind: Option<String>,
130    #[serde(rename = "plan_tier")]
131    tier: Option<String>,
132}
133
134/// Resolve the profile to query: `--profile`, then `$ARKUSE_PROFILE`, then the
135/// first `agent-plan` profile arkcli lists. Also returns that profile's tier,
136/// which is the default for `--plan`.
137pub fn resolve(explicit: Option<&str>) -> Option<(String, Option<String>)> {
138    let name = explicit.map(str::to_string).or_else(|| {
139        std::env::var("ARKUSE_PROFILE")
140            .ok()
141            .filter(|v| !v.is_empty())
142    });
143    let listed = profiles();
144    if let Some(name) = name {
145        let tier = listed
146            .iter()
147            .find(|(n, _, _)| n == &name)
148            .and_then(|(_, _, t)| t.clone());
149        return Some((name, tier));
150    }
151    listed
152        .into_iter()
153        .find(|(_, kind, _)| kind.as_deref() == Some("agent-plan"))
154        .map(|(name, _, tier)| (name, tier))
155}
156
157fn profiles() -> Vec<(String, Option<String>, Option<String>)> {
158    let Ok(out) = run_cli(&["profile", "list", "--format", "json"]) else {
159        return Vec::new();
160    };
161    serde_json::from_str::<ProfileList>(&out)
162        .map(|list| {
163            list.profiles
164                .into_iter()
165                .map(|p| (p.name, p.kind, p.tier))
166                .collect()
167        })
168        .unwrap_or_default()
169}
170
171#[derive(Deserialize)]
172struct UsageEnvelope {
173    items: Vec<UsageItem>,
174}
175
176#[derive(Deserialize)]
177struct UsageItem {
178    edition: Option<String>,
179    tier: Option<String>,
180    subscribed: Option<bool>,
181    #[serde(default)]
182    periods: Vec<Period>,
183}
184
185#[derive(Deserialize)]
186struct Period {
187    label: String,
188    /// A window can be reported without its figures: measured 2026-10-20, the
189    /// just-reset 5-hour block answered `{"label":"5h","total":10000,
190    /// "percent":0}` — no `used`, no `reset_at`. Making these required turned
191    /// the whole answer into a parse error and dropped the dashboard to the
192    /// local estimate, so each is derived from the others when absent.
193    #[serde(default)]
194    used: Option<f64>,
195    #[serde(default)]
196    total: f64,
197    #[serde(default)]
198    percent: Option<f64>,
199    reset_at: Option<String>,
200}
201
202impl Period {
203    /// `(used, percent)`; the server's own percentage wins whenever it is
204    /// present, because it is the figure the console prints.
205    fn figures(&self) -> (f64, f64) {
206        let used = self
207            .used
208            .unwrap_or_else(|| self.total * self.percent.unwrap_or(0.0) / 100.0);
209        let percent = self.percent.unwrap_or_else(|| {
210            if self.total > 0.0 {
211                used / self.total * 100.0
212            } else {
213                0.0
214            }
215        });
216        (used, percent)
217    }
218}
219
220/// Ask the control plane for the plan's meters.
221pub fn fetch(profile: &str) -> Result<Usage, Unavailable> {
222    let raw = run_cli(&[
223        "usage",
224        "plan",
225        "--profile",
226        profile,
227        "--product",
228        "agent-plan",
229        "--format",
230        "json",
231    ])?;
232    parse_usage(&raw, profile)
233}
234
235fn parse_usage(raw: &str, profile: &str) -> Result<Usage, Unavailable> {
236    let envelope: UsageEnvelope =
237        serde_json::from_str(raw).map_err(|e| Unavailable::Failed(format!("parse: {e}")))?;
238    // Prefer the personal edition when the account holds several SKUs.
239    let item = envelope
240        .items
241        .iter()
242        .find(|i| i.edition.as_deref() == Some("personal"))
243        .or_else(|| envelope.items.first())
244        .ok_or_else(|| Unavailable::Failed("no plan items in the answer".into()))?;
245    if item.subscribed == Some(false) {
246        return Err(Unavailable::Failed("no active subscription".into()));
247    }
248    let meter = |label: &str| -> Option<Meter> {
249        item.periods.iter().find(|p| p.label == label).map(|p| {
250            let (used, percent) = p.figures();
251            let status = if percent >= 100.0 { "exceeded" } else { "ok" };
252            Meter::from_parts(status, percent, used, p.total, p.reset_at.as_deref(), None)
253        })
254    };
255    let missing = |label: &str| Unavailable::Failed(format!("the answer has no {label} window"));
256    Ok(Usage {
257        five_hour: meter("5h").ok_or_else(|| missing("5h"))?,
258        daily: meter("daily"),
259        weekly: meter("weekly").ok_or_else(|| missing("weekly"))?,
260        monthly: meter("monthly").ok_or_else(|| missing("monthly"))?,
261        tier: item.tier.clone(),
262        profile: profile.into(),
263    })
264}
265
266/// Run `arkcli` with a hard timeout, returning stdout. Unix-only process kill is
267/// fine here: this crate already requires a Unix terminal for its watch mode.
268fn run_cli(args: &[&str]) -> Result<String, Unavailable> {
269    let mut child = Command::new("arkcli")
270        .args(args)
271        .stdin(Stdio::null())
272        .stdout(Stdio::piped())
273        .stderr(Stdio::piped())
274        .spawn()
275        .map_err(|e| {
276            if e.kind() == std::io::ErrorKind::NotFound {
277                Unavailable::CliMissing
278            } else {
279                Unavailable::Failed(e.to_string())
280            }
281        })?;
282    let started = Instant::now();
283    loop {
284        match child.try_wait() {
285            Ok(Some(status)) => {
286                let out = child
287                    .wait_with_output()
288                    .map_err(|e| Unavailable::Failed(format!("read stdout: {e}")))?;
289                if !status.success() {
290                    let err = String::from_utf8_lossy(&out.stderr);
291                    return Err(Unavailable::Failed(cli_error(&err)));
292                }
293                return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
294            }
295            Ok(None) => {
296                if started.elapsed() > Duration::from_secs(CLI_TIMEOUT_SECS) {
297                    let _ = child.kill();
298                    return Err(Unavailable::Failed(format!(
299                        "timed out after {CLI_TIMEOUT_SECS}s"
300                    )));
301                }
302                std::thread::sleep(Duration::from_millis(50));
303            }
304            Err(e) => return Err(Unavailable::Failed(e.to_string())),
305        }
306    }
307}
308
309/// `arkcli` reports failures as a JSON envelope on stderr
310/// (`{"ok":false,"error":{"message":…}}`), so the first line is just `{`. Take
311/// the message when there is one, else the last non-empty line — the actionable
312/// sentence, not the brace.
313fn cli_error(stderr: &str) -> String {
314    #[derive(Deserialize)]
315    struct Envelope {
316        error: Option<Inner>,
317    }
318    #[derive(Deserialize)]
319    struct Inner {
320        message: Option<String>,
321    }
322    if let Ok(env) = serde_json::from_str::<Envelope>(stderr.trim()) {
323        if let Some(text) = env.error.and_then(|e| e.message) {
324            if !text.trim().is_empty() {
325                return text.trim().to_string();
326            }
327        }
328    }
329    stderr
330        .lines()
331        .map(str::trim)
332        .rfind(|l| !l.is_empty())
333        .unwrap_or("failed")
334        .to_string()
335}
336
337/// Parse an ISO-8601 instant with a numeric offset (`2026-10-20T00:24:37+08:00`)
338/// into epoch ms plus the offset in seconds east of UTC.
339pub fn parse_instant(value: &str) -> Option<(i64, i64)> {
340    let (date, rest) = value.split_once('T')?;
341    let (time, offset) = match rest.find(['+', '-']) {
342        Some(at) => (&rest[..at], &rest[at..]),
343        None => (rest.trim_end_matches('Z'), "+00:00"),
344    };
345    let mut dp = date.split('-');
346    let y: i64 = dp.next()?.parse().ok()?;
347    let m: i64 = dp.next()?.parse().ok()?;
348    let d: i64 = dp.next()?.parse().ok()?;
349    let time = time.trim_end_matches('Z');
350    let mut tp = time.split(':');
351    let hh: i64 = tp.next().unwrap_or("0").parse().unwrap_or(0);
352    let mm: i64 = tp.next().unwrap_or("0").parse().unwrap_or(0);
353    let ss: i64 = tp
354        .next()
355        .unwrap_or("0")
356        .split('.')
357        .next()?
358        .parse()
359        .unwrap_or(0);
360    let (sign, off) = match offset.strip_prefix('-') {
361        Some(rest) => (-1, rest),
362        None => (1, offset.trim_start_matches('+')),
363    };
364    let mut op = off.split(':');
365    let oh: i64 = op.next().unwrap_or("0").parse().unwrap_or(0);
366    let om: i64 = op.next().unwrap_or("0").parse().unwrap_or(0);
367    let offset_secs = sign * (oh * 3600 + om * 60);
368    let local_secs = days_from_civil(y, m, d) * 86_400 + hh * 3600 + mm * 60 + ss;
369    Some(((local_secs - offset_secs) * 1000, offset_secs))
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn parses_the_servers_offset_instant() {
378        let (ms, off) = parse_instant("2026-10-20T00:24:37+08:00").unwrap();
379        assert_eq!(off, 8 * 3600);
380        assert_eq!(
381            ms,
382            (days_from_civil(2026, 10, 19) * 86_400 + 16 * 3600 + 24 * 60 + 37) * 1000
383        );
384        let (ms, off) = parse_instant("2026-10-20T00:24:37Z").unwrap();
385        assert_eq!(off, 0);
386        assert_eq!(
387            ms,
388            (days_from_civil(2026, 10, 20) * 86_400 + 24 * 60 + 37) * 1000
389        );
390    }
391
392    #[test]
393    fn reads_the_four_windows_and_the_tier() {
394        let raw = r#"{"items":[{"edition":"personal","tier":"medium","subscribed":true,
395            "periods":[
396              {"label":"5h","used":2975.67,"total":10000,"percent":29.75,"reset_at":"2026-10-20T00:24:37+08:00"},
397              {"label":"daily","used":100.0,"total":50000,"percent":0.2,"reset_at":"2026-10-20T00:00:00+08:00"},
398              {"label":"weekly","used":2975.67,"total":35000,"percent":8.5,"reset_at":"2026-10-26T00:00:00+08:00"},
399              {"label":"monthly","used":2975.67,"total":100000,"percent":2.98,"reset_at":"2026-11-19T23:59:59+08:00"}]}]}"#;
400        let usage = parse_usage(raw, "agent-plan_cn-beijing_medium").unwrap();
401        assert_eq!(usage.tier.as_deref(), Some("medium"));
402        assert_eq!(usage.five_hour.quota, 10_000.0);
403        assert!(usage.daily.is_some());
404        assert!(usage.five_hour.reset_ms().is_some());
405    }
406
407    #[test]
408    fn a_text_only_plan_has_no_daily_window() {
409        let raw = r#"{"items":[{"edition":"personal","tier":"small","subscribed":true,
410            "periods":[
411              {"label":"5h","used":1,"total":2000,"percent":0.05,"reset_at":"2026-10-20T00:24:37+08:00"},
412              {"label":"weekly","used":1,"total":7000,"percent":0.01,"reset_at":"2026-10-26T00:00:00+08:00"},
413              {"label":"monthly","used":1,"total":20000,"percent":0.005,"reset_at":"2026-11-19T23:59:59+08:00"}]}]}"#;
414        let usage = parse_usage(raw, "p").unwrap();
415        assert!(usage.daily.is_none());
416    }
417
418    #[test]
419    fn a_window_without_figures_is_derived_not_rejected() {
420        // Measured 2026-10-20: the just-reset 5-hour block came back as
421        // `{"label":"5h","total":10000,"percent":0}` — no `used`, no `reset_at`.
422        // Requiring `used` made the whole answer a parse error, which silently
423        // dropped the dashboard to the local estimate.
424        let raw = r#"{"items":[{"edition":"personal","tier":"medium","subscribed":true,
425            "periods":[
426              {"label":"5h","total":10000,"percent":0},
427              {"label":"weekly","used":2975.67,"total":35000,"percent":8.5,"reset_at":"2026-10-26T00:00:00+08:00"},
428              {"label":"monthly","used":2975.67,"total":100000,"percent":2.98,"reset_at":"2026-11-19T23:59:59+08:00"}]}]}"#;
429        let usage = parse_usage(raw, "p").expect("a figureless window is still an answer");
430        assert_eq!(usage.five_hour.used, 0.0);
431        assert_eq!(usage.five_hour.quota, 10_000.0);
432        assert_eq!(usage.five_hour.percent, 0.0);
433        assert!(usage.five_hour.reset_ms().is_none());
434    }
435
436    #[test]
437    fn a_missing_percentage_is_computed_from_used_and_total() {
438        let raw = r#"{"items":[{"edition":"personal","tier":"medium","subscribed":true,
439            "periods":[
440              {"label":"5h","used":2500,"total":10000},
441              {"label":"weekly","used":1,"total":35000},
442              {"label":"monthly","used":1,"total":100000}]}]}"#;
443        let usage = parse_usage(raw, "p").unwrap();
444        assert_eq!(usage.five_hour.percent, 25.0);
445        assert_eq!(usage.five_hour.used, 2_500.0);
446    }
447
448    #[test]
449    fn an_unsubscribed_item_is_an_error() {
450        let raw =
451            r#"{"items":[{"edition":"personal","tier":"medium","subscribed":false,"periods":[]}]}"#;
452        assert!(matches!(parse_usage(raw, "p"), Err(Unavailable::Failed(_))));
453    }
454}