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}
330
331/// Account labels and subscription information supplied by a provider or vault.
332#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
333#[serde(rename_all = "camelCase")]
334pub struct AccountInfo {
335    /// Account email. Absent when the upstream does not identify the account that
336    /// way — not a signal about the account itself.
337    #[serde(skip_serializing_if = "Option::is_none", default)]
338    pub email: Option<String>,
339    /// Organisation label. Absent when the upstream reports none; absent does not
340    /// mean a personal account.
341    #[serde(skip_serializing_if = "Option::is_none", default)]
342    pub org_name: Option<String>,
343    /// The upstream's **own** plan label, not a normalised vocabulary, so it is
344    /// not comparable across providers. Display and grouping only. Absent when
345    /// the upstream states no plan.
346    #[serde(skip_serializing_if = "Option::is_none", default)]
347    pub plan_type: Option<String>,
348}
349
350impl AccountInfo {
351    pub fn is_empty(&self) -> bool {
352        self.email.is_none() && self.org_name.is_none() && self.plan_type.is_none()
353    }
354}
355
356/// One saved reset credit and its expiry time.
357#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
358#[serde(rename_all = "camelCase")]
359pub struct CreditExpiry {
360    pub expires_at: String,
361}
362
363/// Saved reset credits reported by Codex's read-only credits endpoint.
364#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
365#[serde(rename_all = "camelCase")]
366pub struct SavedResets {
367    #[serde(default)]
368    pub available_count: u32,
369    /// When the next credit lapses. Absent means **no credit states an expiry**,
370    /// which is not the same as none expiring soon.
371    #[serde(skip_serializing_if = "Option::is_none", default)]
372    pub soonest_expires_at: Option<String>,
373    #[serde(default)]
374    pub credits: Vec<CreditExpiry>,
375}
376
377fn account_info_is_empty(value: &Option<AccountInfo>) -> bool {
378    value.as_ref().map(AccountInfo::is_empty).unwrap_or(true)
379}
380
381/// One provider/account's usage entry. The `/usage` response is an array of
382/// these. A fetch failure becomes an entry carrying `error` (silent-degrade),
383/// never a failure of the whole array.
384#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
385#[serde(rename_all = "camelCase")]
386pub struct ProviderUsage {
387    /// CodexBar provider name (e.g. "codex"), which consumers map to their own id.
388    pub provider: String,
389    /// Canonical API provider identifier — the models.dev slug for the same
390    /// provider (e.g. "openai" when `provider == "codex"`, "anthropic" for
391    /// "claude", "google" for "gemini", "xai" for "grok"). Present when the
392    /// producer knows the canonical name; absent for providers with no models.dev
393    /// counterpart, where consumers fall back to `provider`. Lets every consumer
394    /// key on one canonical name instead of each maintaining its own
395    /// CodexBar-name → canonical map.
396    #[serde(skip_serializing_if = "Option::is_none", default)]
397    pub api_provider: Option<String>,
398    /// The account this entry describes, as the credential store identifies it.
399    ///
400    /// Absent means the producer **could not resolve an identity for this
401    /// credential**, not that the provider has one account. Some credentials
402    /// carry no account identity at all (a bare API key), and an entry is also
403    /// emitted unlabelled while an identity is still being confirmed — so an
404    /// unlabelled entry is not evidence that a labelled one does not exist.
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub account: Option<String>,
407    /// Which retrieval path produced this (e.g. "oauth") — observability only.
408    ///
409    /// **Per lane, not per account, and it moves.** One account can be reached
410    /// through more than one credential path, and which one answers is decided
411    /// per fetch by whichever is healthy. So the same account can report one
412    /// value on a poll and another on the next with nothing having changed about
413    /// the account, the credential, or anything the consumer did. Do not key on
414    /// it, branch on it, or treat a change in it as an event.
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub source: Option<String>,
417    /// Display labels for the account. Absent when the upstream supplies none of
418    /// them; carries no operational meaning.
419    #[serde(skip_serializing_if = "account_info_is_empty", default)]
420    pub account_info: Option<AccountInfo>,
421    /// When this entry's figures were last **successfully** fetched — producer
422    /// time, per entry, never a common instant across the array.
423    ///
424    /// Absent means this credential has never had a successful fetch. It keeps
425    /// its old value while a failure is being retried, so it ages honestly rather
426    /// than pausing; never restamp it with your own poll time.
427    #[serde(skip_serializing_if = "Option::is_none", default)]
428    pub fetched_at: Option<String>,
429    /// Banked quota-reset credits held by this account.
430    ///
431    /// Absent means there is no credit inventory to report — which includes the
432    /// inventory lookup having **failed** on this fetch, since it is separate
433    /// from the usage fetch and may fail without degrading the entry. Absent is
434    /// therefore not "zero credits held".
435    #[serde(skip_serializing_if = "Option::is_none", default)]
436    pub saved_resets: Option<SavedResets>,
437    /// The windows. Absent on a degraded entry, and on an entry whose credential
438    /// works but whose account reports no quota at all — read `error` and
439    /// `error_class` to tell those apart, rather than inferring from this field.
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub usage: Option<Usage>,
442    /// Prepaid balances and credit pools on this account, when the provider
443    /// reports any.
444    ///
445    /// Deliberately apart from [`Self::usage`], because a pool and a rate window
446    /// are different facts that fail in opposite directions: over-consuming a
447    /// window gets you throttled and recovers by waiting, while over-consuming a
448    /// balance gets you billed and recovers by paying. Nothing in a routing loop
449    /// can undo the second, so a balance is never expressed as a window, never
450    /// carries a reset, and never appears as a percentage — a consumer that
451    /// found one where it expects headroom would pace into a bill.
452    ///
453    /// Absent means the producer has nothing to say, which is not the same as an
454    /// account having no credit. Empty means it looked and the provider reports
455    /// no pools.
456    #[serde(skip_serializing_if = "Option::is_none", default)]
457    pub spend: Option<Vec<Pool>>,
458    /// Present only on a degraded entry. The consumer skips any entry with a
459    /// truthy `error`.
460    #[serde(skip_serializing_if = "Option::is_none")]
461    pub error: Option<String>,
462    /// A stable, machine-readable name for *why* a degraded entry failed,
463    /// published beside the human-readable `error`.
464    ///
465    /// `error` is prose with no stability promise, so consumers are told not to
466    /// branch on it — which leaves them no way to separate failures that mean
467    /// something from failures that are a permanent, correct state. A host that
468    /// never configured a provider and a host whose credential broke this
469    /// morning both produce a degraded entry, and only the second is worth
470    /// anyone's attention.
471    ///
472    /// Classes currently produced:
473    ///
474    /// | Value | Meaning |
475    /// |---|---|
476    /// | `credential_absent` | No credential was found. Permanent and correct on a host that never configured this provider; nothing to fix. |
477    /// | `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. |
478    /// | `credential_rejected` | The upstream rejected the credential (401/403). Usually means logging in again. |
479    /// | `no_quota_reported` | The credential works and the account genuinely has no quota to report. Not a failure. |
480    /// | `upstream_failed` | The upstream could not be reached or returned an error status. Usually transient. |
481    /// | `decode_failed` | The response arrived but was not the expected shape. |
482    ///
483    /// **This list will grow.** A consumer must render an unrecognised class as
484    /// a degraded entry with an unknown reason — never drop the entry, and
485    /// never fold it into an existing bucket. It is a `String` rather than an
486    /// enum for exactly that reason: on an observability surface, meeting an
487    /// unknown value must not turn into a parse failure that makes a provider
488    /// disappear at the moment its state changed.
489    ///
490    /// Absent on healthy entries, and absent from any producer that predates
491    /// this field.
492    #[serde(skip_serializing_if = "Option::is_none", default)]
493    pub error_class: Option<String>,
494    /// Present when this entry is a last-known-good reading served through an
495    /// ongoing failure, absent when it is a fresh success.
496    ///
497    /// Without it a preserved reading is byte-identical to a fresh one apart
498    /// from `fetched_at`, so a consumer cannot separate "this figure is old
499    /// because the producer has been unable to reach the provider" from "this
500    /// figure is old because nothing polled recently". Those have opposite
501    /// remedies — the first is a reason to stop acting on the number, the
502    /// second is not — and a consumer with only a timestamp has to guess with a
503    /// wall-clock threshold, which denies fresh-enough data to catch stale data.
504    ///
505    /// A producer serving preserved readings is behaving correctly: a brief
506    /// upstream failure should not blank a window. This field discloses that it
507    /// is happening rather than reporting a fault.
508    #[serde(skip_serializing_if = "Option::is_none", default)]
509    pub stale: Option<Stale>,
510}
511
512/// Why an entry is being served through a failure, and since when.
513#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
514#[serde(rename_all = "camelCase")]
515pub struct Stale {
516    /// When the producer first failed to refresh this entry, RFC3339.
517    ///
518    /// Distinct from `fetched_at`, which is when the served reading was
519    /// obtained. The gap between them is how long the producer has been unable
520    /// to look, which is the quantity a staleness policy actually wants: a
521    /// reading can be minutes old with the producer perfectly healthy, or
522    /// seconds old with the producer failing since just after it was taken.
523    pub since: String,
524    /// The failure class, using the same vocabulary as `error_class` on a
525    /// degraded entry.
526    ///
527    /// Carried so a consumer can tell a flapping upstream from a credential
528    /// that has started refusing, without branching on prose. Optional because
529    /// a producer may preserve a reading for a reason it cannot classify.
530    #[serde(skip_serializing_if = "Option::is_none", default)]
531    pub class: Option<String>,
532}
533
534impl ProviderUsage {
535    /// A healthy entry with resolved windows.
536    pub fn healthy(provider: &str, account: Option<String>, source: &str, usage: Usage) -> Self {
537        Self {
538            provider: provider.to_string(),
539            api_provider: None,
540            account,
541            source: Some(source.to_string()),
542            account_info: None,
543            fetched_at: None,
544            saved_resets: None,
545            usage: Some(usage),
546            spend: None,
547            error: None,
548            error_class: None,
549            stale: None,
550        }
551    }
552
553    /// A degraded entry: the provider is named so the consumer can correlate,
554    /// but it carries only an error string and no windows.
555    pub fn degraded(provider: &str, error: impl std::fmt::Display) -> Self {
556        Self {
557            provider: provider.to_string(),
558            api_provider: None,
559            account: None,
560            source: None,
561            account_info: None,
562            fetched_at: None,
563            saved_resets: None,
564            usage: None,
565            spend: None,
566            error: Some(error.to_string()),
567            error_class: None,
568            stale: None,
569        }
570    }
571
572    /// A degraded entry that also names *why* it failed.
573    ///
574    /// Prefer this over [`Self::degraded`] wherever the producer knows the
575    /// class: without it a consumer can only tell an unconfigured provider from
576    /// a broken one by reading prose it has been told not to parse. See
577    /// [`ProviderUsage::error_class`] for the classes and for the rule that an
578    /// unrecognised one must still render.
579    pub fn degraded_with_class(
580        provider: &str,
581        error: impl std::fmt::Display,
582        error_class: impl Into<String>,
583    ) -> Self {
584        Self {
585            error_class: Some(error_class.into()),
586            ..Self::degraded(provider, error)
587        }
588    }
589}
590
591#[cfg(test)]
592mod tests {
593
594    /// A window with no stated mechanic serializes exactly as before.
595    ///
596    /// The additive guarantee: every existing producer omits the field, and a
597    /// consumer on the previous version must see byte-identical output.
598    #[test]
599    fn a_window_without_regeneration_is_unchanged_on_the_wire() {
600        let window = RateWindow {
601            used_percent: 42.0,
602            raw_used_percent: None,
603            resets_at: Some("2026-08-17T00:00:00Z".to_string()),
604            window_minutes: Some(300),
605            used_count: None,
606            total_count: None,
607            regeneration: None,
608        };
609        let json = serde_json::to_string(&window).expect("serializes");
610        assert!(
611            !json.contains("regeneration"),
612            "an absent mechanic must not appear on the wire: {json}"
613        );
614    }
615
616    /// A payload from before this field decodes into the new shape.
617    #[test]
618    fn a_pre_regeneration_payload_still_decodes() {
619        let json = r#"{"usedPercent":42.0,"resetsAt":"2026-08-17T00:00:00Z","windowMinutes":300}"#;
620        let window: RateWindow = serde_json::from_str(json).expect("older payloads must decode");
621        assert_eq!(window.regeneration, None);
622    }
623
624    /// The observed cliff shape round-trips, rate and all.
625    ///
626    /// SHAPE FROM A LIVE CAPTURE (insula#1, 2026-08-17): a credentialed JetBrains
627    /// account stating `tariff: { amount: "1000000", duration: "PT720H" }` beside
628    /// a known next-refill instant. First observed rate-bearing payload; the
629    /// numbers here are its scrubbed values.
630    #[test]
631    fn a_stated_cliff_refill_round_trips() {
632        let window = RateWindow {
633            used_percent: 0.67,
634            raw_used_percent: None,
635            resets_at: Some("2026-08-15T06:00:00.000Z".to_string()),
636            window_minutes: None,
637            used_count: Some(8100.0),
638            total_count: Some(1_207_000.0),
639            regeneration: Some(Regeneration {
640                mechanic: "cliff".to_string(),
641                rate: Some(RegenerationRate {
642                    amount: 1_000_000.0,
643                    per_minutes: 43_200,
644                }),
645            }),
646        };
647        let json = serde_json::to_string(&window).expect("serializes");
648        let back: RateWindow = serde_json::from_str(&json).expect("round-trips");
649        assert_eq!(back, window);
650        assert!(
651            json.contains("\"perMinutes\":43200"),
652            "camelCase on the wire: {json}"
653        );
654    }
655
656    /// A mechanic with no rate is a valid statement.
657    ///
658    /// "Credits refresh monthly" with no amount is a real upstream shape, and it
659    /// is precisely the case a rate-only field could not have expressed — which
660    /// is why this is an object rather than a bare rate.
661    #[test]
662    fn a_mechanic_without_a_rate_is_valid() {
663        let json = r#"{"usedPercent":10.0,"regeneration":{"mechanic":"drip"}}"#;
664        let window: RateWindow = serde_json::from_str(json).expect("decodes");
665        let regeneration = window.regeneration.expect("the mechanic is stated");
666        assert_eq!(regeneration.mechanic, "drip");
667        assert_eq!(
668            regeneration.rate, None,
669            "the mechanic stands without a rate"
670        );
671    }
672
673    /// A mechanic this version has never seen decodes intact.
674    ///
675    /// THE REASON `mechanic` IS A STRING. On an observability wire a new variant
676    /// must not delete the record that reports it: an enum here would fail the
677    /// whole entry, so the state a consumer most needs to see is the one that
678    /// would vanish. Consumers treat an unrecognised value as `unstated` --
679    /// render it, pace on nothing.
680    #[test]
681    fn an_unrecognised_mechanic_decodes_rather_than_dropping_the_window() {
682        let json = r#"{"usedPercent":10.0,"regeneration":{"mechanic":"stepped_thaw"}}"#;
683        let window: RateWindow = serde_json::from_str(json).expect("a future variant must decode");
684        assert_eq!(
685            window.regeneration.expect("present").mechanic,
686            "stepped_thaw"
687        );
688        assert_eq!(window.used_percent, 10.0, "the rest of the window survives");
689    }
690
691    /// An entry without the field serializes exactly as before.
692    ///
693    /// Every consumer decoding today's shape must keep working, so absence has
694    /// to be byte-identical rather than merely tolerated.
695    #[test]
696    fn a_fresh_entry_carries_no_stale_key() {
697        let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
698        let json = serde_json::to_string(&entry).expect("entry serializes");
699
700        assert!(
701            !json.contains("stale"),
702            "a fresh entry must not emit the key at all: {json}"
703        );
704    }
705
706    /// A preserved reading states when the producer stopped being able to look.
707    ///
708    /// `since` is deliberately not `fetchedAt`: the reading was taken when it
709    /// was taken, and the failure began afterwards. The gap between the two is
710    /// how long the producer has been blind, which is the quantity a staleness
711    /// policy wants -- an entry can be minutes old with the producer healthy,
712    /// or seconds old with it failing since just after the read.
713    #[test]
714    fn a_preserved_reading_states_when_the_failure_began() {
715        let mut entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
716        entry.fetched_at = Some("2026-08-13T10:00:00Z".to_string());
717        entry.stale = Some(Stale {
718            since: "2026-08-13T10:02:00Z".to_string(),
719            class: Some("upstream_failed".to_string()),
720        });
721
722        let json = serde_json::to_string(&entry).expect("entry serializes");
723        let back: ProviderUsage = serde_json::from_str(&json).expect("entry round-trips");
724        let stale = back.stale.expect("the disclosure survives the round trip");
725
726        assert_eq!(stale.since, "2026-08-13T10:02:00Z");
727        assert_eq!(stale.class.as_deref(), Some("upstream_failed"));
728        assert_ne!(
729            Some(stale.since.as_str()),
730            back.fetched_at.as_deref(),
731            "the two timestamps answer different questions and must not be conflated"
732        );
733        assert!(
734            json.contains("\"stale\""),
735            "the key is camelCase on the wire: {json}"
736        );
737    }
738
739    /// A producer that cannot classify the failure still discloses the state.
740    ///
741    /// Optional rather than required so a preserved reading is never suppressed
742    /// for want of a label -- disclosing "this is stale, cause unstated" beats
743    /// looking fresh.
744    #[test]
745    fn a_disclosure_without_a_class_still_decodes() {
746        let json = r#"{"provider":"codex","stale":{"since":"2026-08-13T10:02:00Z"}}"#;
747        let entry: ProviderUsage = serde_json::from_str(json).expect("decodes");
748        let stale = entry.stale.expect("present");
749
750        assert_eq!(stale.class, None);
751        assert_eq!(stale.since, "2026-08-13T10:02:00Z");
752    }
753
754    /// An entry from a producer that predates the field decodes unchanged.
755    #[test]
756    fn an_entry_without_the_field_decodes() {
757        let json = r#"{"provider":"codex","source":"oauth"}"#;
758        let entry: ProviderUsage = serde_json::from_str(json).expect("decodes");
759
760        assert_eq!(entry.stale, None);
761        assert_eq!(entry.provider, "codex");
762    }
763
764    use super::*;
765
766    #[test]
767    fn account_info_is_omitted_when_empty_and_keeps_partial_labels() {
768        let bare = ProviderUsage::healthy(
769            "codex",
770            None,
771            "oauth",
772            Usage {
773                primary: Some(RateWindow {
774                    used_percent: 10.0,
775                    raw_used_percent: None,
776                    resets_at: None,
777                    window_minutes: Some(300),
778                    used_count: None,
779                    total_count: None,
780                    regeneration: None,
781                }),
782                ..Default::default()
783            },
784        );
785        let json = serde_json::to_string(&bare).unwrap();
786        assert!(
787            !json.contains("accountInfo"),
788            "empty accountInfo must be omitted"
789        );
790
791        let mut labeled = bare.clone();
792        labeled.account_info = Some(AccountInfo {
793            email: Some("a@b.com".to_string()),
794            org_name: None,
795            plan_type: Some("pro".to_string()),
796        });
797        let json = serde_json::to_string(&labeled).unwrap();
798        assert!(json.contains("\"email\":\"a@b.com\""));
799        assert!(json.contains("\"planType\":\"pro\""));
800        assert!(!json.contains("orgName"), "absent orgName must be omitted");
801    }
802
803    #[test]
804    fn saved_resets_use_camel_case_and_round_trip() {
805        let entry = ProviderUsage {
806            saved_resets: Some(SavedResets {
807                available_count: 2,
808                soonest_expires_at: Some("2026-07-31T20:11:35Z".to_string()),
809                credits: vec![CreditExpiry {
810                    expires_at: "2026-07-31T20:11:35Z".to_string(),
811                }],
812            }),
813            ..ProviderUsage::degraded("codex", "x")
814        };
815        let json = serde_json::to_string(&entry).unwrap();
816        assert!(json.contains("\"savedResets\""));
817        assert!(json.contains("\"availableCount\":2"));
818        assert!(json.contains("\"soonestExpiresAt\""));
819        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
820        assert_eq!(back, entry);
821    }
822
823    #[test]
824    fn raw_used_percent_is_absent_from_unrelaxed_windows_and_camel_case_when_present() {
825        let unrelaxed = RateWindow {
826            used_percent: 41.0,
827            raw_used_percent: None,
828            resets_at: Some("2026-07-20T00:00:00Z".to_string()),
829            window_minutes: Some(10080),
830            used_count: None,
831            total_count: None,
832            regeneration: None,
833        };
834        let json = serde_json::to_string(&unrelaxed).unwrap();
835        assert!(
836            !json.contains("rawUsedPercent"),
837            "unrelaxed window must not carry the field"
838        );
839
840        let relaxed = RateWindow {
841            used_percent: 0.0,
842            raw_used_percent: Some(70.0),
843            resets_at: Some("2026-07-20T00:00:00Z".to_string()),
844            window_minutes: Some(10080),
845            used_count: None,
846            total_count: None,
847            regeneration: None,
848        };
849        let json = serde_json::to_string(&relaxed).unwrap();
850        assert!(json.contains("\"rawUsedPercent\":70.0"));
851        let back: RateWindow = serde_json::from_str(&json).unwrap();
852        assert_eq!(back, relaxed);
853    }
854
855    #[test]
856    fn healthy_entry_omits_error_and_degraded_entry_omits_usage() {
857        let healthy = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
858        let json = serde_json::to_string(&healthy).unwrap();
859        assert!(!json.contains("error"));
860
861        let degraded = ProviderUsage::degraded("codex", "no session");
862        let json = serde_json::to_string(&degraded).unwrap();
863        assert!(json.contains("\"error\":\"no session\""));
864        assert!(!json.contains("usage"));
865    }
866
867    #[test]
868    fn api_provider_is_camel_case_present_when_set_and_omitted_when_absent() {
869        let mut entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
870        let json = serde_json::to_string(&entry).unwrap();
871        assert!(
872            !json.contains("apiProvider"),
873            "absent api_provider must be omitted"
874        );
875
876        entry.api_provider = Some("openai".to_string());
877        let json = serde_json::to_string(&entry).unwrap();
878        assert!(json.contains("\"apiProvider\":\"openai\""));
879        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
880        assert_eq!(back, entry);
881    }
882
883    #[test]
884    fn used_count_and_total_count_are_camel_case_and_omitted_when_absent() {
885        let window = RateWindow {
886            used_percent: 25.8,
887            raw_used_percent: None,
888            resets_at: Some("2026-07-26T14:09:00Z".to_string()),
889            window_minutes: Some(10080),
890            used_count: None,
891            total_count: None,
892            regeneration: None,
893        };
894        let json = serde_json::to_string(&window).unwrap();
895        assert!(
896            !json.contains("usedCount"),
897            "absent used_count must be omitted"
898        );
899        assert!(
900            !json.contains("totalCount"),
901            "absent total_count must be omitted"
902        );
903
904        let enriched = RateWindow {
905            used_count: Some(10336.0),
906            total_count: Some(40000.0),
907            ..window
908        };
909        let json = serde_json::to_string(&enriched).unwrap();
910        assert!(json.contains("\"usedCount\":10336.0"));
911        assert!(json.contains("\"totalCount\":40000.0"));
912        let back: RateWindow = serde_json::from_str(&json).unwrap();
913        assert_eq!(back, enriched);
914    }
915
916    /// The field is additive: a producer that does not set it must serialize
917    /// exactly as before, or adding it changes every existing entry on the wire.
918    #[test]
919    fn an_entry_without_a_class_serializes_as_it_did_before_the_field_existed() {
920        let entry = ProviderUsage::degraded("codex", "no session: nothing configured");
921        let json = serde_json::to_string(&entry).unwrap();
922
923        assert!(!json.contains("errorClass"), "absent class must be omitted");
924        // Not vacuous: the entry really is a degraded one carrying its message,
925        // so this cannot pass by serializing something empty.
926        assert!(json.contains("\"error\":\"no session: nothing configured\""));
927
928        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
929        assert_eq!(back, entry);
930        assert_eq!(back.error_class, None);
931    }
932
933    #[test]
934    fn a_class_round_trips_under_its_camel_case_wire_name() {
935        let entry = ProviderUsage::degraded_with_class(
936            "gemini",
937            "credential unusable: gemini creds have no refresh_token",
938            "credential_unusable",
939        );
940        let json = serde_json::to_string(&entry).unwrap();
941
942        assert!(json.contains("\"errorClass\":\"credential_unusable\""));
943        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
944        assert_eq!(back, entry);
945    }
946
947    /// The classes are open by design, so a consumer built today must survive a
948    /// producer that ships a class it has never heard of. Modelling the field as
949    /// a `String` is what buys that: an enum would make this a parse failure,
950    /// and on an observability surface a parse failure means the entry vanishes
951    /// at the moment its state changed.
952    #[test]
953    fn an_unknown_class_decodes_rather_than_failing() {
954        let json = r#"{"provider":"someprovider","error":"something new","errorClass":"a_class_from_the_future"}"#;
955
956        let entry: ProviderUsage =
957            serde_json::from_str(json).expect("an unrecognised class must not fail to decode");
958
959        assert_eq!(
960            entry.error_class.as_deref(),
961            Some("a_class_from_the_future")
962        );
963        // The rest of the entry survives intact, so a consumer can still render
964        // it as degraded-with-unknown-reason rather than dropping it.
965        assert_eq!(entry.provider, "someprovider");
966        assert_eq!(entry.error.as_deref(), Some("something new"));
967    }
968
969    /// An entry with no pools serializes exactly as it did before pools existed.
970    ///
971    /// Consumers pin these payloads, so an additive field that appears as `null`
972    /// on every existing entry is not additive in practice. The check is on the
973    /// rendered text rather than on the field, because that is what a consumer
974    /// parses.
975    #[test]
976    fn an_entry_without_pools_does_not_mention_them() {
977        let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
978        let json = serde_json::to_string(&entry).unwrap();
979        assert!(!json.contains("spend"), "unexpected spend key: {json}");
980    }
981
982    /// Pools survive a round trip, including the two fields a consumer must read
983    /// before acting on an amount.
984    ///
985    /// `basis` and `funding` are what separate "you have 10 granted credits
986    /// left" from "you were granted 10 credits and we cannot tell how many
987    /// remain". A consumer that loses either one is left with a number it cannot
988    /// safely spend against.
989    #[test]
990    fn pools_round_trip_with_their_basis_and_funding() {
991        let pool = Pool {
992            id: "granted_balance".to_string(),
993            label: "Granted".to_string(),
994            funding: PoolFunding::Granted,
995            remaining: Some(Amount {
996                minor: 1050,
997                exponent: 2,
998                unit: "CNY".to_string(),
999            }),
1000            total: None,
1001            basis: PoolBasis::Reported,
1002            spendable: Some(true),
1003        };
1004        let mut entry = ProviderUsage::healthy("deepseek", None, "api", Usage::default());
1005        entry.spend = Some(vec![pool.clone()]);
1006
1007        let json = serde_json::to_string(&entry).unwrap();
1008        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
1009        assert_eq!(back.spend, Some(vec![pool]));
1010
1011        // Rendered as the wire spells them, since consumers key on these.
1012        assert!(json.contains(r#""funding":"granted""#), "{json}");
1013        assert!(json.contains(r#""basis":"reported""#), "{json}");
1014        // 10.50 CNY is carried as minor units, never as a float.
1015        assert!(json.contains(r#""minor":1050"#), "{json}");
1016        assert!(
1017            !json.contains("10.5"),
1018            "an amount was rendered as a decimal: {json}"
1019        );
1020    }
1021
1022    /// An unrecognised funding kind must not take the entry down with it.
1023    ///
1024    /// This payload crosses a repository boundary: one project produces it,
1025    /// others consume it, and their versions move independently. A closed enum
1026    /// makes the first new funding kind fail deserialization of the WHOLE
1027    /// `ProviderUsage` entry rather than one field, so an account's rate windows
1028    /// would vanish because of a credit pool the consumer had never heard of --
1029    /// and a vanished entry reads as the provider being unavailable.
1030    ///
1031    /// Asserted on a mixed entry rather than on the enum alone, because the
1032    /// blast radius is the point: the usage figure below is what a router acts
1033    /// on, and it is downstream of the pool that failed.
1034    #[test]
1035    fn an_unknown_funding_kind_does_not_discard_the_entry() {
1036        let json = r#"{
1037            "provider": "minimax",
1038            "usage": { "primary": { "usedPercent": 42.0 } },
1039            "spend": [
1040              { "id": "a", "label": "A", "funding": "granted",     "basis": "reported" },
1041              { "id": "b", "label": "B", "funding": "crypto_grant", "basis": "reported" }
1042            ]
1043        }"#;
1044
1045        let entry: ProviderUsage = serde_json::from_str(json).expect("entry must survive");
1046        let pools = entry.spend.expect("pools present");
1047        assert_eq!(pools.len(), 2, "no pool may be dropped");
1048        assert_eq!(pools[0].funding, PoolFunding::Granted);
1049        // The unrecognised kind lands on Unknown, which is the correct reading:
1050        // a funding this consumer cannot name is one it must not spend from.
1051        assert_eq!(pools[1].funding, PoolFunding::Unknown);
1052        // And the part a router acts on survived.
1053        assert_eq!(
1054            entry.usage.and_then(|u| u.primary).map(|w| w.used_percent),
1055            Some(42.0)
1056        );
1057    }
1058
1059    /// An unrecognised basis reads as unstated, never as exact.
1060    ///
1061    /// The two poles are not symmetrical. Treating an exact remainder as a
1062    /// ceiling under-spends and costs nothing; treating a ceiling as exact
1063    /// spends money that may not be there. So the fallback folds to the
1064    /// conservative side, and does so under its own name rather than claiming
1065    /// the number was derived -- which would assert a fact about a computation
1066    /// the consumer knows nothing about.
1067    #[test]
1068    fn an_unknown_basis_is_unstated_rather_than_exact() {
1069        let json = r#"{ "id": "a", "label": "A", "funding": "granted",
1070                        "basis": "sampled_hourly" }"#;
1071        let pool: Pool = serde_json::from_str(json).expect("pool must survive");
1072        assert_eq!(pool.basis, PoolBasis::Unstated);
1073        assert_ne!(
1074            pool.basis,
1075            PoolBasis::Reported,
1076            "an unknown basis must never read as an exact remainder"
1077        );
1078    }
1079
1080    /// A healthy entry must never carry a class: the field's presence is itself
1081    /// a signal, and a class on a working provider would be a contradiction a
1082    /// consumer has to resolve.
1083    #[test]
1084    fn a_healthy_entry_carries_no_class() {
1085        let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
1086        assert_eq!(entry.error_class, None);
1087        assert!(!serde_json::to_string(&entry)
1088            .unwrap()
1089            .contains("errorClass"));
1090    }
1091}