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}
25
26#[derive(Debug, Clone, Deserialize, PartialEq)]
28pub struct Window {
29 #[serde(default)]
30 pub usage: Option<f64>,
31 #[serde(default)]
32 pub models: Vec<ModelUsage>,
33}
34
35#[derive(Debug, Clone, Deserialize, PartialEq)]
36pub struct ModelUsage {
37 pub name: String,
38 #[serde(default)]
39 pub request_count: u64,
40}
41
42#[derive(Debug, Clone, Deserialize, PartialEq)]
43pub struct Activity {
44 #[serde(default)]
47 pub cost: Option<String>,
48 #[serde(default)]
49 pub period: Option<Period>,
50}
51
52#[derive(Debug, Clone, Deserialize, PartialEq)]
53pub struct Period {
54 #[serde(rename = "type")]
55 pub kind: String,
56 #[serde(default)]
57 pub starting_at: Option<chrono::DateTime<chrono::Utc>>,
58 #[serde(default)]
59 pub ending_at: Option<chrono::DateTime<chrono::Utc>>,
60}
61
62impl Body {
63 fn pct(frac: Option<f64>) -> i32 {
65 let f = frac.unwrap_or(0.0).clamp(0.0, 1.0);
66 (f * 100.0).round() as i32
67 }
68
69 fn models(w: Option<&Window>) -> Vec<OllamaModelUsage> {
70 w.map(|window| {
71 window
72 .models
73 .iter()
74 .map(|m| OllamaModelUsage {
75 name: m.name.clone(),
76 request_count: m.request_count,
77 })
78 .collect()
79 })
80 .unwrap_or_default()
81 }
82
83 fn window(w: Option<Window>, duration: chrono::Duration) -> Option<UsageWindow> {
84 let w = w?;
85 Some(UsageWindow {
86 utilization_pct: Self::pct(w.usage),
87 resets_at: None,
90 window_duration: duration,
91 })
92 }
93
94 pub fn into_snapshot(self, plan: String) -> OllamaSnapshot {
97 let session_models = Self::models(self.limits.session.as_ref());
98 let weekly_models = Self::models(self.limits.weekly.as_ref());
99 let (cost, period_kind) = match self.activity {
100 Some(a) => (a.cost, a.period.map(|p| p.kind)),
101 None => (None, None),
102 };
103
104 OllamaSnapshot {
105 plan,
106 session: Self::window(self.limits.session, chrono::Duration::hours(5)),
107 weekly: Self::window(self.limits.weekly, chrono::Duration::days(7)),
108 session_models,
109 weekly_models,
110 activity_cost: cost,
111 activity_period: period_kind,
112 }
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 const LIVE: &str = r#"{
123 "activity": {
124 "cost": "0.00000",
125 "period": {
126 "type": "last_4_weeks",
127 "starting_at": "2026-08-17T00:00:00Z",
128 "ending_at": "2026-09-09T18:28:57.120401373Z"
129 },
130 "models": []
131 },
132 "limits": {
133 "session": {
134 "usage": 0.819,
135 "models": [
136 {"name": "kimi-k3", "request_count": 180},
137 {"name": "deepseek-v4-flash:0731", "request_count": 27},
138 {"name": "minimax-m3", "request_count": 8},
139 {"name": "glm-5.3-flash", "request_count": 8},
140 {"name": "gpt-oss:120b", "request_count": 2}
141 ]
142 },
143 "weekly": {
144 "usage": 0.23,
145 "models": [
146 {"name": "kimi-k3", "request_count": 180},
147 {"name": "minimax-m3", "request_count": 554},
148 {"name": "deepseek-v4-flash:0731", "request_count": 27},
149 {"name": "qwen3.5:397b", "request_count": 2},
150 {"name": "glm-5.3-flash", "request_count": 8},
151 {"name": "gpt-oss:120b", "request_count": 2}
152 ]
153 }
154 }
155 }"#;
156
157 #[test]
158 fn parses_live_captured_body() {
159 let body: Body = serde_json::from_str(LIVE).unwrap();
160 let snap = body.into_snapshot("pro".into());
161 assert_eq!(snap.plan, "pro");
162 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 82);
163 assert_eq!(snap.session_models[0].name, "kimi-k3");
164 assert_eq!(snap.session_models[0].request_count, 180);
165 assert_eq!(snap.session_models.len(), 5);
166 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 23);
167 assert_eq!(snap.weekly_models[1].name, "minimax-m3");
168 assert_eq!(snap.weekly_models[1].request_count, 554);
169 assert_eq!(snap.activity_cost.as_deref(), Some("0.00000"));
170 assert_eq!(snap.activity_period.as_deref(), Some("last_4_weeks"));
171 }
172
173 #[test]
174 fn missing_windows_are_none() {
175 let body: Body = serde_json::from_str(r#"{"limits":{}}"#).unwrap();
176 let snap = body.into_snapshot("free".into());
177 assert!(snap.session.is_none());
178 assert!(snap.weekly.is_none());
179 assert!(snap.session_models.is_empty());
180 assert!(snap.weekly_models.is_empty());
181 }
182
183 #[test]
184 fn clamps_over_full_and_negative_usage() {
185 assert_eq!(Body::pct(Some(1.4)), 100);
186 assert_eq!(Body::pct(Some(-0.2)), 0);
187 assert_eq!(Body::pct(None), 0);
188 assert_eq!(Body::pct(Some(0.5)), 50);
189 }
190}