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}
25
26/// One quota window. `usage` is a fraction in `[0.0, 1.0]`.
27#[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    /// Dollars as a string, e.g. `"0.00000"`. Kept exact so a tooltip can
45    /// show what the server sent without rounding drift.
46    #[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    /// Fraction in `[0, 1]` → percent `0..=100`, saturated. Missing usage is 0%.
64    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            // The JSON payload does not carry a reset timestamp (the HTML UI
88            // does). Renderers pace against the window length only.
89            resets_at: None,
90            window_duration: duration,
91        })
92    }
93
94    /// Project the wire payload into the cacheable snapshot. `plan` comes from
95    /// config — the server does not send a plan field on this route.
96    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    /// Real 200 body captured 2026-09-09 against a Pro account (numbers
121    /// redacted only by rounding — structure is verbatim).
122    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}