From 0ef4536caca5bb30d9512e8f6f5c23a09dfec6bd Mon Sep 17 00:00:00 2001 From: ualtinok Date: Sun, 19 Jul 2026 23:11:17 +0200 Subject: [PATCH] Add cortexkit-provider-usage: shared wire types for the quota usage.get payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure serde types (ProviderUsage, Usage, RateWindow, AccountInfo, SavedResets, CreditExpiry, ExtraWindow) extracted from ai-provider-quota's model.rs so every consumer of the usage.get wire — the quota module that produces it, ALF's router reads, astrocyte's capacity axis, and the ck quota renderer — compiles against one definition and the shape cannot drift without a shared-crate PR each side reviews. Shape, not policy: read-time transform semantics (the quota module's banked-reset relaxation, which zeroes used_percent and carries the provider truth in raw_used_percent) are producer behavior documented on the fields but not enforced by these types. serde-only, no logic; serde_json is a dev-dependency for the wire-shape tests. The reserved prepaid-Balance seam is intentionally NOT included (it was never populated and is wire-neutral); it joins this crate additively when the balance axis is designed. Follows the cortexkit-model-catalog precedent. --- Cargo.toml | 2 +- crates/cortexkit-provider-usage/Cargo.toml | 25 ++ crates/cortexkit-provider-usage/src/lib.rs | 279 +++++++++++++++++++++ 3 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 crates/cortexkit-provider-usage/Cargo.toml create mode 100644 crates/cortexkit-provider-usage/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 9ad1880..cb5069f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/cortexkit-paths", "crates/cortexkit-lease", "crates/cortexkit-store-types", "crates/cortexkit-store", "crates/cortexkit-store-postgres", "crates/cortexkit-cache-core", "crates/cortexkit-model-catalog"] +members = ["crates/cortexkit-paths", "crates/cortexkit-lease", "crates/cortexkit-store-types", "crates/cortexkit-store", "crates/cortexkit-store-postgres", "crates/cortexkit-cache-core", "crates/cortexkit-model-catalog", "crates/cortexkit-provider-usage"] # cortexkit/commons — neutral home for cross-product CortexKit primitives. # Shared by subc, AFT, and Magic Context. Each crate is published independently diff --git a/crates/cortexkit-provider-usage/Cargo.toml b/crates/cortexkit-provider-usage/Cargo.toml new file mode 100644 index 0000000..a68f4bc --- /dev/null +++ b/crates/cortexkit-provider-usage/Cargo.toml @@ -0,0 +1,25 @@ +# cortexkit-provider-usage — the shared WIRE REPRESENTATION of the ai-provider +# quota `usage.get` payload: pure serde types only, NO fetch/transform logic. +# Consumers (the quota module that produces it, ALF's router reads, astrocyte's +# capacity axis, the ck CLI renderer) all compile against ONE definition so the +# shape cannot drift without a shared-crate PR every side reviews. +# +# SHAPE, NOT POLICY: read-time transform semantics — e.g. the quota module's +# banked-reset relaxation, which zeroes `used_percent` and carries the provider +# truth in `raw_used_percent` — are PRODUCER behavior documented on the fields +# below but NOT enforced by these types. A consumer renders whatever the wire +# says; this crate makes no guarantee about how a producer derived the numbers. +[package] +name = "cortexkit-provider-usage" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "Shared wire types for the ai-provider-quota usage.get payload: ProviderUsage, Usage, RateWindow, and account/reset metadata. Pure serde, no logic." + +[dependencies] +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +serde_json = "1" diff --git a/crates/cortexkit-provider-usage/src/lib.rs b/crates/cortexkit-provider-usage/src/lib.rs new file mode 100644 index 0000000..be7508a --- /dev/null +++ b/crates/cortexkit-provider-usage/src/lib.rs @@ -0,0 +1,279 @@ +//! Shared wire types for the `ai-provider-quota` module's `usage.get` payload. +//! +//! The quota module serves an array of [`ProviderUsage`] per request; ALF's +//! router (`codexbar-window-extractors.ts`), astrocyte's capacity axis, and the +//! `ck quota` renderer all consume that shape. This crate is the single +//! definition those consumers compile against, so the wire shape cannot drift +//! without a shared-crate PR every side reviews. +//! +//! # Shape, not policy +//! +//! These are pure data types. Read-time transform semantics are PRODUCER +//! behavior documented on the relevant fields but NOT enforced here: +//! - **Banked-reset relaxation:** the quota module may zero +//! [`RateWindow::used_percent`] (the EFFECTIVE number consumers pace on) and +//! carry the provider-reported truth in [`RateWindow::raw_used_percent`]. +//! A consumer renders whatever the wire says; a sudden `0 → high` transition +//! is an honest disarm (credits spent / auth broke), not a glitch. +//! - **Cache-only partial arrays:** the quota module never blocks on a fetch, +//! so a result may omit providers not yet swept. Missing ≠ zero. +//! - **Degraded entries ride in-band:** a provider fetch failure is a normal +//! [`ProviderUsage`] carrying `error`, not a request-level failure. +//! +//! # Serialization contract consumers depend on +//! - camelCase keys (`usedPercent`, `resetsAt`, `windowMinutes`, +//! `extraRateWindows`, `rawUsedPercent`, `accountInfo`, `savedResets`). +//! - A healthy entry MUST NOT carry `error` (consumers skip truthy-`error` +//! entries), so it is omitted when absent. +//! - A window is emitted when it has a `usedPercent`; `resetsAt` is OPTIONAL and +//! omitted when the provider reports no reset (never fabricated). + +use serde::{Deserialize, Serialize}; + +/// One rate-limit window: how much of a quota pool is spent and when it resets. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RateWindow { + /// 0..100 percent of the window's quota consumed. This is the EFFECTIVE + /// number consumers pace on: when banked-reset relaxation applies it is + /// zeroed, and the provider-reported percent moves to `raw_used_percent`. + pub used_percent: f64, + /// The provider-reported percent when `used_percent` has been relaxed to + /// an effective value (banked resets guarantee the window resets before + /// the wall). Present only on relaxed windows; human-facing UIs should + /// display this truth alongside the effective number. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub raw_used_percent: Option, + /// ISO 8601 / RFC 3339 timestamp when the window resets. Omitted when the + /// provider reports no reset (e.g. an idle session window with nothing + /// pending) — never fabricated. + #[serde(skip_serializing_if = "Option::is_none")] + pub resets_at: Option, + /// Window length in minutes. Omitted when the provider does not report one; + /// the consumer then paces on utilization alone rather than a burn rate. + #[serde(skip_serializing_if = "Option::is_none")] + pub window_minutes: Option, +} + +/// A per-model window bundled under one account (e.g. Antigravity's Geminis). +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ExtraWindow { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub window: Option, +} + +/// The window topology for one account: up to three account-wide pools plus an +/// optional list of per-model pools. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct Usage { + #[serde(skip_serializing_if = "Option::is_none")] + pub primary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub secondary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tertiary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_rate_windows: Option>, +} + +/// Account labels and subscription information supplied by a provider or vault. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct AccountInfo { + #[serde(skip_serializing_if = "Option::is_none", default)] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub org_name: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub plan_type: Option, +} + +impl AccountInfo { + pub fn is_empty(&self) -> bool { + self.email.is_none() && self.org_name.is_none() && self.plan_type.is_none() + } +} + +/// One saved reset credit and its expiry time. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CreditExpiry { + pub expires_at: String, +} + +/// Saved reset credits reported by Codex's read-only credits endpoint. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct SavedResets { + #[serde(default)] + pub available_count: u32, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub soonest_expires_at: Option, + #[serde(default)] + pub credits: Vec, +} + +fn account_info_is_empty(value: &Option) -> bool { + value.as_ref().map(AccountInfo::is_empty).unwrap_or(true) +} + +/// One provider/account's usage entry. The `/usage` response is an array of +/// these. A fetch failure becomes an entry carrying `error` (silent-degrade), +/// never a failure of the whole array. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProviderUsage { + /// CodexBar provider name (e.g. "codex"), which consumers map to their own id. + pub provider: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub account: Option, + /// Which retrieval path produced this (e.g. "oauth") — observability only. + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(skip_serializing_if = "account_info_is_empty", default)] + pub account_info: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub fetched_at: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub saved_resets: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub usage: Option, + /// Present only on a degraded entry. The consumer skips any entry with a + /// truthy `error`. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl ProviderUsage { + /// A healthy entry with resolved windows. + pub fn healthy(provider: &str, account: Option, source: &str, usage: Usage) -> Self { + Self { + provider: provider.to_string(), + account, + source: Some(source.to_string()), + account_info: None, + fetched_at: None, + saved_resets: None, + usage: Some(usage), + error: None, + } + } + + /// A degraded entry: the provider is named so the consumer can correlate, + /// but it carries only an error string and no windows. + pub fn degraded(provider: &str, error: impl std::fmt::Display) -> Self { + Self { + provider: provider.to_string(), + account: None, + source: None, + account_info: None, + fetched_at: None, + saved_resets: None, + usage: None, + error: Some(error.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn account_info_is_omitted_when_empty_and_keeps_partial_labels() { + let bare = ProviderUsage::healthy( + "codex", + None, + "oauth", + Usage { + primary: Some(RateWindow { + used_percent: 10.0, + raw_used_percent: None, + resets_at: None, + window_minutes: Some(300), + }), + ..Default::default() + }, + ); + let json = serde_json::to_string(&bare).unwrap(); + assert!( + !json.contains("accountInfo"), + "empty accountInfo must be omitted" + ); + + let mut labeled = bare.clone(); + labeled.account_info = Some(AccountInfo { + email: Some("a@b.com".to_string()), + org_name: None, + plan_type: Some("pro".to_string()), + }); + let json = serde_json::to_string(&labeled).unwrap(); + assert!(json.contains("\"email\":\"a@b.com\"")); + assert!(json.contains("\"planType\":\"pro\"")); + assert!(!json.contains("orgName"), "absent orgName must be omitted"); + } + + #[test] + fn saved_resets_use_camel_case_and_round_trip() { + let entry = ProviderUsage { + saved_resets: Some(SavedResets { + available_count: 2, + soonest_expires_at: Some("2026-07-31T20:11:35Z".to_string()), + credits: vec![CreditExpiry { + expires_at: "2026-07-31T20:11:35Z".to_string(), + }], + }), + ..ProviderUsage::degraded("codex", "x") + }; + let json = serde_json::to_string(&entry).unwrap(); + assert!(json.contains("\"savedResets\"")); + assert!(json.contains("\"availableCount\":2")); + assert!(json.contains("\"soonestExpiresAt\"")); + let back: ProviderUsage = serde_json::from_str(&json).unwrap(); + assert_eq!(back, entry); + } + + #[test] + fn raw_used_percent_is_absent_from_unrelaxed_windows_and_camel_case_when_present() { + let unrelaxed = RateWindow { + used_percent: 41.0, + raw_used_percent: None, + resets_at: Some("2026-07-20T00:00:00Z".to_string()), + window_minutes: Some(10080), + }; + let json = serde_json::to_string(&unrelaxed).unwrap(); + assert!( + !json.contains("rawUsedPercent"), + "unrelaxed window must not carry the field" + ); + + let relaxed = RateWindow { + used_percent: 0.0, + raw_used_percent: Some(70.0), + resets_at: Some("2026-07-20T00:00:00Z".to_string()), + window_minutes: Some(10080), + }; + let json = serde_json::to_string(&relaxed).unwrap(); + assert!(json.contains("\"rawUsedPercent\":70.0")); + let back: RateWindow = serde_json::from_str(&json).unwrap(); + assert_eq!(back, relaxed); + } + + #[test] + fn healthy_entry_omits_error_and_degraded_entry_omits_usage() { + let healthy = ProviderUsage::healthy("codex", None, "oauth", Usage::default()); + let json = serde_json::to_string(&healthy).unwrap(); + assert!(!json.contains("error")); + + let degraded = ProviderUsage::degraded("codex", "no session"); + let json = serde_json::to_string(°raded).unwrap(); + assert!(json.contains("\"error\":\"no session\"")); + assert!(!json.contains("usage")); + } +}