Skip to main content

agent_core/core/auth/
usage.rs

1//! Read-only, normalized quota/usage snapshots for the four OAuth providers
2//! the multi-account broker manages: Claude (Anthropic), ChatGPT/Codex, Kimi
3//! Code and Grok (xAI).
4//!
5//! Design rules (goal plan G3):
6//!
7//! - **Read-only.** Every adapter is a single pinned `GET`. Nothing here can
8//!   redeem a banked reset, purchase credits, or generate inference.
9//! - **Caller supplies the resolved token.** [`fetch_usage`] takes the provider
10//!   and the already-resolved access token for exactly that `(provider,
11//!   account)` so the credential broker invokes it *behind* its boundary. This
12//!   module never reads `auth.json`, never refreshes, never sees a refresh token.
13//! - **Pinned destinations.** URLs are constants. The only override is a
14//!   loopback-only test seam ([`UsageFetchOptions::endpoint_override`]) that is
15//!   rejected for any non-loopback host, so a bearer token can never be coaxed
16//!   toward an attacker-chosen destination.
17//! - **No redirects, bounded time and body.** [`UsageClient`] is built with a
18//!   `redirect::Policy::none()`; 3xx is an error. Bodies are streamed into a
19//!   capped buffer.
20//! - **Sanitized errors.** [`UsageError`] never contains the token, the URL
21//!   query, or a byte of an upstream body. Parse notes use fixed vocabulary —
22//!   never raw upstream key names or values.
23//! - **Unknown is not capacity.** `used_percent` is a tagged enum
24//!   ([`UsedPercent`]); missing, null, non-numeric and out-of-range values are
25//!   explicitly `Unknown { reason }`. Reset timestamps are provider-authoritative
26//!   (RFC 3339 or unix seconds → epoch ms) or `None` — never inferred from
27//!   `now + window`.
28//! - **Dynamic windows.** Codex `primary_window` can be weekly and
29//!   `secondary_window` can be `null`; durations come from
30//!   `limit_window_seconds`, never from a hard-coded "primary = 5h".
31//!
32//! Schema provenance (no network was used):
33//! - Codex field set verified against the string table of the locally installed
34//!   official `codex` binary (`RateLimitWindowSnapshot`, `RateLimitStatusPayload`,
35//!   `RateLimitResetCreditsSummary/Details`, `/wham/usage`,
36//!   `/wham/rate-limit-reset-credits`).
37//! - Kimi field set verified against the official `kimi` CLI bundle
38//!   (`managed-usage.ts`: `usage`, `limits[].window/detail`, `boosterWallet`).
39//! - Anthropic matches the previously shipped `synaps status` reader.
40//! - Grok `/v1/billing?format=credits` follows the QuotaKit-documented shape
41//!   (`config.creditUsagePercent`, `onDemandUsed.val / onDemandCap.val`,
42//!   `config.currentPeriod.end` / `config.billingPeriodEnd`,
43//!   `billingPeriodMinutes`). **Unverified against a live account**; anything
44//!   else yields an explicitly unknown window.
45
46use std::time::Duration;
47
48use serde::{Deserialize, Serialize};
49use serde_json::Value;
50
51use super::broker::BrokerError;
52use super::provider::OAuthProviderId;
53
54// ── Pinned endpoints and bounds ─────────────────────────────────────────────
55
56/// Snapshot JSON schema version (bump on incompatible field changes).
57pub const SCHEMA_VERSION: u32 = 1;
58
59/// Anthropic OAuth usage summary (same endpoint the broker's typed op uses).
60pub const ANTHROPIC_USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
61/// ChatGPT/Codex rate-limit usage (`GET`, bearer + `chatgpt-account-id`).
62pub const CODEX_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
63/// ChatGPT/Codex banked-reset inventory (`GET` only — the sibling `/consume`
64/// endpoint redeems a reset and is deliberately NOT reachable from here).
65pub const CODEX_RESET_CREDITS_URL: &str =
66    "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
67/// Kimi Code managed usage.
68pub const KIMI_USAGE_URL: &str = "https://api.kimi.com/coding/v1/usages";
69/// Grok Build billing (credits view).
70pub const GROK_BILLING_URL: &str = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
71
72/// Total time budget for one usage request.
73pub const USAGE_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
74/// TCP/TLS connect budget.
75pub const USAGE_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
76/// Hard cap on a buffered usage body. Real payloads are a few KiB.
77pub const USAGE_MAX_BODY_BYTES: usize = 256 * 1024;
78
79const ANTHROPIC_OAUTH_BETA: &str = "oauth-2025-04-20";
80const CODEX_ACCOUNT_HEADER: &str = "chatgpt-account-id";
81const GROK_TOKEN_AUTH_HEADER: &str = "x-xai-token-auth";
82const GROK_TOKEN_AUTH_VALUE: &str = "xai-grok-cli";
83const USER_AGENT: &str = concat!("SynapsCLI/", env!("CARGO_PKG_VERSION"));
84
85const SECS_PER_MINUTE: u64 = 60;
86const SECS_PER_HOUR: u64 = 3_600;
87const SECS_PER_DAY: u64 = 86_400;
88const SECS_PER_WEEK: u64 = 604_800;
89
90/// Whether a usage adapter exists for the provider. Copilot and Gemini expose
91/// no quota endpoint we can read, so they are honestly unsupported.
92pub fn supports_usage(provider: OAuthProviderId) -> bool {
93    matches!(
94        provider,
95        OAuthProviderId::Anthropic
96            | OAuthProviderId::OpenAiCodex
97            | OAuthProviderId::KimiCode
98            | OAuthProviderId::Xai
99    )
100}
101
102/// Providers with a usage adapter, in display order.
103pub fn supported_usage_providers() -> &'static [OAuthProviderId] {
104    &[
105        OAuthProviderId::Anthropic,
106        OAuthProviderId::OpenAiCodex,
107        OAuthProviderId::KimiCode,
108        OAuthProviderId::Xai,
109    ]
110}
111
112// ── Errors ──────────────────────────────────────────────────────────────────
113
114/// Usage failures. `Display`/`Serialize` never include a token, a URL query,
115/// or any upstream body bytes.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(tag = "kind", rename_all = "snake_case")]
118pub enum UsageError {
119    /// No adapter for this provider.
120    UnsupportedProvider { provider: String },
121    /// Codex: the access token carries no `chatgpt_account_id` claim, so the
122    /// account header cannot be paired with the bearer. Fail closed.
123    AccountIdUnavailable,
124    /// Codex: the response names a different account than the token. The
125    /// pairing invariant is violated; the snapshot is discarded.
126    IdentityMismatch,
127    /// A non-pinned, non-loopback endpoint override was supplied.
128    InvalidEndpointOverride,
129    /// Provider rejected the credential (401/403).
130    Unauthorized { status: u16 },
131    /// 429 on the usage endpoint itself. Not proof of quota exhaustion.
132    /// `retry_after_s` is the delta-seconds `Retry-After` header, if sent.
133    RateLimited {
134        #[serde(default, skip_serializing_if = "Option::is_none")]
135        retry_after_s: Option<u64>,
136    },
137    /// Any other non-2xx status. `retry_after_s`: the delta-seconds
138    /// `Retry-After` header, if sent (an HTTP-date is ignored).
139    UpstreamStatus {
140        status: u16,
141        #[serde(default, skip_serializing_if = "Option::is_none")]
142        retry_after_s: Option<u64>,
143    },
144    /// 3xx. Redirects are never followed.
145    Redirected { status: u16 },
146    /// No answer within the timeout: no status, no `Retry-After`.
147    Timeout,
148    /// Transport failure; `detail` is a coarse class, never a URL or body.
149    Transport { detail: String },
150    BodyTooLarge { cap: usize },
151    /// Body could not be parsed; `detail` names the shape, never values.
152    Malformed { detail: String },
153}
154
155impl UsageError {
156    /// Stable machine-readable kind (same as the serde tag).
157    pub fn kind(&self) -> &'static str {
158        match self {
159            Self::UnsupportedProvider { .. } => "unsupported_provider",
160            Self::AccountIdUnavailable => "account_id_unavailable",
161            Self::IdentityMismatch => "identity_mismatch",
162            Self::InvalidEndpointOverride => "invalid_endpoint_override",
163            Self::Unauthorized { .. } => "unauthorized",
164            Self::RateLimited { .. } => "rate_limited",
165            Self::UpstreamStatus { .. } => "upstream_status",
166            Self::Redirected { .. } => "redirected",
167            Self::Timeout => "timeout",
168            Self::Transport { .. } => "transport",
169            Self::BodyTooLarge { .. } => "body_too_large",
170            Self::Malformed { .. } => "malformed",
171        }
172    }
173
174    /// True when the failure means the credential itself was rejected.
175    pub fn is_auth_failure(&self) -> bool {
176        matches!(
177            self,
178            Self::Unauthorized { .. } | Self::AccountIdUnavailable | Self::IdentityMismatch
179        )
180    }
181
182    /// The typed observation failure this error stands for (kind, HTTP
183    /// status, `Retry-After`), for selection reports and logs.
184    pub fn failure(&self) -> super::quota_policy::ObservationFailure {
185        use super::quota_policy::{AccountFailureKind as K, ObservationFailure};
186        match self {
187            Self::UnsupportedProvider { .. } | Self::InvalidEndpointOverride => {
188                ObservationFailure::new(K::Unsupported)
189            }
190            Self::AccountIdUnavailable | Self::IdentityMismatch => {
191                ObservationFailure::new(K::UsageUnauthorized)
192            }
193            Self::Unauthorized { status } => {
194                ObservationFailure::new(K::UsageUnauthorized).with_status(*status)
195            }
196            Self::RateLimited { retry_after_s } => ObservationFailure::new(K::UsageRateLimited)
197                .with_status(429)
198                .with_retry_after(*retry_after_s),
199            Self::UpstreamStatus {
200                status,
201                retry_after_s,
202            } => ObservationFailure::new(K::UsageUpstream)
203                .with_status(*status)
204                .with_retry_after(*retry_after_s),
205            Self::Redirected { status } => {
206                ObservationFailure::new(K::UsageUpstream).with_status(*status)
207            }
208            // No answer arrived, so there is no status or Retry-After.
209            Self::Timeout => ObservationFailure::new(K::UsageTimeout),
210            Self::Transport { .. } => ObservationFailure::new(K::UsageTransport),
211            Self::BodyTooLarge { .. } | Self::Malformed { .. } => {
212                ObservationFailure::new(K::UsageMalformed)
213            }
214        }
215    }
216
217    /// Map onto the broker error vocabulary (secret-free by construction).
218    pub fn into_broker_error(self) -> BrokerError {
219        match self {
220            Self::UnsupportedProvider { provider } => BrokerError::UnsupportedCapability {
221                provider,
222                capability: "usage".to_string(),
223            },
224            Self::Unauthorized { .. } | Self::AccountIdUnavailable | Self::IdentityMismatch => {
225                BrokerError::Credential(format!("usage: {self}"))
226            }
227            Self::InvalidEndpointOverride => BrokerError::Denied(format!("usage: {self}")),
228            other => BrokerError::Transport(format!("usage: {other}")),
229        }
230    }
231}
232
233impl std::fmt::Display for UsageError {
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        match self {
236            Self::UnsupportedProvider { provider } => {
237                write!(f, "no usage adapter for provider '{provider}'")
238            }
239            Self::AccountIdUnavailable => {
240                write!(f, "access token carries no account id; cannot pair account header")
241            }
242            Self::IdentityMismatch => {
243                write!(f, "usage response names a different account than the token")
244            }
245            Self::InvalidEndpointOverride => {
246                write!(f, "endpoint override must be a loopback test server")
247            }
248            Self::Unauthorized { status } => {
249                write!(f, "provider rejected the access token (HTTP {status})")
250            }
251            Self::RateLimited {
252                retry_after_s: Some(secs),
253            } => write!(
254                f,
255                "usage endpoint rate limited (HTTP 429, retry after {secs}s)"
256            ),
257            Self::RateLimited {
258                retry_after_s: None,
259            } => {
260                write!(f, "usage endpoint rate limited (HTTP 429)")
261            }
262            Self::UpstreamStatus {
263                status,
264                retry_after_s: Some(secs),
265            } => write!(
266                f,
267                "usage request failed: HTTP {status}, retry after {secs}s"
268            ),
269            Self::UpstreamStatus { status, .. } => write!(f, "usage request failed: HTTP {status}"),
270            Self::Redirected { status } => {
271                write!(f, "usage endpoint redirected (HTTP {status}); not followed")
272            }
273            Self::Timeout => write!(f, "usage request timed out"),
274            Self::Transport { detail } => write!(f, "usage transport error: {detail}"),
275            Self::BodyTooLarge { cap } => write!(f, "usage response exceeded {cap} bytes"),
276            Self::Malformed { detail } => write!(f, "usage response malformed: {detail}"),
277        }
278    }
279}
280
281impl std::error::Error for UsageError {}
282
283// ── Snapshot schema ─────────────────────────────────────────────────────────
284
285/// Fraction of a window consumed. Never a bare number: the parser must decide
286/// `Valid` (finite, `0.0..=100.0`) or `Unknown` with a fixed-vocabulary reason.
287#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
288#[serde(tag = "state", rename_all = "snake_case")]
289pub enum UsedPercent {
290    Valid { percent: f64 },
291    Unknown { reason: String },
292}
293
294/// Largest reported percentage still read as "window overrun" (exhausted)
295/// rather than garbage. Above 10x the value is more likely a unit error
296/// (basis points, a raw counter) than a real overrun, so it stays unknown;
297/// both fail closed. Shared with `quota_policy::WindowLimit::verdict`.
298pub const OVERRUN_PERCENT_MAX: f64 = 1000.0;
299
300impl UsedPercent {
301    pub fn valid(&self) -> Option<f64> {
302        match self {
303            Self::Valid { percent } => Some(*percent),
304            Self::Unknown { .. } => None,
305        }
306    }
307    pub fn is_known(&self) -> bool {
308        matches!(self, Self::Valid { .. })
309    }
310    fn unknown(reason: &str) -> Self {
311        Self::Unknown {
312            reason: reason.to_string(),
313        }
314    }
315    /// Validate a candidate percentage. An overrun window (`100 < v <= 1000`,
316    /// e.g. Anthropic reporting 250% on a spent window) is clamped to 100 so it
317    /// classifies as exhausted; negative or absurd values stay unknown.
318    pub fn from_f64(value: f64) -> Self {
319        if !value.is_finite() {
320            Self::unknown("not a number")
321        } else if value > 100.0 && value <= OVERRUN_PERCENT_MAX {
322            Self::Valid { percent: 100.0 }
323        } else if !(0.0..=100.0).contains(&value) {
324            Self::unknown("out of range")
325        } else {
326            Self::Valid { percent: value }
327        }
328    }
329    /// Validate a JSON value (`None` = key absent).
330    fn from_json(value: Option<&Value>) -> Self {
331        match value {
332            None => Self::unknown("missing"),
333            Some(Value::Null) => Self::unknown("null"),
334            Some(v) => match number_from_value(v) {
335                Some(n) => Self::from_f64(n),
336                None => Self::unknown("not a number"),
337            },
338        }
339    }
340    /// `used / limit * 100` with the usual guards.
341    fn from_ratio(used: Option<f64>, limit: Option<f64>) -> Self {
342        match (used, limit) {
343            (None, _) | (_, None) => Self::unknown("missing"),
344            (_, Some(l)) if l <= 0.0 => Self::unknown("limit is zero"),
345            (Some(u), Some(l)) => Self::from_f64(u / l * 100.0),
346        }
347    }
348}
349
350/// What a window applies to.
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352#[serde(tag = "kind", rename_all = "snake_case")]
353pub enum WindowScope {
354    /// Whole-account quota.
355    Account,
356    /// A model-specific sub-quota (Anthropic `seven_day_opus`, Codex slugs).
357    Model { model: String },
358    /// A feature-specific sub-quota (Codex code review, Anthropic OAuth apps).
359    Feature { feature: String },
360}
361
362/// What kind of clock a `reset_at` describes.
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
364#[serde(rename_all = "snake_case")]
365pub enum ResetKind {
366    /// Rolling/anchored quota window reset.
367    QuotaWindow,
368    /// Billing-period renewal (credits replenish); not an OAuth expiry.
369    BillingRenewal,
370}
371
372/// One quota window as reported by the provider.
373#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
374pub struct UsageWindow {
375    /// Provider-native identifier (`five_hour`, `primary`, `limits[0]`, …).
376    pub id: String,
377    /// Short human label (`5h`, `7d`, provider name, or `unknown`).
378    pub label: String,
379    pub scope: WindowScope,
380    /// Window length in seconds when the provider states or implies one.
381    pub duration_secs: Option<u64>,
382    pub used_percent: UsedPercent,
383    /// Raw counters when the provider exposes them (Kimi, Grok on-demand).
384    pub used: Option<f64>,
385    pub limit: Option<f64>,
386    /// Provider-authoritative reset instant, epoch milliseconds.
387    pub reset_at: Option<u64>,
388    pub reset_kind: ResetKind,
389    /// Per-window exhaustion flag if the provider gives one.
390    pub limit_reached: Option<bool>,
391}
392
393impl UsageWindow {
394    /// Exhausted iff the provider says so or the valid percent is ≥ 100.
395    pub fn is_exhausted(&self) -> bool {
396        self.limit_reached == Some(true) || self.used_percent.valid().is_some_and(|p| p >= 100.0)
397    }
398}
399
400/// Three-state model availability derived from model-scoped windows.
401#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
402#[serde(rename_all = "snake_case")]
403pub enum Availability {
404    Available,
405    Exhausted,
406    Unknown,
407}
408
409/// Per-model availability. Sources, in precedence order:
410/// 1. an explicit provider statement (Codex top-level `model_usage`), and
411/// 2. a model-scoped window (Anthropic `seven_day_<model>`, Codex slugs).
412///
413/// When both exist the entry is merged: exhaustion from either source wins
414/// (fail closed), the explicit `available_at` beats the window reset.
415#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
416pub struct ModelAvailability {
417    pub model: String,
418    pub availability: Availability,
419    pub used_percent: UsedPercent,
420    /// When the model becomes usable again on the subscription (explicit
421    /// `available_at`, else the model window reset), epoch ms.
422    pub reset_at: Option<u64>,
423    /// Provider says purchased credits would unlock the model right now.
424    /// Credits are billing, not subscription capacity: this never makes the
425    /// model `Available` and nothing here purchases or redeems anything.
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub credits_would_enable: Option<bool>,
428    /// `model_usage` | `window` | `merged`.
429    #[serde(default)]
430    pub source: String,
431}
432
433impl ModelAvailability {
434    /// Case-insensitive match of an availability entry against a requested
435    /// model id. Exact match, or the entry names a family token contained in
436    /// the id (`sonnet` ⊂ `claude-sonnet-4-5`, `gpt-6-astra` == `gpt-6-astra`).
437    pub fn matches_model(&self, requested: &str) -> bool {
438        let entry = self.model.to_ascii_lowercase();
439        let req = requested.trim().to_ascii_lowercase();
440        if entry.is_empty() || req.is_empty() {
441            return false;
442        }
443        if entry == req {
444            return true;
445        }
446        req.split(['-', '_', '/', ':', '.']).any(|tok| tok == entry)
447    }
448}
449
450/// Billing/credit balance. This is money-like inventory, not quota.
451#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
452pub struct CreditBalance {
453    /// `codex_credits` | `grok_credits` | `kimi_booster`.
454    pub kind: String,
455    pub has_credits: Option<bool>,
456    pub unlimited: Option<bool>,
457    pub remaining: Option<f64>,
458    pub total: Option<f64>,
459    pub unit: Option<String>,
460    /// Billing renewal instant, epoch ms, when reported.
461    pub renews_at: Option<u64>,
462}
463
464/// Codex banked weekly-reset inventory (read-only view).
465#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
466pub struct BankedResets {
467    /// `None` = provider did not report a count (unknown ≠ zero).
468    pub available_count: Option<u64>,
469    pub credits: Vec<BankedResetCredit>,
470    /// The optional inventory call failed; the rest of the snapshot is valid.
471    pub inventory_error: Option<String>,
472}
473
474#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
475pub struct BankedResetCredit {
476    pub reset_type: Option<String>,
477    pub granted_at: Option<u64>,
478    pub expires_at: Option<u64>,
479}
480
481/// Normalized, secret-free usage observation for one `(provider, account)`.
482#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
483pub struct UsageSnapshot {
484    pub schema_version: u32,
485    /// Canonical OAuth provider id (`anthropic`, `openai-codex`, `kimi-code`, `xai-auth`).
486    pub provider: String,
487    /// `default` or the account label.
488    pub account: String,
489    /// Epoch ms when the body was received; authoritative for staleness.
490    pub observed_at: u64,
491    pub plan: Option<String>,
492    /// Provider-asserted overall exhaustion flag (Codex `rate_limit.limit_reached`).
493    pub limit_reached: Option<bool>,
494    /// Workspace/account spend control tripped (Codex `spend_control.reached`).
495    /// `Some(true)` blocks subscribed availability exactly like `limit_reached`.
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub spend_control_reached: Option<bool>,
498    pub windows: Vec<UsageWindow>,
499    pub model_availability: Vec<ModelAvailability>,
500    pub credits: Option<CreditBalance>,
501    pub banked_resets: Option<BankedResets>,
502    /// First 8 chars of a provider account id when the response carries one.
503    pub identity_prefix: Option<String>,
504    /// Opaque full seat identity paired with the bearer used for this reading.
505    /// Currently supplied by the Codex adapter; absent on older brokers.
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    pub seat_fingerprint: Option<String>,
508    /// Fixed-vocabulary parse notes (never raw keys or values).
509    pub notes: Vec<String>,
510}
511
512impl UsageSnapshot {
513    fn empty(provider: OAuthProviderId, account: &str, observed_at: u64) -> Self {
514        Self {
515            schema_version: SCHEMA_VERSION,
516            provider: provider.as_str().to_string(),
517            account: account.to_string(),
518            observed_at,
519            plan: None,
520            limit_reached: None,
521            spend_control_reached: None,
522            windows: Vec::new(),
523            model_availability: Vec::new(),
524            credits: None,
525            banked_resets: None,
526            identity_prefix: None,
527            seat_fingerprint: None,
528            notes: Vec::new(),
529        }
530    }
531
532    /// Observation older than `max_age_ms` (or from the future) is stale.
533    /// Stale readings are never capacity.
534    pub fn is_stale(&self, now_ms: u64, max_age_ms: u64) -> bool {
535        now_ms < self.observed_at || now_ms - self.observed_at > max_age_ms
536    }
537
538    pub fn window(&self, id: &str) -> Option<&UsageWindow> {
539        self.windows.iter().find(|w| w.id == id)
540    }
541
542    pub fn account_windows(&self) -> impl Iterator<Item = &UsageWindow> {
543        self.windows
544            .iter()
545            .filter(|w| matches!(w.scope, WindowScope::Account))
546    }
547
548    /// The account window with the longest known duration (e.g. weekly).
549    /// Windows with unknown duration sort last.
550    pub fn longest_account_window(&self) -> Option<&UsageWindow> {
551        self.account_windows()
552            .max_by_key(|w| (w.duration_secs.is_some(), w.duration_secs.unwrap_or(0)))
553    }
554
555    /// Fresh, provider did not flag exhaustion or a tripped spend control, at
556    /// least one account window, and every account window has a *valid*
557    /// percent below 100.
558    pub fn has_proven_capacity(&self, now_ms: u64, max_age_ms: u64) -> bool {
559        if self.is_stale(now_ms, max_age_ms)
560            || self.limit_reached == Some(true)
561            || self.spend_control_reached == Some(true)
562        {
563            return false;
564        }
565        let mut any = false;
566        for w in self.account_windows() {
567            any = true;
568            match w.used_percent.valid() {
569                Some(p) if p < 100.0 && w.limit_reached != Some(true) => {}
570                _ => return false,
571            }
572        }
573        any
574    }
575
576    /// Availability entry for a requested model id, if the provider reported one.
577    pub fn model_availability_for(&self, model: &str) -> Option<&ModelAvailability> {
578        self.model_availability
579            .iter()
580            .find(|m| m.matches_model(model))
581    }
582
583    /// Model-aware capacity: account capacity AND, when the provider reported
584    /// anything about this model, that entry must be `Available`. A gated
585    /// model (`model_usage.available == false`, Astra-style) or an exhausted
586    /// model window rejects even when the generic quota is nearly unused.
587    /// Models the provider did not mention fall back to account capacity.
588    pub fn has_proven_capacity_for_model(&self, model: &str, now_ms: u64, max_age_ms: u64) -> bool {
589        if !self.has_proven_capacity(now_ms, max_age_ms) {
590            return false;
591        }
592        match self.model_availability_for(model) {
593            Some(entry) => entry.availability == Availability::Available,
594            None => true,
595        }
596    }
597
598    /// Earliest reset across all windows and model availability entries.
599    pub fn earliest_reset_at(&self) -> Option<u64> {
600        self.windows
601            .iter()
602            .filter_map(|w| w.reset_at)
603            .chain(self.model_availability.iter().filter_map(|m| m.reset_at))
604            .min()
605    }
606
607    /// Fold model-scoped windows into `model_availability`, merging with any
608    /// explicit provider entries already present (e.g. Codex `model_usage`).
609    /// Explicit entries are never overwritten: exhaustion from either source
610    /// wins, an explicit reset beats the window reset, and the window
611    /// contributes its percent when the explicit entry has none.
612    fn derive_model_availability(&mut self) {
613        let mut derived: Vec<ModelAvailability> = Vec::new();
614        for w in &self.windows {
615            let WindowScope::Model { model } = &w.scope else {
616                continue;
617            };
618            let from_window = ModelAvailability {
619                model: model.clone(),
620                availability: if w.is_exhausted() {
621                    Availability::Exhausted
622                } else if w.used_percent.is_known() {
623                    Availability::Available
624                } else {
625                    Availability::Unknown
626                },
627                used_percent: w.used_percent.clone(),
628                reset_at: w.reset_at,
629                credits_would_enable: None,
630                source: "window".into(),
631            };
632            match self
633                .model_availability
634                .iter_mut()
635                .find(|m| m.model.eq_ignore_ascii_case(model))
636            {
637                Some(explicit) => {
638                    if from_window.availability == Availability::Exhausted {
639                        explicit.availability = Availability::Exhausted;
640                    }
641                    if !explicit.used_percent.is_known() {
642                        explicit.used_percent = from_window.used_percent;
643                    }
644                    if explicit.reset_at.is_none() {
645                        explicit.reset_at = from_window.reset_at;
646                    }
647                    explicit.source = "merged".into();
648                }
649                None => derived.push(from_window),
650            }
651        }
652        self.model_availability.extend(derived);
653    }
654
655    fn note(&mut self, text: &str) {
656        if !self.notes.iter().any(|n| n == text) {
657            self.notes.push(text.to_string());
658        }
659    }
660}
661
662// ── Aggregate report (status command / broker listing) ──────────────────────
663
664/// One account's outcome inside a [`UsageReport`]: partial failures never
665/// discard the accounts that succeeded.
666#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
667#[serde(tag = "status", rename_all = "snake_case")]
668pub enum AccountUsageOutcome {
669    Ok { snapshot: Box<UsageSnapshot> },
670    Error { error: UsageErrorSummary },
671}
672
673impl AccountUsageOutcome {
674    pub fn ok(snapshot: UsageSnapshot) -> Self {
675        Self::Ok {
676            snapshot: Box::new(snapshot),
677        }
678    }
679    pub fn error(error: &UsageError) -> Self {
680        Self::Error {
681            error: error.into(),
682        }
683    }
684    pub fn snapshot(&self) -> Option<&UsageSnapshot> {
685        match self {
686            Self::Ok { snapshot } => Some(snapshot),
687            Self::Error { .. } => None,
688        }
689    }
690}
691
692/// Secret-free error row.
693#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
694pub struct UsageErrorSummary {
695    pub kind: String,
696    pub message: String,
697    #[serde(default, skip_serializing_if = "Option::is_none")]
698    pub http_status: Option<u16>,
699}
700
701impl From<&UsageError> for UsageErrorSummary {
702    fn from(e: &UsageError) -> Self {
703        let http_status = match e {
704            UsageError::Unauthorized { status }
705            | UsageError::UpstreamStatus { status, .. }
706            | UsageError::Redirected { status } => Some(*status),
707            UsageError::RateLimited { .. } => Some(429),
708            _ => None,
709        };
710        Self {
711            kind: e.kind().to_string(),
712            message: e.to_string(),
713            http_status,
714        }
715    }
716}
717
718impl UsageErrorSummary {
719    /// Generic (non-`UsageError`) failure such as token resolution.
720    pub fn other(kind: &str, message: impl Into<String>) -> Self {
721        Self {
722            kind: kind.to_string(),
723            message: message.into(),
724            http_status: None,
725        }
726    }
727}
728
729#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
730pub struct AccountUsageEntry {
731    pub provider: String,
732    pub account: String,
733    /// Display-only identity from broker metadata (usually an email); never a token.
734    #[serde(default, skip_serializing_if = "Option::is_none")]
735    pub identity: Option<String>,
736    #[serde(flatten)]
737    pub outcome: AccountUsageOutcome,
738}
739
740/// `synaps status --json` / broker listing payload.
741#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
742pub struct UsageReport {
743    pub schema_version: u32,
744    pub observed_at: u64,
745    /// `local` or `remote` credential source.
746    pub source: String,
747    pub accounts: Vec<AccountUsageEntry>,
748}
749
750impl UsageReport {
751    pub fn new(source: &str, observed_at: u64) -> Self {
752        Self {
753            schema_version: SCHEMA_VERSION,
754            observed_at,
755            source: source.to_string(),
756            accounts: Vec::new(),
757        }
758    }
759    pub fn ok_count(&self) -> usize {
760        self.accounts
761            .iter()
762            .filter(|a| matches!(a.outcome, AccountUsageOutcome::Ok { .. }))
763            .count()
764    }
765    pub fn error_count(&self) -> usize {
766        self.accounts.len() - self.ok_count()
767    }
768}
769
770// ── Value helpers (pure) ────────────────────────────────────────────────────
771
772fn number_from_value(v: &Value) -> Option<f64> {
773    match v {
774        Value::Number(n) => n.as_f64().filter(|f| f.is_finite()),
775        Value::String(s) => s.trim().parse::<f64>().ok().filter(|f| f.is_finite()),
776        _ => None,
777    }
778}
779
780fn u64_from_value(v: &Value) -> Option<u64> {
781    number_from_value(v).filter(|f| *f >= 0.0 && *f <= u64::MAX as f64).map(|f| f as u64)
782}
783
784fn string_from_value(v: Option<&Value>) -> Option<String> {
785    v.and_then(Value::as_str)
786        .map(str::trim)
787        .filter(|s| !s.is_empty())
788        .map(ToString::to_string)
789}
790
791fn bool_from_value(v: Option<&Value>) -> Option<bool> {
792    v.and_then(Value::as_bool)
793}
794
795fn epoch_ms_from_rfc3339(s: &str) -> Option<u64> {
796    chrono::DateTime::parse_from_rfc3339(s.trim())
797        .ok()
798        .map(|dt| dt.timestamp_millis())
799        .filter(|ms| *ms >= 0)
800        .map(|ms| ms as u64)
801}
802
803/// Threshold above which a bare number is treated as milliseconds (≈ 5138 AD
804/// in seconds, ≈ 1973 in ms).
805const EPOCH_MS_THRESHOLD: f64 = 1.0e11;
806
807/// RFC 3339 string, unix seconds, or unix milliseconds → epoch ms.
808fn epoch_ms_from_value(v: Option<&Value>) -> Option<u64> {
809    match v? {
810        Value::String(s) => epoch_ms_from_rfc3339(s).or_else(|| {
811            s.trim()
812                .parse::<f64>()
813                .ok()
814                .and_then(epoch_ms_from_number)
815        }),
816        Value::Number(n) => n.as_f64().and_then(epoch_ms_from_number),
817        _ => None,
818    }
819}
820
821fn epoch_ms_from_number(n: f64) -> Option<u64> {
822    if !n.is_finite() || n <= 0.0 {
823        return None;
824    }
825    let ms = if n > EPOCH_MS_THRESHOLD { n } else { n * 1000.0 };
826    (ms <= u64::MAX as f64).then_some(ms as u64)
827}
828
829/// `18000` → `5h`, `604800` → `7d`, `90` → `90s`.
830pub fn duration_label(secs: u64) -> String {
831    if secs == 0 {
832        "0s".into()
833    } else if secs.is_multiple_of(SECS_PER_DAY) {
834        format!("{}d", secs / SECS_PER_DAY)
835    } else if secs.is_multiple_of(SECS_PER_HOUR) {
836        format!("{}h", secs / SECS_PER_HOUR)
837    } else if secs.is_multiple_of(SECS_PER_MINUTE) {
838        format!("{}m", secs / SECS_PER_MINUTE)
839    } else {
840        format!("{secs}s")
841    }
842}
843
844fn identity_prefix(id: &str) -> String {
845    id.chars().take(8).collect()
846}
847
848fn parse_json_object(body: &str) -> Result<serde_json::Map<String, Value>, UsageError> {
849    let root: Value = serde_json::from_str(body).map_err(|_| UsageError::Malformed {
850        detail: "body is not JSON".into(),
851    })?;
852    match root {
853        Value::Object(map) => Ok(map),
854        _ => Err(UsageError::Malformed {
855            detail: "top-level value is not an object".into(),
856        }),
857    }
858}
859
860// ── Anthropic parser ────────────────────────────────────────────────────────
861
862/// `five_hour` → 18000, `seven_day…` → 604800, `<n>_<unit>` generally.
863fn anthropic_duration_hint(key: &str) -> Option<u64> {
864    let mut parts = key.splitn(3, '_');
865    let count = parts.next()?;
866    let unit = parts.next()?;
867    let n: u64 = match count {
868        "one" => 1,
869        "two" => 2,
870        "three" => 3,
871        "four" => 4,
872        "five" => 5,
873        "six" => 6,
874        "seven" => 7,
875        "eight" => 8,
876        "nine" => 9,
877        "ten" => 10,
878        "twelve" => 12,
879        "thirty" => 30,
880        other => other.parse().ok()?,
881    };
882    let unit_secs = match unit {
883        "minute" | "minutes" => SECS_PER_MINUTE,
884        "hour" | "hours" => SECS_PER_HOUR,
885        "day" | "days" => SECS_PER_DAY,
886        "week" | "weeks" => SECS_PER_WEEK,
887        _ => return None,
888    };
889    n.checked_mul(unit_secs)
890}
891
892/// The Anthropic scope for a window key: `seven_day_sonnet` → Model, `…_oauth_apps` → Feature.
893fn anthropic_scope(key: &str) -> WindowScope {
894    if key == "extra_usage" {
895        return WindowScope::Feature {
896            feature: "extra_usage".into(),
897        };
898    }
899    let mut parts = key.splitn(3, '_');
900    let (_count, _unit, rest) = (parts.next(), parts.next(), parts.next());
901    match rest {
902        None => WindowScope::Account,
903        Some("oauth_apps") => WindowScope::Feature {
904            feature: "oauth_apps".into(),
905        },
906        Some(model) if !model.is_empty() => WindowScope::Model {
907            model: model.to_string(),
908        },
909        Some(_) => WindowScope::Account,
910    }
911}
912
913/// Whether Anthropic's `extra_usage` entry becomes a window: `is_enabled` is
914/// `true` and `utilization` is a JSON number (0 counts).
915fn extra_usage_shown(val: &Value) -> bool {
916    val.get("is_enabled").and_then(Value::as_bool) == Some(true)
917        && val.get("utilization").is_some_and(Value::is_number)
918}
919
920/// Parse `GET /api/oauth/usage`. Every object value carrying `utilization`
921/// or `resets_at` is a window; `null` windows are skipped with a note.
922pub fn parse_anthropic_usage(
923    body: &str,
924    account_label: &str,
925    observed_at: u64,
926) -> Result<UsageSnapshot, UsageError> {
927    let map = parse_json_object(body)?;
928    let mut snap = UsageSnapshot::empty(OAuthProviderId::Anthropic, account_label, observed_at);
929    let mut skipped_null = 0usize;
930    let mut unrecognized = 0usize;
931    for (key, val) in &map {
932        // Paid extra usage is a window only when the account has it switched
933        // on and reports a number; otherwise the entry is omitted, with no
934        // note: a feature that is off is not a gap in the reading.
935        if key == "extra_usage" && !extra_usage_shown(val) {
936            continue;
937        }
938        match val {
939            Value::Object(w) if w.contains_key("utilization") || w.contains_key("resets_at") => {
940                let used_percent = UsedPercent::from_json(w.get("utilization"));
941                let reset_at = match w.get("resets_at") {
942                    None | Some(Value::Null) => None,
943                    Some(v) => {
944                        let parsed = epoch_ms_from_value(Some(v));
945                        if parsed.is_none() {
946                            snap.note("reset timestamp unparseable");
947                        }
948                        parsed
949                    }
950                };
951                let duration_secs = anthropic_duration_hint(key);
952                snap.windows.push(UsageWindow {
953                    id: key.clone(),
954                    label: duration_secs
955                        .map(duration_label)
956                        .unwrap_or_else(|| "unknown".into()),
957                    scope: anthropic_scope(key),
958                    duration_secs,
959                    used_percent,
960                    used: None,
961                    limit: None,
962                    reset_at,
963                    reset_kind: ResetKind::QuotaWindow,
964                    limit_reached: None,
965                });
966            }
967            Value::Null => skipped_null += 1,
968            _ => unrecognized += 1,
969        }
970    }
971    if skipped_null > 0 {
972        snap.note("null windows skipped");
973    }
974    if unrecognized > 0 {
975        snap.note("unrecognized fields ignored");
976    }
977    if snap.windows.is_empty() {
978        snap.note("no usage windows in response");
979    }
980    snap.derive_model_availability();
981    Ok(snap)
982}
983
984// ── Codex parser ────────────────────────────────────────────────────────────
985
986/// One `RateLimitWindowSnapshot`: `used_percent`, `limit_window_seconds`,
987/// `reset_after_seconds`, `reset_at` (unix seconds).
988fn codex_window(
989    snap: &mut UsageSnapshot,
990    id: &str,
991    scope: WindowScope,
992    raw: &Value,
993    observed_at: u64,
994) -> Option<UsageWindow> {
995    let w = match raw {
996        Value::Object(w) => w,
997        Value::Null => {
998            snap.note("null window skipped");
999            return None;
1000        }
1001        _ => {
1002            snap.note("window is not an object");
1003            return None;
1004        }
1005    };
1006    let used_percent = UsedPercent::from_json(w.get("used_percent"));
1007    let duration_secs = w.get("limit_window_seconds").and_then(u64_from_value);
1008    let mut reset_at = epoch_ms_from_value(w.get("reset_at"));
1009    if reset_at.is_none() {
1010        if let Some(after) = w.get("reset_after_seconds").and_then(u64_from_value) {
1011            reset_at = observed_at.checked_add(after.saturating_mul(1000));
1012            snap.note("reset derived from reset_after_seconds");
1013        }
1014    }
1015    Some(UsageWindow {
1016        id: id.to_string(),
1017        label: duration_secs
1018            .map(duration_label)
1019            .unwrap_or_else(|| "unknown".into()),
1020        scope,
1021        duration_secs,
1022        used_percent,
1023        used: None,
1024        limit: None,
1025        reset_at,
1026        reset_kind: ResetKind::QuotaWindow,
1027        limit_reached: None,
1028    })
1029}
1030
1031/// One `RateLimitStatusPayload` (`allowed`, `limit_reached`, `primary_window`,
1032/// `secondary_window`) → up to two windows tagged `<prefix>primary` / `<prefix>secondary`.
1033fn codex_status_windows(
1034    snap: &mut UsageSnapshot,
1035    prefix: &str,
1036    scope: WindowScope,
1037    raw: &Value,
1038    observed_at: u64,
1039) -> Option<bool> {
1040    let status = raw.as_object()?;
1041    let limit_reached = bool_from_value(status.get("limit_reached"));
1042    for (suffix, key) in [("primary", "primary_window"), ("secondary", "secondary_window")] {
1043        match status.get(key) {
1044            None => {}
1045            Some(v) => {
1046                if let Some(mut w) =
1047                    codex_window(snap, &format!("{prefix}{suffix}"), scope.clone(), v, observed_at)
1048                {
1049                    w.limit_reached = limit_reached;
1050                    snap.windows.push(w);
1051                }
1052            }
1053        }
1054    }
1055    limit_reached
1056}
1057
1058fn codex_banked_resets(raw: &Value) -> Option<BankedResets> {
1059    let obj = raw.as_object()?;
1060    let available_count = obj.get("available_count").and_then(u64_from_value);
1061    let credits = obj
1062        .get("credits")
1063        .and_then(Value::as_array)
1064        .map(|items| {
1065            items
1066                .iter()
1067                .filter_map(Value::as_object)
1068                .map(|c| BankedResetCredit {
1069                    reset_type: string_from_value(c.get("reset_type")),
1070                    granted_at: epoch_ms_from_value(c.get("granted_at")),
1071                    expires_at: epoch_ms_from_value(c.get("expires_at")),
1072                })
1073                .collect()
1074        })
1075        .unwrap_or_default();
1076    Some(BankedResets {
1077        available_count,
1078        credits,
1079        inventory_error: None,
1080    })
1081}
1082
1083/// Parse `GET /wham/usage`. `expected_account_id` is the id derived from the
1084/// bearer token; a differing response `account_id` fails closed.
1085pub fn parse_codex_usage(
1086    body: &str,
1087    account_label: &str,
1088    observed_at: u64,
1089    expected_account_id: Option<&str>,
1090) -> Result<UsageSnapshot, UsageError> {
1091    let map = parse_json_object(body)?;
1092    let mut snap = UsageSnapshot::empty(OAuthProviderId::OpenAiCodex, account_label, observed_at);
1093
1094    if let Some(id) = string_from_value(map.get("account_id")) {
1095        if let Some(expected) = expected_account_id {
1096            if id != expected {
1097                return Err(UsageError::IdentityMismatch);
1098            }
1099        }
1100        snap.identity_prefix = Some(identity_prefix(&id));
1101    } else if let Some(expected) = expected_account_id {
1102        snap.identity_prefix = Some(identity_prefix(expected));
1103    }
1104
1105    // Only the caller-paired credential identity authorizes spending. A body
1106    // claiming an id without an expected token identity is display-only.
1107    snap.seat_fingerprint = expected_account_id
1108        .and_then(|id| super::seat_fingerprint(OAuthProviderId::OpenAiCodex, id));
1109
1110    snap.plan = string_from_value(map.get("plan_type"));
1111
1112    match map.get("rate_limit") {
1113        Some(rl @ Value::Object(_)) => {
1114            snap.limit_reached = codex_status_windows(&mut snap, "", WindowScope::Account, rl, observed_at);
1115        }
1116        Some(Value::Null) | None => snap.note("rate_limit missing"),
1117        Some(_) => snap.note("rate_limit is not an object"),
1118    }
1119
1120    if let Some(cr @ Value::Object(_)) = map.get("code_review_rate_limit") {
1121        codex_status_windows(
1122            &mut snap,
1123            "code_review.",
1124            WindowScope::Feature {
1125                feature: "code_review".into(),
1126            },
1127            cr,
1128            observed_at,
1129        );
1130    }
1131
1132    if let Some(extra) = map.get("additional_rate_limits").and_then(Value::as_array) {
1133        for (i, item) in extra.iter().enumerate() {
1134            let Some(obj) = item.as_object() else {
1135                snap.note("additional rate limit is not an object");
1136                continue;
1137            };
1138            let name = string_from_value(obj.get("limit_name"))
1139                .or_else(|| string_from_value(obj.get("metered_feature")))
1140                .unwrap_or_else(|| format!("additional[{i}]"));
1141            let scope = match string_from_value(obj.get("normal_model_slug")) {
1142                Some(model) => WindowScope::Model { model },
1143                None => WindowScope::Feature {
1144                    feature: name.clone(),
1145                },
1146            };
1147            if let Some(rl) = obj.get("rate_limit") {
1148                codex_status_windows(&mut snap, &format!("{name}."), scope, rl, observed_at);
1149            }
1150        }
1151    }
1152
1153    // Explicit per-model gating (e.g. `{"gpt-6-astra": {"available": false,
1154    // "available_at": "…", "credits_would_enable": true}}`). This is the
1155    // authoritative statement for premium models and must be parsed before
1156    // windows are folded in so the merge never overwrites it.
1157    match map.get("model_usage") {
1158        Some(Value::Object(models)) => {
1159            for (model, raw) in models {
1160                let Some(entry) = raw.as_object() else {
1161                    snap.note("model usage entry is not an object");
1162                    continue;
1163                };
1164                let availability = match entry.get("available") {
1165                    Some(Value::Bool(true)) => Availability::Available,
1166                    Some(Value::Bool(false)) => Availability::Exhausted,
1167                    _ => Availability::Unknown,
1168                };
1169                let reset_at = match entry.get("available_at") {
1170                    None | Some(Value::Null) => None,
1171                    Some(v) => {
1172                        let parsed = epoch_ms_from_value(Some(v));
1173                        if parsed.is_none() {
1174                            snap.note("reset timestamp unparseable");
1175                        }
1176                        parsed
1177                    }
1178                };
1179                snap.model_availability.push(ModelAvailability {
1180                    model: model.clone(),
1181                    availability,
1182                    used_percent: UsedPercent::from_json(entry.get("used_percent")),
1183                    reset_at,
1184                    credits_would_enable: bool_from_value(entry.get("credits_would_enable")),
1185                    source: "model_usage".into(),
1186                });
1187            }
1188        }
1189        Some(Value::Null) | None => {}
1190        Some(_) => snap.note("model usage field is not an object"),
1191    }
1192
1193    // Spend control: `reached` at the top of `spend_control` or on any nested
1194    // limit object trips the flag (fail closed). A nested limit with
1195    // `remaining_percent`/`resets_at` becomes a feature-scoped window.
1196    match map.get("spend_control") {
1197        Some(Value::Object(sc)) => {
1198            let mut reached = bool_from_value(sc.get("reached"));
1199            for (_key, nested) in sc {
1200                let Some(limit) = nested.as_object() else {
1201                    continue;
1202                };
1203                if bool_from_value(limit.get("reached")) == Some(true) {
1204                    reached = Some(true);
1205                }
1206                let remaining = limit.get("remaining_percent").and_then(number_from_value);
1207                let resets_at = epoch_ms_from_value(limit.get("resets_at"));
1208                if remaining.is_some() || resets_at.is_some() {
1209                    snap.windows.push(UsageWindow {
1210                        id: "spend_control".into(),
1211                        label: "spend control".into(),
1212                        scope: WindowScope::Feature {
1213                            feature: "spend_control".into(),
1214                        },
1215                        duration_secs: None,
1216                        used_percent: match remaining {
1217                            Some(r) => UsedPercent::from_f64(100.0 - r),
1218                            None => UsedPercent::unknown("missing"),
1219                        },
1220                        used: None,
1221                        limit: limit.get("limit").and_then(number_from_value),
1222                        reset_at: resets_at,
1223                        reset_kind: ResetKind::BillingRenewal,
1224                        limit_reached: bool_from_value(limit.get("reached")),
1225                    });
1226                }
1227            }
1228            snap.spend_control_reached = reached;
1229        }
1230        Some(Value::Null) | None => {}
1231        Some(_) => snap.note("spend control field is not an object"),
1232    }
1233
1234    if let Some(credits) = map.get("credits").and_then(Value::as_object) {
1235        snap.credits = Some(CreditBalance {
1236            kind: "codex_credits".into(),
1237            has_credits: bool_from_value(credits.get("has_credits")),
1238            unlimited: bool_from_value(credits.get("unlimited")),
1239            remaining: credits.get("balance").and_then(number_from_value),
1240            total: None,
1241            unit: Some("credits".into()),
1242            renews_at: None,
1243        });
1244    }
1245
1246    match map.get("rate_limit_reset_credits") {
1247        Some(v @ Value::Object(_)) => snap.banked_resets = codex_banked_resets(v),
1248        Some(Value::Null) | None => {}
1249        Some(_) => snap.note("reset credits field is not an object"),
1250    }
1251
1252    if snap.windows.is_empty() {
1253        snap.note("no usage windows in response");
1254    }
1255    snap.derive_model_availability();
1256    Ok(snap)
1257}
1258
1259/// Parse `GET /wham/rate-limit-reset-credits` (inventory detail).
1260pub fn parse_codex_reset_credits(body: &str) -> Result<BankedResets, UsageError> {
1261    let map = parse_json_object(body)?;
1262    codex_banked_resets(&Value::Object(map)).ok_or_else(|| UsageError::Malformed {
1263        detail: "reset credits payload has unexpected shape".into(),
1264    })
1265}
1266
1267// ── Kimi parser ─────────────────────────────────────────────────────────────
1268
1269fn kimi_unit_secs(raw: Option<&Value>) -> Option<u64> {
1270    match raw?.as_str()? {
1271        "TIME_UNIT_MINUTE" | "minute" => Some(SECS_PER_MINUTE),
1272        "TIME_UNIT_HOUR" | "hour" => Some(SECS_PER_HOUR),
1273        "TIME_UNIT_DAY" | "day" => Some(SECS_PER_DAY),
1274        "TIME_UNIT_WEEK" | "week" => Some(SECS_PER_WEEK),
1275        _ => None,
1276    }
1277}
1278
1279fn kimi_window_secs(raw: Option<&Value>) -> Option<u64> {
1280    let w = raw?.as_object()?;
1281    let duration = w.get("duration").and_then(u64_from_value)?;
1282    let unit = kimi_unit_secs(w.get("timeUnit"))?;
1283    duration.checked_mul(unit)
1284}
1285
1286/// `{used, limit, resetTime}` (numbers or numeric strings) → window.
1287fn kimi_row(
1288    snap: &mut UsageSnapshot,
1289    id: &str,
1290    label_hint: Option<String>,
1291    duration_secs: Option<u64>,
1292    raw: Option<&Value>,
1293) -> Option<UsageWindow> {
1294    let row = raw?.as_object()?;
1295    let used = row.get("used").and_then(number_from_value);
1296    let limit = row.get("limit").and_then(number_from_value);
1297    if used.is_none() && limit.is_none() {
1298        return None;
1299    }
1300    let reset_at = match row.get("resetTime") {
1301        None | Some(Value::Null) => None,
1302        Some(v) => {
1303            let parsed = epoch_ms_from_value(Some(v));
1304            if parsed.is_none() {
1305                snap.note("reset timestamp unparseable");
1306            }
1307            parsed
1308        }
1309    };
1310    let label = label_hint
1311        .or_else(|| duration_secs.map(duration_label))
1312        .unwrap_or_else(|| "unknown".into());
1313    Some(UsageWindow {
1314        id: id.to_string(),
1315        label,
1316        scope: WindowScope::Account,
1317        duration_secs,
1318        used_percent: UsedPercent::from_ratio(used, limit),
1319        used,
1320        limit,
1321        reset_at,
1322        reset_kind: ResetKind::QuotaWindow,
1323        limit_reached: None,
1324    })
1325}
1326
1327/// Parse `GET /coding/v1/usages` (official CLI `parseManagedUsagePayload`).
1328pub fn parse_kimi_usage(
1329    body: &str,
1330    account_label: &str,
1331    observed_at: u64,
1332) -> Result<UsageSnapshot, UsageError> {
1333    let map = parse_json_object(body)?;
1334    let mut snap = UsageSnapshot::empty(OAuthProviderId::KimiCode, account_label, observed_at);
1335
1336    // Summary row: the official CLI treats a summary without a window as 1 week.
1337    if let Some(w) = kimi_row(&mut snap, "usage", None, Some(SECS_PER_WEEK), map.get("usage")) {
1338        snap.windows.push(w);
1339    } else if map.contains_key("usage") {
1340        snap.note("summary usage row unparseable");
1341    }
1342
1343    match map.get("limits") {
1344        Some(Value::Array(items)) => {
1345            for (i, item) in items.iter().enumerate() {
1346                let Some(obj) = item.as_object() else {
1347                    snap.note("limit row is not an object");
1348                    continue;
1349                };
1350                let duration = kimi_window_secs(obj.get("window"));
1351                let name = string_from_value(obj.get("name")).or_else(|| {
1352                    obj.get("detail")
1353                        .and_then(Value::as_object)
1354                        .and_then(|d| string_from_value(d.get("name")))
1355                });
1356                let id = format!("limits[{i}]");
1357                match kimi_row(&mut snap, &id, name, duration, obj.get("detail")) {
1358                    Some(w) => snap.windows.push(w),
1359                    None => snap.note("limit row unparseable"),
1360                }
1361            }
1362        }
1363        Some(Value::Null) | None => {}
1364        Some(_) => snap.note("limits field is not an array"),
1365    }
1366
1367    if let Some(wallet) = map.get("boosterWallet").and_then(Value::as_object) {
1368        if let Some(balance) = wallet.get("balance").and_then(Value::as_object) {
1369            // Fixed-point amounts (1e6 = one cent in the official CLI).
1370            const FIXED_POINT_PER_CENT: f64 = 1.0e6;
1371            let total = balance
1372                .get("amount")
1373                .and_then(number_from_value)
1374                .map(|v| v / FIXED_POINT_PER_CENT / 100.0);
1375            let remaining = balance
1376                .get("amountLeft")
1377                .and_then(number_from_value)
1378                .map(|v| v / FIXED_POINT_PER_CENT / 100.0);
1379            let currency = ["monthlyChargeLimit", "monthlyUsed"]
1380                .iter()
1381                .filter_map(|k| wallet.get(*k).and_then(Value::as_object))
1382                .find_map(|m| string_from_value(m.get("currency")))
1383                .unwrap_or_else(|| "USD".into());
1384            if total.is_some() || remaining.is_some() {
1385                snap.credits = Some(CreditBalance {
1386                    kind: "kimi_booster".into(),
1387                    has_credits: remaining.map(|r| r > 0.0),
1388                    unlimited: None,
1389                    remaining,
1390                    total,
1391                    unit: Some(currency),
1392                    renews_at: None,
1393                });
1394            }
1395        }
1396    }
1397
1398    if snap.windows.is_empty() {
1399        snap.note("no usage windows in response");
1400    }
1401    snap.derive_model_availability();
1402    Ok(snap)
1403}
1404
1405// ── Grok parser (schema unverified live; QuotaKit-documented shape) ─────────
1406
1407/// Look up `key` in `config` first, then at the root.
1408fn grok_lookup<'a>(
1409    root: &'a serde_json::Map<String, Value>,
1410    config: Option<&'a serde_json::Map<String, Value>>,
1411    key: &str,
1412) -> Option<&'a Value> {
1413    config
1414        .and_then(|c| c.get(key))
1415        .filter(|v| !v.is_null())
1416        .or_else(|| root.get(key).filter(|v| !v.is_null()))
1417}
1418
1419/// `{ "val": 12 }` or a bare number/string.
1420fn grok_val(v: Option<&Value>) -> Option<f64> {
1421    match v? {
1422        Value::Object(o) => o.get("val").and_then(number_from_value),
1423        other => number_from_value(other),
1424    }
1425}
1426
1427/// Parse `GET /v1/billing?format=credits`.
1428///
1429/// Recognized: `config.creditUsagePercent` (preferred), else
1430/// `onDemandUsed.val / onDemandCap.val * 100`; reset from
1431/// `config.currentPeriod.end` or `config.billingPeriodEnd`; duration from
1432/// `billingPeriodMinutes`. Each field is also accepted at the root. Without a
1433/// recognizable fraction the window is explicitly `Unknown`.
1434pub fn parse_grok_billing(
1435    body: &str,
1436    account_label: &str,
1437    observed_at: u64,
1438) -> Result<UsageSnapshot, UsageError> {
1439    let map = parse_json_object(body)?;
1440    let mut snap = UsageSnapshot::empty(OAuthProviderId::Xai, account_label, observed_at);
1441    let config = map.get("config").and_then(Value::as_object);
1442
1443    let used = grok_val(grok_lookup(&map, config, "onDemandUsed"));
1444    let cap = grok_val(grok_lookup(&map, config, "onDemandCap"));
1445
1446    let percent_field = grok_lookup(&map, config, "creditUsagePercent");
1447    let used_percent = match percent_field {
1448        Some(v) => match number_from_value(v) {
1449            Some(n) => UsedPercent::from_f64(n),
1450            None => UsedPercent::unknown("not a number"),
1451        },
1452        None => match (used, cap) {
1453            (Some(_), Some(_)) => {
1454                snap.note("percent derived from on-demand counters");
1455                UsedPercent::from_ratio(used, cap)
1456            }
1457            _ => UsedPercent::unknown("missing"),
1458        },
1459    };
1460
1461    let reset_at = grok_lookup(&map, config, "currentPeriod")
1462        .and_then(Value::as_object)
1463        .and_then(|p| epoch_ms_from_value(p.get("end")))
1464        .or_else(|| epoch_ms_from_value(grok_lookup(&map, config, "billingPeriodEnd")));
1465    let duration_secs = grok_lookup(&map, config, "billingPeriodMinutes")
1466        .and_then(u64_from_value)
1467        .and_then(|m| m.checked_mul(SECS_PER_MINUTE));
1468
1469    // The schema counts as recognized when any documented field is present,
1470    // even if its value failed validation (the window is then Unknown).
1471    let recognized = percent_field.is_some()
1472        || reset_at.is_some()
1473        || duration_secs.is_some()
1474        || used.is_some()
1475        || cap.is_some();
1476    if recognized {
1477        snap.windows.push(UsageWindow {
1478            id: "credits".into(),
1479            label: duration_secs
1480                .map(duration_label)
1481                .unwrap_or_else(|| "billing period".into()),
1482            scope: WindowScope::Account,
1483            duration_secs,
1484            used_percent,
1485            used,
1486            limit: cap,
1487            reset_at,
1488            reset_kind: ResetKind::BillingRenewal,
1489            limit_reached: None,
1490        });
1491        if used.is_some() || cap.is_some() {
1492            snap.credits = Some(CreditBalance {
1493                kind: "grok_credits".into(),
1494                has_credits: match (used, cap) {
1495                    (Some(u), Some(c)) => Some(u < c),
1496                    _ => None,
1497                },
1498                unlimited: None,
1499                remaining: match (used, cap) {
1500                    (Some(u), Some(c)) => Some((c - u).max(0.0)),
1501                    _ => None,
1502                },
1503                total: cap,
1504                unit: Some("credits".into()),
1505                renews_at: reset_at,
1506            });
1507        }
1508    } else {
1509        snap.note("unrecognized billing schema");
1510    }
1511    snap.note("grok billing schema unverified against a live account");
1512    snap.derive_model_availability();
1513    Ok(snap)
1514}
1515
1516// ── Fetch ───────────────────────────────────────────────────────────────────
1517
1518/// Options for [`fetch_usage`]. Production callers use `Default`.
1519#[derive(Debug, Clone)]
1520pub struct UsageFetchOptions {
1521    /// Test seam: replace the pinned URL. **Loopback hosts only** —
1522    /// anything else fails with [`UsageError::InvalidEndpointOverride`]
1523    /// before any request is built.
1524    pub endpoint_override: Option<String>,
1525    /// Test seam for the Codex inventory URL; same loopback rule.
1526    pub inventory_endpoint_override: Option<String>,
1527    /// Also `GET` the Codex banked-reset inventory. Failures there are
1528    /// recorded in `banked_resets.inventory_error`, never fatal.
1529    pub include_inventory: bool,
1530    pub timeout: Duration,
1531    pub max_body_bytes: usize,
1532}
1533
1534impl Default for UsageFetchOptions {
1535    fn default() -> Self {
1536        Self {
1537            endpoint_override: None,
1538            inventory_endpoint_override: None,
1539            include_inventory: false,
1540            timeout: USAGE_REQUEST_TIMEOUT,
1541            max_body_bytes: USAGE_MAX_BODY_BYTES,
1542        }
1543    }
1544}
1545
1546/// True for `http(s)://127.0.0.1…`, `localhost`, `[::1]`.
1547pub(crate) fn is_loopback_url(raw: &str) -> bool {
1548    let Ok(url) = url::Url::parse(raw) else {
1549        return false;
1550    };
1551    if !matches!(url.scheme(), "http" | "https") {
1552        return false;
1553    }
1554    match url.host() {
1555        Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
1556        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1557        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1558        None => false,
1559    }
1560}
1561
1562fn resolve_endpoint(pinned: &str, override_url: Option<&str>) -> Result<String, UsageError> {
1563    match override_url {
1564        None => Ok(pinned.to_string()),
1565        Some(u) if is_loopback_url(u) => Ok(u.to_string()),
1566        Some(_) => Err(UsageError::InvalidEndpointOverride),
1567    }
1568}
1569
1570/// HTTP client with the policy this module requires: no redirects, bounded
1571/// connect/total timeouts, built-in roots. Construct once and reuse.
1572#[derive(Clone)]
1573pub struct UsageClient {
1574    http: reqwest::Client,
1575}
1576
1577impl std::fmt::Debug for UsageClient {
1578    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1579        f.write_str("UsageClient")
1580    }
1581}
1582
1583impl UsageClient {
1584    pub fn new() -> Result<Self, UsageError> {
1585        let http = reqwest::Client::builder()
1586            .redirect(reqwest::redirect::Policy::none())
1587            .connect_timeout(USAGE_CONNECT_TIMEOUT)
1588            .timeout(USAGE_REQUEST_TIMEOUT)
1589            .tls_built_in_webpki_certs(true)
1590            .tls_built_in_native_certs(true)
1591            .user_agent(USER_AGENT)
1592            .build()
1593            .map_err(|_| UsageError::Transport {
1594                detail: "could not build HTTP client".into(),
1595            })?;
1596        Ok(Self { http })
1597    }
1598}
1599
1600fn classify_reqwest_error(e: &reqwest::Error) -> UsageError {
1601    if e.is_timeout() {
1602        UsageError::Timeout
1603    } else {
1604        let detail = if e.is_connect() {
1605            "connect"
1606        } else if e.is_request() {
1607            "request"
1608        } else if e.is_body() || e.is_decode() {
1609            "body"
1610        } else if e.is_builder() {
1611            "builder"
1612        } else {
1613            "unknown"
1614        };
1615        UsageError::Transport {
1616            detail: detail.into(),
1617        }
1618    }
1619}
1620
1621/// Stream the body into a capped buffer. Fail closed on overflow.
1622async fn read_body_capped(resp: reqwest::Response, cap: usize) -> Result<String, UsageError> {
1623    use futures::StreamExt;
1624    let mut buf: Vec<u8> = Vec::new();
1625    let mut stream = resp.bytes_stream();
1626    while let Some(chunk) = stream.next().await {
1627        let chunk = chunk.map_err(|e| classify_reqwest_error(&e))?;
1628        if buf.len().saturating_add(chunk.len()) > cap {
1629            return Err(UsageError::BodyTooLarge { cap });
1630        }
1631        buf.extend_from_slice(&chunk);
1632    }
1633    String::from_utf8(buf).map_err(|_| UsageError::Malformed {
1634        detail: "body is not UTF-8".into(),
1635    })
1636}
1637
1638/// Longest `Retry-After` we record; a larger value is clamped to it.
1639const MAX_RETRY_AFTER_SECS: u64 = 24 * 60 * 60;
1640
1641/// The delta-seconds form of `Retry-After` (an HTTP-date or garbage is
1642/// ignored), clamped to [`MAX_RETRY_AFTER_SECS`]. Only the header is read;
1643/// the body stays unread.
1644fn retry_after_secs(headers: &reqwest::header::HeaderMap) -> Option<u64> {
1645    headers
1646        .get(reqwest::header::RETRY_AFTER)?
1647        .to_str()
1648        .ok()?
1649        .trim()
1650        .parse::<u64>()
1651        .ok()
1652        .map(|secs| secs.min(MAX_RETRY_AFTER_SECS))
1653}
1654
1655/// Send one pinned GET; classify status; return the capped body text.
1656async fn get_json_text(
1657    client: &UsageClient,
1658    url: &str,
1659    headers: &[(&str, String)],
1660    opts: &UsageFetchOptions,
1661) -> Result<String, UsageError> {
1662    let mut req = client
1663        .http
1664        .get(url)
1665        .timeout(opts.timeout)
1666        .header("Accept", "application/json");
1667    for (k, v) in headers {
1668        req = req.header(*k, v);
1669    }
1670    let resp = req.send().await.map_err(|e| classify_reqwest_error(&e))?;
1671    let status = resp.status().as_u16();
1672    // Non-2xx bodies are upstream-controlled and may echo the request: dropped unread.
1673    match status {
1674        200..=299 => {}
1675        300..=399 => {
1676            drop(resp);
1677            return Err(UsageError::Redirected { status });
1678        }
1679        401 | 403 => {
1680            drop(resp);
1681            return Err(UsageError::Unauthorized { status });
1682        }
1683        429 => {
1684            let retry_after_s = retry_after_secs(resp.headers());
1685            drop(resp);
1686            return Err(UsageError::RateLimited { retry_after_s });
1687        }
1688        _ => {
1689            let retry_after_s = retry_after_secs(resp.headers());
1690            drop(resp);
1691            return Err(UsageError::UpstreamStatus {
1692                status,
1693                retry_after_s,
1694            });
1695        }
1696    }
1697    read_body_capped(resp, opts.max_body_bytes).await
1698}
1699
1700/// Read-only usage fetch for one `(provider, account)` using the caller's
1701/// already-resolved access token. See the module docs for the guarantees.
1702pub async fn fetch_usage(
1703    client: &UsageClient,
1704    provider: OAuthProviderId,
1705    account_label: &str,
1706    access_token: &str,
1707    opts: &UsageFetchOptions,
1708) -> Result<UsageSnapshot, UsageError> {
1709    if !supports_usage(provider) {
1710        return Err(UsageError::UnsupportedProvider {
1711            provider: provider.as_str().to_string(),
1712        });
1713    }
1714    let bearer = format!("Bearer {access_token}");
1715    match provider {
1716        OAuthProviderId::Anthropic => {
1717            let url = resolve_endpoint(ANTHROPIC_USAGE_URL, opts.endpoint_override.as_deref())?;
1718            let headers = [
1719                ("Authorization", bearer),
1720                ("anthropic-beta", ANTHROPIC_OAUTH_BETA.to_string()),
1721            ];
1722            let body = get_json_text(client, &url, &headers, opts).await?;
1723            parse_anthropic_usage(&body, account_label, crate::epoch_millis())
1724        }
1725        OAuthProviderId::OpenAiCodex => {
1726            // The account header is derived from THE SAME token that carries
1727            // the bearer, so header and credential can never disagree.
1728            let account_id = super::openai_codex::extract_account_id(access_token)
1729                .ok_or(UsageError::AccountIdUnavailable)?;
1730            let url = resolve_endpoint(CODEX_USAGE_URL, opts.endpoint_override.as_deref())?;
1731            let inventory_url = resolve_endpoint(
1732                CODEX_RESET_CREDITS_URL,
1733                opts.inventory_endpoint_override.as_deref(),
1734            )?;
1735            let headers = [
1736                ("Authorization", bearer),
1737                (CODEX_ACCOUNT_HEADER, account_id.clone()),
1738            ];
1739            let body = get_json_text(client, &url, &headers, opts).await?;
1740            let mut snap =
1741                parse_codex_usage(&body, account_label, crate::epoch_millis(), Some(&account_id))?;
1742            if opts.include_inventory {
1743                let inventory = match get_json_text(client, &inventory_url, &headers, opts).await {
1744                    Ok(text) => parse_codex_reset_credits(&text),
1745                    Err(e) => Err(e),
1746                };
1747                let banked = snap.banked_resets.get_or_insert(BankedResets {
1748                    available_count: None,
1749                    credits: Vec::new(),
1750                    inventory_error: None,
1751                });
1752                match inventory {
1753                    Ok(detail) => {
1754                        if detail.available_count.is_some() {
1755                            banked.available_count = detail.available_count;
1756                        }
1757                        banked.credits = detail.credits;
1758                        banked.inventory_error = None;
1759                    }
1760                    Err(e) => {
1761                        // Partial: the usage snapshot stands; only inventory is missing.
1762                        banked.inventory_error = Some(e.kind().to_string());
1763                    }
1764                }
1765            }
1766            Ok(snap)
1767        }
1768        OAuthProviderId::KimiCode => {
1769            // The official CLI's usage fetch sends bearer + Accept only; no
1770            // device-identity headers, so no device-id state is touched.
1771            let url = resolve_endpoint(KIMI_USAGE_URL, opts.endpoint_override.as_deref())?;
1772            let headers = [("Authorization", bearer)];
1773            let body = get_json_text(client, &url, &headers, opts).await?;
1774            parse_kimi_usage(&body, account_label, crate::epoch_millis())
1775        }
1776        OAuthProviderId::Xai => {
1777            let url = resolve_endpoint(GROK_BILLING_URL, opts.endpoint_override.as_deref())?;
1778            let headers = [
1779                ("Authorization", bearer),
1780                (GROK_TOKEN_AUTH_HEADER, GROK_TOKEN_AUTH_VALUE.to_string()),
1781            ];
1782            let body = get_json_text(client, &url, &headers, opts).await?;
1783            parse_grok_billing(&body, account_label, crate::epoch_millis())
1784        }
1785        // Muse reports plan usage only as a side effect of the key mint
1786        // (`POST /muse-code/key`), which is not a read; no usage adapter.
1787        OAuthProviderId::GitHubCopilot | OAuthProviderId::GoogleGemini | OAuthProviderId::Muse => {
1788            Err(UsageError::UnsupportedProvider {
1789                provider: provider.as_str().to_string(),
1790            })
1791        }
1792    }
1793}
1794
1795// ── Tests ───────────────────────────────────────────────────────────────────
1796
1797#[cfg(test)]
1798mod tests {
1799    use super::*;
1800    use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
1801
1802    const T0: u64 = 1_758_400_000_000; // 2025-09-20T20:26:40Z
1803
1804    fn secret_free(text: &str, secrets: &[&str]) {
1805        for s in secrets {
1806            assert!(!text.contains(s), "secret material leaked: {text}");
1807        }
1808    }
1809
1810    // ── UsedPercent ─────────────────────────────────────────────────────
1811
1812    #[test]
1813    fn used_percent_validation() {
1814        assert_eq!(UsedPercent::from_f64(0.0).valid(), Some(0.0));
1815        assert_eq!(UsedPercent::from_f64(100.0).valid(), Some(100.0));
1816        // An overrun window (> 100) is spent, not unknown: clamp to 100 so
1817        // it reads and classifies as exhausted. Absurd values stay unknown.
1818        assert_eq!(UsedPercent::from_f64(100.5).valid(), Some(100.0));
1819        assert_eq!(UsedPercent::from_f64(250.0).valid(), Some(100.0));
1820        assert_eq!(UsedPercent::from_f64(1000.0).valid(), Some(100.0));
1821        assert_eq!(UsedPercent::from_f64(1000.5), UsedPercent::unknown("out of range"));
1822        assert_eq!(UsedPercent::from_f64(-0.1), UsedPercent::unknown("out of range"));
1823        assert_eq!(UsedPercent::from_f64(f64::NAN), UsedPercent::unknown("not a number"));
1824        assert_eq!(UsedPercent::from_f64(f64::INFINITY), UsedPercent::unknown("not a number"));
1825        assert_eq!(UsedPercent::from_json(None), UsedPercent::unknown("missing"));
1826        assert_eq!(UsedPercent::from_json(Some(&Value::Null)), UsedPercent::unknown("null"));
1827        assert_eq!(
1828            UsedPercent::from_json(Some(&serde_json::json!("abc"))),
1829            UsedPercent::unknown("not a number")
1830        );
1831        assert_eq!(UsedPercent::from_json(Some(&serde_json::json!("42.5"))).valid(), Some(42.5));
1832        assert_eq!(UsedPercent::from_ratio(Some(5.0), Some(0.0)), UsedPercent::unknown("limit is zero"));
1833        assert_eq!(UsedPercent::from_ratio(Some(25.0), Some(100.0)).valid(), Some(25.0));
1834    }
1835
1836    #[test]
1837    fn used_percent_json_is_tagged_never_bare() {
1838        let v = serde_json::to_value(UsedPercent::Valid { percent: 12.5 }).unwrap();
1839        assert_eq!(v, serde_json::json!({"state":"valid","percent":12.5}));
1840        let u = serde_json::to_value(UsedPercent::unknown("null")).unwrap();
1841        assert_eq!(u, serde_json::json!({"state":"unknown","reason":"null"}));
1842        let back: UsedPercent = serde_json::from_value(v).unwrap();
1843        assert_eq!(back.valid(), Some(12.5));
1844    }
1845
1846    #[test]
1847    fn epoch_helpers() {
1848        assert_eq!(epoch_ms_from_rfc3339("2025-09-20T20:26:40Z"), Some(T0));
1849        assert_eq!(epoch_ms_from_rfc3339("2025-09-20T22:26:40+02:00"), Some(T0));
1850        assert_eq!(epoch_ms_from_rfc3339("not a date"), None);
1851        assert_eq!(epoch_ms_from_value(Some(&serde_json::json!(1_758_400_000))), Some(T0));
1852        assert_eq!(epoch_ms_from_value(Some(&serde_json::json!(1_758_400_000_000u64))), Some(T0));
1853        assert_eq!(epoch_ms_from_value(Some(&serde_json::json!("1758400000"))), Some(T0));
1854        assert_eq!(epoch_ms_from_value(Some(&Value::Null)), None);
1855        assert_eq!(epoch_ms_from_value(Some(&serde_json::json!(-5))), None);
1856        assert_eq!(epoch_ms_from_value(None), None);
1857        assert_eq!(duration_label(18000), "5h");
1858        assert_eq!(duration_label(604800), "7d");
1859        assert_eq!(duration_label(300), "5m");
1860        assert_eq!(duration_label(90), "90s");
1861    }
1862
1863    // ── Anthropic ───────────────────────────────────────────────────────
1864
1865    const ANTHROPIC_FIXTURE: &str = r#"{
1866        "five_hour": {"utilization": 12.5, "resets_at": "2025-09-20T22:00:00Z"},
1867        "seven_day": {"utilization": 60, "resets_at": "2025-09-24T00:00:00Z"},
1868        "seven_day_oauth_apps": {"utilization": 3, "resets_at": "2025-09-24T00:00:00Z"},
1869        "seven_day_opus": null,
1870        "seven_day_sonnet": {"utilization": 100, "resets_at": "2025-09-24T00:00:00Z"},
1871        "extra_usage": {"is_enabled": false}
1872    }"#;
1873
1874    #[test]
1875    fn anthropic_valid_fixture() {
1876        let s = parse_anthropic_usage(ANTHROPIC_FIXTURE, "default", T0).unwrap();
1877        assert_eq!(s.provider, "anthropic");
1878        assert_eq!(s.account, "default");
1879        assert_eq!(s.observed_at, T0);
1880        assert_eq!(s.windows.len(), 4);
1881        let fh = s.window("five_hour").unwrap();
1882        assert_eq!(fh.duration_secs, Some(18000));
1883        assert_eq!(fh.label, "5h");
1884        assert_eq!(fh.used_percent.valid(), Some(12.5));
1885        assert_eq!(fh.reset_at, epoch_ms_from_rfc3339("2025-09-20T22:00:00Z"));
1886        assert_eq!(fh.scope, WindowScope::Account);
1887        let sd = s.window("seven_day").unwrap();
1888        assert_eq!(sd.duration_secs, Some(604800));
1889        assert_eq!(sd.scope, WindowScope::Account);
1890        assert_eq!(
1891            s.window("seven_day_oauth_apps").unwrap().scope,
1892            WindowScope::Feature { feature: "oauth_apps".into() }
1893        );
1894        let sonnet = s.window("seven_day_sonnet").unwrap();
1895        assert_eq!(sonnet.scope, WindowScope::Model { model: "sonnet".into() });
1896        assert!(sonnet.is_exhausted());
1897        assert_eq!(s.model_availability.len(), 1);
1898        assert_eq!(s.model_availability[0].model, "sonnet");
1899        assert_eq!(s.model_availability[0].availability, Availability::Exhausted);
1900        assert!(s.notes.iter().any(|n| n == "null windows skipped"));
1901        // The fixture's only other entry is a disabled extra_usage: omitted,
1902        // and not reported as an unrecognized field.
1903        assert!(s.window("extra_usage").is_none());
1904        assert!(!s.notes.iter().any(|n| n == "unrecognized fields ignored"));
1905        assert_eq!(s.limit_reached, None);
1906        // Account-level capacity: 12.5% and 60% → proven.
1907        assert!(s.has_proven_capacity(T0 + 1000, 60_000));
1908        assert_eq!(s.longest_account_window().unwrap().id, "seven_day");
1909        assert_eq!(s.earliest_reset_at(), epoch_ms_from_rfc3339("2025-09-20T22:00:00Z"));
1910    }
1911
1912    /// Paid extra usage is a window only when the account has it switched on
1913    /// and reports a numeric utilization (0 counts). Every other shape is
1914    /// omitted with no note, so no status row can show it.
1915    #[test]
1916    fn anthropic_extra_usage_is_kept_only_when_enabled_with_a_numeric_utilization() {
1917        let parse = |extra: &str| {
1918            let body = format!(
1919                r#"{{"five_hour": {{"utilization": 10, "resets_at": "2025-09-20T22:00:00Z"}}, "extra_usage": {extra}}}"#
1920            );
1921            parse_anthropic_usage(&body, "a", T0).unwrap()
1922        };
1923        for omitted in [
1924            r#"{"is_enabled": false, "utilization": 0}"#,
1925            r#"{"is_enabled": false, "utilization": null}"#,
1926            r#"{"is_enabled": false, "monthly_limit": null, "used_credits": null, "utilization": null}"#,
1927            r#"{"is_enabled": false}"#,
1928            r#"{"is_enabled": true, "utilization": null}"#,
1929            r#"{"is_enabled": true, "utilization": "42"}"#,
1930            r#"{"is_enabled": true}"#,
1931            r#"{"is_enabled": "true", "utilization": 7}"#,
1932            r#"{"utilization": 7}"#,
1933            "null",
1934        ] {
1935            let s = parse(omitted);
1936            assert!(s.window("extra_usage").is_none(), "{omitted}");
1937            assert_eq!(s.windows.len(), 1, "{omitted}");
1938            assert!(s.notes.is_empty(), "{omitted}: {:?}", s.notes);
1939        }
1940        for (shown, percent) in [
1941            (r#"{"is_enabled": true, "utilization": 0}"#, 0.0),
1942            (
1943                r#"{"is_enabled": true, "monthly_limit": 5000, "used_credits": 2100, "utilization": 42}"#,
1944                42.0,
1945            ),
1946        ] {
1947            let s = parse(shown);
1948            let w = s.window("extra_usage").unwrap_or_else(|| panic!("{shown}"));
1949            assert_eq!(w.used_percent.valid(), Some(percent), "{shown}");
1950            assert_eq!(w.scope, WindowScope::Feature { feature: "extra_usage".into() });
1951            assert!(s.notes.is_empty(), "{shown}: {:?}", s.notes);
1952        }
1953        // Any other unrecognized entry is still noted.
1954        let s = parse_anthropic_usage(
1955            r#"{"five_hour": {"utilization": 10}, "extra_usage": {"is_enabled": false}, "new_counter": 3}"#,
1956            "a",
1957            T0,
1958        )
1959        .unwrap();
1960        assert!(s.notes.iter().any(|n| n == "unrecognized fields ignored"));
1961    }
1962
1963    #[test]
1964    fn anthropic_missing_null_malformed_percent_and_reset() {
1965        let body = r#"{
1966            "five_hour": {"resets_at": "2025-09-20T22:00:00Z"},
1967            "seven_day": {"utilization": null, "resets_at": null},
1968            "seven_day_sonnet": {"utilization": "lots", "resets_at": "yesterday"},
1969            "seven_day_opus": {"utilization": 250, "resets_at": 1758400000},
1970            "one_day": {"utilization": -1}
1971        }"#;
1972        let s = parse_anthropic_usage(body, "a", T0).unwrap();
1973        assert_eq!(s.window("five_hour").unwrap().used_percent, UsedPercent::unknown("missing"));
1974        assert_eq!(s.window("seven_day").unwrap().used_percent, UsedPercent::unknown("null"));
1975        assert_eq!(s.window("seven_day").unwrap().reset_at, None);
1976        let sonnet = s.window("seven_day_sonnet").unwrap();
1977        assert_eq!(sonnet.used_percent, UsedPercent::unknown("not a number"));
1978        assert_eq!(sonnet.reset_at, None);
1979        assert!(s.notes.iter().any(|n| n == "reset timestamp unparseable"));
1980        let opus = s.window("seven_day_opus").unwrap();
1981        assert_eq!(opus.used_percent.valid(), Some(100.0), "overrun is exhausted");
1982        assert_eq!(opus.reset_at, Some(T0), "unix seconds accepted");
1983        assert_eq!(s.window("one_day").unwrap().duration_secs, Some(86400));
1984        assert_eq!(s.window("one_day").unwrap().used_percent, UsedPercent::unknown("out of range"));
1985        // Unknown is not capacity.
1986        assert!(!s.has_proven_capacity(T0, 60_000));
1987        // The overrun opus window is exhausted; the unparseable rest is unknown.
1988        assert!(s.model_availability.iter().all(|m| m.availability
1989            == if m.model.contains("opus") {
1990                Availability::Exhausted
1991            } else {
1992                Availability::Unknown
1993            }));
1994        // Notes never carry raw values.
1995        secret_free(&format!("{:?}", s.notes), &["lots", "yesterday"]);
1996    }
1997
1998    #[test]
1999    fn anthropic_rejects_non_json_and_non_object() {
2000        assert_eq!(
2001            parse_anthropic_usage("<html>", "a", T0).unwrap_err(),
2002            UsageError::Malformed { detail: "body is not JSON".into() }
2003        );
2004        assert_eq!(
2005            parse_anthropic_usage("[1,2]", "a", T0).unwrap_err(),
2006            UsageError::Malformed { detail: "top-level value is not an object".into() }
2007        );
2008        let empty = parse_anthropic_usage("{}", "a", T0).unwrap();
2009        assert!(empty.windows.is_empty());
2010        assert!(empty.notes.iter().any(|n| n == "no usage windows in response"));
2011        assert!(!empty.has_proven_capacity(T0, 60_000));
2012    }
2013
2014    // ── Codex ───────────────────────────────────────────────────────────
2015
2016    fn codex_fixture(secondary: &str) -> String {
2017        format!(
2018            r#"{{
2019            "plan_type": "pro",
2020            "account_id": "acct_1234567890",
2021            "rate_limit": {{
2022                "allowed": true,
2023                "limit_reached": false,
2024                "primary_window": {{"used_percent": 37, "limit_window_seconds": 604800,
2025                                    "reset_after_seconds": 100000, "reset_at": 1758500000}},
2026                "secondary_window": {secondary}
2027            }},
2028            "code_review_rate_limit": {{
2029                "allowed": true, "limit_reached": true,
2030                "primary_window": {{"used_percent": 100, "limit_window_seconds": 18000, "reset_after_seconds": 120}}
2031            }},
2032            "additional_rate_limits": [
2033                {{"limit_name": "codex-auto-review", "metered_feature": "auto_review",
2034                  "rate_limit": {{"allowed": true, "limit_reached": false,
2035                                 "primary_window": {{"used_percent": 5, "limit_window_seconds": 86400, "reset_at": 1758450000}}}}}},
2036                {{"limit_name": "gpt-5-mini", "normal_model_slug": "gpt-5-mini",
2037                  "rate_limit": {{"limit_reached": false,
2038                                 "primary_window": {{"used_percent": 99.5, "limit_window_seconds": 18000}}}}}}
2039            ],
2040            "credits": {{"has_credits": true, "unlimited": false, "balance": "12.50"}},
2041            "rate_limit_reset_credits": {{"available_count": 2}}
2042        }}"#
2043        )
2044    }
2045
2046    #[test]
2047    fn codex_weekly_primary_and_null_secondary() {
2048        let s = parse_codex_usage(&codex_fixture("null"), "astra2", T0, Some("acct_1234567890")).unwrap();
2049        assert_eq!(s.provider, "openai-codex");
2050        assert_eq!(s.account, "astra2");
2051        assert_eq!(s.plan.as_deref(), Some("pro"));
2052        assert_eq!(s.identity_prefix.as_deref(), Some("acct_123"));
2053        assert_eq!(s.seat_fingerprint,
2054            super::super::seat_fingerprint(OAuthProviderId::OpenAiCodex, "acct_1234567890"));
2055        let unpaired = parse_codex_usage(&codex_fixture("null"), "astra2", T0, None).unwrap();
2056        assert!(unpaired.seat_fingerprint.is_none());
2057        assert_eq!(s.limit_reached, Some(false));
2058        let p = s.window("primary").unwrap();
2059        assert_eq!(p.duration_secs, Some(604800), "primary is weekly, not assumed 5h");
2060        assert_eq!(p.label, "7d");
2061        assert_eq!(p.used_percent.valid(), Some(37.0));
2062        assert_eq!(p.reset_at, Some(1_758_500_000_000), "provider reset_at is authoritative");
2063        assert_eq!(p.limit_reached, Some(false));
2064        assert!(s.window("secondary").is_none());
2065        assert!(s.notes.iter().any(|n| n == "null window skipped"));
2066        // Feature-scoped code review window with reset derived from reset_after_seconds.
2067        let cr = s.window("code_review.primary").unwrap();
2068        assert_eq!(cr.scope, WindowScope::Feature { feature: "code_review".into() });
2069        assert_eq!(cr.reset_at, Some(T0 + 120_000));
2070        assert_eq!(cr.limit_reached, Some(true));
2071        assert!(cr.is_exhausted());
2072        assert!(s.notes.iter().any(|n| n == "reset derived from reset_after_seconds"));
2073        // Additional limits: feature + model scopes.
2074        assert_eq!(
2075            s.window("codex-auto-review.primary").unwrap().scope,
2076            WindowScope::Feature { feature: "codex-auto-review".into() }
2077        );
2078        let mini = s.window("gpt-5-mini.primary").unwrap();
2079        assert_eq!(mini.scope, WindowScope::Model { model: "gpt-5-mini".into() });
2080        assert_eq!(s.model_availability.len(), 1);
2081        assert_eq!(s.model_availability[0].availability, Availability::Available);
2082        // Credits and banked resets.
2083        let c = s.credits.as_ref().unwrap();
2084        assert_eq!(c.kind, "codex_credits");
2085        assert_eq!(c.remaining, Some(12.5));
2086        assert_eq!(c.has_credits, Some(true));
2087        let b = s.banked_resets.as_ref().unwrap();
2088        assert_eq!(b.available_count, Some(2));
2089        assert!(b.credits.is_empty());
2090        assert_eq!(b.inventory_error, None);
2091        // Only account-scoped windows count toward capacity: the exhausted
2092        // code-review feature window must not veto.
2093        assert!(s.has_proven_capacity(T0, 60_000));
2094        assert_eq!(s.longest_account_window().unwrap().id, "primary");
2095    }
2096
2097    #[test]
2098    fn codex_secondary_present_and_limit_reached_blocks_capacity() {
2099        let body = codex_fixture(
2100            r#"{"used_percent": 100, "limit_window_seconds": 18000, "reset_at": 1758410000}"#,
2101        )
2102        .replace(r#""limit_reached": false,
2103                "primary_window""#, r#""limit_reached": true,
2104                "primary_window""#);
2105        let s = parse_codex_usage(&body, "x", T0, None).unwrap();
2106        let sec = s.window("secondary").unwrap();
2107        assert_eq!(sec.duration_secs, Some(18000));
2108        assert!(sec.is_exhausted());
2109        assert_eq!(s.limit_reached, Some(true));
2110        assert!(!s.has_proven_capacity(T0, 60_000));
2111        assert_eq!(s.earliest_reset_at(), Some(T0 + 120_000));
2112    }
2113
2114    #[test]
2115    fn codex_identity_mismatch_fails_closed_and_absent_id_is_tolerated() {
2116        let err = parse_codex_usage(&codex_fixture("null"), "x", T0, Some("acct_other")).unwrap_err();
2117        assert_eq!(err, UsageError::IdentityMismatch);
2118        assert!(err.is_auth_failure());
2119        // Response without account_id: prefix comes from the token-derived id.
2120        let body = codex_fixture("null").replace(r#""account_id": "acct_1234567890","#, "");
2121        let s = parse_codex_usage(&body, "x", T0, Some("acct_zzzzzzzzz")).unwrap();
2122        assert_eq!(s.identity_prefix.as_deref(), Some("acct_zzz"));
2123    }
2124
2125    #[test]
2126    fn codex_missing_and_malformed_fields_stay_unknown() {
2127        let body = r#"{
2128            "rate_limit": {
2129                "primary_window": {"used_percent": null, "limit_window_seconds": "week", "reset_at": "soon"},
2130                "secondary_window": {"used_percent": 101}
2131            },
2132            "rate_limit_reset_credits": null,
2133            "credits": {"has_credits": "yes"}
2134        }"#;
2135        let s = parse_codex_usage(body, "x", T0, None).unwrap();
2136        let p = s.window("primary").unwrap();
2137        assert_eq!(p.used_percent, UsedPercent::unknown("null"));
2138        assert_eq!(p.duration_secs, None);
2139        assert_eq!(p.label, "unknown");
2140        assert_eq!(p.reset_at, None);
2141        assert_eq!(s.window("secondary").unwrap().used_percent.valid(), Some(100.0));
2142        assert_eq!(s.limit_reached, None);
2143        assert!(s.banked_resets.is_none());
2144        assert_eq!(s.credits.as_ref().unwrap().has_credits, None);
2145        assert!(!s.has_proven_capacity(T0, 60_000));
2146        secret_free(&format!("{:?}", s.notes), &["soon", "week"]);
2147
2148        let none = parse_codex_usage(r#"{"plan_type": "plus"}"#, "x", T0, None).unwrap();
2149        assert!(none.windows.is_empty());
2150        assert!(none.notes.iter().any(|n| n == "rate_limit missing"));
2151        assert!(!none.has_proven_capacity(T0, 60_000));
2152    }
2153
2154    /// Shape observed in a real prior `/wham/usage` response: generic quota is
2155    /// nearly unused, yet the premium model is gated with an `available_at`
2156    /// and a hint that purchased credits would unlock it.
2157    const CODEX_ASTRA_FIXTURE: &str = r#"{
2158        "plan_type": "pro",
2159        "rate_limit": {
2160            "allowed": true, "limit_reached": false,
2161            "primary_window": {"used_percent": 10, "limit_window_seconds": 604800, "reset_at": 1758900000},
2162            "secondary_window": {"used_percent": 4, "limit_window_seconds": 18000, "reset_at": 1758410000}
2163        },
2164        "model_usage": {
2165            "gpt-6-astra": {"available": false, "available_at": "2026-01-05T09:00:00Z", "credits_would_enable": true},
2166            "gpt-5.1-codex": {"available": true},
2167            "gpt-5-mini": {"available": "maybe", "available_at": "later"},
2168            "broken": 7
2169        },
2170        "spend_control": {"reached": false,
2171                          "workspace": {"reached": false, "limit": 500, "remaining_percent": 80, "resets_at": 1759276800}},
2172        "credits": {"has_credits": false, "unlimited": false, "balance": "0"}
2173    }"#;
2174
2175    #[test]
2176    fn codex_model_usage_gates_astra_while_generic_quota_has_room() {
2177        let s = parse_codex_usage(CODEX_ASTRA_FIXTURE, "astra1", T0, None).unwrap();
2178        // Generic (account) capacity is proven: 10% weekly, 4% five-hour.
2179        assert!(s.has_proven_capacity(T0, 60_000));
2180        // …but the gated premium model is NOT available on the subscription.
2181        let astra = s.model_availability_for("gpt-6-astra").unwrap();
2182        assert_eq!(astra.availability, Availability::Exhausted);
2183        assert_eq!(astra.reset_at, epoch_ms_from_rfc3339("2026-01-05T09:00:00Z"));
2184        assert_eq!(astra.credits_would_enable, Some(true));
2185        assert_eq!(astra.source, "model_usage");
2186        assert_eq!(astra.used_percent, UsedPercent::unknown("missing"));
2187        assert!(!s.has_proven_capacity_for_model("gpt-6-astra", T0, 60_000));
2188        assert!(!s.has_proven_capacity_for_model("GPT-6-Astra", T0, 60_000), "case-insensitive");
2189        // An explicitly available model passes; an unmentioned model falls back to account capacity.
2190        assert!(s.has_proven_capacity_for_model("gpt-5.1-codex", T0, 60_000));
2191        assert!(s.has_proven_capacity_for_model("gpt-5.1-codex-max", T0, 60_000), "unmentioned → account capacity");
2192        // Tri-state: non-boolean `available` is Unknown, and Unknown is not capacity.
2193        let mini = s.model_availability_for("gpt-5-mini").unwrap();
2194        assert_eq!(mini.availability, Availability::Unknown);
2195        assert_eq!(mini.reset_at, None);
2196        assert!(!s.has_proven_capacity_for_model("gpt-5-mini", T0, 60_000));
2197        assert!(s.notes.iter().any(|n| n == "reset timestamp unparseable"));
2198        assert!(s.notes.iter().any(|n| n == "model usage entry is not an object"));
2199        secret_free(&format!("{:?}", s.notes), &["maybe", "later", "broken"]);
2200        // Spend control not reached; nested limit becomes a feature window.
2201        assert_eq!(s.spend_control_reached, Some(false));
2202        let sc = s.window("spend_control").unwrap();
2203        assert_eq!(sc.used_percent.valid(), Some(20.0));
2204        assert_eq!(sc.reset_at, Some(1_759_276_800_000));
2205        assert_eq!(sc.reset_kind, ResetKind::BillingRenewal);
2206        // The gated model's available_at participates in earliest_reset_at only
2207        // if earlier than the windows; here the 5h window is earlier.
2208        assert_eq!(s.earliest_reset_at(), Some(1_758_410_000_000));
2209        // JSON carries the gating so keeper/runtime consumers can see it.
2210        let v = serde_json::to_value(&s).unwrap();
2211        let astra_json = v["model_availability"]
2212            .as_array()
2213            .unwrap()
2214            .iter()
2215            .find(|m| m["model"] == "gpt-6-astra")
2216            .expect("astra entry serialized");
2217        assert_eq!(astra_json["availability"], "exhausted");
2218        assert_eq!(astra_json["credits_would_enable"], true);
2219        assert_eq!(astra_json["source"], "model_usage");
2220    }
2221
2222    #[test]
2223    fn codex_spend_control_reached_blocks_capacity() {
2224        let top = CODEX_ASTRA_FIXTURE.replace(r#""spend_control": {"reached": false,"#, r#""spend_control": {"reached": true,"#);
2225        let s = parse_codex_usage(&top, "a", T0, None).unwrap();
2226        assert_eq!(s.spend_control_reached, Some(true));
2227        assert!(!s.has_proven_capacity(T0, 60_000));
2228        assert!(!s.has_proven_capacity_for_model("gpt-5.1-codex", T0, 60_000));
2229        // Nested `reached: true` alone also trips it (fail closed).
2230        let nested = CODEX_ASTRA_FIXTURE.replace(r#""workspace": {"reached": false,"#, r#""workspace": {"reached": true,"#);
2231        let s = parse_codex_usage(&nested, "a", T0, None).unwrap();
2232        assert_eq!(s.spend_control_reached, Some(true));
2233        assert!(!s.has_proven_capacity(T0, 60_000));
2234        // Absent spend_control → None (unknown, not asserted) and does not block by itself.
2235        let s = parse_codex_usage(&codex_fixture("null"), "a", T0, None).unwrap();
2236        assert_eq!(s.spend_control_reached, None);
2237        assert!(s.has_proven_capacity(T0, 60_000));
2238    }
2239
2240    #[test]
2241    fn model_availability_merge_never_overwrites_explicit_entry() {
2242        // Explicit model_usage says available; a model-scoped window says exhausted → Exhausted (fail closed),
2243        // window percent fills in, explicit available_at is kept.
2244        let body = r#"{
2245            "rate_limit": {"limit_reached": false, "primary_window": {"used_percent": 1, "limit_window_seconds": 18000}},
2246            "model_usage": {"gpt-5-mini": {"available": true, "available_at": "2025-09-21T00:00:00Z"},
2247                            "gpt-6-astra": {"available": true}},
2248            "additional_rate_limits": [
2249                {"limit_name": "mini", "normal_model_slug": "gpt-5-mini",
2250                 "rate_limit": {"primary_window": {"used_percent": 100, "reset_at": 1758500000}}}
2251            ]
2252        }"#;
2253        let s = parse_codex_usage(body, "a", T0, None).unwrap();
2254        assert_eq!(s.model_availability.len(), 2, "merged, not duplicated");
2255        let mini = s.model_availability_for("gpt-5-mini").unwrap();
2256        assert_eq!(mini.availability, Availability::Exhausted);
2257        assert_eq!(mini.used_percent.valid(), Some(100.0));
2258        assert_eq!(mini.reset_at, epoch_ms_from_rfc3339("2025-09-21T00:00:00Z"), "explicit reset kept");
2259        assert_eq!(mini.source, "merged");
2260        let astra = s.model_availability_for("gpt-6-astra").unwrap();
2261        assert_eq!(astra.availability, Availability::Available);
2262        assert_eq!(astra.source, "model_usage");
2263        assert!(!s.has_proven_capacity_for_model("gpt-5-mini", T0, 60_000));
2264        assert!(s.has_proven_capacity_for_model("gpt-6-astra", T0, 60_000));
2265        // Family-token matching for Anthropic-style entries.
2266        let a = parse_anthropic_usage(ANTHROPIC_FIXTURE, "d", T0).unwrap();
2267        assert!(!a.has_proven_capacity_for_model("claude-sonnet-4-5", T0, 60_000), "sonnet window is at 100%");
2268        assert!(a.has_proven_capacity_for_model("claude-opus-4-1", T0, 60_000), "opus window was null → unmentioned");
2269        assert!(a.model_availability_for("sonnetx").is_none(), "token match, not substring");
2270        assert!(a.model_availability_for("claude-3-7-sonnet-latest").is_some());
2271    }
2272
2273    #[test]
2274    fn codex_reset_credit_inventory_parses_details() {
2275        let body = r#"{"available_count": 1, "credits": [
2276            {"credit_id": "c1", "reset_type": "weekly", "granted_at": 1758300000, "expires_at": "2025-10-20T00:00:00Z"},
2277            {"reset_type": null, "expires_at": "garbage"}
2278        ]}"#;
2279        let b = parse_codex_reset_credits(body).unwrap();
2280        assert_eq!(b.available_count, Some(1));
2281        assert_eq!(b.credits.len(), 2);
2282        assert_eq!(b.credits[0].reset_type.as_deref(), Some("weekly"));
2283        assert_eq!(b.credits[0].granted_at, Some(1_758_300_000_000));
2284        assert_eq!(b.credits[0].expires_at, epoch_ms_from_rfc3339("2025-10-20T00:00:00Z"));
2285        assert_eq!(b.credits[1].expires_at, None);
2286        assert!(parse_codex_reset_credits("[]").is_err());
2287        let unknown = parse_codex_reset_credits("{}").unwrap();
2288        assert_eq!(unknown.available_count, None, "unknown is not zero");
2289    }
2290
2291    // ── Kimi ────────────────────────────────────────────────────────────
2292
2293    const KIMI_FIXTURE: &str = r#"{
2294        "usage": {"limit": "1000", "used": "250", "remaining": "750", "resetTime": "2025-09-24T00:00:00Z"},
2295        "limits": [
2296            {"window": {"duration": 300, "timeUnit": "TIME_UNIT_MINUTE"},
2297             "detail": {"limit": 100, "used": 100, "resetTime": "2025-09-20T22:00:00Z"}},
2298            {"name": "weekly", "window": {"duration": 1, "timeUnit": "TIME_UNIT_WEEK"},
2299             "detail": {"limit": "0", "used": "0"}},
2300            {"window": {"duration": 1, "timeUnit": "TIME_UNIT_FORTNIGHT"},
2301             "detail": {"limit": 10, "used": "many", "resetTime": 1758400000}}
2302        ],
2303        "boosterWallet": {
2304            "balance": {"type": "BOOSTER", "amount": 2000000000, "amountLeft": 500000000},
2305            "monthlyChargeLimit": {"priceInCents": 5000, "currency": "USD"}
2306        }
2307    }"#;
2308
2309    #[test]
2310    fn kimi_valid_fixture() {
2311        let s = parse_kimi_usage(KIMI_FIXTURE, "m27", T0).unwrap();
2312        assert_eq!(s.provider, "kimi-code");
2313        let summary = s.window("usage").unwrap();
2314        assert_eq!(summary.duration_secs, Some(604800), "summary implies one week");
2315        assert_eq!(summary.used_percent.valid(), Some(25.0));
2316        assert_eq!(summary.used, Some(250.0));
2317        assert_eq!(summary.limit, Some(1000.0));
2318        assert_eq!(summary.reset_at, epoch_ms_from_rfc3339("2025-09-24T00:00:00Z"));
2319        let w0 = s.window("limits[0]").unwrap();
2320        assert_eq!(w0.duration_secs, Some(18000), "300 minutes = 5h");
2321        assert_eq!(w0.label, "5h");
2322        assert!(w0.is_exhausted());
2323        let w1 = s.window("limits[1]").unwrap();
2324        assert_eq!(w1.label, "weekly");
2325        assert_eq!(w1.used_percent, UsedPercent::unknown("limit is zero"));
2326        let w2 = s.window("limits[2]").unwrap();
2327        assert_eq!(w2.duration_secs, None, "unknown time unit → unknown duration");
2328        assert_eq!(w2.used_percent, UsedPercent::unknown("missing"), "non-numeric used");
2329        assert_eq!(w2.reset_at, Some(T0));
2330        // Exhausted 5h window + unknown windows → no proven capacity.
2331        assert!(!s.has_proven_capacity(T0, 60_000));
2332        let c = s.credits.as_ref().unwrap();
2333        assert_eq!(c.kind, "kimi_booster");
2334        assert_eq!(c.total, Some(20.0));
2335        assert_eq!(c.remaining, Some(5.0));
2336        assert_eq!(c.unit.as_deref(), Some("USD"));
2337        assert_eq!(c.has_credits, Some(true));
2338    }
2339
2340    #[test]
2341    fn kimi_missing_and_malformed() {
2342        let s = parse_kimi_usage(r#"{"usage": {"remaining": "5"}, "limits": "nope"}"#, "x", T0).unwrap();
2343        assert!(s.windows.is_empty());
2344        assert!(s.notes.iter().any(|n| n == "summary usage row unparseable"));
2345        assert!(s.notes.iter().any(|n| n == "limits field is not an array"));
2346        assert!(s.notes.iter().any(|n| n == "no usage windows in response"));
2347        assert!(parse_kimi_usage("null", "x", T0).is_err());
2348        let ok = parse_kimi_usage(
2349            r#"{"limits": [{"window": {"duration": 5, "timeUnit": "TIME_UNIT_HOUR"}, "detail": {"limit": 100, "used": 10}}]}"#,
2350            "x",
2351            T0,
2352        )
2353        .unwrap();
2354        assert!(ok.has_proven_capacity(T0, 60_000));
2355        assert_eq!(ok.window("limits[0]").unwrap().reset_at, None);
2356    }
2357
2358    // ── Grok ────────────────────────────────────────────────────────────
2359
2360    #[test]
2361    fn grok_quotakit_shape_with_percent() {
2362        let body = r#"{"config": {"creditUsagePercent": 42.5, "billingPeriodMinutes": 43200,
2363                                   "currentPeriod": {"start": "2025-09-01T00:00:00Z", "end": "2025-10-01T00:00:00Z"}},
2364                       "onDemandUsed": {"val": 425}, "onDemandCap": {"val": 1000}}"#;
2365        let s = parse_grok_billing(body, "default", T0).unwrap();
2366        assert_eq!(s.provider, "xai-auth");
2367        let w = s.window("credits").unwrap();
2368        assert_eq!(w.used_percent.valid(), Some(42.5));
2369        assert_eq!(w.duration_secs, Some(43200 * 60));
2370        assert_eq!(w.label, "30d");
2371        assert_eq!(w.reset_at, epoch_ms_from_rfc3339("2025-10-01T00:00:00Z"));
2372        assert_eq!(w.reset_kind, ResetKind::BillingRenewal);
2373        assert_eq!(w.used, Some(425.0));
2374        assert_eq!(w.limit, Some(1000.0));
2375        let c = s.credits.as_ref().unwrap();
2376        assert_eq!(c.remaining, Some(575.0));
2377        assert_eq!(c.renews_at, w.reset_at);
2378        assert!(s.has_proven_capacity(T0, 60_000));
2379        assert!(s.notes.iter().any(|n| n.contains("unverified")));
2380    }
2381
2382    #[test]
2383    fn grok_fallback_ratio_and_billing_period_end() {
2384        let body = r#"{"onDemandUsed": {"val": "250"}, "onDemandCap": {"val": 1000},
2385                       "config": {"billingPeriodEnd": 1759276800}}"#;
2386        let s = parse_grok_billing(body, "x", T0).unwrap();
2387        let w = s.window("credits").unwrap();
2388        assert_eq!(w.used_percent.valid(), Some(25.0));
2389        assert_eq!(w.reset_at, Some(1_759_276_800_000));
2390        assert_eq!(w.label, "billing period");
2391        assert!(s.notes.iter().any(|n| n == "percent derived from on-demand counters"));
2392    }
2393
2394    #[test]
2395    fn grok_unknown_when_no_fraction_or_schema() {
2396        // Period known but no fraction → window exists, percent unknown.
2397        let s = parse_grok_billing(r#"{"config": {"billingPeriodMinutes": 1440}}"#, "x", T0).unwrap();
2398        let w = s.window("credits").unwrap();
2399        assert_eq!(w.used_percent, UsedPercent::unknown("missing"));
2400        assert!(!s.has_proven_capacity(T0, 60_000));
2401        // An overrun percent is exhausted, not unknown.
2402        let s = parse_grok_billing(r#"{"config": {"creditUsagePercent": 140}}"#, "x", T0).unwrap();
2403        assert_eq!(s.window("credits").unwrap().used_percent.valid(), Some(100.0));
2404        // Negative stays unknown.
2405        let s = parse_grok_billing(r#"{"config": {"creditUsagePercent": -5}}"#, "x", T0).unwrap();
2406        assert_eq!(s.window("credits").unwrap().used_percent, UsedPercent::unknown("out of range"));
2407        // Zero cap → limit is zero.
2408        let s = parse_grok_billing(r#"{"onDemandUsed": {"val": 0}, "onDemandCap": {"val": 0}}"#, "x", T0).unwrap();
2409        assert_eq!(s.window("credits").unwrap().used_percent, UsedPercent::unknown("limit is zero"));
2410        // Entirely unrecognized → no windows, fixed-vocabulary note, no key names.
2411        let s = parse_grok_billing(r#"{"sk_live_secretkey": 1, "mystery": {"x": 2}}"#, "x", T0).unwrap();
2412        assert!(s.windows.is_empty());
2413        assert!(s.notes.iter().any(|n| n == "unrecognized billing schema"));
2414        secret_free(&serde_json::to_string(&s).unwrap(), &["sk_live", "mystery"]);
2415        assert!(parse_grok_billing("", "x", T0).is_err());
2416    }
2417
2418    // ── Snapshot helpers / serialization ────────────────────────────────
2419
2420    #[test]
2421    fn staleness_and_json_round_trip() {
2422        let s = parse_codex_usage(&codex_fixture("null"), "a", T0, None).unwrap();
2423        assert!(!s.is_stale(T0 + 1000, 5_000));
2424        assert!(s.is_stale(T0 + 6_000, 5_000));
2425        assert!(s.is_stale(T0 - 1, 5_000), "future observation is stale");
2426        assert!(!s.has_proven_capacity(T0 + 10_000, 5_000), "stale is not capacity");
2427        let json = serde_json::to_string_pretty(&s).unwrap();
2428        let back: UsageSnapshot = serde_json::from_str(&json).unwrap();
2429        assert_eq!(back, s);
2430        let v: Value = serde_json::from_str(&json).unwrap();
2431        assert_eq!(v["schema_version"], SCHEMA_VERSION);
2432        assert_eq!(v["windows"][0]["used_percent"]["state"], "valid");
2433        assert_eq!(v["windows"][0]["scope"]["kind"], "account");
2434        assert_eq!(v["windows"][0]["reset_kind"], "quota_window");
2435    }
2436
2437    #[test]
2438    fn usage_entry_accepts_older_reports_without_identity() {
2439        let entry: AccountUsageEntry = serde_json::from_value(serde_json::json!({
2440            "provider": "anthropic", "account": "default", "status": "error",
2441            "error": {"kind": "credential", "message": "unavailable", "http_status": null}
2442        })).unwrap();
2443        assert_eq!(entry.identity, None);
2444        assert!(serde_json::to_value(entry).unwrap().get("identity").is_none());
2445    }
2446
2447    #[test]
2448    fn report_round_trip_with_partial_errors() {
2449        let snap = parse_anthropic_usage(ANTHROPIC_FIXTURE, "default", T0).unwrap();
2450        let mut report = UsageReport::new("local", T0);
2451        report.accounts.push(AccountUsageEntry {
2452            provider: "anthropic".into(),
2453            account: "default".into(),
2454            identity: None,
2455            outcome: AccountUsageOutcome::ok(snap),
2456        });
2457        report.accounts.push(AccountUsageEntry {
2458            provider: "openai-codex".into(),
2459            account: "astra2".into(),
2460            identity: None,
2461            outcome: AccountUsageOutcome::error(&UsageError::Unauthorized { status: 401 }),
2462        });
2463        assert_eq!(report.ok_count(), 1);
2464        assert_eq!(report.error_count(), 1);
2465        let v = serde_json::to_value(&report).unwrap();
2466        assert_eq!(v["accounts"][0]["status"], "ok");
2467        assert_eq!(v["accounts"][1]["status"], "error");
2468        assert_eq!(v["accounts"][1]["error"]["kind"], "unauthorized");
2469        assert_eq!(v["accounts"][1]["error"]["http_status"], 401);
2470        let back: UsageReport = serde_json::from_value(v).unwrap();
2471        assert_eq!(back, report);
2472    }
2473
2474    #[test]
2475    fn error_display_and_broker_mapping_are_secret_free() {
2476        let e = UsageError::Transport { detail: "connect".into() };
2477        assert_eq!(e.kind(), "transport");
2478        assert!(matches!(e.clone().into_broker_error(), BrokerError::Transport(_)));
2479        assert!(matches!(
2480            UsageError::Unauthorized { status: 401 }.into_broker_error(),
2481            BrokerError::Credential(_)
2482        ));
2483        assert!(matches!(
2484            UsageError::UnsupportedProvider { provider: "github-copilot".into() }.into_broker_error(),
2485            BrokerError::UnsupportedCapability { capability, .. } if capability == "usage"
2486        ));
2487        assert!(matches!(
2488            UsageError::InvalidEndpointOverride.into_broker_error(),
2489            BrokerError::Denied(_)
2490        ));
2491        let v = serde_json::to_value(UsageError::UpstreamStatus {
2492            status: 502,
2493            retry_after_s: None,
2494        })
2495        .unwrap();
2496        assert_eq!(v, serde_json::json!({"kind":"upstream_status","status":502}));
2497        assert!(!supports_usage(OAuthProviderId::GitHubCopilot));
2498        assert!(!supports_usage(OAuthProviderId::GoogleGemini));
2499        assert_eq!(supported_usage_providers().len(), 4);
2500    }
2501
2502    #[test]
2503    fn endpoint_override_is_loopback_only() {
2504        assert!(is_loopback_url("http://127.0.0.1:8080/usage"));
2505        assert!(is_loopback_url("http://localhost:1/x"));
2506        assert!(is_loopback_url("http://[::1]:9/x"));
2507        assert!(!is_loopback_url("https://api.anthropic.com/api/oauth/usage"));
2508        assert!(!is_loopback_url("http://127.0.0.1.evil.example/x"));
2509        assert!(!is_loopback_url("ftp://127.0.0.1/x"));
2510        assert!(!is_loopback_url("not a url"));
2511        assert_eq!(
2512            resolve_endpoint(CODEX_USAGE_URL, Some("https://attacker.example/usage")).unwrap_err(),
2513            UsageError::InvalidEndpointOverride
2514        );
2515        assert_eq!(resolve_endpoint(CODEX_USAGE_URL, None).unwrap(), CODEX_USAGE_URL);
2516    }
2517
2518    // ── Fake-server transport tests ─────────────────────────────────────
2519
2520    fn codex_token(account_id: Option<&str>) -> String {
2521        let payload = match account_id {
2522            Some(id) => serde_json::json!({"https://api.openai.com/auth": {"chatgpt_account_id": id}}),
2523            None => serde_json::json!({"sub": "nobody"}),
2524        };
2525        format!(
2526            "hdr.{}.sig",
2527            URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap())
2528        )
2529    }
2530
2531    async fn spawn(app: axum::Router) -> String {
2532        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2533        let addr = listener.local_addr().unwrap();
2534        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2535        format!("http://{addr}")
2536    }
2537
2538    fn opts(url: &str) -> UsageFetchOptions {
2539        UsageFetchOptions {
2540            endpoint_override: Some(url.to_string()),
2541            ..Default::default()
2542        }
2543    }
2544
2545    #[tokio::test]
2546    async fn fetch_unsupported_provider_makes_no_request() {
2547        let client = UsageClient::new().unwrap();
2548        let err = fetch_usage(
2549            &client,
2550            OAuthProviderId::GitHubCopilot,
2551            "default",
2552            "tok",
2553            &opts("http://127.0.0.1:9/never"),
2554        )
2555        .await
2556        .unwrap_err();
2557        assert!(matches!(err, UsageError::UnsupportedProvider { .. }));
2558    }
2559
2560    #[tokio::test]
2561    async fn fetch_rejects_non_loopback_override_before_sending() {
2562        let client = UsageClient::new().unwrap();
2563        let err = fetch_usage(
2564            &client,
2565            OAuthProviderId::Anthropic,
2566            "default",
2567            "tok",
2568            &opts("https://evil.example/usage"),
2569        )
2570        .await
2571        .unwrap_err();
2572        assert_eq!(err, UsageError::InvalidEndpointOverride);
2573    }
2574
2575    #[tokio::test]
2576    async fn fetch_anthropic_sends_bearer_and_beta_header() {
2577        use axum::{extract::Request, routing::get};
2578        let app = axum::Router::new().route(
2579            "/usage",
2580            get(|req: Request| async move {
2581                let h = req.headers();
2582                assert_eq!(h.get("authorization").unwrap(), "Bearer anthropic-access-token");
2583                assert_eq!(h.get("anthropic-beta").unwrap(), ANTHROPIC_OAUTH_BETA);
2584                assert_eq!(h.get("accept").unwrap(), "application/json");
2585                assert!(h.get("user-agent").unwrap().to_str().unwrap().starts_with("SynapsCLI/"));
2586                ([("content-type", "application/json")], ANTHROPIC_FIXTURE)
2587            }),
2588        );
2589        let url = spawn(app).await;
2590        let client = UsageClient::new().unwrap();
2591        let s = fetch_usage(
2592            &client,
2593            OAuthProviderId::Anthropic,
2594            "default",
2595            "anthropic-access-token",
2596            &opts(&format!("{url}/usage")),
2597        )
2598        .await
2599        .unwrap();
2600        assert_eq!(s.windows.len(), 4);
2601        assert!(s.observed_at >= T0);
2602        secret_free(&serde_json::to_string(&s).unwrap(), &["anthropic-access-token"]);
2603    }
2604
2605    #[tokio::test]
2606    async fn fetch_codex_pairs_account_header_with_token_and_reads_inventory() {
2607        use axum::{extract::Request, routing::get};
2608        let app = axum::Router::new()
2609            .route(
2610                "/wham/usage",
2611                get(|req: Request| async move {
2612                    let h = req.headers();
2613                    assert_eq!(h.get("authorization").unwrap().to_str().unwrap(), format!("Bearer {}", codex_token(Some("acct_1234567890"))));
2614                    assert_eq!(h.get(CODEX_ACCOUNT_HEADER).unwrap(), "acct_1234567890");
2615                    codex_fixture("null")
2616                }),
2617            )
2618            .route(
2619                "/wham/rate-limit-reset-credits",
2620                get(|req: Request| async move {
2621                    assert_eq!(req.headers().get(CODEX_ACCOUNT_HEADER).unwrap(), "acct_1234567890");
2622                    r#"{"available_count": 3, "credits": [{"reset_type": "weekly", "expires_at": 1760000000}]}"#
2623                }),
2624            );
2625        let url = spawn(app).await;
2626        let client = UsageClient::new().unwrap();
2627        let o = UsageFetchOptions {
2628            endpoint_override: Some(format!("{url}/wham/usage")),
2629            inventory_endpoint_override: Some(format!("{url}/wham/rate-limit-reset-credits")),
2630            include_inventory: true,
2631            ..Default::default()
2632        };
2633        let s = fetch_usage(&client, OAuthProviderId::OpenAiCodex, "astra2", &codex_token(Some("acct_1234567890")), &o)
2634            .await
2635            .unwrap();
2636        let b = s.banked_resets.as_ref().unwrap();
2637        assert_eq!(b.available_count, Some(3), "inventory detail overrides summary count");
2638        assert_eq!(b.credits.len(), 1);
2639        assert_eq!(b.credits[0].expires_at, Some(1_760_000_000_000));
2640        assert_eq!(b.inventory_error, None);
2641    }
2642
2643    #[tokio::test]
2644    async fn fetch_codex_inventory_failure_is_partial_not_total() {
2645        use axum::routing::get;
2646        let app = axum::Router::new()
2647            .route("/wham/usage", get(|| async { codex_fixture("null") }))
2648            .route(
2649                "/wham/rate-limit-reset-credits",
2650                get(|| async { (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "boom-secret-body") }),
2651            );
2652        let url = spawn(app).await;
2653        let client = UsageClient::new().unwrap();
2654        let o = UsageFetchOptions {
2655            endpoint_override: Some(format!("{url}/wham/usage")),
2656            inventory_endpoint_override: Some(format!("{url}/wham/rate-limit-reset-credits")),
2657            include_inventory: true,
2658            ..Default::default()
2659        };
2660        let s = fetch_usage(&client, OAuthProviderId::OpenAiCodex, "a", &codex_token(Some("acct_1234567890")), &o)
2661            .await
2662            .unwrap();
2663        assert_eq!(s.window("primary").unwrap().used_percent.valid(), Some(37.0));
2664        let b = s.banked_resets.as_ref().unwrap();
2665        assert_eq!(b.available_count, Some(2), "summary count survives");
2666        assert_eq!(b.inventory_error.as_deref(), Some("upstream_status"));
2667        secret_free(&serde_json::to_string(&s).unwrap(), &["boom-secret-body"]);
2668    }
2669
2670    #[tokio::test]
2671    async fn fetch_codex_without_account_claim_makes_no_request() {
2672        use axum::routing::get;
2673        let app = axum::Router::new().route(
2674            "/wham/usage",
2675            get(|| async {
2676                if true {
2677                    panic!("request must not be sent without a paired account id");
2678                }
2679                "{}"
2680            }),
2681        );
2682        let url = spawn(app).await;
2683        let client = UsageClient::new().unwrap();
2684        let err = fetch_usage(&client, OAuthProviderId::OpenAiCodex, "a", &codex_token(None), &opts(&format!("{url}/wham/usage")))
2685            .await
2686            .unwrap_err();
2687        assert_eq!(err, UsageError::AccountIdUnavailable);
2688        assert!(matches!(err.into_broker_error(), BrokerError::Credential(_)));
2689    }
2690
2691    #[tokio::test]
2692    async fn fetch_codex_identity_mismatch_from_server() {
2693        use axum::routing::get;
2694        let app = axum::Router::new().route(
2695            "/wham/usage",
2696            get(|| async { codex_fixture("null").replace("acct_1234567890", "acct_someoneelse") }),
2697        );
2698        let url = spawn(app).await;
2699        let client = UsageClient::new().unwrap();
2700        let err = fetch_usage(&client, OAuthProviderId::OpenAiCodex, "a", &codex_token(Some("acct_1234567890")), &opts(&format!("{url}/wham/usage")))
2701            .await
2702            .unwrap_err();
2703        assert_eq!(err, UsageError::IdentityMismatch);
2704    }
2705
2706    #[tokio::test]
2707    async fn fetch_kimi_sends_only_bearer_and_accept() {
2708        use axum::{extract::Request, routing::get};
2709        let app = axum::Router::new().route(
2710            "/coding/v1/usages",
2711            get(|req: Request| async move {
2712                let h = req.headers();
2713                assert_eq!(h.get("authorization").unwrap(), "Bearer kimi-token");
2714                assert!(h.get("x-msh-device-id").is_none(), "no device identity on usage reads");
2715                KIMI_FIXTURE
2716            }),
2717        );
2718        let url = spawn(app).await;
2719        let client = UsageClient::new().unwrap();
2720        let s = fetch_usage(&client, OAuthProviderId::KimiCode, "m27", "kimi-token", &opts(&format!("{url}/coding/v1/usages")))
2721            .await
2722            .unwrap();
2723        assert_eq!(s.window("usage").unwrap().used_percent.valid(), Some(25.0));
2724    }
2725
2726    #[tokio::test]
2727    async fn fetch_grok_sends_token_auth_header() {
2728        use axum::{extract::Request, routing::get};
2729        let app = axum::Router::new().route(
2730            "/v1/billing",
2731            get(|req: Request| async move {
2732                let h = req.headers();
2733                assert_eq!(h.get("authorization").unwrap(), "Bearer grok-token");
2734                assert_eq!(h.get(GROK_TOKEN_AUTH_HEADER).unwrap(), GROK_TOKEN_AUTH_VALUE);
2735                assert_eq!(req.uri().query(), Some("format=credits"));
2736                r#"{"config": {"creditUsagePercent": 10}}"#
2737            }),
2738        );
2739        let url = spawn(app).await;
2740        let client = UsageClient::new().unwrap();
2741        let s = fetch_usage(&client, OAuthProviderId::Xai, "default", "grok-token", &opts(&format!("{url}/v1/billing?format=credits")))
2742            .await
2743            .unwrap();
2744        assert_eq!(s.window("credits").unwrap().used_percent.valid(), Some(10.0));
2745    }
2746
2747    #[tokio::test]
2748    async fn fetch_status_classification_drops_upstream_bodies() {
2749        use axum::routing::get;
2750        let hostile = "{\"error\":\"leak-me-ZZZ\",\"token\":\"sk-ant-secret\"}";
2751        let app = axum::Router::new()
2752            .route(
2753                "/401",
2754                get(move || async move { (axum::http::StatusCode::UNAUTHORIZED, hostile) }),
2755            )
2756            .route(
2757                "/403",
2758                get(move || async move { (axum::http::StatusCode::FORBIDDEN, hostile) }),
2759            )
2760            .route(
2761                "/429",
2762                get(move || async move { (axum::http::StatusCode::TOO_MANY_REQUESTS, hostile) }),
2763            )
2764            .route(
2765                "/429r",
2766                get(move || async move {
2767                    (
2768                        axum::http::StatusCode::TOO_MANY_REQUESTS,
2769                        [("retry-after", "120")],
2770                        hostile,
2771                    )
2772                }),
2773            )
2774            .route(
2775                "/429big",
2776                get(move || async move {
2777                    (
2778                        axum::http::StatusCode::TOO_MANY_REQUESTS,
2779                        [("retry-after", "999999999")],
2780                        hostile,
2781                    )
2782                }),
2783            )
2784            .route(
2785                "/429date",
2786                get(move || async move {
2787                    (
2788                        axum::http::StatusCode::TOO_MANY_REQUESTS,
2789                        [("retry-after", "Wed, 21 Oct 2015 07:28:00 GMT")],
2790                        hostile,
2791                    )
2792                }),
2793            )
2794            .route(
2795                "/500",
2796                get(
2797                    move || async move { (axum::http::StatusCode::INTERNAL_SERVER_ERROR, hostile) },
2798                ),
2799            )
2800            .route(
2801                "/503r",
2802                get(move || async move {
2803                    (
2804                        axum::http::StatusCode::SERVICE_UNAVAILABLE,
2805                        [("retry-after", "30")],
2806                        hostile,
2807                    )
2808                }),
2809            )
2810            .route(
2811                "/503date",
2812                get(move || async move {
2813                    (
2814                        axum::http::StatusCode::SERVICE_UNAVAILABLE,
2815                        [("retry-after", "Wed, 21 Oct 2015 07:28:00 GMT")],
2816                        hostile,
2817                    )
2818                }),
2819            )
2820            .route(
2821                "/302",
2822                get(move || async move {
2823                    (
2824                        axum::http::StatusCode::FOUND,
2825                        [("location", "http://127.0.0.1:9/elsewhere")],
2826                        hostile,
2827                    )
2828                }),
2829            );
2830        let url = spawn(app).await;
2831        let client = UsageClient::new().unwrap();
2832        let cases: [(&str, UsageError); 10] = [
2833            ("401", UsageError::Unauthorized { status: 401 }),
2834            ("403", UsageError::Unauthorized { status: 403 }),
2835            (
2836                "429",
2837                UsageError::RateLimited {
2838                    retry_after_s: None,
2839                },
2840            ),
2841            (
2842                "429r",
2843                UsageError::RateLimited {
2844                    retry_after_s: Some(120),
2845                },
2846            ),
2847            (
2848                "429big",
2849                UsageError::RateLimited {
2850                    retry_after_s: Some(86_400),
2851                },
2852            ),
2853            (
2854                "429date",
2855                UsageError::RateLimited {
2856                    retry_after_s: None,
2857                },
2858            ),
2859            (
2860                "500",
2861                UsageError::UpstreamStatus {
2862                    status: 500,
2863                    retry_after_s: None,
2864                },
2865            ),
2866            // r2 item 6: a 5xx keeps its delta-seconds Retry-After too; an
2867            // HTTP-date is still ignored.
2868            (
2869                "503r",
2870                UsageError::UpstreamStatus {
2871                    status: 503,
2872                    retry_after_s: Some(30),
2873                },
2874            ),
2875            (
2876                "503date",
2877                UsageError::UpstreamStatus {
2878                    status: 503,
2879                    retry_after_s: None,
2880                },
2881            ),
2882            ("302", UsageError::Redirected { status: 302 }),
2883        ];
2884        for (path, expected) in cases {
2885            let err = fetch_usage(&client, OAuthProviderId::Anthropic, "d", "tok-ZZ-secret", &opts(&format!("{url}/{path}")))
2886                .await
2887                .unwrap_err();
2888            assert_eq!(err, expected, "path {path}");
2889            secret_free(&err.to_string(), &["leak-me", "sk-ant", "tok-ZZ"]);
2890            secret_free(&serde_json::to_string(&err).unwrap(), &["leak-me", "sk-ant"]);
2891        }
2892        // The selection report keeps that Retry-After with the 503.
2893        let failure = UsageError::UpstreamStatus {
2894            status: 503,
2895            retry_after_s: Some(30),
2896        }
2897        .failure();
2898        assert_eq!(failure.http_status, Some(503));
2899        assert_eq!(failure.retry_after_s, Some(30));
2900    }
2901
2902    #[tokio::test]
2903    async fn fetch_body_cap_and_timeout_and_malformed() {
2904        use axum::routing::get;
2905        let app = axum::Router::new()
2906            .route("/big", get(|| async { format!("{{\"five_hour\":{{\"utilization\":1,\"pad\":\"{}\"}}}}", "x".repeat(4096)) }))
2907            .route(
2908                "/slow",
2909                get(|| async {
2910                    tokio::time::sleep(Duration::from_secs(5)).await;
2911                    "{}"
2912                }),
2913            )
2914            .route("/html", get(|| async { "<html>not json</html>" }));
2915        let url = spawn(app).await;
2916        let client = UsageClient::new().unwrap();
2917
2918        let o = UsageFetchOptions {
2919            endpoint_override: Some(format!("{url}/big")),
2920            max_body_bytes: 1024,
2921            ..Default::default()
2922        };
2923        let err = fetch_usage(&client, OAuthProviderId::Anthropic, "d", "tok", &o).await.unwrap_err();
2924        assert_eq!(err, UsageError::BodyTooLarge { cap: 1024 });
2925
2926        let o = UsageFetchOptions {
2927            endpoint_override: Some(format!("{url}/slow")),
2928            timeout: Duration::from_millis(300),
2929            ..Default::default()
2930        };
2931        let err = fetch_usage(&client, OAuthProviderId::Anthropic, "d", "tok", &o).await.unwrap_err();
2932        assert_eq!(err, UsageError::Timeout);
2933
2934        let err = fetch_usage(&client, OAuthProviderId::Anthropic, "d", "tok", &opts(&format!("{url}/html")))
2935            .await
2936            .unwrap_err();
2937        assert_eq!(err, UsageError::Malformed { detail: "body is not JSON".into() });
2938    }
2939}