1use std::path::{Path, PathBuf};
9
10use async_trait::async_trait;
11use chrono::{DateTime, Utc};
12
13use crate::error::SpendPanelError;
14use crate::model::{
15 CreditsSnapshot, NamedRateWindow, PlanInfo, RateWindow, RateWindowStatus, UsageSnapshot,
16};
17use crate::provider::{ProviderContext, ProviderMetadata, UsageProvider};
18
19const OAUTH_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
21const OAUTH_BETA_HEADER: &str = "oauth-2025-04-20";
23const USER_AGENT: &str = "claude-code/2.1.0";
25
26const DEFAULT_API_BASE: &str = "https://api.anthropic.com";
27const DEFAULT_TOKEN_BASE: &str = "https://platform.claude.com";
28
29#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)]
34struct CredentialsFile {
35 #[serde(rename = "claudeAiOauth")]
36 claude_ai_oauth: Option<OAuthSection>,
37 #[serde(flatten)]
38 extra: serde_json::Map<String, serde_json::Value>,
39}
40
41#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)]
42struct OAuthSection {
43 #[serde(rename = "accessToken")]
44 access_token: Option<String>,
45 #[serde(rename = "refreshToken")]
46 refresh_token: Option<String>,
47 #[serde(rename = "expiresAt")]
49 expires_at: Option<f64>,
50 #[serde(rename = "subscriptionType")]
51 subscription_type: Option<String>,
52 #[serde(flatten)]
53 extra: serde_json::Map<String, serde_json::Value>,
54}
55
56#[derive(Debug, Clone, PartialEq)]
58pub struct ClaudeOAuthCredentials {
59 pub access_token: String,
60 pub refresh_token: Option<String>,
61 pub expires_at: Option<DateTime<Utc>>,
62 pub subscription_type: Option<String>,
63}
64
65impl ClaudeOAuthCredentials {
66 pub fn default_path() -> Option<PathBuf> {
68 std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".claude/.credentials.json"))
69 }
70
71 pub fn load_from_path(path: &Path) -> Result<Self, SpendPanelError> {
73 let raw = std::fs::read_to_string(path).map_err(|e| {
74 SpendPanelError::AuthFailed(
75 "claude".into(),
76 format!("cannot read credentials at {}: {}", path.display(), e),
77 )
78 })?;
79 Self::parse(&raw)
80 }
81
82 pub fn parse(raw: &str) -> Result<Self, SpendPanelError> {
84 let file: CredentialsFile = serde_json::from_str(raw).map_err(|e| {
85 SpendPanelError::ParseError("claude".into(), format!("credentials: {}", e))
86 })?;
87 let oauth = file.claude_ai_oauth.ok_or_else(|| {
88 SpendPanelError::AuthFailed(
89 "claude".into(),
90 "no claudeAiOauth section in credentials".into(),
91 )
92 })?;
93 let access_token = oauth.access_token.unwrap_or_default().trim().to_string();
94 if access_token.is_empty() {
95 return Err(SpendPanelError::AuthFailed(
96 "claude".into(),
97 "empty access token in credentials".into(),
98 ));
99 }
100 Ok(Self {
101 access_token,
102 refresh_token: oauth.refresh_token,
103 expires_at: oauth.expires_at.and_then(millis_to_datetime),
104 subscription_type: oauth.subscription_type,
105 })
106 }
107
108 pub fn is_expired(&self) -> bool {
110 match self.expires_at {
111 Some(at) => Utc::now() >= at,
112 None => false,
113 }
114 }
115}
116
117fn millis_to_datetime(millis: f64) -> Option<DateTime<Utc>> {
118 DateTime::<Utc>::from_timestamp_millis(millis as i64)
119}
120
121#[derive(serde::Deserialize, Debug, Default)]
126struct OAuthUsageWindow {
127 utilization: Option<f64>,
129 resets_at: Option<String>,
131}
132
133#[derive(serde::Deserialize, Debug, Default)]
134struct OAuthExtraUsage {
135 is_enabled: Option<bool>,
136 monthly_limit: Option<f64>,
137 used_credits: Option<f64>,
138 currency: Option<String>,
139}
140
141#[derive(serde::Deserialize, Debug, Default)]
142struct OAuthUsageResponse {
143 five_hour: Option<OAuthUsageWindow>,
144 seven_day: Option<OAuthUsageWindow>,
145 seven_day_opus: Option<OAuthUsageWindow>,
146 seven_day_sonnet: Option<OAuthUsageWindow>,
147 extra_usage: Option<OAuthExtraUsage>,
148}
149
150#[derive(serde::Deserialize, Debug)]
151struct TokenRefreshResponse {
152 access_token: String,
153 refresh_token: Option<String>,
154 expires_in: Option<f64>,
156}
157
158pub struct ClaudeProvider {
163 metadata: ProviderMetadata,
164 api_base: Option<String>,
166 token_base: Option<String>,
168}
169
170impl ClaudeProvider {
171 pub fn new() -> Self {
172 Self {
173 metadata: ProviderMetadata {
174 id: "claude",
175 name: "Claude (subscription)",
176 description: "Claude Pro/Max subscription usage monitor via Claude Code OAuth",
177 auth_methods: &["oauth", "cli"],
178 website: Some("https://claude.ai"),
179 },
180 api_base: None,
181 token_base: None,
182 }
183 }
184
185 pub fn with_base_urls(api_base: &str, token_base: &str) -> Self {
187 let mut p = Self::new();
188 p.api_base = Some(api_base.to_string());
189 p.token_base = Some(token_base.to_string());
190 p
191 }
192
193 fn api_base(&self) -> &str {
194 self.api_base.as_deref().unwrap_or(DEFAULT_API_BASE)
195 }
196
197 fn token_base(&self) -> &str {
198 self.token_base.as_deref().unwrap_or(DEFAULT_TOKEN_BASE)
199 }
200
201 fn detect_credentials_at(path: Option<&Path>) -> bool {
203 path.is_some_and(|p| p.exists())
204 }
205
206 fn credentials_path(ctx: &ProviderContext) -> Result<PathBuf, SpendPanelError> {
207 if let Some(p) = ctx.config.get("credentials_path") {
208 return Ok(PathBuf::from(p));
209 }
210 ClaudeOAuthCredentials::default_path().ok_or_else(|| {
211 SpendPanelError::ConfigError("cannot resolve HOME for claude credentials".into())
212 })
213 }
214
215 fn build_client(ctx: &ProviderContext) -> Result<reqwest::Client, SpendPanelError> {
216 reqwest::Client::builder()
217 .timeout(std::time::Duration::from_secs(ctx.timeout_secs))
218 .build()
219 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))
220 }
221
222 async fn refresh_token(
225 token_base: &str,
226 client: &reqwest::Client,
227 creds: &ClaudeOAuthCredentials,
228 persist_path: Option<&Path>,
229 ) -> Result<ClaudeOAuthCredentials, SpendPanelError> {
230 let refresh_token = creds.refresh_token.as_deref().ok_or_else(|| {
231 SpendPanelError::AuthFailed(
232 "claude".into(),
233 "access token expired and no refresh token available; run `claude` to re-authenticate".into(),
234 )
235 })?;
236
237 let url = format!("{}/v1/oauth/token", token_base);
238 let resp = client
239 .post(&url)
240 .form(&[
241 ("grant_type", "refresh_token"),
242 ("refresh_token", refresh_token),
243 ("client_id", OAUTH_CLIENT_ID),
244 ])
245 .send()
246 .await
247 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
248
249 let status = resp.status();
250 let body = resp
251 .text()
252 .await
253 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
254 if !status.is_success() {
255 return Err(SpendPanelError::AuthFailed(
256 "claude".into(),
257 format!("token refresh failed (HTTP {}): {}", status, body),
258 ));
259 }
260
261 let token: TokenRefreshResponse = serde_json::from_str(&body).map_err(|e| {
262 SpendPanelError::ParseError("claude".into(), format!("token refresh: {}", e))
263 })?;
264
265 let expires_at = token
266 .expires_in
267 .map(|secs| Utc::now() + chrono::Duration::milliseconds((secs * 1000.0) as i64));
268
269 let refreshed = ClaudeOAuthCredentials {
270 access_token: token.access_token,
271 refresh_token: token.refresh_token.or_else(|| creds.refresh_token.clone()),
272 expires_at,
273 subscription_type: creds.subscription_type.clone(),
274 };
275
276 if let Some(path) = persist_path
277 && let Err(e) = Self::persist_credentials(path, &refreshed)
278 {
279 tracing::warn!("failed to persist refreshed claude credentials: {}", e);
280 }
281
282 Ok(refreshed)
283 }
284
285 fn persist_credentials(
287 path: &Path,
288 creds: &ClaudeOAuthCredentials,
289 ) -> Result<(), SpendPanelError> {
290 let raw = std::fs::read_to_string(path)
291 .map_err(|e| SpendPanelError::ConfigError(format!("read credentials: {}", e)))?;
292 let mut file: CredentialsFile = serde_json::from_str(&raw).map_err(|e| {
293 SpendPanelError::ParseError("claude".into(), format!("credentials: {}", e))
294 })?;
295
296 let mut section = file.claude_ai_oauth.take().unwrap_or(OAuthSection {
297 access_token: None,
298 refresh_token: None,
299 expires_at: None,
300 subscription_type: None,
301 extra: serde_json::Map::new(),
302 });
303 section.access_token = Some(creds.access_token.clone());
304 section.refresh_token = creds.refresh_token.clone();
305 section.expires_at = creds.expires_at.map(|at| at.timestamp_millis() as f64);
306 section.subscription_type = creds.subscription_type.clone();
307 file.claude_ai_oauth = Some(section);
308
309 let serialized = serde_json::to_string(&file).map_err(|e| {
310 SpendPanelError::ParseError("claude".into(), format!("credentials: {}", e))
311 })?;
312 std::fs::write(path, serialized)
313 .map_err(|e| SpendPanelError::ConfigError(format!("write credentials: {}", e)))
314 }
315
316 async fn fetch_oauth_usage(
318 api_base: &str,
319 client: &reqwest::Client,
320 access_token: &str,
321 ) -> Result<OAuthUsageResponse, SpendPanelError> {
322 let url = format!("{}/api/oauth/usage", api_base);
323 let resp = client
324 .get(&url)
325 .header("authorization", format!("Bearer {}", access_token))
326 .header("anthropic-beta", OAUTH_BETA_HEADER)
327 .header("accept", "application/json")
328 .header("content-type", "application/json")
329 .header("user-agent", USER_AGENT)
330 .send()
331 .await
332 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
333
334 let status = resp.status();
335 if status == 401 {
336 return Err(SpendPanelError::AuthFailed(
337 "claude".into(),
338 "OAuth token rejected; run `claude` to re-authenticate".into(),
339 ));
340 }
341 if status == 429 {
342 let retry_after = resp
343 .headers()
344 .get("retry-after")
345 .and_then(|v| v.to_str().ok())
346 .and_then(|s| s.trim().parse::<u64>().ok());
347 return Err(SpendPanelError::RateLimited("claude".into(), retry_after));
348 }
349
350 let body = resp
351 .text()
352 .await
353 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
354 if !status.is_success() {
355 return Err(SpendPanelError::ProviderError(
356 "claude".into(),
357 format!("HTTP {}: {}", status, body),
358 ));
359 }
360
361 serde_json::from_str(&body).map_err(|e| {
362 SpendPanelError::ParseError("claude".into(), format!("oauth usage: {}", e))
363 })
364 }
365
366 fn rate_window(label: &str, window_minutes: u32, w: &OAuthUsageWindow) -> RateWindow {
367 let ratio = (w.utilization.unwrap_or(0.0) / 100.0).clamp(0.0, 1.0);
368 RateWindow {
369 label: label.into(),
370 window_minutes,
371 usage_ratio: ratio,
372 limit: None,
373 used: None,
374 remaining: None,
375 resets_at: w
376 .resets_at
377 .as_deref()
378 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
379 .map(|d| d.with_timezone(&Utc)),
380 status: RateWindowStatus::from_ratio(ratio),
381 }
382 }
383
384 fn plan_from_subscription(subscription_type: Option<&str>) -> PlanInfo {
385 let name = match subscription_type {
386 Some("pro") => "Claude Pro".to_string(),
387 Some("max") => "Claude Max".to_string(),
388 Some("team") => "Claude Team".to_string(),
389 Some("enterprise") => "Claude Enterprise".to_string(),
390 Some(other) => format!("Claude ({})", other),
391 None => "Claude".to_string(),
392 };
393 PlanInfo {
394 name,
395 tier: subscription_type.map(|s| s.to_string()),
396 features: vec![],
397 price: None,
398 currency: None,
399 billing_period: Some("monthly".into()),
400 }
401 }
402
403 fn snapshot_from_usage(
404 usage: &OAuthUsageResponse,
405 creds: &ClaudeOAuthCredentials,
406 ) -> UsageSnapshot {
407 let mut snapshot = UsageSnapshot::new("claude");
408 snapshot.collected_at = Utc::now();
409
410 if let Some(w) = &usage.five_hour {
411 snapshot.primary_rate_window = Some(Self::rate_window("Session (5h)", 300, w));
412 }
413 if let Some(w) = &usage.seven_day {
414 snapshot.secondary_rate_window =
415 Some(Self::rate_window("Weekly (all models)", 10_080, w));
416 }
417 if let Some(w) = &usage.seven_day_opus {
418 snapshot.extra_rate_windows.push(NamedRateWindow {
419 id: "seven_day_opus".into(),
420 label: "Weekly (Opus)".into(),
421 window: Self::rate_window("Weekly (Opus)", 10_080, w),
422 });
423 }
424 if let Some(w) = &usage.seven_day_sonnet {
425 snapshot.extra_rate_windows.push(NamedRateWindow {
426 id: "seven_day_sonnet".into(),
427 label: "Weekly (Sonnet)".into(),
428 window: Self::rate_window("Weekly (Sonnet)", 10_080, w),
429 });
430 }
431
432 if let Some(extra) = &usage.extra_usage
433 && extra.is_enabled.unwrap_or(false)
434 {
435 let used = extra.used_credits.unwrap_or(0.0);
436 let total = extra.monthly_limit;
437 snapshot.credits = Some(CreditsSnapshot {
438 balance: total.map(|t| (t - used).max(0.0)).unwrap_or(0.0),
439 currency: extra.currency.clone().unwrap_or_else(|| "USD".into()),
440 total,
441 used: Some(used),
442 renews_at: None,
443 bonus: None,
444 purchased: None,
445 });
446 }
447
448 snapshot.plan = Some(Self::plan_from_subscription(
449 creds.subscription_type.as_deref(),
450 ));
451 snapshot
452 }
453}
454
455impl Default for ClaudeProvider {
456 fn default() -> Self {
457 Self::new()
458 }
459}
460
461#[async_trait]
462impl UsageProvider for ClaudeProvider {
463 fn metadata(&self) -> &ProviderMetadata {
464 &self.metadata
465 }
466
467 fn detect_credentials(&self) -> bool {
468 ClaudeProvider::detect_credentials_at(ClaudeOAuthCredentials::default_path().as_deref())
469 }
470
471 async fn fetch_usage(&self, ctx: &ProviderContext) -> Result<UsageSnapshot, SpendPanelError> {
472 let client = Self::build_client(ctx)?;
473
474 let (mut creds, persist_path) = if let Some(token) = ctx.config.get("access_token") {
476 (
477 ClaudeOAuthCredentials {
478 access_token: token.clone(),
479 refresh_token: None,
480 expires_at: None,
481 subscription_type: ctx.config.get("subscription_type").cloned(),
482 },
483 None,
484 )
485 } else {
486 let path = Self::credentials_path(ctx)?;
487 (ClaudeOAuthCredentials::load_from_path(&path)?, Some(path))
488 };
489
490 if creds.is_expired() {
491 creds =
492 Self::refresh_token(self.token_base(), &client, &creds, persist_path.as_deref())
493 .await?;
494 }
495
496 let usage = Self::fetch_oauth_usage(self.api_base(), &client, &creds.access_token).await?;
497 Ok(Self::snapshot_from_usage(&usage, &creds))
498 }
499}
500
501#[cfg(test)]
506mod tests {
507 use super::*;
508 use wiremock::matchers::{header, method, path};
509 use wiremock::{Mock, MockServer, ResponseTemplate};
510
511 fn write_temp_credentials(name: &str, contents: &str) -> PathBuf {
512 let path = std::env::temp_dir().join(format!(
513 "usage-monitor-test-{}-{}.json",
514 name,
515 std::process::id()
516 ));
517 std::fs::write(&path, contents).unwrap();
518 path
519 }
520
521 fn credentials_json(access_token: &str, expires_at_millis: i64) -> String {
522 serde_json::json!({
523 "claudeAiOauth": {
524 "accessToken": access_token,
525 "refreshToken": "rt-test",
526 "expiresAt": expires_at_millis,
527 "scopes": ["user:inference", "user:profile"],
528 "subscriptionType": "max"
529 }
530 })
531 .to_string()
532 }
533
534 fn future_millis() -> i64 {
535 (Utc::now() + chrono::Duration::hours(1)).timestamp_millis()
536 }
537
538 fn past_millis() -> i64 {
539 (Utc::now() - chrono::Duration::hours(1)).timestamp_millis()
540 }
541
542 #[test]
543 fn test_parse_credentials() {
544 let creds =
545 ClaudeOAuthCredentials::parse(&credentials_json("at-test", future_millis())).unwrap();
546 assert_eq!(creds.access_token, "at-test");
547 assert_eq!(creds.refresh_token.as_deref(), Some("rt-test"));
548 assert_eq!(creds.subscription_type.as_deref(), Some("max"));
549 assert!(!creds.is_expired());
550 }
551
552 #[test]
553 fn test_parse_credentials_expired() {
554 let creds =
555 ClaudeOAuthCredentials::parse(&credentials_json("at-test", past_millis())).unwrap();
556 assert!(creds.is_expired());
557 }
558
559 #[test]
560 fn test_parse_credentials_missing_section() {
561 let result = ClaudeOAuthCredentials::parse(r#"{"foo": 1}"#);
562 assert!(matches!(result, Err(SpendPanelError::AuthFailed(_, _))));
563 }
564
565 #[test]
566 fn test_parse_credentials_empty_token() {
567 let result = ClaudeOAuthCredentials::parse(r#"{"claudeAiOauth": {"accessToken": " "}}"#);
568 assert!(matches!(result, Err(SpendPanelError::AuthFailed(_, _))));
569 }
570
571 #[test]
572 fn test_provider_metadata() {
573 let p = ClaudeProvider::new();
574 let m = p.metadata();
575 assert_eq!(m.id, "claude");
576 assert!(m.auth_methods.contains(&"oauth"));
577 }
578
579 #[tokio::test]
580 async fn test_fetch_oauth_usage_success() {
581 let server = MockServer::start().await;
582
583 Mock::given(method("GET"))
584 .and(path("/api/oauth/usage"))
585 .and(header("authorization", "Bearer at-test"))
586 .and(header("anthropic-beta", OAUTH_BETA_HEADER))
587 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
588 "five_hour": {"utilization": 42.0, "resets_at": "2026-06-12T20:00:00Z"},
589 "seven_day": {"utilization": 81.5, "resets_at": "2026-06-15T00:00:00Z"},
590 "seven_day_opus": {"utilization": 96.0, "resets_at": "2026-06-15T00:00:00Z"}
591 })))
592 .mount(&server)
593 .await;
594
595 let client = reqwest::Client::new();
596 let usage = ClaudeProvider::fetch_oauth_usage(&server.uri(), &client, "at-test")
597 .await
598 .unwrap();
599 assert_eq!(usage.five_hour.as_ref().unwrap().utilization, Some(42.0));
600 assert_eq!(usage.seven_day.as_ref().unwrap().utilization, Some(81.5));
601 assert!(usage.seven_day_sonnet.is_none());
602 }
603
604 #[tokio::test]
605 async fn test_fetch_oauth_usage_401() {
606 let server = MockServer::start().await;
607 Mock::given(method("GET"))
608 .and(path("/api/oauth/usage"))
609 .respond_with(ResponseTemplate::new(401))
610 .mount(&server)
611 .await;
612
613 let client = reqwest::Client::new();
614 let result = ClaudeProvider::fetch_oauth_usage(&server.uri(), &client, "bad").await;
615 assert!(matches!(result, Err(SpendPanelError::AuthFailed(_, _))));
616 }
617
618 #[tokio::test]
619 async fn test_fetch_oauth_usage_429() {
620 let server = MockServer::start().await;
621 Mock::given(method("GET"))
622 .and(path("/api/oauth/usage"))
623 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "120"))
624 .mount(&server)
625 .await;
626
627 let client = reqwest::Client::new();
628 let result = ClaudeProvider::fetch_oauth_usage(&server.uri(), &client, "at").await;
629 assert!(matches!(
630 result,
631 Err(SpendPanelError::RateLimited(_, Some(120)))
632 ));
633 }
634
635 #[test]
636 fn test_rate_window_mapping() {
637 let w = OAuthUsageWindow {
638 utilization: Some(81.5),
639 resets_at: Some("2026-06-15T00:00:00Z".into()),
640 };
641 let rw = ClaudeProvider::rate_window("Weekly", 10_080, &w);
642 assert!((rw.usage_ratio - 0.815).abs() < 1e-9);
643 assert_eq!(rw.status, RateWindowStatus::Warning);
644 assert!(rw.resets_at.is_some());
645 }
646
647 #[test]
648 fn test_rate_window_clamps_utilization() {
649 let w = OAuthUsageWindow {
650 utilization: Some(140.0),
651 resets_at: None,
652 };
653 let rw = ClaudeProvider::rate_window("Session", 300, &w);
654 assert_eq!(rw.usage_ratio, 1.0);
655 assert_eq!(rw.status, RateWindowStatus::Exhausted);
656 }
657
658 #[test]
659 fn test_plan_from_subscription() {
660 assert_eq!(
661 ClaudeProvider::plan_from_subscription(Some("max")).name,
662 "Claude Max"
663 );
664 assert_eq!(
665 ClaudeProvider::plan_from_subscription(Some("pro")).name,
666 "Claude Pro"
667 );
668 assert_eq!(ClaudeProvider::plan_from_subscription(None).name, "Claude");
669 }
670
671 #[tokio::test]
672 async fn test_full_fetch_with_credentials_file() {
673 let server = MockServer::start().await;
674
675 Mock::given(method("GET"))
676 .and(path("/api/oauth/usage"))
677 .and(header("authorization", "Bearer at-valid"))
678 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
679 "five_hour": {"utilization": 30.0, "resets_at": "2026-06-12T20:00:00Z"},
680 "seven_day": {"utilization": 55.0, "resets_at": "2026-06-15T00:00:00Z"},
681 "seven_day_sonnet": {"utilization": 10.0, "resets_at": "2026-06-15T00:00:00Z"},
682 "extra_usage": {"is_enabled": true, "monthly_limit": 50.0, "used_credits": 12.5, "currency": "USD"}
683 })))
684 .mount(&server)
685 .await;
686
687 let creds_path =
688 write_temp_credentials("full-fetch", &credentials_json("at-valid", future_millis()));
689
690 let provider = ClaudeProvider::with_base_urls(&server.uri(), &server.uri());
691 let mut ctx = ProviderContext::new();
692 ctx.config
693 .insert("credentials_path".into(), creds_path.display().to_string());
694
695 let snap = provider.fetch_usage(&ctx).await.unwrap();
696 std::fs::remove_file(&creds_path).ok();
697
698 assert_eq!(snap.provider_id, "claude");
699
700 let primary = snap.primary_rate_window.unwrap();
701 assert!((primary.usage_ratio - 0.30).abs() < 1e-9);
702 assert_eq!(primary.window_minutes, 300);
703
704 let secondary = snap.secondary_rate_window.unwrap();
705 assert!((secondary.usage_ratio - 0.55).abs() < 1e-9);
706
707 assert_eq!(snap.extra_rate_windows.len(), 1);
708 assert_eq!(snap.extra_rate_windows[0].id, "seven_day_sonnet");
709
710 let credits = snap.credits.unwrap();
711 assert_eq!(credits.total, Some(50.0));
712 assert_eq!(credits.used, Some(12.5));
713 assert!((credits.balance - 37.5).abs() < 1e-9);
714
715 assert_eq!(snap.plan.unwrap().name, "Claude Max");
716 }
717
718 #[tokio::test]
719 async fn test_expired_token_triggers_refresh_and_persists() {
720 let server = MockServer::start().await;
721
722 Mock::given(method("POST"))
723 .and(path("/v1/oauth/token"))
724 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
725 "access_token": "at-refreshed",
726 "refresh_token": "rt-rotated",
727 "expires_in": 28800
728 })))
729 .mount(&server)
730 .await;
731
732 Mock::given(method("GET"))
733 .and(path("/api/oauth/usage"))
734 .and(header("authorization", "Bearer at-refreshed"))
735 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
736 "five_hour": {"utilization": 5.0, "resets_at": "2026-06-12T20:00:00Z"}
737 })))
738 .mount(&server)
739 .await;
740
741 let creds_path =
742 write_temp_credentials("refresh", &credentials_json("at-stale", past_millis()));
743
744 let provider = ClaudeProvider::with_base_urls(&server.uri(), &server.uri());
745 let mut ctx = ProviderContext::new();
746 ctx.config
747 .insert("credentials_path".into(), creds_path.display().to_string());
748
749 let snap = provider.fetch_usage(&ctx).await.unwrap();
750 assert!(snap.primary_rate_window.is_some());
751
752 let persisted = ClaudeOAuthCredentials::load_from_path(&creds_path).unwrap();
754 std::fs::remove_file(&creds_path).ok();
755 assert_eq!(persisted.access_token, "at-refreshed");
756 assert_eq!(persisted.refresh_token.as_deref(), Some("rt-rotated"));
757 assert!(!persisted.is_expired());
758 assert_eq!(persisted.subscription_type.as_deref(), Some("max"));
760 }
761
762 #[tokio::test]
763 async fn test_expired_token_without_refresh_token_fails() {
764 let creds_path = write_temp_credentials(
765 "no-refresh",
766 &serde_json::json!({
767 "claudeAiOauth": {
768 "accessToken": "at-stale",
769 "expiresAt": past_millis()
770 }
771 })
772 .to_string(),
773 );
774
775 let provider = ClaudeProvider::new();
776 let mut ctx = ProviderContext::new();
777 ctx.config
778 .insert("credentials_path".into(), creds_path.display().to_string());
779
780 let result = provider.fetch_usage(&ctx).await;
781 std::fs::remove_file(&creds_path).ok();
782 assert!(matches!(result, Err(SpendPanelError::AuthFailed(_, _))));
783 }
784
785 #[test]
786 fn test_detect_credentials_at() {
787 let existing = write_temp_credentials("detect", &credentials_json("at", future_millis()));
788 assert!(ClaudeProvider::detect_credentials_at(Some(&existing)));
789 std::fs::remove_file(&existing).ok();
790 assert!(!ClaudeProvider::detect_credentials_at(Some(&existing)));
791 assert!(!ClaudeProvider::detect_credentials_at(None));
792 }
793
794 #[tokio::test]
795 async fn test_access_token_from_config_skips_file() {
796 let server = MockServer::start().await;
797 Mock::given(method("GET"))
798 .and(path("/api/oauth/usage"))
799 .and(header("authorization", "Bearer at-direct"))
800 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
801 "five_hour": {"utilization": 1.0, "resets_at": null}
802 })))
803 .mount(&server)
804 .await;
805
806 let provider = ClaudeProvider::with_base_urls(&server.uri(), &server.uri());
807 let mut ctx = ProviderContext::new();
808 ctx.config.insert("access_token".into(), "at-direct".into());
809
810 let snap = provider.fetch_usage(&ctx).await.unwrap();
811 assert!(snap.primary_rate_window.is_some());
812 }
813}