Skip to main content

usage_monitor_cli/provider/
codex.rs

1//! Provider for OpenAI Codex (ChatGPT plan) via Codex CLI OAuth credentials.
2//!
3//! Reads the OAuth tokens written by the Codex CLI to `~/.codex/auth.json`
4//! (or `$CODEX_HOME/auth.json`) and queries the ChatGPT backend usage endpoint
5//! (`/backend-api/wham/usage`) for the subscription rate limit windows
6//! (5h session and weekly), plan type, and credits.
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 Codex CLI (not a secret).
20const OAUTH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
21
22const DEFAULT_API_BASE: &str = "https://chatgpt.com";
23const DEFAULT_TOKEN_BASE: &str = "https://auth.openai.com";
24
25// ---------------------------------------------------------------------------
26// OAuth credentials (Codex CLI format: ~/.codex/auth.json)
27// ---------------------------------------------------------------------------
28
29#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)]
30struct AuthFile {
31    tokens: Option<TokensSection>,
32    last_refresh: Option<String>,
33    #[serde(flatten)]
34    extra: serde_json::Map<String, serde_json::Value>,
35}
36
37#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)]
38struct TokensSection {
39    id_token: Option<String>,
40    access_token: Option<String>,
41    refresh_token: Option<String>,
42    account_id: Option<String>,
43    #[serde(flatten)]
44    extra: serde_json::Map<String, serde_json::Value>,
45}
46
47/// Codex CLI OAuth credentials, loaded from disk.
48#[derive(Debug, Clone, PartialEq)]
49pub struct CodexOAuthCredentials {
50    pub access_token: String,
51    pub refresh_token: Option<String>,
52    pub account_id: Option<String>,
53    /// Account email decoded from the OAuth `id_token`, when present.
54    pub email: Option<String>,
55}
56
57/// Decodes the `email` claim from a JWT (the Codex `id_token`) without verifying
58/// the signature — we only need the identity for display.
59fn jwt_email(id_token: &str) -> Option<String> {
60    let payload = id_token.split('.').nth(1)?;
61    let bytes = base64url_decode(payload)?;
62    let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
63    claims
64        .get("email")
65        .and_then(|v| v.as_str())
66        .map(str::to_string)
67}
68
69/// Minimal base64url decoder (no padding) for JWT payloads.
70fn base64url_decode(input: &str) -> Option<Vec<u8>> {
71    let mut out = Vec::with_capacity(input.len() * 3 / 4);
72    let mut buffer = 0u32;
73    let mut bits = 0u32;
74    for byte in input.bytes() {
75        let value = match byte {
76            b'A'..=b'Z' => byte - b'A',
77            b'a'..=b'z' => byte - b'a' + 26,
78            b'0'..=b'9' => byte - b'0' + 52,
79            b'-' => 62,
80            b'_' => 63,
81            b'=' => continue,
82            _ => return None,
83        } as u32;
84        buffer = (buffer << 6) | value;
85        bits += 6;
86        if bits >= 8 {
87            bits -= 8;
88            out.push((buffer >> bits) as u8);
89        }
90    }
91    Some(out)
92}
93
94impl CodexOAuthCredentials {
95    /// Default path of the Codex CLI credentials ($CODEX_HOME or ~/.codex).
96    pub fn default_path() -> Option<PathBuf> {
97        if let Some(home) = std::env::var_os("CODEX_HOME") {
98            return Some(PathBuf::from(home).join("auth.json"));
99        }
100        std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".codex/auth.json"))
101    }
102
103    /// Loads and validates credentials from a file.
104    pub fn load_from_path(path: &Path) -> Result<Self, SpendPanelError> {
105        let raw = std::fs::read_to_string(path).map_err(|e| {
106            SpendPanelError::AuthFailed(
107                "codex".into(),
108                format!("cannot read credentials at {}: {}", path.display(), e),
109            )
110        })?;
111        Self::parse(&raw)
112    }
113
114    /// Parses the Codex CLI auth.json.
115    pub fn parse(raw: &str) -> Result<Self, SpendPanelError> {
116        let file: AuthFile = serde_json::from_str(raw).map_err(|e| {
117            SpendPanelError::ParseError("codex".into(), format!("auth.json: {}", e))
118        })?;
119        let tokens = file.tokens.ok_or_else(|| {
120            SpendPanelError::AuthFailed(
121                "codex".into(),
122                "no tokens section in auth.json; run `codex login`".into(),
123            )
124        })?;
125        let access_token = tokens.access_token.unwrap_or_default().trim().to_string();
126        if access_token.is_empty() {
127            return Err(SpendPanelError::AuthFailed(
128                "codex".into(),
129                "empty access token in auth.json".into(),
130            ));
131        }
132        Ok(Self {
133            access_token,
134            refresh_token: tokens.refresh_token,
135            account_id: tokens.account_id,
136            email: tokens.id_token.as_deref().and_then(jwt_email),
137        })
138    }
139}
140
141// ---------------------------------------------------------------------------
142// Response types for the /backend-api/wham/usage endpoint
143// ---------------------------------------------------------------------------
144
145#[derive(serde::Deserialize, Debug, Default)]
146struct WhamUsageResponse {
147    plan_type: Option<String>,
148    rate_limit: Option<WhamRateLimit>,
149    additional_rate_limits: Option<Vec<WhamAdditionalRateLimit>>,
150    credits: Option<WhamCredits>,
151}
152
153#[derive(serde::Deserialize, Debug, Default)]
154struct WhamRateLimit {
155    primary_window: Option<WhamWindow>,
156    secondary_window: Option<WhamWindow>,
157}
158
159#[derive(serde::Deserialize, Debug, Default)]
160struct WhamWindow {
161    /// Usage percentage (0–100).
162    used_percent: Option<f64>,
163    /// Window size in seconds (e.g. 18000 = 5h, 604800 = 7d).
164    limit_window_seconds: Option<u64>,
165    /// Epoch seconds of the next reset.
166    reset_at: Option<i64>,
167}
168
169#[derive(serde::Deserialize, Debug, Default)]
170struct WhamAdditionalRateLimit {
171    name: Option<String>,
172    label: Option<String>,
173    #[serde(alias = "rate_limit", alias = "limit")]
174    window: Option<WhamWindow>,
175}
176
177#[derive(serde::Deserialize, Debug, Default)]
178struct WhamCredits {
179    has_credits: Option<bool>,
180    /// Balance comes as a string (e.g. "0").
181    balance: Option<String>,
182}
183
184#[derive(serde::Deserialize, Debug)]
185struct TokenRefreshResponse {
186    access_token: String,
187    refresh_token: Option<String>,
188    id_token: Option<String>,
189}
190
191// ---------------------------------------------------------------------------
192// Provider
193// ---------------------------------------------------------------------------
194
195pub struct CodexProvider {
196    metadata: ProviderMetadata,
197    /// API base URL override for tests.
198    api_base: Option<String>,
199    /// Refresh endpoint base URL override for tests.
200    token_base: Option<String>,
201}
202
203impl CodexProvider {
204    pub fn new() -> Self {
205        Self {
206            metadata: ProviderMetadata {
207                id: "codex",
208                name: "Codex (ChatGPT)",
209                description: "ChatGPT plan Codex usage monitor via Codex CLI OAuth",
210                auth_methods: &["oauth", "cli"],
211                website: Some("https://chatgpt.com/codex"),
212            },
213            api_base: None,
214            token_base: None,
215        }
216    }
217
218    /// Creates a provider with custom base URLs (for tests).
219    pub fn with_base_urls(api_base: &str, token_base: &str) -> Self {
220        let mut p = Self::new();
221        p.api_base = Some(api_base.to_string());
222        p.token_base = Some(token_base.to_string());
223        p
224    }
225
226    fn api_base(&self) -> &str {
227        self.api_base.as_deref().unwrap_or(DEFAULT_API_BASE)
228    }
229
230    fn token_base(&self) -> &str {
231        self.token_base.as_deref().unwrap_or(DEFAULT_TOKEN_BASE)
232    }
233
234    /// Detection helper: credentials exist at the given path.
235    fn detect_credentials_at(path: Option<&Path>) -> bool {
236        path.is_some_and(|p| p.exists())
237    }
238
239    fn credentials_path(ctx: &ProviderContext) -> Result<PathBuf, SpendPanelError> {
240        if let Some(p) = ctx.config.get("credentials_path") {
241            return Ok(PathBuf::from(p));
242        }
243        CodexOAuthCredentials::default_path().ok_or_else(|| {
244            SpendPanelError::ConfigError("cannot resolve HOME for codex credentials".into())
245        })
246    }
247
248    fn build_client(ctx: &ProviderContext) -> Result<reqwest::Client, SpendPanelError> {
249        reqwest::Client::builder()
250            .timeout(std::time::Duration::from_secs(ctx.timeout_secs))
251            .build()
252            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))
253    }
254
255    /// Refreshes the access token via the refresh token and persists it back to
256    /// the file, preserving fields we do not know about.
257    async fn refresh_token(
258        token_base: &str,
259        client: &reqwest::Client,
260        creds: &CodexOAuthCredentials,
261        persist_path: Option<&Path>,
262    ) -> Result<CodexOAuthCredentials, SpendPanelError> {
263        let refresh_token = creds.refresh_token.as_deref().ok_or_else(|| {
264            SpendPanelError::AuthFailed(
265                "codex".into(),
266                "access token rejected and no refresh token available; run `codex login`".into(),
267            )
268        })?;
269
270        let url = format!("{}/oauth/token", token_base);
271        let resp = client
272            .post(&url)
273            .json(&serde_json::json!({
274                "client_id": OAUTH_CLIENT_ID,
275                "grant_type": "refresh_token",
276                "refresh_token": refresh_token,
277                "scope": "openid profile email",
278            }))
279            .send()
280            .await
281            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
282
283        let status = resp.status();
284        let body = resp
285            .text()
286            .await
287            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
288        if !status.is_success() {
289            return Err(SpendPanelError::AuthFailed(
290                "codex".into(),
291                format!("token refresh failed (HTTP {}): {}", status, body),
292            ));
293        }
294
295        let token: TokenRefreshResponse = serde_json::from_str(&body).map_err(|e| {
296            SpendPanelError::ParseError("codex".into(), format!("token refresh: {}", e))
297        })?;
298
299        let refreshed = CodexOAuthCredentials {
300            access_token: token.access_token,
301            refresh_token: token.refresh_token.or_else(|| creds.refresh_token.clone()),
302            account_id: creds.account_id.clone(),
303            email: token
304                .id_token
305                .as_deref()
306                .and_then(jwt_email)
307                .or_else(|| creds.email.clone()),
308        };
309
310        if let Some(path) = persist_path
311            && let Err(e) = Self::persist_credentials(path, &refreshed, token.id_token.as_deref())
312        {
313            tracing::warn!("failed to persist refreshed codex credentials: {}", e);
314        }
315
316        Ok(refreshed)
317    }
318
319    /// Rewrites auth.json with the refreshed tokens, keeping extra fields.
320    fn persist_credentials(
321        path: &Path,
322        creds: &CodexOAuthCredentials,
323        id_token: Option<&str>,
324    ) -> Result<(), SpendPanelError> {
325        let raw = std::fs::read_to_string(path)
326            .map_err(|e| SpendPanelError::ConfigError(format!("read auth.json: {}", e)))?;
327        let mut file: AuthFile = serde_json::from_str(&raw).map_err(|e| {
328            SpendPanelError::ParseError("codex".into(), format!("auth.json: {}", e))
329        })?;
330
331        let mut tokens = file.tokens.take().unwrap_or(TokensSection {
332            id_token: None,
333            access_token: None,
334            refresh_token: None,
335            account_id: None,
336            extra: serde_json::Map::new(),
337        });
338        tokens.access_token = Some(creds.access_token.clone());
339        tokens.refresh_token = creds.refresh_token.clone();
340        if let Some(idt) = id_token {
341            tokens.id_token = Some(idt.to_string());
342        }
343        file.tokens = Some(tokens);
344        file.last_refresh = Some(Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
345
346        let serialized = serde_json::to_string(&file).map_err(|e| {
347            SpendPanelError::ParseError("codex".into(), format!("auth.json: {}", e))
348        })?;
349        std::fs::write(path, serialized)
350            .map_err(|e| SpendPanelError::ConfigError(format!("write auth.json: {}", e)))
351    }
352
353    /// GET /backend-api/wham/usage with the ChatGPT Bearer token.
354    async fn fetch_wham_usage(
355        api_base: &str,
356        client: &reqwest::Client,
357        creds: &CodexOAuthCredentials,
358    ) -> Result<WhamUsageResponse, SpendPanelError> {
359        let url = format!("{}/backend-api/wham/usage", api_base);
360        let mut req = client
361            .get(&url)
362            .header("authorization", format!("Bearer {}", creds.access_token))
363            .header("accept", "application/json");
364        if let Some(account_id) = &creds.account_id {
365            req = req.header("chatgpt-account-id", account_id);
366        }
367
368        let resp = req
369            .send()
370            .await
371            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
372
373        let status = resp.status();
374        if status == 401 || status == 403 {
375            return Err(SpendPanelError::AuthFailed(
376                "codex".into(),
377                "OAuth token rejected; run `codex login` to re-authenticate".into(),
378            ));
379        }
380        if status == 429 {
381            let retry_after = resp
382                .headers()
383                .get("retry-after")
384                .and_then(|v| v.to_str().ok())
385                .and_then(|s| s.trim().parse::<u64>().ok());
386            return Err(SpendPanelError::RateLimited("codex".into(), retry_after));
387        }
388
389        let body = resp
390            .text()
391            .await
392            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
393        if !status.is_success() {
394            return Err(SpendPanelError::ProviderError(
395                "codex".into(),
396                format!("HTTP {}: {}", status, body),
397            ));
398        }
399
400        serde_json::from_str(&body)
401            .map_err(|e| SpendPanelError::ParseError("codex".into(), format!("wham usage: {}", e)))
402    }
403
404    fn rate_window(label: &str, w: &WhamWindow) -> RateWindow {
405        let ratio = (w.used_percent.unwrap_or(0.0) / 100.0).clamp(0.0, 1.0);
406        RateWindow {
407            label: label.into(),
408            window_minutes: (w.limit_window_seconds.unwrap_or(0) / 60) as u32,
409            usage_ratio: ratio,
410            limit: None,
411            used: None,
412            remaining: None,
413            resets_at: w
414                .reset_at
415                .and_then(|s| DateTime::<Utc>::from_timestamp(s, 0)),
416            status: RateWindowStatus::from_ratio(ratio),
417        }
418    }
419
420    /// Label for a window based on its size (5h session vs weekly).
421    fn window_label(w: &WhamWindow, fallback: &str) -> String {
422        match w.limit_window_seconds {
423            Some(s) if s <= 6 * 3600 => format!("Session ({}h)", s / 3600),
424            Some(s) if s >= 6 * 86_400 => "Weekly".to_string(),
425            _ => fallback.to_string(),
426        }
427    }
428
429    fn plan_from_type(plan_type: Option<&str>) -> PlanInfo {
430        let name = match plan_type {
431            Some("plus") => "ChatGPT Plus".to_string(),
432            Some("pro") => "ChatGPT Pro".to_string(),
433            Some("team") => "ChatGPT Team".to_string(),
434            Some("business") => "ChatGPT Business".to_string(),
435            Some("enterprise") => "ChatGPT Enterprise".to_string(),
436            Some("free") => "ChatGPT Free".to_string(),
437            Some(other) => format!("ChatGPT ({})", other),
438            None => "ChatGPT".to_string(),
439        };
440        PlanInfo {
441            name,
442            tier: plan_type.map(|s| s.to_string()),
443            features: vec![],
444            price: None,
445            currency: None,
446            billing_period: Some("monthly".into()),
447        }
448    }
449
450    fn snapshot_from_usage(usage: &WhamUsageResponse) -> UsageSnapshot {
451        let mut snapshot = UsageSnapshot::new("codex");
452        snapshot.collected_at = Utc::now();
453
454        if let Some(rl) = &usage.rate_limit {
455            if let Some(w) = &rl.primary_window {
456                snapshot.primary_rate_window =
457                    Some(Self::rate_window(&Self::window_label(w, "Session"), w));
458            }
459            if let Some(w) = &rl.secondary_window {
460                snapshot.secondary_rate_window =
461                    Some(Self::rate_window(&Self::window_label(w, "Weekly"), w));
462            }
463        }
464
465        if let Some(extras) = &usage.additional_rate_limits {
466            for extra in extras {
467                let Some(w) = &extra.window else { continue };
468                let label = extra
469                    .label
470                    .clone()
471                    .or_else(|| extra.name.clone())
472                    .unwrap_or_else(|| "Additional".to_string());
473                snapshot.extra_rate_windows.push(NamedRateWindow {
474                    id: extra.name.clone().unwrap_or_else(|| label.clone()),
475                    label: label.clone(),
476                    window: Self::rate_window(&label, w),
477                });
478            }
479        }
480
481        if let Some(credits) = &usage.credits
482            && credits.has_credits.unwrap_or(false)
483        {
484            let balance = credits
485                .balance
486                .as_deref()
487                .and_then(|b| b.parse::<f64>().ok())
488                .unwrap_or(0.0);
489            snapshot.credits = Some(CreditsSnapshot::new(balance, "credits"));
490        }
491
492        snapshot.plan = Some(Self::plan_from_type(usage.plan_type.as_deref()));
493        snapshot
494    }
495}
496
497impl Default for CodexProvider {
498    fn default() -> Self {
499        Self::new()
500    }
501}
502
503#[async_trait]
504impl UsageProvider for CodexProvider {
505    fn metadata(&self) -> &ProviderMetadata {
506        &self.metadata
507    }
508
509    fn detect_credentials(&self) -> bool {
510        CodexProvider::detect_credentials_at(CodexOAuthCredentials::default_path().as_deref())
511    }
512
513    async fn fetch_usage(&self, ctx: &ProviderContext) -> Result<UsageSnapshot, SpendPanelError> {
514        let client = Self::build_client(ctx)?;
515
516        // A direct token via config takes precedence (useful for tests/integrations).
517        let (creds, persist_path) = if let Some(token) = ctx.config.get("access_token") {
518            (
519                CodexOAuthCredentials {
520                    access_token: token.clone(),
521                    refresh_token: None,
522                    account_id: ctx.config.get("account_id").cloned(),
523                    email: None,
524                },
525                None,
526            )
527        } else {
528            let path = Self::credentials_path(ctx)?;
529            (CodexOAuthCredentials::load_from_path(&path)?, Some(path))
530        };
531
532        // auth.json has no expiry field, so try the request and refresh on rejection.
533        match Self::fetch_wham_usage(self.api_base(), &client, &creds).await {
534            Ok(usage) => {
535                let mut snapshot = Self::snapshot_from_usage(&usage);
536                snapshot.account_email = creds.email.clone();
537                Ok(snapshot)
538            }
539            Err(SpendPanelError::AuthFailed(_, _)) if creds.refresh_token.is_some() => {
540                let refreshed = Self::refresh_token(
541                    self.token_base(),
542                    &client,
543                    &creds,
544                    persist_path.as_deref(),
545                )
546                .await?;
547                let usage = Self::fetch_wham_usage(self.api_base(), &client, &refreshed).await?;
548                let mut snapshot = Self::snapshot_from_usage(&usage);
549                snapshot.account_email = refreshed.email.clone();
550                Ok(snapshot)
551            }
552            Err(e) => Err(e),
553        }
554    }
555}
556
557// ---------------------------------------------------------------------------
558// Tests
559// ---------------------------------------------------------------------------
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use wiremock::matchers::{header, method, path};
565    use wiremock::{Mock, MockServer, ResponseTemplate};
566
567    fn write_temp_auth(name: &str, contents: &str) -> PathBuf {
568        let path = std::env::temp_dir().join(format!(
569            "usage-monitor-codex-{}-{}.json",
570            name,
571            std::process::id()
572        ));
573        std::fs::write(&path, contents).unwrap();
574        path
575    }
576
577    fn auth_json(access_token: &str) -> String {
578        serde_json::json!({
579            "auth_mode": "chatgpt",
580            "OPENAI_API_KEY": null,
581            "tokens": {
582                "id_token": "idt-test",
583                "access_token": access_token,
584                "refresh_token": "rt-test",
585                "account_id": "acc-123"
586            },
587            "last_refresh": "2026-06-12T10:00:00.000Z"
588        })
589        .to_string()
590    }
591
592    fn wham_body() -> serde_json::Value {
593        serde_json::json!({
594            "plan_type": "plus",
595            "rate_limit": {
596                "allowed": false,
597                "limit_reached": true,
598                "primary_window": {
599                    "used_percent": 1,
600                    "limit_window_seconds": 18000,
601                    "reset_after_seconds": 18000,
602                    "reset_at": 1781326965
603                },
604                "secondary_window": {
605                    "used_percent": 100,
606                    "limit_window_seconds": 604800,
607                    "reset_after_seconds": 49494,
608                    "reset_at": 1781358459
609                }
610            },
611            "additional_rate_limits": null,
612            "credits": {
613                "has_credits": false,
614                "unlimited": false,
615                "balance": "0"
616            }
617        })
618    }
619
620    #[test]
621    fn test_parse_auth_json() {
622        let creds = CodexOAuthCredentials::parse(&auth_json("at-test")).unwrap();
623        assert_eq!(creds.access_token, "at-test");
624        assert_eq!(creds.refresh_token.as_deref(), Some("rt-test"));
625        assert_eq!(creds.account_id.as_deref(), Some("acc-123"));
626    }
627
628    #[test]
629    fn test_parse_auth_json_missing_tokens() {
630        let result = CodexOAuthCredentials::parse(r#"{"OPENAI_API_KEY": "sk-x"}"#);
631        assert!(matches!(result, Err(SpendPanelError::AuthFailed(_, _))));
632    }
633
634    #[test]
635    fn test_parse_auth_json_empty_token() {
636        let result = CodexOAuthCredentials::parse(r#"{"tokens": {"access_token": " "}}"#);
637        assert!(matches!(result, Err(SpendPanelError::AuthFailed(_, _))));
638    }
639
640    #[test]
641    fn test_provider_metadata() {
642        let p = CodexProvider::new();
643        let m = p.metadata();
644        assert_eq!(m.id, "codex");
645        assert!(m.auth_methods.contains(&"oauth"));
646    }
647
648    #[test]
649    fn test_window_label_from_size() {
650        let session = WhamWindow {
651            used_percent: None,
652            limit_window_seconds: Some(18000),
653            reset_at: None,
654        };
655        let weekly = WhamWindow {
656            used_percent: None,
657            limit_window_seconds: Some(604800),
658            reset_at: None,
659        };
660        let odd = WhamWindow {
661            used_percent: None,
662            limit_window_seconds: None,
663            reset_at: None,
664        };
665        assert_eq!(CodexProvider::window_label(&session, "x"), "Session (5h)");
666        assert_eq!(CodexProvider::window_label(&weekly, "x"), "Weekly");
667        assert_eq!(CodexProvider::window_label(&odd, "fallback"), "fallback");
668    }
669
670    #[test]
671    fn test_plan_from_type() {
672        assert_eq!(
673            CodexProvider::plan_from_type(Some("plus")).name,
674            "ChatGPT Plus"
675        );
676        assert_eq!(
677            CodexProvider::plan_from_type(Some("pro")).name,
678            "ChatGPT Pro"
679        );
680        assert_eq!(CodexProvider::plan_from_type(None).name, "ChatGPT");
681    }
682
683    #[tokio::test]
684    async fn test_fetch_wham_usage_success() {
685        let server = MockServer::start().await;
686
687        Mock::given(method("GET"))
688            .and(path("/backend-api/wham/usage"))
689            .and(header("authorization", "Bearer at-test"))
690            .and(header("chatgpt-account-id", "acc-123"))
691            .respond_with(ResponseTemplate::new(200).set_body_json(wham_body()))
692            .mount(&server)
693            .await;
694
695        let client = reqwest::Client::new();
696        let creds = CodexOAuthCredentials {
697            access_token: "at-test".into(),
698            refresh_token: None,
699            account_id: Some("acc-123".into()),
700            email: None,
701        };
702        let usage = CodexProvider::fetch_wham_usage(&server.uri(), &client, &creds)
703            .await
704            .unwrap();
705        assert_eq!(usage.plan_type.as_deref(), Some("plus"));
706        let rl = usage.rate_limit.unwrap();
707        assert_eq!(rl.primary_window.unwrap().used_percent, Some(1.0));
708        assert_eq!(rl.secondary_window.unwrap().used_percent, Some(100.0));
709    }
710
711    #[tokio::test]
712    async fn test_fetch_wham_usage_401() {
713        let server = MockServer::start().await;
714        Mock::given(method("GET"))
715            .and(path("/backend-api/wham/usage"))
716            .respond_with(ResponseTemplate::new(401))
717            .mount(&server)
718            .await;
719
720        let client = reqwest::Client::new();
721        let creds = CodexOAuthCredentials {
722            access_token: "bad".into(),
723            refresh_token: None,
724            account_id: None,
725            email: None,
726        };
727        let result = CodexProvider::fetch_wham_usage(&server.uri(), &client, &creds).await;
728        assert!(matches!(result, Err(SpendPanelError::AuthFailed(_, _))));
729    }
730
731    #[test]
732    fn test_jwt_email_decodes_claim() {
733        let token = "header.eyJlbWFpbCI6ICJ0ZXN0QGV4YW1wbGUuY29tIiwgIm5hbWUiOiAiVGVzdCJ9.sig";
734        assert_eq!(jwt_email(token).as_deref(), Some("test@example.com"));
735        assert_eq!(jwt_email("not-a-jwt"), None);
736        assert_eq!(jwt_email("a.b.c"), None);
737    }
738
739    #[test]
740    fn test_parse_sets_email_from_id_token() {
741        let auth = serde_json::json!({
742            "tokens": {
743                "id_token": "header.eyJlbWFpbCI6ICJ0ZXN0QGV4YW1wbGUuY29tIiwgIm5hbWUiOiAiVGVzdCJ9.sig",
744                "access_token": "at-test",
745                "account_id": "acc-1"
746            }
747        })
748        .to_string();
749        let creds = CodexOAuthCredentials::parse(&auth).unwrap();
750        assert_eq!(creds.email.as_deref(), Some("test@example.com"));
751    }
752
753    #[test]
754    fn test_detect_credentials_at() {
755        let existing = write_temp_auth("detect", &auth_json("at"));
756        assert!(CodexProvider::detect_credentials_at(Some(&existing)));
757        std::fs::remove_file(&existing).ok();
758        assert!(!CodexProvider::detect_credentials_at(Some(&existing)));
759        assert!(!CodexProvider::detect_credentials_at(None));
760    }
761
762    #[tokio::test]
763    async fn test_fetch_wham_usage_429() {
764        let server = MockServer::start().await;
765        Mock::given(method("GET"))
766            .and(path("/backend-api/wham/usage"))
767            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "60"))
768            .mount(&server)
769            .await;
770
771        let client = reqwest::Client::new();
772        let creds = CodexOAuthCredentials {
773            access_token: "at".into(),
774            refresh_token: None,
775            account_id: None,
776            email: None,
777        };
778        let result = CodexProvider::fetch_wham_usage(&server.uri(), &client, &creds).await;
779        assert!(matches!(
780            result,
781            Err(SpendPanelError::RateLimited(_, Some(60)))
782        ));
783    }
784
785    #[tokio::test]
786    async fn test_full_fetch_with_auth_file() {
787        let server = MockServer::start().await;
788
789        Mock::given(method("GET"))
790            .and(path("/backend-api/wham/usage"))
791            .and(header("authorization", "Bearer at-valid"))
792            .respond_with(ResponseTemplate::new(200).set_body_json(wham_body()))
793            .mount(&server)
794            .await;
795
796        let auth_path = write_temp_auth("full-fetch", &auth_json("at-valid"));
797
798        let provider = CodexProvider::with_base_urls(&server.uri(), &server.uri());
799        let mut ctx = ProviderContext::new();
800        ctx.config
801            .insert("credentials_path".into(), auth_path.display().to_string());
802
803        let snap = provider.fetch_usage(&ctx).await.unwrap();
804        std::fs::remove_file(&auth_path).ok();
805
806        assert_eq!(snap.provider_id, "codex");
807
808        let primary = snap.primary_rate_window.unwrap();
809        assert_eq!(primary.label, "Session (5h)");
810        assert_eq!(primary.window_minutes, 300);
811        assert!((primary.usage_ratio - 0.01).abs() < 1e-9);
812        assert!(primary.resets_at.is_some());
813
814        let secondary = snap.secondary_rate_window.unwrap();
815        assert_eq!(secondary.label, "Weekly");
816        assert_eq!(secondary.window_minutes, 10_080);
817        assert_eq!(secondary.usage_ratio, 1.0);
818        assert_eq!(secondary.status, RateWindowStatus::Exhausted);
819
820        // has_credits=false → no credits snapshot.
821        assert!(snap.credits.is_none());
822
823        assert_eq!(snap.plan.unwrap().name, "ChatGPT Plus");
824    }
825
826    #[tokio::test]
827    async fn test_rejected_token_triggers_refresh_and_persists() {
828        let server = MockServer::start().await;
829
830        // First request with stale token → 401; refreshed token → 200.
831        Mock::given(method("GET"))
832            .and(path("/backend-api/wham/usage"))
833            .and(header("authorization", "Bearer at-stale"))
834            .respond_with(ResponseTemplate::new(401))
835            .mount(&server)
836            .await;
837
838        Mock::given(method("POST"))
839            .and(path("/oauth/token"))
840            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
841                "access_token": "at-refreshed",
842                "refresh_token": "rt-rotated",
843                "id_token": "idt-new"
844            })))
845            .mount(&server)
846            .await;
847
848        Mock::given(method("GET"))
849            .and(path("/backend-api/wham/usage"))
850            .and(header("authorization", "Bearer at-refreshed"))
851            .respond_with(ResponseTemplate::new(200).set_body_json(wham_body()))
852            .mount(&server)
853            .await;
854
855        let auth_path = write_temp_auth("refresh", &auth_json("at-stale"));
856
857        let provider = CodexProvider::with_base_urls(&server.uri(), &server.uri());
858        let mut ctx = ProviderContext::new();
859        ctx.config
860            .insert("credentials_path".into(), auth_path.display().to_string());
861
862        let snap = provider.fetch_usage(&ctx).await.unwrap();
863        assert!(snap.primary_rate_window.is_some());
864
865        // Refreshed credentials must have been persisted to the file.
866        let raw = std::fs::read_to_string(&auth_path).unwrap();
867        std::fs::remove_file(&auth_path).ok();
868        let persisted: serde_json::Value = serde_json::from_str(&raw).unwrap();
869        assert_eq!(persisted["tokens"]["access_token"], "at-refreshed");
870        assert_eq!(persisted["tokens"]["refresh_token"], "rt-rotated");
871        assert_eq!(persisted["tokens"]["id_token"], "idt-new");
872        // account_id and unknown fields preserved.
873        assert_eq!(persisted["tokens"]["account_id"], "acc-123");
874        assert_eq!(persisted["auth_mode"], "chatgpt");
875    }
876
877    #[tokio::test]
878    async fn test_rejected_token_without_refresh_token_fails() {
879        let server = MockServer::start().await;
880        Mock::given(method("GET"))
881            .and(path("/backend-api/wham/usage"))
882            .respond_with(ResponseTemplate::new(401))
883            .mount(&server)
884            .await;
885
886        let provider = CodexProvider::with_base_urls(&server.uri(), &server.uri());
887        let mut ctx = ProviderContext::new();
888        ctx.config.insert("access_token".into(), "at-bad".into());
889
890        let result = provider.fetch_usage(&ctx).await;
891        assert!(matches!(result, Err(SpendPanelError::AuthFailed(_, _))));
892    }
893}