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//! - A healthy entry MUST NOT carry `error` (consumers skip truthy-`error`
27//!   entries), so it is omitted when absent.
28//! - A window is emitted when it has a `usedPercent`; `resetsAt` is OPTIONAL and
29//!   omitted when the provider reports no reset (never fabricated).
30
31use serde::{Deserialize, Serialize};
32
33/// One rate-limit window: how much of a quota pool is spent and when it resets.
34#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
35#[serde(rename_all = "camelCase")]
36pub struct RateWindow {
37    /// 0..100 percent of the window's quota consumed. This is the EFFECTIVE
38    /// number consumers pace on: when banked-reset relaxation applies it is
39    /// zeroed, and the provider-reported percent moves to `raw_used_percent`.
40    pub used_percent: f64,
41    /// The provider-reported percent when `used_percent` has been relaxed to
42    /// an effective value (banked resets guarantee the window resets before
43    /// the wall). Present only on relaxed windows; human-facing UIs should
44    /// display this truth alongside the effective number.
45    #[serde(skip_serializing_if = "Option::is_none", default)]
46    pub raw_used_percent: Option<f64>,
47    /// ISO 8601 / RFC 3339 timestamp when the window resets. Omitted when the
48    /// provider reports no reset (e.g. an idle session window with nothing
49    /// pending) — never fabricated.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub resets_at: Option<String>,
52    /// Window length in minutes. Omitted when the provider does not report one;
53    /// the consumer then paces on utilization alone rather than a burn rate.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub window_minutes: Option<i64>,
56}
57
58/// A per-model window bundled under one account (e.g. Antigravity's Geminis).
59#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
60#[serde(rename_all = "camelCase")]
61pub struct ExtraWindow {
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub title: Option<String>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub id: Option<String>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub window: Option<RateWindow>,
68}
69
70/// The window topology for one account: up to three account-wide pools plus an
71/// optional list of per-model pools.
72#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
73#[serde(rename_all = "camelCase")]
74pub struct Usage {
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub primary: Option<RateWindow>,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub secondary: Option<RateWindow>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub tertiary: Option<RateWindow>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub extra_rate_windows: Option<Vec<ExtraWindow>>,
83}
84
85/// Account labels and subscription information supplied by a provider or vault.
86#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
87#[serde(rename_all = "camelCase")]
88pub struct AccountInfo {
89    #[serde(skip_serializing_if = "Option::is_none", default)]
90    pub email: Option<String>,
91    #[serde(skip_serializing_if = "Option::is_none", default)]
92    pub org_name: Option<String>,
93    #[serde(skip_serializing_if = "Option::is_none", default)]
94    pub plan_type: Option<String>,
95}
96
97impl AccountInfo {
98    pub fn is_empty(&self) -> bool {
99        self.email.is_none() && self.org_name.is_none() && self.plan_type.is_none()
100    }
101}
102
103/// One saved reset credit and its expiry time.
104#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
105#[serde(rename_all = "camelCase")]
106pub struct CreditExpiry {
107    pub expires_at: String,
108}
109
110/// Saved reset credits reported by Codex's read-only credits endpoint.
111#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
112#[serde(rename_all = "camelCase")]
113pub struct SavedResets {
114    #[serde(default)]
115    pub available_count: u32,
116    #[serde(skip_serializing_if = "Option::is_none", default)]
117    pub soonest_expires_at: Option<String>,
118    #[serde(default)]
119    pub credits: Vec<CreditExpiry>,
120}
121
122fn account_info_is_empty(value: &Option<AccountInfo>) -> bool {
123    value.as_ref().map(AccountInfo::is_empty).unwrap_or(true)
124}
125
126/// One provider/account's usage entry. The `/usage` response is an array of
127/// these. A fetch failure becomes an entry carrying `error` (silent-degrade),
128/// never a failure of the whole array.
129#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
130#[serde(rename_all = "camelCase")]
131pub struct ProviderUsage {
132    /// CodexBar provider name (e.g. "codex"), which consumers map to their own id.
133    pub provider: String,
134    /// Canonical API provider identifier — the models.dev slug for the same
135    /// provider (e.g. "openai" when `provider == "codex"`, "anthropic" for
136    /// "claude", "google" for "gemini", "xai" for "grok"). Present when the
137    /// producer knows the canonical name; absent for providers with no models.dev
138    /// counterpart, where consumers fall back to `provider`. Lets every consumer
139    /// key on one canonical name instead of each maintaining its own
140    /// CodexBar-name → canonical map.
141    #[serde(skip_serializing_if = "Option::is_none", default)]
142    pub api_provider: Option<String>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub account: Option<String>,
145    /// Which retrieval path produced this (e.g. "oauth") — observability only.
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub source: Option<String>,
148    #[serde(skip_serializing_if = "account_info_is_empty", default)]
149    pub account_info: Option<AccountInfo>,
150    #[serde(skip_serializing_if = "Option::is_none", default)]
151    pub fetched_at: Option<String>,
152    #[serde(skip_serializing_if = "Option::is_none", default)]
153    pub saved_resets: Option<SavedResets>,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub usage: Option<Usage>,
156    /// Present only on a degraded entry. The consumer skips any entry with a
157    /// truthy `error`.
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub error: Option<String>,
160}
161
162impl ProviderUsage {
163    /// A healthy entry with resolved windows.
164    pub fn healthy(provider: &str, account: Option<String>, source: &str, usage: Usage) -> Self {
165        Self {
166            provider: provider.to_string(),
167            api_provider: None,
168            account,
169            source: Some(source.to_string()),
170            account_info: None,
171            fetched_at: None,
172            saved_resets: None,
173            usage: Some(usage),
174            error: None,
175        }
176    }
177
178    /// A degraded entry: the provider is named so the consumer can correlate,
179    /// but it carries only an error string and no windows.
180    pub fn degraded(provider: &str, error: impl std::fmt::Display) -> Self {
181        Self {
182            provider: provider.to_string(),
183            api_provider: None,
184            account: None,
185            source: None,
186            account_info: None,
187            fetched_at: None,
188            saved_resets: None,
189            usage: None,
190            error: Some(error.to_string()),
191        }
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn account_info_is_omitted_when_empty_and_keeps_partial_labels() {
201        let bare = ProviderUsage::healthy(
202            "codex",
203            None,
204            "oauth",
205            Usage {
206                primary: Some(RateWindow {
207                    used_percent: 10.0,
208                    raw_used_percent: None,
209                    resets_at: None,
210                    window_minutes: Some(300),
211                }),
212                ..Default::default()
213            },
214        );
215        let json = serde_json::to_string(&bare).unwrap();
216        assert!(
217            !json.contains("accountInfo"),
218            "empty accountInfo must be omitted"
219        );
220
221        let mut labeled = bare.clone();
222        labeled.account_info = Some(AccountInfo {
223            email: Some("a@b.com".to_string()),
224            org_name: None,
225            plan_type: Some("pro".to_string()),
226        });
227        let json = serde_json::to_string(&labeled).unwrap();
228        assert!(json.contains("\"email\":\"a@b.com\""));
229        assert!(json.contains("\"planType\":\"pro\""));
230        assert!(!json.contains("orgName"), "absent orgName must be omitted");
231    }
232
233    #[test]
234    fn saved_resets_use_camel_case_and_round_trip() {
235        let entry = ProviderUsage {
236            saved_resets: Some(SavedResets {
237                available_count: 2,
238                soonest_expires_at: Some("2026-07-31T20:11:35Z".to_string()),
239                credits: vec![CreditExpiry {
240                    expires_at: "2026-07-31T20:11:35Z".to_string(),
241                }],
242            }),
243            ..ProviderUsage::degraded("codex", "x")
244        };
245        let json = serde_json::to_string(&entry).unwrap();
246        assert!(json.contains("\"savedResets\""));
247        assert!(json.contains("\"availableCount\":2"));
248        assert!(json.contains("\"soonestExpiresAt\""));
249        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
250        assert_eq!(back, entry);
251    }
252
253    #[test]
254    fn raw_used_percent_is_absent_from_unrelaxed_windows_and_camel_case_when_present() {
255        let unrelaxed = RateWindow {
256            used_percent: 41.0,
257            raw_used_percent: None,
258            resets_at: Some("2026-07-20T00:00:00Z".to_string()),
259            window_minutes: Some(10080),
260        };
261        let json = serde_json::to_string(&unrelaxed).unwrap();
262        assert!(
263            !json.contains("rawUsedPercent"),
264            "unrelaxed window must not carry the field"
265        );
266
267        let relaxed = RateWindow {
268            used_percent: 0.0,
269            raw_used_percent: Some(70.0),
270            resets_at: Some("2026-07-20T00:00:00Z".to_string()),
271            window_minutes: Some(10080),
272        };
273        let json = serde_json::to_string(&relaxed).unwrap();
274        assert!(json.contains("\"rawUsedPercent\":70.0"));
275        let back: RateWindow = serde_json::from_str(&json).unwrap();
276        assert_eq!(back, relaxed);
277    }
278
279    #[test]
280    fn healthy_entry_omits_error_and_degraded_entry_omits_usage() {
281        let healthy = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
282        let json = serde_json::to_string(&healthy).unwrap();
283        assert!(!json.contains("error"));
284
285        let degraded = ProviderUsage::degraded("codex", "no session");
286        let json = serde_json::to_string(&degraded).unwrap();
287        assert!(json.contains("\"error\":\"no session\""));
288        assert!(!json.contains("usage"));
289    }
290
291    #[test]
292    fn api_provider_is_camel_case_present_when_set_and_omitted_when_absent() {
293        let mut entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default());
294        let json = serde_json::to_string(&entry).unwrap();
295        assert!(
296            !json.contains("apiProvider"),
297            "absent api_provider must be omitted"
298        );
299
300        entry.api_provider = Some("openai".to_string());
301        let json = serde_json::to_string(&entry).unwrap();
302        assert!(json.contains("\"apiProvider\":\"openai\""));
303        let back: ProviderUsage = serde_json::from_str(&json).unwrap();
304        assert_eq!(back, entry);
305    }
306}