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}
84
85/// A per-model window bundled under one account (e.g. Antigravity's Geminis).
86#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
87#[serde(rename_all = "camelCase")]
88pub struct ExtraWindow {
89    /// Human-facing label. Absent when the producer has no display text for this
90    /// window; render `id` instead rather than dropping the entry.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub title: Option<String>,
93    /// Stable identifier to match on. Absent when the producer cannot name the
94    /// window stably. **Not unique across providers** — one provider's ids are
95    /// model names, another's its own scope labels — so key on
96    /// `(provider, id)`, never on `id` alone.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub id: Option<String>,
99    /// The figures for this window. Absent means the provider **named a limit it
100    /// could not read a figure for**, which is not the same as no limit: the
101    /// entry is still evidence the limit exists.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub window: Option<RateWindow>,
104}
105
106/// The window topology for one account: up to three account-wide pools plus an
107/// optional list of per-model pools.
108#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
109#[serde(rename_all = "camelCase")]
110pub struct Usage {
111    /// The provider's **shortest** window, not its most constrained one. Absent
112    /// when the provider reported no window of that cadence.
113    ///
114    /// The three slots are positions, not a ranking, and **they can have holes**:
115    /// each is filled from its own optional upstream field, so `secondary` may be
116    /// absent while `tertiary` is present. Walk all three plus
117    /// `extra_rate_windows` rather than stopping at the first gap, and take the
118    /// maximum when asking how much headroom an account has.
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub primary: Option<RateWindow>,
121    /// The next cadence up, typically weekly. Absent means not reported — never
122    /// that the window exists at zero. See [`Usage::primary`] on slot holes.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub secondary: Option<RateWindow>,
125    /// A third account-wide window where a provider has one. Absent means not
126    /// reported. See [`Usage::primary`] on slot holes.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub tertiary: Option<RateWindow>,
129    /// Windows whose meaning has no slot — per-model pools, scoped weeklies.
130    /// Absent means the provider published none.
131    ///
132    /// These are **real limits**, not extras in the dispensable sense: a consumer
133    /// ignoring this list silently ignores whichever limits did not fit three
134    /// slots.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub extra_rate_windows: Option<Vec<ExtraWindow>>,
137}
138
139/// An amount of money or credit, in integer minor units.
140///
141/// Not a float, and the reason is not stylistic. A balance is compared against
142/// zero on every routing decision that reads it, and binary floating point
143/// cannot hold ordinary decimal amounts exactly — the nearest `f64` to `0.1` is
144/// not `0.1`, so sums drift and a comparison near zero can fall either way. The
145/// providers agree: DeepSeek and MiniMax both send decimal strings, and
146/// Anthropic sends integer minor units with an exponent.
147///
148/// Parse a provider's own representation once, where its precision is still
149/// known, rather than passing a float along and re-rendering it.
150#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
151#[serde(rename_all = "camelCase")]
152pub struct Amount {
153    /// The amount in minor units: `1050` with `exponent: 2` is 10.50.
154    pub minor: i64,
155    /// Decimal places in `minor`. `2` for currencies with cents; `0` for whole
156    /// credits or points.
157    pub exponent: u8,
158    /// What the amount is denominated in: a currency code like `"USD"`, or a
159    /// provider's own label for its credits.
160    ///
161    /// A free string rather than a currency enum, because not every pool is
162    /// money — some are points that convert to no currency, and an enum would
163    /// force those into a currency slot or drop them.
164    pub unit: String,
165}
166
167/// Where a pool's balance came from, which decides what a consumer may promise.
168#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
169#[serde(rename_all = "snake_case")]
170pub enum PoolFunding {
171    /// Given by the provider: a promotion, a trial grant, a voucher. Spendable
172    /// without a bill.
173    Granted,
174    /// Bought. Spending it costs money.
175    Purchased,
176    /// Included in a subscription the account already pays for.
177    Subscription,
178    /// The provider separates this pool but does not say what funds it, **or**
179    /// the producer named a funding kind this consumer does not recognise.
180    ///
181    /// A correct answer rather than a failure one: some providers name their
182    /// pools without defining them, and guessing the funding is how a consumer
183    /// ends up spending money it meant to protect.
184    ///
185    /// It is also the deserialization fallback, and the two meanings genuinely
186    /// agree — a funding kind added after this consumer was built is, to this
187    /// consumer, of unknown funding. Without the fallback an unrecognised value
188    /// fails the whole `ProviderUsage` entry rather than this one field, so a
189    /// new pool kind would take an account's *usage* down with it and read as
190    /// the provider being unavailable.
191    #[serde(other)]
192    Unknown,
193}
194
195/// How a pool's `remaining` was obtained.
196#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
197#[serde(rename_all = "snake_case")]
198pub enum PoolBasis {
199    /// The provider states this pool's remaining balance directly.
200    Reported,
201    /// Computed from a total and a consumption figure that covers several pools
202    /// at once, so the split between them is not known.
203    ///
204    /// The distinction is load-bearing for any "spend only granted credits"
205    /// policy: against a `Reported` pool it is exact, and against a `Derived`
206    /// one it can only be a ceiling.
207    Derived,
208    /// No basis was stated, or one was stated that this consumer does not
209    /// recognise. **Treat `remaining` as a ceiling, never as exact.**
210    ///
211    /// This is deliberately its own variant rather than folding an unrecognised
212    /// value into [`Self::Derived`]. Both are read conservatively, so the
213    /// spending behaviour is the same either way — but `Derived` is a statement
214    /// about how a number was obtained, and answering "I do not know" with it
215    /// would have the producer assert a fact it does not hold. That is the
216    /// failure this type exists to prevent, one level up.
217    ///
218    /// Reading it conservatively is safe in the direction that matters: an
219    /// exact remainder treated as a ceiling under-spends, while a ceiling
220    /// treated as exact spends money that may not be there.
221    #[serde(other)]
222    Unstated,
223}
224
225/// A prepaid balance or credit pool on an account.
226///
227/// Plural by necessity: one figure cannot express "9.50 granted and 40
228/// purchased", which is exactly the distinction a consumer needs to spend the
229/// first without spending the second.
230#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
231#[serde(rename_all = "camelCase")]
232pub struct Pool {
233    /// The provider's own name for this pool, never one invented here.
234    ///
235    /// Providers separate pools without always defining them — a wallet may list
236    /// voucher, cash and credit balances and document none of them. Passing the
237    /// provider's name through lets a consumer decide; renaming one `granted`
238    /// would be inventing the label a spend policy keys on.
239    pub id: String,
240    /// Human-readable name for display.
241    pub label: String,
242    /// What funds this pool.
243    pub funding: PoolFunding,
244    /// What is left, when it can be established.
245    #[serde(skip_serializing_if = "Option::is_none", default)]
246    pub remaining: Option<Amount>,
247    /// The pool's size, when the provider reports one.
248    #[serde(skip_serializing_if = "Option::is_none", default)]
249    pub total: Option<Amount>,
250    /// How `remaining` was obtained. Read it before acting on `remaining`.
251    pub basis: PoolBasis,
252    /// Whether the provider says this pool may currently be drawn on.
253    ///
254    /// Read from the provider, never inferred from `remaining > 0`: a pool can
255    /// be non-empty and closed, which several providers publish directly through
256    /// their own enable flags. Absent means the provider does not say.
257    #[serde(skip_serializing_if = "Option::is_none", default)]
258    pub spendable: Option<bool>,
259}
260
261/// Account labels and subscription information supplied by a provider or vault.
262#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
263#[serde(rename_all = "camelCase")]
264pub struct AccountInfo {
265    /// Account email. Absent when the upstream does not identify the account that
266    /// way — not a signal about the account itself.
267    #[serde(skip_serializing_if = "Option::is_none", default)]
268    pub email: Option<String>,
269    /// Organisation label. Absent when the upstream reports none; absent does not
270    /// mean a personal account.
271    #[serde(skip_serializing_if = "Option::is_none", default)]
272    pub org_name: Option<String>,
273    /// The upstream's **own** plan label, not a normalised vocabulary, so it is
274    /// not comparable across providers. Display and grouping only. Absent when
275    /// the upstream states no plan.
276    #[serde(skip_serializing_if = "Option::is_none", default)]
277    pub plan_type: Option<String>,
278}
279
280impl AccountInfo {
281    pub fn is_empty(&self) -> bool {
282        self.email.is_none() && self.org_name.is_none() && self.plan_type.is_none()
283    }
284}
285
286/// One saved reset credit and its expiry time.
287#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
288#[serde(rename_all = "camelCase")]
289pub struct CreditExpiry {
290    pub expires_at: String,
291}
292
293/// Saved reset credits reported by Codex's read-only credits endpoint.
294#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
295#[serde(rename_all = "camelCase")]
296pub struct SavedResets {
297    #[serde(default)]
298    pub available_count: u32,
299    /// When the next credit lapses. Absent means **no credit states an expiry**,
300    /// which is not the same as none expiring soon.
301    #[serde(skip_serializing_if = "Option::is_none", default)]
302    pub soonest_expires_at: Option<String>,
303    #[serde(default)]
304    pub credits: Vec<CreditExpiry>,
305}
306
307fn account_info_is_empty(value: &Option<AccountInfo>) -> bool {
308    value.as_ref().map(AccountInfo::is_empty).unwrap_or(true)
309}
310
311/// One provider/account's usage entry. The `/usage` response is an array of
312/// these. A fetch failure becomes an entry carrying `error` (silent-degrade),
313/// never a failure of the whole array.
314#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
315#[serde(rename_all = "camelCase")]
316pub struct ProviderUsage {
317    /// CodexBar provider name (e.g. "codex"), which consumers map to their own id.
318    pub provider: String,
319    /// Canonical API provider identifier — the models.dev slug for the same
320    /// provider (e.g. "openai" when `provider == "codex"`, "anthropic" for
321    /// "claude", "google" for "gemini", "xai" for "grok"). Present when the
322    /// producer knows the canonical name; absent for providers with no models.dev
323    /// counterpart, where consumers fall back to `provider`. Lets every consumer
324    /// key on one canonical name instead of each maintaining its own
325    /// CodexBar-name → canonical map.
326    #[serde(skip_serializing_if = "Option::is_none", default)]
327    pub api_provider: Option<String>,
328    /// The account this entry describes, as the credential store identifies it.
329    ///
330    /// Absent means the producer **could not resolve an identity for this
331    /// credential**, not that the provider has one account. Some credentials
332    /// carry no account identity at all (a bare API key), and an entry is also
333    /// emitted unlabelled while an identity is still being confirmed — so an
334    /// unlabelled entry is not evidence that a labelled one does not exist.
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub account: Option<String>,
337    /// Which retrieval path produced this (e.g. "oauth") — observability only.
338    ///
339    /// **Per lane, not per account, and it moves.** One account can be reached
340    /// through more than one credential path, and which one answers is decided
341    /// per fetch by whichever is healthy. So the same account can report one
342    /// value on a poll and another on the next with nothing having changed about
343    /// the account, the credential, or anything the consumer did. Do not key on
344    /// it, branch on it, or treat a change in it as an event.
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub source: Option<String>,
347    /// Display labels for the account. Absent when the upstream supplies none of
348    /// them; carries no operational meaning.
349    #[serde(skip_serializing_if = "account_info_is_empty", default)]
350    pub account_info: Option<AccountInfo>,
351    /// When this entry's figures were last **successfully** fetched — producer
352    /// time, per entry, never a common instant across the array.
353    ///
354    /// Absent means this credential has never had a successful fetch. It keeps
355    /// its old value while a failure is being retried, so it ages honestly rather
356    /// than pausing; never restamp it with your own poll time.
357    #[serde(skip_serializing_if = "Option::is_none", default)]
358    pub fetched_at: Option<String>,
359    /// Banked quota-reset credits held by this account.
360    ///
361    /// Absent means there is no credit inventory to report — which includes the
362    /// inventory lookup having **failed** on this fetch, since it is separate
363    /// from the usage fetch and may fail without degrading the entry. Absent is
364    /// therefore not "zero credits held".
365    #[serde(skip_serializing_if = "Option::is_none", default)]
366    pub saved_resets: Option<SavedResets>,
367    /// The windows. Absent on a degraded entry, and on an entry whose credential
368    /// works but whose account reports no quota at all — read `error` and
369    /// `error_class` to tell those apart, rather than inferring from this field.
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub usage: Option<Usage>,
372    /// Prepaid balances and credit pools on this account, when the provider
373    /// reports any.
374    ///
375    /// Deliberately apart from [`Self::usage`], because a pool and a rate window
376    /// are different facts that fail in opposite directions: over-consuming a
377    /// window gets you throttled and recovers by waiting, while over-consuming a
378    /// balance gets you billed and recovers by paying. Nothing in a routing loop
379    /// can undo the second, so a balance is never expressed as a window, never
380    /// carries a reset, and never appears as a percentage — a consumer that
381    /// found one where it expects headroom would pace into a bill.
382    ///
383    /// Absent means the producer has nothing to say, which is not the same as an
384    /// account having no credit. Empty means it looked and the provider reports
385    /// no pools.
386    #[serde(skip_serializing_if = "Option::is_none", default)]
387    pub spend: Option<Vec<Pool>>,
388    /// Present only on a degraded entry. The consumer skips any entry with a
389    /// truthy `error`.
390    #[serde(skip_serializing_if = "Option::is_none")]
391    pub error: Option<String>,
392    /// A stable, machine-readable name for *why* a degraded entry failed,
393    /// published beside the human-readable `error`.
394    ///
395    /// `error` is prose with no stability promise, so consumers are told not to
396    /// branch on it — which leaves them no way to separate failures that mean
397    /// something from failures that are a permanent, correct state. A host that
398    /// never configured a provider and a host whose credential broke this
399    /// morning both produce a degraded entry, and only the second is worth
400    /// anyone's attention.
401    ///
402    /// Classes currently produced:
403    ///
404    /// | Value | Meaning |
405    /// |---|---|
406    /// | `credential_absent` | No credential was found. Permanent and correct on a host that never configured this provider; nothing to fix. |
407    /// | `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. |
408    /// | `credential_rejected` | The upstream rejected the credential (401/403). Usually means logging in again. |
409    /// | `no_quota_reported` | The credential works and the account genuinely has no quota to report. Not a failure. |
410    /// | `upstream_failed` | The upstream could not be reached or returned an error status. Usually transient. |
411    /// | `decode_failed` | The response arrived but was not the expected shape. |
412    ///
413    /// **This list will grow.** A consumer must render an unrecognised class as
414    /// a degraded entry with an unknown reason — never drop the entry, and
415    /// never fold it into an existing bucket. It is a `String` rather than an
416    /// enum for exactly that reason: on an observability surface, meeting an
417    /// unknown value must not turn into a parse failure that makes a provider
418    /// disappear at the moment its state changed.
419    ///
420    /// Absent on healthy entries, and absent from any producer that predates
421    /// this field.
422    #[serde(skip_serializing_if = "Option::is_none", default)]
423    pub error_class: Option<String>,
424    /// Present when this entry is a last-known-good reading served through an
425    /// ongoing failure, absent when it is a fresh success.
426    ///
427    /// Without it a preserved reading is byte-identical to a fresh one apart
428    /// from `fetched_at`, so a consumer cannot separate "this figure is old
429    /// because the producer has been unable to reach the provider" from "this
430    /// figure is old because nothing polled recently". Those have opposite
431    /// remedies — the first is a reason to stop acting on the number, the
432    /// second is not — and a consumer with only a timestamp has to guess with a
433    /// wall-clock threshold, which denies fresh-enough data to catch stale data.
434    ///
435    /// A producer serving preserved readings is behaving correctly: a brief
436    /// upstream failure should not blank a window. This field discloses that it
437    /// is happening rather than reporting a fault.
438    #[serde(skip_serializing_if = "Option::is_none", default)]
439    pub stale: Option<Stale>,
440}
441
442/// Why an entry is being served through a failure, and since when.
443#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
444#[serde(rename_all = "camelCase")]
445pub struct Stale {
446    /// When the producer first failed to refresh this entry, RFC3339.
447    ///
448    /// Distinct from `fetched_at`, which is when the served reading was
449    /// obtained. The gap between them is how long the producer has been unable
450    /// to look, which is the quantity a staleness policy actually wants: a
451    /// reading can be minutes old with the producer perfectly healthy, or
452    /// seconds old with the producer failing since just after it was taken.
453    pub since: String,
454    /// The failure class, using the same vocabulary as `error_class` on a
455    /// degraded entry.
456    ///
457    /// Carried so a consumer can tell a flapping upstream from a credential
458    /// that has started refusing, without branching on prose. Optional because
459    /// a producer may preserve a reading for a reason it cannot classify.
460    #[serde(skip_serializing_if = "Option::is_none", default)]
461    pub class: Option<String>,
462}
463
464impl ProviderUsage {
465    /// A healthy entry with resolved windows.
466    pub fn healthy(provider: &str, account: Option<String>, source: &str, usage: Usage) -> Self {
467        Self {
468            provider: provider.to_string(),
469            api_provider: None,
470            account,
471            source: Some(source.to_string()),
472            account_info: None,
473            fetched_at: None,
474            saved_resets: None,
475            usage: Some(usage),
476            spend: None,
477            error: None,
478            error_class: None,
479            stale: None,
480        }
481    }
482
483    /// A degraded entry: the provider is named so the consumer can correlate,
484    /// but it carries only an error string and no windows.
485    pub fn degraded(provider: &str, error: impl std::fmt::Display) -> Self {
486        Self {
487            provider: provider.to_string(),
488            api_provider: None,
489            account: None,
490            source: None,
491            account_info: None,
492            fetched_at: None,
493            saved_resets: None,
494            usage: None,
495            spend: None,
496            error: Some(error.to_string()),
497            error_class: None,
498            stale: None,
499        }
500    }
501
502    /// A degraded entry that also names *why* it failed.
503    ///
504    /// Prefer this over [`Self::degraded`] wherever the producer knows the
505    /// class: without it a consumer can only tell an unconfigured provider from
506    /// a broken one by reading prose it has been told not to parse. See
507    /// [`ProviderUsage::error_class`] for the classes and for the rule that an
508    /// unrecognised one must still render.
509    pub fn degraded_with_class(
510        provider: &str,
511        error: impl std::fmt::Display,
512        error_class: impl Into<String>,
513    ) -> Self {
514        Self {
515            error_class: Some(error_class.into()),
516            ..Self::degraded(provider, error)
517        }
518    }
519}
520
521#[cfg(test)]
522mod tests {
523
524    /// An entry without the field serializes exactly as before.
525    ///
526    /// Every consumer decoding today's shape must keep working, so absence has
527    /// to be byte-identical rather than merely tolerated.
528    #[test]
529    fn a_fresh_entry_carries_no_stale_key() {
530        let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
531        let json = serde_json::to_string(&entry).expect("entry serializes");
532
533        assert!(
534            !json.contains("stale"),
535            "a fresh entry must not emit the key at all: {json}"
536        );
537    }
538
539    /// A preserved reading states when the producer stopped being able to look.
540    ///
541    /// `since` is deliberately not `fetchedAt`: the reading was taken when it
542    /// was taken, and the failure began afterwards. The gap between the two is
543    /// how long the producer has been blind, which is the quantity a staleness
544    /// policy wants -- an entry can be minutes old with the producer healthy,
545    /// or seconds old with it failing since just after the read.
546    #[test]
547    fn a_preserved_reading_states_when_the_failure_began() {
548        let mut entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
549        entry.fetched_at = Some("2026-08-13T10:00:00Z".to_string());
550        entry.stale = Some(Stale {
551            since: "2026-08-13T10:02:00Z".to_string(),
552            class: Some("upstream_failed".to_string()),
553        });
554
555        let json = serde_json::to_string(&entry).expect("entry serializes");
556        let back: ProviderUsage = serde_json::from_str(&json).expect("entry round-trips");
557        let stale = back.stale.expect("the disclosure survives the round trip");
558
559        assert_eq!(stale.since, "2026-08-13T10:02:00Z");
560        assert_eq!(stale.class.as_deref(), Some("upstream_failed"));
561        assert_ne!(
562            Some(stale.since.as_str()),
563            back.fetched_at.as_deref(),
564            "the two timestamps answer different questions and must not be conflated"
565        );
566        assert!(
567            json.contains("\"stale\""),
568            "the key is camelCase on the wire: {json}"
569        );
570    }
571
572    /// A producer that cannot classify the failure still discloses the state.
573    ///
574    /// Optional rather than required so a preserved reading is never suppressed
575    /// for want of a label -- disclosing "this is stale, cause unstated" beats
576    /// looking fresh.
577    #[test]
578    fn a_disclosure_without_a_class_still_decodes() {
579        let json = r#"{"provider":"codex","stale":{"since":"2026-08-13T10:02:00Z"}}"#;
580        let entry: ProviderUsage = serde_json::from_str(json).expect("decodes");
581        let stale = entry.stale.expect("present");
582
583        assert_eq!(stale.class, None);
584        assert_eq!(stale.since, "2026-08-13T10:02:00Z");
585    }
586
587    /// An entry from a producer that predates the field decodes unchanged.
588    #[test]
589    fn an_entry_without_the_field_decodes() {
590        let json = r#"{"provider":"codex","source":"oauth"}"#;
591        let entry: ProviderUsage = serde_json::from_str(json).expect("decodes");
592
593        assert_eq!(entry.stale, None);
594        assert_eq!(entry.provider, "codex");
595    }
596
597    use super::*;
598
599    #[test]
600    fn account_info_is_omitted_when_empty_and_keeps_partial_labels() {
601        let bare = ProviderUsage::healthy(
602            "codex",
603            None,
604            "oauth",
605            Usage {
606                primary: Some(RateWindow {
607                    used_percent: 10.0,
608                    raw_used_percent: None,
609                    resets_at: None,
610                    window_minutes: Some(300),
611                    used_count: None,
612                    total_count: None,
613                }),
614                ..Default::default()
615            },
616        );
617        let json = serde_json::to_string(&bare).unwrap();
618        assert!(
619            !json.contains("accountInfo"),
620            "empty accountInfo must be omitted"
621        );
622
623        let mut labeled = bare.clone();
624        labeled.account_info = Some(AccountInfo {
625            email: Some("a@b.com".to_string()),
626            org_name: None,
627            plan_type: Some("pro".to_string()),
628        });
629        let json = serde_json::to_string(&labeled).unwrap();
630        assert!(json.contains("\"email\":\"a@b.com\""));
631        assert!(json.contains("\"planType\":\"pro\""));
632        assert!(!json.contains("orgName"), "absent orgName must be omitted");
633    }
634
635    #[test]
636    fn saved_resets_use_camel_case_and_round_trip() {
637        let entry = ProviderUsage {
638            saved_resets: Some(SavedResets {
639                available_count: 2,
640                soonest_expires_at: Some("2026-07-31T20:11:35Z".to_string()),
641                credits: vec![CreditExpiry {
642                    expires_at: "2026-07-31T20:11:35Z".to_string(),
643                }],
644            }),
645            ..ProviderUsage::degraded("codex", "x")
646        };
647        let json = serde_json::to_string(&entry).unwrap();
648        assert!(json.contains("\"savedResets\""));
649        assert!(json.contains("\"availableCount\":2"));
650        assert!(json.contains("\"soonestExpiresAt\""));
651        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
652        assert_eq!(back, entry);
653    }
654
655    #[test]
656    fn raw_used_percent_is_absent_from_unrelaxed_windows_and_camel_case_when_present() {
657        let unrelaxed = RateWindow {
658            used_percent: 41.0,
659            raw_used_percent: None,
660            resets_at: Some("2026-07-20T00:00:00Z".to_string()),
661            window_minutes: Some(10080),
662            used_count: None,
663            total_count: None,
664        };
665        let json = serde_json::to_string(&unrelaxed).unwrap();
666        assert!(
667            !json.contains("rawUsedPercent"),
668            "unrelaxed window must not carry the field"
669        );
670
671        let relaxed = RateWindow {
672            used_percent: 0.0,
673            raw_used_percent: Some(70.0),
674            resets_at: Some("2026-07-20T00:00:00Z".to_string()),
675            window_minutes: Some(10080),
676            used_count: None,
677            total_count: None,
678        };
679        let json = serde_json::to_string(&relaxed).unwrap();
680        assert!(json.contains("\"rawUsedPercent\":70.0"));
681        let back: RateWindow = serde_json::from_str(&json).unwrap();
682        assert_eq!(back, relaxed);
683    }
684
685    #[test]
686    fn healthy_entry_omits_error_and_degraded_entry_omits_usage() {
687        let healthy = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
688        let json = serde_json::to_string(&healthy).unwrap();
689        assert!(!json.contains("error"));
690
691        let degraded = ProviderUsage::degraded("codex", "no session");
692        let json = serde_json::to_string(&degraded).unwrap();
693        assert!(json.contains("\"error\":\"no session\""));
694        assert!(!json.contains("usage"));
695    }
696
697    #[test]
698    fn api_provider_is_camel_case_present_when_set_and_omitted_when_absent() {
699        let mut entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
700        let json = serde_json::to_string(&entry).unwrap();
701        assert!(
702            !json.contains("apiProvider"),
703            "absent api_provider must be omitted"
704        );
705
706        entry.api_provider = Some("openai".to_string());
707        let json = serde_json::to_string(&entry).unwrap();
708        assert!(json.contains("\"apiProvider\":\"openai\""));
709        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
710        assert_eq!(back, entry);
711    }
712
713    #[test]
714    fn used_count_and_total_count_are_camel_case_and_omitted_when_absent() {
715        let window = RateWindow {
716            used_percent: 25.8,
717            raw_used_percent: None,
718            resets_at: Some("2026-07-26T14:09:00Z".to_string()),
719            window_minutes: Some(10080),
720            used_count: None,
721            total_count: None,
722        };
723        let json = serde_json::to_string(&window).unwrap();
724        assert!(
725            !json.contains("usedCount"),
726            "absent used_count must be omitted"
727        );
728        assert!(
729            !json.contains("totalCount"),
730            "absent total_count must be omitted"
731        );
732
733        let enriched = RateWindow {
734            used_count: Some(10336.0),
735            total_count: Some(40000.0),
736            ..window
737        };
738        let json = serde_json::to_string(&enriched).unwrap();
739        assert!(json.contains("\"usedCount\":10336.0"));
740        assert!(json.contains("\"totalCount\":40000.0"));
741        let back: RateWindow = serde_json::from_str(&json).unwrap();
742        assert_eq!(back, enriched);
743    }
744
745    /// The field is additive: a producer that does not set it must serialize
746    /// exactly as before, or adding it changes every existing entry on the wire.
747    #[test]
748    fn an_entry_without_a_class_serializes_as_it_did_before_the_field_existed() {
749        let entry = ProviderUsage::degraded("codex", "no session: nothing configured");
750        let json = serde_json::to_string(&entry).unwrap();
751
752        assert!(!json.contains("errorClass"), "absent class must be omitted");
753        // Not vacuous: the entry really is a degraded one carrying its message,
754        // so this cannot pass by serializing something empty.
755        assert!(json.contains("\"error\":\"no session: nothing configured\""));
756
757        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
758        assert_eq!(back, entry);
759        assert_eq!(back.error_class, None);
760    }
761
762    #[test]
763    fn a_class_round_trips_under_its_camel_case_wire_name() {
764        let entry = ProviderUsage::degraded_with_class(
765            "gemini",
766            "credential unusable: gemini creds have no refresh_token",
767            "credential_unusable",
768        );
769        let json = serde_json::to_string(&entry).unwrap();
770
771        assert!(json.contains("\"errorClass\":\"credential_unusable\""));
772        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
773        assert_eq!(back, entry);
774    }
775
776    /// The classes are open by design, so a consumer built today must survive a
777    /// producer that ships a class it has never heard of. Modelling the field as
778    /// a `String` is what buys that: an enum would make this a parse failure,
779    /// and on an observability surface a parse failure means the entry vanishes
780    /// at the moment its state changed.
781    #[test]
782    fn an_unknown_class_decodes_rather_than_failing() {
783        let json = r#"{"provider":"someprovider","error":"something new","errorClass":"a_class_from_the_future"}"#;
784
785        let entry: ProviderUsage =
786            serde_json::from_str(json).expect("an unrecognised class must not fail to decode");
787
788        assert_eq!(
789            entry.error_class.as_deref(),
790            Some("a_class_from_the_future")
791        );
792        // The rest of the entry survives intact, so a consumer can still render
793        // it as degraded-with-unknown-reason rather than dropping it.
794        assert_eq!(entry.provider, "someprovider");
795        assert_eq!(entry.error.as_deref(), Some("something new"));
796    }
797
798    /// An entry with no pools serializes exactly as it did before pools existed.
799    ///
800    /// Consumers pin these payloads, so an additive field that appears as `null`
801    /// on every existing entry is not additive in practice. The check is on the
802    /// rendered text rather than on the field, because that is what a consumer
803    /// parses.
804    #[test]
805    fn an_entry_without_pools_does_not_mention_them() {
806        let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
807        let json = serde_json::to_string(&entry).unwrap();
808        assert!(!json.contains("spend"), "unexpected spend key: {json}");
809    }
810
811    /// Pools survive a round trip, including the two fields a consumer must read
812    /// before acting on an amount.
813    ///
814    /// `basis` and `funding` are what separate "you have 10 granted credits
815    /// left" from "you were granted 10 credits and we cannot tell how many
816    /// remain". A consumer that loses either one is left with a number it cannot
817    /// safely spend against.
818    #[test]
819    fn pools_round_trip_with_their_basis_and_funding() {
820        let pool = Pool {
821            id: "granted_balance".to_string(),
822            label: "Granted".to_string(),
823            funding: PoolFunding::Granted,
824            remaining: Some(Amount {
825                minor: 1050,
826                exponent: 2,
827                unit: "CNY".to_string(),
828            }),
829            total: None,
830            basis: PoolBasis::Reported,
831            spendable: Some(true),
832        };
833        let mut entry = ProviderUsage::healthy("deepseek", None, "api", Usage::default());
834        entry.spend = Some(vec![pool.clone()]);
835
836        let json = serde_json::to_string(&entry).unwrap();
837        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
838        assert_eq!(back.spend, Some(vec![pool]));
839
840        // Rendered as the wire spells them, since consumers key on these.
841        assert!(json.contains(r#""funding":"granted""#), "{json}");
842        assert!(json.contains(r#""basis":"reported""#), "{json}");
843        // 10.50 CNY is carried as minor units, never as a float.
844        assert!(json.contains(r#""minor":1050"#), "{json}");
845        assert!(
846            !json.contains("10.5"),
847            "an amount was rendered as a decimal: {json}"
848        );
849    }
850
851    /// An unrecognised funding kind must not take the entry down with it.
852    ///
853    /// This payload crosses a repository boundary: one project produces it,
854    /// others consume it, and their versions move independently. A closed enum
855    /// makes the first new funding kind fail deserialization of the WHOLE
856    /// `ProviderUsage` entry rather than one field, so an account's rate windows
857    /// would vanish because of a credit pool the consumer had never heard of --
858    /// and a vanished entry reads as the provider being unavailable.
859    ///
860    /// Asserted on a mixed entry rather than on the enum alone, because the
861    /// blast radius is the point: the usage figure below is what a router acts
862    /// on, and it is downstream of the pool that failed.
863    #[test]
864    fn an_unknown_funding_kind_does_not_discard_the_entry() {
865        let json = r#"{
866            "provider": "minimax",
867            "usage": { "primary": { "usedPercent": 42.0 } },
868            "spend": [
869              { "id": "a", "label": "A", "funding": "granted",     "basis": "reported" },
870              { "id": "b", "label": "B", "funding": "crypto_grant", "basis": "reported" }
871            ]
872        }"#;
873
874        let entry: ProviderUsage = serde_json::from_str(json).expect("entry must survive");
875        let pools = entry.spend.expect("pools present");
876        assert_eq!(pools.len(), 2, "no pool may be dropped");
877        assert_eq!(pools[0].funding, PoolFunding::Granted);
878        // The unrecognised kind lands on Unknown, which is the correct reading:
879        // a funding this consumer cannot name is one it must not spend from.
880        assert_eq!(pools[1].funding, PoolFunding::Unknown);
881        // And the part a router acts on survived.
882        assert_eq!(
883            entry.usage.and_then(|u| u.primary).map(|w| w.used_percent),
884            Some(42.0)
885        );
886    }
887
888    /// An unrecognised basis reads as unstated, never as exact.
889    ///
890    /// The two poles are not symmetrical. Treating an exact remainder as a
891    /// ceiling under-spends and costs nothing; treating a ceiling as exact
892    /// spends money that may not be there. So the fallback folds to the
893    /// conservative side, and does so under its own name rather than claiming
894    /// the number was derived -- which would assert a fact about a computation
895    /// the consumer knows nothing about.
896    #[test]
897    fn an_unknown_basis_is_unstated_rather_than_exact() {
898        let json = r#"{ "id": "a", "label": "A", "funding": "granted",
899                        "basis": "sampled_hourly" }"#;
900        let pool: Pool = serde_json::from_str(json).expect("pool must survive");
901        assert_eq!(pool.basis, PoolBasis::Unstated);
902        assert_ne!(
903            pool.basis,
904            PoolBasis::Reported,
905            "an unknown basis must never read as an exact remainder"
906        );
907    }
908
909    /// A healthy entry must never carry a class: the field's presence is itself
910    /// a signal, and a class on a working provider would be a contradiction a
911    /// consumer has to resolve.
912    #[test]
913    fn a_healthy_entry_carries_no_class() {
914        let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
915        assert_eq!(entry.error_class, None);
916        assert!(!serde_json::to_string(&entry)
917            .unwrap()
918            .contains("errorClass"));
919    }
920}