Skip to main content

usage_monitor_cli/model/
usage.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4/// Status of a rate limit window.
5#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum RateWindowStatus {
8    Normal,
9    Warning,
10    Critical,
11    Exhausted,
12    Unknown,
13}
14
15impl RateWindowStatus {
16    pub fn from_ratio(ratio: f64) -> Self {
17        if ratio >= 1.0 {
18            Self::Exhausted
19        } else if ratio >= 0.95 {
20            Self::Critical
21        } else if ratio >= 0.80 {
22            Self::Warning
23        } else if ratio >= 0.0 {
24            Self::Normal
25        } else {
26            Self::Unknown
27        }
28    }
29}
30
31/// Rate limit window with current usage, limit, and reset.
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct RateWindow {
34    pub label: String,
35    pub window_minutes: u32,
36    pub usage_ratio: f64,
37    pub limit: Option<u64>,
38    pub used: Option<u64>,
39    pub remaining: Option<u64>,
40    pub resets_at: Option<DateTime<Utc>>,
41    pub status: RateWindowStatus,
42}
43
44impl RateWindow {
45    pub fn new(used: u64, limit: u64, label: impl Into<String>, window_minutes: u32) -> Self {
46        let ratio = if limit > 0 {
47            (used as f64) / (limit as f64)
48        } else {
49            0.0
50        };
51        let ratio = ratio.clamp(0.0, 1.0);
52
53        Self {
54            label: label.into(),
55            window_minutes,
56            usage_ratio: ratio,
57            limit: Some(limit),
58            used: Some(used),
59            remaining: Some(limit.saturating_sub(used)),
60            resets_at: None,
61            status: RateWindowStatus::from_ratio(ratio),
62        }
63    }
64
65    /// Creates an "empty" window (no data).
66    pub fn unknown(label: impl Into<String>) -> Self {
67        Self {
68            label: label.into(),
69            window_minutes: 0,
70            usage_ratio: 0.0,
71            limit: None,
72            used: None,
73            remaining: None,
74            resets_at: None,
75            status: RateWindowStatus::Unknown,
76        }
77    }
78}
79
80/// Named window (e.g. "Sonnet 5-hour", "Pro Weekly").
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82pub struct NamedRateWindow {
83    pub id: String,
84    pub label: String,
85    pub window: RateWindow,
86}
87
88/// Complete usage snapshot for a provider.
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct UsageSnapshot {
91    pub provider_id: String,
92    /// Account this snapshot was fetched for. `None` for the implicit single
93    /// account (provider with no configured accounts).
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub account_id: Option<String>,
96    /// Human-friendly account label, when one is configured.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub account_label: Option<String>,
99    /// Account email/identity extracted from the provider's credentials, when
100    /// available (e.g. the Codex OAuth id_token). Independent of `account_label`,
101    /// which the registry overwrites with the configured label.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub account_email: Option<String>,
104    pub collected_at: DateTime<Utc>,
105    pub primary_rate_window: Option<RateWindow>,
106    pub secondary_rate_window: Option<RateWindow>,
107    pub tertiary_rate_window: Option<RateWindow>,
108    pub extra_rate_windows: Vec<NamedRateWindow>,
109    pub credits: Option<CreditsSnapshot>,
110    pub cost: Option<CostSnapshot>,
111    pub plan: Option<PlanInfo>,
112}
113
114impl UsageSnapshot {
115    pub fn new(provider_id: impl Into<String>) -> Self {
116        Self {
117            provider_id: provider_id.into(),
118            account_id: None,
119            account_label: None,
120            account_email: None,
121            collected_at: Utc::now(),
122            primary_rate_window: None,
123            secondary_rate_window: None,
124            tertiary_rate_window: None,
125            extra_rate_windows: Vec::new(),
126            credits: None,
127            cost: None,
128            plan: None,
129        }
130    }
131}
132
133// Re-export from submodules to avoid circular dependency
134use super::cost::CostSnapshot;
135use super::credits::CreditsSnapshot;
136
137/// Plan/subscription information.
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139pub struct PlanInfo {
140    pub name: String,
141    pub tier: Option<String>,
142    pub features: Vec<String>,
143    pub price: Option<f64>,
144    pub currency: Option<String>,
145    pub billing_period: Option<String>,
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn test_rate_window_new_calculates_ratio() {
154        let w = RateWindow::new(45, 100, "RPM", 1);
155        assert_eq!(w.usage_ratio, 0.45);
156        assert_eq!(w.remaining, Some(55));
157        assert_eq!(w.status, RateWindowStatus::Normal);
158    }
159
160    #[test]
161    fn test_rate_window_exhausted() {
162        let w = RateWindow::new(100, 100, "RPM", 1);
163        assert_eq!(w.usage_ratio, 1.0);
164        assert_eq!(w.remaining, Some(0));
165        assert_eq!(w.status, RateWindowStatus::Exhausted);
166    }
167
168    #[test]
169    fn test_rate_window_warning_at_80() {
170        let w = RateWindow::new(80, 100, "test", 1);
171        assert_eq!(w.status, RateWindowStatus::Warning);
172    }
173
174    #[test]
175    fn test_rate_window_critical_at_95() {
176        let w = RateWindow::new(95, 100, "test", 1);
177        assert_eq!(w.status, RateWindowStatus::Critical);
178    }
179
180    #[test]
181    fn test_rate_window_normal_below_80() {
182        let w = RateWindow::new(79, 100, "test", 1);
183        assert_eq!(w.status, RateWindowStatus::Normal);
184    }
185
186    #[test]
187    fn test_rate_window_uses_saturating_sub_for_remaining() {
188        let w = RateWindow::new(150, 100, "test", 1);
189        assert_eq!(w.usage_ratio, 1.0);
190        assert_eq!(w.remaining, Some(0));
191    }
192
193    #[test]
194    fn test_rate_window_no_limit() {
195        let w = RateWindow::new(0, 0, "test", 1);
196        assert_eq!(w.usage_ratio, 0.0);
197        assert_eq!(w.limit, Some(0));
198        assert_eq!(w.remaining, Some(0));
199    }
200
201    #[test]
202    fn test_rate_window_unknown() {
203        let w = RateWindow::unknown("unused");
204        assert_eq!(w.status, RateWindowStatus::Unknown);
205        assert!(w.limit.is_none());
206        assert!(w.used.is_none());
207    }
208
209    #[test]
210    fn test_usage_snapshot_new() {
211        let s = UsageSnapshot::new("openai");
212        assert_eq!(s.provider_id, "openai");
213        assert!(s.primary_rate_window.is_none());
214        assert!(s.credits.is_none());
215    }
216
217    #[test]
218    fn test_usage_snapshot_serialization_roundtrip() {
219        let s = UsageSnapshot {
220            provider_id: "test".into(),
221            account_id: None,
222            account_label: None,
223            account_email: None,
224            collected_at: Utc::now(),
225            primary_rate_window: Some(RateWindow::new(50, 100, "test", 60)),
226            secondary_rate_window: None,
227            tertiary_rate_window: None,
228            extra_rate_windows: vec![NamedRateWindow {
229                id: "extra".into(),
230                label: "Extra Window".into(),
231                window: RateWindow::new(10, 20, "extra", 300),
232            }],
233            credits: None,
234            cost: None,
235            plan: None,
236        };
237
238        let json = serde_json::to_string(&s).unwrap();
239        let deserialized: UsageSnapshot = serde_json::from_str(&json).unwrap();
240
241        assert_eq!(s.provider_id, deserialized.provider_id);
242        assert_eq!(
243            s.primary_rate_window.unwrap().usage_ratio,
244            deserialized.primary_rate_window.unwrap().usage_ratio
245        );
246    }
247
248    #[test]
249    fn test_status_from_ratio_negative_becomes_unknown() {
250        // negative ratio should not happen in practice, but test the fallback
251        let s = RateWindowStatus::from_ratio(-0.1);
252        assert_eq!(s, RateWindowStatus::Unknown);
253    }
254}