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(°raded).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}