1use serde::Deserialize;
32
33pub const USAGE_URL: &str = "https://opencode.ai/zen/go/v1/usage";
35
36#[derive(Debug, Clone, Deserialize, PartialEq)]
39pub struct Meter {
40 pub status: String,
43 pub percent: f64,
45 #[serde(rename = "resetsAt")]
46 pub resets_at: Option<String>,
47}
48
49impl Meter {
50 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 pub fn exceeded(&self) -> bool {
60 self.percent >= 100.0 || !self.status.eq_ignore_ascii_case("ok")
61 }
62}
63
64#[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
78pub 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
97pub fn fetch(key: &str) -> Result<Usage, String> {
101 let response = ureq::get(USAGE_URL)
102 .set("Authorization", &format!("Bearer {key}"))
103 .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 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 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 assert_eq!(usage.weekly.reset_ms(), Some(1_792_368_000_000));
159 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 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}