Skip to main content

ai_usagebar/kimi/
types.rs

1//! Wire types for Kimi's `/coding/v1/usages` endpoint.
2
3use 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    // Kimi omits this for accounts without a rolling quota and has also
15    // returned `null`; both mean no rolling window is available.
16    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        // `limits` is absent for accounts where Kimi does not expose the
87        // rolling quota. Once it is present, a 5h window is required: silently
88        // treating an unfamiliar advertised window as zero usage masks drift.
89        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
141/// Kimi documents the rolling window as 300 minutes. Accept only equivalent
142/// spellings used by protobuf/JSON gateways, not arbitrary duration units.
143fn 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        // Deserialization itself must fail because -1 is not a valid u64.
335        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}