Skip to main content

usage_monitor_cli/provider/
opencode_go.rs

1//! Provider for OpenCode Go via the official Zen usage endpoint.
2//!
3//! Setup: the provider calls `GET https://opencode.ai/zen/go/v1/usage` with
4//! the OpenCode Go API key as `Authorization: Bearer <key>` and maps the
5//! account-wide `rolling` (5h), `weekly`, and optional `monthly` windows —
6//! the same used percents the OpenCode dashboard shows. The key can be set
7//! explicitly (`opencode-go set token <key>`) or auto-detected from
8//! `~/.local/share/opencode/auth.json` (the `opencode-go` entry, falling back
9//! to the `opencode` entry).
10//! See `docs/providers/opencode-go.md` for the full spec.
11
12use async_trait::async_trait;
13use chrono::{DateTime, Utc};
14
15use crate::error::SpendPanelError;
16use crate::model::{RateWindow, RateWindowStatus, UsageSnapshot};
17use crate::provider::{ProviderContext, ProviderMetadata, UsageProvider};
18
19const DEFAULT_BASE: &str = "https://opencode.ai";
20const USAGE_PATH: &str = "/zen/go/v1/usage";
21/// Environment variable holding the OpenCode Go API key (same name CodexBar uses).
22const API_KEY_ENV: &str = "OPENCODE_API_KEY";
23/// File holding the desktop login, whose key entries work as Bearer keys for
24/// the usage endpoint: `$XDG_DATA_HOME/opencode/auth.json`, falling back to
25/// `~/.local/share/opencode/auth.json`.
26const AUTH_FILE_REL: &str = "opencode/auth.json";
27const AUTH_FILE_FALLBACK_REL: &str = ".local/share/opencode/auth.json";
28/// Login-file entries tried in order when no explicit key is configured: the
29/// Go key first, then the main Console key (valid for the endpoint, bound to
30/// whatever subscription that login holds).
31const AUTH_FILE_ENTRIES: &[&str] = &["opencode-go", "opencode"];
32
33/// One parsed usage window from the endpoint payload.
34#[derive(Debug, Clone, Copy, PartialEq)]
35struct ParsedWindow {
36    /// 0–100 (used percent, as the dashboard shows it).
37    percent: f64,
38    /// Server-reported reset time.
39    resets_at: Option<DateTime<Utc>>,
40    /// True when the server reports the window as `rate-limited`.
41    limited: bool,
42}
43
44pub struct OpenCodeGoProvider {
45    metadata: ProviderMetadata,
46    /// Base URL override for tests.
47    base_url: Option<String>,
48}
49
50impl OpenCodeGoProvider {
51    pub fn new() -> Self {
52        Self {
53            metadata: ProviderMetadata {
54                id: "opencode-go",
55                name: "OpenCode Go",
56                description: "OpenCode Go quota via the official Zen usage endpoint (API key)",
57                auth_methods: &["api_key"],
58                website: Some("https://opencode.ai"),
59            },
60            base_url: None,
61        }
62    }
63
64    /// Creates a provider with a custom base URL (for tests).
65    pub fn with_base_url(url: &str) -> Self {
66        let mut p = Self::new();
67        p.base_url = Some(url.to_string());
68        p
69    }
70
71    fn api_base(&self) -> &str {
72        self.base_url.as_deref().unwrap_or(DEFAULT_BASE)
73    }
74
75    fn build_client(ctx: &ProviderContext) -> Result<reqwest::Client, SpendPanelError> {
76        reqwest::Client::builder()
77            .timeout(std::time::Duration::from_secs(ctx.timeout_secs))
78            .redirect(reqwest::redirect::Policy::none())
79            .build()
80            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))
81    }
82
83    /// API key from the `token`/`api_key` config field, the `OPENCODE_API_KEY`
84    /// env var, or the desktop login file. A pasted `Bearer <key>` value is
85    /// accepted and stripped. Legacy cookie values from the pre-endpoint
86    /// setup are never sent: they are skipped, and when nothing else
87    /// resolves, the error tells the user to configure an API key instead.
88    fn resolve_api_key(ctx: &ProviderContext) -> Result<String, SpendPanelError> {
89        let mut saw_legacy_cookie = false;
90        for field in ["token", "api_key"] {
91            if let Some(raw) = ctx.config.get(field) {
92                if looks_like_cookie(raw) {
93                    saw_legacy_cookie = true;
94                    continue;
95                }
96                if let Some(key) = clean_api_key(raw) {
97                    return Ok(key);
98                }
99            }
100        }
101        if let Some(raw) = std::env::var_os(API_KEY_ENV).and_then(|v| v.into_string().ok())
102            && let Some(key) = clean_api_key(&raw)
103        {
104            return Ok(key);
105        }
106        if let Some(key) = Self::auth_file_key() {
107            return Ok(key);
108        }
109        Err(SpendPanelError::AuthFailed(
110            "opencode-go".into(),
111            if saw_legacy_cookie {
112                "the configured token is a legacy dashboard cookie, which the usage endpoint rejects; run `usage-monitor-cli opencode-go set token \"<API key>\"` or sign in with opencode so ~/.local/share/opencode/auth.json holds a key (see docs/providers/opencode-go.md)".into()
113            } else {
114                "no API key configured; run `usage-monitor-cli opencode-go set token \"<key>\"` or sign in with opencode so ~/.local/share/opencode/auth.json holds an opencode-go key (see docs/providers/opencode-go.md)".into()
115            },
116        ))
117    }
118
119    /// Reads the first usable key from the desktop login file, if present.
120    fn auth_file_key() -> Option<String> {
121        let path = auth_file_path()?;
122        let raw = std::fs::read_to_string(path).ok()?;
123        let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
124        AUTH_FILE_ENTRIES
125            .iter()
126            .filter_map(|entry| json.get(entry)?.get("key")?.as_str())
127            .filter_map(clean_api_key)
128            .next()
129    }
130
131    fn rate_window(label: String, window_minutes: u32, w: &ParsedWindow) -> RateWindow {
132        let ratio = (w.percent / 100.0).clamp(0.0, 1.0);
133        RateWindow {
134            label,
135            window_minutes,
136            usage_ratio: ratio,
137            limit: None,
138            used: None,
139            remaining: None,
140            resets_at: w.resets_at,
141            status: if w.limited {
142                RateWindowStatus::Exhausted
143            } else {
144                RateWindowStatus::from_ratio(ratio)
145            },
146        }
147    }
148
149    fn snapshot_from_windows(
150        rolling: &ParsedWindow,
151        weekly: &ParsedWindow,
152        monthly: Option<&ParsedWindow>,
153    ) -> UsageSnapshot {
154        let mut snapshot = UsageSnapshot::new("opencode-go");
155        snapshot.collected_at = Utc::now();
156        snapshot.primary_rate_window = Some(Self::rate_window("Rolling (5h)".into(), 300, rolling));
157        snapshot.secondary_rate_window = Some(Self::rate_window("Weekly".into(), 10_080, weekly));
158        if let Some(monthly) = monthly {
159            snapshot.tertiary_rate_window =
160                Some(Self::rate_window("Monthly".into(), 43_200, monthly));
161        }
162        snapshot
163    }
164}
165
166impl Default for OpenCodeGoProvider {
167    fn default() -> Self {
168        Self::new()
169    }
170}
171
172/// Login-file location: `$XDG_DATA_HOME/opencode/auth.json`, falling back to
173/// `~/.local/share/opencode/auth.json` when `XDG_DATA_HOME` is unset/empty.
174fn auth_file_path() -> Option<std::path::PathBuf> {
175    if let Some(xdg) = std::env::var_os("XDG_DATA_HOME")
176        && !xdg.is_empty()
177    {
178        return Some(std::path::PathBuf::from(xdg).join(AUTH_FILE_REL));
179    }
180    std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(AUTH_FILE_FALLBACK_REL))
181}
182
183/// Detects leftover values from the pre-endpoint cookie setup: a full
184/// `Cookie:` header, an `auth=<value>` pair, a multi-cookie header, or a
185/// bare Better-Auth session token. A lone `=` is NOT treated as a cookie so
186/// base64-padded API keys keep working.
187fn looks_like_cookie(raw: &str) -> bool {
188    let value = raw.trim();
189    let lower = value.to_lowercase();
190    value.contains(';')
191        || lower.starts_with("cookie:")
192        || lower.starts_with("auth=")
193        || value.starts_with("Fe26.")
194}
195
196/// True when an env/file value holds a usable key (trims whitespace, unlike a
197/// bare emptiness check).
198fn has_key_value(raw: &std::ffi::OsStr) -> bool {
199    raw.to_str().is_some_and(|s| clean_api_key(s).is_some())
200}
201
202/// Trims a pasted key, accepting a leading `Bearer ` scheme the same way
203/// other Bearer-token providers do. Empty values resolve to `None`.
204fn clean_api_key(raw: &str) -> Option<String> {
205    let value = raw.trim();
206    let value = value
207        .strip_prefix("Bearer ")
208        .or_else(|| value.strip_prefix("bearer "))
209        .map(str::trim)
210        .unwrap_or(value);
211    if value.is_empty() {
212        None
213    } else {
214        Some(value.to_string())
215    }
216}
217
218/// Extracts a server-provided error message (`{"error": {"message": ...}}` or
219/// `{"error": "..."}`), when present.
220fn server_error_message(body: &str) -> Option<String> {
221    let json: serde_json::Value = serde_json::from_str(body).ok()?;
222    let error = json.get("error")?;
223    if let Some(message) = error.get("message").and_then(|m| m.as_str()) {
224        return (!message.is_empty()).then(|| message.to_string());
225    }
226    error
227        .as_str()
228        .filter(|s| !s.is_empty())
229        .map(|s| s.to_string())
230}
231
232/// Parses one usage window (`{status, percent, resetsAt}`); returns `None`
233/// when the value is missing or malformed so callers can decide whether the
234/// window is required or optional.
235fn parse_window(value: Option<&serde_json::Value>) -> Option<ParsedWindow> {
236    let window = value?;
237    let percent = window.get("percent")?.as_f64()?;
238    if !percent.is_finite() || percent < 0.0 || percent > 100.0 {
239        return None;
240    }
241    let status = window.get("status").and_then(|s| s.as_str()).unwrap_or("");
242    if status != "ok" && status != "rate-limited" {
243        return None;
244    }
245    let resets_at = window
246        .get("resetsAt")
247        .or_else(|| window.get("resets_at"))
248        .or_else(|| window.get("renewsAt"))
249        .and_then(|v| v.as_str())
250        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
251        .map(|dt| dt.with_timezone(&Utc));
252    Some(ParsedWindow {
253        percent,
254        resets_at,
255        limited: status == "rate-limited",
256    })
257}
258
259#[async_trait]
260impl UsageProvider for OpenCodeGoProvider {
261    fn metadata(&self) -> &ProviderMetadata {
262        &self.metadata
263    }
264
265    fn detect_credentials(&self) -> bool {
266        if std::env::var_os(API_KEY_ENV).is_some_and(|v| has_key_value(&v)) {
267            return true;
268        }
269        Self::auth_file_key().is_some()
270    }
271
272    async fn fetch_usage(&self, ctx: &ProviderContext) -> Result<UsageSnapshot, SpendPanelError> {
273        let key = Self::resolve_api_key(ctx)?;
274        let client = Self::build_client(ctx)?;
275        let url = format!("{}{}", self.api_base(), USAGE_PATH);
276
277        let resp = client
278            .get(&url)
279            .header("Authorization", format!("Bearer {}", key))
280            .header("Accept", "application/json")
281            .send()
282            .await
283            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
284
285        let status = resp.status();
286        // `text()` consumes the response, so grab `retry-after` first.
287        let retry_after = resp
288            .headers()
289            .get("retry-after")
290            .and_then(|v| v.to_str().ok())
291            .and_then(|s| s.trim().parse::<u64>().ok());
292        let body = resp
293            .text()
294            .await
295            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
296
297        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
298            let detail = server_error_message(&body)
299                .map(|m| format!(": {}", m))
300                .unwrap_or_default();
301            return Err(SpendPanelError::AuthFailed(
302                "opencode-go".into(),
303                format!(
304                    "API key rejected (HTTP {}){}; check the key or subscription at https://opencode.ai",
305                    status.as_u16(),
306                    detail
307                ),
308            ));
309        }
310        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
311            return Err(SpendPanelError::RateLimited(
312                "opencode-go".into(),
313                retry_after,
314            ));
315        }
316        if !status.is_success() {
317            return Err(SpendPanelError::ProviderError(
318                "opencode-go".into(),
319                format!("usage endpoint HTTP {}", status.as_u16()),
320            ));
321        }
322
323        let json: serde_json::Value = serde_json::from_str(&body)
324            .map_err(|e| SpendPanelError::ParseError("opencode-go".into(), e.to_string()))?;
325        let usage = json.get("usage");
326        let rolling = parse_window(usage.and_then(|u| u.get("rolling")));
327        let weekly = parse_window(usage.and_then(|u| u.get("weekly")));
328        match (rolling, weekly) {
329            (Some(rolling), Some(weekly)) => {
330                let monthly = parse_window(usage.and_then(|u| u.get("monthly")));
331                Ok(Self::snapshot_from_windows(
332                    &rolling,
333                    &weekly,
334                    monthly.as_ref(),
335                ))
336            }
337            _ => Err(SpendPanelError::ParseError(
338                "opencode-go".into(),
339                "response is missing rolling/weekly usage windows".into(),
340            )),
341        }
342    }
343}
344
345// ---------------------------------------------------------------------------
346// Tests
347// ---------------------------------------------------------------------------
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use wiremock::matchers::{header, method, path};
353    use wiremock::{Mock, MockServer, ResponseTemplate};
354
355    fn usage_payload(rolling: &str, weekly: &str, monthly: Option<&str>) -> String {
356        let monthly_part = monthly
357            .map(|m| format!(r#","monthly":{}"#, m))
358            .unwrap_or_default();
359        format!(
360            r#"{{"usage":{{"rolling":{},"weekly":{}{}}}}}"#,
361            rolling, weekly, monthly_part
362        )
363    }
364
365    fn window(status: &str, percent: f64, resets_at: &str) -> String {
366        format!(
367            r#"{{"status":"{}","percent":{},"resetsAt":"{}"}}"#,
368            status, percent, resets_at
369        )
370    }
371
372    #[test]
373    fn test_clean_api_key() {
374        assert_eq!(clean_api_key("  abc123  "), Some("abc123".into()));
375        assert_eq!(clean_api_key("Bearer abc123"), Some("abc123".into()));
376        assert_eq!(clean_api_key("bearer abc123  "), Some("abc123".into()));
377        assert_eq!(clean_api_key("   "), None);
378        assert_eq!(clean_api_key(""), None);
379    }
380
381    #[test]
382    fn test_resolve_api_key_token_field_preferred() {
383        let mut ctx = ProviderContext::new();
384        ctx.config.insert("token".into(), "key-token".into());
385        ctx.config.insert("api_key".into(), "key-alias".into());
386        assert_eq!(
387            OpenCodeGoProvider::resolve_api_key(&ctx).unwrap(),
388            "key-token"
389        );
390
391        let mut ctx = ProviderContext::new();
392        ctx.config.insert("api_key".into(), "key-alias".into());
393        assert_eq!(
394            OpenCodeGoProvider::resolve_api_key(&ctx).unwrap(),
395            "key-alias"
396        );
397    }
398
399    #[test]
400    fn test_looks_like_cookie() {
401        for raw in [
402            "auth=Fe26.abc123",
403            "Fe26.abc123",
404            "Cookie: auth=Fe26.abc; other=1",
405            "cookie: auth=x",
406            "a=b; c=d",
407            "  auth=Fe26.abc  ",
408        ] {
409            assert!(looks_like_cookie(raw), "should detect {raw}");
410        }
411        for raw in [
412            "sk-opengo-abc123",
413            "oc_sk_live_abc123",
414            "Bearer sk-opengo-abc123",
415            "abc123==", // base64 padding alone is not a cookie
416            "plain-key-without-separators",
417        ] {
418            assert!(!looks_like_cookie(raw), "should accept {raw}");
419        }
420    }
421
422    #[test]
423    fn test_resolve_api_key_skips_legacy_cookie_for_valid_alias() {
424        // A stale cookie in `token` is skipped; the `api_key` alias still wins.
425        let mut ctx = ProviderContext::new();
426        ctx.config.insert("token".into(), "auth=Fe26.stale".into());
427        ctx.config.insert("api_key".into(), "live-key".into());
428        assert_eq!(
429            OpenCodeGoProvider::resolve_api_key(&ctx).unwrap(),
430            "live-key"
431        );
432    }
433
434    #[test]
435    fn test_has_key_value_trims_whitespace() {
436        use std::ffi::OsStr;
437        assert!(has_key_value(OsStr::new("k")));
438        assert!(!has_key_value(OsStr::new("   ")));
439        assert!(!has_key_value(OsStr::new("")));
440    }
441
442    #[test]
443    fn test_resolve_api_key_strips_bearer_prefix() {
444        let mut ctx = ProviderContext::new();
445        ctx.config
446            .insert("token".into(), "Bearer pasted-key".into());
447        assert_eq!(
448            OpenCodeGoProvider::resolve_api_key(&ctx).unwrap(),
449            "pasted-key"
450        );
451    }
452
453    #[test]
454    fn test_parse_window_ok() {
455        let v: serde_json::Value = serde_json::from_str(
456            r#"{"status":"ok","percent":42.5,"resetsAt":"2026-09-23T00:00:00.000Z"}"#,
457        )
458        .unwrap();
459        let w = parse_window(Some(&v)).unwrap();
460        assert!((w.percent - 42.5).abs() < 1e-9);
461        assert!(!w.limited);
462        assert!(w.resets_at.is_some());
463    }
464
465    #[test]
466    fn test_parse_window_rate_limited() {
467        let v: serde_json::Value = serde_json::from_str(
468            r#"{"status":"rate-limited","percent":100,"resetsAt":"2026-09-23T00:00:00Z"}"#,
469        )
470        .unwrap();
471        let w = parse_window(Some(&v)).unwrap();
472        assert!(w.limited);
473    }
474
475    #[test]
476    fn test_parse_window_rejects_malformed() {
477        assert!(parse_window(None).is_none());
478        for raw in [
479            r#"{"status":"ok","percent":140,"resetsAt":"2026-09-23T00:00:00Z"}"#,
480            r#"{"status":"ok","percent":-1,"resetsAt":"2026-09-23T00:00:00Z"}"#,
481            r#"{"status":"weird","percent":10,"resetsAt":"2026-09-23T00:00:00Z"}"#,
482            r#"{"status":"ok","resetsAt":"2026-09-23T00:00:00Z"}"#,
483            r#"{"status":"ok","percent":10}"#,
484        ] {
485            // Missing resetsAt is fine (reset time unknown); everything else
486            // must be rejected.
487            let v: serde_json::Value = serde_json::from_str(raw).unwrap();
488            let parsed = parse_window(Some(&v));
489            if raw.contains("\"percent\":140")
490                || raw.contains("\"percent\":-1")
491                || raw.contains("\"status\":\"weird\"")
492                || !raw.contains("percent")
493            {
494                assert!(parsed.is_none(), "should reject {raw}");
495            } else {
496                assert!(parsed.is_some(), "should accept {raw}");
497            }
498        }
499    }
500
501    #[test]
502    fn test_server_error_message() {
503        assert_eq!(
504            server_error_message(r#"{"error":{"message":"EntitlementError"}}"#).as_deref(),
505            Some("EntitlementError")
506        );
507        assert_eq!(
508            server_error_message(r#"{"error":"boom"}"#).as_deref(),
509            Some("boom")
510        );
511        assert!(server_error_message(r#"{"ok":true}"#).is_none());
512        assert!(server_error_message("not json").is_none());
513    }
514
515    #[test]
516    fn test_provider_metadata() {
517        let p = OpenCodeGoProvider::new();
518        assert_eq!(p.metadata().id, "opencode-go");
519        assert!(p.metadata().auth_methods.contains(&"api_key"));
520    }
521
522    #[tokio::test]
523    async fn test_fetch_maps_windows() {
524        let server = MockServer::start().await;
525        Mock::given(method("GET"))
526            .and(path("/zen/go/v1/usage"))
527            .and(header("Authorization", "Bearer test-key"))
528            .respond_with(ResponseTemplate::new(200).set_body_string(usage_payload(
529                &window("ok", 10.0, "2026-09-23T05:00:00.000Z"),
530                &window("ok", 50.0, "2026-09-29T00:00:00.000Z"),
531                Some(&window("ok", 5.0, "2026-10-23T00:00:00.000Z")),
532            )))
533            .mount(&server)
534            .await;
535
536        let provider = OpenCodeGoProvider::with_base_url(&server.uri());
537        let mut ctx = ProviderContext::new();
538        ctx.config.insert("token".into(), "test-key".into());
539
540        let snap = provider.fetch_usage(&ctx).await.unwrap();
541        assert_eq!(snap.provider_id, "opencode-go");
542
543        let primary = snap.primary_rate_window.unwrap();
544        assert_eq!(primary.label, "Rolling (5h)");
545        assert_eq!(primary.window_minutes, 300);
546        assert!((primary.usage_ratio - 0.10).abs() < 1e-9);
547        assert_eq!(primary.status, RateWindowStatus::Normal);
548
549        let secondary = snap.secondary_rate_window.unwrap();
550        assert_eq!(secondary.label, "Weekly");
551        assert!((secondary.usage_ratio - 0.50).abs() < 1e-9);
552
553        let tertiary = snap.tertiary_rate_window.unwrap();
554        assert_eq!(tertiary.label, "Monthly");
555        assert!((tertiary.usage_ratio - 0.05).abs() < 1e-9);
556        assert!(snap.extra_rate_windows.is_empty());
557    }
558
559    #[tokio::test]
560    async fn test_fetch_without_monthly() {
561        let server = MockServer::start().await;
562        Mock::given(method("GET"))
563            .and(path("/zen/go/v1/usage"))
564            .respond_with(ResponseTemplate::new(200).set_body_string(usage_payload(
565                &window("ok", 20.0, "2026-09-23T05:00:00.000Z"),
566                &window("ok", 95.0, "2026-09-29T00:00:00.000Z"),
567                None,
568            )))
569            .mount(&server)
570            .await;
571
572        let provider = OpenCodeGoProvider::with_base_url(&server.uri());
573        let mut ctx = ProviderContext::new();
574        ctx.config.insert("api_key".into(), "k".into());
575
576        let snap = provider.fetch_usage(&ctx).await.unwrap();
577        assert!(snap.tertiary_rate_window.is_none());
578        assert_eq!(
579            snap.secondary_rate_window.unwrap().status,
580            RateWindowStatus::Critical
581        );
582    }
583
584    #[tokio::test]
585    async fn test_fetch_rate_limited_window_is_exhausted() {
586        let server = MockServer::start().await;
587        Mock::given(method("GET"))
588            .and(path("/zen/go/v1/usage"))
589            .respond_with(ResponseTemplate::new(200).set_body_string(usage_payload(
590                &window("rate-limited", 67.0, "2026-09-23T05:00:00.000Z"),
591                &window("ok", 10.0, "2026-09-29T00:00:00.000Z"),
592                None,
593            )))
594            .mount(&server)
595            .await;
596
597        let provider = OpenCodeGoProvider::with_base_url(&server.uri());
598        let mut ctx = ProviderContext::new();
599        ctx.config.insert("token".into(), "k".into());
600
601        let snap = provider.fetch_usage(&ctx).await.unwrap();
602        assert_eq!(
603            snap.primary_rate_window.unwrap().status,
604            RateWindowStatus::Exhausted
605        );
606    }
607
608    #[tokio::test]
609    async fn test_fetch_401_is_auth_failed() {
610        let server = MockServer::start().await;
611        Mock::given(method("GET"))
612            .and(path("/zen/go/v1/usage"))
613            .respond_with(ResponseTemplate::new(401))
614            .mount(&server)
615            .await;
616
617        let provider = OpenCodeGoProvider::with_base_url(&server.uri());
618        let mut ctx = ProviderContext::new();
619        ctx.config.insert("token".into(), "bad".into());
620
621        let err = provider.fetch_usage(&ctx).await.unwrap_err();
622        assert!(matches!(err, SpendPanelError::AuthFailed(_, _)));
623        assert!(err.to_string().contains("401"));
624    }
625
626    #[tokio::test]
627    async fn test_fetch_403_includes_server_message() {
628        let server = MockServer::start().await;
629        Mock::given(method("GET"))
630            .and(path("/zen/go/v1/usage"))
631            .respond_with(
632                ResponseTemplate::new(403)
633                    .set_body_string(r#"{"error":{"message":"EntitlementError"}}"#),
634            )
635            .mount(&server)
636            .await;
637
638        let provider = OpenCodeGoProvider::with_base_url(&server.uri());
639        let mut ctx = ProviderContext::new();
640        ctx.config.insert("token".into(), "no-sub".into());
641
642        let err = provider.fetch_usage(&ctx).await.unwrap_err();
643        assert!(matches!(err, SpendPanelError::AuthFailed(_, _)));
644        assert!(err.to_string().contains("EntitlementError"));
645    }
646
647    #[tokio::test]
648    async fn test_fetch_missing_windows_is_parse_error() {
649        let server = MockServer::start().await;
650        Mock::given(method("GET"))
651            .and(path("/zen/go/v1/usage"))
652            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"usage":{}}"#))
653            .mount(&server)
654            .await;
655
656        let provider = OpenCodeGoProvider::with_base_url(&server.uri());
657        let mut ctx = ProviderContext::new();
658        ctx.config.insert("token".into(), "k".into());
659
660        let err = provider.fetch_usage(&ctx).await.unwrap_err();
661        assert!(matches!(err, SpendPanelError::ParseError(_, _)));
662    }
663
664    #[tokio::test]
665    async fn test_fetch_server_error() {
666        let server = MockServer::start().await;
667        Mock::given(method("GET"))
668            .and(path("/zen/go/v1/usage"))
669            .respond_with(ResponseTemplate::new(500))
670            .mount(&server)
671            .await;
672
673        let provider = OpenCodeGoProvider::with_base_url(&server.uri());
674        let mut ctx = ProviderContext::new();
675        ctx.config.insert("token".into(), "k".into());
676
677        let err = provider.fetch_usage(&ctx).await.unwrap_err();
678        assert!(matches!(err, SpendPanelError::ProviderError(_, _)));
679    }
680
681    #[tokio::test]
682    async fn test_fetch_429_is_rate_limited_with_retry_after() {
683        let server = MockServer::start().await;
684        Mock::given(method("GET"))
685            .and(path("/zen/go/v1/usage"))
686            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "120"))
687            .mount(&server)
688            .await;
689
690        let provider = OpenCodeGoProvider::with_base_url(&server.uri());
691        let mut ctx = ProviderContext::new();
692        ctx.config.insert("token".into(), "k".into());
693
694        let err = provider.fetch_usage(&ctx).await.unwrap_err();
695        assert!(
696            matches!(err, SpendPanelError::RateLimited(_, Some(120))),
697            "got: {err}"
698        );
699    }
700
701    #[tokio::test]
702    async fn test_fetch_429_without_retry_after() {
703        let server = MockServer::start().await;
704        Mock::given(method("GET"))
705            .and(path("/zen/go/v1/usage"))
706            .respond_with(ResponseTemplate::new(429))
707            .mount(&server)
708            .await;
709
710        let provider = OpenCodeGoProvider::with_base_url(&server.uri());
711        let mut ctx = ProviderContext::new();
712        ctx.config.insert("token".into(), "k".into());
713
714        let err = provider.fetch_usage(&ctx).await.unwrap_err();
715        assert!(
716            matches!(err, SpendPanelError::RateLimited(_, None)),
717            "got: {err}"
718        );
719    }
720}