Skip to main content

cortexkit_provider_usage/
lib.rs

1//! Shared wire types for the `ai-provider-quota` module's `usage.get` payload.
2//!
3//! The quota module serves an array of [`ProviderUsage`] per request; ALF's
4//! router (`codexbar-window-extractors.ts`), astrocyte's capacity axis, and the
5//! `ck quota` renderer all consume that shape. This crate is the single
6//! definition those consumers compile against, so the wire shape cannot drift
7//! without a shared-crate PR every side reviews.
8//!
9//! # Shape, not policy
10//!
11//! These are pure data types. Read-time transform semantics are PRODUCER
12//! behavior documented on the relevant fields but NOT enforced here:
13//! - **Banked-reset relaxation:** the quota module may zero
14//!   [`RateWindow::used_percent`] (the EFFECTIVE number consumers pace on) and
15//!   carry the provider-reported truth in [`RateWindow::raw_used_percent`].
16//!   A consumer renders whatever the wire says; a sudden `0 → high` transition
17//!   is an honest disarm (credits spent / auth broke), not a glitch.
18//! - **Cache-only partial arrays:** the quota module never blocks on a fetch,
19//!   so a result may omit providers not yet swept. Missing ≠ zero.
20//! - **Degraded entries ride in-band:** a provider fetch failure is a normal
21//!   [`ProviderUsage`] carrying `error`, not a request-level failure.
22//!
23//! # Serialization contract consumers depend on
24//! - camelCase keys (`usedPercent`, `resetsAt`, `windowMinutes`,
25//!   `extraRateWindows`, `rawUsedPercent`, `accountInfo`, `savedResets`,
26//!   `usedCount`, `totalCount`).
27//! - A healthy entry MUST NOT carry `error` (consumers skip truthy-`error`
28//!   entries), so it is omitted when absent.
29//! - A window is emitted when it has a `usedPercent`; `resetsAt` is OPTIONAL and
30//!   omitted when the provider reports no reset (never fabricated).
31
32use serde::{Deserialize, Serialize};
33
34/// One rate-limit window: how much of a quota pool is spent and when it resets.
35#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
36#[serde(rename_all = "camelCase")]
37pub struct RateWindow {
38    /// 0..100 percent of the window's quota consumed. This is the EFFECTIVE
39    /// number consumers pace on: when banked-reset relaxation applies it is
40    /// zeroed, and the provider-reported percent moves to `raw_used_percent`.
41    pub used_percent: f64,
42    /// The provider-reported percent when `used_percent` has been relaxed to
43    /// an effective value (banked resets guarantee the window resets before
44    /// the wall).
45    ///
46    /// **Pace on `used_percent`, not on this.** The effective number is the real
47    /// headroom: a reset that is going to happen has already been accounted for.
48    /// Treating this as the truer figure routes work away from an account whose
49    /// credit is about to be spent — and the credit expires whether or not it is
50    /// used, so the cautious-looking reading is the lossy one. Display it beside
51    /// the effective number in a human-facing view, where a zero next to real
52    /// consumption would otherwise look like a fault.
53    ///
54    /// Emitted **only where the two diverge**, so its absence means they agree
55    /// and falling back to `used_percent` is exact rather than approximate.
56    /// Rendering a placeholder for absence would be wrong on every unrelaxed
57    /// window, which is nearly all of them.
58    #[serde(skip_serializing_if = "Option::is_none", default)]
59    pub raw_used_percent: Option<f64>,
60    /// ISO 8601 / RFC 3339 timestamp when the window resets. Omitted when the
61    /// provider reports no reset (e.g. an idle session window with nothing
62    /// pending) — never fabricated.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub resets_at: Option<String>,
65    /// Window length in minutes. Omitted when the provider does not report one;
66    /// the consumer then paces on utilization alone rather than a burn rate.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub window_minutes: Option<i64>,
69    /// Absolute consumed count in the window (e.g. tokens, requests). Present
70    /// only when the provider reports or derives it; human-facing UIs can show
71    /// "10,336 / 40,000" alongside the percentage for richer context.
72    #[serde(skip_serializing_if = "Option::is_none", default)]
73    pub used_count: Option<f64>,
74    /// Absolute total cap for the window. Present alongside `used_count` when
75    /// the provider knows the ceiling; omitted otherwise.
76    #[serde(skip_serializing_if = "Option::is_none", default)]
77    pub total_count: Option<f64>,
78}
79
80/// A per-model window bundled under one account (e.g. Antigravity's Geminis).
81#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
82#[serde(rename_all = "camelCase")]
83pub struct ExtraWindow {
84    /// Human-facing label. Absent when the producer has no display text for this
85    /// window; render `id` instead rather than dropping the entry.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub title: Option<String>,
88    /// Stable identifier to match on. Absent when the producer cannot name the
89    /// window stably. **Not unique across providers** — one provider's ids are
90    /// model names, another's its own scope labels — so key on
91    /// `(provider, id)`, never on `id` alone.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub id: Option<String>,
94    /// The figures for this window. Absent means the provider **named a limit it
95    /// could not read a figure for**, which is not the same as no limit: the
96    /// entry is still evidence the limit exists.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub window: Option<RateWindow>,
99}
100
101/// The window topology for one account: up to three account-wide pools plus an
102/// optional list of per-model pools.
103#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
104#[serde(rename_all = "camelCase")]
105pub struct Usage {
106    /// The provider's **shortest** window, not its most constrained one. Absent
107    /// when the provider reported no window of that cadence.
108    ///
109    /// The three slots are positions, not a ranking, and **they can have holes**:
110    /// each is filled from its own optional upstream field, so `secondary` may be
111    /// absent while `tertiary` is present. Walk all three plus
112    /// `extra_rate_windows` rather than stopping at the first gap, and take the
113    /// maximum when asking how much headroom an account has.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub primary: Option<RateWindow>,
116    /// The next cadence up, typically weekly. Absent means not reported — never
117    /// that the window exists at zero. See [`Usage::primary`] on slot holes.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub secondary: Option<RateWindow>,
120    /// A third account-wide window where a provider has one. Absent means not
121    /// reported. See [`Usage::primary`] on slot holes.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub tertiary: Option<RateWindow>,
124    /// Windows whose meaning has no slot — per-model pools, scoped weeklies.
125    /// Absent means the provider published none.
126    ///
127    /// These are **real limits**, not extras in the dispensable sense: a consumer
128    /// ignoring this list silently ignores whichever limits did not fit three
129    /// slots.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub extra_rate_windows: Option<Vec<ExtraWindow>>,
132}
133
134/// Account labels and subscription information supplied by a provider or vault.
135#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
136#[serde(rename_all = "camelCase")]
137pub struct AccountInfo {
138    /// Account email. Absent when the upstream does not identify the account that
139    /// way — not a signal about the account itself.
140    #[serde(skip_serializing_if = "Option::is_none", default)]
141    pub email: Option<String>,
142    /// Organisation label. Absent when the upstream reports none; absent does not
143    /// mean a personal account.
144    #[serde(skip_serializing_if = "Option::is_none", default)]
145    pub org_name: Option<String>,
146    /// The upstream's **own** plan label, not a normalised vocabulary, so it is
147    /// not comparable across providers. Display and grouping only. Absent when
148    /// the upstream states no plan.
149    #[serde(skip_serializing_if = "Option::is_none", default)]
150    pub plan_type: Option<String>,
151}
152
153impl AccountInfo {
154    pub fn is_empty(&self) -> bool {
155        self.email.is_none() && self.org_name.is_none() && self.plan_type.is_none()
156    }
157}
158
159/// One saved reset credit and its expiry time.
160#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
161#[serde(rename_all = "camelCase")]
162pub struct CreditExpiry {
163    pub expires_at: String,
164}
165
166/// Saved reset credits reported by Codex's read-only credits endpoint.
167#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
168#[serde(rename_all = "camelCase")]
169pub struct SavedResets {
170    #[serde(default)]
171    pub available_count: u32,
172    /// When the next credit lapses. Absent means **no credit states an expiry**,
173    /// which is not the same as none expiring soon.
174    #[serde(skip_serializing_if = "Option::is_none", default)]
175    pub soonest_expires_at: Option<String>,
176    #[serde(default)]
177    pub credits: Vec<CreditExpiry>,
178}
179
180fn account_info_is_empty(value: &Option<AccountInfo>) -> bool {
181    value.as_ref().map(AccountInfo::is_empty).unwrap_or(true)
182}
183
184/// One provider/account's usage entry. The `/usage` response is an array of
185/// these. A fetch failure becomes an entry carrying `error` (silent-degrade),
186/// never a failure of the whole array.
187#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
188#[serde(rename_all = "camelCase")]
189pub struct ProviderUsage {
190    /// CodexBar provider name (e.g. "codex"), which consumers map to their own id.
191    pub provider: String,
192    /// Canonical API provider identifier — the models.dev slug for the same
193    /// provider (e.g. "openai" when `provider == "codex"`, "anthropic" for
194    /// "claude", "google" for "gemini", "xai" for "grok"). Present when the
195    /// producer knows the canonical name; absent for providers with no models.dev
196    /// counterpart, where consumers fall back to `provider`. Lets every consumer
197    /// key on one canonical name instead of each maintaining its own
198    /// CodexBar-name → canonical map.
199    #[serde(skip_serializing_if = "Option::is_none", default)]
200    pub api_provider: Option<String>,
201    /// The account this entry describes, as the credential store identifies it.
202    ///
203    /// Absent means the producer **could not resolve an identity for this
204    /// credential**, not that the provider has one account. Some credentials
205    /// carry no account identity at all (a bare API key), and an entry is also
206    /// emitted unlabelled while an identity is still being confirmed — so an
207    /// unlabelled entry is not evidence that a labelled one does not exist.
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub account: Option<String>,
210    /// Which retrieval path produced this (e.g. "oauth") — observability only.
211    ///
212    /// **Per lane, not per account, and it moves.** One account can be reached
213    /// through more than one credential path, and which one answers is decided
214    /// per fetch by whichever is healthy. So the same account can report one
215    /// value on a poll and another on the next with nothing having changed about
216    /// the account, the credential, or anything the consumer did. Do not key on
217    /// it, branch on it, or treat a change in it as an event.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub source: Option<String>,
220    /// Display labels for the account. Absent when the upstream supplies none of
221    /// them; carries no operational meaning.
222    #[serde(skip_serializing_if = "account_info_is_empty", default)]
223    pub account_info: Option<AccountInfo>,
224    /// When this entry's figures were last **successfully** fetched — producer
225    /// time, per entry, never a common instant across the array.
226    ///
227    /// Absent means this credential has never had a successful fetch. It keeps
228    /// its old value while a failure is being retried, so it ages honestly rather
229    /// than pausing; never restamp it with your own poll time.
230    #[serde(skip_serializing_if = "Option::is_none", default)]
231    pub fetched_at: Option<String>,
232    /// Banked quota-reset credits held by this account.
233    ///
234    /// Absent means there is no credit inventory to report — which includes the
235    /// inventory lookup having **failed** on this fetch, since it is separate
236    /// from the usage fetch and may fail without degrading the entry. Absent is
237    /// therefore not "zero credits held".
238    #[serde(skip_serializing_if = "Option::is_none", default)]
239    pub saved_resets: Option<SavedResets>,
240    /// The windows. Absent on a degraded entry, and on an entry whose credential
241    /// works but whose account reports no quota at all — read `error` and
242    /// `error_class` to tell those apart, rather than inferring from this field.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub usage: Option<Usage>,
245    /// Present only on a degraded entry. The consumer skips any entry with a
246    /// truthy `error`.
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub error: Option<String>,
249    /// A stable, machine-readable name for *why* a degraded entry failed,
250    /// published beside the human-readable `error`.
251    ///
252    /// `error` is prose with no stability promise, so consumers are told not to
253    /// branch on it — which leaves them no way to separate failures that mean
254    /// something from failures that are a permanent, correct state. A host that
255    /// never configured a provider and a host whose credential broke this
256    /// morning both produce a degraded entry, and only the second is worth
257    /// anyone's attention.
258    ///
259    /// Classes currently produced:
260    ///
261    /// | Value | Meaning |
262    /// |---|---|
263    /// | `credential_absent` | No credential was found. Permanent and correct on a host that never configured this provider; nothing to fix. |
264    /// | `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. |
265    /// | `credential_rejected` | The upstream rejected the credential (401/403). Usually means logging in again. |
266    /// | `no_quota_reported` | The credential works and the account genuinely has no quota to report. Not a failure. |
267    /// | `upstream_failed` | The upstream could not be reached or returned an error status. Usually transient. |
268    /// | `decode_failed` | The response arrived but was not the expected shape. |
269    ///
270    /// **This list will grow.** A consumer must render an unrecognised class as
271    /// a degraded entry with an unknown reason — never drop the entry, and
272    /// never fold it into an existing bucket. It is a `String` rather than an
273    /// enum for exactly that reason: on an observability surface, meeting an
274    /// unknown value must not turn into a parse failure that makes a provider
275    /// disappear at the moment its state changed.
276    ///
277    /// Absent on healthy entries, and absent from any producer that predates
278    /// this field.
279    #[serde(skip_serializing_if = "Option::is_none", default)]
280    pub error_class: Option<String>,
281}
282
283impl ProviderUsage {
284    /// A healthy entry with resolved windows.
285    pub fn healthy(provider: &str, account: Option<String>, source: &str, usage: Usage) -> Self {
286        Self {
287            provider: provider.to_string(),
288            api_provider: None,
289            account,
290            source: Some(source.to_string()),
291            account_info: None,
292            fetched_at: None,
293            saved_resets: None,
294            usage: Some(usage),
295            error: None,
296            error_class: None,
297        }
298    }
299
300    /// A degraded entry: the provider is named so the consumer can correlate,
301    /// but it carries only an error string and no windows.
302    pub fn degraded(provider: &str, error: impl std::fmt::Display) -> Self {
303        Self {
304            provider: provider.to_string(),
305            api_provider: None,
306            account: None,
307            source: None,
308            account_info: None,
309            fetched_at: None,
310            saved_resets: None,
311            usage: None,
312            error: Some(error.to_string()),
313            error_class: None,
314        }
315    }
316
317    /// A degraded entry that also names *why* it failed.
318    ///
319    /// Prefer this over [`Self::degraded`] wherever the producer knows the
320    /// class: without it a consumer can only tell an unconfigured provider from
321    /// a broken one by reading prose it has been told not to parse. See
322    /// [`ProviderUsage::error_class`] for the classes and for the rule that an
323    /// unrecognised one must still render.
324    pub fn degraded_with_class(
325        provider: &str,
326        error: impl std::fmt::Display,
327        error_class: impl Into<String>,
328    ) -> Self {
329        Self {
330            error_class: Some(error_class.into()),
331            ..Self::degraded(provider, error)
332        }
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn account_info_is_omitted_when_empty_and_keeps_partial_labels() {
342        let bare = ProviderUsage::healthy(
343            "codex",
344            None,
345            "oauth",
346            Usage {
347                primary: Some(RateWindow {
348                    used_percent: 10.0,
349                    raw_used_percent: None,
350                    resets_at: None,
351                    window_minutes: Some(300),
352                    used_count: None,
353                    total_count: None,
354                }),
355                ..Default::default()
356            },
357        );
358        let json = serde_json::to_string(&bare).unwrap();
359        assert!(
360            !json.contains("accountInfo"),
361            "empty accountInfo must be omitted"
362        );
363
364        let mut labeled = bare.clone();
365        labeled.account_info = Some(AccountInfo {
366            email: Some("a@b.com".to_string()),
367            org_name: None,
368            plan_type: Some("pro".to_string()),
369        });
370        let json = serde_json::to_string(&labeled).unwrap();
371        assert!(json.contains("\"email\":\"a@b.com\""));
372        assert!(json.contains("\"planType\":\"pro\""));
373        assert!(!json.contains("orgName"), "absent orgName must be omitted");
374    }
375
376    #[test]
377    fn saved_resets_use_camel_case_and_round_trip() {
378        let entry = ProviderUsage {
379            saved_resets: Some(SavedResets {
380                available_count: 2,
381                soonest_expires_at: Some("2026-07-31T20:11:35Z".to_string()),
382                credits: vec![CreditExpiry {
383                    expires_at: "2026-07-31T20:11:35Z".to_string(),
384                }],
385            }),
386            ..ProviderUsage::degraded("codex", "x")
387        };
388        let json = serde_json::to_string(&entry).unwrap();
389        assert!(json.contains("\"savedResets\""));
390        assert!(json.contains("\"availableCount\":2"));
391        assert!(json.contains("\"soonestExpiresAt\""));
392        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
393        assert_eq!(back, entry);
394    }
395
396    #[test]
397    fn raw_used_percent_is_absent_from_unrelaxed_windows_and_camel_case_when_present() {
398        let unrelaxed = RateWindow {
399            used_percent: 41.0,
400            raw_used_percent: None,
401            resets_at: Some("2026-07-20T00:00:00Z".to_string()),
402            window_minutes: Some(10080),
403            used_count: None,
404            total_count: None,
405        };
406        let json = serde_json::to_string(&unrelaxed).unwrap();
407        assert!(
408            !json.contains("rawUsedPercent"),
409            "unrelaxed window must not carry the field"
410        );
411
412        let relaxed = RateWindow {
413            used_percent: 0.0,
414            raw_used_percent: Some(70.0),
415            resets_at: Some("2026-07-20T00:00:00Z".to_string()),
416            window_minutes: Some(10080),
417            used_count: None,
418            total_count: None,
419        };
420        let json = serde_json::to_string(&relaxed).unwrap();
421        assert!(json.contains("\"rawUsedPercent\":70.0"));
422        let back: RateWindow = serde_json::from_str(&json).unwrap();
423        assert_eq!(back, relaxed);
424    }
425
426    #[test]
427    fn healthy_entry_omits_error_and_degraded_entry_omits_usage() {
428        let healthy = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
429        let json = serde_json::to_string(&healthy).unwrap();
430        assert!(!json.contains("error"));
431
432        let degraded = ProviderUsage::degraded("codex", "no session");
433        let json = serde_json::to_string(&degraded).unwrap();
434        assert!(json.contains("\"error\":\"no session\""));
435        assert!(!json.contains("usage"));
436    }
437
438    #[test]
439    fn api_provider_is_camel_case_present_when_set_and_omitted_when_absent() {
440        let mut entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
441        let json = serde_json::to_string(&entry).unwrap();
442        assert!(
443            !json.contains("apiProvider"),
444            "absent api_provider must be omitted"
445        );
446
447        entry.api_provider = Some("openai".to_string());
448        let json = serde_json::to_string(&entry).unwrap();
449        assert!(json.contains("\"apiProvider\":\"openai\""));
450        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
451        assert_eq!(back, entry);
452    }
453
454    #[test]
455    fn used_count_and_total_count_are_camel_case_and_omitted_when_absent() {
456        let window = RateWindow {
457            used_percent: 25.8,
458            raw_used_percent: None,
459            resets_at: Some("2026-07-26T14:09:00Z".to_string()),
460            window_minutes: Some(10080),
461            used_count: None,
462            total_count: None,
463        };
464        let json = serde_json::to_string(&window).unwrap();
465        assert!(
466            !json.contains("usedCount"),
467            "absent used_count must be omitted"
468        );
469        assert!(
470            !json.contains("totalCount"),
471            "absent total_count must be omitted"
472        );
473
474        let enriched = RateWindow {
475            used_count: Some(10336.0),
476            total_count: Some(40000.0),
477            ..window
478        };
479        let json = serde_json::to_string(&enriched).unwrap();
480        assert!(json.contains("\"usedCount\":10336.0"));
481        assert!(json.contains("\"totalCount\":40000.0"));
482        let back: RateWindow = serde_json::from_str(&json).unwrap();
483        assert_eq!(back, enriched);
484    }
485
486    /// The field is additive: a producer that does not set it must serialize
487    /// exactly as before, or adding it changes every existing entry on the wire.
488    #[test]
489    fn an_entry_without_a_class_serializes_as_it_did_before_the_field_existed() {
490        let entry = ProviderUsage::degraded("codex", "no session: nothing configured");
491        let json = serde_json::to_string(&entry).unwrap();
492
493        assert!(!json.contains("errorClass"), "absent class must be omitted");
494        // Not vacuous: the entry really is a degraded one carrying its message,
495        // so this cannot pass by serializing something empty.
496        assert!(json.contains("\"error\":\"no session: nothing configured\""));
497
498        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
499        assert_eq!(back, entry);
500        assert_eq!(back.error_class, None);
501    }
502
503    #[test]
504    fn a_class_round_trips_under_its_camel_case_wire_name() {
505        let entry = ProviderUsage::degraded_with_class(
506            "gemini",
507            "credential unusable: gemini creds have no refresh_token",
508            "credential_unusable",
509        );
510        let json = serde_json::to_string(&entry).unwrap();
511
512        assert!(json.contains("\"errorClass\":\"credential_unusable\""));
513        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
514        assert_eq!(back, entry);
515    }
516
517    /// The classes are open by design, so a consumer built today must survive a
518    /// producer that ships a class it has never heard of. Modelling the field as
519    /// a `String` is what buys that: an enum would make this a parse failure,
520    /// and on an observability surface a parse failure means the entry vanishes
521    /// at the moment its state changed.
522    #[test]
523    fn an_unknown_class_decodes_rather_than_failing() {
524        let json = r#"{"provider":"someprovider","error":"something new","errorClass":"a_class_from_the_future"}"#;
525
526        let entry: ProviderUsage =
527            serde_json::from_str(json).expect("an unrecognised class must not fail to decode");
528
529        assert_eq!(
530            entry.error_class.as_deref(),
531            Some("a_class_from_the_future")
532        );
533        // The rest of the entry survives intact, so a consumer can still render
534        // it as degraded-with-unknown-reason rather than dropping it.
535        assert_eq!(entry.provider, "someprovider");
536        assert_eq!(entry.error.as_deref(), Some("something new"));
537    }
538
539    /// A healthy entry must never carry a class: the field's presence is itself
540    /// a signal, and a class on a working provider would be a contradiction a
541    /// consumer has to resolve.
542    #[test]
543    fn a_healthy_entry_carries_no_class() {
544        let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
545        assert_eq!(entry.error_class, None);
546        assert!(!serde_json::to_string(&entry)
547            .unwrap()
548            .contains("errorClass"));
549    }
550}