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 = "app_EMoamEEZ73f0CkXaXp7hrann";
21
22const DEFAULT_API_BASE: &str = "https://chatgpt.com";
23const DEFAULT_TOKEN_BASE: &str = "https://auth.openai.com";
24
25#[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#[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 pub email: Option<String>,
55}
56
57fn 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
69fn 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 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 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 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#[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 used_percent: Option<f64>,
163 limit_window_seconds: Option<u64>,
165 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: 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
191pub struct CodexProvider {
196 metadata: ProviderMetadata,
197 api_base: Option<String>,
199 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 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 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 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 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 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 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 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 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#[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 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 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 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 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}