1use chrono::{DateTime, Utc};
4use serde::Deserialize;
5
6use crate::error::{AppError, Result};
7use crate::usage::KimiSnapshot;
8
9#[derive(Debug, Clone, Deserialize, Default)]
10#[serde(default)]
11pub struct UsagesResponse {
12 user: Option<User>,
13 usage: Option<UsageBlock>,
14 limits: Option<Vec<Limit>>,
17}
18
19#[derive(Debug, Clone, Deserialize, Default)]
20#[serde(default)]
21struct User {
22 membership: Option<Membership>,
23}
24
25#[derive(Debug, Clone, Deserialize, Default)]
26#[serde(default)]
27struct Membership {
28 level: Option<String>,
29}
30
31#[derive(Debug, Clone, Deserialize, Default)]
32#[serde(default)]
33struct UsageBlock {
34 limit: Option<NumericOrString>,
35 used: Option<NumericOrString>,
36 remaining: Option<NumericOrString>,
37 #[serde(
38 rename = "resetTime",
39 alias = "resetAt",
40 alias = "reset_at",
41 alias = "reset_time"
42 )]
43 reset_time: Option<String>,
44}
45
46#[derive(Debug, Clone, Deserialize, Default)]
47#[serde(default)]
48struct Limit {
49 window: Option<Window>,
50 detail: Option<UsageBlock>,
51}
52
53#[derive(Debug, Clone, Deserialize, Default)]
54#[serde(default)]
55struct Window {
56 duration: u64,
57 #[serde(rename = "timeUnit", alias = "time_unit")]
58 time_unit: String,
59}
60
61#[derive(Debug, Clone, Deserialize)]
62#[serde(untagged)]
63enum NumericOrString {
64 Number(u64),
65 String(String),
66}
67
68impl NumericOrString {
69 fn as_u64(&self) -> Option<u64> {
70 match self {
71 NumericOrString::Number(n) => Some(*n),
72 NumericOrString::String(s) => s.trim().parse::<u64>().ok(),
73 }
74 }
75}
76
77impl UsagesResponse {
78 pub fn into_snapshot(self) -> Result<KimiSnapshot> {
79 let plan = self.user.and_then(|u| u.membership).and_then(|m| m.level);
80
81 let usage = self
82 .usage
83 .ok_or_else(|| AppError::Schema("kimi: missing top-level usage block".into()))?;
84 let (weekly_limit, weekly_used, weekly_remaining, weekly_reset) = extract_block(usage)?;
85
86 let limits = self.limits.unwrap_or_default();
90 let (window_limit, window_used, window_remaining, window_reset) = if limits.is_empty() {
91 (0, 0, 0, None)
92 } else {
93 let detail = limits
94 .into_iter()
95 .find_map(|l| {
96 (l.window.as_ref().is_some_and(is_five_hour_window))
97 .then_some(l.detail)
98 .flatten()
99 })
100 .ok_or_else(|| {
101 AppError::Schema("kimi: missing recognized 5h usage window".into())
102 })?;
103 extract_block(detail)?
104 };
105
106 Ok(KimiSnapshot {
107 plan,
108 weekly_limit,
109 weekly_used,
110 weekly_remaining,
111 weekly_reset_at: weekly_reset,
112 window_limit,
113 window_used,
114 window_remaining,
115 window_reset_at: window_reset,
116 })
117 }
118}
119
120fn extract_block(block: UsageBlock) -> Result<(u64, u64, u64, Option<DateTime<Utc>>)> {
121 let limit = parse_count(&block.limit, "limit")?
122 .ok_or_else(|| AppError::Schema("kimi: missing limit in usage block".into()))?;
123 let used = parse_count(&block.used, "used")?;
124 let remaining = parse_count(&block.remaining, "remaining")?;
125 let reset = parse_reset(block.reset_time.as_deref())?;
126
127 let (used, remaining) = match (used, remaining) {
128 (Some(u), Some(r)) => (u, r),
129 (Some(u), None) => (u, limit.saturating_sub(u)),
130 (None, Some(r)) => (limit.saturating_sub(r), r),
131 (None, None) => {
132 return Err(AppError::Schema(
133 "kimi: usage block is missing both used and remaining".into(),
134 ));
135 }
136 };
137
138 Ok((limit, used, remaining, reset))
139}
140
141fn is_five_hour_window(window: &Window) -> bool {
144 matches!(
145 (window.duration, window.time_unit.as_str()),
146 (300, "TIME_UNIT_MINUTE" | "MINUTE" | "MINUTES") | (5, "TIME_UNIT_HOUR" | "HOUR" | "HOURS")
147 )
148}
149
150fn parse_count(field: &Option<NumericOrString>, name: &str) -> Result<Option<u64>> {
151 match field {
152 None => Ok(None),
153 Some(n) => n
154 .as_u64()
155 .map(Some)
156 .ok_or_else(|| AppError::Schema(format!("kimi: invalid numeric value for {name}"))),
157 }
158}
159
160fn parse_reset(s: Option<&str>) -> Result<Option<DateTime<Utc>>> {
161 match s {
162 None | Some("") => Ok(None),
163 Some(s) => DateTime::parse_from_rfc3339(s)
164 .map(|dt| Some(dt.into()))
165 .map_err(|e| AppError::Schema(format!("kimi: unparseable resetTime: {e}"))),
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn parses_representative_json_with_string_numbers() {
175 let raw = r#"{
176 "user": { "membership": { "level": "LEVEL_INTERMEDIATE" } },
177 "usage": { "limit": "100", "used": "26", "remaining": "74", "resetTime": "2026-02-11T17:32:50.757941Z" },
178 "limits": [
179 {
180 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
181 "detail": { "limit": "100", "used": "15", "remaining": "85", "resetTime": "2026-02-07T12:32:50.757941Z" }
182 }
183 ]
184 }"#;
185 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
186 .unwrap()
187 .into_snapshot()
188 .unwrap();
189 assert_eq!(snap.plan, Some("LEVEL_INTERMEDIATE".into()));
190 assert_eq!(snap.weekly_limit, 100);
191 assert_eq!(snap.weekly_used, 26);
192 assert_eq!(snap.weekly_remaining, 74);
193 assert!(snap.weekly_reset_at.is_some());
194 assert_eq!(snap.window_limit, 100);
195 assert_eq!(snap.window_used, 15);
196 assert_eq!(snap.window_remaining, 85);
197 assert!(snap.window_reset_at.is_some());
198 assert_eq!(snap.weekly_pct(), 26);
199 assert_eq!(snap.window_pct(), 15);
200 }
201
202 #[test]
203 fn parses_numeric_json_numbers() {
204 let raw = r#"{
205 "user": { "membership": { "level": "LEVEL_ADVANCED" } },
206 "usage": { "limit": 500, "used": 123, "remaining": 377, "resetTime": "2026-02-11T17:32:50Z" },
207 "limits": [
208 {
209 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
210 "detail": { "limit": 200, "used": 50, "remaining": 150, "resetTime": "2026-02-07T12:32:50Z" }
211 }
212 ]
213 }"#;
214 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
215 .unwrap()
216 .into_snapshot()
217 .unwrap();
218 assert_eq!(snap.plan, Some("LEVEL_ADVANCED".into()));
219 assert_eq!(snap.weekly_limit, 500);
220 assert_eq!(snap.weekly_used, 123);
221 assert_eq!(snap.weekly_remaining, 377);
222 assert_eq!(snap.window_limit, 200);
223 assert_eq!(snap.window_used, 50);
224 assert_eq!(snap.window_remaining, 150);
225 }
226
227 #[test]
228 fn parses_missing_user_and_limits() {
229 let raw = r#"{
230 "usage": { "limit": "100", "used": "26", "remaining": "74" }
231 }"#;
232 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
233 .unwrap()
234 .into_snapshot()
235 .unwrap();
236 assert_eq!(snap.plan, None);
237 assert_eq!(snap.weekly_limit, 100);
238 assert_eq!(snap.weekly_used, 26);
239 assert_eq!(snap.weekly_remaining, 74);
240 assert_eq!(snap.weekly_reset_at, None);
241 assert_eq!(snap.window_limit, 0);
242 assert_eq!(snap.window_used, 0);
243 assert_eq!(snap.window_remaining, 0);
244 assert_eq!(snap.window_reset_at, None);
245 }
246
247 #[test]
248 fn computes_used_when_missing() {
249 let raw = r#"{
250 "usage": { "limit": "100", "remaining": "74" }
251 }"#;
252 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
253 .unwrap()
254 .into_snapshot()
255 .unwrap();
256 assert_eq!(snap.weekly_used, 26);
257 assert_eq!(snap.weekly_remaining, 74);
258 }
259
260 #[test]
261 fn computes_remaining_when_missing() {
262 let raw = r#"{
263 "usage": { "limit": "100", "used": "26" }
264 }"#;
265 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
266 .unwrap()
267 .into_snapshot()
268 .unwrap();
269 assert_eq!(snap.weekly_used, 26);
270 assert_eq!(snap.weekly_remaining, 74);
271 }
272
273 #[test]
274 fn zero_strings_are_valid() {
275 let raw = r#"{
276 "usage": { "limit": "100", "used": "0", "remaining": "100" }
277 }"#;
278 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
279 .unwrap()
280 .into_snapshot()
281 .unwrap();
282 assert_eq!(snap.weekly_used, 0);
283 assert_eq!(snap.weekly_remaining, 100);
284 assert_eq!(snap.weekly_pct(), 0);
285 }
286
287 #[test]
288 fn both_counts_missing_is_schema_drift() {
289 let raw = r#"{
290 "usage": { "limit": "100" }
291 }"#;
292 let err = serde_json::from_str::<UsagesResponse>(raw)
293 .unwrap()
294 .into_snapshot()
295 .unwrap_err();
296 assert!(err.to_string().contains("both used and remaining"));
297 }
298
299 #[test]
300 fn malformed_numeric_string_rejected() {
301 let raw = r#"{
302 "usage": { "limit": "100", "used": "garbage" }
303 }"#;
304 let err = serde_json::from_str::<UsagesResponse>(raw)
305 .unwrap()
306 .into_snapshot()
307 .unwrap_err();
308 assert!(
309 err.to_string().contains("used"),
310 "expected used parse error, got {err}"
311 );
312 }
313
314 #[test]
315 fn overflow_string_rejected() {
316 let raw = r#"{
317 "usage": { "limit": "18446744073709551616", "used": "0" }
318 }"#;
319 let err = serde_json::from_str::<UsagesResponse>(raw)
320 .unwrap()
321 .into_snapshot()
322 .unwrap_err();
323 assert!(
324 err.to_string().contains("limit"),
325 "expected limit overflow error, got {err}"
326 );
327 }
328
329 #[test]
330 fn negative_json_number_rejected_without_panic() {
331 let raw = r#"{
332 "usage": { "limit": 100, "used": -1 }
333 }"#;
334 let res = serde_json::from_str::<UsagesResponse>(raw);
336 assert!(
337 res.is_err(),
338 "negative u64 should not deserialize without panic"
339 );
340 }
341
342 #[test]
343 fn selects_300_min_window() {
344 let raw = r#"{
345 "usage": { "limit": "100", "used": "26", "remaining": "74" },
346 "limits": [
347 {
348 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
349 "detail": { "limit": "100", "used": "15", "remaining": "85" }
350 }
351 ]
352 }"#;
353 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
354 .unwrap()
355 .into_snapshot()
356 .unwrap();
357 assert_eq!(snap.window_limit, 100);
358 assert_eq!(snap.window_used, 15);
359 }
360
361 #[test]
362 fn empty_limits_yield_no_window() {
363 let raw = r#"{
364 "usage": { "limit": "100", "used": "26", "remaining": "74" },
365 "limits": []
366 }"#;
367 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
368 .unwrap()
369 .into_snapshot()
370 .unwrap();
371 assert_eq!(snap.window_limit, 0);
372 assert_eq!(snap.window_used, 0);
373 assert_eq!(snap.window_remaining, 0);
374 }
375
376 #[test]
377 fn null_limits_yield_no_window() {
378 let raw = r#"{
379 "usage": { "limit": "100", "used": "26", "remaining": "74" },
380 "limits": null
381 }"#;
382 let snap = serde_json::from_str::<UsagesResponse>(raw)
383 .unwrap()
384 .into_snapshot()
385 .unwrap();
386 assert_eq!(snap.window_limit, 0);
387 assert_eq!(snap.window_used, 0);
388 }
389
390 #[test]
391 fn unrecognized_window_is_schema_drift() {
392 let raw = r#"{
393 "usage": { "limit": "100", "used": "26", "remaining": "74" },
394 "limits": [
395 {
396 "window": { "duration": 60, "timeUnit": "TIME_UNIT_MINUTE" },
397 "detail": { "limit": "100", "used": "1", "remaining": "99" }
398 }
399 ]
400 }"#;
401 let err = serde_json::from_str::<UsagesResponse>(raw)
402 .unwrap()
403 .into_snapshot()
404 .unwrap_err();
405 assert!(err.to_string().contains("recognized 5h"));
406 }
407
408 #[test]
409 fn selects_second_300_min_window_when_first_lacks_detail() {
410 let raw = r#"{
411 "usage": { "limit": "100", "used": "10", "remaining": "90" },
412 "limits": [
413 {
414 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" }
415 },
416 {
417 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
418 "detail": { "limit": "100", "used": "25", "remaining": "75" }
419 }
420 ]
421 }"#;
422 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
423 .unwrap()
424 .into_snapshot()
425 .unwrap();
426 assert_eq!(snap.window_limit, 100);
427 assert_eq!(snap.window_used, 25);
428 assert_eq!(snap.window_remaining, 75);
429 }
430
431 #[test]
432 fn selects_first_300_min_window_among_multiple() {
433 let raw = r#"{
434 "usage": { "limit": "100", "used": "10", "remaining": "90" },
435 "limits": [
436 {
437 "window": { "duration": 60, "timeUnit": "TIME_UNIT_MINUTE" },
438 "detail": { "limit": "100", "used": "1", "remaining": "99" }
439 },
440 {
441 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
442 "detail": { "limit": "100", "used": "25", "remaining": "75" }
443 },
444 {
445 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
446 "detail": { "limit": "100", "used": "50", "remaining": "50" }
447 }
448 ]
449 }"#;
450 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
451 .unwrap()
452 .into_snapshot()
453 .unwrap();
454 assert_eq!(snap.window_used, 25);
455 }
456
457 #[test]
458 fn used_greater_than_limit_clamps_pct() {
459 let snap = KimiSnapshot {
460 plan: None,
461 weekly_limit: 100,
462 weekly_used: 150,
463 weekly_remaining: 0,
464 weekly_reset_at: None,
465 window_limit: 0,
466 window_used: 0,
467 window_remaining: 0,
468 window_reset_at: None,
469 };
470 assert_eq!(snap.weekly_pct(), 100);
471 }
472
473 #[test]
474 fn u64_max_round_trip() {
475 let raw = r#"{
476 "usage": { "limit": "18446744073709551615", "used": "0", "remaining": "18446744073709551615" }
477 }"#;
478 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
479 .unwrap()
480 .into_snapshot()
481 .unwrap();
482 assert_eq!(snap.weekly_limit, u64::MAX);
483 assert_eq!(snap.weekly_remaining, u64::MAX);
484 }
485
486 #[test]
487 fn accepts_reset_and_duration_aliases() {
488 let raw = r#"{
489 "usage": { "limit": 100, "used": 20, "resetAt": "2026-02-11T17:32:50Z" },
490 "limits": [{
491 "window": { "duration": 5, "time_unit": "TIME_UNIT_HOUR" },
492 "detail": { "limit": 100, "remaining": 75, "reset_at": "2026-02-07T12:32:50Z" }
493 }]
494 }"#;
495 let snap = serde_json::from_str::<UsagesResponse>(raw)
496 .unwrap()
497 .into_snapshot()
498 .unwrap();
499 assert_eq!(snap.weekly_used, 20);
500 assert_eq!(snap.window_used, 25);
501 assert!(snap.weekly_reset_at.is_some());
502 assert!(snap.window_reset_at.is_some());
503 }
504}