Skip to main content

usage_monitor_cli/provider/
claude.rs

1//! Provider for Claude subscriptions (Pro/Max) via Claude Code OAuth credentials.
2//!
3//! Unlike the `anthropic` provider (Admin API with api_key), this provider reads
4//! the OAuth credentials written by the Claude Code CLI to `~/.claude/.credentials.json`
5//! and queries the `/api/oauth/usage` endpoint to obtain the subscription rate limit
6//! windows (5h session, weekly, weekly per model).
7
8use 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
19/// Public OAuth client ID of the Claude Code CLI (not a secret).
20const OAUTH_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
21/// Beta header required by the OAuth usage endpoint.
22const OAUTH_BETA_HEADER: &str = "oauth-2025-04-20";
23/// User-Agent mimicking Claude Code (the endpoint expects a known client).
24const 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// ---------------------------------------------------------------------------
30// OAuth credentials (Claude Code format: ~/.claude/.credentials.json)
31// ---------------------------------------------------------------------------
32
33#[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    /// Epoch in milliseconds.
48    #[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/// Claude Code OAuth credentials, loaded from disk.
57#[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    /// Default path of the Claude Code credentials on Linux.
67    pub fn default_path() -> Option<PathBuf> {
68        std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".claude/.credentials.json"))
69    }
70
71    /// Loads and validates credentials from a file.
72    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    /// Parses the Claude Code credentials JSON.
83    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    /// Is the token expired? Missing expiry info is treated as still valid.
109    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// ---------------------------------------------------------------------------
122// Response types for the /api/oauth/usage endpoint
123// ---------------------------------------------------------------------------
124
125#[derive(serde::Deserialize, Debug, Default)]
126struct OAuthUsageWindow {
127    /// Usage percentage (0–100).
128    utilization: Option<f64>,
129    /// ISO-8601 timestamp of the next reset.
130    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    /// Seconds until expiry.
155    expires_in: Option<f64>,
156}
157
158// ---------------------------------------------------------------------------
159// Provider
160// ---------------------------------------------------------------------------
161
162pub struct ClaudeProvider {
163    metadata: ProviderMetadata,
164    /// API base URL override for tests.
165    api_base: Option<String>,
166    /// Refresh endpoint base URL override for tests.
167    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    /// Creates a provider with custom base URLs (for tests).
186    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    /// Detection helper: credentials exist at the given path.
202    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    /// Refreshes the access token via the refresh token and persists it back to
223    /// the file, preserving fields we do not know about.
224    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    /// Rewrites the credentials file with the refreshed token, keeping extra fields.
286    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    /// GET /api/oauth/usage with the subscription Bearer token.
317    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        // A direct token via config takes precedence (useful for tests/integrations).
475        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// ---------------------------------------------------------------------------
502// Tests
503// ---------------------------------------------------------------------------
504
505#[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        // Refreshed credentials must have been persisted to the file.
753        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        // subscriptionType preserved.
759        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}