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