Skip to main content

cmd_usage/ocuse/
api.rs

1// OpenCode Go's own usage endpoint — the numbers the console shows.
2//
3// `GET https://opencode.ai/zen/go/v1/usage` with a Go key answers the console's
4// three meters:
5//
6//   {"usage":{"rolling":{"status":"ok","percent":2,"resetsAt":"…Z"},
7//             "weekly":{…}, "monthly":{…}}}
8//
9// They are authoritative in a way a local reconstruction cannot be, because the
10// server's own windows are not the ones the store can be sliced into:
11//
12//   * the 5-hour window is a *block* that resets at its own instant, not
13//     `now - 5h` (measured 2026-10-16: `resetsAt` sat 4h31m ahead of now, so the
14//     block had opened 29 minutes earlier, while the last 5h of requests were
15//     worth nine times the block's reported share);
16//   * the weekly window is anchored to Monday 00:00 UTC, not a rolling 7 days;
17//   * the monthly window is the subscription anniversary (Oct 13 09:14:35 →
18//     Nov 13 09:14:35), not the calendar month;
19//   * and the server meters with its own price basis, which the store's recorded
20//     `cost` is not — fitted from 9952 real rows its rates land *between* the
21//     docs' off-peak and peak sheets ($0.1529/$0.8717/$0.0039 per 1M against
22//     0.15/0.60/0.003 and 0.30/1.20/0.006).
23//
24// The gap that produced is exactly the "weekly and 5h are wrong" report: on the
25// same account at the same minute, local derivation said 9.4% / 42.5% / 21.3%
26// where the console said 2% / 58% / 29%.
27//
28// No key (an opencode install without Go), a non-Go key (entitlement error), or
29// any network failure returns `None` at the call site, and the caller keeps its
30// local estimate.
31use serde::Deserialize;
32
33/// The Go usage endpoint — the same source the console reads.
34pub const USAGE_URL: &str = "https://opencode.ai/zen/go/v1/usage";
35
36/// One of the account's three meters: what share of the window's limit is gone,
37/// and when it resets.
38#[derive(Debug, Clone, Deserialize, PartialEq)]
39pub struct Meter {
40    /// The server's own word for the state ("ok" when inside the limit); kept
41    /// verbatim so an unrecognised one is still surfaced rather than swallowed.
42    pub status: String,
43    /// Percent of the window's limit already used — the console's number.
44    pub percent: f64,
45    #[serde(rename = "resetsAt")]
46    pub resets_at: Option<String>,
47}
48
49impl Meter {
50    /// The reset instant in epoch ms; `None` when the server sends no timestamp.
51    pub fn reset_ms(&self) -> Option<i64> {
52        self.resets_at
53            .as_deref()
54            .and_then(cmduse_core::dates::parse_iso_utc)
55            .map(|ms| ms as i64)
56    }
57
58    /// Over the limit: either the server says so, or the percent is at 100.
59    pub fn exceeded(&self) -> bool {
60        self.percent >= 100.0 || !self.status.eq_ignore_ascii_case("ok")
61    }
62}
63
64/// The account's three meters. Field names are the endpoint's, so `rolling` is
65/// the 5-hour window.
66#[derive(Debug, Clone, Deserialize)]
67pub struct Usage {
68    pub rolling: Meter,
69    pub weekly: Meter,
70    pub monthly: Meter,
71}
72
73#[derive(Deserialize)]
74struct Envelope {
75    usage: Usage,
76}
77
78/// The Go key: `OCUSE_API_KEY`, else `OPENCODE_API_KEY`, else the `opencode-go`
79/// entry of opencode's own auth file — the same credential `/connect` stores.
80pub fn key() -> Option<String> {
81    for var in ["OCUSE_API_KEY", "OPENCODE_API_KEY"] {
82        if let Ok(value) = std::env::var(var) {
83            if !value.trim().is_empty() {
84                return Some(value.trim().to_string());
85            }
86        }
87    }
88    let path = format!("{}/auth.json", crate::ocuse::db::opencode_home());
89    let text = std::fs::read_to_string(path).ok()?;
90    let auth: serde_json::Value = serde_json::from_str(&text).ok()?;
91    auth["opencode-go"]["key"]
92        .as_str()
93        .filter(|key| !key.is_empty())
94        .map(str::to_string)
95}
96
97/// Fetch the account's meters. One attempt: the caller always has a local
98/// fallback, and in watch mode a previous answer, so a retry ladder would only
99/// stall the frame it is drawn into.
100pub fn fetch(key: &str) -> Result<Usage, String> {
101    let response = ureq::get(USAGE_URL)
102        .set("Authorization", &format!("Bearer {key}"))
103        // Go's docs ask clients to identify themselves rather than arrive as an
104        // anonymous HTTP library.
105        .set(
106            "User-Agent",
107            &format!("ocuse/{}", env!("CARGO_PKG_VERSION")),
108        )
109        .timeout(std::time::Duration::from_secs(10))
110        .call();
111    match response {
112        Ok(response) => {
113            let body = response
114                .into_string()
115                .map_err(|e| format!("usage: read: {e}"))?;
116            serde_json::from_str::<Envelope>(&body)
117                .map(|envelope| envelope.usage)
118                .map_err(|e| format!("usage: {e}"))
119        }
120        // A key without a Go subscription is an entitlement refusal, not a
121        // transport fault: say so, since the fallback is otherwise silent.
122        Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => {
123            Err(format!("usage: HTTP {code} (key has no Go subscription)"))
124        }
125        Err(e) => Err(format!("usage: {e}")),
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    /// Exactly what the endpoint answered for this account on 2026-10-16.
134    const LIVE: &str = r#"{"usage":{
135        "rolling":{"status":"ok","percent":2,"resetsAt":"2026-10-16T10:57:34.000Z"},
136        "weekly":{"status":"ok","percent":58,"resetsAt":"2026-10-19T00:00:00.000Z"},
137        "monthly":{"status":"ok","percent":29,"resetsAt":"2026-11-13T09:14:35.000Z"}}}"#;
138
139    fn parse(text: &str) -> Usage {
140        serde_json::from_str::<Envelope>(text)
141            .expect("fixture parses")
142            .usage
143    }
144
145    #[test]
146    fn parses_the_live_response() {
147        let usage = parse(LIVE);
148        assert_eq!(usage.rolling.percent, 2.0);
149        assert_eq!(usage.weekly.percent, 58.0);
150        assert_eq!(usage.monthly.percent, 29.0);
151        assert!(!usage.weekly.exceeded());
152    }
153
154    #[test]
155    fn resets_at_becomes_epoch_ms() {
156        let usage = parse(LIVE);
157        // 2026-10-19T00:00:00Z — the Monday the weekly window turns over.
158        assert_eq!(usage.weekly.reset_ms(), Some(1_792_368_000_000));
159        // 2026-11-13T09:14:35Z — the subscription anniversary.
160        assert_eq!(usage.monthly.reset_ms(), Some(1_794_561_275_000));
161        assert_eq!(
162            usage.rolling.resets_at.as_deref(),
163            Some("2026-10-16T10:57:34.000Z")
164        );
165    }
166
167    #[test]
168    fn a_missing_reset_is_not_an_error() {
169        let usage = parse(
170            r#"{"usage":{
171            "rolling":{"status":"ok","percent":0},
172            "weekly":{"status":"ok","percent":0,"resetsAt":null},
173            "monthly":{"status":"ok","percent":0,"resetsAt":null}}}"#,
174        );
175        assert_eq!(usage.rolling.reset_ms(), None);
176    }
177
178    #[test]
179    fn a_non_ok_status_reads_as_exceeded() {
180        let usage = parse(
181            r#"{"usage":{
182            "rolling":{"status":"rate_limited","percent":100,"resetsAt":null},
183            "weekly":{"status":"ok","percent":99.9,"resetsAt":null},
184            "monthly":{"status":"ok","percent":100,"resetsAt":null}}}"#,
185        );
186        assert!(usage.rolling.exceeded(), "status is not ok");
187        assert!(!usage.weekly.exceeded(), "99.9% is not over");
188        assert!(usage.monthly.exceeded(), "100% is over");
189    }
190
191    #[test]
192    fn the_env_override_wins_over_null() {
193        // The env path is the documented way to point ocuse at another account.
194        std::env::set_var("OCUSE_API_KEY", "env-key");
195        assert_eq!(key().as_deref(), Some("env-key"));
196        std::env::remove_var("OCUSE_API_KEY");
197    }
198}