1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4#[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#[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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82pub struct NamedRateWindow {
83 pub id: String,
84 pub label: String,
85 pub window: RateWindow,
86}
87
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct UsageSnapshot {
91 pub provider_id: String,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub account_id: Option<String>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub account_label: Option<String>,
99 #[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
133use super::cost::CostSnapshot;
135use super::credits::CreditsSnapshot;
136
137#[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 let s = RateWindowStatus::from_ratio(-0.1);
252 assert_eq!(s, RateWindowStatus::Unknown);
253 }
254}