Skip to main content

ai_usagebar/openai/
fetch.rs

1//! Orchestrate: read ~/.codex/auth.json → maybe refresh → fetch usage → cache.
2//!
3//! Mirrors `anthropic::fetch::fetch_snapshot` but for the Codex OAuth flow.
4
5use 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    // Corrupt fresh cache falls through to a live fetch rather than returning
60    // an all-zero snapshot.
61    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    // Maybe refresh — Codex CLI doesn't always populate expires_at, so we use
68    // the id_token's exp claim.
69    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                // A rotated refresh token exists only in memory until it is
80                // persisted; losing it silently logs the user out on the next
81                // run. See the matching comment in `anthropic::fetch`.
82                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                // Expiry is normally read from the id_token's exp claim, so a
90                // refresh that returns no new id_token would leave the old
91                // (expired) claim in place and make every later run refresh
92                // again. Record the response's own `expires_in` as the explicit
93                // `expires_at` so the expiry is known either way.
94                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
180/// On failure we show the last good figure with the error alongside it. With
181/// nothing usable cached there is nothing to show, so the **original** error is
182/// returned rather than a generic "no usable cache" that hides what went wrong
183/// — a cold cache and an expired key would otherwise look identical.
184fn 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        // exp 1h in the future.
275        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        // `reuse` used to swallow a parse failure into `empty(plan_hint)` and
368        // serve that all-zero snapshot for the rest of the TTL.
369        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        // A long TTL: the payload IS fresh, it is simply unusable.
389        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}