Skip to main content

vct_core/models/
quota.rs

1//! Quota / rate-limit data models for the `usage` quota panels.
2//!
3//! Each provider has a raw wire shape that normalizes into one shared output
4//! ([`QuotaWindow`] / per-provider `*QuotaSnapshot`) so the TUI gauges render
5//! every provider identically:
6//!
7//! - **Claude** — `GET /api/oauth/usage` ([`ClaudeUsageResponse`]) plus the
8//!   OAuth credentials in `~/.claude/.credentials.json` ([`ClaudeCredentials`]).
9//! - **Codex** — `wham/usage` API ([`WhamUsageResponse`]) with a session-log
10//!   fallback ([`CodexSessionRateLimits`]) and `~/.codex/auth.json`.
11//!
12//! Structs holding bearer tokens use a hand-written [`fmt::Debug`] that redacts
13//! the secret so a token can never reach a log or assertion message.
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17use std::fmt;
18
19/// Renders an optional secret as `Some("<redacted>")` / `None` for `Debug`.
20fn redact(v: &Option<String>) -> Option<&'static str> {
21    v.as_ref().map(|_| "<redacted>")
22}
23
24// ---- Claude usage API (GET /api/oauth/usage) ----
25
26/// `https://api.anthropic.com/api/oauth/usage` response (subset we read).
27///
28/// The richer `limits` / `spend` fields only appear when the request carries the
29/// `anthropic-beta: oauth-2025-04-20` header; without it they stay empty and the
30/// panel falls back to just the two top-level windows.
31#[derive(Debug, Clone, Deserialize)]
32pub struct ClaudeUsageResponse {
33    /// 5-hour window.
34    #[serde(default)]
35    pub five_hour: Option<ClaudeUsageWindow>,
36    /// Weekly window.
37    #[serde(default)]
38    pub seven_day: Option<ClaudeUsageWindow>,
39    /// Per-scope limit entries (session / weekly_all / weekly_scoped, ...).
40    /// Parsed leniently so one malformed / volatile entry never fails the body.
41    #[serde(default, deserialize_with = "de_lenient_limits")]
42    pub limits: Vec<ClaudeLimit>,
43    /// Pay-as-you-go spend / credit balance.
44    #[serde(default)]
45    pub spend: Option<ClaudeSpend>,
46}
47
48/// One Claude usage window.
49#[derive(Debug, Clone, Deserialize)]
50pub struct ClaudeUsageWindow {
51    /// Percent of the window consumed (0..100). Null/wrong-type reads as 0.
52    #[serde(default, deserialize_with = "de_f64_or_zero")]
53    pub utilization: f64,
54    /// Absolute reset time as an ISO-8601 string.
55    #[serde(default)]
56    pub resets_at: Option<String>,
57}
58
59/// One entry of the `limits` array; carries the per-model weekly scope.
60#[derive(Debug, Clone, Deserialize)]
61pub struct ClaudeLimit {
62    /// Limit kind, e.g. `session` / `weekly_all` / `weekly_scoped`.
63    #[serde(default)]
64    pub kind: Option<String>,
65    /// Percent of the window consumed (0..100).
66    #[serde(default)]
67    pub percent: f64,
68    /// Severity, e.g. `normal` / `warning` / `reached`.
69    #[serde(default)]
70    pub severity: Option<String>,
71    /// Absolute reset time as an ISO-8601 string.
72    #[serde(default)]
73    pub resets_at: Option<String>,
74    /// Scope (present for `weekly_scoped`: the model this cap applies to).
75    #[serde(default)]
76    pub scope: Option<ClaudeScope>,
77    /// Whether this limit is the currently active/binding one.
78    #[serde(default, deserialize_with = "de_bool_or_false")]
79    pub is_active: bool,
80}
81
82/// The `scope` object of a `weekly_scoped` limit.
83#[derive(Debug, Clone, Deserialize)]
84pub struct ClaudeScope {
85    /// The model this scoped limit applies to.
86    #[serde(default)]
87    pub model: Option<ClaudeScopeModel>,
88}
89
90/// The `scope.model` object of a `weekly_scoped` limit.
91#[derive(Debug, Clone, Deserialize)]
92pub struct ClaudeScopeModel {
93    /// Human-readable model name, e.g. "Opus".
94    #[serde(default)]
95    pub display_name: Option<String>,
96}
97
98/// The `spend` object of a usage response (pay-as-you-go credit / spend).
99#[derive(Debug, Clone, Deserialize)]
100pub struct ClaudeSpend {
101    /// Amount spent this period.
102    #[serde(default)]
103    pub used: Option<ClaudeMoney>,
104    /// Remaining prepaid credit balance, when enabled.
105    #[serde(default)]
106    pub balance: Option<ClaudeMoney>,
107    /// Whether pay-as-you-go spend is enabled for this account.
108    #[serde(default)]
109    pub enabled: bool,
110}
111
112/// A money amount in minor units (e.g. cents) with an explicit exponent.
113#[derive(Debug, Clone, Deserialize)]
114pub struct ClaudeMoney {
115    /// Amount in minor units (e.g. cents when `exponent == 2`).
116    #[serde(default)]
117    pub amount_minor: i64,
118    /// ISO currency code, e.g. "USD".
119    #[serde(default)]
120    pub currency: Option<String>,
121    /// Power of ten separating minor units from major (2 = cents).
122    #[serde(default)]
123    pub exponent: i32,
124}
125
126impl ClaudeMoney {
127    /// Formats the amount as a currency string, e.g. `$0.00`.
128    pub fn as_display(&self) -> String {
129        let value = self.amount_minor as f64 / 10f64.powi(self.exponent.max(0));
130        match self.currency.as_deref() {
131            Some("USD") | None => format!("${value:.2}"),
132            Some(cur) => format!("{value:.2} {cur}"),
133        }
134    }
135}
136
137// ---- ~/.claude/.credentials.json ----
138
139/// `~/.claude/.credentials.json` (only the `claudeAiOauth` block; the sibling
140/// `designOauth` and any unknown keys are preserved on write-back).
141#[derive(Clone, Deserialize)]
142pub struct ClaudeCredentials {
143    /// The Claude subscription OAuth token bundle.
144    #[serde(rename = "claudeAiOauth", default)]
145    pub claude_ai_oauth: Option<ClaudeOauth>,
146}
147
148impl fmt::Debug for ClaudeCredentials {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        f.debug_struct("ClaudeCredentials")
151            .field("claude_ai_oauth", &self.claude_ai_oauth)
152            .finish()
153    }
154}
155
156/// The `claudeAiOauth` object of `~/.claude/.credentials.json`.
157#[derive(Clone, Deserialize)]
158pub struct ClaudeOauth {
159    /// Bearer access token for the OAuth usage API.
160    #[serde(rename = "accessToken", default)]
161    pub access_token: Option<String>,
162    /// Refresh token (rotates on refresh; must be persisted).
163    #[serde(rename = "refreshToken", default)]
164    pub refresh_token: Option<String>,
165    /// Access-token expiry, Unix **milliseconds**.
166    #[serde(rename = "expiresAt", default)]
167    pub expires_at: Option<i64>,
168    /// OAuth scopes, carried back into the refresh request.
169    #[serde(default)]
170    pub scopes: Vec<String>,
171    /// Rate-limit tier (e.g. "default_claude_max_20x"), preferred for the Plan
172    /// line because it distinguishes 5x / 20x where `subscription_type` does not.
173    #[serde(rename = "rateLimitTier", default)]
174    pub rate_limit_tier: Option<String>,
175    /// Subscription tier (e.g. "max" / "pro"); Plan-line fallback.
176    #[serde(rename = "subscriptionType", default)]
177    pub subscription_type: Option<String>,
178}
179
180impl fmt::Debug for ClaudeOauth {
181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182        f.debug_struct("ClaudeOauth")
183            .field("access_token", &redact(&self.access_token))
184            .field("refresh_token", &redact(&self.refresh_token))
185            .field("expires_at", &self.expires_at)
186            .field("scopes", &self.scopes)
187            .field("rate_limit_tier", &self.rate_limit_tier)
188            .field("subscription_type", &self.subscription_type)
189            .finish()
190    }
191}
192
193/// `platform.claude.com/v1/oauth/token` refresh response.
194#[derive(Clone, Deserialize)]
195pub struct ClaudeRefreshResponse {
196    /// New bearer access token.
197    #[serde(default)]
198    pub access_token: Option<String>,
199    /// New refresh token (rotates).
200    #[serde(default)]
201    pub refresh_token: Option<String>,
202    /// Lifetime of the new access token, in seconds.
203    #[serde(default)]
204    pub expires_in: Option<i64>,
205    /// Space-separated granted scopes.
206    #[serde(default)]
207    pub scope: Option<String>,
208}
209
210impl fmt::Debug for ClaudeRefreshResponse {
211    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212        f.debug_struct("ClaudeRefreshResponse")
213            .field("access_token", &redact(&self.access_token))
214            .field("refresh_token", &redact(&self.refresh_token))
215            .field("expires_in", &self.expires_in)
216            .field("scope", &self.scope)
217            .finish()
218    }
219}
220
221/// Normalized Claude quota snapshot (worker output + on-disk cache).
222#[derive(Debug, Clone, Default, Serialize, Deserialize)]
223pub struct ClaudeQuotaSnapshot {
224    /// Which source produced this snapshot.
225    #[serde(default)]
226    pub source: QuotaSource,
227    /// Unix seconds when this snapshot was produced.
228    pub fetched_at: i64,
229    /// Plan tier from the credentials file (e.g. "max 20x"), shown as Plan.
230    #[serde(default)]
231    pub plan_type: Option<String>,
232    /// 5-hour window.
233    pub five_hour: Option<QuotaWindow>,
234    /// Weekly window (all models).
235    pub seven_day: Option<QuotaWindow>,
236    /// Per-model weekly window (`weekly_scoped`), when present.
237    #[serde(default)]
238    pub scoped_weekly: Option<QuotaWindow>,
239    /// Model label for [`Self::scoped_weekly`], e.g. "Opus".
240    #[serde(default)]
241    pub scoped_label: Option<String>,
242    /// Prepaid credit balance, pre-formatted (e.g. `$5.00`), when enabled.
243    #[serde(default)]
244    pub balance: Option<String>,
245    /// Amount spent this period, pre-formatted (e.g. `$0.00`).
246    #[serde(default)]
247    pub spend_used: Option<String>,
248    /// Whether any window has hit its cap (drives the `LIMIT` flag).
249    #[serde(default)]
250    pub limit_reached: bool,
251    /// Credentials present but the token is unusable (expired / refresh
252    /// failed / 401); the panel shows a `claude auth login` hint.
253    #[serde(default)]
254    pub needs_login: bool,
255}
256
257// ---- Codex wham/usage API ----
258
259/// `https://chatgpt.com/backend-api/wham/usage` response (subset we read).
260#[derive(Debug, Clone, Deserialize)]
261pub struct WhamUsageResponse {
262    /// Plan tier, e.g. "plus".
263    #[serde(default)]
264    pub plan_type: Option<String>,
265    /// Rate-limit windows + status.
266    #[serde(default)]
267    pub rate_limit: Option<WhamRateLimit>,
268    /// Credit balance info.
269    #[serde(default)]
270    pub credits: Option<WhamCredits>,
271    /// Rate-limit reset credits.
272    #[serde(default)]
273    pub rate_limit_reset_credits: Option<WhamResetCredits>,
274    /// Per-account spend cap.
275    #[serde(default)]
276    pub spend_control: Option<WhamSpendControl>,
277}
278
279/// The `spend_control` object of a wham/usage response.
280#[derive(Debug, Clone, Deserialize)]
281pub struct WhamSpendControl {
282    /// Whether the spend cap has been reached.
283    #[serde(default)]
284    pub reached: Option<bool>,
285    /// The configured spend cap, when set.
286    #[serde(default)]
287    pub individual_limit: Option<f64>,
288}
289
290/// The `rate_limit` object of a wham/usage response.
291#[derive(Debug, Clone, Deserialize)]
292pub struct WhamRateLimit {
293    /// Whether a limit has been reached.
294    #[serde(default)]
295    pub limit_reached: Option<bool>,
296    /// 5-hour window.
297    #[serde(default)]
298    pub primary_window: Option<WhamWindow>,
299    /// Weekly window.
300    #[serde(default)]
301    pub secondary_window: Option<WhamWindow>,
302}
303
304/// One wham/usage rate-limit window.
305#[derive(Debug, Clone, Deserialize)]
306pub struct WhamWindow {
307    /// Percent of the window consumed (0..100).
308    #[serde(default)]
309    pub used_percent: Option<f64>,
310    /// Window length in seconds (18000 = 5h, 604800 = 7d).
311    #[serde(default)]
312    pub limit_window_seconds: Option<i64>,
313    /// Seconds until reset (relative).
314    #[serde(default)]
315    pub reset_after_seconds: Option<i64>,
316    /// Absolute reset time, Unix seconds.
317    #[serde(default)]
318    pub reset_at: Option<i64>,
319}
320
321/// The `credits` object of a wham/usage response.
322#[derive(Debug, Clone, Deserialize)]
323pub struct WhamCredits {
324    /// Whether the account has purchasable credits enabled.
325    #[serde(default)]
326    pub has_credits: Option<bool>,
327    /// Whether usage is unlimited.
328    #[serde(default)]
329    pub unlimited: Option<bool>,
330    /// Whether the overage limit has been reached.
331    #[serde(default)]
332    pub overage_limit_reached: Option<bool>,
333    /// Credit balance, kept as a string to match the API's `"0"`.
334    #[serde(default, deserialize_with = "de_string_or_number")]
335    pub balance: Option<String>,
336    /// Approximate `[low, high]` local (CLI) messages the credits still buy.
337    #[serde(default)]
338    pub approx_local_messages: Option<Vec<i64>>,
339    /// Approximate `[low, high]` cloud-task messages the credits still buy.
340    #[serde(default)]
341    pub approx_cloud_messages: Option<Vec<i64>>,
342}
343
344/// Deserializes a JSON string or number into `Option<String>`.
345///
346/// The wham/usage `balance` is usually the string `"0"`, but some accounts
347/// return it as a number; accepting both keeps a numeric balance from failing
348/// the entire response. Any other type (or null) becomes `None`.
349fn de_string_or_number<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
350where
351    D: serde::Deserializer<'de>,
352{
353    Ok(match Option::<Value>::deserialize(deserializer)? {
354        Some(Value::String(s)) => Some(s),
355        Some(Value::Number(n)) => Some(n.to_string()),
356        _ => None,
357    })
358}
359
360/// Deserializes a JSON number into `f64`, mapping null / wrong-type to 0.0.
361///
362/// The usage windows are volatile (a scoped tier can appear or vanish); a stray
363/// `null` percent must not fail the whole response, only read as 0.
364fn de_f64_or_zero<'de, D>(deserializer: D) -> Result<f64, D::Error>
365where
366    D: serde::Deserializer<'de>,
367{
368    Ok(match Option::<Value>::deserialize(deserializer)? {
369        Some(Value::Number(n)) => n.as_f64().unwrap_or(0.0),
370        _ => 0.0,
371    })
372}
373
374/// Deserializes a JSON bool, mapping null / wrong-type to false.
375fn de_bool_or_false<'de, D>(deserializer: D) -> Result<bool, D::Error>
376where
377    D: serde::Deserializer<'de>,
378{
379    Ok(matches!(
380        Option::<Value>::deserialize(deserializer)?,
381        Some(Value::Bool(true))
382    ))
383}
384
385/// Deserializes the `limits` array leniently: an element that fails to parse (a
386/// volatile / malformed limit entry) is skipped rather than failing the whole
387/// response, and a non-array value yields an empty list. This keeps a broken
388/// per-model scoped entry from taking down the 5h / 7d / balance rows.
389fn de_lenient_limits<'de, D>(deserializer: D) -> Result<Vec<ClaudeLimit>, D::Error>
390where
391    D: serde::Deserializer<'de>,
392{
393    Ok(match Value::deserialize(deserializer)? {
394        Value::Array(arr) => arr
395            .into_iter()
396            .filter_map(|e| serde_json::from_value(e).ok())
397            .collect(),
398        _ => Vec::new(),
399    })
400}
401
402/// The reset-credit summary embedded in a wham/usage response.
403#[derive(Debug, Clone, Deserialize)]
404pub struct WhamResetCredits {
405    /// Number of rate-limit reset credits available.
406    #[serde(default)]
407    pub available_count: Option<i64>,
408}
409
410/// The response from the reset-credit details endpoint.
411#[derive(Debug, Clone, Deserialize)]
412pub struct WhamResetCreditsDetails {
413    /// Per-credit details. The backend may cap this list.
414    pub credits: Vec<WhamResetCreditDetails>,
415    /// Authoritative number of available reset credits.
416    pub available_count: i64,
417}
418
419/// One earned rate-limit reset credit.
420#[derive(Debug, Clone, Deserialize)]
421pub struct WhamResetCreditDetails {
422    /// Stable backend identifier.
423    pub id: String,
424    /// Limit family reset by this credit.
425    pub reset_type: String,
426    /// Lifecycle state, e.g. `available` / `redeeming` / `redeemed`.
427    pub status: String,
428    /// RFC3339 grant time.
429    pub granted_at: String,
430    /// RFC3339 expiry time, or `None` when the credit does not expire.
431    pub expires_at: Option<String>,
432}
433
434// ---- ~/.codex/auth.json ----
435
436/// `~/.codex/auth.json` (token fields only; deserialize-only, never logged).
437#[derive(Debug, Clone, Deserialize)]
438pub struct CodexAuthJson {
439    /// OAuth token bundle.
440    #[serde(default)]
441    pub tokens: Option<CodexAuthTokens>,
442}
443
444/// The `tokens` object of `~/.codex/auth.json`.
445///
446/// `Debug` is implemented by hand to redact the secrets: the tokens are bearer
447/// credentials and the account id is an identifier, so none should reach a log
448/// or assertion message. The wham client relies on this guarantee.
449#[derive(Clone, Deserialize)]
450pub struct CodexAuthTokens {
451    /// OIDC id token (JWT); refreshed alongside the access token.
452    #[serde(default)]
453    pub id_token: Option<String>,
454    /// Bearer access token for the ChatGPT backend.
455    #[serde(default)]
456    pub access_token: Option<String>,
457    /// Refresh token (rotates on refresh; must be persisted).
458    #[serde(default)]
459    pub refresh_token: Option<String>,
460    /// Account id sent as the `ChatGPT-Account-Id` header.
461    #[serde(default)]
462    pub account_id: Option<String>,
463}
464
465impl fmt::Debug for CodexAuthTokens {
466    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
467        f.debug_struct("CodexAuthTokens")
468            .field("id_token", &redact(&self.id_token))
469            .field("access_token", &redact(&self.access_token))
470            .field("refresh_token", &redact(&self.refresh_token))
471            .field("account_id", &redact(&self.account_id))
472            .finish()
473    }
474}
475
476/// `https://auth.openai.com/oauth/token` refresh response.
477#[derive(Clone, Deserialize)]
478pub struct CodexRefreshResponse {
479    /// New OIDC id token.
480    #[serde(default)]
481    pub id_token: Option<String>,
482    /// New bearer access token.
483    #[serde(default)]
484    pub access_token: Option<String>,
485    /// New refresh token (rotates).
486    #[serde(default)]
487    pub refresh_token: Option<String>,
488}
489
490impl fmt::Debug for CodexRefreshResponse {
491    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
492        f.debug_struct("CodexRefreshResponse")
493            .field("id_token", &redact(&self.id_token))
494            .field("access_token", &redact(&self.access_token))
495            .field("refresh_token", &redact(&self.refresh_token))
496            .finish()
497    }
498}
499
500// ---- Codex session-log fallback ----
501
502/// The `rate_limits` object embedded in Codex `token_count` events.
503#[derive(Debug, Clone, Deserialize)]
504pub struct CodexSessionRateLimits {
505    /// Limit family this snapshot describes; only the main `codex` account
506    /// quota maps to the 5h/7d panel, so other families are skipped.
507    #[serde(default)]
508    pub limit_id: Option<String>,
509    /// Plan tier (e.g. "plus"), alongside the windows.
510    #[serde(default)]
511    pub plan_type: Option<String>,
512    /// 5-hour window.
513    #[serde(default)]
514    pub primary: Option<CodexSessionWindow>,
515    /// Weekly window.
516    #[serde(default)]
517    pub secondary: Option<CodexSessionWindow>,
518}
519
520/// One Codex session rate-limit window.
521#[derive(Debug, Clone, Deserialize)]
522pub struct CodexSessionWindow {
523    /// Percent of the window consumed (0..100).
524    #[serde(default)]
525    pub used_percent: Option<f64>,
526    /// Window length in minutes (300 = 5h, 10080 = 7d).
527    #[serde(default)]
528    pub window_minutes: Option<i64>,
529    /// Absolute reset time, Unix seconds.
530    #[serde(default)]
531    pub resets_at: Option<i64>,
532}
533
534// ---- Normalized output (render target + on-disk cache) ----
535
536/// Which source produced a quota snapshot.
537#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
538#[serde(rename_all = "snake_case")]
539pub enum QuotaSource {
540    /// No data available.
541    #[default]
542    None,
543    /// Live API (`wham/usage` or Claude usage).
544    Api,
545    /// Newest Codex session-log `rate_limits`.
546    SessionFallback,
547}
548
549/// One normalized rate-limit window, shared by every provider's rendering.
550#[derive(Debug, Clone, Default, Serialize, Deserialize)]
551pub struct QuotaWindow {
552    /// Percent of the window consumed (0..100).
553    pub used_percent: f64,
554    /// Absolute reset time in Unix seconds, when known.
555    pub resets_at_unix: Option<i64>,
556}
557
558/// Normalized Codex quota snapshot, shared via `Arc<Mutex>` and persisted to
559/// `~/.vct/codex_usage.json`.
560#[derive(Debug, Clone, Default, Serialize, Deserialize)]
561pub struct CodexQuotaSnapshot {
562    /// Which source produced this snapshot.
563    pub source: QuotaSource,
564    /// Unix seconds when this snapshot was produced.
565    pub fetched_at: i64,
566    /// Plan tier, e.g. "plus".
567    pub plan_type: Option<String>,
568    /// 5-hour window.
569    pub primary: Option<QuotaWindow>,
570    /// Weekly window.
571    pub secondary: Option<QuotaWindow>,
572    /// Credit balance (string, matching the API's `"0"`).
573    pub credits_balance: Option<String>,
574    /// Whether the account has purchasable credits enabled.
575    pub has_credits: Option<bool>,
576    /// Whether usage is unlimited.
577    pub unlimited: Option<bool>,
578    /// Number of rate-limit reset credits available.
579    pub reset_credits_available: Option<i64>,
580    /// Expiry times for fetched `available` reset-credit details. The outer
581    /// `None` means details were unavailable; an inner `None` never expires.
582    /// The backend may cap this list, so its length is not the total count.
583    #[serde(default)]
584    pub reset_credit_expirations: Option<Vec<Option<i64>>>,
585    /// Approximate `[low, high]` messages the remaining credits still buy.
586    #[serde(default)]
587    pub approx_messages: Option<(i64, i64)>,
588    /// Configured spend cap, when set.
589    #[serde(default)]
590    pub spend_limit: Option<f64>,
591    /// Whether a rate limit (or credit / spend cap) has been reached.
592    pub limit_reached: Option<bool>,
593    /// Token present but unusable (refresh failed / 401); the panel shows a
594    /// `codex auth login` hint alongside any session-fallback data.
595    #[serde(default)]
596    pub needs_login: bool,
597}
598
599// ---- GitHub Copilot usage API (GET /copilot_internal/user) ----
600
601/// `https://api.github.com/copilot_internal/user` response (subset we read).
602///
603/// Field names match the API's snake_case shape directly.
604#[derive(Debug, Clone, Deserialize)]
605pub struct CopilotUserResponse {
606    /// Plan tier, e.g. "individual" / "business".
607    #[serde(default)]
608    pub copilot_plan: Option<String>,
609    /// Quota reset instant (ISO-8601), preferred over the date-only field.
610    #[serde(default)]
611    pub quota_reset_date_utc: Option<String>,
612    /// Quota reset date (`YYYY-MM-DD`), fallback when the UTC instant is absent.
613    #[serde(default)]
614    pub quota_reset_date: Option<String>,
615    /// Per-quota snapshots (premium interactions / chat / completions).
616    #[serde(default)]
617    pub quota_snapshots: Option<CopilotQuotaSnapshots>,
618}
619
620/// The `quota_snapshots` object of a Copilot user response.
621#[derive(Debug, Clone, Deserialize)]
622pub struct CopilotQuotaSnapshots {
623    /// Premium (model) request quota — the headline gauge.
624    #[serde(default)]
625    pub premium_interactions: Option<CopilotQuotaEntry>,
626}
627
628/// One Copilot quota snapshot entry.
629#[derive(Debug, Clone, Deserialize)]
630pub struct CopilotQuotaEntry {
631    /// Percent of the quota still available (0..100).
632    #[serde(default)]
633    pub percent_remaining: Option<f64>,
634    /// Absolute remaining request count.
635    #[serde(default)]
636    pub remaining: Option<f64>,
637    /// Total request entitlement for the period.
638    #[serde(default)]
639    pub entitlement: Option<f64>,
640    /// Whether this quota is unlimited.
641    #[serde(default)]
642    pub unlimited: Option<bool>,
643}
644
645// ---- Cursor usage API (GET /api/usage-summary) ----
646
647/// `https://cursor.com/api/usage-summary` response (subset we read).
648#[derive(Debug, Clone, Deserialize)]
649#[serde(rename_all = "camelCase")]
650pub struct CursorUsageSummary {
651    /// Plan tier, e.g. "free" / "pro" / "enterprise".
652    #[serde(default)]
653    pub membership_type: Option<String>,
654    /// Whether usage is unlimited.
655    #[serde(default)]
656    pub is_unlimited: Option<bool>,
657    /// Billing cycle end (ISO-8601), used as the reset time for every gauge.
658    #[serde(default)]
659    pub billing_cycle_end: Option<String>,
660    /// Per-user usage breakdown.
661    #[serde(default)]
662    pub individual_usage: Option<CursorIndividualUsage>,
663    /// Team / enterprise usage breakdown (on-demand may live here instead).
664    #[serde(default)]
665    pub team_usage: Option<CursorTeamUsage>,
666}
667
668/// The `teamUsage` object of a Cursor usage summary.
669#[derive(Debug, Clone, Deserialize)]
670#[serde(rename_all = "camelCase")]
671pub struct CursorTeamUsage {
672    /// Shared team on-demand (overage) spend.
673    #[serde(default)]
674    pub on_demand: Option<CursorOnDemand>,
675}
676
677/// The `individualUsage` object of a Cursor usage summary.
678#[derive(Debug, Clone, Deserialize)]
679#[serde(rename_all = "camelCase")]
680pub struct CursorIndividualUsage {
681    /// Included-plan usage percentages.
682    #[serde(default)]
683    pub plan: Option<CursorPlanUsage>,
684    /// On-demand (overage) spend.
685    #[serde(default)]
686    pub on_demand: Option<CursorOnDemand>,
687}
688
689/// The `individualUsage.plan` object (percentages are already in percent units).
690#[derive(Debug, Clone, Deserialize)]
691#[serde(rename_all = "camelCase")]
692pub struct CursorPlanUsage {
693    /// Auto / Composer usage percent.
694    #[serde(default)]
695    pub auto_percent_used: Option<f64>,
696    /// Named-model (API) usage percent.
697    #[serde(default)]
698    pub api_percent_used: Option<f64>,
699    /// Headline total usage percent.
700    #[serde(default)]
701    pub total_percent_used: Option<f64>,
702}
703
704/// The `individualUsage.onDemand` object.
705#[derive(Debug, Clone, Deserialize)]
706#[serde(rename_all = "camelCase")]
707pub struct CursorOnDemand {
708    /// Whether on-demand spend is enabled.
709    #[serde(default)]
710    pub enabled: Option<bool>,
711    /// Amount spent this period, in cents.
712    #[serde(default)]
713    pub used: Option<f64>,
714}
715
716// ---- Grok billing API (GET /v1/billing?format=credits) ----
717
718/// `https://cli-chat-proxy.grok.com/v1/billing?format=credits` response.
719///
720/// The credits config arrives wrapped in a `config` object. The CLI fills
721/// `subscription_tier` / `on_demand_enabled` into the same object from
722/// `/v1/settings`, so a bare fetch returns only `config`.
723#[derive(Debug, Clone, Deserialize)]
724pub struct GrokBillingResponse {
725    /// The credit configuration, absent on an error body.
726    #[serde(default)]
727    pub config: Option<GrokCreditsConfig>,
728}
729
730/// The `config` object of a Grok credits response (subset we read).
731///
732/// This is a proto3 `GetGrokCreditsConfig` rendered as JSON, so every
733/// zero-valued scalar may be omitted — including the headline
734/// `creditUsagePercent`. Every field is therefore optional and an absent one
735/// reads as zero, never as an error. The wire response is a superset of the
736/// fields below (a live account also returns `topUpMethod`), so unknown keys are
737/// ignored rather than rejected.
738#[derive(Debug, Clone, Deserialize)]
739#[serde(rename_all = "camelCase")]
740pub struct GrokCreditsConfig {
741    /// Included-allowance usage (0..100) — what the CLI status bar shows.
742    #[serde(default, deserialize_with = "de_f64_or_zero")]
743    pub credit_usage_percent: f64,
744    /// The current billing period (weekly or monthly) with its RFC3339 bounds.
745    #[serde(default)]
746    pub current_period: Option<GrokUsagePeriod>,
747    /// Pay-as-you-go cap for this period.
748    #[serde(default)]
749    pub on_demand_cap: Option<GrokMoney>,
750    /// Pay-as-you-go spend this period.
751    #[serde(default)]
752    pub on_demand_used: Option<GrokMoney>,
753    /// Remaining purchased ("bought") credit balance.
754    #[serde(default)]
755    pub prepaid_balance: Option<GrokMoney>,
756    /// Deprecated legacy period end, still emitted by older servers; the
757    /// fallback reset time when `current_period` is absent.
758    #[serde(default)]
759    pub billing_period_end: Option<String>,
760}
761
762/// The `currentPeriod` object of a Grok credits config.
763#[derive(Debug, Clone, Deserialize)]
764pub struct GrokUsagePeriod {
765    /// Period kind, e.g. `USAGE_PERIOD_TYPE_WEEKLY` / `..._MONTHLY`.
766    #[serde(default, rename = "type")]
767    pub period_type: Option<String>,
768    /// RFC3339 period end — when the included allowance resets.
769    #[serde(default)]
770    pub end: Option<String>,
771}
772
773/// A Grok money amount in **cents**, e.g. `{"val": 1840}` for $18.40.
774///
775/// A zero amount can arrive as `{}` (proto3 omits zero scalars) or as an
776/// explicit `{"val": 0}`; both read as zero.
777#[derive(Debug, Clone, Deserialize)]
778pub struct GrokMoney {
779    /// Amount in cents.
780    #[serde(default, deserialize_with = "de_f64_or_zero")]
781    pub val: f64,
782}
783
784impl GrokMoney {
785    /// The amount in whole currency units (dollars).
786    pub fn as_dollars(&self) -> f64 {
787        self.val / 100.0
788    }
789}
790
791/// `https://cli-chat-proxy.grok.com/v1/settings` response (the plan label only).
792///
793/// The real body carries well over a hundred feature flags; only the display
794/// tier is read, so every other key is ignored.
795#[derive(Debug, Clone, Deserialize)]
796pub struct GrokSettingsResponse {
797    /// Human-readable plan tier, e.g. "Free" / "SuperGrok".
798    #[serde(default)]
799    pub subscription_tier_display: Option<String>,
800}
801
802// ---- ~/.grok/auth.json ----
803
804/// One entry of `~/.grok/auth.json`, keyed in the file by login scope
805/// (`"<issuer>::<client_id>"`, `"xai::api_key"`, or the legacy sign-in URL).
806///
807/// The access token is the **`key`** field, not a field named `access_token`.
808/// `Debug` is written by hand so no bearer, refresh token, or account
809/// identifier can reach a log or assertion message.
810#[derive(Clone, Default, Deserialize)]
811pub struct GrokAuthEntry {
812    /// Bearer access token (a JWT).
813    #[serde(default)]
814    pub key: Option<String>,
815    /// Refresh token (rotates on refresh; must be persisted).
816    #[serde(default)]
817    pub refresh_token: Option<String>,
818    /// Access-token expiry as an RFC3339 timestamp.
819    #[serde(default)]
820    pub expires_at: Option<String>,
821    /// OIDC issuer this login came from; the base for token-endpoint discovery.
822    #[serde(default)]
823    pub oidc_issuer: Option<String>,
824    /// OAuth client id, sent as a form field on refresh.
825    #[serde(default)]
826    pub oidc_client_id: Option<String>,
827    /// Account id, sent as the `x-userid` header.
828    #[serde(default)]
829    pub user_id: Option<String>,
830    /// Principal kind for a team login, re-sent on refresh.
831    #[serde(default)]
832    pub principal_type: Option<String>,
833    /// Principal id for a team login, re-sent on refresh.
834    #[serde(default)]
835    pub principal_id: Option<String>,
836}
837
838impl fmt::Debug for GrokAuthEntry {
839    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
840        f.debug_struct("GrokAuthEntry")
841            .field("key", &redact(&self.key))
842            .field("refresh_token", &redact(&self.refresh_token))
843            .field("expires_at", &self.expires_at)
844            .field("oidc_issuer", &self.oidc_issuer)
845            .field("oidc_client_id", &self.oidc_client_id)
846            .field("user_id", &redact(&self.user_id))
847            .field("principal_type", &self.principal_type)
848            .field("principal_id", &redact(&self.principal_id))
849            .finish()
850    }
851}
852
853/// An xAI OIDC token-endpoint refresh response.
854#[derive(Clone, Deserialize)]
855pub struct GrokRefreshResponse {
856    /// New bearer access token (written back into the entry's `key`).
857    #[serde(default)]
858    pub access_token: Option<String>,
859    /// New refresh token (rotates).
860    #[serde(default)]
861    pub refresh_token: Option<String>,
862    /// Lifetime of the new access token, in seconds.
863    #[serde(default)]
864    pub expires_in: Option<i64>,
865}
866
867impl fmt::Debug for GrokRefreshResponse {
868    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
869        f.debug_struct("GrokRefreshResponse")
870            .field("access_token", &redact(&self.access_token))
871            .field("refresh_token", &redact(&self.refresh_token))
872            .field("expires_in", &self.expires_in)
873            .finish()
874    }
875}
876
877// ---- Normalized Copilot / Cursor / Grok snapshots (worker output + cache) ----
878
879/// Normalized Copilot quota snapshot, shared via `Arc<Mutex>` and persisted to
880/// `~/.vct/copilot_usage.json`.
881#[derive(Debug, Clone, Default, Serialize, Deserialize)]
882pub struct CopilotQuotaSnapshot {
883    /// Which source produced this snapshot.
884    #[serde(default)]
885    pub source: QuotaSource,
886    /// Unix seconds when this snapshot was produced.
887    pub fetched_at: i64,
888    /// Plan tier (e.g. "individual"), shown as Plan.
889    #[serde(default)]
890    pub plan_type: Option<String>,
891    /// Premium-interactions window (the headline gauge).
892    #[serde(default)]
893    pub premium: Option<QuotaWindow>,
894    /// Remaining premium requests.
895    #[serde(default)]
896    pub premium_remaining: Option<i64>,
897    /// Total premium request entitlement.
898    #[serde(default)]
899    pub premium_entitlement: Option<i64>,
900    /// Whether premium interactions are unlimited.
901    #[serde(default)]
902    pub premium_unlimited: bool,
903    /// Whether the premium quota has been exhausted (drives the `LIMIT` flag).
904    #[serde(default)]
905    pub limit_reached: bool,
906    /// Credentials present but the token is unusable (401/403); the panel shows
907    /// a `copilot login` hint.
908    #[serde(default)]
909    pub needs_login: bool,
910}
911
912/// Normalized Cursor quota snapshot, shared via `Arc<Mutex>` and persisted to
913/// `~/.vct/cursor_usage.json`.
914#[derive(Debug, Clone, Default, Serialize, Deserialize)]
915pub struct CursorQuotaSnapshot {
916    /// Which source produced this snapshot.
917    #[serde(default)]
918    pub source: QuotaSource,
919    /// Unix seconds when this snapshot was produced.
920    pub fetched_at: i64,
921    /// Plan tier (e.g. "free" / "pro"), shown as Plan.
922    #[serde(default)]
923    pub plan_type: Option<String>,
924    /// Headline total-usage window.
925    #[serde(default)]
926    pub total: Option<QuotaWindow>,
927    /// Auto / Composer usage window.
928    #[serde(default)]
929    pub auto: Option<QuotaWindow>,
930    /// Named-model (API) usage window.
931    #[serde(default)]
932    pub api: Option<QuotaWindow>,
933    /// On-demand spend this period, in USD, when enabled.
934    #[serde(default)]
935    pub on_demand_dollars: Option<f64>,
936    /// Whether the plan usage has hit 100% (drives the `LIMIT` flag).
937    #[serde(default)]
938    pub limit_reached: bool,
939    /// Credentials present but the token is unusable (expired / 401); the panel
940    /// shows a `cursor login` hint.
941    #[serde(default)]
942    pub needs_login: bool,
943}
944
945/// Normalized Grok quota snapshot, shared via `Arc<Mutex>` and persisted to
946/// `~/.vct/grok_usage.json`.
947#[derive(Debug, Clone, Default, Serialize, Deserialize)]
948pub struct GrokQuotaSnapshot {
949    /// Which source produced this snapshot.
950    #[serde(default)]
951    pub source: QuotaSource,
952    /// Unix seconds when this snapshot was produced.
953    pub fetched_at: i64,
954    /// Plan tier (e.g. "Free"), from `/v1/settings`, shown as Plan.
955    #[serde(default)]
956    pub plan_type: Option<String>,
957    /// Included-allowance usage for the current period.
958    #[serde(default)]
959    pub included: Option<QuotaWindow>,
960    /// Short label for the included window's period, e.g. "week" / "month".
961    #[serde(default)]
962    pub period_label: Option<String>,
963    /// Pay-as-you-go spend this period, in USD.
964    #[serde(default)]
965    pub on_demand_dollars: Option<f64>,
966    /// Pay-as-you-go cap this period, in USD, when one is set.
967    #[serde(default)]
968    pub on_demand_cap_dollars: Option<f64>,
969    /// Remaining prepaid credit balance, in USD.
970    #[serde(default)]
971    pub prepaid_balance_dollars: Option<f64>,
972    /// Whether the included allowance is exhausted (drives the `LIMIT` flag).
973    #[serde(default)]
974    pub limit_reached: bool,
975    /// Credentials present but the token is unusable (expired / refresh failed /
976    /// 401); the panel shows a `grok login` hint.
977    #[serde(default)]
978    pub needs_login: bool,
979}
980
981#[cfg(test)]
982mod tests {
983    use super::*;
984
985    #[test]
986    fn auth_tokens_debug_redacts_secrets() {
987        let tokens = CodexAuthTokens {
988            id_token: Some("jwt-header.payload.sig".into()),
989            access_token: Some("sk-super-secret-value".into()),
990            refresh_token: Some("rt-super-secret".into()),
991            account_id: Some("acct-1234567890".into()),
992        };
993        let direct = format!("{tokens:?}");
994        assert!(!direct.contains("sk-super-secret-value"));
995        assert!(!direct.contains("rt-super-secret"));
996        assert!(!direct.contains("acct-1234567890"));
997        assert!(direct.contains("<redacted>"));
998
999        // The wrapper's derived Debug must inherit the redaction.
1000        let wrapped = format!(
1001            "{:?}",
1002            CodexAuthJson {
1003                tokens: Some(tokens)
1004            }
1005        );
1006        assert!(!wrapped.contains("sk-super-secret-value"));
1007        assert!(!wrapped.contains("acct-1234567890"));
1008    }
1009
1010    #[test]
1011    fn claude_oauth_debug_redacts_secrets() {
1012        let oauth = ClaudeOauth {
1013            access_token: Some("claude-access-secret".into()),
1014            refresh_token: Some("claude-refresh-secret".into()),
1015            expires_at: Some(1783108188604),
1016            scopes: vec!["user:inference".into()],
1017            rate_limit_tier: Some("default_claude_max_20x".into()),
1018            subscription_type: Some("max".into()),
1019        };
1020        let s = format!("{oauth:?}");
1021        assert!(!s.contains("claude-access-secret"));
1022        assert!(!s.contains("claude-refresh-secret"));
1023        assert!(s.contains("<redacted>"));
1024        // Non-secret fields are still visible.
1025        assert!(s.contains("1783108188604"));
1026        assert!(s.contains("user:inference"));
1027    }
1028
1029    #[test]
1030    fn refresh_responses_debug_redact_secrets() {
1031        let c = ClaudeRefreshResponse {
1032            access_token: Some("new-access".into()),
1033            refresh_token: Some("new-refresh".into()),
1034            expires_in: Some(28800),
1035            scope: Some("user:inference".into()),
1036        };
1037        let cs = format!("{c:?}");
1038        assert!(!cs.contains("new-access"));
1039        assert!(!cs.contains("new-refresh"));
1040        assert!(cs.contains("28800"));
1041
1042        let x = CodexRefreshResponse {
1043            id_token: Some("id-secret".into()),
1044            access_token: Some("acc-secret".into()),
1045            refresh_token: Some("ref-secret".into()),
1046        };
1047        let xs = format!("{x:?}");
1048        assert!(!xs.contains("id-secret"));
1049        assert!(!xs.contains("acc-secret"));
1050        assert!(!xs.contains("ref-secret"));
1051    }
1052}