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
83 .user
84 .and_then(|u| u.membership)
85 .and_then(|m| m.level)
86 .map(|level| humanize_membership_level(&level));
87
88 let usage = self
89 .usage
90 .ok_or_else(|| AppError::Schema("kimi: missing top-level usage block".into()))?;
91 let (weekly_limit, weekly_used, weekly_remaining, weekly_reset) = extract_block(usage)?;
92
93 let limits = self.limits.unwrap_or_default();
97 let (window_limit, window_used, window_remaining, window_reset) = if limits.is_empty() {
98 (0, 0, 0, None)
99 } else {
100 let detail = limits
101 .into_iter()
102 .find_map(|l| {
103 (l.window.as_ref().is_some_and(is_five_hour_window))
104 .then_some(l.detail)
105 .flatten()
106 })
107 .ok_or_else(|| {
108 AppError::Schema("kimi: missing recognized 5h usage window".into())
109 })?;
110 extract_block(detail)?
111 };
112
113 Ok(KimiSnapshot {
114 plan,
115 weekly_limit,
116 weekly_used,
117 weekly_remaining,
118 weekly_reset_at: weekly_reset,
119 window_limit,
120 window_used,
121 window_remaining,
122 window_reset_at: window_reset,
123 })
124 }
125}
126
127fn extract_block(block: UsageBlock) -> Result<(u64, u64, u64, Option<DateTime<Utc>>)> {
128 let limit = parse_count(&block.limit, "limit")?
129 .ok_or_else(|| AppError::Schema("kimi: missing limit in usage block".into()))?;
130 let used = parse_count(&block.used, "used")?;
131 let remaining = parse_count(&block.remaining, "remaining")?;
132 let reset = parse_reset(block.reset_time.as_deref())?;
133
134 let (used, remaining) = match (used, remaining) {
135 (Some(u), Some(r)) => (u, r),
136 (Some(u), None) => (u, limit.saturating_sub(u)),
137 (None, Some(r)) => (limit.saturating_sub(r), r),
138 (None, None) => {
139 return Err(AppError::Schema(
140 "kimi: usage block is missing both used and remaining".into(),
141 ));
142 }
143 };
144
145 Ok((limit, used, remaining, reset))
146}
147
148#[derive(Debug, Clone, Deserialize, Default)]
155#[serde(default)]
156pub struct UserInfoResponse {
157 user_level_name: Option<String>,
161}
162
163impl UserInfoResponse {
164 pub fn plan_label(&self) -> Option<String> {
165 self.user_level_name
166 .as_deref()
167 .map(str::trim)
168 .filter(|name| !name.is_empty())
169 .map(str::to_string)
170 }
171}
172
173pub fn humanize_membership_level(level: &str) -> String {
182 let trimmed = level.trim();
183 let body = trimmed.strip_prefix("LEVEL_").unwrap_or(trimmed);
184 if body.is_empty() {
185 return trimmed.to_string();
186 }
187 body.split('_')
188 .filter(|word| !word.is_empty())
189 .map(|word| {
190 let mut chars = word.chars();
191 match chars.next() {
192 Some(first) => {
193 first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase()
194 }
195 None => String::new(),
196 }
197 })
198 .collect::<Vec<_>>()
199 .join(" ")
200}
201
202fn is_five_hour_window(window: &Window) -> bool {
205 matches!(
206 (window.duration, window.time_unit.as_str()),
207 (300, "TIME_UNIT_MINUTE" | "MINUTE" | "MINUTES") | (5, "TIME_UNIT_HOUR" | "HOUR" | "HOURS")
208 )
209}
210
211fn parse_count(field: &Option<NumericOrString>, name: &str) -> Result<Option<u64>> {
212 match field {
213 None => Ok(None),
214 Some(n) => n
215 .as_u64()
216 .map(Some)
217 .ok_or_else(|| AppError::Schema(format!("kimi: invalid numeric value for {name}"))),
218 }
219}
220
221fn parse_reset(s: Option<&str>) -> Result<Option<DateTime<Utc>>> {
222 match s {
223 None | Some("") => Ok(None),
224 Some(s) => DateTime::parse_from_rfc3339(s)
225 .map(|dt| Some(dt.into()))
226 .map_err(|e| AppError::Schema(format!("kimi: unparseable resetTime: {e}"))),
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn membership_levels_are_humanized_not_invented() {
236 assert_eq!(
237 humanize_membership_level("LEVEL_INTERMEDIATE"),
238 "Intermediate"
239 );
240 assert_eq!(
241 humanize_membership_level("LEVEL_SUPER_ADVANCED"),
242 "Super Advanced"
243 );
244 assert_eq!(humanize_membership_level(" basic "), "Basic");
246 assert_eq!(humanize_membership_level("LEVEL_"), "LEVEL_");
248 assert_eq!(humanize_membership_level(""), "");
249 }
250
251 #[test]
252 fn the_profile_response_yields_only_the_tier_name() {
253 let raw = r#"{
254 "user_id": "u-1", "nickname": "someone", "email": "someone@example.com",
255 "phone": {"country_code": "55", "number": "999999999"},
256 "user_level": 25, "user_level_name": "Allegretto",
257 "domain_name": "DOMAIN_NEXUS"
258 }"#;
259 let me: UserInfoResponse = serde_json::from_str(raw).unwrap();
260 assert_eq!(me.plan_label(), Some("Allegretto".into()));
261 let rendered = format!("{me:?}");
264 assert!(!rendered.contains("example.com"), "{rendered}");
265 assert!(!rendered.contains("999999999"), "{rendered}");
266 }
267
268 #[test]
269 fn a_blank_or_absent_tier_name_is_no_label() {
270 for raw in [
271 r#"{"user_level_name": ""}"#,
272 r#"{"user_level_name": " "}"#,
273 "{}",
274 ] {
275 let me: UserInfoResponse = serde_json::from_str(raw).unwrap();
276 assert_eq!(me.plan_label(), None, "{raw}");
277 }
278 }
279
280 #[test]
281 fn parses_representative_json_with_string_numbers() {
282 let raw = r#"{
283 "user": { "membership": { "level": "LEVEL_INTERMEDIATE" } },
284 "usage": { "limit": "100", "used": "26", "remaining": "74", "resetTime": "2026-02-11T17:32:50.757941Z" },
285 "limits": [
286 {
287 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
288 "detail": { "limit": "100", "used": "15", "remaining": "85", "resetTime": "2026-02-07T12:32:50.757941Z" }
289 }
290 ]
291 }"#;
292 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
293 .unwrap()
294 .into_snapshot()
295 .unwrap();
296 assert_eq!(snap.plan, Some("Intermediate".into()));
297 assert_eq!(snap.weekly_limit, 100);
298 assert_eq!(snap.weekly_used, 26);
299 assert_eq!(snap.weekly_remaining, 74);
300 assert!(snap.weekly_reset_at.is_some());
301 assert_eq!(snap.window_limit, 100);
302 assert_eq!(snap.window_used, 15);
303 assert_eq!(snap.window_remaining, 85);
304 assert!(snap.window_reset_at.is_some());
305 assert_eq!(snap.weekly_pct(), 26);
306 assert_eq!(snap.window_pct(), 15);
307 }
308
309 #[test]
310 fn parses_numeric_json_numbers() {
311 let raw = r#"{
312 "user": { "membership": { "level": "LEVEL_ADVANCED" } },
313 "usage": { "limit": 500, "used": 123, "remaining": 377, "resetTime": "2026-02-11T17:32:50Z" },
314 "limits": [
315 {
316 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
317 "detail": { "limit": 200, "used": 50, "remaining": 150, "resetTime": "2026-02-07T12:32:50Z" }
318 }
319 ]
320 }"#;
321 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
322 .unwrap()
323 .into_snapshot()
324 .unwrap();
325 assert_eq!(snap.plan, Some("Advanced".into()));
326 assert_eq!(snap.weekly_limit, 500);
327 assert_eq!(snap.weekly_used, 123);
328 assert_eq!(snap.weekly_remaining, 377);
329 assert_eq!(snap.window_limit, 200);
330 assert_eq!(snap.window_used, 50);
331 assert_eq!(snap.window_remaining, 150);
332 }
333
334 #[test]
335 fn parses_missing_user_and_limits() {
336 let raw = r#"{
337 "usage": { "limit": "100", "used": "26", "remaining": "74" }
338 }"#;
339 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
340 .unwrap()
341 .into_snapshot()
342 .unwrap();
343 assert_eq!(snap.plan, None);
344 assert_eq!(snap.weekly_limit, 100);
345 assert_eq!(snap.weekly_used, 26);
346 assert_eq!(snap.weekly_remaining, 74);
347 assert_eq!(snap.weekly_reset_at, None);
348 assert_eq!(snap.window_limit, 0);
349 assert_eq!(snap.window_used, 0);
350 assert_eq!(snap.window_remaining, 0);
351 assert_eq!(snap.window_reset_at, None);
352 }
353
354 #[test]
355 fn computes_used_when_missing() {
356 let raw = r#"{
357 "usage": { "limit": "100", "remaining": "74" }
358 }"#;
359 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
360 .unwrap()
361 .into_snapshot()
362 .unwrap();
363 assert_eq!(snap.weekly_used, 26);
364 assert_eq!(snap.weekly_remaining, 74);
365 }
366
367 #[test]
368 fn computes_remaining_when_missing() {
369 let raw = r#"{
370 "usage": { "limit": "100", "used": "26" }
371 }"#;
372 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
373 .unwrap()
374 .into_snapshot()
375 .unwrap();
376 assert_eq!(snap.weekly_used, 26);
377 assert_eq!(snap.weekly_remaining, 74);
378 }
379
380 #[test]
381 fn zero_strings_are_valid() {
382 let raw = r#"{
383 "usage": { "limit": "100", "used": "0", "remaining": "100" }
384 }"#;
385 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
386 .unwrap()
387 .into_snapshot()
388 .unwrap();
389 assert_eq!(snap.weekly_used, 0);
390 assert_eq!(snap.weekly_remaining, 100);
391 assert_eq!(snap.weekly_pct(), 0);
392 }
393
394 #[test]
395 fn both_counts_missing_is_schema_drift() {
396 let raw = r#"{
397 "usage": { "limit": "100" }
398 }"#;
399 let err = serde_json::from_str::<UsagesResponse>(raw)
400 .unwrap()
401 .into_snapshot()
402 .unwrap_err();
403 assert!(err.to_string().contains("both used and remaining"));
404 }
405
406 #[test]
407 fn malformed_numeric_string_rejected() {
408 let raw = r#"{
409 "usage": { "limit": "100", "used": "garbage" }
410 }"#;
411 let err = serde_json::from_str::<UsagesResponse>(raw)
412 .unwrap()
413 .into_snapshot()
414 .unwrap_err();
415 assert!(
416 err.to_string().contains("used"),
417 "expected used parse error, got {err}"
418 );
419 }
420
421 #[test]
422 fn overflow_string_rejected() {
423 let raw = r#"{
424 "usage": { "limit": "18446744073709551616", "used": "0" }
425 }"#;
426 let err = serde_json::from_str::<UsagesResponse>(raw)
427 .unwrap()
428 .into_snapshot()
429 .unwrap_err();
430 assert!(
431 err.to_string().contains("limit"),
432 "expected limit overflow error, got {err}"
433 );
434 }
435
436 #[test]
437 fn negative_json_number_rejected_without_panic() {
438 let raw = r#"{
439 "usage": { "limit": 100, "used": -1 }
440 }"#;
441 let res = serde_json::from_str::<UsagesResponse>(raw);
443 assert!(
444 res.is_err(),
445 "negative u64 should not deserialize without panic"
446 );
447 }
448
449 #[test]
450 fn selects_300_min_window() {
451 let raw = r#"{
452 "usage": { "limit": "100", "used": "26", "remaining": "74" },
453 "limits": [
454 {
455 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
456 "detail": { "limit": "100", "used": "15", "remaining": "85" }
457 }
458 ]
459 }"#;
460 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
461 .unwrap()
462 .into_snapshot()
463 .unwrap();
464 assert_eq!(snap.window_limit, 100);
465 assert_eq!(snap.window_used, 15);
466 }
467
468 #[test]
469 fn empty_limits_yield_no_window() {
470 let raw = r#"{
471 "usage": { "limit": "100", "used": "26", "remaining": "74" },
472 "limits": []
473 }"#;
474 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
475 .unwrap()
476 .into_snapshot()
477 .unwrap();
478 assert_eq!(snap.window_limit, 0);
479 assert_eq!(snap.window_used, 0);
480 assert_eq!(snap.window_remaining, 0);
481 }
482
483 #[test]
484 fn null_limits_yield_no_window() {
485 let raw = r#"{
486 "usage": { "limit": "100", "used": "26", "remaining": "74" },
487 "limits": null
488 }"#;
489 let snap = serde_json::from_str::<UsagesResponse>(raw)
490 .unwrap()
491 .into_snapshot()
492 .unwrap();
493 assert_eq!(snap.window_limit, 0);
494 assert_eq!(snap.window_used, 0);
495 }
496
497 #[test]
498 fn unrecognized_window_is_schema_drift() {
499 let raw = r#"{
500 "usage": { "limit": "100", "used": "26", "remaining": "74" },
501 "limits": [
502 {
503 "window": { "duration": 60, "timeUnit": "TIME_UNIT_MINUTE" },
504 "detail": { "limit": "100", "used": "1", "remaining": "99" }
505 }
506 ]
507 }"#;
508 let err = serde_json::from_str::<UsagesResponse>(raw)
509 .unwrap()
510 .into_snapshot()
511 .unwrap_err();
512 assert!(err.to_string().contains("recognized 5h"));
513 }
514
515 #[test]
516 fn selects_second_300_min_window_when_first_lacks_detail() {
517 let raw = r#"{
518 "usage": { "limit": "100", "used": "10", "remaining": "90" },
519 "limits": [
520 {
521 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" }
522 },
523 {
524 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
525 "detail": { "limit": "100", "used": "25", "remaining": "75" }
526 }
527 ]
528 }"#;
529 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
530 .unwrap()
531 .into_snapshot()
532 .unwrap();
533 assert_eq!(snap.window_limit, 100);
534 assert_eq!(snap.window_used, 25);
535 assert_eq!(snap.window_remaining, 75);
536 }
537
538 #[test]
539 fn selects_first_300_min_window_among_multiple() {
540 let raw = r#"{
541 "usage": { "limit": "100", "used": "10", "remaining": "90" },
542 "limits": [
543 {
544 "window": { "duration": 60, "timeUnit": "TIME_UNIT_MINUTE" },
545 "detail": { "limit": "100", "used": "1", "remaining": "99" }
546 },
547 {
548 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
549 "detail": { "limit": "100", "used": "25", "remaining": "75" }
550 },
551 {
552 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
553 "detail": { "limit": "100", "used": "50", "remaining": "50" }
554 }
555 ]
556 }"#;
557 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
558 .unwrap()
559 .into_snapshot()
560 .unwrap();
561 assert_eq!(snap.window_used, 25);
562 }
563
564 #[test]
565 fn used_greater_than_limit_clamps_pct() {
566 let snap = KimiSnapshot {
567 plan: None,
568 weekly_limit: 100,
569 weekly_used: 150,
570 weekly_remaining: 0,
571 weekly_reset_at: None,
572 window_limit: 0,
573 window_used: 0,
574 window_remaining: 0,
575 window_reset_at: None,
576 };
577 assert_eq!(snap.weekly_pct(), 100);
578 }
579
580 #[test]
581 fn u64_max_round_trip() {
582 let raw = r#"{
583 "usage": { "limit": "18446744073709551615", "used": "0", "remaining": "18446744073709551615" }
584 }"#;
585 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
586 .unwrap()
587 .into_snapshot()
588 .unwrap();
589 assert_eq!(snap.weekly_limit, u64::MAX);
590 assert_eq!(snap.weekly_remaining, u64::MAX);
591 }
592
593 #[test]
594 fn accepts_reset_and_duration_aliases() {
595 let raw = r#"{
596 "usage": { "limit": 100, "used": 20, "resetAt": "2026-02-11T17:32:50Z" },
597 "limits": [{
598 "window": { "duration": 5, "time_unit": "TIME_UNIT_HOUR" },
599 "detail": { "limit": 100, "remaining": 75, "reset_at": "2026-02-07T12:32:50Z" }
600 }]
601 }"#;
602 let snap = serde_json::from_str::<UsagesResponse>(raw)
603 .unwrap()
604 .into_snapshot()
605 .unwrap();
606 assert_eq!(snap.weekly_used, 20);
607 assert_eq!(snap.window_used, 25);
608 assert!(snap.weekly_reset_at.is_some());
609 assert!(snap.window_reset_at.is_some());
610 }
611}