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