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,
}
#[derive(Deserialize)]
struct ErrorBody {
error: Option<ErrorDetail>,
}
#[derive(Deserialize)]
struct ErrorDetail {
code: Option<String>,
#[serde(rename = "type")]
kind: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Unavailable {
NoKey,
Disabled,
Rejected { status: u16, kind: Option<String> },
Transport(String),
}
impl Unavailable {
pub fn reason(&self) -> Option<String> {
match self {
Unavailable::Disabled => None,
Unavailable::NoKey => Some("no Go key".into()),
Unavailable::Rejected { status, kind } => Some(match kind {
Some(kind) => format!("HTTP {status} {kind}"),
None => format!("HTTP {status}"),
}),
Unavailable::Transport(why) => Some(why.clone()),
}
}
}
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, Unavailable> {
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| Unavailable::Transport(format!("usage: read: {e}")))?;
serde_json::from_str::<Envelope>(&body)
.map(|envelope| envelope.usage)
.map_err(|e| Unavailable::Transport(format!("usage: {e}")))
}
Err(ureq::Error::Status(status, response)) if status == 401 || status == 403 => {
let kind = response
.into_string()
.ok()
.and_then(|body| serde_json::from_str::<ErrorBody>(&body).ok())
.and_then(|body| body.error)
.and_then(|detail| detail.code.or(detail.kind));
Err(Unavailable::Rejected { status, kind })
}
Err(e) => Err(Unavailable::Transport(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 a_refusal_names_the_servers_own_code() {
let body: ErrorBody = serde_json::from_str(
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."}}"#,
)
.expect("the refusal body parses");
let kind = body.error.and_then(|detail| detail.code.or(detail.kind));
let refused = Unavailable::Rejected { status: 401, kind };
assert_eq!(
refused.reason().as_deref(),
Some("HTTP 401 api_key_expired")
);
}
#[test]
fn a_refusal_without_a_code_falls_back_to_its_type() {
let body: ErrorBody =
serde_json::from_str(r#"{"error":{"type":"EntitlementError"}}"#).expect("parses");
let kind = body.error.and_then(|detail| detail.code.or(detail.kind));
assert_eq!(
Unavailable::Rejected { status: 403, kind }
.reason()
.as_deref(),
Some("HTTP 403 EntitlementError")
);
}
#[test]
fn only_a_fault_gets_a_reason() {
assert_eq!(Unavailable::NoKey.reason().as_deref(), Some("no Go key"));
assert_eq!(Unavailable::Disabled.reason(), None);
assert_eq!(
Unavailable::Transport("usage: timed out".into())
.reason()
.as_deref(),
Some("usage: timed out")
);
}
#[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");
}
}