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