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        // The raw membership enum ("LEVEL_INTERMEDIATE") is a wire value, not
80        // something to put on a status bar. `fetch` overwrites this with the
81        // vendor's own tier name from `/me` when that call succeeds.
82        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        // `limits` is absent for accounts where Kimi does not expose the
94        // rolling quota. Once it is present, a 5h window is required: silently
95        // treating an unfamiliar advertised window as zero usage masks drift.
96        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/// The profile response from `/coding/v1/me`, read for exactly one field.
149///
150/// That endpoint also returns the account's email, phone, nickname, avatar and
151/// ids. **None of them are deserialized here** — serde drops unknown fields, so
152/// the personal data never enters a snapshot, the cache, or an error message.
153/// Keep it that way: the plan label is the only thing this vendor needs.
154#[derive(Debug, Clone, Deserialize, Default)]
155#[serde(default)]
156pub struct UserInfoResponse {
157    /// The subscription tier's own name — "Andante", "Moderato",
158    /// "Allegretto", "Allegro". Kimi names its plans after tempo markings, so
159    /// this reads as a product name and not as a gamification badge.
160    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
173/// Make a raw membership enum readable when the vendor's own label is
174/// unavailable (`/me` unreachable, or an API key whose account has no coding
175/// profile). `LEVEL_INTERMEDIATE` → `Intermediate`.
176///
177/// Deliberately *not* a table mapping levels onto tier names: the enum-to-tier
178/// correspondence is not published anywhere, and inventing "Allegretto" for a
179/// level that might mean something else would put a wrong plan on screen with
180/// full confidence. Prettifying what the vendor said is the honest fallback.
181pub 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
202/// Kimi documents the rolling window as 300 minutes. Accept only equivalent
203/// spellings used by protobuf/JSON gateways, not arbitrary duration units.
204fn 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        // No LEVEL_ prefix, mixed case, and surrounding space still read well.
245        assert_eq!(humanize_membership_level("  basic  "), "Basic");
246        // Degenerate inputs are returned rather than turned into an empty label.
247        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        // The struct has no field to hold the personal data, so nothing else
262        // can leak into a snapshot or a Debug line.
263        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        // Deserialization itself must fail because -1 is not a valid u64.
442        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}