Skip to main content

ai_usagebar/ollama/
types.rs

1//! Wire types for the unofficial-but-stable `https://ollama.com/api/usage`
2//! endpoint. Fields are `Option` / `Default` where the server may omit them
3//! (a fresh account can have empty `models` or no `activity`).
4
5use serde::Deserialize;
6
7use crate::usage::{OllamaModelUsage, OllamaSnapshot, UsageWindow};
8
9/// Top-level body of `GET /api/usage`.
10#[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    /// Calendar-month quota (`limits.monthly`). Reported instead of
25    /// `session`/`weekly` on at least some Pro accounts — the two shapes are
26    /// mutually observed, never combined in one response so far.
27    #[serde(default)]
28    pub monthly: Option<Window>,
29}
30
31/// One quota window. `usage` is a fraction in `[0.0, 1.0]`.
32#[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    /// Dollars as a string, e.g. `"0.00000"`. Kept exact so a tooltip can
50    /// show what the server sent without rounding drift.
51    #[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    /// Fraction in `[0, 1]` → percent `0..=100`, saturated. Missing usage is 0%.
69    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            // The JSON payload does not carry a reset timestamp (the HTML UI
93            // does). Renderers pace against the window length only.
94            resets_at: None,
95            window_duration: duration,
96        })
97    }
98
99    /// Project the wire payload into the cacheable snapshot. `plan` comes from
100    /// config — the server does not send a plan field on this route.
101    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            // Nominal length only — the API gives no cycle-start date, so
115            // pacing against a real subscription month is not possible. Kept
116            // consistent with session/weekly, which also carry no reset time.
117            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    /// Real 200 body captured 2026-09-09 against a Pro account (numbers
132    /// redacted only by rounding — structure is verbatim).
133    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    /// Real 200 body captured 2026-09-16 against a different Pro account —
197    /// this shape reports `limits.monthly` instead of `session`/`weekly`.
198    /// Both shapes exist in the wild for the same "pro" plan label.
199    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}