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