1use serde::Deserialize;
6
7use crate::usage::{OllamaCredits, OllamaModelUsage, OllamaSnapshot, UsageWindow};
8
9#[derive(Debug, Clone, Deserialize, PartialEq)]
10#[serde(untagged)]
11pub enum Body {
12 Balance(BalanceBody),
13 Legacy(LegacyBody),
14}
15
16impl Body {
17 pub fn into_snapshot(self, plan: String) -> OllamaSnapshot {
18 match self {
19 Self::Balance(body) => body.into_snapshot(plan),
20 Self::Legacy(body) => body.into_snapshot(plan),
21 }
22 }
23}
24
25#[derive(Debug, Clone, Deserialize, PartialEq)]
27pub struct LegacyBody {
28 pub limits: Limits,
29 #[serde(default)]
30 pub activity: Option<Activity>,
31}
32
33#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
34pub struct Limits {
35 #[serde(default)]
36 pub session: Option<Window>,
37 #[serde(default)]
38 pub weekly: Option<Window>,
39 #[serde(default)]
43 pub monthly: Option<Window>,
44}
45
46#[derive(Debug, Clone, Deserialize, PartialEq)]
48pub struct Window {
49 #[serde(default)]
50 pub usage: Option<f64>,
51 #[serde(default)]
52 pub models: Vec<ModelUsage>,
53}
54
55#[derive(Debug, Clone, Deserialize, PartialEq)]
56pub struct ModelUsage {
57 pub name: String,
58 #[serde(default)]
59 pub request_count: u64,
60}
61
62#[derive(Debug, Clone, Deserialize, PartialEq)]
63pub struct Activity {
64 #[serde(default)]
67 pub cost: Option<String>,
68 #[serde(default)]
69 pub period: Option<Period>,
70}
71
72#[derive(Debug, Clone, Deserialize, PartialEq)]
73pub struct Period {
74 #[serde(rename = "type")]
75 pub kind: String,
76 #[serde(default)]
77 pub starting_at: Option<chrono::DateTime<chrono::Utc>>,
78 #[serde(default)]
79 pub ending_at: Option<chrono::DateTime<chrono::Utc>>,
80}
81
82impl LegacyBody {
83 fn pct(frac: Option<f64>) -> i32 {
85 let f = frac.unwrap_or(0.0).clamp(0.0, 1.0);
86 (f * 100.0).round() as i32
87 }
88
89 fn models(w: Option<&Window>) -> Vec<OllamaModelUsage> {
90 w.map(|window| {
91 window
92 .models
93 .iter()
94 .map(|m| OllamaModelUsage {
95 name: m.name.clone(),
96 request_count: m.request_count,
97 })
98 .collect()
99 })
100 .unwrap_or_default()
101 }
102
103 fn window(w: Option<Window>, duration: chrono::Duration) -> Option<UsageWindow> {
104 let w = w?;
105 Some(UsageWindow {
106 utilization_pct: Self::pct(w.usage),
107 resets_at: None,
110 window_duration: duration,
111 })
112 }
113
114 pub fn into_snapshot(self, plan: String) -> OllamaSnapshot {
117 let session_models = Self::models(self.limits.session.as_ref());
118 let weekly_models = Self::models(self.limits.weekly.as_ref());
119 let monthly_models = Self::models(self.limits.monthly.as_ref());
120 let (cost, period_kind) = match self.activity {
121 Some(a) => (a.cost, a.period.map(|p| p.kind)),
122 None => (None, None),
123 };
124
125 OllamaSnapshot {
126 plan,
127 session: Self::window(self.limits.session, chrono::Duration::hours(5)),
128 weekly: Self::window(self.limits.weekly, chrono::Duration::days(7)),
129 monthly: Self::window(self.limits.monthly, chrono::Duration::days(30)),
133 session_models,
134 weekly_models,
135 monthly_models,
136 activity_cost: cost,
137 activity_period: period_kind,
138 credits: None,
139 }
140 }
141}
142
143#[derive(Debug, Clone, Deserialize, PartialEq)]
144pub struct BalanceBody {
145 pub included: Included,
146 #[serde(default)]
147 pub purchased: Option<Purchased>,
148}
149
150#[derive(Debug, Clone, Deserialize, PartialEq)]
151#[serde(untagged)]
152pub enum Included {
153 Credits {
154 balance_usd: f64,
155 allowance_usd: f64,
156 period: BillingPeriod,
157 },
158 Legacy {
159 session: RemainingWindow,
160 weekly: RemainingWindow,
161 },
162}
163
164#[derive(Debug, Clone, Deserialize, PartialEq)]
165pub struct BillingPeriod {
166 pub from: chrono::DateTime<chrono::Utc>,
167 pub until: chrono::DateTime<chrono::Utc>,
168}
169
170#[derive(Debug, Clone, Deserialize, PartialEq)]
171pub struct Purchased {
172 pub balance_usd: f64,
173}
174
175#[derive(Debug, Clone, Deserialize, PartialEq)]
176pub struct RemainingWindow {
177 pub remaining_percent: f64,
178 pub resets_at: chrono::DateTime<chrono::Utc>,
179}
180
181impl RemainingWindow {
182 fn into_window(self, duration: chrono::Duration) -> UsageWindow {
183 UsageWindow {
184 utilization_pct: (100.0 - self.remaining_percent).clamp(0.0, 100.0).round() as i32,
185 resets_at: Some(self.resets_at),
186 window_duration: duration,
187 }
188 }
189}
190
191impl BalanceBody {
192 fn into_snapshot(self, plan: String) -> OllamaSnapshot {
193 let mut snap = LegacyBody {
194 limits: Limits::default(),
195 activity: None,
196 }
197 .into_snapshot(plan);
198 match self.included {
199 Included::Credits {
200 balance_usd,
201 allowance_usd,
202 period,
203 } => {
204 if allowance_usd > 0.0 {
207 snap.monthly = Some(UsageWindow {
208 utilization_pct: LegacyBody::pct(Some(1.0 - balance_usd / allowance_usd)),
209 resets_at: Some(period.until),
210 window_duration: period.until - period.from,
211 });
212 }
213 snap.credits = Some(OllamaCredits {
214 balance: crate::format::usd(balance_usd),
215 allowance: crate::format::usd(allowance_usd),
216 purchased: self.purchased.map(|p| crate::format::usd(p.balance_usd)),
217 });
218 }
219 Included::Legacy { session, weekly } => {
220 snap.session = Some(session.into_window(chrono::Duration::hours(5)));
221 snap.weekly = Some(weekly.into_window(chrono::Duration::days(7)));
222 }
223 }
224 snap
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 #[test]
236 fn a_real_quota_account_balance_payload_keeps_its_windows() {
237 let raw = include_str!("../../tests/fixtures/ollama/balance_quota.json");
238 let body: Body = serde_json::from_str(raw).expect("captured payload must parse");
239 let snap = body.into_snapshot("pro".into());
240
241 let session = snap.session.expect("session window");
244 assert_eq!(session.utilization_pct, 0);
245 let weekly = snap.weekly.expect("weekly window");
246 assert_eq!(weekly.utilization_pct, 41);
247
248 assert!(
251 snap.monthly.is_none(),
252 "quota accounts report no monthly window"
253 );
254 assert!(snap.credits.is_none(), "a quota account has no credits");
255
256 assert!(
258 session.resets_at.is_some(),
259 "session reset comes from the payload"
260 );
261 assert!(
262 weekly.resets_at.is_some(),
263 "weekly reset comes from the payload"
264 );
265 }
266
267 use super::*;
268
269 const LIVE: &str = r#"{
272 "activity": {
273 "cost": "0.00000",
274 "period": {
275 "type": "last_4_weeks",
276 "starting_at": "2026-08-17T00:00:00Z",
277 "ending_at": "2026-09-09T18:28:57.120401373Z"
278 },
279 "models": []
280 },
281 "limits": {
282 "session": {
283 "usage": 0.819,
284 "models": [
285 {"name": "kimi-k3", "request_count": 180},
286 {"name": "deepseek-v4-flash:0731", "request_count": 27},
287 {"name": "minimax-m3", "request_count": 8},
288 {"name": "glm-5.3-flash", "request_count": 8},
289 {"name": "gpt-oss:120b", "request_count": 2}
290 ]
291 },
292 "weekly": {
293 "usage": 0.23,
294 "models": [
295 {"name": "kimi-k3", "request_count": 180},
296 {"name": "minimax-m3", "request_count": 554},
297 {"name": "deepseek-v4-flash:0731", "request_count": 27},
298 {"name": "qwen3.5:397b", "request_count": 2},
299 {"name": "glm-5.3-flash", "request_count": 8},
300 {"name": "gpt-oss:120b", "request_count": 2}
301 ]
302 }
303 }
304 }"#;
305
306 #[test]
307 fn parses_live_captured_body() {
308 let body: Body = serde_json::from_str(LIVE).unwrap();
309 let snap = body.into_snapshot("pro".into());
310 assert_eq!(snap.plan, "pro");
311 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 82);
312 assert_eq!(snap.session_models[0].name, "kimi-k3");
313 assert_eq!(snap.session_models[0].request_count, 180);
314 assert_eq!(snap.session_models.len(), 5);
315 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 23);
316 assert_eq!(snap.weekly_models[1].name, "minimax-m3");
317 assert_eq!(snap.weekly_models[1].request_count, 554);
318 assert_eq!(snap.activity_cost.as_deref(), Some("0.00000"));
319 assert_eq!(snap.activity_period.as_deref(), Some("last_4_weeks"));
320 }
321
322 #[test]
323 fn missing_windows_are_none() {
324 let body: Body = serde_json::from_str(r#"{"limits":{}}"#).unwrap();
325 let snap = body.into_snapshot("free".into());
326 assert!(snap.session.is_none());
327 assert!(snap.weekly.is_none());
328 assert!(snap.monthly.is_none());
329 assert!(snap.session_models.is_empty());
330 assert!(snap.weekly_models.is_empty());
331 assert!(snap.monthly_models.is_empty());
332 }
333
334 const LIVE_MONTHLY: &str = r#"{
338 "activity": {
339 "cost": "0.00000",
340 "period": {
341 "type": "last_4_weeks",
342 "starting_at": "2026-08-24T00:00:00Z",
343 "ending_at": "2026-09-16T08:55:34.663902649Z"
344 }
345 },
346 "limits": {
347 "monthly": {
348 "usage": 0.003,
349 "models": [
350 {"name": "gpt-oss:120b", "request_count": 100},
351 {"name": "gpt-oss:20b", "request_count": 2}
352 ]
353 }
354 }
355 }"#;
356
357 #[test]
358 fn parses_live_captured_monthly_body() {
359 let body: Body = serde_json::from_str(LIVE_MONTHLY).unwrap();
360 let snap = body.into_snapshot("pro".into());
361 assert!(snap.session.is_none());
362 assert!(snap.weekly.is_none());
363 assert_eq!(snap.monthly.as_ref().unwrap().utilization_pct, 0);
364 assert_eq!(snap.monthly_models[0].name, "gpt-oss:120b");
365 assert_eq!(snap.monthly_models[0].request_count, 100);
366 assert_eq!(snap.monthly_models.len(), 2);
367 assert_eq!(snap.activity_cost.as_deref(), Some("0.00000"));
368 }
369
370 #[test]
371 fn credit_balance_projects_monthly_usage_and_exact_billing_period() {
372 let body: Body = serde_json::from_str(include_str!(
373 "../../tests/fixtures/ollama/balance_credits.json"
374 ))
375 .unwrap();
376 let snap = body.into_snapshot("pro".into());
377 let monthly = snap.monthly.unwrap();
378 assert_eq!(monthly.utilization_pct, 25);
379 assert_eq!(
380 monthly.resets_at.unwrap().to_rfc3339(),
381 "2026-11-08T08:00:00+00:00"
382 );
383 assert_eq!(monthly.window_duration, chrono::Duration::days(31));
384 assert!(snap.session.is_none());
385 assert!(snap.weekly.is_none());
386 let credits = snap.credits.unwrap();
387 assert_eq!(credits.balance, "$45.00");
388 assert_eq!(credits.allowance, "$60.00");
389 assert_eq!(credits.purchased.as_deref(), Some("$12.50"));
390 assert!(
391 snap.activity_cost.is_none(),
392 "balance is not usage-history cost"
393 );
394 }
395
396 #[test]
397 fn legacy_balance_inverts_remaining_percent_and_keeps_resets() {
398 let body: Body = serde_json::from_str(
399 r#"{
400 "included": {
401 "session": {"remaining_percent":75,"resets_at":"2026-10-01T07:00:00Z"},
402 "weekly": {"remaining_percent":40,"resets_at":"2026-10-05T00:00:00Z"}
403 },
404 "purchased": {"balance_usd":25}
405 }"#,
406 )
407 .unwrap();
408 let snap = body.into_snapshot("pro".into());
409 let session = snap.session.unwrap();
410 assert_eq!(session.utilization_pct, 25);
411 assert_eq!(session.window_duration, chrono::Duration::hours(5));
412 assert_eq!(
413 session.resets_at.unwrap().to_rfc3339(),
414 "2026-10-01T07:00:00+00:00"
415 );
416 assert_eq!(snap.weekly.unwrap().utilization_pct, 60);
417 assert!(snap.monthly.is_none());
418 assert!(snap.credits.is_none());
419 }
420
421 #[test]
422 fn zero_allowance_is_not_a_fabricated_percentage() {
423 let body: Body = serde_json::from_str(
424 r#"{
425 "included": {"balance_usd":0,"allowance_usd":0,
426 "period":{"from":"2026-10-01T00:00:00Z","until":"2026-11-01T00:00:00Z"}}
427 }"#,
428 )
429 .unwrap();
430 let snap = body.into_snapshot("free".into());
431 assert!(snap.monthly.is_none());
432 assert_eq!(snap.credits.unwrap().balance, "$0.00");
433 }
434
435 #[test]
436 fn usage_history_and_unknown_shapes_are_rejected() {
437 for payload in [
438 r#"{"range":"7d","totals":{"request_count":213,"usage_usd":0.2},"buckets":[]}"#,
439 r#"{}"#,
440 r#"{"included":{}}"#,
441 r#"{"included":{"balance_usd":45}}"#,
442 ] {
443 assert!(serde_json::from_str::<Body>(payload).is_err(), "{payload}");
444 }
445 }
446
447 #[test]
448 fn clamps_over_full_and_negative_usage() {
449 assert_eq!(LegacyBody::pct(Some(1.4)), 100);
450 assert_eq!(LegacyBody::pct(Some(-0.2)), 0);
451 assert_eq!(LegacyBody::pct(None), 0);
452 assert_eq!(LegacyBody::pct(Some(0.5)), 50);
453 }
454}