Skip to main content

vct_core/quota/
mod.rs

1//! Quota orchestration for the `usage` panels.
2//!
3//! Each supported provider (Claude / Codex / Copilot / Cursor) runs its own
4//! background worker ([`provider::spawn_quota_worker`]) that refreshes a shared
5//! snapshot on the configured cadence, seeded from an on-disk cache. The
6//! provider-specific fetch (and, for Claude / Codex, token refresh) lives in
7//! [`claude`], [`wham`] (+ this module's [`CodexState`]), [`copilot`], and
8//! [`cursor`]; the shared HTTP + refresh primitives live in [`http`] and
9//! [`refresh`]. The one-shot `vct quota` raw fetchers (`fetch_*_raw`) are the
10//! reusable core entry points; the CLI does the render.
11
12pub mod cache;
13pub mod claude;
14pub mod codex_session;
15pub mod copilot;
16pub mod cursor;
17pub mod http;
18pub mod provider;
19pub mod refresh;
20pub mod wham;
21
22pub use cache::{
23    load_claude_cache, load_codex_cache, load_copilot_cache, load_cursor_cache, save_claude_cache,
24    save_codex_cache, save_copilot_cache, save_cursor_cache,
25};
26pub use claude::{CLAUDE_LOGIN_HINT, ClaudeState};
27pub use copilot::{COPILOT_LOGIN_HINT, CopilotState};
28pub use cursor::{CURSOR_LOGIN_HINT, CursorState};
29pub use provider::{QuotaOutcome, QuotaSnapshot, spawn_quota_worker};
30
31use crate::models::CodexQuotaSnapshot;
32use crate::quota::refresh::{RefreshCooldown, file_mtime};
33use crate::quota::wham::{ResetCreditsResult, WhamResult};
34use std::time::SystemTime;
35
36/// Login hint shown when Codex refresh fails.
37pub const CODEX_LOGIN_HINT: &str = "run: codex auth login";
38
39/// Outcome of a Codex wham fetch (with reactive refresh).
40enum CodexFetch {
41    Ok(CodexQuotaSnapshot),
42    /// Token rejected and refresh failed → show login hint (keep session data).
43    NeedsLogin,
44    /// Network / non-auth error → fall back to session logs.
45    Transient,
46}
47
48/// Per-worker Codex state: an in-memory access token + refresh backoff.
49///
50/// Codex `auth.json` carries no explicit expiry, so refresh is **reactive**:
51/// the stored (or in-memory) access token is used until a Codex quota endpoint
52/// 401s, then it is refreshed and the rejected request is retried once.
53#[derive(Default)]
54pub struct CodexState {
55    /// Cached access token + the `auth.json` mtime it came from, so a re-login /
56    /// account switch (which rewrites the file) drops the stale token.
57    token: Option<(String, Option<SystemTime>)>,
58    cooldown: RefreshCooldown,
59    /// Separate backoff for a details-only 401, which must never blank a valid
60    /// usage snapshot or turn into a login hint.
61    reset_credits_cooldown: RefreshCooldown,
62}
63
64impl CodexState {
65    /// One worker tick: wham API with reactive refresh, else session fallback.
66    pub fn resolve(
67        &mut self,
68        client: &reqwest::blocking::Client,
69    ) -> QuotaOutcome<CodexQuotaSnapshot> {
70        let now = chrono::Local::now().timestamp();
71        let auth = crate::utils::resolve_paths()
72            .ok()
73            .map(|p| p.codex_dir.join("auth.json"));
74
75        if let Some(auth) = &auth
76            && auth.exists()
77        {
78            match self.fetch_with_refresh(
79                client,
80                auth,
81                now,
82                wham::WHAM_URL,
83                wham::WHAM_RESET_CREDITS_URL,
84                wham::CODEX_TOKEN_URL,
85            ) {
86                CodexFetch::Ok(snap) => return QuotaOutcome::Data(snap),
87                CodexFetch::NeedsLogin => {
88                    // Keep any session-fallback data, flag the login hint (S3).
89                    let mut snap = codex_session::latest_session_rate_limits()
90                        .ok()
91                        .flatten()
92                        .unwrap_or_default();
93                    snap.needs_login = true;
94                    snap.fetched_at = now;
95                    return QuotaOutcome::Data(snap);
96                }
97                CodexFetch::Transient => { /* fall through to session logs */ }
98            }
99        }
100
101        match codex_session::latest_session_rate_limits() {
102            Ok(Some(snap)) => QuotaOutcome::Data(snap),
103            _ => QuotaOutcome::Transient,
104        }
105    }
106
107    /// wham call with a reactive 401 → refresh → retry-once.
108    ///
109    /// The URL parameters are the usage, reset-credit details, and token
110    /// endpoints. Production passes the [`wham`] module constants; tests point
111    /// them at a local mock server so the full refresh path runs offline.
112    fn fetch_with_refresh(
113        &mut self,
114        client: &reqwest::blocking::Client,
115        auth: &std::path::Path,
116        now: i64,
117        wham_url: &str,
118        reset_credits_url: &str,
119        token_url: &str,
120    ) -> CodexFetch {
121        let body = match std::fs::read_to_string(auth) {
122            Ok(b) => b,
123            Err(e) => {
124                log::warn!("codex quota: failed to read {}: {e}", auth.display());
125                return CodexFetch::Transient;
126            }
127        };
128        let (file_token, account_id) = match wham::parse_auth(&body) {
129            Ok(x) => x,
130            Err(e) => {
131                log::warn!("codex quota: failed to parse auth.json: {e}");
132                return CodexFetch::Transient;
133            }
134        };
135        let cur_mtime = file_mtime(auth);
136        // Reuse the cached token only if auth.json hasn't changed since we cached
137        // it; a re-login / account switch rewrites the file and must be picked up.
138        let token = match &self.token {
139            Some((t, m)) if *m == cur_mtime => t.clone(),
140            _ => file_token,
141        };
142
143        match wham::call_wham(client, &token, account_id.as_deref(), now, wham_url) {
144            WhamResult::Ok(snap) => self.finish_with_reset_credits(
145                client,
146                auth,
147                snap,
148                token,
149                cur_mtime,
150                account_id.as_deref(),
151                now,
152                reset_credits_url,
153                token_url,
154                true,
155            ),
156            WhamResult::Unauthorized => {
157                if self.cooldown.active(now, cur_mtime) {
158                    self.token = None;
159                    return CodexFetch::NeedsLogin;
160                }
161                match wham::refresh_codex(client, auth, token_url) {
162                    Ok(new_tok) => {
163                        self.cooldown.clear();
164                        // The successful refresh just rewrote auth.json; key the
165                        // cache + cooldown on the post-write mtime (a stale one
166                        // would never suppress the next tick).
167                        let post_mtime = file_mtime(auth);
168                        self.token = Some((new_tok.clone(), post_mtime));
169                        match wham::call_wham(
170                            client,
171                            &new_tok,
172                            account_id.as_deref(),
173                            now,
174                            wham_url,
175                        ) {
176                            WhamResult::Ok(snap) => self.finish_with_reset_credits(
177                                client,
178                                auth,
179                                snap,
180                                new_tok,
181                                post_mtime,
182                                account_id.as_deref(),
183                                now,
184                                reset_credits_url,
185                                token_url,
186                                false,
187                            ),
188                            // A transient retry error keeps the fresh token and
189                            // falls back to session data; only a 401 means login.
190                            WhamResult::Transient => CodexFetch::Transient,
191                            WhamResult::Unauthorized => {
192                                self.cooldown.arm(now, post_mtime);
193                                self.token = None;
194                                CodexFetch::NeedsLogin
195                            }
196                        }
197                    }
198                    Err(e) => {
199                        log::warn!("codex token refresh failed: {e}");
200                        self.cooldown.arm(now, file_mtime(auth));
201                        self.token = None;
202                        CodexFetch::NeedsLogin
203                    }
204                }
205            }
206            WhamResult::Transient => CodexFetch::Transient,
207        }
208    }
209
210    /// Enriches a successful usage snapshot and optionally refreshes once when
211    /// only the reset-credit details endpoint rejects the token.
212    #[allow(clippy::too_many_arguments)]
213    fn finish_with_reset_credits(
214        &mut self,
215        client: &reqwest::blocking::Client,
216        auth: &std::path::Path,
217        mut snap: CodexQuotaSnapshot,
218        token: String,
219        token_mtime: Option<SystemTime>,
220        account_id: Option<&str>,
221        now: i64,
222        reset_credits_url: &str,
223        token_url: &str,
224        refresh_on_unauthorized: bool,
225    ) -> CodexFetch {
226        self.cooldown.clear();
227        self.token = Some((token.clone(), token_mtime));
228
229        match wham::call_reset_credit_details(client, &token, account_id, reset_credits_url) {
230            ResetCreditsResult::Ok {
231                available_count,
232                expirations,
233            } => {
234                self.reset_credits_cooldown.clear();
235                snap.reset_credits_available = Some(available_count);
236                snap.reset_credit_expirations = Some(expirations);
237            }
238            ResetCreditsResult::Transient => {}
239            ResetCreditsResult::Unauthorized => {
240                if !refresh_on_unauthorized {
241                    self.reset_credits_cooldown.arm(now, token_mtime);
242                    return CodexFetch::Ok(snap);
243                }
244                if self.reset_credits_cooldown.active(now, token_mtime) {
245                    return CodexFetch::Ok(snap);
246                }
247
248                let new_token = match wham::refresh_codex(client, auth, token_url) {
249                    Ok(new_token) => new_token,
250                    Err(e) => {
251                        log::warn!("codex token refresh for reset-credit details failed: {e}");
252                        self.reset_credits_cooldown.arm(now, file_mtime(auth));
253                        return CodexFetch::Ok(snap);
254                    }
255                };
256                let post_mtime = file_mtime(auth);
257                self.token = Some((new_token.clone(), post_mtime));
258                match wham::call_reset_credit_details(
259                    client,
260                    &new_token,
261                    account_id,
262                    reset_credits_url,
263                ) {
264                    ResetCreditsResult::Ok {
265                        available_count,
266                        expirations,
267                    } => {
268                        self.reset_credits_cooldown.clear();
269                        snap.reset_credits_available = Some(available_count);
270                        snap.reset_credit_expirations = Some(expirations);
271                    }
272                    ResetCreditsResult::Unauthorized => {
273                        self.reset_credits_cooldown.arm(now, post_mtime);
274                    }
275                    ResetCreditsResult::Transient => {}
276                }
277            }
278        }
279
280        CodexFetch::Ok(snap)
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use httpmock::prelude::*;
288
289    /// The reactive 401 → refresh → retry loop, end-to-end against a mock server.
290    ///
291    /// The first wham call carries the stale token and 401s; the loop then hits
292    /// the (mock) token endpoint, writes the rotated token back to auth.json, and
293    /// retries wham with the fresh token, which succeeds. The two wham mocks are
294    /// distinguished by their `Authorization` header so the ordering is asserted
295    /// structurally rather than by call sequence.
296    #[test]
297    fn fetch_with_refresh_recovers_from_401() {
298        let server = MockServer::start();
299        let stale = server.mock(|when, then| {
300            when.method(GET)
301                .path("/wham")
302                .header("authorization", "Bearer stale");
303            then.status(401);
304        });
305        let fresh = server.mock(|when, then| {
306            when.method(GET)
307                .path("/wham")
308                .header("authorization", "Bearer new-access");
309            then.status(200).body(r#"{"plan_type":"plus"}"#);
310        });
311        let reset_credits = server.mock(|when, then| {
312            when.method(GET)
313                .path("/reset-credits")
314                .header("authorization", "Bearer new-access");
315            then.status(200)
316                .body(r#"{"credits":[],"available_count":0}"#);
317        });
318        let token = server.mock(|when, then| {
319            when.method(POST).path("/token");
320            then.status(200).json_body(serde_json::json!({
321                "access_token": "new-access",
322                "refresh_token": "new-refresh"
323            }));
324        });
325
326        let dir = tempfile::tempdir().unwrap();
327        let auth = dir.path().join("auth.json");
328        std::fs::write(
329            &auth,
330            r#"{"tokens":{"access_token":"stale","refresh_token":"rt","account_id":"acct"}}"#,
331        )
332        .unwrap();
333
334        let client = crate::quota::http::build_client().unwrap();
335        let mut state = CodexState::default();
336        let result = state.fetch_with_refresh(
337            &client,
338            &auth,
339            1_000_000,
340            &server.url("/wham"),
341            &server.url("/reset-credits"),
342            &server.url("/token"),
343        );
344
345        stale.assert();
346        token.assert();
347        fresh.assert();
348        reset_credits.assert();
349        match result {
350            CodexFetch::Ok(snap) => assert_eq!(snap.plan_type.as_deref(), Some("plus")),
351            _ => panic!("expected a recovered snapshot after refresh"),
352        }
353
354        // The rotated token was persisted back to auth.json.
355        let written: serde_json::Value =
356            serde_json::from_str(&std::fs::read_to_string(&auth).unwrap()).unwrap();
357        assert_eq!(written["tokens"]["access_token"], "new-access");
358    }
359
360    /// A 401 from only the optional details endpoint refreshes once, retries
361    /// details with the new token, and keeps the already-successful usage body.
362    #[test]
363    fn fetch_with_refresh_recovers_from_reset_credit_401() {
364        let server = MockServer::start();
365        let usage = server.mock(|when, then| {
366            when.method(GET)
367                .path("/wham")
368                .header("authorization", "Bearer stale");
369            then.status(200)
370                .body(r#"{"plan_type":"plus","rate_limit_reset_credits":{"available_count":2}}"#);
371        });
372        let stale_details = server.mock(|when, then| {
373            when.method(GET)
374                .path("/reset-credits")
375                .header("authorization", "Bearer stale");
376            then.status(401);
377        });
378        let token = server.mock(|when, then| {
379            when.method(POST).path("/token");
380            then.status(200).json_body(serde_json::json!({
381                "access_token": "new-access",
382                "refresh_token": "new-refresh"
383            }));
384        });
385        let fresh_details = server.mock(|when, then| {
386            when.method(GET)
387                .path("/reset-credits")
388                .header("authorization", "Bearer new-access");
389            then.status(200).body(
390                r#"{"credits":[{"id":"credit-1","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-07-01T00:00:00Z","expires_at":"2026-08-01T00:00:00Z"}],"available_count":1}"#,
391            );
392        });
393
394        let dir = tempfile::tempdir().unwrap();
395        let auth = dir.path().join("auth.json");
396        std::fs::write(
397            &auth,
398            r#"{"tokens":{"access_token":"stale","refresh_token":"rt","account_id":"acct"}}"#,
399        )
400        .unwrap();
401
402        let client = crate::quota::http::build_client().unwrap();
403        let mut state = CodexState::default();
404        let result = state.fetch_with_refresh(
405            &client,
406            &auth,
407            1_000_000,
408            &server.url("/wham"),
409            &server.url("/reset-credits"),
410            &server.url("/token"),
411        );
412
413        usage.assert();
414        stale_details.assert();
415        token.assert();
416        fresh_details.assert();
417        match result {
418            CodexFetch::Ok(snap) => {
419                assert_eq!(snap.plan_type.as_deref(), Some("plus"));
420                assert_eq!(snap.reset_credits_available, Some(1));
421                assert_eq!(snap.reset_credit_expirations.unwrap().len(), 1);
422            }
423            _ => panic!("expected a recovered snapshot after details refresh"),
424        }
425    }
426
427    /// When refresh itself fails (token endpoint 400), the loop reports
428    /// `NeedsLogin` rather than looping or succeeding.
429    #[test]
430    fn fetch_with_refresh_needs_login_when_refresh_fails() {
431        let server = MockServer::start();
432        server.mock(|when, then| {
433            when.method(GET).path("/wham");
434            then.status(401);
435        });
436        server.mock(|when, then| {
437            when.method(POST).path("/token");
438            then.status(400)
439                .json_body(serde_json::json!({ "error": "invalid_grant" }));
440        });
441
442        let dir = tempfile::tempdir().unwrap();
443        let auth = dir.path().join("auth.json");
444        std::fs::write(
445            &auth,
446            r#"{"tokens":{"access_token":"stale","refresh_token":"rt"}}"#,
447        )
448        .unwrap();
449
450        let client = crate::quota::http::build_client().unwrap();
451        let mut state = CodexState::default();
452        let result = state.fetch_with_refresh(
453            &client,
454            &auth,
455            1_000_000,
456            &server.url("/wham"),
457            &server.url("/reset-credits"),
458            &server.url("/token"),
459        );
460        assert!(matches!(result, CodexFetch::NeedsLogin));
461    }
462}