Skip to main content

ai_usagebar/anthropic/
types.rs

1//! Wire types for the Anthropic OAuth usage endpoint.
2//!
3//! Every field is `Option<T>` or has `#[serde(default)]` — the endpoint is
4//! undocumented and the shape varies across plan tiers and over time. The
5//! lossy `serde(default)` approach matches claudebar's jq pattern of
6//! `.field // empty`.
7
8use serde::{Deserialize, Serialize};
9
10use crate::usage::{AnthropicSnapshot, Cents, ExtraUsage, ScopedWindow, UsageWindow};
11
12/// Top-level response from `GET /api/oauth/usage`.
13#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
14pub struct UsageResponse {
15    #[serde(default)]
16    pub five_hour: Option<Window>,
17    #[serde(default)]
18    pub seven_day: Option<Window>,
19    #[serde(default)]
20    pub seven_day_sonnet: Option<Window>,
21    #[serde(default)]
22    pub extra_usage: Option<ExtraUsageBlock>,
23    /// Newer per-limit array. Carries model-scoped weekly windows
24    /// (`kind == "weekly_scoped"`, e.g. the Fable weekly cap) that have no
25    /// dedicated `seven_day_*` field.
26    #[serde(default)]
27    pub limits: Vec<LimitEntry>,
28}
29
30/// One entry of the `limits[]` array. Only `weekly_scoped` entries with a
31/// model display name are lifted into the snapshot; everything else in the
32/// array duplicates `five_hour`/`seven_day`.
33#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
34pub struct LimitEntry {
35    #[serde(default)]
36    pub kind: Option<String>,
37    #[serde(default, deserialize_with = "de_percent_opt")]
38    pub percent: Option<f64>,
39    #[serde(default)]
40    pub resets_at: Option<String>,
41    #[serde(default)]
42    pub scope: Option<LimitScope>,
43}
44
45#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
46pub struct LimitScope {
47    #[serde(default)]
48    pub model: Option<LimitModel>,
49}
50
51#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
52pub struct LimitModel {
53    #[serde(default)]
54    pub display_name: Option<String>,
55}
56
57/// A single usage window — `utilization` is `0..=100` (integer percent).
58#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
59pub struct Window {
60    #[serde(default, deserialize_with = "de_percent")]
61    pub utilization: f64,
62    #[serde(default)]
63    pub resets_at: Option<String>,
64}
65
66/// Pay-as-you-go extra usage. Both money values are non-negative integer cents,
67/// but the API sometimes returns integral floats (e.g. `0.0`). Missing values
68/// remain absent so an enabled but incomplete block cannot manufacture $0.00.
69#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
70pub struct ExtraUsageBlock {
71    #[serde(default)]
72    pub is_enabled: bool,
73    #[serde(default, deserialize_with = "de_opt_cents")]
74    pub monthly_limit: Option<i64>,
75    #[serde(default, deserialize_with = "de_opt_cents")]
76    pub used_credits: Option<i64>,
77}
78
79fn de_opt_cents<'de, D>(d: D) -> std::result::Result<Option<i64>, D::Error>
80where
81    D: serde::Deserializer<'de>,
82{
83    let v = serde_json::Value::deserialize(d)?;
84    match v {
85        serde_json::Value::Null => Ok(None),
86        serde_json::Value::Number(n) => {
87            if let Some(i) = n.as_i64() {
88                if i >= 0 {
89                    Ok(Some(i))
90                } else {
91                    Err(serde::de::Error::custom("cents cannot be negative"))
92                }
93            } else if let Some(f) = n.as_f64() {
94                const MAX_EXACT_F64_INT: f64 = (1_u64 << 53) as f64;
95                if f.is_finite() && f.fract() == 0.0 && (0.0..=MAX_EXACT_F64_INT).contains(&f) {
96                    Ok(Some(f as i64))
97                } else {
98                    Err(serde::de::Error::custom(
99                        "cents must be a non-negative integer in range",
100                    ))
101                }
102            } else {
103                Err(serde::de::Error::custom("number out of i64 range"))
104            }
105        }
106        other => Err(serde::de::Error::custom(format!(
107            "expected number or null, got {other:?}"
108        ))),
109    }
110}
111
112/// Slack tolerated above 100. The endpoint occasionally reports a hair over its
113/// own cap — `used/limit` rounding, or usage that landed just before the block
114/// did — and `to_window` saturates that back to 100.
115const PCT_SLACK: f64 = 1.0;
116
117/// Gate a wire percentage into `0..=100` (+ [`PCT_SLACK`]).
118///
119/// Rejecting here rather than in `to_window` is deliberate: `to_window` is
120/// infallible and `into_snapshot` has no error channel, so the parse boundary
121/// is the only place a bad value can still become a loud failure. A rejection
122/// surfaces as `AppError::Json` and reaches the user as `⚠` — never as a
123/// number we invented. Past the slack the field simply isn't a percentage on
124/// this scale (rescaled to per-mille, a raw counter, a sentinel), and clamping
125/// it would paint a "100%" bar we cannot vouch for. Non-finite values matter
126/// most: `f64::NAN as i32` is silently `0`.
127fn checked_percent<E: serde::de::Error>(v: f64) -> std::result::Result<f64, E> {
128    if !v.is_finite() {
129        return Err(E::custom(format!("percentage {v} is not finite")));
130    }
131    if !(0.0..=100.0 + PCT_SLACK).contains(&v) {
132        return Err(E::custom(format!("percentage {v} outside 0..=100")));
133    }
134    Ok(v)
135}
136
137fn de_percent<'de, D>(d: D) -> std::result::Result<f64, D::Error>
138where
139    D: serde::Deserializer<'de>,
140{
141    checked_percent(f64::deserialize(d)?)
142}
143
144fn de_percent_opt<'de, D>(d: D) -> std::result::Result<Option<f64>, D::Error>
145where
146    D: serde::Deserializer<'de>,
147{
148    Option::<f64>::deserialize(d)?
149        .map(checked_percent)
150        .transpose()
151}
152
153impl UsageResponse {
154    /// Lift the wire response into our canonical [`AnthropicSnapshot`].
155    ///
156    /// `plan_label` is the rendered plan name ("Max 5x" etc.), derived from
157    /// the credentials file (since the usage endpoint doesn't include it).
158    pub fn into_snapshot(self, plan_label: String) -> AnthropicSnapshot {
159        // Window durations are constants per claudebar:172-173.
160        const SESSION: chrono::Duration = chrono::Duration::hours(5);
161        const WEEKLY: chrono::Duration = chrono::Duration::days(7);
162
163        fn to_window(w: Option<Window>, dur: chrono::Duration) -> UsageWindow {
164            let Some(w) = w else {
165                return UsageWindow {
166                    utilization_pct: 0,
167                    resets_at: None,
168                    window_duration: dur,
169                };
170            };
171            UsageWindow {
172                // Round to nearest, matching claudebar's `| round` jq filter,
173                // then absorb the overshoot `de_percent` deliberately lets
174                // through (100.4 → 100) so the bar never renders past full.
175                utilization_pct: (w.utilization.round() as i32).clamp(0, 100),
176                resets_at: w
177                    .resets_at
178                    .as_deref()
179                    .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
180                    .map(|dt| dt.with_timezone(&chrono::Utc)),
181                window_duration: dur,
182            }
183        }
184
185        let session = to_window(self.five_hour, SESSION);
186        let weekly = to_window(self.seven_day, WEEKLY);
187        let sonnet = self.seven_day_sonnet.map(|w| to_window(Some(w), WEEKLY));
188        let extra = self.extra_usage.filter(|e| e.is_enabled).and_then(|e| {
189            Some(ExtraUsage {
190                limit: Cents(e.monthly_limit?),
191                spent: Cents(e.used_credits?),
192            })
193        });
194        let scoped = self
195            .limits
196            .into_iter()
197            .filter(|l| l.kind.as_deref() == Some("weekly_scoped"))
198            .filter_map(|l| {
199                let label = l.scope?.model?.display_name?;
200                // Same `?` discipline as the label above: an entry without a
201                // percentage is dropped, not defaulted. `unwrap_or(0.0)` drew a
202                // confident "Fable 0%" bar under a real model name — a number
203                // the API never sent, which is worse than no bar at all.
204                let utilization = l.percent?;
205                let window = to_window(
206                    Some(Window {
207                        utilization,
208                        resets_at: l.resets_at,
209                    }),
210                    WEEKLY,
211                );
212                Some(ScopedWindow { label, window })
213            })
214            .collect();
215
216        AnthropicSnapshot {
217            plan: plan_label,
218            session,
219            weekly,
220            sonnet,
221            scoped,
222            extra,
223        }
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn parses_full_response() {
233        let raw = r#"{
234            "five_hour":         {"utilization": 42.7, "resets_at": "2026-05-23T17:30:00Z"},
235            "seven_day":         {"utilization": 27.0, "resets_at": "2026-05-30T12:00:00Z"},
236            "seven_day_sonnet":  {"utilization":  4.2, "resets_at": "2026-05-30T12:00:00Z"},
237            "extra_usage":       {"is_enabled": true, "monthly_limit": 5000, "used_credits": 250}
238        }"#;
239        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
240        let snap = resp.into_snapshot("Max 5x".into());
241        assert_eq!(snap.session.utilization_pct, 43); // rounded
242        assert_eq!(snap.weekly.utilization_pct, 27);
243        assert_eq!(snap.sonnet.as_ref().unwrap().utilization_pct, 4);
244        assert_eq!(snap.extra.unwrap().limit.0, 5000);
245        assert_eq!(snap.extra.unwrap().spent.0, 250);
246        assert!(snap.session.resets_at.is_some());
247    }
248
249    #[test]
250    fn parses_weekly_scoped_limits() {
251        // Real shape observed 2026-07-08: the Fable weekly cap only exists
252        // inside `limits[]`; there is no `seven_day_fable` field.
253        let raw = r#"{
254            "five_hour": {"utilization": 10.0, "resets_at": "2026-07-08T22:59:59Z"},
255            "seven_day": {"utilization": 55.0, "resets_at": "2026-07-10T10:59:59Z"},
256            "limits": [
257                {"kind": "session", "group": "session", "percent": 10,
258                 "severity": "normal", "resets_at": "2026-07-08T22:59:59Z",
259                 "scope": null, "is_active": false},
260                {"kind": "weekly_all", "group": "weekly", "percent": 55,
261                 "severity": "normal", "resets_at": "2026-07-10T10:59:59Z",
262                 "scope": null, "is_active": false},
263                {"kind": "weekly_scoped", "group": "weekly", "percent": 84,
264                 "severity": "warning", "resets_at": "2026-07-10T10:59:59Z",
265                 "scope": {"model": {"id": null, "display_name": "Fable"}, "surface": null},
266                 "is_active": true}
267            ]
268        }"#;
269        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
270        let snap = resp.into_snapshot("Pro".into());
271        assert_eq!(snap.scoped.len(), 1);
272        assert_eq!(snap.scoped[0].label, "Fable");
273        assert_eq!(snap.scoped[0].window.utilization_pct, 84);
274        assert!(snap.scoped[0].window.resets_at.is_some());
275        // Unscoped entries never duplicate into `scoped`.
276        assert_eq!(snap.weekly.utilization_pct, 55);
277    }
278
279    #[test]
280    fn missing_limits_array_yields_empty_scoped() {
281        let raw = r#"{
282            "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
283            "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
284        }"#;
285        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
286        let snap = resp.into_snapshot("Pro".into());
287        assert!(snap.scoped.is_empty());
288    }
289
290    #[test]
291    fn missing_sonnet_and_extra_are_none() {
292        let raw = r#"{
293            "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
294            "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
295        }"#;
296        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
297        let snap = resp.into_snapshot("Pro".into());
298        assert!(snap.sonnet.is_none());
299        assert!(snap.extra.is_none());
300    }
301
302    #[test]
303    fn disabled_extra_usage_becomes_none() {
304        let raw = r#"{
305            "five_hour": {"utilization": 0},
306            "seven_day": {"utilization": 0},
307            "extra_usage": {"is_enabled": false, "monthly_limit": 5000, "used_credits": 0}
308        }"#;
309        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
310        let snap = resp.into_snapshot("Pro".into());
311        assert!(snap.extra.is_none());
312    }
313
314    #[test]
315    fn enabled_incomplete_extra_usage_does_not_invent_zero_money() {
316        for raw in [
317            r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000}}"#,
318            r#"{"extra_usage":{"is_enabled":true,"used_credits":250}}"#,
319            r#"{"extra_usage":{"is_enabled":true,"monthly_limit":null,"used_credits":250}}"#,
320        ] {
321            let resp: UsageResponse = serde_json::from_str(raw).unwrap();
322            assert!(resp.into_snapshot("Pro".into()).extra.is_none(), "{raw}");
323        }
324    }
325
326    #[test]
327    fn malformed_cent_values_are_schema_drift() {
328        for value in ["-1", "1.5", "1e300", "true", r#""lots""#] {
329            let raw = format!(
330                r#"{{"extra_usage":{{"is_enabled":true,"monthly_limit":{value},"used_credits":0}}}}"#
331            );
332            assert!(
333                serde_json::from_str::<UsageResponse>(&raw).is_err(),
334                "{raw}"
335            );
336        }
337
338        let resp: UsageResponse = serde_json::from_str(
339            r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000.0,"used_credits":250.0}}"#,
340        )
341        .unwrap();
342        let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
343        assert_eq!(extra.limit.0, 5000);
344        assert_eq!(extra.spent.0, 250);
345    }
346
347    #[test]
348    fn empty_object_yields_neutral_snapshot() {
349        let resp: UsageResponse = serde_json::from_str("{}").unwrap();
350        let snap = resp.into_snapshot("Unknown".into());
351        assert_eq!(snap.session.utilization_pct, 0);
352        assert_eq!(snap.weekly.utilization_pct, 0);
353        assert!(snap.session.resets_at.is_none());
354    }
355
356    #[test]
357    fn benign_overshoot_saturates_to_hundred() {
358        // A hair over the cap is rounding noise, not drift — it must render as
359        // a full bar, not break the widget.
360        let raw = r#"{
361            "five_hour": {"utilization": 100.4},
362            "seven_day": {"utilization": 100.6}
363        }"#;
364        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
365        let snap = resp.into_snapshot("Pro".into());
366        assert_eq!(snap.session.utilization_pct, 100);
367        assert_eq!(snap.weekly.utilization_pct, 100); // rounds to 101, saturated
368    }
369
370    #[test]
371    fn out_of_range_utilization_is_rejected_not_clamped() {
372        for raw in [
373            r#"{"five_hour": {"utilization": 500}}"#,
374            r#"{"five_hour": {"utilization": -1}}"#,
375            r#"{"seven_day": {"utilization": 101.5}}"#,
376        ] {
377            let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
378            assert!(err.to_string().contains("outside 0..=100"), "{raw}: {err}");
379        }
380    }
381
382    #[test]
383    fn out_of_range_scoped_percent_is_rejected() {
384        // `limits[].percent` reaches the same bar via the synthesized Window.
385        let raw = r#"{
386            "limits": [{"kind": "weekly_scoped", "percent": 420,
387                        "scope": {"model": {"display_name": "Fable"}}}]
388        }"#;
389        let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
390        assert!(err.to_string().contains("outside 0..=100"), "{err}");
391    }
392
393    #[test]
394    fn non_finite_percentage_is_rejected() {
395        // `f64::NAN as i32` is silently 0 — the fabricated number this gate
396        // exists to prevent. JSON has no NaN literal, so drive it directly.
397        for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
398            assert!(checked_percent::<serde_json::Error>(v).is_err(), "{v}");
399        }
400    }
401
402    #[test]
403    fn scoped_limit_without_a_percentage_is_dropped_not_zeroed() {
404        // The gate rejects impossible numbers; an absent one is not a parse
405        // failure. But it must not become a bar either: `unwrap_or(0.0)` drew a
406        // confident "Fable 0%" under a real model name that the API never sent.
407        let raw = r#"{
408            "limits": [{"kind": "weekly_scoped", "percent": null,
409                        "scope": {"model": {"display_name": "Fable"}}}]
410        }"#;
411        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
412        let snap = resp.into_snapshot("Pro".into());
413        assert!(
414            snap.scoped.is_empty(),
415            "a scoped limit with no percentage must not render a fabricated bar"
416        );
417
418        // A real percentage still produces the window, so the drop is targeted.
419        let ok = r#"{
420            "limits": [{"kind": "weekly_scoped", "percent": 84,
421                        "scope": {"model": {"display_name": "Fable"}}}]
422        }"#;
423        let resp: UsageResponse = serde_json::from_str(ok).unwrap();
424        let snap = resp.into_snapshot("Pro".into());
425        assert_eq!(snap.scoped[0].label, "Fable");
426        assert_eq!(snap.scoped[0].window.utilization_pct, 84);
427    }
428
429    #[test]
430    fn unparseable_reset_becomes_none() {
431        let raw = r#"{
432            "five_hour": {"utilization": 50, "resets_at": "not a date"},
433            "seven_day": {"utilization": 0}
434        }"#;
435        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
436        let snap = resp.into_snapshot("Pro".into());
437        assert!(snap.session.resets_at.is_none());
438        assert_eq!(snap.session.utilization_pct, 50);
439    }
440}