1use std::path::Path;
6use std::time::Duration;
7
8use chrono::Utc;
9
10use crate::cache::{Cache, acquire_lock_async};
11use crate::error::{AppError, Result};
12use crate::usage::OpenAiSnapshot;
13
14use super::creds::{self, Tokens};
15use super::oauth;
16use super::types::UsageResponse;
17
18pub const USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
19const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
20const REFRESH_TIMEOUT: Duration = Duration::from_secs(25);
21const LOCK_TIMEOUT: Duration = Duration::from_secs(45);
22
23#[derive(Debug, Clone)]
24pub struct Endpoints {
25 pub usage: String,
26 pub token: String,
27}
28
29impl Default for Endpoints {
30 fn default() -> Self {
31 Self {
32 usage: USAGE_URL.into(),
33 token: oauth::TOKEN_URL.into(),
34 }
35 }
36}
37
38pub type FetchOutcome = crate::outcome::Outcome<OpenAiSnapshot>;
41
42pub async fn fetch_snapshot(
43 client: &reqwest::Client,
44 creds_path: &Path,
45 cache: &Cache,
46 endpoints: &Endpoints,
47 cache_ttl: Duration,
48) -> Result<FetchOutcome> {
49 cache.ensure_dir()?;
50 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
51
52 let mut auth = creds::read_from(creds_path)?;
53 let plan_hint = auth.tokens.plan_type_from_id_token();
54
55 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
58 && let Ok(outcome) = reuse(bytes, cache, false, plan_hint.as_deref())
59 {
60 return Ok(outcome);
61 }
62
63 let now = Utc::now().timestamp();
66 if oauth::needs_refresh(auth.tokens.expires_at_secs(), now) {
67 match tokio::time::timeout(
68 REFRESH_TIMEOUT,
69 oauth::refresh(client, &endpoints.token, &auth.tokens.refresh_token),
70 )
71 .await
72 {
73 Ok(Ok(rr)) => {
74 auth.tokens.access_token = rr.access_token;
75 let rotated = rr.refresh_token.is_some();
79 if let Some(rt) = rr.refresh_token {
80 auth.tokens.refresh_token = rt;
81 }
82 if let Some(id) = rr.id_token {
83 auth.tokens.id_token = id;
84 }
85 if let Some(secs) = rr.expires_in
91 && let Some(dt) = chrono::DateTime::from_timestamp(now + secs as i64, 0)
92 {
93 auth.tokens.expires_at = Some(dt.to_rfc3339());
94 }
95 if let Err(e) = creds::write_back(creds_path, &auth)
96 && rotated
97 {
98 let msg = format!(
99 "refreshed token could not be saved ({e}); the rotated \
100 refresh token is lost — re-run `codex login`"
101 );
102 cache.write_last_error(0, &msg);
103 return handle_auth_failure(cache, plan_hint.as_deref(), false);
104 }
105 }
106 Ok(Err(AppError::Http { status, body })) => {
107 cache.write_last_error(status, &body);
108 return handle_auth_failure(cache, plan_hint.as_deref(), false);
109 }
110 Ok(Err(e)) if e.is_transient() => {
111 return handle_auth_failure(cache, plan_hint.as_deref(), true);
112 }
113 Ok(Err(e)) => {
114 cache.write_last_error(0, &e.to_string());
115 return handle_auth_failure(cache, plan_hint.as_deref(), false);
116 }
117 Err(_) => return handle_auth_failure(cache, plan_hint.as_deref(), true),
118 }
119 }
120
121 match tokio::time::timeout(
122 HTTP_TIMEOUT,
123 fetch_usage(client, &endpoints.usage, &auth.tokens),
124 )
125 .await
126 {
127 Ok(Ok(response)) => {
128 cache.write_payload(&serde_json::to_vec(&response)?)?;
134 let snap = response.into_snapshot(plan_hint.as_deref())?;
135 Ok(crate::outcome::Outcome::fresh(snap))
136 }
137 Ok(Err(AppError::Http { status, body })) => {
138 cache.mark_stale();
139 let last_error = Some(cache.write_last_error(status, &body));
140 fallback(
141 cache,
142 plan_hint.as_deref(),
143 last_error,
144 AppError::Http { status, body },
145 )
146 }
147 Ok(Err(e)) if e.is_transient() => fallback_silent(cache, plan_hint.as_deref(), e),
148 Ok(Err(e)) => {
149 cache.mark_stale();
150 let last_error = Some(cache.write_last_error(0, &e.to_string()));
151 fallback(cache, plan_hint.as_deref(), last_error, e)
152 }
153 Err(_) => fallback_silent(
154 cache,
155 plan_hint.as_deref(),
156 AppError::Transport("openai: usage request timed out".into()),
157 ),
158 }
159}
160
161fn reuse(
162 bytes: Vec<u8>,
163 cache: &Cache,
164 stale: bool,
165 plan_hint: Option<&str>,
166) -> Result<FetchOutcome> {
167 let snap = parse_payload(&bytes, plan_hint)?;
168 Ok(crate::outcome::Outcome::cached(snap, cache, stale))
169}
170
171fn fallback(
172 cache: &Cache,
173 plan_hint: Option<&str>,
174 last_error: Option<(u16, String)>,
175 original: AppError,
176) -> Result<FetchOutcome> {
177 crate::outcome::fallback(cache, last_error, original, |bytes| {
178 parse_payload(bytes, plan_hint)
179 })
180}
181
182fn fallback_silent(
183 cache: &Cache,
184 plan_hint: Option<&str>,
185 original: AppError,
186) -> Result<FetchOutcome> {
187 crate::outcome::fallback(cache, None, original, |bytes| {
188 parse_payload(bytes, plan_hint)
189 })
190}
191
192fn handle_auth_failure(
196 cache: &Cache,
197 plan_hint: Option<&str>,
198 transient: bool,
199) -> Result<FetchOutcome> {
200 let original = if transient {
201 AppError::Transport("openai: no cache and refresh failed transiently".into())
202 } else {
203 AppError::Credentials("openai: token refresh failed; run `codex login` to re-auth".into())
204 };
205 crate::outcome::fallback(cache, None, original, |bytes| {
206 parse_payload(bytes, plan_hint)
207 })
208}
209
210fn parse_payload(bytes: &[u8], plan_hint: Option<&str>) -> Result<OpenAiSnapshot> {
211 parse_response(bytes)?.into_snapshot(plan_hint)
212}
213
214fn parse_response(bytes: &[u8]) -> Result<UsageResponse> {
217 Ok(serde_json::from_slice(bytes)?)
218}
219
220fn authorized(client: &reqwest::Client, url: String, t: &Tokens) -> reqwest::RequestBuilder {
221 let mut req = client
222 .get(url)
223 .header("Authorization", format!("Bearer {}", t.access_token))
224 .header("User-Agent", "codex-cli");
225 if let Some(aid) = t.account_id.as_deref() {
226 req = req.header("ChatGPT-Account-Id", aid);
227 }
228 req
229}
230
231async fn fetch_usage(client: &reqwest::Client, url: &str, t: &Tokens) -> Result<UsageResponse> {
240 let resp = authorized(client, url.to_string(), t).send().await?;
241 let status = resp.status();
242 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
243
244 if !status.is_success() {
245 let body: String = String::from_utf8_lossy(&bytes).chars().take(200).collect();
246 return Err(AppError::Http {
247 status: status.as_u16(),
248 body,
249 });
250 }
251 let mut parsed: UsageResponse = serde_json::from_slice(&bytes)
252 .map_err(|e| AppError::Schema(format!("openai usage response: {e}")))?;
253 parsed.rate_limit_reset_credits = enrich_reset_credits(client, url, t, &parsed).await;
254 parsed.clone().into_snapshot(None)?;
256 Ok(parsed)
257}
258
259async fn enrich_reset_credits(
268 client: &reqwest::Client,
269 usage_url: &str,
270 t: &Tokens,
271 parsed: &UsageResponse,
272) -> Option<super::types::ResetCreditsBlock> {
273 let mut block = parsed.rate_limit_reset_credits.clone()?;
274 if block.available_count == 0 {
275 return Some(block);
276 }
277 if let Ok(details) = fetch_reset_credits(client, usage_url, t).await {
278 block.credits = details.credits;
279 }
280 Some(block)
281}
282
283async fn fetch_reset_credits(
284 client: &reqwest::Client,
285 usage_url: &str,
286 t: &Tokens,
287) -> Result<super::types::ResetCreditsBlock> {
288 let base = usage_url.strip_suffix("/usage").unwrap_or(usage_url);
289 let resp = authorized(client, format!("{base}/rate-limit-reset-credits"), t)
290 .send()
291 .await?;
292 let status = resp.status();
293 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
294 if !status.is_success() {
295 return Err(AppError::Http {
299 status: status.as_u16(),
300 body: String::new(),
301 });
302 }
303 serde_json::from_slice(&bytes)
304 .map_err(|_| AppError::Schema("openai reset credits response is invalid".into()))
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use base64::Engine;
311 use std::io::Write;
312 use tempfile::{NamedTempFile, TempDir};
313
314 fn fake_jwt(claims: serde_json::Value) -> String {
315 let h = base64::engine::general_purpose::URL_SAFE_NO_PAD
316 .encode(br#"{"alg":"none","typ":"JWT"}"#);
317 let p =
318 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes());
319 format!("{h}.{p}.sig")
320 }
321
322 fn future_creds() -> NamedTempFile {
323 let exp = Utc::now().timestamp() + 3600;
325 let jwt = fake_jwt(serde_json::json!({
326 "exp": exp,
327 "https://api.openai.com/auth": {"chatgpt_plan_type": "plus"}
328 }));
329 let body = format!(
330 r#"{{"tokens":{{"access_token":"AT","refresh_token":"RT","id_token":"{jwt}",
331 "account_id":"acc"}}}}"#
332 );
333 let mut f = NamedTempFile::new().unwrap();
334 f.write_all(body.as_bytes()).unwrap();
335 f.flush().unwrap();
336 f
337 }
338
339 fn cache_fixture() -> (TempDir, Cache) {
340 let td = TempDir::new().unwrap();
341 let c = Cache::at(td.path().join("openai"));
342 c.ensure_dir().unwrap();
343 (td, c)
344 }
345
346 #[tokio::test]
347 async fn live_200_returns_snapshot_with_plan_from_id_token() {
348 let mut server = mockito::Server::new_async().await;
349 server
350 .mock("GET", "/backend-api/wham/usage")
351 .with_status(200)
352 .with_body(
353 r#"{"plan_type":"plus","rate_limit":{
354 "primary_window":{"used_percent":1,"limit_window_seconds":18000,"reset_at":1779597324},
355 "secondary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_at":1780184124}
356 }}"#,
357 )
358 .create_async()
359 .await;
360 let (_td, cache) = cache_fixture();
361 let creds = future_creds();
362 let client = reqwest::Client::new();
363 let endpoints = Endpoints {
364 usage: format!("{}/backend-api/wham/usage", server.url()),
365 token: format!("{}/oauth/token", server.url()),
366 };
367 let out = fetch_snapshot(
368 &client,
369 creds.path(),
370 &cache,
371 &endpoints,
372 Duration::from_secs(0),
373 )
374 .await
375 .unwrap();
376 assert_eq!(out.snapshot.plan, "ChatGPT Plus");
377 assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 1);
378 assert!(!out.stale);
379 }
380
381 #[tokio::test]
382 async fn weekly_only_primary_returns_weekly_snapshot() {
383 let mut server = mockito::Server::new_async().await;
384 server
385 .mock("GET", "/backend-api/wham/usage")
386 .with_status(200)
387 .with_body(
388 r#"{"plan_type":"prolite","rate_limit":{
389 "primary_window":{"used_percent":66,"limit_window_seconds":604800,"reset_at":1785261834},
390 "secondary_window":null
391 }}"#,
392 )
393 .create_async()
394 .await;
395 let (_td, cache) = cache_fixture();
396 let creds = future_creds();
397 let endpoints = Endpoints {
398 usage: format!("{}/backend-api/wham/usage", server.url()),
399 token: format!("{}/oauth/token", server.url()),
400 };
401 let out = fetch_snapshot(
402 &reqwest::Client::new(),
403 creds.path(),
404 &cache,
405 &endpoints,
406 Duration::from_secs(0),
407 )
408 .await
409 .unwrap();
410 assert!(out.snapshot.session.is_none());
411 assert_eq!(out.snapshot.weekly.unwrap().utilization_pct, 66);
412 }
413
414 #[tokio::test]
415 async fn corrupt_fresh_cache_refetches_instead_of_showing_an_empty_snapshot() {
416 let mut server = mockito::Server::new_async().await;
419 server
420 .mock("GET", "/backend-api/wham/usage")
421 .with_status(200)
422 .with_body(
423 r#"{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":37,"limit_window_seconds":18000}}}"#,
424 )
425 .create_async()
426 .await;
427
428 let (_td, cache) = cache_fixture();
429 cache.write_payload(b"{ truncated").unwrap();
430
431 let creds = future_creds();
432 let client = reqwest::Client::new();
433 let endpoints = Endpoints {
434 usage: format!("{}/backend-api/wham/usage", server.url()),
435 token: format!("{}/oauth/token", server.url()),
436 };
437 let out = fetch_snapshot(
439 &client,
440 creds.path(),
441 &cache,
442 &endpoints,
443 Duration::from_secs(3600),
444 )
445 .await
446 .unwrap();
447 assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 37);
448 assert!(!out.stale);
449 }
450
451 #[tokio::test]
455 async fn banked_reset_expiries_are_fetched_and_cached_with_the_usage_figures() {
456 let mut server = mockito::Server::new_async().await;
457 let usage = server
458 .mock("GET", "/backend-api/wham/usage")
459 .with_body(
460 r#"{"plan_type":"plus","future_field":true,"rate_limit":{
461 "primary_window":{"used_percent":81,"limit_window_seconds":18000,"reset_at":1786536977}},
462 "rate_limit_reset_credits":{"available_count":2}}"#,
463 )
464 .create_async()
465 .await;
466 let details = server
467 .mock("GET", "/backend-api/wham/rate-limit-reset-credits")
468 .with_body(
469 r#"{"available_count":2,"credits":[
470 {"id":"c1","status":"available","title":"Full reset (Weekly + 5 hr)","expires_at":"2026-07-17T00:00:00Z"},
471 {"id":"c2","status":"available","title":"Full reset (Weekly + 5 hr)","expires_at":"2026-08-01T00:00:00Z"}]}"#,
472 )
473 .create_async()
474 .await;
475
476 let (_td, cache) = cache_fixture();
477 let creds = future_creds();
478 let endpoints = Endpoints {
479 usage: format!("{}/backend-api/wham/usage", server.url()),
480 token: format!("{}/oauth/token", server.url()),
481 };
482 let out = fetch_snapshot(
483 &reqwest::Client::new(),
484 creds.path(),
485 &cache,
486 &endpoints,
487 Duration::from_secs(0),
488 )
489 .await
490 .unwrap();
491 usage.assert_async().await;
492 details.assert_async().await;
493 assert_eq!(out.snapshot.reset_credits.available, 2);
494 assert_eq!(
495 out.snapshot.reset_credits.next_expiry(),
496 Some("2026-07-17T00:00:00Z".parse().unwrap())
497 );
498
499 let cached = std::fs::read_to_string(cache.payload_path()).unwrap();
502 assert!(!cached.contains("\"c1\""), "{cached}");
503 assert!(
508 !cached.contains("future_field"),
509 "the cache must not carry fields nothing parses: {cached}"
510 );
511 let reused = parse_payload(cached.as_bytes(), None).unwrap();
512 assert_eq!(reused.reset_credits, out.snapshot.reset_credits);
513 }
514
515 #[tokio::test]
521 async fn the_cache_holds_no_account_identity() {
522 let mut server = mockito::Server::new_async().await;
523 let usage = server
524 .mock("GET", "/backend-api/wham/usage")
525 .with_body(
526 r#"{"plan_type":"pro",
527 "user_id":"user_abc123",
528 "account_id":"acct_abc123",
529 "email":"person@example.test",
530 "rate_limit":{"primary_window":{"used_percent":5,
531 "limit_window_seconds":604800}}}"#,
532 )
533 .create_async()
534 .await;
535
536 let (_td, cache) = cache_fixture();
537 let creds = future_creds();
538 let endpoints = Endpoints {
539 usage: format!("{}/backend-api/wham/usage", server.url()),
540 token: format!("{}/oauth/token", server.url()),
541 };
542 let out = fetch_snapshot(
543 &reqwest::Client::new(),
544 creds.path(),
545 &cache,
546 &endpoints,
547 Duration::from_secs(0),
548 )
549 .await
550 .unwrap();
551 usage.assert_async().await;
552
553 assert_eq!(out.snapshot.weekly.as_ref().unwrap().utilization_pct, 5);
555
556 let cached = std::fs::read_to_string(cache.payload_path()).unwrap();
557 for identity in ["user_abc123", "acct_abc123", "person@example.test"] {
558 assert!(
559 !cached.contains(identity),
560 "{identity} reached the cache: {cached}"
561 );
562 }
563 for key in ["user_id", "account_id", "email"] {
564 assert!(!cached.contains(key), "{key} reached the cache: {cached}");
565 }
566 }
567
568 #[tokio::test]
572 async fn a_failed_detail_call_keeps_the_count_from_the_usage_response() {
573 let mut server = mockito::Server::new_async().await;
574 server
575 .mock("GET", "/backend-api/wham/usage")
576 .with_body(
577 r#"{"plan_type":"plus","rate_limit":{
578 "primary_window":{"used_percent":10,"limit_window_seconds":18000}},
579 "rate_limit_reset_credits":{"available_count":1}}"#,
580 )
581 .create_async()
582 .await;
583 let details = server
584 .mock("GET", "/backend-api/wham/rate-limit-reset-credits")
585 .with_status(404)
586 .create_async()
587 .await;
588
589 let (_td, cache) = cache_fixture();
590 let creds = future_creds();
591 let endpoints = Endpoints {
592 usage: format!("{}/backend-api/wham/usage", server.url()),
593 token: format!("{}/oauth/token", server.url()),
594 };
595 let out = fetch_snapshot(
596 &reqwest::Client::new(),
597 creds.path(),
598 &cache,
599 &endpoints,
600 Duration::from_secs(0),
601 )
602 .await
603 .unwrap();
604 details.assert_async().await;
605 assert!(!out.stale);
606 assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 10);
607 assert_eq!(out.snapshot.reset_credits.available, 1);
608 assert!(out.snapshot.reset_credits.credits.is_empty());
609 }
610
611 #[tokio::test]
614 async fn no_banked_resets_means_no_second_request() {
615 let mut server = mockito::Server::new_async().await;
616 server
617 .mock("GET", "/backend-api/wham/usage")
618 .with_body(
619 r#"{"plan_type":"plus","rate_limit":{
620 "primary_window":{"used_percent":10,"limit_window_seconds":18000}},
621 "rate_limit_reset_credits":{"available_count":0}}"#,
622 )
623 .create_async()
624 .await;
625 let details = server
626 .mock("GET", "/backend-api/wham/rate-limit-reset-credits")
627 .expect(0)
628 .create_async()
629 .await;
630
631 let (_td, cache) = cache_fixture();
632 let creds = future_creds();
633 let endpoints = Endpoints {
634 usage: format!("{}/backend-api/wham/usage", server.url()),
635 token: format!("{}/oauth/token", server.url()),
636 };
637 let out = fetch_snapshot(
638 &reqwest::Client::new(),
639 creds.path(),
640 &cache,
641 &endpoints,
642 Duration::from_secs(0),
643 )
644 .await
645 .unwrap();
646 details.assert_async().await;
647 assert!(out.snapshot.reset_credits.is_empty());
648 }
649
650 #[tokio::test]
651 async fn http_500_falls_back_to_cache_when_present() {
652 let mut server = mockito::Server::new_async().await;
653 server
654 .mock("GET", "/backend-api/wham/usage")
655 .with_status(500)
656 .with_body(r#"{"error":{"message":"upstream"}}"#)
657 .create_async()
658 .await;
659 let (_td, cache) = cache_fixture();
660 cache
661 .write_payload(
662 br#"{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":50,"limit_window_seconds":18000}}}"#,
663 )
664 .unwrap();
665 let creds = future_creds();
666 let client = reqwest::Client::new();
667 let endpoints = Endpoints {
668 usage: format!("{}/backend-api/wham/usage", server.url()),
669 token: format!("{}/oauth/token", server.url()),
670 };
671 let out = fetch_snapshot(
672 &client,
673 creds.path(),
674 &cache,
675 &endpoints,
676 Duration::from_secs(0),
677 )
678 .await
679 .unwrap();
680 assert!(out.stale);
681 assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 50);
682 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(500));
683 }
684}