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 cache.write_last_error(status, &body);
144 fallback(cache, plan_hint.as_deref(), Some((status, body)))
145 }
146 Ok(Err(e)) if e.is_transient() => fallback_silent(cache, plan_hint.as_deref()),
147 Ok(Err(e)) => {
148 cache.mark_stale();
149 cache.write_last_error(0, &e.to_string());
150 fallback(cache, plan_hint.as_deref(), Some((0, e.to_string())))
151 }
152 Err(_) => fallback_silent(cache, plan_hint.as_deref()),
153 }
154}
155
156fn reuse(
157 bytes: Vec<u8>,
158 cache: &Cache,
159 stale: bool,
160 plan_hint: Option<&str>,
161) -> Result<FetchOutcome> {
162 let snap = parse_payload(&bytes, plan_hint)?;
163 Ok(FetchOutcome {
164 snapshot: snap,
165 stale,
166 last_error: cache.read_last_error(),
167 cache_age: cache.payload_age(),
168 })
169}
170
171fn fallback(
172 cache: &Cache,
173 plan_hint: Option<&str>,
174 last_error: Option<(u16, String)>,
175) -> Result<FetchOutcome> {
176 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
177 return Err(AppError::Other("openai: no usable cache".into()));
178 };
179 let mut out = reuse(bytes, cache, true, plan_hint)?;
180 out.last_error = last_error;
181 Ok(out)
182}
183
184fn fallback_silent(cache: &Cache, plan_hint: Option<&str>) -> Result<FetchOutcome> {
185 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
186 return Err(AppError::Transport(
187 "openai: no cache and network unreachable".into(),
188 ));
189 };
190 reuse(bytes, cache, true, plan_hint)
191}
192
193fn handle_auth_failure(
194 cache: &Cache,
195 plan_hint: Option<&str>,
196 transient: bool,
197) -> Result<FetchOutcome> {
198 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
199 return if transient {
200 Err(AppError::Transport(
201 "openai: no cache and refresh failed transiently".into(),
202 ))
203 } else {
204 Err(AppError::Credentials(
205 "openai: token refresh failed; run `codex login` to re-auth".into(),
206 ))
207 };
208 };
209 reuse(bytes, cache, true, plan_hint)
210}
211
212fn parse_payload(bytes: &[u8], plan_hint: Option<&str>) -> Result<OpenAiSnapshot> {
213 let r: UsageResponse = serde_json::from_slice(bytes)?;
214 r.into_snapshot(plan_hint)
215}
216
217async fn fetch_usage(client: &reqwest::Client, url: &str, t: &Tokens) -> Result<Vec<u8>> {
218 let mut req = client
219 .get(url)
220 .header("Authorization", format!("Bearer {}", t.access_token))
221 .header("User-Agent", "codex-cli");
222 if let Some(aid) = t.account_id.as_deref() {
223 req = req.header("ChatGPT-Account-Id", aid);
224 }
225 let resp = req.send().await?;
226 let status = resp.status();
227 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
228
229 if !status.is_success() {
230 let body: String = String::from_utf8_lossy(&bytes).chars().take(200).collect();
231 return Err(AppError::Http {
232 status: status.as_u16(),
233 body,
234 });
235 }
236 let parsed: UsageResponse = serde_json::from_slice(&bytes)
237 .map_err(|e| AppError::Schema(format!("openai usage response: {e}")))?;
238 parsed.into_snapshot(None)?;
239 Ok(bytes)
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use base64::Engine;
246 use std::io::Write;
247 use tempfile::{NamedTempFile, TempDir};
248
249 fn fake_jwt(claims: serde_json::Value) -> String {
250 let h = base64::engine::general_purpose::URL_SAFE_NO_PAD
251 .encode(br#"{"alg":"none","typ":"JWT"}"#);
252 let p =
253 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes());
254 format!("{h}.{p}.sig")
255 }
256
257 fn future_creds() -> NamedTempFile {
258 let exp = Utc::now().timestamp() + 3600;
260 let jwt = fake_jwt(serde_json::json!({
261 "exp": exp,
262 "https://api.openai.com/auth": {"chatgpt_plan_type": "plus"}
263 }));
264 let body = format!(
265 r#"{{"tokens":{{"access_token":"AT","refresh_token":"RT","id_token":"{jwt}",
266 "account_id":"acc"}}}}"#
267 );
268 let mut f = NamedTempFile::new().unwrap();
269 f.write_all(body.as_bytes()).unwrap();
270 f.flush().unwrap();
271 f
272 }
273
274 fn cache_fixture() -> (TempDir, Cache) {
275 let td = TempDir::new().unwrap();
276 let c = Cache::at(td.path().join("openai"));
277 c.ensure_dir().unwrap();
278 (td, c)
279 }
280
281 #[tokio::test]
282 async fn live_200_returns_snapshot_with_plan_from_id_token() {
283 let mut server = mockito::Server::new_async().await;
284 server
285 .mock("GET", "/backend-api/wham/usage")
286 .with_status(200)
287 .with_body(
288 r#"{"plan_type":"plus","rate_limit":{
289 "primary_window":{"used_percent":1,"limit_window_seconds":18000,"reset_at":1779597324},
290 "secondary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_at":1780184124}
291 }}"#,
292 )
293 .create_async()
294 .await;
295 let (_td, cache) = cache_fixture();
296 let creds = future_creds();
297 let client = reqwest::Client::new();
298 let endpoints = Endpoints {
299 usage: format!("{}/backend-api/wham/usage", server.url()),
300 token: format!("{}/oauth/token", server.url()),
301 };
302 let out = fetch_snapshot(
303 &client,
304 creds.path(),
305 &cache,
306 &endpoints,
307 Duration::from_secs(0),
308 )
309 .await
310 .unwrap();
311 assert_eq!(out.snapshot.plan, "ChatGPT Plus");
312 assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 1);
313 assert!(!out.stale);
314 }
315
316 #[tokio::test]
317 async fn weekly_only_primary_returns_weekly_snapshot() {
318 let mut server = mockito::Server::new_async().await;
319 server
320 .mock("GET", "/backend-api/wham/usage")
321 .with_status(200)
322 .with_body(
323 r#"{"plan_type":"prolite","rate_limit":{
324 "primary_window":{"used_percent":66,"limit_window_seconds":604800,"reset_at":1785261834},
325 "secondary_window":null
326 }}"#,
327 )
328 .create_async()
329 .await;
330 let (_td, cache) = cache_fixture();
331 let creds = future_creds();
332 let endpoints = Endpoints {
333 usage: format!("{}/backend-api/wham/usage", server.url()),
334 token: format!("{}/oauth/token", server.url()),
335 };
336 let out = fetch_snapshot(
337 &reqwest::Client::new(),
338 creds.path(),
339 &cache,
340 &endpoints,
341 Duration::from_secs(0),
342 )
343 .await
344 .unwrap();
345 assert!(out.snapshot.session.is_none());
346 assert_eq!(out.snapshot.weekly.unwrap().utilization_pct, 66);
347 }
348
349 #[tokio::test]
350 async fn corrupt_fresh_cache_refetches_instead_of_showing_an_empty_snapshot() {
351 let mut server = mockito::Server::new_async().await;
354 server
355 .mock("GET", "/backend-api/wham/usage")
356 .with_status(200)
357 .with_body(
358 r#"{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":37,"limit_window_seconds":18000}}}"#,
359 )
360 .create_async()
361 .await;
362
363 let (_td, cache) = cache_fixture();
364 cache.write_payload(b"{ truncated").unwrap();
365
366 let creds = future_creds();
367 let client = reqwest::Client::new();
368 let endpoints = Endpoints {
369 usage: format!("{}/backend-api/wham/usage", server.url()),
370 token: format!("{}/oauth/token", server.url()),
371 };
372 let out = fetch_snapshot(
374 &client,
375 creds.path(),
376 &cache,
377 &endpoints,
378 Duration::from_secs(3600),
379 )
380 .await
381 .unwrap();
382 assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 37);
383 assert!(!out.stale);
384 }
385
386 #[tokio::test]
387 async fn http_500_falls_back_to_cache_when_present() {
388 let mut server = mockito::Server::new_async().await;
389 server
390 .mock("GET", "/backend-api/wham/usage")
391 .with_status(500)
392 .with_body(r#"{"error":{"message":"upstream"}}"#)
393 .create_async()
394 .await;
395 let (_td, cache) = cache_fixture();
396 cache
397 .write_payload(
398 br#"{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":50,"limit_window_seconds":18000}}}"#,
399 )
400 .unwrap();
401 let creds = future_creds();
402 let client = reqwest::Client::new();
403 let endpoints = Endpoints {
404 usage: format!("{}/backend-api/wham/usage", server.url()),
405 token: format!("{}/oauth/token", server.url()),
406 };
407 let out = fetch_snapshot(
408 &client,
409 creds.path(),
410 &cache,
411 &endpoints,
412 Duration::from_secs(0),
413 )
414 .await
415 .unwrap();
416 assert!(out.stale);
417 assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 50);
418 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(500));
419 }
420}