use serde::Deserialize;
pub const USAGE_URL: &str = "https://opencode.ai/zen/go/v1/usage";
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Meter {
pub status: String,
pub percent: f64,
#[serde(rename = "resetsAt")]
pub resets_at: Option<String>,
}
impl Meter {
pub fn reset_ms(&self) -> Option<i64> {
self.resets_at
.as_deref()
.and_then(cmduse_core::dates::parse_iso_utc)
.map(|ms| ms as i64)
}
pub fn exceeded(&self) -> bool {
self.percent >= 100.0 || !self.status.eq_ignore_ascii_case("ok")
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Usage {
pub rolling: Meter,
pub weekly: Meter,
pub monthly: Meter,
}
#[derive(Deserialize)]
struct Envelope {
usage: Usage,
}
pub fn key() -> Option<String> {
for var in ["OCUSE_API_KEY", "OPENCODE_API_KEY"] {
if let Ok(value) = std::env::var(var) {
if !value.trim().is_empty() {
return Some(value.trim().to_string());
}
}
}
let path = format!("{}/auth.json", crate::ocuse::db::opencode_home());
let text = std::fs::read_to_string(path).ok()?;
let auth: serde_json::Value = serde_json::from_str(&text).ok()?;
auth["opencode-go"]["key"]
.as_str()
.filter(|key| !key.is_empty())
.map(str::to_string)
}
pub fn fetch(key: &str) -> Result<Usage, String> {
let response = ureq::get(USAGE_URL)
.set("Authorization", &format!("Bearer {key}"))
.set(
"User-Agent",
&format!("ocuse/{}", env!("CARGO_PKG_VERSION")),
)
.timeout(std::time::Duration::from_secs(10))
.call();
match response {
Ok(response) => {
let body = response
.into_string()
.map_err(|e| format!("usage: read: {e}"))?;
serde_json::from_str::<Envelope>(&body)
.map(|envelope| envelope.usage)
.map_err(|e| format!("usage: {e}"))
}
Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => {
Err(format!("usage: HTTP {code} (key has no Go subscription)"))
}
Err(e) => Err(format!("usage: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
const LIVE: &str = r#"{"usage":{
"rolling":{"status":"ok","percent":2,"resetsAt":"2026-10-16T10:57:34.000Z"},
"weekly":{"status":"ok","percent":58,"resetsAt":"2026-10-19T00:00:00.000Z"},
"monthly":{"status":"ok","percent":29,"resetsAt":"2026-11-13T09:14:35.000Z"}}}"#;
fn parse(text: &str) -> Usage {
serde_json::from_str::<Envelope>(text)
.expect("fixture parses")
.usage
}
#[test]
fn parses_the_live_response() {
let usage = parse(LIVE);
assert_eq!(usage.rolling.percent, 2.0);
assert_eq!(usage.weekly.percent, 58.0);
assert_eq!(usage.monthly.percent, 29.0);
assert!(!usage.weekly.exceeded());
}
#[test]
fn resets_at_becomes_epoch_ms() {
let usage = parse(LIVE);
assert_eq!(usage.weekly.reset_ms(), Some(1_792_368_000_000));
assert_eq!(usage.monthly.reset_ms(), Some(1_794_561_275_000));
assert_eq!(
usage.rolling.resets_at.as_deref(),
Some("2026-10-16T10:57:34.000Z")
);
}
#[test]
fn a_missing_reset_is_not_an_error() {
let usage = parse(
r#"{"usage":{
"rolling":{"status":"ok","percent":0},
"weekly":{"status":"ok","percent":0,"resetsAt":null},
"monthly":{"status":"ok","percent":0,"resetsAt":null}}}"#,
);
assert_eq!(usage.rolling.reset_ms(), None);
}
#[test]
fn a_non_ok_status_reads_as_exceeded() {
let usage = parse(
r#"{"usage":{
"rolling":{"status":"rate_limited","percent":100,"resetsAt":null},
"weekly":{"status":"ok","percent":99.9,"resetsAt":null},
"monthly":{"status":"ok","percent":100,"resetsAt":null}}}"#,
);
assert!(usage.rolling.exceeded(), "status is not ok");
assert!(!usage.weekly.exceeded(), "99.9% is not over");
assert!(usage.monthly.exceeded(), "100% is over");
}
#[test]
fn the_env_override_wins_over_null() {
std::env::set_var("OCUSE_API_KEY", "env-key");
assert_eq!(key().as_deref(), Some("env-key"));
std::env::remove_var("OCUSE_API_KEY");
}
}