ai_usagebar/anthropic/
types.rs1use serde::{Deserialize, Serialize};
9
10use crate::usage::{AnthropicSnapshot, Cents, ExtraUsage, UsageWindow};
11
12#[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#[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#[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
46fn 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 pub fn into_snapshot(self, plan_label: String) -> AnthropicSnapshot {
76 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 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); 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}