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