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, 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}
24
25/// A single usage window — `utilization` is `0..=100` (integer percent).
26#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
27pub struct Window {
28    #[serde(default)]
29    pub utilization: f64,
30    #[serde(default)]
31    pub resets_at: Option<String>,
32}
33
34/// Pay-as-you-go extra usage. Both money values are integer cents, but the
35/// API sometimes returns them as floats (e.g. `0.0`) so we accept either.
36#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
37pub struct ExtraUsageBlock {
38    #[serde(default)]
39    pub is_enabled: bool,
40    #[serde(default, deserialize_with = "de_int_or_float")]
41    pub monthly_limit: i64,
42    #[serde(default, deserialize_with = "de_int_or_float")]
43    pub used_credits: i64,
44}
45
46/// Accept JSON int or float, truncating floats. Mirrors claudebar's
47/// `(.field // 0) | floor` jq pattern.
48fn de_int_or_float<'de, D>(d: D) -> std::result::Result<i64, D::Error>
49where
50    D: serde::Deserializer<'de>,
51{
52    let v = serde_json::Value::deserialize(d)?;
53    match v {
54        serde_json::Value::Null => Ok(0),
55        serde_json::Value::Number(n) => {
56            if let Some(i) = n.as_i64() {
57                Ok(i)
58            } else if let Some(f) = n.as_f64() {
59                Ok(f as i64)
60            } else {
61                Err(serde::de::Error::custom("number out of i64 range"))
62            }
63        }
64        other => Err(serde::de::Error::custom(format!(
65            "expected number or null, got {other:?}"
66        ))),
67    }
68}
69
70impl UsageResponse {
71    /// Lift the wire response into our canonical [`AnthropicSnapshot`].
72    ///
73    /// `plan_label` is the rendered plan name ("Max 5x" etc.), derived from
74    /// the credentials file (since the usage endpoint doesn't include it).
75    pub fn into_snapshot(self, plan_label: String) -> AnthropicSnapshot {
76        // Window durations are constants per claudebar:172-173.
77        const SESSION: chrono::Duration = chrono::Duration::hours(5);
78        const WEEKLY: chrono::Duration = chrono::Duration::days(7);
79
80        fn to_window(w: Option<Window>, dur: chrono::Duration) -> UsageWindow {
81            let Some(w) = w else {
82                return UsageWindow {
83                    utilization_pct: 0,
84                    resets_at: None,
85                    window_duration: dur,
86                };
87            };
88            UsageWindow {
89                // Round to nearest, matching claudebar's `| round` jq filter.
90                utilization_pct: w.utilization.round() as i32,
91                resets_at: w
92                    .resets_at
93                    .as_deref()
94                    .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
95                    .map(|dt| dt.with_timezone(&chrono::Utc)),
96                window_duration: dur,
97            }
98        }
99
100        let session = to_window(self.five_hour, SESSION);
101        let weekly = to_window(self.seven_day, WEEKLY);
102        let sonnet = self.seven_day_sonnet.map(|w| to_window(Some(w), WEEKLY));
103        let extra = self
104            .extra_usage
105            .filter(|e| e.is_enabled)
106            .map(|e| ExtraUsage {
107                limit: Cents(e.monthly_limit),
108                spent: Cents(e.used_credits),
109            });
110
111        AnthropicSnapshot {
112            plan: plan_label,
113            session,
114            weekly,
115            sonnet,
116            extra,
117        }
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn parses_full_response() {
127        let raw = r#"{
128            "five_hour":         {"utilization": 42.7, "resets_at": "2026-05-23T17:30:00Z"},
129            "seven_day":         {"utilization": 27.0, "resets_at": "2026-05-30T12:00:00Z"},
130            "seven_day_sonnet":  {"utilization":  4.2, "resets_at": "2026-05-30T12:00:00Z"},
131            "extra_usage":       {"is_enabled": true, "monthly_limit": 5000, "used_credits": 250}
132        }"#;
133        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
134        let snap = resp.into_snapshot("Max 5x".into());
135        assert_eq!(snap.session.utilization_pct, 43); // rounded
136        assert_eq!(snap.weekly.utilization_pct, 27);
137        assert_eq!(snap.sonnet.as_ref().unwrap().utilization_pct, 4);
138        assert_eq!(snap.extra.unwrap().limit.0, 5000);
139        assert_eq!(snap.extra.unwrap().spent.0, 250);
140        assert!(snap.session.resets_at.is_some());
141    }
142
143    #[test]
144    fn missing_sonnet_and_extra_are_none() {
145        let raw = r#"{
146            "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
147            "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
148        }"#;
149        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
150        let snap = resp.into_snapshot("Pro".into());
151        assert!(snap.sonnet.is_none());
152        assert!(snap.extra.is_none());
153    }
154
155    #[test]
156    fn disabled_extra_usage_becomes_none() {
157        let raw = r#"{
158            "five_hour": {"utilization": 0},
159            "seven_day": {"utilization": 0},
160            "extra_usage": {"is_enabled": false, "monthly_limit": 5000, "used_credits": 0}
161        }"#;
162        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
163        let snap = resp.into_snapshot("Pro".into());
164        assert!(snap.extra.is_none());
165    }
166
167    #[test]
168    fn empty_object_yields_neutral_snapshot() {
169        let resp: UsageResponse = serde_json::from_str("{}").unwrap();
170        let snap = resp.into_snapshot("Unknown".into());
171        assert_eq!(snap.session.utilization_pct, 0);
172        assert_eq!(snap.weekly.utilization_pct, 0);
173        assert!(snap.session.resets_at.is_none());
174    }
175
176    #[test]
177    fn unparseable_reset_becomes_none() {
178        let raw = r#"{
179            "five_hour": {"utilization": 50, "resets_at": "not a date"},
180            "seven_day": {"utilization": 0}
181        }"#;
182        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
183        let snap = resp.into_snapshot("Pro".into());
184        assert!(snap.session.resets_at.is_none());
185        assert_eq!(snap.session.utilization_pct, 50);
186    }
187}