Skip to main content

cortexkit_provider_usage/
lib.rs

1//! Shared wire types for the `ai-provider-quota` module's `usage.get` payload.
2//!
3//! The quota module serves an array of [`ProviderUsage`] per request; ALF's
4//! router (`codexbar-window-extractors.ts`), astrocyte's capacity axis, and the
5//! `ck quota` renderer all consume that shape. This crate is the single
6//! definition those consumers compile against, so the wire shape cannot drift
7//! without a shared-crate PR every side reviews.
8//!
9//! # Shape, not policy
10//!
11//! These are pure data types. Read-time transform semantics are PRODUCER
12//! behavior documented on the relevant fields but NOT enforced here:
13//! - **Banked-reset relaxation:** the quota module may zero
14//!   [`RateWindow::used_percent`] (the EFFECTIVE number consumers pace on) and
15//!   carry the provider-reported truth in [`RateWindow::raw_used_percent`].
16//!   A consumer renders whatever the wire says; a sudden `0 → high` transition
17//!   is an honest disarm (credits spent / auth broke), not a glitch.
18//! - **Cache-only partial arrays:** the quota module never blocks on a fetch,
19//!   so a result may omit providers not yet swept. Missing ≠ zero.
20//! - **Degraded entries ride in-band:** a provider fetch failure is a normal
21//!   [`ProviderUsage`] carrying `error`, not a request-level failure.
22//!
23//! # Serialization contract consumers depend on
24//! - camelCase keys (`usedPercent`, `resetsAt`, `windowMinutes`,
25//!   `extraRateWindows`, `rawUsedPercent`, `accountInfo`, `savedResets`,
26//!   `usedCount`, `totalCount`).
27//! - A healthy entry MUST NOT carry `error` (consumers skip truthy-`error`
28//!   entries), so it is omitted when absent.
29//! - A window is emitted when it has a `usedPercent`; `resetsAt` is OPTIONAL and
30//!   omitted when the provider reports no reset (never fabricated).
31
32use serde::{Deserialize, Serialize};
33
34/// One rate-limit window: how much of a quota pool is spent and when it resets.
35#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
36#[serde(rename_all = "camelCase")]
37pub struct RateWindow {
38    /// 0..100 percent of the window's quota consumed. This is the EFFECTIVE
39    /// number consumers pace on: when banked-reset relaxation applies it is
40    /// zeroed, and the provider-reported percent moves to `raw_used_percent`.
41    pub used_percent: f64,
42    /// The provider-reported percent when `used_percent` has been relaxed to
43    /// an effective value (banked resets guarantee the window resets before
44    /// the wall).
45    ///
46    /// **Pace on `used_percent`, not on this.** The effective number is the real
47    /// headroom: a reset that is going to happen has already been accounted for.
48    /// Treating this as the truer figure routes work away from an account whose
49    /// credit is about to be spent — and the credit expires whether or not it is
50    /// used, so the cautious-looking reading is the lossy one. Display it beside
51    /// the effective number in a human-facing view, where a zero next to real
52    /// consumption would otherwise look like a fault.
53    ///
54    /// Emitted **only where the two diverge**, so its absence means they agree
55    /// and falling back to `used_percent` is exact rather than approximate.
56    /// Rendering a placeholder for absence would be wrong on every unrelaxed
57    /// window, which is nearly all of them.
58    #[serde(skip_serializing_if = "Option::is_none", default)]
59    pub raw_used_percent: Option<f64>,
60    /// ISO 8601 / RFC 3339 timestamp when the window resets. Omitted when the
61    /// provider reports no reset (e.g. an idle session window with nothing
62    /// pending) — never fabricated.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub resets_at: Option<String>,
65    /// Window length in minutes. Omitted when the provider does not report one;
66    /// the consumer then paces on utilization alone rather than a burn rate.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub window_minutes: Option<i64>,
69    /// Absolute consumed count in the window (e.g. tokens, requests). A count
70    /// of things, so integral by contract, and only ever the upstream's own
71    /// figure — never recovered from a percentage and a cap (a derived figure
72    /// can carry a disagreement between two provider endpoints while wearing
73    /// a type that claims exactness). Omitted when the upstream reports only
74    /// a percentage. Human-facing UIs can show "10,336 / 40,000" alongside
75    /// the percentage for richer context.
76    #[serde(skip_serializing_if = "Option::is_none", default)]
77    pub used_count: Option<f64>,
78    /// Absolute total cap for the window, when the upstream states one. May
79    /// appear without `used_count`: the cap can be known while the consumed
80    /// figure is only a percentage.
81    #[serde(skip_serializing_if = "Option::is_none", default)]
82    pub total_count: Option<f64>,
83    /// How the window's quota comes back, when the upstream STATES a mechanic.
84    ///
85    /// **Absence licenses nothing.** It means the upstream said nothing about
86    /// replenishment — never "this is a fixed window". Most providers state
87    /// nothing, so absence is the common case and carries no information.
88    #[serde(skip_serializing_if = "Option::is_none", default)]
89    pub regeneration: Option<Regeneration>,
90    /// How the window's consumption divides among the upstream's own
91    /// categories, when the upstream states that split.
92    ///
93    /// **Absence means not fetched or not published, never "all zero".** Most
94    /// providers state no split, so absence is the common case.
95    #[serde(skip_serializing_if = "Option::is_none", default)]
96    pub breakdown: Option<UsageBreakdown>,
97}
98
99/// A window's consumption split by category, as the upstream reports it.
100///
101/// First observed on Anthropic's weekly window, which splits consumption across
102/// `claude_code`, `chat`, `cowork` and `other`. A consumer that sees only some
103/// of an account's traffic uses the split to tell which share of a movement in
104/// `used_percent` it can account for.
105#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
106#[serde(rename_all = "camelCase")]
107pub struct UsageBreakdown {
108    /// When the upstream computed the split, RFC 3339. Omitted when unstated.
109    #[serde(skip_serializing_if = "Option::is_none", default)]
110    pub as_of: Option<String>,
111    /// When the window this split describes began, RFC 3339, as the upstream
112    /// states it. Lets a consumer tell which window a split belongs to across a
113    /// reset. Omitted when unstated.
114    #[serde(skip_serializing_if = "Option::is_none", default)]
115    pub window_started_at: Option<String>,
116    /// One row per category, in the upstream's order.
117    pub rows: Vec<BreakdownRow>,
118}
119
120/// One category's part of a window's consumption.
121#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
122#[serde(rename_all = "camelCase")]
123pub struct BreakdownRow {
124    /// The upstream's own category key, verbatim: never mapped or renamed.
125    ///
126    /// A `String` rather than an enum, because this is an observability wire: a
127    /// category added upstream must arrive as itself, not fail the record.
128    pub key: String,
129    /// This category's share of what the window has CONSUMED, 0..=100.
130    ///
131    /// **A share, not a used-percent.** Across rows it sums to about 100 whatever
132    /// the window's `used_percent`: a window 85% used whose consumption was all
133    /// one category reads 100 on that row, not 85. The part of `used_percent` a
134    /// category accounts for is `used_percent * share_percent / 100`.
135    ///
136    /// Omitted, never zero, when the upstream's row carries no figure.
137    #[serde(skip_serializing_if = "Option::is_none", default)]
138    pub share_percent: Option<f64>,
139}
140
141/// A stated replenishment mechanic for a window.
142///
143/// Present only where an upstream describes how the quota returns. It exists
144/// because the alternative — projecting a replenishment onto `resets_at` — makes
145/// a continuously refilling pool indistinguishable from a hard cutoff, and the
146/// projection is unrecoverable once published.
147#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
148#[serde(rename_all = "camelCase")]
149pub struct Regeneration {
150    /// Which mechanic the upstream describes: `cliff`, `drip`, or `unstated`.
151    ///
152    /// **PACING DEPENDS ON THIS AND THE THREE ANSWERS DIFFER SHARPLY.** A rate
153    /// alone cannot separate them: "1,000,000 units per 720h" describes both a
154    /// lump arriving on one instant and a steady accrual every hour, and the two
155    /// give opposite answers about headroom on day 14.
156    ///
157    /// - `cliff` — the whole amount lands at `resets_at`, and **accrual before
158    ///   that instant is exactly zero**. A consumer treating this as gradual
159    ///   believes it has partial headroom when it has none.
160    /// - `drip` — the quota accrues continuously at `rate`, so headroom grows
161    ///   between reads and an exhausted pool becomes usable again without any
162    ///   reset event.
163    /// - `unstated` — the upstream states that quota replenishes but nothing
164    ///   establishes which mechanic. **No pacing claim exists: display the rate,
165    ///   never derive headroom from it.** Same contract as `PoolFunding::Unknown`
166    ///   and `PoolBasis::Unstated`, for the same reason — an honest arm keeps a
167    ///   producer from guessing to satisfy the type.
168    ///
169    /// A plain `String` rather than an enum, and REQUIRED rather than optional.
170    /// String because this is an observability wire: a variant added later must
171    /// not make an old consumer drop the record that reports a state it has never
172    /// seen. Required because an optional discriminator invites exactly the
173    /// inference this field exists to prevent — with no value present, a consumer
174    /// picks one, and the picker has less evidence than the producer.
175    ///
176    /// Treat an unrecognised value as `unstated`: render it, pace on nothing.
177    pub mechanic: String,
178    /// The stated replenishment rate, when the upstream gives one.
179    ///
180    /// Absent means the mechanic is described without a quantity — a real and
181    /// common shape ("credits refresh monthly" with no amount). Absence here says
182    /// nothing about `mechanic`, which stays authoritative.
183    #[serde(skip_serializing_if = "Option::is_none", default)]
184    pub rate: Option<RegenerationRate>,
185}
186
187/// How much quota returns, and over what period.
188#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
189#[serde(rename_all = "camelCase")]
190pub struct RegenerationRate {
191    /// Amount replenished per period, in the SAME units as `used_count` and
192    /// `total_count` on the window. Not integral by contract: unlike a count, a
193    /// rate is legitimately fractional (a monthly grant read per hour).
194    pub amount: f64,
195    /// Length of the replenishment period in minutes.
196    ///
197    /// Distinct from the window's own `window_minutes`, which they need not
198    /// match: an observed payload states a 720h refill period on a balance whose
199    /// percentage is measured against a larger total that includes a purchased
200    /// pool that never refills.
201    pub per_minutes: i64,
202}
203
204/// A per-model window bundled under one account (e.g. Antigravity's Geminis).
205#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
206#[serde(rename_all = "camelCase")]
207pub struct ExtraWindow {
208    /// Human-facing label. Absent when the producer has no display text for this
209    /// window; render `id` instead rather than dropping the entry.
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub title: Option<String>,
212    /// Stable identifier to match on. Absent when the producer cannot name the
213    /// window stably. **Not unique across providers** — one provider's ids are
214    /// model names, another's its own scope labels — so key on
215    /// `(provider, id)`, never on `id` alone.
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub id: Option<String>,
218    /// The figures for this window. Absent means the provider **named a limit it
219    /// could not read a figure for**, which is not the same as no limit: the
220    /// entry is still evidence the limit exists.
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub window: Option<RateWindow>,
223}
224
225/// The window topology for one account: up to three account-wide pools plus an
226/// optional list of per-model pools.
227#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
228#[serde(rename_all = "camelCase")]
229pub struct Usage {
230    /// The provider's **shortest** window, not its most constrained one. Absent
231    /// when the provider reported no window of that cadence.
232    ///
233    /// The three slots are positions, not a ranking, and **they can have holes**:
234    /// each is filled from its own optional upstream field, so `secondary` may be
235    /// absent while `tertiary` is present. Walk all three plus
236    /// `extra_rate_windows` rather than stopping at the first gap, and take the
237    /// maximum when asking how much headroom an account has.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub primary: Option<RateWindow>,
240    /// The next cadence up, typically weekly. Absent means not reported — never
241    /// that the window exists at zero. See [`Usage::primary`] on slot holes.
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub secondary: Option<RateWindow>,
244    /// A third account-wide window where a provider has one. Absent means not
245    /// reported. See [`Usage::primary`] on slot holes.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub tertiary: Option<RateWindow>,
248    /// Windows whose meaning has no slot — per-model pools, scoped weeklies.
249    /// Absent means the provider published none.
250    ///
251    /// These are **real limits**, not extras in the dispensable sense: a consumer
252    /// ignoring this list silently ignores whichever limits did not fit three
253    /// slots.
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub extra_rate_windows: Option<Vec<ExtraWindow>>,
256}
257
258/// An amount of money or credit, in integer minor units.
259///
260/// Not a float, and the reason is not stylistic. A balance is compared against
261/// zero on every routing decision that reads it, and binary floating point
262/// cannot hold ordinary decimal amounts exactly — the nearest `f64` to `0.1` is
263/// not `0.1`, so sums drift and a comparison near zero can fall either way. The
264/// providers agree: DeepSeek and MiniMax both send decimal strings, and
265/// Anthropic sends integer minor units with an exponent.
266///
267/// Parse a provider's own representation once, where its precision is still
268/// known, rather than passing a float along and re-rendering it.
269#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
270#[serde(rename_all = "camelCase")]
271pub struct Amount {
272    /// The amount in minor units: `1050` with `exponent: 2` is 10.50.
273    pub minor: i64,
274    /// Decimal places in `minor`. `2` for currencies with cents; `0` for whole
275    /// credits or points.
276    pub exponent: u8,
277    /// What the amount is denominated in: a currency code like `"USD"`, or a
278    /// provider's own label for its credits.
279    ///
280    /// A free string rather than a currency enum, because not every pool is
281    /// money — some are points that convert to no currency, and an enum would
282    /// force those into a currency slot or drop them.
283    pub unit: String,
284}
285
286/// Where a pool's balance came from, which decides what a consumer may promise.
287#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
288#[serde(rename_all = "snake_case")]
289pub enum PoolFunding {
290    /// Given by the provider: a promotion, a trial grant, a voucher. Spendable
291    /// without a bill.
292    Granted,
293    /// Bought. Spending it costs money.
294    Purchased,
295    /// Included in a subscription the account already pays for.
296    Subscription,
297    /// The provider separates this pool but does not say what funds it, **or**
298    /// the producer named a funding kind this consumer does not recognise.
299    ///
300    /// A correct answer rather than a failure one: some providers name their
301    /// pools without defining them, and guessing the funding is how a consumer
302    /// ends up spending money it meant to protect.
303    ///
304    /// It is also the deserialization fallback, and the two meanings genuinely
305    /// agree — a funding kind added after this consumer was built is, to this
306    /// consumer, of unknown funding. Without the fallback an unrecognised value
307    /// fails the whole `ProviderUsage` entry rather than this one field, so a
308    /// new pool kind would take an account's *usage* down with it and read as
309    /// the provider being unavailable.
310    #[serde(other)]
311    Unknown,
312}
313
314/// How a pool's `remaining` was obtained.
315#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
316#[serde(rename_all = "snake_case")]
317pub enum PoolBasis {
318    /// The provider states this pool's remaining balance directly.
319    Reported,
320    /// Computed from a total and a consumption figure that covers several pools
321    /// at once, so the split between them is not known.
322    ///
323    /// The distinction is load-bearing for any "spend only granted credits"
324    /// policy: against a `Reported` pool it is exact, and against a `Derived`
325    /// one it can only be a ceiling.
326    Derived,
327    /// No basis was stated, or one was stated that this consumer does not
328    /// recognise. **Treat `remaining` as a ceiling, never as exact.**
329    ///
330    /// This is deliberately its own variant rather than folding an unrecognised
331    /// value into [`Self::Derived`]. Both are read conservatively, so the
332    /// spending behaviour is the same either way — but `Derived` is a statement
333    /// about how a number was obtained, and answering "I do not know" with it
334    /// would have the producer assert a fact it does not hold. That is the
335    /// failure this type exists to prevent, one level up.
336    ///
337    /// Reading it conservatively is safe in the direction that matters: an
338    /// exact remainder treated as a ceiling under-spends, while a ceiling
339    /// treated as exact spends money that may not be there.
340    #[serde(other)]
341    Unstated,
342}
343
344/// A prepaid balance or credit pool on an account.
345///
346/// Plural by necessity: one figure cannot express "9.50 granted and 40
347/// purchased", which is exactly the distinction a consumer needs to spend the
348/// first without spending the second.
349#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
350#[serde(rename_all = "camelCase")]
351pub struct Pool {
352    /// The provider's own name for this pool, never one invented here.
353    ///
354    /// Providers separate pools without always defining them — a wallet may list
355    /// voucher, cash and credit balances and document none of them. Passing the
356    /// provider's name through lets a consumer decide; renaming one `granted`
357    /// would be inventing the label a spend policy keys on.
358    pub id: String,
359    /// Human-readable name for display.
360    pub label: String,
361    /// What funds this pool.
362    pub funding: PoolFunding,
363    /// What is left, when it can be established.
364    #[serde(skip_serializing_if = "Option::is_none", default)]
365    pub remaining: Option<Amount>,
366    /// The pool's size, when the provider reports one.
367    #[serde(skip_serializing_if = "Option::is_none", default)]
368    pub total: Option<Amount>,
369    /// How `remaining` was obtained. Read it before acting on `remaining`.
370    pub basis: PoolBasis,
371    /// Whether the provider says this pool may currently be drawn on.
372    ///
373    /// Read from the provider, never inferred from `remaining > 0`: a pool can
374    /// be non-empty and closed, which several providers publish directly through
375    /// their own enable flags. Absent means the provider does not say.
376    #[serde(skip_serializing_if = "Option::is_none", default)]
377    pub spendable: Option<bool>,
378    /// When the pool's period renews, as RFC 3339, if the provider states one.
379    ///
380    /// **Absent means not stated, never "does not renew".** Some providers state
381    /// a period for a money pool (a monthly workspace credit limit with its own
382    /// reset time); others state none for a pool that may well be monthly (paid
383    /// overage named `monthly_limit` with no date anywhere in the payload). A
384    /// consumer that reads absence as "this balance is all there will ever be"
385    /// under-spends at best, and at worst treats a pool that refills on the 1st
386    /// as permanently exhausted.
387    ///
388    /// A producer sets it only from a value the provider sent. A period guessed
389    /// from a field name is the fabricated-precision failure this crate refuses
390    /// everywhere else.
391    #[serde(skip_serializing_if = "Option::is_none", default)]
392    pub resets_at: Option<String>,
393}
394
395/// Account labels and subscription information supplied by a provider or vault.
396#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
397#[serde(rename_all = "camelCase")]
398pub struct AccountInfo {
399    /// Account email. Absent when the upstream does not identify the account that
400    /// way — not a signal about the account itself.
401    #[serde(skip_serializing_if = "Option::is_none", default)]
402    pub email: Option<String>,
403    /// Organisation label. Absent when the upstream reports none; absent does not
404    /// mean a personal account.
405    #[serde(skip_serializing_if = "Option::is_none", default)]
406    pub org_name: Option<String>,
407    /// The upstream's **own** plan label, not a normalised vocabulary, so it is
408    /// not comparable across providers. Display and grouping only. Absent when
409    /// the upstream states no plan.
410    #[serde(skip_serializing_if = "Option::is_none", default)]
411    pub plan_type: Option<String>,
412}
413
414impl AccountInfo {
415    pub fn is_empty(&self) -> bool {
416        self.email.is_none() && self.org_name.is_none() && self.plan_type.is_none()
417    }
418}
419
420/// One saved reset credit and its expiry time.
421#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
422#[serde(rename_all = "camelCase")]
423pub struct CreditExpiry {
424    pub expires_at: String,
425}
426
427/// Saved reset credits reported by Codex's read-only credits endpoint.
428#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
429#[serde(rename_all = "camelCase")]
430pub struct SavedResets {
431    #[serde(default)]
432    pub available_count: u32,
433    /// When the next credit lapses. Absent means **no credit states an expiry**,
434    /// which is not the same as none expiring soon.
435    #[serde(skip_serializing_if = "Option::is_none", default)]
436    pub soonest_expires_at: Option<String>,
437    #[serde(default)]
438    pub credits: Vec<CreditExpiry>,
439}
440
441fn account_info_is_empty(value: &Option<AccountInfo>) -> bool {
442    value.as_ref().map(AccountInfo::is_empty).unwrap_or(true)
443}
444
445/// One provider/account's usage entry. The `/usage` response is an array of
446/// these. A fetch failure becomes an entry carrying `error` (silent-degrade),
447/// never a failure of the whole array.
448#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
449#[serde(rename_all = "camelCase")]
450pub struct ProviderUsage {
451    /// CodexBar provider name (e.g. "codex"), which consumers map to their own id.
452    pub provider: String,
453    /// Canonical API provider identifier — the models.dev slug for the same
454    /// provider (e.g. "openai" when `provider == "codex"`, "anthropic" for
455    /// "claude", "google" for "gemini", "xai" for "grok"). Present when the
456    /// producer knows the canonical name; absent for providers with no models.dev
457    /// counterpart, where consumers fall back to `provider`. Lets every consumer
458    /// key on one canonical name instead of each maintaining its own
459    /// CodexBar-name → canonical map.
460    #[serde(skip_serializing_if = "Option::is_none", default)]
461    pub api_provider: Option<String>,
462    /// The account this entry describes, as the credential store identifies it.
463    ///
464    /// Absent means the producer **could not resolve an identity for this
465    /// credential**, not that the provider has one account. Some credentials
466    /// carry no account identity at all (a bare API key), and an entry is also
467    /// emitted unlabelled while an identity is still being confirmed — so an
468    /// unlabelled entry is not evidence that a labelled one does not exist.
469    #[serde(skip_serializing_if = "Option::is_none")]
470    pub account: Option<String>,
471    /// Which retrieval path produced this (e.g. "oauth") — observability only.
472    ///
473    /// **Per lane, not per account, and it moves.** One account can be reached
474    /// through more than one credential path, and which one answers is decided
475    /// per fetch by whichever is healthy. So the same account can report one
476    /// value on a poll and another on the next with nothing having changed about
477    /// the account, the credential, or anything the consumer did. Do not key on
478    /// it, branch on it, or treat a change in it as an event.
479    #[serde(skip_serializing_if = "Option::is_none")]
480    pub source: Option<String>,
481    /// Display labels for the account. Absent when the upstream supplies none of
482    /// them; carries no operational meaning.
483    #[serde(skip_serializing_if = "account_info_is_empty", default)]
484    pub account_info: Option<AccountInfo>,
485    /// When this entry's figures were last **successfully** fetched — producer
486    /// time, per entry, never a common instant across the array.
487    ///
488    /// Absent means this credential has never had a successful fetch. It keeps
489    /// its old value while a failure is being retried, so it ages honestly rather
490    /// than pausing; never restamp it with your own poll time.
491    #[serde(skip_serializing_if = "Option::is_none", default)]
492    pub fetched_at: Option<String>,
493    /// Banked quota-reset credits held by this account.
494    ///
495    /// Absent means there is no credit inventory to report — which includes the
496    /// inventory lookup having **failed** on this fetch, since it is separate
497    /// from the usage fetch and may fail without degrading the entry. Absent is
498    /// therefore not "zero credits held".
499    #[serde(skip_serializing_if = "Option::is_none", default)]
500    pub saved_resets: Option<SavedResets>,
501    /// The windows. Absent on a degraded entry, and on an entry whose credential
502    /// works but whose account reports no quota at all — read `error` and
503    /// `error_class` to tell those apart, rather than inferring from this field.
504    #[serde(skip_serializing_if = "Option::is_none")]
505    pub usage: Option<Usage>,
506    /// Prepaid balances and credit pools on this account, when the provider
507    /// reports any.
508    ///
509    /// Deliberately apart from [`Self::usage`], because a pool and a rate window
510    /// are different facts that fail in opposite directions: over-consuming a
511    /// window gets you throttled and recovers by waiting, while over-consuming a
512    /// balance gets you billed and recovers by paying. Nothing in a routing loop
513    /// can undo the second, so a balance is never expressed as a window, never
514    /// carries a reset, and never appears as a percentage — a consumer that
515    /// found one where it expects headroom would pace into a bill.
516    ///
517    /// Absent means the producer has nothing to say, which is not the same as an
518    /// account having no credit. Empty means it looked and the provider reports
519    /// no pools.
520    #[serde(skip_serializing_if = "Option::is_none", default)]
521    pub spend: Option<Vec<Pool>>,
522    /// Present only on a degraded entry. The consumer skips any entry with a
523    /// truthy `error`.
524    #[serde(skip_serializing_if = "Option::is_none")]
525    pub error: Option<String>,
526    /// A stable, machine-readable name for *why* a degraded entry failed,
527    /// published beside the human-readable `error`.
528    ///
529    /// `error` is prose with no stability promise, so consumers are told not to
530    /// branch on it — which leaves them no way to separate failures that mean
531    /// something from failures that are a permanent, correct state. A host that
532    /// never configured a provider and a host whose credential broke this
533    /// morning both produce a degraded entry, and only the second is worth
534    /// anyone's attention.
535    ///
536    /// Classes currently produced:
537    ///
538    /// | Value | Meaning |
539    /// |---|---|
540    /// | `credential_absent` | No credential was found. Permanent and correct on a host that never configured this provider; nothing to fix. |
541    /// | `credential_unusable` | A credential was found but cannot be used as it stands (empty, incomplete, or refused by the credential store). Someone configured this and it needs fixing. |
542    /// | `credential_rejected` | The upstream rejected the credential (401/403). Usually means logging in again. |
543    /// | `no_quota_reported` | The credential works and the account genuinely has no quota to report. Not a failure. |
544    /// | `upstream_failed` | The upstream could not be reached or returned an error status. Usually transient. |
545    /// | `decode_failed` | The response arrived but was not the expected shape. |
546    ///
547    /// **This list will grow.** A consumer must render an unrecognised class as
548    /// a degraded entry with an unknown reason — never drop the entry, and
549    /// never fold it into an existing bucket. It is a `String` rather than an
550    /// enum for exactly that reason: on an observability surface, meeting an
551    /// unknown value must not turn into a parse failure that makes a provider
552    /// disappear at the moment its state changed.
553    ///
554    /// Absent on healthy entries, and absent from any producer that predates
555    /// this field.
556    #[serde(skip_serializing_if = "Option::is_none", default)]
557    pub error_class: Option<String>,
558    /// Present when this entry is a last-known-good reading served through an
559    /// ongoing failure, absent when it is a fresh success.
560    ///
561    /// Without it a preserved reading is byte-identical to a fresh one apart
562    /// from `fetched_at`, so a consumer cannot separate "this figure is old
563    /// because the producer has been unable to reach the provider" from "this
564    /// figure is old because nothing polled recently". Those have opposite
565    /// remedies — the first is a reason to stop acting on the number, the
566    /// second is not — and a consumer with only a timestamp has to guess with a
567    /// wall-clock threshold, which denies fresh-enough data to catch stale data.
568    ///
569    /// A producer serving preserved readings is behaving correctly: a brief
570    /// upstream failure should not blank a window. This field discloses that it
571    /// is happening rather than reporting a fault.
572    #[serde(skip_serializing_if = "Option::is_none", default)]
573    pub stale: Option<Stale>,
574}
575
576/// Why an entry is being served through a failure, and since when.
577#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
578#[serde(rename_all = "camelCase")]
579pub struct Stale {
580    /// When the producer first failed to refresh this entry, RFC3339.
581    ///
582    /// Distinct from `fetched_at`, which is when the served reading was
583    /// obtained. The gap between them is how long the producer has been unable
584    /// to look, which is the quantity a staleness policy actually wants: a
585    /// reading can be minutes old with the producer perfectly healthy, or
586    /// seconds old with the producer failing since just after it was taken.
587    pub since: String,
588    /// The failure class, using the same vocabulary as `error_class` on a
589    /// degraded entry.
590    ///
591    /// Carried so a consumer can tell a flapping upstream from a credential
592    /// that has started refusing, without branching on prose. Optional because
593    /// a producer may preserve a reading for a reason it cannot classify.
594    #[serde(skip_serializing_if = "Option::is_none", default)]
595    pub class: Option<String>,
596}
597
598impl ProviderUsage {
599    /// A healthy entry with resolved windows.
600    pub fn healthy(provider: &str, account: Option<String>, source: &str, usage: Usage) -> Self {
601        Self {
602            provider: provider.to_string(),
603            api_provider: None,
604            account,
605            source: Some(source.to_string()),
606            account_info: None,
607            fetched_at: None,
608            saved_resets: None,
609            usage: Some(usage),
610            spend: None,
611            error: None,
612            error_class: None,
613            stale: None,
614        }
615    }
616
617    /// A degraded entry: the provider is named so the consumer can correlate,
618    /// but it carries only an error string and no windows.
619    pub fn degraded(provider: &str, error: impl std::fmt::Display) -> Self {
620        Self {
621            provider: provider.to_string(),
622            api_provider: None,
623            account: None,
624            source: None,
625            account_info: None,
626            fetched_at: None,
627            saved_resets: None,
628            usage: None,
629            spend: None,
630            error: Some(error.to_string()),
631            error_class: None,
632            stale: None,
633        }
634    }
635
636    /// A degraded entry that also names *why* it failed.
637    ///
638    /// Prefer this over [`Self::degraded`] wherever the producer knows the
639    /// class: without it a consumer can only tell an unconfigured provider from
640    /// a broken one by reading prose it has been told not to parse. See
641    /// [`ProviderUsage::error_class`] for the classes and for the rule that an
642    /// unrecognised one must still render.
643    pub fn degraded_with_class(
644        provider: &str,
645        error: impl std::fmt::Display,
646        error_class: impl Into<String>,
647    ) -> Self {
648        Self {
649            error_class: Some(error_class.into()),
650            ..Self::degraded(provider, error)
651        }
652    }
653}
654
655/// Shared account-identity type (commons#13, operator-ratified 2026-08-29).
656///
657/// `provider` was two namespaces wearing one name across the fleet: usage
658/// sources, credential configs, sweep-unit names, and catalog slugs all emit
659/// plausible provider strings, so a wrong join never fails loudly. This type
660/// makes the namespace explicit so no consumer ever writes the alias map that
661/// a silent join failure invites.
662///
663/// Absence is ordinary here, not exceptional: at ratification time 34 of 40
664/// live wire entries carried no verified identity. Consumers must treat a
665/// missing `account_ref` as the expected shape, never as an error.
666#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
667pub struct AccountIdentity {
668    /// Source-scoped provider name, spelled in the emitting module's own
669    /// namespace (a usage source's "claude", a registry's invented plan
670    /// name). MANDATORY. The same string under two sources need not mean the
671    /// same thing; this field never claims cross-source meaning.
672    pub provider: String,
673
674    /// The models.dev slug for this provider, when a join to the model
675    /// catalog EXISTS. Named for its source deliberately: a second catalog
676    /// source would get its own field rather than silently changing this
677    /// one's meaning.
678    ///
679    /// `None` means NO JOIN EXISTS — never fall back to [`Self::provider`].
680    /// models.dev publishes no alias metadata of any kind (measured:
681    /// no `alias`, `renamed_from`, `supersedes`, or `deprecated_by`), so
682    /// there is nothing for a fallback to fall back to; "use the other name"
683    /// fabricates a join, and a fabricated join produces plausible rows
684    /// forever instead of erroring.
685    #[serde(default, skip_serializing_if = "Option::is_none")]
686    pub models_dev_provider_slug: Option<String>,
687
688    /// Verified account identity, when the provider disclosed one.
689    ///
690    /// PROPAGATION RULE: provenance travels with the value and is NEVER
691    /// re-derived downstream. The moment a consumer writes
692    /// `if provider == "anthropic" { assume frozen }` they have re-implemented
693    /// the producer's discriminant from the outside, keyed on a provider
694    /// string — the exact namespace hazard this type exists to prevent,
695    /// recreated one level down. The producer that holds the value at its
696    /// source knows which branch produced it; nobody else does.
697    #[serde(default, skip_serializing_if = "Option::is_none")]
698    pub account_ref: Option<AccountRef>,
699}
700
701/// An account identity value bundled with how it was obtained.
702///
703/// Bundled rather than parallel fields, so the bug-causing states — a value
704/// with no provenance, or a provenance with no value — are unrepresentable.
705/// Absent identity carries no provenance because there is nothing to qualify.
706#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
707pub struct AccountRef {
708    /// The identity value as the provider disclosed it (an email, an account
709    /// id, an org slug — whatever the source speaks).
710    pub value: String,
711    /// Which staleness story produced [`Self::value`]. See
712    /// [`AccountRefProvenance`] for why the two must never be conflated.
713    pub provenance: AccountRefProvenance,
714}
715
716/// The two ways an account identity reaches the wire, with opposite staleness
717/// properties.
718///
719/// The discriminant says a value *could* be wrong, not that it *is*: the
720/// frozen branch goes stale only if a record is re-pointed without a
721/// re-login. A consumer holding a [`Self::StoredLogin`] value knows it needs
722/// watching; a [`Self::LiveClaim`] value re-corrects on every read.
723///
724/// This enum is CLOSED on purpose — identity-bearing, not diagnostic. An
725/// unknown provenance must refuse to decode rather than default: a wrong
726/// guess here poisons joins silently, which is worse than a loud decode
727/// error on a version skew.
728///
729/// WHAT CLOSURE COSTS: because decoders refuse unknown spellings, adding a
730/// variant is a WIRE-BREAKING change for every consumer on an older crate
731/// version — a coordinated version boundary across all adopters, never an
732/// additive edit. That cost is deliberate. It is also a boundary nobody
733/// should be crossing:
734///
735/// This enum discriminates STALENESS AMONG CUSTODIAN-VOUCHED VALUES. A
736/// consumer-derived identity — parsed from a logged-in page, say — is not a
737/// third staleness case: it changes WHO VOUCHES, and belongs in its own
738/// field rather than as a variant here. [`AccountIdentity::account_ref`]
739/// means "the custodian observed this"; a value the custodian did not vouch
740/// for does not belong in it at any spelling, because then a provenance tag
741/// does load-bearing work AGAINST its own field's semantics — the shape
742/// that fails quietly. A separate field is additive and breaks no decoder;
743/// a variant here is the coordinated wire boundary AND an unattested value
744/// in an attested field — the expensive mistake and the semantic one at
745/// once.
746#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
747#[serde(rename_all = "snake_case")]
748pub enum AccountRefProvenance {
749    /// Parsed from the served token at read time — re-derived on every get,
750    /// self-correcting.
751    LiveClaim,
752    /// Stored at login time and returned unchanged — frozen, can go stale
753    /// silently if the account is re-pointed without a re-login.
754    StoredLogin,
755}
756
757#[cfg(test)]
758mod tests {
759    /// `AccountInfo` IS AN IDENTITY STRUCT, SO AN ADDITIVE FIELD HERE IS A
760    /// DECISION RATHER THAN A DETAIL. This pins its serialised key set closed
761    /// in both directions so adding one reddens here, at the moment it is
762    /// written, rather than at whatever a consumer notices later.
763    ///
764    /// Why an identity struct earns a pin that an ordinary payload does not:
765    /// downstream, `ck quota --redact` renders these fields for screenshots and
766    /// removes the identifying ones BY NAME -- so a new identifying field
767    /// defaults to visible, the redaction filter keeps passing, its tests keep
768    /// going green, and a published screenshot carries a name nobody can
769    /// unpublish. Measured live 2026-09-18: Anthropic's `/api/oauth/profile`
770    /// carries `account.full_name`, `account.display_name` and
771    /// `organization.name` beside the subscription tier a consumer wanted, so
772    /// the pressure to widen this struct is real and already arrived once.
773    ///
774    /// THE THREE-LINK CHAIN, so nobody mistakes one link for the whole:
775    /// 1 the capturing consumer decodes only the fields it means to keep,
776    /// 2 THIS PIN (the shared struct's key set, closed at the producer),
777    /// 3 the rendering consumer's redaction filter, downstream.
778    ///
779    /// Link 2 alone does not stop a field being added; it stops one being added
780    /// WITHOUT SOMEONE SAYING SO. If you are here because this test is red and
781    /// the field is genuinely wanted, add it, update this test, and tell the
782    /// `ck quota --redact` owner -- that last step is the whole reason the test
783    /// exists.
784    #[test]
785    fn the_account_info_key_set_is_closed_in_both_directions() {
786        let populated = AccountInfo {
787            email: Some("operator@example.com".to_string()),
788            org_name: Some("Example Org".to_string()),
789            plan_type: Some("max".to_string()),
790        };
791        let value = serde_json::to_value(&populated).expect("serialise");
792        let object = value.as_object().expect("object");
793        let mut keys: Vec<&str> = object.keys().map(String::as_str).collect();
794        keys.sort_unstable();
795        assert_eq!(
796            keys,
797            ["email", "orgName", "planType"],
798            "AccountInfo's serialised key set changed. If a field was ADDED: is it \
799             personally identifying? If so it reaches `ck quota --redact`, whose filter \
800             removes fields by name and will pass it straight through into a screenshot. \
801             Update this test and notify that owner."
802        );
803
804        // The other direction: every empty account must serialise to nothing at
805        // all, so a consumer cannot read an absent identity as an empty one.
806        let empty = serde_json::to_value(AccountInfo::default()).expect("serialise");
807        assert_eq!(
808            empty.as_object().expect("object").len(),
809            0,
810            "an empty AccountInfo must emit no keys; a present-but-null field reads to a \
811             consumer as a resolved-and-blank identity rather than an unresolved one"
812        );
813    }
814
815    /// Full AccountIdentity round-trips with every field present, and the
816    /// wire spelling of both provenance branches is pinned.
817    #[test]
818    fn account_identity_full_round_trip_pins_provenance_spellings() {
819        let full = AccountIdentity {
820            provider: "claude".into(),
821            models_dev_provider_slug: Some("anthropic".into()),
822            account_ref: Some(AccountRef {
823                value: "ufuk@example.com".into(),
824                provenance: AccountRefProvenance::StoredLogin,
825            }),
826        };
827        let wire = serde_json::to_string(&full).unwrap();
828        assert!(
829            wire.contains("\"stored_login\""),
830            "frozen branch spelling: {wire}"
831        );
832        let back: AccountIdentity = serde_json::from_str(&wire).unwrap();
833        assert_eq!(back, full);
834
835        let live = serde_json::to_string(&AccountRefProvenance::LiveClaim).unwrap();
836        assert_eq!(live, "\"live_claim\"");
837    }
838
839    /// A minimal identity (provider only) keeps absent fields ABSENT on the
840    /// wire — not null — so pre-identity consumers see byte-identical output.
841    #[test]
842    fn a_minimal_identity_serializes_provider_only() {
843        let min = AccountIdentity {
844            provider: "kimi-for-coding".into(),
845            models_dev_provider_slug: None,
846            account_ref: None,
847        };
848        let wire = serde_json::to_string(&min).unwrap();
849        assert_eq!(wire, r#"{"provider":"kimi-for-coding"}"#);
850        let back: AccountIdentity = serde_json::from_str(&wire).unwrap();
851        assert_eq!(back, min);
852    }
853
854    /// A null slug stays None through a round trip and never inherits the
855    /// provider's value: the fallback is fabrication, and this asserts the
856    /// bytes, not the intent.
857    #[test]
858    fn a_none_slug_never_becomes_the_provider_value() {
859        let id = AccountIdentity {
860            provider: "qwen-cloud".into(),
861            models_dev_provider_slug: None,
862            account_ref: None,
863        };
864        let wire = serde_json::to_string(&id).unwrap();
865        assert!(
866            !wire.contains("models_dev_provider_slug"),
867            "absent, not null: {wire}"
868        );
869        let back: AccountIdentity = serde_json::from_str(&wire).unwrap();
870        assert_eq!(back.models_dev_provider_slug, None);
871        assert_ne!(
872            back.models_dev_provider_slug.as_deref(),
873            Some(back.provider.as_str()),
874            "a decode path that mirrors provider into the slug has fabricated a join"
875        );
876    }
877
878    /// Unknown provenance REFUSES to decode. Identity-bearing enum: a
879    /// wrong-guess default poisons joins silently, so version skew must fail
880    /// loud here, unlike the diagnostic value enums.
881    #[test]
882    fn an_unknown_provenance_refuses_to_decode() {
883        let err = serde_json::from_str::<AccountRef>(r#"{"value":"x","provenance":"vibes"}"#);
884        assert!(err.is_err(), "unknown provenance must refuse, got {err:?}");
885        // And a bare value with no provenance is unrepresentable:
886        let err2 = serde_json::from_str::<AccountRef>(r#"{"value":"x"}"#);
887        assert!(
888            err2.is_err(),
889            "value without provenance must refuse, got {err2:?}"
890        );
891    }
892
893    /// A window with no stated mechanic serializes exactly as before.
894    ///
895    /// The additive guarantee: every existing producer omits the field, and a
896    /// consumer on the previous version must see byte-identical output.
897    #[test]
898    fn a_window_without_regeneration_is_unchanged_on_the_wire() {
899        let window = RateWindow {
900            used_percent: 42.0,
901            raw_used_percent: None,
902            resets_at: Some("2026-08-17T00:00:00Z".to_string()),
903            window_minutes: Some(300),
904            used_count: None,
905            total_count: None,
906            regeneration: None,
907            breakdown: None,
908        };
909        let json = serde_json::to_string(&window).expect("serializes");
910        assert!(
911            !json.contains("regeneration"),
912            "an absent mechanic must not appear on the wire: {json}"
913        );
914    }
915
916    /// A payload from before this field decodes into the new shape.
917    #[test]
918    fn a_pre_regeneration_payload_still_decodes() {
919        let json = r#"{"usedPercent":42.0,"resetsAt":"2026-08-17T00:00:00Z","windowMinutes":300}"#;
920        let window: RateWindow = serde_json::from_str(json).expect("older payloads must decode");
921        assert_eq!(window.regeneration, None);
922    }
923
924    /// The observed cliff shape round-trips, rate and all.
925    ///
926    /// SHAPE FROM A LIVE CAPTURE (insula#1, 2026-08-17): a credentialed JetBrains
927    /// account stating `tariff: { amount: "1000000", duration: "PT720H" }` beside
928    /// a known next-refill instant. First observed rate-bearing payload; the
929    /// numbers here are its scrubbed values.
930    #[test]
931    fn a_stated_cliff_refill_round_trips() {
932        let window = RateWindow {
933            used_percent: 0.67,
934            raw_used_percent: None,
935            resets_at: Some("2026-08-15T06:00:00.000Z".to_string()),
936            window_minutes: None,
937            used_count: Some(8100.0),
938            total_count: Some(1_207_000.0),
939            regeneration: Some(Regeneration {
940                mechanic: "cliff".to_string(),
941                rate: Some(RegenerationRate {
942                    amount: 1_000_000.0,
943                    per_minutes: 43_200,
944                }),
945            }),
946            breakdown: None,
947        };
948        let json = serde_json::to_string(&window).expect("serializes");
949        let back: RateWindow = serde_json::from_str(&json).expect("round-trips");
950        assert_eq!(back, window);
951        assert!(
952            json.contains("\"perMinutes\":43200"),
953            "camelCase on the wire: {json}"
954        );
955    }
956
957    /// A mechanic with no rate is a valid statement.
958    ///
959    /// "Credits refresh monthly" with no amount is a real upstream shape, and it
960    /// is precisely the case a rate-only field could not have expressed — which
961    /// is why this is an object rather than a bare rate.
962    #[test]
963    fn a_mechanic_without_a_rate_is_valid() {
964        let json = r#"{"usedPercent":10.0,"regeneration":{"mechanic":"drip"}}"#;
965        let window: RateWindow = serde_json::from_str(json).expect("decodes");
966        let regeneration = window.regeneration.expect("the mechanic is stated");
967        assert_eq!(regeneration.mechanic, "drip");
968        assert_eq!(
969            regeneration.rate, None,
970            "the mechanic stands without a rate"
971        );
972    }
973
974    /// A mechanic this version has never seen decodes intact.
975    ///
976    /// THE REASON `mechanic` IS A STRING. On an observability wire a new variant
977    /// must not delete the record that reports it: an enum here would fail the
978    /// whole entry, so the state a consumer most needs to see is the one that
979    /// would vanish. Consumers treat an unrecognised value as `unstated` --
980    /// render it, pace on nothing.
981    #[test]
982    fn an_unrecognised_mechanic_decodes_rather_than_dropping_the_window() {
983        let json = r#"{"usedPercent":10.0,"regeneration":{"mechanic":"stepped_thaw"}}"#;
984        let window: RateWindow = serde_json::from_str(json).expect("a future variant must decode");
985        assert_eq!(
986            window.regeneration.expect("present").mechanic,
987            "stepped_thaw"
988        );
989        assert_eq!(window.used_percent, 10.0, "the rest of the window survives");
990    }
991
992    /// An entry without the field serializes exactly as before.
993    ///
994    /// Every consumer decoding today's shape must keep working, so absence has
995    /// to be byte-identical rather than merely tolerated.
996    #[test]
997    fn a_fresh_entry_carries_no_stale_key() {
998        let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
999        let json = serde_json::to_string(&entry).expect("entry serializes");
1000
1001        assert!(
1002            !json.contains("stale"),
1003            "a fresh entry must not emit the key at all: {json}"
1004        );
1005    }
1006
1007    /// A preserved reading states when the producer stopped being able to look.
1008    ///
1009    /// `since` is deliberately not `fetchedAt`: the reading was taken when it
1010    /// was taken, and the failure began afterwards. The gap between the two is
1011    /// how long the producer has been blind, which is the quantity a staleness
1012    /// policy wants -- an entry can be minutes old with the producer healthy,
1013    /// or seconds old with it failing since just after the read.
1014    #[test]
1015    fn a_preserved_reading_states_when_the_failure_began() {
1016        let mut entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
1017        entry.fetched_at = Some("2026-08-13T10:00:00Z".to_string());
1018        entry.stale = Some(Stale {
1019            since: "2026-08-13T10:02:00Z".to_string(),
1020            class: Some("upstream_failed".to_string()),
1021        });
1022
1023        let json = serde_json::to_string(&entry).expect("entry serializes");
1024        let back: ProviderUsage = serde_json::from_str(&json).expect("entry round-trips");
1025        let stale = back.stale.expect("the disclosure survives the round trip");
1026
1027        assert_eq!(stale.since, "2026-08-13T10:02:00Z");
1028        assert_eq!(stale.class.as_deref(), Some("upstream_failed"));
1029        assert_ne!(
1030            Some(stale.since.as_str()),
1031            back.fetched_at.as_deref(),
1032            "the two timestamps answer different questions and must not be conflated"
1033        );
1034        assert!(
1035            json.contains("\"stale\""),
1036            "the key is camelCase on the wire: {json}"
1037        );
1038    }
1039
1040    /// A producer that cannot classify the failure still discloses the state.
1041    ///
1042    /// Optional rather than required so a preserved reading is never suppressed
1043    /// for want of a label -- disclosing "this is stale, cause unstated" beats
1044    /// looking fresh.
1045    #[test]
1046    fn a_disclosure_without_a_class_still_decodes() {
1047        let json = r#"{"provider":"codex","stale":{"since":"2026-08-13T10:02:00Z"}}"#;
1048        let entry: ProviderUsage = serde_json::from_str(json).expect("decodes");
1049        let stale = entry.stale.expect("present");
1050
1051        assert_eq!(stale.class, None);
1052        assert_eq!(stale.since, "2026-08-13T10:02:00Z");
1053    }
1054
1055    /// An entry from a producer that predates the field decodes unchanged.
1056    #[test]
1057    fn an_entry_without_the_field_decodes() {
1058        let json = r#"{"provider":"codex","source":"oauth"}"#;
1059        let entry: ProviderUsage = serde_json::from_str(json).expect("decodes");
1060
1061        assert_eq!(entry.stale, None);
1062        assert_eq!(entry.provider, "codex");
1063    }
1064
1065    use super::*;
1066
1067    #[test]
1068    fn account_info_is_omitted_when_empty_and_keeps_partial_labels() {
1069        let bare = ProviderUsage::healthy(
1070            "codex",
1071            None,
1072            "oauth",
1073            Usage {
1074                primary: Some(RateWindow {
1075                    used_percent: 10.0,
1076                    raw_used_percent: None,
1077                    resets_at: None,
1078                    window_minutes: Some(300),
1079                    used_count: None,
1080                    total_count: None,
1081                    regeneration: None,
1082                    breakdown: None,
1083                }),
1084                ..Default::default()
1085            },
1086        );
1087        let json = serde_json::to_string(&bare).unwrap();
1088        assert!(
1089            !json.contains("accountInfo"),
1090            "empty accountInfo must be omitted"
1091        );
1092
1093        let mut labeled = bare.clone();
1094        labeled.account_info = Some(AccountInfo {
1095            email: Some("a@b.com".to_string()),
1096            org_name: None,
1097            plan_type: Some("pro".to_string()),
1098        });
1099        let json = serde_json::to_string(&labeled).unwrap();
1100        assert!(json.contains("\"email\":\"a@b.com\""));
1101        assert!(json.contains("\"planType\":\"pro\""));
1102        assert!(!json.contains("orgName"), "absent orgName must be omitted");
1103    }
1104
1105    #[test]
1106    fn saved_resets_use_camel_case_and_round_trip() {
1107        let entry = ProviderUsage {
1108            saved_resets: Some(SavedResets {
1109                available_count: 2,
1110                soonest_expires_at: Some("2026-07-31T20:11:35Z".to_string()),
1111                credits: vec![CreditExpiry {
1112                    expires_at: "2026-07-31T20:11:35Z".to_string(),
1113                }],
1114            }),
1115            ..ProviderUsage::degraded("codex", "x")
1116        };
1117        let json = serde_json::to_string(&entry).unwrap();
1118        assert!(json.contains("\"savedResets\""));
1119        assert!(json.contains("\"availableCount\":2"));
1120        assert!(json.contains("\"soonestExpiresAt\""));
1121        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
1122        assert_eq!(back, entry);
1123    }
1124
1125    #[test]
1126    fn raw_used_percent_is_absent_from_unrelaxed_windows_and_camel_case_when_present() {
1127        let unrelaxed = RateWindow {
1128            used_percent: 41.0,
1129            raw_used_percent: None,
1130            resets_at: Some("2026-07-20T00:00:00Z".to_string()),
1131            window_minutes: Some(10080),
1132            used_count: None,
1133            total_count: None,
1134            regeneration: None,
1135            breakdown: None,
1136        };
1137        let json = serde_json::to_string(&unrelaxed).unwrap();
1138        assert!(
1139            !json.contains("rawUsedPercent"),
1140            "unrelaxed window must not carry the field"
1141        );
1142
1143        let relaxed = RateWindow {
1144            used_percent: 0.0,
1145            raw_used_percent: Some(70.0),
1146            resets_at: Some("2026-07-20T00:00:00Z".to_string()),
1147            window_minutes: Some(10080),
1148            used_count: None,
1149            total_count: None,
1150            regeneration: None,
1151            breakdown: None,
1152        };
1153        let json = serde_json::to_string(&relaxed).unwrap();
1154        assert!(json.contains("\"rawUsedPercent\":70.0"));
1155        let back: RateWindow = serde_json::from_str(&json).unwrap();
1156        assert_eq!(back, relaxed);
1157    }
1158
1159    #[test]
1160    fn healthy_entry_omits_error_and_degraded_entry_omits_usage() {
1161        let healthy = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
1162        let json = serde_json::to_string(&healthy).unwrap();
1163        assert!(!json.contains("error"));
1164
1165        let degraded = ProviderUsage::degraded("codex", "no session");
1166        let json = serde_json::to_string(&degraded).unwrap();
1167        assert!(json.contains("\"error\":\"no session\""));
1168        assert!(!json.contains("usage"));
1169    }
1170
1171    #[test]
1172    fn api_provider_is_camel_case_present_when_set_and_omitted_when_absent() {
1173        let mut entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
1174        let json = serde_json::to_string(&entry).unwrap();
1175        assert!(
1176            !json.contains("apiProvider"),
1177            "absent api_provider must be omitted"
1178        );
1179
1180        entry.api_provider = Some("openai".to_string());
1181        let json = serde_json::to_string(&entry).unwrap();
1182        assert!(json.contains("\"apiProvider\":\"openai\""));
1183        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
1184        assert_eq!(back, entry);
1185    }
1186
1187    #[test]
1188    fn used_count_and_total_count_are_camel_case_and_omitted_when_absent() {
1189        let window = RateWindow {
1190            used_percent: 25.8,
1191            raw_used_percent: None,
1192            resets_at: Some("2026-07-26T14:09:00Z".to_string()),
1193            window_minutes: Some(10080),
1194            used_count: None,
1195            total_count: None,
1196            regeneration: None,
1197            breakdown: None,
1198        };
1199        let json = serde_json::to_string(&window).unwrap();
1200        assert!(
1201            !json.contains("usedCount"),
1202            "absent used_count must be omitted"
1203        );
1204        assert!(
1205            !json.contains("totalCount"),
1206            "absent total_count must be omitted"
1207        );
1208
1209        let enriched = RateWindow {
1210            used_count: Some(10336.0),
1211            total_count: Some(40000.0),
1212            ..window
1213        };
1214        let json = serde_json::to_string(&enriched).unwrap();
1215        assert!(json.contains("\"usedCount\":10336.0"));
1216        assert!(json.contains("\"totalCount\":40000.0"));
1217        let back: RateWindow = serde_json::from_str(&json).unwrap();
1218        assert_eq!(back, enriched);
1219    }
1220
1221    /// The field is additive: a producer that does not set it must serialize
1222    /// exactly as before, or adding it changes every existing entry on the wire.
1223    #[test]
1224    fn an_entry_without_a_class_serializes_as_it_did_before_the_field_existed() {
1225        let entry = ProviderUsage::degraded("codex", "no session: nothing configured");
1226        let json = serde_json::to_string(&entry).unwrap();
1227
1228        assert!(!json.contains("errorClass"), "absent class must be omitted");
1229        // Not vacuous: the entry really is a degraded one carrying its message,
1230        // so this cannot pass by serializing something empty.
1231        assert!(json.contains("\"error\":\"no session: nothing configured\""));
1232
1233        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
1234        assert_eq!(back, entry);
1235        assert_eq!(back.error_class, None);
1236    }
1237
1238    #[test]
1239    fn a_class_round_trips_under_its_camel_case_wire_name() {
1240        let entry = ProviderUsage::degraded_with_class(
1241            "gemini",
1242            "credential unusable: gemini creds have no refresh_token",
1243            "credential_unusable",
1244        );
1245        let json = serde_json::to_string(&entry).unwrap();
1246
1247        assert!(json.contains("\"errorClass\":\"credential_unusable\""));
1248        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
1249        assert_eq!(back, entry);
1250    }
1251
1252    /// The classes are open by design, so a consumer built today must survive a
1253    /// producer that ships a class it has never heard of. Modelling the field as
1254    /// a `String` is what buys that: an enum would make this a parse failure,
1255    /// and on an observability surface a parse failure means the entry vanishes
1256    /// at the moment its state changed.
1257    #[test]
1258    fn an_unknown_class_decodes_rather_than_failing() {
1259        let json = r#"{"provider":"someprovider","error":"something new","errorClass":"a_class_from_the_future"}"#;
1260
1261        let entry: ProviderUsage =
1262            serde_json::from_str(json).expect("an unrecognised class must not fail to decode");
1263
1264        assert_eq!(
1265            entry.error_class.as_deref(),
1266            Some("a_class_from_the_future")
1267        );
1268        // The rest of the entry survives intact, so a consumer can still render
1269        // it as degraded-with-unknown-reason rather than dropping it.
1270        assert_eq!(entry.provider, "someprovider");
1271        assert_eq!(entry.error.as_deref(), Some("something new"));
1272    }
1273
1274    /// An entry with no pools serializes exactly as it did before pools existed.
1275    ///
1276    /// Consumers pin these payloads, so an additive field that appears as `null`
1277    /// on every existing entry is not additive in practice. The check is on the
1278    /// rendered text rather than on the field, because that is what a consumer
1279    /// parses.
1280    #[test]
1281    fn an_entry_without_pools_does_not_mention_them() {
1282        let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
1283        let json = serde_json::to_string(&entry).unwrap();
1284        assert!(!json.contains("spend"), "unexpected spend key: {json}");
1285    }
1286
1287    /// Pools survive a round trip, including the two fields a consumer must read
1288    /// before acting on an amount.
1289    ///
1290    /// `basis` and `funding` are what separate "you have 10 granted credits
1291    /// left" from "you were granted 10 credits and we cannot tell how many
1292    /// remain". A consumer that loses either one is left with a number it cannot
1293    /// safely spend against.
1294    #[test]
1295    fn pools_round_trip_with_their_basis_and_funding() {
1296        let pool = Pool {
1297            id: "granted_balance".to_string(),
1298            label: "Granted".to_string(),
1299            funding: PoolFunding::Granted,
1300            remaining: Some(Amount {
1301                minor: 1050,
1302                exponent: 2,
1303                unit: "CNY".to_string(),
1304            }),
1305            total: None,
1306            basis: PoolBasis::Reported,
1307            spendable: Some(true),
1308            resets_at: None,
1309        };
1310        let mut entry = ProviderUsage::healthy("deepseek", None, "api", Usage::default());
1311        entry.spend = Some(vec![pool.clone()]);
1312
1313        let json = serde_json::to_string(&entry).unwrap();
1314        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
1315        assert_eq!(back.spend, Some(vec![pool]));
1316
1317        // Rendered as the wire spells them, since consumers key on these.
1318        assert!(json.contains(r#""funding":"granted""#), "{json}");
1319        assert!(json.contains(r#""basis":"reported""#), "{json}");
1320        // 10.50 CNY is carried as minor units, never as a float.
1321        assert!(json.contains(r#""minor":1050"#), "{json}");
1322        assert!(
1323            !json.contains("10.5"),
1324            "an amount was rendered as a decimal: {json}"
1325        );
1326    }
1327
1328    /// A stated reset survives a round trip under its wire name, and an absent
1329    /// one is not rendered at all.
1330    #[test]
1331    fn a_pool_reset_round_trips_and_is_omitted_when_absent() {
1332        let pool = Pool {
1333            id: "individual_limit".to_string(),
1334            label: "Credit limit".to_string(),
1335            funding: PoolFunding::Unknown,
1336            remaining: None,
1337            total: None,
1338            basis: PoolBasis::Unstated,
1339            spendable: None,
1340            resets_at: Some("2026-10-01T00:00:00Z".to_string()),
1341        };
1342        let json = serde_json::to_string(&pool).unwrap();
1343        assert!(
1344            json.contains(r#""resetsAt":"2026-10-01T00:00:00Z""#),
1345            "{json}"
1346        );
1347        assert_eq!(serde_json::from_str::<Pool>(&json).unwrap(), pool);
1348
1349        let unstated = Pool {
1350            resets_at: None,
1351            ..pool
1352        };
1353        let json = serde_json::to_string(&unstated).unwrap();
1354        assert!(!json.contains("resetsAt"), "{json}");
1355    }
1356
1357    /// A pool written before `resetsAt` existed still decodes, to `None`.
1358    ///
1359    /// The direction that bites: a producer on an older crate, or a stored
1360    /// payload, has no such key. A required field would fail every such pool,
1361    /// and the whole entry with it, rate windows included. serde already decodes
1362    /// a missing `Option` as `None` (dropping `default` alone leaves this green,
1363    /// measured), so what this pins is that the field stays optional.
1364    #[test]
1365    fn a_pool_without_a_reset_key_decodes_to_none() {
1366        let json = r#"{ "id": "credits", "label": "Credits", "funding": "unknown", "basis": "reported",
1367                       "remaining": { "minor": 2402, "exponent": 2, "unit": "credit" } }"#;
1368        let pool: Pool = serde_json::from_str(json).unwrap();
1369        assert_eq!(pool.resets_at, None);
1370    }
1371
1372    /// An unrecognised funding kind must not take the entry down with it.
1373    ///
1374    /// This payload crosses a repository boundary: one project produces it,
1375    /// others consume it, and their versions move independently. A closed enum
1376    /// makes the first new funding kind fail deserialization of the WHOLE
1377    /// `ProviderUsage` entry rather than one field, so an account's rate windows
1378    /// would vanish because of a credit pool the consumer had never heard of --
1379    /// and a vanished entry reads as the provider being unavailable.
1380    ///
1381    /// Asserted on a mixed entry rather than on the enum alone, because the
1382    /// blast radius is the point: the usage figure below is what a router acts
1383    /// on, and it is downstream of the pool that failed.
1384    #[test]
1385    fn an_unknown_funding_kind_does_not_discard_the_entry() {
1386        let json = r#"{
1387            "provider": "minimax",
1388            "usage": { "primary": { "usedPercent": 42.0 } },
1389            "spend": [
1390              { "id": "a", "label": "A", "funding": "granted",     "basis": "reported" },
1391              { "id": "b", "label": "B", "funding": "crypto_grant", "basis": "reported" }
1392            ]
1393        }"#;
1394
1395        let entry: ProviderUsage = serde_json::from_str(json).expect("entry must survive");
1396        let pools = entry.spend.expect("pools present");
1397        assert_eq!(pools.len(), 2, "no pool may be dropped");
1398        assert_eq!(pools[0].funding, PoolFunding::Granted);
1399        // The unrecognised kind lands on Unknown, which is the correct reading:
1400        // a funding this consumer cannot name is one it must not spend from.
1401        assert_eq!(pools[1].funding, PoolFunding::Unknown);
1402        // And the part a router acts on survived.
1403        assert_eq!(
1404            entry.usage.and_then(|u| u.primary).map(|w| w.used_percent),
1405            Some(42.0)
1406        );
1407    }
1408
1409    /// An unrecognised basis reads as unstated, never as exact.
1410    ///
1411    /// The two poles are not symmetrical. Treating an exact remainder as a
1412    /// ceiling under-spends and costs nothing; treating a ceiling as exact
1413    /// spends money that may not be there. So the fallback folds to the
1414    /// conservative side, and does so under its own name rather than claiming
1415    /// the number was derived -- which would assert a fact about a computation
1416    /// the consumer knows nothing about.
1417    #[test]
1418    fn an_unknown_basis_is_unstated_rather_than_exact() {
1419        let json = r#"{ "id": "a", "label": "A", "funding": "granted",
1420                        "basis": "sampled_hourly" }"#;
1421        let pool: Pool = serde_json::from_str(json).expect("pool must survive");
1422        assert_eq!(pool.basis, PoolBasis::Unstated);
1423        assert_ne!(
1424            pool.basis,
1425            PoolBasis::Reported,
1426            "an unknown basis must never read as an exact remainder"
1427        );
1428    }
1429
1430    /// A healthy entry must never carry a class: the field's presence is itself
1431    /// a signal, and a class on a working provider would be a contradiction a
1432    /// consumer has to resolve.
1433    #[test]
1434    fn a_healthy_entry_carries_no_class() {
1435        let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
1436        assert_eq!(entry.error_class, None);
1437        assert!(!serde_json::to_string(&entry)
1438            .unwrap()
1439            .contains("errorClass"));
1440    }
1441
1442    fn weekly(breakdown: Option<UsageBreakdown>) -> RateWindow {
1443        RateWindow {
1444            used_percent: 85.0,
1445            raw_used_percent: None,
1446            resets_at: Some("2026-09-30T14:00:00Z".to_string()),
1447            window_minutes: Some(10_080),
1448            used_count: None,
1449            total_count: None,
1450            regeneration: None,
1451            breakdown,
1452        }
1453    }
1454
1455    /// The field is additive: a window without a split serializes exactly as
1456    /// before, with no `breakdown` key, so every existing entry is unchanged.
1457    #[test]
1458    fn a_window_without_a_breakdown_omits_the_key() {
1459        let json = serde_json::to_string(&weekly(None)).unwrap();
1460        assert!(!json.contains("breakdown"), "{json}");
1461    }
1462
1463    /// A window written before `breakdown` existed still decodes, to `None`.
1464    #[test]
1465    fn a_window_from_before_the_field_decodes_without_one() {
1466        let json =
1467            r#"{"usedPercent":85.0,"resetsAt":"2026-09-30T14:00:00Z","windowMinutes":10080}"#;
1468        let window: RateWindow = serde_json::from_str(json).unwrap();
1469        assert_eq!(window.breakdown, None);
1470    }
1471
1472    /// The split round-trips with the upstream's keys verbatim, including one no
1473    /// consumer has seen, and a row with no figure stays without one rather than
1474    /// becoming zero.
1475    #[test]
1476    fn a_breakdown_round_trips_keys_verbatim_and_keeps_a_missing_share_absent() {
1477        let breakdown = UsageBreakdown {
1478            as_of: Some("2026-09-27T08:37:37Z".to_string()),
1479            window_started_at: Some("2026-09-23T14:00:00Z".to_string()),
1480            rows: vec![
1481                BreakdownRow {
1482                    key: "claude_code".to_string(),
1483                    share_percent: Some(100.0),
1484                },
1485                BreakdownRow {
1486                    key: "chat".to_string(),
1487                    share_percent: Some(0.0),
1488                },
1489                BreakdownRow {
1490                    key: "a_surface_from_the_future".to_string(),
1491                    share_percent: None,
1492                },
1493            ],
1494        };
1495        let window = weekly(Some(breakdown));
1496        let json = serde_json::to_string(&window).unwrap();
1497        assert!(
1498            json.contains(r#""breakdown":{"asOf":"2026-09-27T08:37:37Z","windowStartedAt":"2026-09-23T14:00:00Z","rows":[{"key":"claude_code","sharePercent":100.0},{"key":"chat","sharePercent":0.0},{"key":"a_surface_from_the_future"}]}"#),
1499            "{json}"
1500        );
1501        let back: RateWindow = serde_json::from_str(&json).unwrap();
1502        assert_eq!(back, window);
1503        assert_eq!(back.breakdown.unwrap().rows[2].share_percent, None);
1504    }
1505}