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). Present only on relaxed windows; human-facing UIs should
45    /// display this truth alongside the effective number.
46    #[serde(skip_serializing_if = "Option::is_none", default)]
47    pub raw_used_percent: Option<f64>,
48    /// ISO 8601 / RFC 3339 timestamp when the window resets. Omitted when the
49    /// provider reports no reset (e.g. an idle session window with nothing
50    /// pending) — never fabricated.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub resets_at: Option<String>,
53    /// Window length in minutes. Omitted when the provider does not report one;
54    /// the consumer then paces on utilization alone rather than a burn rate.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub window_minutes: Option<i64>,
57    /// Absolute consumed count in the window (e.g. tokens, requests). Present
58    /// only when the provider reports or derives it; human-facing UIs can show
59    /// "10,336 / 40,000" alongside the percentage for richer context.
60    #[serde(skip_serializing_if = "Option::is_none", default)]
61    pub used_count: Option<f64>,
62    /// Absolute total cap for the window. Present alongside `used_count` when
63    /// the provider knows the ceiling; omitted otherwise.
64    #[serde(skip_serializing_if = "Option::is_none", default)]
65    pub total_count: Option<f64>,
66}
67
68/// A per-model window bundled under one account (e.g. Antigravity's Geminis).
69#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
70#[serde(rename_all = "camelCase")]
71pub struct ExtraWindow {
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub title: Option<String>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub id: Option<String>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub window: Option<RateWindow>,
78}
79
80/// The window topology for one account: up to three account-wide pools plus an
81/// optional list of per-model pools.
82#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
83#[serde(rename_all = "camelCase")]
84pub struct Usage {
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub primary: Option<RateWindow>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub secondary: Option<RateWindow>,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub tertiary: Option<RateWindow>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub extra_rate_windows: Option<Vec<ExtraWindow>>,
93}
94
95/// Account labels and subscription information supplied by a provider or vault.
96#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
97#[serde(rename_all = "camelCase")]
98pub struct AccountInfo {
99    #[serde(skip_serializing_if = "Option::is_none", default)]
100    pub email: Option<String>,
101    #[serde(skip_serializing_if = "Option::is_none", default)]
102    pub org_name: Option<String>,
103    #[serde(skip_serializing_if = "Option::is_none", default)]
104    pub plan_type: Option<String>,
105}
106
107impl AccountInfo {
108    pub fn is_empty(&self) -> bool {
109        self.email.is_none() && self.org_name.is_none() && self.plan_type.is_none()
110    }
111}
112
113/// One saved reset credit and its expiry time.
114#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
115#[serde(rename_all = "camelCase")]
116pub struct CreditExpiry {
117    pub expires_at: String,
118}
119
120/// Saved reset credits reported by Codex's read-only credits endpoint.
121#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
122#[serde(rename_all = "camelCase")]
123pub struct SavedResets {
124    #[serde(default)]
125    pub available_count: u32,
126    #[serde(skip_serializing_if = "Option::is_none", default)]
127    pub soonest_expires_at: Option<String>,
128    #[serde(default)]
129    pub credits: Vec<CreditExpiry>,
130}
131
132fn account_info_is_empty(value: &Option<AccountInfo>) -> bool {
133    value.as_ref().map(AccountInfo::is_empty).unwrap_or(true)
134}
135
136/// One provider/account's usage entry. The `/usage` response is an array of
137/// these. A fetch failure becomes an entry carrying `error` (silent-degrade),
138/// never a failure of the whole array.
139#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
140#[serde(rename_all = "camelCase")]
141pub struct ProviderUsage {
142    /// CodexBar provider name (e.g. "codex"), which consumers map to their own id.
143    pub provider: String,
144    /// Canonical API provider identifier — the models.dev slug for the same
145    /// provider (e.g. "openai" when `provider == "codex"`, "anthropic" for
146    /// "claude", "google" for "gemini", "xai" for "grok"). Present when the
147    /// producer knows the canonical name; absent for providers with no models.dev
148    /// counterpart, where consumers fall back to `provider`. Lets every consumer
149    /// key on one canonical name instead of each maintaining its own
150    /// CodexBar-name → canonical map.
151    #[serde(skip_serializing_if = "Option::is_none", default)]
152    pub api_provider: Option<String>,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub account: Option<String>,
155    /// Which retrieval path produced this (e.g. "oauth") — observability only.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub source: Option<String>,
158    #[serde(skip_serializing_if = "account_info_is_empty", default)]
159    pub account_info: Option<AccountInfo>,
160    #[serde(skip_serializing_if = "Option::is_none", default)]
161    pub fetched_at: Option<String>,
162    #[serde(skip_serializing_if = "Option::is_none", default)]
163    pub saved_resets: Option<SavedResets>,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub usage: Option<Usage>,
166    /// Present only on a degraded entry. The consumer skips any entry with a
167    /// truthy `error`.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub error: Option<String>,
170    /// A stable, machine-readable name for *why* a degraded entry failed,
171    /// published beside the human-readable `error`.
172    ///
173    /// `error` is prose with no stability promise, so consumers are told not to
174    /// branch on it — which leaves them no way to separate failures that mean
175    /// something from failures that are a permanent, correct state. A host that
176    /// never configured a provider and a host whose credential broke this
177    /// morning both produce a degraded entry, and only the second is worth
178    /// anyone's attention.
179    ///
180    /// Classes currently produced:
181    ///
182    /// | Value | Meaning |
183    /// |---|---|
184    /// | `credential_absent` | No credential was found. Permanent and correct on a host that never configured this provider; nothing to fix. |
185    /// | `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. |
186    /// | `credential_rejected` | The upstream rejected the credential (401/403). Usually means logging in again. |
187    /// | `no_quota_reported` | The credential works and the account genuinely has no quota to report. Not a failure. |
188    /// | `upstream_failed` | The upstream could not be reached or returned an error status. Usually transient. |
189    /// | `decode_failed` | The response arrived but was not the expected shape. |
190    ///
191    /// **This list will grow.** A consumer must render an unrecognised class as
192    /// a degraded entry with an unknown reason — never drop the entry, and
193    /// never fold it into an existing bucket. It is a `String` rather than an
194    /// enum for exactly that reason: on an observability surface, meeting an
195    /// unknown value must not turn into a parse failure that makes a provider
196    /// disappear at the moment its state changed.
197    ///
198    /// Absent on healthy entries, and absent from any producer that predates
199    /// this field.
200    #[serde(skip_serializing_if = "Option::is_none", default)]
201    pub error_class: Option<String>,
202}
203
204impl ProviderUsage {
205    /// A healthy entry with resolved windows.
206    pub fn healthy(provider: &str, account: Option<String>, source: &str, usage: Usage) -> Self {
207        Self {
208            provider: provider.to_string(),
209            api_provider: None,
210            account,
211            source: Some(source.to_string()),
212            account_info: None,
213            fetched_at: None,
214            saved_resets: None,
215            usage: Some(usage),
216            error: None,
217            error_class: None,
218        }
219    }
220
221    /// A degraded entry: the provider is named so the consumer can correlate,
222    /// but it carries only an error string and no windows.
223    pub fn degraded(provider: &str, error: impl std::fmt::Display) -> Self {
224        Self {
225            provider: provider.to_string(),
226            api_provider: None,
227            account: None,
228            source: None,
229            account_info: None,
230            fetched_at: None,
231            saved_resets: None,
232            usage: None,
233            error: Some(error.to_string()),
234            error_class: None,
235        }
236    }
237
238    /// A degraded entry that also names *why* it failed.
239    ///
240    /// Prefer this over [`Self::degraded`] wherever the producer knows the
241    /// class: without it a consumer can only tell an unconfigured provider from
242    /// a broken one by reading prose it has been told not to parse. See
243    /// [`ProviderUsage::error_class`] for the classes and for the rule that an
244    /// unrecognised one must still render.
245    pub fn degraded_with_class(
246        provider: &str,
247        error: impl std::fmt::Display,
248        error_class: impl Into<String>,
249    ) -> Self {
250        Self {
251            error_class: Some(error_class.into()),
252            ..Self::degraded(provider, error)
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn account_info_is_omitted_when_empty_and_keeps_partial_labels() {
263        let bare = ProviderUsage::healthy(
264            "codex",
265            None,
266            "oauth",
267            Usage {
268                primary: Some(RateWindow {
269                    used_percent: 10.0,
270                    raw_used_percent: None,
271                    resets_at: None,
272                    window_minutes: Some(300),
273                    used_count: None,
274                    total_count: None,
275                }),
276                ..Default::default()
277            },
278        );
279        let json = serde_json::to_string(&bare).unwrap();
280        assert!(
281            !json.contains("accountInfo"),
282            "empty accountInfo must be omitted"
283        );
284
285        let mut labeled = bare.clone();
286        labeled.account_info = Some(AccountInfo {
287            email: Some("a@b.com".to_string()),
288            org_name: None,
289            plan_type: Some("pro".to_string()),
290        });
291        let json = serde_json::to_string(&labeled).unwrap();
292        assert!(json.contains("\"email\":\"a@b.com\""));
293        assert!(json.contains("\"planType\":\"pro\""));
294        assert!(!json.contains("orgName"), "absent orgName must be omitted");
295    }
296
297    #[test]
298    fn saved_resets_use_camel_case_and_round_trip() {
299        let entry = ProviderUsage {
300            saved_resets: Some(SavedResets {
301                available_count: 2,
302                soonest_expires_at: Some("2026-07-31T20:11:35Z".to_string()),
303                credits: vec![CreditExpiry {
304                    expires_at: "2026-07-31T20:11:35Z".to_string(),
305                }],
306            }),
307            ..ProviderUsage::degraded("codex", "x")
308        };
309        let json = serde_json::to_string(&entry).unwrap();
310        assert!(json.contains("\"savedResets\""));
311        assert!(json.contains("\"availableCount\":2"));
312        assert!(json.contains("\"soonestExpiresAt\""));
313        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
314        assert_eq!(back, entry);
315    }
316
317    #[test]
318    fn raw_used_percent_is_absent_from_unrelaxed_windows_and_camel_case_when_present() {
319        let unrelaxed = RateWindow {
320            used_percent: 41.0,
321            raw_used_percent: None,
322            resets_at: Some("2026-07-20T00:00:00Z".to_string()),
323            window_minutes: Some(10080),
324            used_count: None,
325            total_count: None,
326        };
327        let json = serde_json::to_string(&unrelaxed).unwrap();
328        assert!(
329            !json.contains("rawUsedPercent"),
330            "unrelaxed window must not carry the field"
331        );
332
333        let relaxed = RateWindow {
334            used_percent: 0.0,
335            raw_used_percent: Some(70.0),
336            resets_at: Some("2026-07-20T00:00:00Z".to_string()),
337            window_minutes: Some(10080),
338            used_count: None,
339            total_count: None,
340        };
341        let json = serde_json::to_string(&relaxed).unwrap();
342        assert!(json.contains("\"rawUsedPercent\":70.0"));
343        let back: RateWindow = serde_json::from_str(&json).unwrap();
344        assert_eq!(back, relaxed);
345    }
346
347    #[test]
348    fn healthy_entry_omits_error_and_degraded_entry_omits_usage() {
349        let healthy = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
350        let json = serde_json::to_string(&healthy).unwrap();
351        assert!(!json.contains("error"));
352
353        let degraded = ProviderUsage::degraded("codex", "no session");
354        let json = serde_json::to_string(&degraded).unwrap();
355        assert!(json.contains("\"error\":\"no session\""));
356        assert!(!json.contains("usage"));
357    }
358
359    #[test]
360    fn api_provider_is_camel_case_present_when_set_and_omitted_when_absent() {
361        let mut entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
362        let json = serde_json::to_string(&entry).unwrap();
363        assert!(
364            !json.contains("apiProvider"),
365            "absent api_provider must be omitted"
366        );
367
368        entry.api_provider = Some("openai".to_string());
369        let json = serde_json::to_string(&entry).unwrap();
370        assert!(json.contains("\"apiProvider\":\"openai\""));
371        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
372        assert_eq!(back, entry);
373    }
374
375    #[test]
376    fn used_count_and_total_count_are_camel_case_and_omitted_when_absent() {
377        let window = RateWindow {
378            used_percent: 25.8,
379            raw_used_percent: None,
380            resets_at: Some("2026-07-26T14:09:00Z".to_string()),
381            window_minutes: Some(10080),
382            used_count: None,
383            total_count: None,
384        };
385        let json = serde_json::to_string(&window).unwrap();
386        assert!(
387            !json.contains("usedCount"),
388            "absent used_count must be omitted"
389        );
390        assert!(
391            !json.contains("totalCount"),
392            "absent total_count must be omitted"
393        );
394
395        let enriched = RateWindow {
396            used_count: Some(10336.0),
397            total_count: Some(40000.0),
398            ..window
399        };
400        let json = serde_json::to_string(&enriched).unwrap();
401        assert!(json.contains("\"usedCount\":10336.0"));
402        assert!(json.contains("\"totalCount\":40000.0"));
403        let back: RateWindow = serde_json::from_str(&json).unwrap();
404        assert_eq!(back, enriched);
405    }
406
407    /// The field is additive: a producer that does not set it must serialize
408    /// exactly as before, or adding it changes every existing entry on the wire.
409    #[test]
410    fn an_entry_without_a_class_serializes_as_it_did_before_the_field_existed() {
411        let entry = ProviderUsage::degraded("codex", "no session: nothing configured");
412        let json = serde_json::to_string(&entry).unwrap();
413
414        assert!(!json.contains("errorClass"), "absent class must be omitted");
415        // Not vacuous: the entry really is a degraded one carrying its message,
416        // so this cannot pass by serializing something empty.
417        assert!(json.contains("\"error\":\"no session: nothing configured\""));
418
419        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
420        assert_eq!(back, entry);
421        assert_eq!(back.error_class, None);
422    }
423
424    #[test]
425    fn a_class_round_trips_under_its_camel_case_wire_name() {
426        let entry = ProviderUsage::degraded_with_class(
427            "gemini",
428            "credential unusable: gemini creds have no refresh_token",
429            "credential_unusable",
430        );
431        let json = serde_json::to_string(&entry).unwrap();
432
433        assert!(json.contains("\"errorClass\":\"credential_unusable\""));
434        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
435        assert_eq!(back, entry);
436    }
437
438    /// The classes are open by design, so a consumer built today must survive a
439    /// producer that ships a class it has never heard of. Modelling the field as
440    /// a `String` is what buys that: an enum would make this a parse failure,
441    /// and on an observability surface a parse failure means the entry vanishes
442    /// at the moment its state changed.
443    #[test]
444    fn an_unknown_class_decodes_rather_than_failing() {
445        let json = r#"{"provider":"someprovider","error":"something new","errorClass":"a_class_from_the_future"}"#;
446
447        let entry: ProviderUsage =
448            serde_json::from_str(json).expect("an unrecognised class must not fail to decode");
449
450        assert_eq!(
451            entry.error_class.as_deref(),
452            Some("a_class_from_the_future")
453        );
454        // The rest of the entry survives intact, so a consumer can still render
455        // it as degraded-with-unknown-reason rather than dropping it.
456        assert_eq!(entry.provider, "someprovider");
457        assert_eq!(entry.error.as_deref(), Some("something new"));
458    }
459
460    /// A healthy entry must never carry a class: the field's presence is itself
461    /// a signal, and a class on a working provider would be a contradiction a
462    /// consumer has to resolve.
463    #[test]
464    fn a_healthy_entry_carries_no_class() {
465        let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
466        assert_eq!(entry.error_class, None);
467        assert!(!serde_json::to_string(&entry)
468            .unwrap()
469            .contains("errorClass"));
470    }
471}