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