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