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