Skip to main content

ai_usagebar/ollama/
types.rs

1//! Wire types for `https://ollama.com/api/balance` and historical quota
2//! payloads cached from `/api/usage`. Current usage-history responses are
3//! deliberately rejected: request/token totals are not quota limits.
4
5use 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/// Required discriminator prevents unrelated JSON from becoming empty quotas.
26#[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    /// Calendar-month quota (`limits.monthly`). Reported instead of
40    /// `session`/`weekly` on at least some Pro accounts — the two shapes are
41    /// mutually observed, never combined in one response so far.
42    #[serde(default)]
43    pub monthly: Option<Window>,
44}
45
46/// One quota window. `usage` is a fraction in `[0.0, 1.0]`.
47#[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    /// Dollars as a string, e.g. `"0.00000"`. Kept exact so a tooltip can
65    /// show what the server sent without rounding drift.
66    #[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    /// Fraction in `[0, 1]` → percent `0..=100`, saturated. Missing usage is 0%.
84    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            // The JSON payload does not carry a reset timestamp (the HTML UI
108            // does). Renderers pace against the window length only.
109            resets_at: None,
110            window_duration: duration,
111        })
112    }
113
114    /// Project the wire payload into the cacheable snapshot. `plan` comes from
115    /// config — the server does not send a plan field on this route.
116    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            // Nominal length only — the API gives no cycle-start date, so
130            // pacing against a real subscription month is not possible. Kept
131            // consistent with session/weekly, which also carry no reset time.
132            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                // No measurable percentage for a zero allowance; still show
205                // the actual dollar balance, without dividing by zero.
206                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    /// A real `/api/balance` payload from a session/weekly account, captured by
231    /// a third party on #387. The credits branch of `Included` had a committed
232    /// capture; this one did not, and it is the branch every pre-existing user
233    /// depends on — so the shape is pinned by the bytes the API actually sent
234    /// rather than by a hand-written sample.
235    #[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        // `remaining_percent` is what is LEFT, so utilization is its complement:
242        // 100 remaining is 0 used, and 59.19 remaining rounds to 41 used.
243        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        // A quota account has no credits block and no monthly window; a zero
249        // purchased balance must not invent one.
250        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        // The resets travel with the windows rather than being guessed.
257        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    /// Real 200 body captured 2026-09-09 against a Pro account (numbers
270    /// redacted only by rounding — structure is verbatim).
271    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    /// Real 200 body captured 2026-09-16 against a different Pro account —
335    /// this shape reports `limits.monthly` instead of `session`/`weekly`.
336    /// Both shapes exist in the wild for the same "pro" plan label.
337    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}