1use std::path::Path;
6use std::time::Duration;
7
8use chrono::Utc;
9
10use crate::cache::{Cache, MAX_STALE, 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
38#[derive(Debug, Clone)]
39pub struct FetchOutcome {
40 pub snapshot: OpenAiSnapshot,
41 pub stale: bool,
42 pub last_error: Option<(u16, String)>,
43 pub cache_age: Option<Duration>,
44}
45
46pub async fn fetch_snapshot(
47 client: &reqwest::Client,
48 creds_path: &Path,
49 cache: &Cache,
50 endpoints: &Endpoints,
51 cache_ttl: Duration,
52) -> Result<FetchOutcome> {
53 cache.ensure_dir()?;
54 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
55
56 let mut auth = creds::read_from(creds_path)?;
57 let plan_hint = auth.tokens.plan_type_from_id_token();
58
59 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
62 && let Ok(outcome) = reuse(bytes, cache, false, plan_hint.as_deref())
63 {
64 return Ok(outcome);
65 }
66
67 let now = Utc::now().timestamp();
70 if oauth::needs_refresh(auth.tokens.expires_at_secs(), now) {
71 match tokio::time::timeout(
72 REFRESH_TIMEOUT,
73 oauth::refresh(client, &endpoints.token, &auth.tokens.refresh_token),
74 )
75 .await
76 {
77 Ok(Ok(rr)) => {
78 auth.tokens.access_token = rr.access_token;
79 let rotated = rr.refresh_token.is_some();
83 if let Some(rt) = rr.refresh_token {
84 auth.tokens.refresh_token = rt;
85 }
86 if let Some(id) = rr.id_token {
87 auth.tokens.id_token = id;
88 }
89 if let Some(secs) = rr.expires_in
95 && let Some(dt) = chrono::DateTime::from_timestamp(now + secs as i64, 0)
96 {
97 auth.tokens.expires_at = Some(dt.to_rfc3339());
98 }
99 if let Err(e) = creds::write_back(creds_path, &auth)
100 && rotated
101 {
102 let msg = format!(
103 "refreshed token could not be saved ({e}); the rotated \
104 refresh token is lost — re-run `codex login`"
105 );
106 cache.write_last_error(0, &msg);
107 return handle_auth_failure(cache, plan_hint.as_deref(), false);
108 }
109 }
110 Ok(Err(AppError::Http { status, body })) => {
111 cache.write_last_error(status, &body);
112 return handle_auth_failure(cache, plan_hint.as_deref(), false);
113 }
114 Ok(Err(e)) if e.is_transient() => {
115 return handle_auth_failure(cache, plan_hint.as_deref(), true);
116 }
117 Ok(Err(e)) => {
118 cache.write_last_error(0, &e.to_string());
119 return handle_auth_failure(cache, plan_hint.as_deref(), false);
120 }
121 Err(_) => return handle_auth_failure(cache, plan_hint.as_deref(), true),
122 }
123 }
124
125 match tokio::time::timeout(
126 HTTP_TIMEOUT,
127 fetch_usage(client, &endpoints.usage, &auth.tokens),
128 )
129 .await
130 {
131 Ok(Ok(bytes)) => {
132 cache.write_payload(&bytes)?;
133 let snap = parse_payload(&bytes, plan_hint.as_deref())?;
134 Ok(FetchOutcome {
135 snapshot: snap,
136 stale: false,
137 last_error: None,
138 cache_age: Some(Duration::ZERO),
139 })
140 }
141 Ok(Err(AppError::Http { status, body })) => {
142 cache.mark_stale();
143 let last_error = Some(cache.write_last_error(status, &body));
144 fallback(
145 cache,
146 plan_hint.as_deref(),
147 last_error,
148 AppError::Http { status, body },
149 )
150 }
151 Ok(Err(e)) if e.is_transient() => fallback_silent(cache, plan_hint.as_deref(), e),
152 Ok(Err(e)) => {
153 cache.mark_stale();
154 let last_error = Some(cache.write_last_error(0, &e.to_string()));
155 fallback(cache, plan_hint.as_deref(), last_error, e)
156 }
157 Err(_) => fallback_silent(
158 cache,
159 plan_hint.as_deref(),
160 AppError::Transport("openai: usage request timed out".into()),
161 ),
162 }
163}
164
165fn reuse(
166 bytes: Vec<u8>,
167 cache: &Cache,
168 stale: bool,
169 plan_hint: Option<&str>,
170) -> Result<FetchOutcome> {
171 let snap = parse_payload(&bytes, plan_hint)?;
172 Ok(FetchOutcome {
173 snapshot: snap,
174 stale,
175 last_error: cache.read_last_error(),
176 cache_age: cache.payload_age(),
177 })
178}
179
180fn fallback(
185 cache: &Cache,
186 plan_hint: Option<&str>,
187 last_error: Option<(u16, String)>,
188 original: AppError,
189) -> Result<FetchOutcome> {
190 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
191 return Err(original);
192 };
193 let mut out = reuse(bytes, cache, true, plan_hint)?;
194 out.last_error = last_error;
195 Ok(out)
196}
197
198fn fallback_silent(
199 cache: &Cache,
200 plan_hint: Option<&str>,
201 original: AppError,
202) -> Result<FetchOutcome> {
203 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
204 return Err(original);
205 };
206 reuse(bytes, cache, true, plan_hint)
207}
208
209fn handle_auth_failure(
210 cache: &Cache,
211 plan_hint: Option<&str>,
212 transient: bool,
213) -> Result<FetchOutcome> {
214 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
215 return if transient {
216 Err(AppError::Transport(
217 "openai: no cache and refresh failed transiently".into(),
218 ))
219 } else {
220 Err(AppError::Credentials(
221 "openai: token refresh failed; run `codex login` to re-auth".into(),
222 ))
223 };
224 };
225 reuse(bytes, cache, true, plan_hint)
226}
227
228fn parse_payload(bytes: &[u8], plan_hint: Option<&str>) -> Result<OpenAiSnapshot> {
229 let r: UsageResponse = serde_json::from_slice(bytes)?;
230 r.into_snapshot(plan_hint)
231}
232
233async fn fetch_usage(client: &reqwest::Client, url: &str, t: &Tokens) -> Result<Vec<u8>> {
234 let mut req = client
235 .get(url)
236 .header("Authorization", format!("Bearer {}", t.access_token))
237 .header("User-Agent", "codex-cli");
238 if let Some(aid) = t.account_id.as_deref() {
239 req = req.header("ChatGPT-Account-Id", aid);
240 }
241 let resp = req.send().await?;
242 let status = resp.status();
243 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
244
245 if !status.is_success() {
246 let body: String = String::from_utf8_lossy(&bytes).chars().take(200).collect();
247 return Err(AppError::Http {
248 status: status.as_u16(),
249 body,
250 });
251 }
252 let parsed: UsageResponse = serde_json::from_slice(&bytes)
253 .map_err(|e| AppError::Schema(format!("openai usage response: {e}")))?;
254 parsed.into_snapshot(None)?;
255 Ok(bytes)
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use base64::Engine;
262 use std::io::Write;
263 use tempfile::{NamedTempFile, TempDir};
264
265 fn fake_jwt(claims: serde_json::Value) -> String {
266 let h = base64::engine::general_purpose::URL_SAFE_NO_PAD
267 .encode(br#"{"alg":"none","typ":"JWT"}"#);
268 let p =
269 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes());
270 format!("{h}.{p}.sig")
271 }
272
273 fn future_creds() -> NamedTempFile {
274 let exp = Utc::now().timestamp() + 3600;
276 let jwt = fake_jwt(serde_json::json!({
277 "exp": exp,
278 "https://api.openai.com/auth": {"chatgpt_plan_type": "plus"}
279 }));
280 let body = format!(
281 r#"{{"tokens":{{"access_token":"AT","refresh_token":"RT","id_token":"{jwt}",
282 "account_id":"acc"}}}}"#
283 );
284 let mut f = NamedTempFile::new().unwrap();
285 f.write_all(body.as_bytes()).unwrap();
286 f.flush().unwrap();
287 f
288 }
289
290 fn cache_fixture() -> (TempDir, Cache) {
291 let td = TempDir::new().unwrap();
292 let c = Cache::at(td.path().join("openai"));
293 c.ensure_dir().unwrap();
294 (td, c)
295 }
296
297 #[tokio::test]
298 async fn live_200_returns_snapshot_with_plan_from_id_token() {
299 let mut server = mockito::Server::new_async().await;
300 server
301 .mock("GET", "/backend-api/wham/usage")
302 .with_status(200)
303 .with_body(
304 r#"{"plan_type":"plus","rate_limit":{
305 "primary_window":{"used_percent":1,"limit_window_seconds":18000,"reset_at":1779597324},
306 "secondary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_at":1780184124}
307 }}"#,
308 )
309 .create_async()
310 .await;
311 let (_td, cache) = cache_fixture();
312 let creds = future_creds();
313 let client = reqwest::Client::new();
314 let endpoints = Endpoints {
315 usage: format!("{}/backend-api/wham/usage", server.url()),
316 token: format!("{}/oauth/token", server.url()),
317 };
318 let out = fetch_snapshot(
319 &client,
320 creds.path(),
321 &cache,
322 &endpoints,
323 Duration::from_secs(0),
324 )
325 .await
326 .unwrap();
327 assert_eq!(out.snapshot.plan, "ChatGPT Plus");
328 assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 1);
329 assert!(!out.stale);
330 }
331
332 #[tokio::test]
333 async fn weekly_only_primary_returns_weekly_snapshot() {
334 let mut server = mockito::Server::new_async().await;
335 server
336 .mock("GET", "/backend-api/wham/usage")
337 .with_status(200)
338 .with_body(
339 r#"{"plan_type":"prolite","rate_limit":{
340 "primary_window":{"used_percent":66,"limit_window_seconds":604800,"reset_at":1785261834},
341 "secondary_window":null
342 }}"#,
343 )
344 .create_async()
345 .await;
346 let (_td, cache) = cache_fixture();
347 let creds = future_creds();
348 let endpoints = Endpoints {
349 usage: format!("{}/backend-api/wham/usage", server.url()),
350 token: format!("{}/oauth/token", server.url()),
351 };
352 let out = fetch_snapshot(
353 &reqwest::Client::new(),
354 creds.path(),
355 &cache,
356 &endpoints,
357 Duration::from_secs(0),
358 )
359 .await
360 .unwrap();
361 assert!(out.snapshot.session.is_none());
362 assert_eq!(out.snapshot.weekly.unwrap().utilization_pct, 66);
363 }
364
365 #[tokio::test]
366 async fn corrupt_fresh_cache_refetches_instead_of_showing_an_empty_snapshot() {
367 let mut server = mockito::Server::new_async().await;
370 server
371 .mock("GET", "/backend-api/wham/usage")
372 .with_status(200)
373 .with_body(
374 r#"{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":37,"limit_window_seconds":18000}}}"#,
375 )
376 .create_async()
377 .await;
378
379 let (_td, cache) = cache_fixture();
380 cache.write_payload(b"{ truncated").unwrap();
381
382 let creds = future_creds();
383 let client = reqwest::Client::new();
384 let endpoints = Endpoints {
385 usage: format!("{}/backend-api/wham/usage", server.url()),
386 token: format!("{}/oauth/token", server.url()),
387 };
388 let out = fetch_snapshot(
390 &client,
391 creds.path(),
392 &cache,
393 &endpoints,
394 Duration::from_secs(3600),
395 )
396 .await
397 .unwrap();
398 assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 37);
399 assert!(!out.stale);
400 }
401
402 #[tokio::test]
403 async fn http_500_falls_back_to_cache_when_present() {
404 let mut server = mockito::Server::new_async().await;
405 server
406 .mock("GET", "/backend-api/wham/usage")
407 .with_status(500)
408 .with_body(r#"{"error":{"message":"upstream"}}"#)
409 .create_async()
410 .await;
411 let (_td, cache) = cache_fixture();
412 cache
413 .write_payload(
414 br#"{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":50,"limit_window_seconds":18000}}}"#,
415 )
416 .unwrap();
417 let creds = future_creds();
418 let client = reqwest::Client::new();
419 let endpoints = Endpoints {
420 usage: format!("{}/backend-api/wham/usage", server.url()),
421 token: format!("{}/oauth/token", server.url()),
422 };
423 let out = fetch_snapshot(
424 &client,
425 creds.path(),
426 &cache,
427 &endpoints,
428 Duration::from_secs(0),
429 )
430 .await
431 .unwrap();
432 assert!(out.stale);
433 assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 50);
434 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(500));
435 }
436}