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