1use serde::Deserialize;
6
7use crate::usage::{OllamaModelUsage, OllamaSnapshot, UsageWindow};
8
9#[derive(Debug, Clone, Deserialize, PartialEq)]
11pub struct Body {
12 #[serde(default)]
13 pub limits: Limits,
14 #[serde(default)]
15 pub activity: Option<Activity>,
16}
17
18#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
19pub struct Limits {
20 #[serde(default)]
21 pub session: Option<Window>,
22 #[serde(default)]
23 pub weekly: Option<Window>,
24 #[serde(default)]
28 pub monthly: Option<Window>,
29}
30
31#[derive(Debug, Clone, Deserialize, PartialEq)]
33pub struct Window {
34 #[serde(default)]
35 pub usage: Option<f64>,
36 #[serde(default)]
37 pub models: Vec<ModelUsage>,
38}
39
40#[derive(Debug, Clone, Deserialize, PartialEq)]
41pub struct ModelUsage {
42 pub name: String,
43 #[serde(default)]
44 pub request_count: u64,
45}
46
47#[derive(Debug, Clone, Deserialize, PartialEq)]
48pub struct Activity {
49 #[serde(default)]
52 pub cost: Option<String>,
53 #[serde(default)]
54 pub period: Option<Period>,
55}
56
57#[derive(Debug, Clone, Deserialize, PartialEq)]
58pub struct Period {
59 #[serde(rename = "type")]
60 pub kind: String,
61 #[serde(default)]
62 pub starting_at: Option<chrono::DateTime<chrono::Utc>>,
63 #[serde(default)]
64 pub ending_at: Option<chrono::DateTime<chrono::Utc>>,
65}
66
67impl Body {
68 fn pct(frac: Option<f64>) -> i32 {
70 let f = frac.unwrap_or(0.0).clamp(0.0, 1.0);
71 (f * 100.0).round() as i32
72 }
73
74 fn models(w: Option<&Window>) -> Vec<OllamaModelUsage> {
75 w.map(|window| {
76 window
77 .models
78 .iter()
79 .map(|m| OllamaModelUsage {
80 name: m.name.clone(),
81 request_count: m.request_count,
82 })
83 .collect()
84 })
85 .unwrap_or_default()
86 }
87
88 fn window(w: Option<Window>, duration: chrono::Duration) -> Option<UsageWindow> {
89 let w = w?;
90 Some(UsageWindow {
91 utilization_pct: Self::pct(w.usage),
92 resets_at: None,
95 window_duration: duration,
96 })
97 }
98
99 pub fn into_snapshot(self, plan: String) -> OllamaSnapshot {
102 let session_models = Self::models(self.limits.session.as_ref());
103 let weekly_models = Self::models(self.limits.weekly.as_ref());
104 let monthly_models = Self::models(self.limits.monthly.as_ref());
105 let (cost, period_kind) = match self.activity {
106 Some(a) => (a.cost, a.period.map(|p| p.kind)),
107 None => (None, None),
108 };
109
110 OllamaSnapshot {
111 plan,
112 session: Self::window(self.limits.session, chrono::Duration::hours(5)),
113 weekly: Self::window(self.limits.weekly, chrono::Duration::days(7)),
114 monthly: Self::window(self.limits.monthly, chrono::Duration::days(30)),
118 session_models,
119 weekly_models,
120 monthly_models,
121 activity_cost: cost,
122 activity_period: period_kind,
123 }
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 const LIVE: &str = r#"{
134 "activity": {
135 "cost": "0.00000",
136 "period": {
137 "type": "last_4_weeks",
138 "starting_at": "2026-08-17T00:00:00Z",
139 "ending_at": "2026-09-09T18:28:57.120401373Z"
140 },
141 "models": []
142 },
143 "limits": {
144 "session": {
145 "usage": 0.819,
146 "models": [
147 {"name": "kimi-k3", "request_count": 180},
148 {"name": "deepseek-v4-flash:0731", "request_count": 27},
149 {"name": "minimax-m3", "request_count": 8},
150 {"name": "glm-5.3-flash", "request_count": 8},
151 {"name": "gpt-oss:120b", "request_count": 2}
152 ]
153 },
154 "weekly": {
155 "usage": 0.23,
156 "models": [
157 {"name": "kimi-k3", "request_count": 180},
158 {"name": "minimax-m3", "request_count": 554},
159 {"name": "deepseek-v4-flash:0731", "request_count": 27},
160 {"name": "qwen3.5:397b", "request_count": 2},
161 {"name": "glm-5.3-flash", "request_count": 8},
162 {"name": "gpt-oss:120b", "request_count": 2}
163 ]
164 }
165 }
166 }"#;
167
168 #[test]
169 fn parses_live_captured_body() {
170 let body: Body = serde_json::from_str(LIVE).unwrap();
171 let snap = body.into_snapshot("pro".into());
172 assert_eq!(snap.plan, "pro");
173 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 82);
174 assert_eq!(snap.session_models[0].name, "kimi-k3");
175 assert_eq!(snap.session_models[0].request_count, 180);
176 assert_eq!(snap.session_models.len(), 5);
177 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 23);
178 assert_eq!(snap.weekly_models[1].name, "minimax-m3");
179 assert_eq!(snap.weekly_models[1].request_count, 554);
180 assert_eq!(snap.activity_cost.as_deref(), Some("0.00000"));
181 assert_eq!(snap.activity_period.as_deref(), Some("last_4_weeks"));
182 }
183
184 #[test]
185 fn missing_windows_are_none() {
186 let body: Body = serde_json::from_str(r#"{"limits":{}}"#).unwrap();
187 let snap = body.into_snapshot("free".into());
188 assert!(snap.session.is_none());
189 assert!(snap.weekly.is_none());
190 assert!(snap.monthly.is_none());
191 assert!(snap.session_models.is_empty());
192 assert!(snap.weekly_models.is_empty());
193 assert!(snap.monthly_models.is_empty());
194 }
195
196 const LIVE_MONTHLY: &str = r#"{
200 "activity": {
201 "cost": "0.00000",
202 "period": {
203 "type": "last_4_weeks",
204 "starting_at": "2026-08-24T00:00:00Z",
205 "ending_at": "2026-09-16T08:55:34.663902649Z"
206 }
207 },
208 "limits": {
209 "monthly": {
210 "usage": 0.003,
211 "models": [
212 {"name": "gpt-oss:120b", "request_count": 100},
213 {"name": "gpt-oss:20b", "request_count": 2}
214 ]
215 }
216 }
217 }"#;
218
219 #[test]
220 fn parses_live_captured_monthly_body() {
221 let body: Body = serde_json::from_str(LIVE_MONTHLY).unwrap();
222 let snap = body.into_snapshot("pro".into());
223 assert!(snap.session.is_none());
224 assert!(snap.weekly.is_none());
225 assert_eq!(snap.monthly.as_ref().unwrap().utilization_pct, 0);
226 assert_eq!(snap.monthly_models[0].name, "gpt-oss:120b");
227 assert_eq!(snap.monthly_models[0].request_count, 100);
228 assert_eq!(snap.monthly_models.len(), 2);
229 assert_eq!(snap.activity_cost.as_deref(), Some("0.00000"));
230 }
231
232 #[test]
233 fn clamps_over_full_and_negative_usage() {
234 assert_eq!(Body::pct(Some(1.4)), 100);
235 assert_eq!(Body::pct(Some(-0.2)), 0);
236 assert_eq!(Body::pct(None), 0);
237 assert_eq!(Body::pct(Some(0.5)), 50);
238 }
239}