1use 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";
21const API_KEY_ENV: &str = "OPENCODE_API_KEY";
23const AUTH_FILE_REL: &str = "opencode/auth.json";
27const AUTH_FILE_FALLBACK_REL: &str = ".local/share/opencode/auth.json";
28const AUTH_FILE_ENTRIES: &[&str] = &["opencode-go", "opencode"];
32
33#[derive(Debug, Clone, Copy, PartialEq)]
35struct ParsedWindow {
36 percent: f64,
38 resets_at: Option<DateTime<Utc>>,
40 limited: bool,
42}
43
44pub struct OpenCodeGoProvider {
45 metadata: ProviderMetadata,
46 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 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 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 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
172fn 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
183fn 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
196fn has_key_value(raw: &std::ffi::OsStr) -> bool {
199 raw.to_str().is_some_and(|s| clean_api_key(s).is_some())
200}
201
202fn 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
218fn 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
232fn 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 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#[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==", "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 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 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}