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 refused key, or any network failure
29// leaves the caller on its local estimate — and with an [`Unavailable`] naming
30// which of the three it was, because only one of them is something a person can
31// act on (`api_key_expired` means "create a new key", not "try again later").
32// The key itself comes from opencode's own precedence — see [`key`], which reads
33// v2's credential table before the legacy `auth.json`.
34use serde::Deserialize;
35
36/// The Go usage endpoint — the same source the console reads.
37pub const USAGE_URL: &str = "https://opencode.ai/zen/go/v1/usage";
38
39/// One of the account's three meters: what share of the window's limit is gone,
40/// and when it resets.
41#[derive(Debug, Clone, Deserialize, PartialEq)]
42pub struct Meter {
43    /// The server's own word for the state ("ok" when inside the limit); kept
44    /// verbatim so an unrecognised one is still surfaced rather than swallowed.
45    pub status: String,
46    /// Percent of the window's limit already used — the console's number.
47    pub percent: f64,
48    #[serde(rename = "resetsAt")]
49    pub resets_at: Option<String>,
50}
51
52impl Meter {
53    /// The reset instant in epoch ms; `None` when the server sends no timestamp.
54    pub fn reset_ms(&self) -> Option<i64> {
55        self.resets_at
56            .as_deref()
57            .and_then(cmduse_core::dates::parse_iso_utc)
58            .map(|ms| ms as i64)
59    }
60
61    /// Over the limit: either the server says so, or the percent is at 100.
62    pub fn exceeded(&self) -> bool {
63        self.percent >= 100.0 || !self.status.eq_ignore_ascii_case("ok")
64    }
65}
66
67/// The account's three meters. Field names are the endpoint's, so `rolling` is
68/// the 5-hour window.
69#[derive(Debug, Clone, Deserialize)]
70pub struct Usage {
71    pub rolling: Meter,
72    pub weekly: Meter,
73    pub monthly: Meter,
74}
75
76#[derive(Deserialize)]
77struct Envelope {
78    usage: Usage,
79}
80
81/// The endpoint's refusal body: `{"error":{"type":"authentication_error",
82/// "code":"api_key_expired","message":"…"}}`. Both halves are optional because
83/// the shape is not ours; whichever arrives is read, since the code alone is the
84/// actionable part and a `type` is all an entitlement refusal carries.
85#[derive(Deserialize)]
86struct ErrorBody {
87    error: Option<ErrorDetail>,
88}
89
90#[derive(Deserialize)]
91struct ErrorDetail {
92    code: Option<String>,
93    #[serde(rename = "type")]
94    kind: Option<String>,
95}
96
97/// Why the account's meters are absent.
98///
99/// `None` meters are a normal state — an opencode install without a Go
100/// subscription has no key — so the *fault* cases are the ones worth a line on
101/// the dashboard: "estimated locally" on its own cannot distinguish a missing
102/// key from a refused one, and the console's own numbers are what that section
103/// exists to show.
104#[derive(Debug, Clone, PartialEq)]
105pub enum Unavailable {
106    /// No key in the environment and none in opencode's auth file.
107    NoKey,
108    /// `--no-api`: the account route was switched off on purpose.
109    Disabled,
110    /// The endpoint refused the key. `kind` is its own word for why
111    /// (`api_key_expired`), which is the part a person can act on; `status` is
112    /// the HTTP status behind it.
113    Rejected { status: u16, kind: Option<String> },
114    /// No network, a timeout, or a body that would not parse.
115    Transport(String),
116}
117
118impl Unavailable {
119    /// The line to show, or `None` when the account route was switched off on
120    /// purpose: a deliberate choice is not a fault to report.
121    pub fn reason(&self) -> Option<String> {
122        match self {
123            Unavailable::Disabled => None,
124            Unavailable::NoKey => Some("no Go key".into()),
125            Unavailable::Rejected { status, kind } => Some(match kind {
126                Some(kind) => format!("HTTP {status} {kind}"),
127                None => format!("HTTP {status}"),
128            }),
129            Unavailable::Transport(why) => Some(why.clone()),
130        }
131    }
132}
133
134/// The Go key: `OCUSE_API_KEY`, else `OPENCODE_API_KEY`, else the `opencode-go`
135/// entry of opencode's own auth file — the same credential `/connect` stores.
136/// The bearer token, in opencode's own order of precedence.
137///
138/// The store leads the legacy file because v2 writes provider credentials to
139/// `credential` and `/connect` is what actually replaces a key; `auth.json` is
140/// only where a v1 install keeps them. Reading the file first (or alone) is how
141/// the dashboard came to say `api_key_expired` while Go inference was working:
142/// the replacement key had gone to the store, and the file still held the old
143/// one.
144pub fn key() -> Option<String> {
145    for var in ["OCUSE_API_KEY", "OPENCODE_API_KEY"] {
146        if let Ok(value) = std::env::var(var) {
147            if !value.trim().is_empty() {
148                return Some(value.trim().to_string());
149            }
150        }
151    }
152    if let Some(key) = crate::ocuse::db::credential_key() {
153        return Some(key);
154    }
155    let path = format!("{}/auth.json", crate::ocuse::db::opencode_home());
156    let text = std::fs::read_to_string(path).ok()?;
157    let auth: serde_json::Value = serde_json::from_str(&text).ok()?;
158    auth["opencode-go"]["key"]
159        .as_str()
160        .filter(|key| !key.is_empty())
161        .map(str::to_string)
162}
163
164/// Fetch the account's meters. One attempt: the caller always has a local
165/// fallback, and in watch mode a previous answer, so a retry ladder would only
166/// stall the frame it is drawn into.
167pub fn fetch(key: &str) -> Result<Usage, Unavailable> {
168    let response = ureq::get(USAGE_URL)
169        .set("Authorization", &format!("Bearer {key}"))
170        // Go's docs ask clients to identify themselves rather than arrive as an
171        // anonymous HTTP library.
172        .set(
173            "User-Agent",
174            &format!("ocuse/{}", env!("CARGO_PKG_VERSION")),
175        )
176        .timeout(std::time::Duration::from_secs(10))
177        .call();
178    match response {
179        Ok(response) => {
180            let body = response
181                .into_string()
182                .map_err(|e| Unavailable::Transport(format!("usage: read: {e}")))?;
183            serde_json::from_str::<Envelope>(&body)
184                .map(|envelope| envelope.usage)
185                .map_err(|e| Unavailable::Transport(format!("usage: {e}")))
186        }
187        // A refused key, and the body says which refusal: an expired key and a
188        // key without the Go subscription are the same status, and only the
189        // server's own code tells them apart (`api_key_expired` is the one that
190        // means "create a new key").
191        Err(ureq::Error::Status(status, response)) if status == 401 || status == 403 => {
192            let kind = response
193                .into_string()
194                .ok()
195                .and_then(|body| serde_json::from_str::<ErrorBody>(&body).ok())
196                .and_then(|body| body.error)
197                .and_then(|detail| detail.code.or(detail.kind));
198            Err(Unavailable::Rejected { status, kind })
199        }
200        Err(e) => Err(Unavailable::Transport(format!("usage: {e}"))),
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    /// Exactly what the endpoint answered for this account on 2026-10-16.
209    const LIVE: &str = r#"{"usage":{
210        "rolling":{"status":"ok","percent":2,"resetsAt":"2026-10-16T10:57:34.000Z"},
211        "weekly":{"status":"ok","percent":58,"resetsAt":"2026-10-19T00:00:00.000Z"},
212        "monthly":{"status":"ok","percent":29,"resetsAt":"2026-11-13T09:14:35.000Z"}}}"#;
213
214    fn parse(text: &str) -> Usage {
215        serde_json::from_str::<Envelope>(text)
216            .expect("fixture parses")
217            .usage
218    }
219
220    #[test]
221    fn parses_the_live_response() {
222        let usage = parse(LIVE);
223        assert_eq!(usage.rolling.percent, 2.0);
224        assert_eq!(usage.weekly.percent, 58.0);
225        assert_eq!(usage.monthly.percent, 29.0);
226        assert!(!usage.weekly.exceeded());
227    }
228
229    #[test]
230    fn resets_at_becomes_epoch_ms() {
231        let usage = parse(LIVE);
232        // 2026-10-19T00:00:00Z — the Monday the weekly window turns over.
233        assert_eq!(usage.weekly.reset_ms(), Some(1_792_368_000_000));
234        // 2026-11-13T09:14:35Z — the subscription anniversary.
235        assert_eq!(usage.monthly.reset_ms(), Some(1_794_561_275_000));
236        assert_eq!(
237            usage.rolling.resets_at.as_deref(),
238            Some("2026-10-16T10:57:34.000Z")
239        );
240    }
241
242    #[test]
243    fn a_missing_reset_is_not_an_error() {
244        let usage = parse(
245            r#"{"usage":{
246            "rolling":{"status":"ok","percent":0},
247            "weekly":{"status":"ok","percent":0,"resetsAt":null},
248            "monthly":{"status":"ok","percent":0,"resetsAt":null}}}"#,
249        );
250        assert_eq!(usage.rolling.reset_ms(), None);
251    }
252
253    #[test]
254    fn a_non_ok_status_reads_as_exceeded() {
255        let usage = parse(
256            r#"{"usage":{
257            "rolling":{"status":"rate_limited","percent":100,"resetsAt":null},
258            "weekly":{"status":"ok","percent":99.9,"resetsAt":null},
259            "monthly":{"status":"ok","percent":100,"resetsAt":null}}}"#,
260        );
261        assert!(usage.rolling.exceeded(), "status is not ok");
262        assert!(!usage.weekly.exceeded(), "99.9% is not over");
263        assert!(usage.monthly.exceeded(), "100% is over");
264    }
265
266    /// The endpoint's own refusal, verbatim from the live 401 on 2026-10-19.
267    #[test]
268    fn a_refusal_names_the_servers_own_code() {
269        let body: ErrorBody = serde_json::from_str(
270            r#"{"error":{"type":"authentication_error","code":"api_key_expired","message":"This API key has expired. Create a new API key in OpenCode Console and replace it in your client or secret store."}}"#,
271        )
272        .expect("the refusal body parses");
273        let kind = body.error.and_then(|detail| detail.code.or(detail.kind));
274        let refused = Unavailable::Rejected { status: 401, kind };
275        // The code is the actionable half: it is the difference between "create a
276        // new key" and "try again later".
277        assert_eq!(
278            refused.reason().as_deref(),
279            Some("HTTP 401 api_key_expired")
280        );
281    }
282
283    /// A refusal with no `code` still names what it can: an entitlement error
284    /// carries only a `type`.
285    #[test]
286    fn a_refusal_without_a_code_falls_back_to_its_type() {
287        let body: ErrorBody =
288            serde_json::from_str(r#"{"error":{"type":"EntitlementError"}}"#).expect("parses");
289        let kind = body.error.and_then(|detail| detail.code.or(detail.kind));
290        assert_eq!(
291            Unavailable::Rejected { status: 403, kind }
292                .reason()
293                .as_deref(),
294            Some("HTTP 403 EntitlementError")
295        );
296    }
297
298    /// Only a fault earns a line: a missing key is worth saying, `--no-api` is a
299    /// choice the person already made.
300    #[test]
301    fn only_a_fault_gets_a_reason() {
302        assert_eq!(Unavailable::NoKey.reason().as_deref(), Some("no Go key"));
303        assert_eq!(Unavailable::Disabled.reason(), None);
304        assert_eq!(
305            Unavailable::Transport("usage: timed out".into())
306                .reason()
307                .as_deref(),
308            Some("usage: timed out")
309        );
310    }
311
312    #[test]
313    fn the_env_override_wins_over_null() {
314        // The env path is the documented way to point ocuse at another account.
315        std::env::set_var("OCUSE_API_KEY", "env-key");
316        assert_eq!(key().as_deref(), Some("env-key"));
317        std::env::remove_var("OCUSE_API_KEY");
318    }
319}