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, 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/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
39/// specialised to its snapshot.
40pub 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    // Corrupt fresh cache falls through to a live fetch rather than returning
56    // an all-zero snapshot.
57    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    // Maybe refresh — Codex CLI doesn't always populate expires_at, so we use
64    // the id_token's exp claim.
65    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                // A rotated refresh token exists only in memory until it is
76                // persisted; losing it silently logs the user out on the next
77                // run. See the matching comment in `anthropic::fetch`.
78                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                // Expiry is normally read from the id_token's exp claim, so a
86                // refresh that returns no new id_token would leave the old
87                // (expired) claim in place and make every later run refresh
88                // again. Record the response's own `expires_in` as the explicit
89                // `expires_at` so the expiry is known either way.
90                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
187/// The one place a *synthesized* error beats the original: the refresh failed,
188/// and "run `codex login` to re-auth" tells the user what to do about it,
189/// which the underlying OAuth error does not.
190fn 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        // exp 1h in the future.
252        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        // `reuse` used to swallow a parse failure into `empty(plan_hint)` and
345        // serve that all-zero snapshot for the rest of the TTL.
346        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        // A long TTL: the payload IS fresh, it is simply unusable.
366        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}