Skip to main content

ai_usagebar/kimi/
fetch.rs

1//! Fetch Kimi usage from `/coding/v1/usages`.
2//!
3//! One endpoint, two credentials: an **API key**, or the **Kimi Code CLI's
4//! OAuth session** — the credential a subscriber already has locally, with no
5//! key to create or paste. See [`Auth`] and `oauth.rs`.
6
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use chrono::{DateTime, Utc};
11
12use crate::cache::{Cache, acquire_lock_async};
13use crate::error::{AppError, Result};
14use crate::usage::KimiSnapshot;
15
16use super::oauth::{self, Region};
17use super::types::{UsagesResponse, UserInfoResponse, humanize_membership_level};
18
19pub const BASE_URL: &str = "https://api.kimi.com";
20const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
21const REFRESH_TIMEOUT: Duration = Duration::from_secs(15);
22const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
23/// Stable marker stored alongside code 0 for a successful HTTP response whose
24/// payload no longer matches Kimi's undocumented usage schema.
25pub const SCHEMA_DRIFT_MESSAGE: &str = "Kimi API schema drift";
26
27#[derive(Debug, Clone)]
28pub struct Endpoints {
29    pub usages: String,
30    /// Profile endpoint, read solely for the subscription's own tier name.
31    pub me: String,
32    /// OAuth token endpoint for the same deployment. Unused by the API-key
33    /// path, which never refreshes anything.
34    pub token: String,
35}
36
37impl Default for Endpoints {
38    fn default() -> Self {
39        Self::for_region(Region::MainlandCn)
40    }
41}
42
43impl Endpoints {
44    pub fn for_region(region: Region) -> Self {
45        Self {
46            usages: format!("{}/usages", region.api_base()),
47            me: format!("{}/me", region.api_base()),
48            token: oauth::token_endpoint(region.oauth_host()),
49        }
50    }
51}
52
53/// Where the bearer for `/coding/v1/usages` comes from.
54#[derive(Debug, Clone)]
55pub enum Auth {
56    /// A platform API key (`KIMI_API_KEY` or `[kimi] api_key`).
57    ApiKey(String),
58    /// The Kimi Code CLI's own OAuth session — a subscription login.
59    KimiCode(KimiCodeAuth),
60}
61
62/// The two paths inside a kimi-code home this vendor touches: the credential
63/// file it reads and rewrites, and the lock target that serializes a refresh
64/// against the CLI's own (see `lock.rs`).
65#[derive(Debug, Clone)]
66pub struct KimiCodeAuth {
67    pub credentials_path: PathBuf,
68    pub lock_target: PathBuf,
69}
70
71impl KimiCodeAuth {
72    pub fn in_home(home: &Path) -> Self {
73        Self {
74            credentials_path: oauth::credentials_path_in(home),
75            lock_target: oauth::lock_target_in(home),
76        }
77    }
78
79    /// A credential file relocated by config (`[kimi] credentials_path`) still
80    /// belongs to a kimi-code home; the lock target is derived from that home
81    /// so both clients agree on which file the lock protects.
82    pub fn with_credentials_path(home: &Path, credentials_path: PathBuf) -> Self {
83        Self {
84            credentials_path,
85            lock_target: oauth::lock_target_in(home),
86        }
87    }
88}
89
90/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
91/// specialised to its snapshot.
92pub type FetchOutcome = crate::outcome::Outcome<KimiSnapshot>;
93
94/// API-key fetch. Kept as-is for existing callers; the OAuth path goes
95/// through [`fetch_snapshot_with_auth`].
96pub async fn fetch_snapshot(
97    client: &reqwest::Client,
98    api_key: &str,
99    cache: &Cache,
100    endpoints: &Endpoints,
101    cache_ttl: Duration,
102) -> Result<FetchOutcome> {
103    fetch_snapshot_with_auth(
104        client,
105        &Auth::ApiKey(api_key.to_string()),
106        cache,
107        endpoints,
108        cache_ttl,
109    )
110    .await
111}
112
113pub async fn fetch_snapshot_with_auth(
114    client: &reqwest::Client,
115    auth: &Auth,
116    cache: &Cache,
117    endpoints: &Endpoints,
118    cache_ttl: Duration,
119) -> Result<FetchOutcome> {
120    fetch_snapshot_at(client, auth, cache, endpoints, cache_ttl, Utc::now()).await
121}
122
123/// Clock seam for the OAuth expiry decision, mirroring
124/// `kiro::fetch::fetch_snapshot_at`.
125async fn fetch_snapshot_at(
126    client: &reqwest::Client,
127    auth: &Auth,
128    cache: &Cache,
129    endpoints: &Endpoints,
130    cache_ttl: Duration,
131    now: DateTime<Utc>,
132) -> Result<FetchOutcome> {
133    cache.ensure_dir()?;
134    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
135
136    if let Some(bytes) = cache.fresh_payload(cache_ttl)? {
137        // Releases before the profile lookup cached `/usages`' internal enum
138        // verbatim. Do not let a still-fresh legacy entry postpone `/me` until
139        // the normal TTL expires: refresh it once and replace it with Kimi's
140        // own tier name. If the network is down, the fallback path below still
141        // serves the quota after humanizing the enum.
142        if !cache_has_legacy_plan(&bytes)
143            && let Ok(outcome) = reuse_cache(bytes, cache, false)
144        {
145            return Ok(outcome);
146        }
147    }
148    // Corrupt fresh cache: fall through to live fetch rather than return a
149    // fabricated zero snapshot.
150
151    match fetch_live(client, endpoints, auth, now).await {
152        Ok(snap) => {
153            let bytes = serde_json::to_vec(&snap_to_json(&snap))?;
154            cache.write_payload(&bytes)?;
155            Ok(crate::outcome::Outcome::fresh(snap))
156        }
157        Err(e) if e.is_transient() => fallback_silent(cache, e),
158        Err(e) => {
159            cache.mark_stale();
160            if let Some((code, msg)) = error_to_pair(&e) {
161                cache.write_last_error(code, &msg);
162            }
163            fallback_with_error(cache, e)
164        }
165    }
166}
167
168fn fallback_silent(cache: &Cache, original: AppError) -> Result<FetchOutcome> {
169    crate::outcome::fallback(cache, None, original, parse_cache)
170}
171
172fn fallback_with_error(cache: &Cache, original: AppError) -> Result<FetchOutcome> {
173    let last_error = error_to_pair(&original);
174    crate::outcome::fallback(cache, last_error, original, parse_cache)
175}
176
177fn error_to_pair(e: &AppError) -> Option<(u16, String)> {
178    match e {
179        AppError::Http { status, body } => Some((*status, body.clone())),
180        // A 2xx response with an unknown shape is not an HTTP 422 response.
181        AppError::Schema(_) => Some((0, SCHEMA_DRIFT_MESSAGE.into())),
182        e => Some((0, e.to_string())),
183    }
184}
185
186fn reuse_cache(bytes: Vec<u8>, cache: &Cache, stale: bool) -> Result<FetchOutcome> {
187    let snap = parse_cache(&bytes)?;
188    Ok(crate::outcome::Outcome::cached(snap, cache, stale))
189}
190
191fn parse_cache(bytes: &[u8]) -> Result<KimiSnapshot> {
192    let v: serde_json::Value = serde_json::from_slice(bytes)?;
193    let weekly_limit = parse_cache_u64(&v["weekly_limit"], "weekly_limit")?;
194    let weekly_used = parse_cache_u64(&v["weekly_used"], "weekly_used")?;
195    let weekly_remaining = parse_cache_u64(&v["weekly_remaining"], "weekly_remaining")?;
196    // Caches written before the monthly shape have no `has_weekly` key; they
197    // all carried weekly counters, so absence means the legacy shape.
198    let has_weekly = match &v["has_weekly"] {
199        serde_json::Value::Null => true,
200        serde_json::Value::Bool(b) => *b,
201        _ => return Err(AppError::Schema("kimi cache: invalid has_weekly".into())),
202    };
203    if !has_weekly && (weekly_limit > 0 || weekly_used > 0 || weekly_remaining > 0) {
204        // A combination the fetch path can never produce: the newer shape has
205        // no weekly counters, so nonzero ones here are a corrupt cache.
206        return Err(AppError::Schema(
207            "kimi cache: weekly counters on a monthly-shape snapshot".into(),
208        ));
209    }
210    let monthly_pct = parse_cache_monthly_pct(&v["monthly_pct"])?;
211    Ok(KimiSnapshot {
212        plan: v["plan"].as_str().map(|plan| {
213            if plan.starts_with("LEVEL_") {
214                humanize_membership_level(plan)
215            } else {
216                plan.to_string()
217            }
218        }),
219        weekly_limit,
220        weekly_used,
221        weekly_remaining,
222        weekly_reset_at: parse_cache_datetime(&v["weekly_reset_at"])?,
223        has_weekly,
224        monthly_pct,
225        monthly_reset_at: parse_cache_datetime(&v["monthly_reset_at"])?,
226        window_limit: parse_cache_u64(&v["window_limit"], "window_limit")?,
227        window_used: parse_cache_u64(&v["window_used"], "window_used")?,
228        window_remaining: parse_cache_u64(&v["window_remaining"], "window_remaining")?,
229        window_reset_at: parse_cache_datetime(&v["window_reset_at"])?,
230    })
231}
232
233fn cache_has_legacy_plan(bytes: &[u8]) -> bool {
234    serde_json::from_slice::<serde_json::Value>(bytes)
235        .ok()
236        .and_then(|value| value["plan"].as_str().map(str::to_owned))
237        .is_some_and(|plan| plan.starts_with("LEVEL_"))
238}
239
240fn parse_cache_u64(v: &serde_json::Value, name: &str) -> Result<u64> {
241    v.as_u64()
242        .ok_or_else(|| AppError::Schema(format!("kimi cache: invalid {name}")))
243}
244
245fn parse_cache_datetime(v: &serde_json::Value) -> Result<Option<DateTime<Utc>>> {
246    match v {
247        serde_json::Value::Null => Ok(None),
248        serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
249            .map(|dt| Some(dt.into()))
250            .map_err(|e| AppError::Schema(format!("kimi cache: invalid reset timestamp: {e}"))),
251        _ => Err(AppError::Schema(
252            "kimi cache: invalid reset timestamp".into(),
253        )),
254    }
255}
256
257/// A percentage the fetch path already clamped to 0..=100; anything else in
258/// the cache is corruption, not a quota.
259fn parse_cache_monthly_pct(v: &serde_json::Value) -> Result<Option<i32>> {
260    match v {
261        serde_json::Value::Null => Ok(None),
262        serde_json::Value::Number(n) => {
263            let pct = n
264                .as_i64()
265                .ok_or_else(|| AppError::Schema("kimi cache: invalid monthly_pct".into()))?;
266            if !(0..=100).contains(&pct) {
267                return Err(AppError::Schema(
268                    "kimi cache: monthly_pct out of range".into(),
269                ));
270            }
271            Ok(Some(pct as i32))
272        }
273        _ => Err(AppError::Schema("kimi cache: invalid monthly_pct".into())),
274    }
275}
276
277fn snap_to_json(snap: &KimiSnapshot) -> serde_json::Value {
278    serde_json::json!({
279        "plan": snap.plan,
280        "weekly_limit": snap.weekly_limit,
281        "weekly_used": snap.weekly_used,
282        "weekly_remaining": snap.weekly_remaining,
283        "weekly_reset_at": snap.weekly_reset_at.map(|dt| dt.to_rfc3339()),
284        "has_weekly": snap.has_weekly,
285        "monthly_pct": snap.monthly_pct,
286        "monthly_reset_at": snap.monthly_reset_at.map(|dt| dt.to_rfc3339()),
287        "window_limit": snap.window_limit,
288        "window_used": snap.window_used,
289        "window_remaining": snap.window_remaining,
290        "window_reset_at": snap.window_reset_at.map(|dt| dt.to_rfc3339()),
291    })
292}
293
294/// Resolve the bearer for this fetch. The API key is already one; a Kimi Code
295/// login may first need a refresh, which rotates the CLI's stored token pair
296/// and is therefore serialized against the CLI itself.
297async fn bearer_token(
298    client: &reqwest::Client,
299    endpoints: &Endpoints,
300    auth: &Auth,
301    now: DateTime<Utc>,
302) -> Result<String> {
303    let kimi_code = match auth {
304        Auth::ApiKey(key) => return Ok(key.clone()),
305        Auth::KimiCode(kimi_code) => kimi_code,
306    };
307
308    let creds = oauth::read_from(&kimi_code.credentials_path)?;
309    if !oauth::needs_refresh(creds.expires_at, now.timestamp()) {
310        return Ok(creds.access_token);
311    }
312
313    let _lock = super::lock::acquire(&kimi_code.lock_target).await?;
314    // Re-read under the lock: the CLI (or another ai-usagebar process) may
315    // have refreshed while we waited, and reusing our pre-lock copy would burn
316    // an already-rotated refresh token.
317    let creds = oauth::read_from(&kimi_code.credentials_path)?;
318    if !oauth::needs_refresh(creds.expires_at, now.timestamp()) {
319        return Ok(creds.access_token);
320    }
321
322    let refreshed = tokio::time::timeout(
323        REFRESH_TIMEOUT,
324        oauth::refresh(
325            client,
326            &endpoints.token,
327            oauth::CLIENT_ID,
328            &creds.refresh_token,
329        ),
330    )
331    .await
332    .map_err(|_| AppError::Transport(format!("kimi token refresh timeout: {}", endpoints.token)))?
333    .map_err(|e| match e {
334        AppError::Transport(msg) => AppError::Transport(msg),
335        e => AppError::Credentials(format!(
336            "Kimi Code CLI token refresh failed ({e}). Run `kimi` and log in again."
337        )),
338    })?;
339
340    let next = oauth::apply_refresh(&creds, refreshed, now.timestamp());
341    oauth::write_to(&kimi_code.credentials_path, &next).map_err(|e| {
342        // The rotation already happened upstream, so a failed write-back means
343        // the CLI is now holding a dead refresh token: say so plainly instead
344        // of leaving the user to discover it at their next `kimi` run.
345        AppError::Credentials(format!(
346            "the refreshed Kimi Code CLI credentials could not be saved ({e}); run `kimi` and log in again"
347        ))
348    })?;
349    Ok(next.access_token)
350}
351
352/// Ask `/coding/v1/me` for the subscription's own tier name ("Allegretto"),
353/// which is the only place the vendor spells its plans the way its pricing
354/// page does — `/usages` only carries the `LEVEL_*` wire enum.
355///
356/// Best-effort by construction: every failure returns `None` and leaves the
357/// humanized enum in place. A missing plan label must never cost the user
358/// their quota numbers, and this endpoint is documented (by kimi-code's own
359/// error text) to 404 for accounts without a coding profile.
360async fn plan_label(client: &reqwest::Client, url: &str, token: &str) -> Option<String> {
361    let resp = tokio::time::timeout(
362        HTTP_TIMEOUT,
363        client
364            .get(url)
365            .header("Authorization", format!("Bearer {token}"))
366            .header("Accept", "application/json")
367            .send(),
368    )
369    .await
370    .ok()?
371    .ok()?;
372    if !resp.status().is_success() {
373        return None;
374    }
375    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES)
376        .await
377        .ok()?;
378    serde_json::from_slice::<UserInfoResponse>(&bytes)
379        .ok()?
380        .plan_label()
381}
382
383async fn fetch_live(
384    client: &reqwest::Client,
385    endpoints: &Endpoints,
386    auth: &Auth,
387    now: DateTime<Utc>,
388) -> Result<KimiSnapshot> {
389    let url = &endpoints.usages;
390    let token = bearer_token(client, endpoints, auth, now).await?;
391    // Concurrent, not sequential: the plan label is a second request against
392    // the same deployment, and a widget tick should cost one round-trip's
393    // latency, not two.
394    let (usages, label) = tokio::join!(
395        tokio::time::timeout(
396            HTTP_TIMEOUT,
397            client
398                .get(url)
399                .header("Authorization", format!("Bearer {token}"))
400                .header("Accept", "application/json")
401                .send(),
402        ),
403        plan_label(client, &endpoints.me, &token),
404    );
405    let resp = usages.map_err(|_| AppError::Transport(format!("kimi timeout: {url}")))??;
406
407    let status = resp.status();
408
409    if !status.is_success() {
410        // Never surface upstream/proxy bodies: they can contain credentials or
411        // arbitrary markup. Keep the cached diagnostic useful but generic.
412        let body = if matches!(status.as_u16(), 401 | 403) {
413            "Kimi authentication failed".into()
414        } else {
415            format!("Kimi API returned HTTP {}", status.as_u16())
416        };
417        return Err(AppError::Http {
418            status: status.as_u16(),
419            body,
420        });
421    }
422
423    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
424    let r: UsagesResponse = serde_json::from_slice(&bytes)
425        .map_err(|e| AppError::Schema(format!("kimi usages response: {e}")))?;
426    let mut snap = r.into_snapshot()?;
427    if let Some(label) = label {
428        snap.plan = Some(label);
429    }
430    Ok(snap)
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use tempfile::TempDir;
437
438    fn cache_fixture() -> (TempDir, Cache) {
439        let td = TempDir::new().unwrap();
440        let cache = Cache::at(td.path().join("kimi"));
441        cache.ensure_dir().unwrap();
442        (td, cache)
443    }
444
445    /// Both endpoints point at the same mock server, so an OAuth test can
446    /// serve `/api/oauth/token` and `/coding/v1/usages` from one mockito.
447    fn test_endpoints(base: &str) -> Endpoints {
448        Endpoints {
449            usages: format!("{base}/coding/v1/usages"),
450            me: format!("{base}/coding/v1/me"),
451            token: format!("{base}/api/oauth/token"),
452        }
453    }
454
455    fn sample_json() -> &'static str {
456        r#"{
457            "user": { "membership": { "level": "LEVEL_INTERMEDIATE" } },
458            "usage": { "limit": "100", "used": "26", "remaining": "74", "resetTime": "2026-02-11T17:32:50.757941Z" },
459            "limits": [
460                {
461                    "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
462                    "detail": { "limit": "100", "used": "15", "remaining": "85", "resetTime": "2026-02-07T12:32:50.757941Z" }
463                }
464            ]
465        }"#
466    }
467
468    fn sample_seed() -> serde_json::Value {
469        serde_json::json!({
470            "plan": "LEVEL_INTERMEDIATE",
471            "weekly_limit": 100,
472            "weekly_used": 30,
473            "weekly_remaining": 70,
474            "weekly_reset_at": "2026-02-11T17:32:50.757941Z",
475            "window_limit": 100,
476            "window_used": 20,
477            "window_remaining": 80,
478            "window_reset_at": "2026-02-07T12:32:50.757941Z"
479        })
480    }
481
482    #[tokio::test]
483    async fn live_200_returns_snapshot_and_sends_headers() {
484        let mut server = mockito::Server::new_async().await;
485        let m = server
486            .mock("GET", "/coding/v1/usages")
487            .with_status(200)
488            .with_body(sample_json())
489            .match_header("authorization", "Bearer sk-test")
490            .match_header("accept", "application/json")
491            .create_async()
492            .await;
493
494        let (_td, cache) = cache_fixture();
495        let client = reqwest::Client::new();
496        let endpoints = test_endpoints(&server.url());
497        let out = fetch_snapshot(
498            &client,
499            "sk-test",
500            &cache,
501            &endpoints,
502            Duration::from_secs(0),
503        )
504        .await
505        .unwrap();
506        m.assert_async().await;
507        // No /me mock on this server, so the humanized wire enum stands.
508        assert_eq!(out.snapshot.plan, Some("Intermediate".into()));
509        assert_eq!(out.snapshot.weekly_limit, 100);
510        assert_eq!(out.snapshot.weekly_used, 26);
511        assert_eq!(out.snapshot.weekly_remaining, 74);
512        assert_eq!(out.snapshot.window_limit, 100);
513        assert_eq!(out.snapshot.window_used, 15);
514        assert!(!out.stale);
515    }
516
517    #[tokio::test]
518    async fn http_401_falls_back_to_cache() {
519        let mut server = mockito::Server::new_async().await;
520        server
521            .mock("GET", "/coding/v1/usages")
522            .with_status(401)
523            .with_body(r#"{"error": "invalid api key"}"#)
524            .create_async()
525            .await;
526
527        let (_td, cache) = cache_fixture();
528        cache
529            .write_payload(sample_seed().to_string().as_bytes())
530            .unwrap();
531
532        let client = reqwest::Client::new();
533        let endpoints = test_endpoints(&server.url());
534        let out = fetch_snapshot(
535            &client,
536            "bad-key",
537            &cache,
538            &endpoints,
539            Duration::from_secs(0),
540        )
541        .await
542        .unwrap();
543        assert!(out.stale);
544        assert_eq!(out.snapshot.weekly_used, 30);
545        assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
546    }
547
548    #[tokio::test]
549    async fn http_500_falls_back_to_cache() {
550        let mut server = mockito::Server::new_async().await;
551        server
552            .mock("GET", "/coding/v1/usages")
553            .with_status(500)
554            .with_body(r#"{"error": "internal server error"}"#)
555            .create_async()
556            .await;
557
558        let (_td, cache) = cache_fixture();
559        cache
560            .write_payload(sample_seed().to_string().as_bytes())
561            .unwrap();
562
563        let client = reqwest::Client::new();
564        let endpoints = test_endpoints(&server.url());
565        let out = fetch_snapshot(
566            &client,
567            "sk-test",
568            &cache,
569            &endpoints,
570            Duration::from_secs(0),
571        )
572        .await
573        .unwrap();
574        assert!(out.stale);
575        assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(500));
576    }
577
578    #[tokio::test]
579    async fn http_401_without_cache_returns_http_error() {
580        let mut server = mockito::Server::new_async().await;
581        server
582            .mock("GET", "/coding/v1/usages")
583            .with_status(401)
584            .with_body(r#"{"error": "invalid api key"}"#)
585            .create_async()
586            .await;
587
588        let (_td, cache) = cache_fixture();
589        let client = reqwest::Client::new();
590        let endpoints = test_endpoints(&server.url());
591        let err = fetch_snapshot(
592            &client,
593            "bad-key",
594            &cache,
595            &endpoints,
596            Duration::from_secs(0),
597        )
598        .await
599        .unwrap_err();
600        match err {
601            AppError::Http { status, .. } => assert_eq!(status, 401),
602            other => panic!("expected Http 401, got {other:?}"),
603        }
604    }
605
606    #[tokio::test]
607    async fn malformed_numeric_200_returns_schema_error() {
608        let mut server = mockito::Server::new_async().await;
609        server
610            .mock("GET", "/coding/v1/usages")
611            .with_status(200)
612            .with_body(r#"{"usage": {"limit": "100", "used": "garbage"}}"#)
613            .create_async()
614            .await;
615
616        let (_td, cache) = cache_fixture();
617        let client = reqwest::Client::new();
618        let endpoints = test_endpoints(&server.url());
619        let err = fetch_snapshot(
620            &client,
621            "sk-test",
622            &cache,
623            &endpoints,
624            Duration::from_secs(0),
625        )
626        .await
627        .unwrap_err();
628        assert!(
629            err.to_string().contains("used") || err.to_string().contains("Schema"),
630            "expected schema error, got {err}"
631        );
632    }
633
634    #[tokio::test]
635    async fn malformed_numeric_200_with_seeded_cache_returns_stale_snapshot_and_preserves_cache() {
636        let mut server = mockito::Server::new_async().await;
637        server
638            .mock("GET", "/coding/v1/usages")
639            .with_status(200)
640            .with_body(r#"{"usage": {"limit": "100", "used": "garbage"}}"#)
641            .create_async()
642            .await;
643
644        let (_td, cache) = cache_fixture();
645        let seeded = sample_seed().to_string();
646        cache.write_payload(seeded.as_bytes()).unwrap();
647
648        let client = reqwest::Client::new();
649        let endpoints = test_endpoints(&server.url());
650        let out = fetch_snapshot(
651            &client,
652            "sk-test",
653            &cache,
654            &endpoints,
655            Duration::from_secs(0),
656        )
657        .await
658        .unwrap();
659
660        assert!(out.stale);
661        assert_eq!(out.snapshot.weekly_used, 30);
662        assert_eq!(out.snapshot.window_used, 20);
663        assert_eq!(out.last_error, Some((0, SCHEMA_DRIFT_MESSAGE.into())));
664
665        // The payload file must still contain the original seeded snapshot.
666        let payload = std::fs::read_to_string(cache.payload_path()).unwrap();
667        assert_eq!(payload, seeded);
668    }
669
670    #[tokio::test]
671    async fn error_object_200_returns_schema_error() {
672        let mut server = mockito::Server::new_async().await;
673        server
674            .mock("GET", "/coding/v1/usages")
675            .with_status(200)
676            .with_body(r#"{"error": "invalid token"}"#)
677            .create_async()
678            .await;
679
680        let (_td, cache) = cache_fixture();
681        let client = reqwest::Client::new();
682        let endpoints = test_endpoints(&server.url());
683        let err = fetch_snapshot(
684            &client,
685            "sk-test",
686            &cache,
687            &endpoints,
688            Duration::from_secs(0),
689        )
690        .await
691        .unwrap_err();
692        assert!(err.to_string().contains("usage block"), "got {err}");
693    }
694
695    #[tokio::test]
696    async fn corrupt_fresh_cache_ignored() {
697        let mut server = mockito::Server::new_async().await;
698        server
699            .mock("GET", "/coding/v1/usages")
700            .with_status(200)
701            .with_body(sample_json())
702            .create_async()
703            .await;
704
705        let (_td, cache) = cache_fixture();
706        cache.write_payload(b"not valid json".as_slice()).unwrap();
707
708        let client = reqwest::Client::new();
709        let endpoints = test_endpoints(&server.url());
710        let out = fetch_snapshot(
711            &client,
712            "sk-test",
713            &cache,
714            &endpoints,
715            Duration::from_secs(60),
716        )
717        .await
718        .unwrap();
719        assert_eq!(out.snapshot.weekly_used, 26);
720        assert!(!out.stale);
721    }
722
723    #[tokio::test]
724    async fn a_fresh_legacy_plan_cache_is_upgraded_through_the_profile_endpoint() {
725        let mut server = mockito::Server::new_async().await;
726        let usages = server
727            .mock("GET", "/coding/v1/usages")
728            .with_status(200)
729            .with_body(sample_json())
730            .create_async()
731            .await;
732        let me = server
733            .mock("GET", "/coding/v1/me")
734            .with_status(200)
735            .with_body(r#"{"user_level_name":"Allegretto"}"#)
736            .create_async()
737            .await;
738
739        let (_td, cache) = cache_fixture();
740        cache
741            .write_payload(sample_seed().to_string().as_bytes())
742            .unwrap();
743
744        let out = fetch_snapshot(
745            &reqwest::Client::new(),
746            "sk-test",
747            &cache,
748            &test_endpoints(&server.url()),
749            Duration::from_secs(60),
750        )
751        .await
752        .unwrap();
753
754        usages.assert_async().await;
755        me.assert_async().await;
756        assert_eq!(out.snapshot.plan, Some("Allegretto".into()));
757        let cached: serde_json::Value =
758            serde_json::from_slice(&std::fs::read(cache.payload_path()).unwrap()).unwrap();
759        assert_eq!(cached["plan"], "Allegretto");
760    }
761
762    #[test]
763    fn a_legacy_plan_is_humanized_when_only_fallback_cache_is_available() {
764        let bytes = sample_seed().to_string();
765        let snap = parse_cache(bytes.as_bytes()).unwrap();
766        assert_eq!(snap.plan, Some("Intermediate".into()));
767    }
768
769    #[tokio::test]
770    async fn corrupt_stale_cache_returns_error() {
771        let mut server = mockito::Server::new_async().await;
772        server
773            .mock("GET", "/coding/v1/usages")
774            .with_status(401)
775            .with_body(r#"{"error": "invalid api key"}"#)
776            .create_async()
777            .await;
778
779        let (_td, cache) = cache_fixture();
780        cache.write_payload(b"not valid json".as_slice()).unwrap();
781
782        let client = reqwest::Client::new();
783        let endpoints = test_endpoints(&server.url());
784        let err = fetch_snapshot(
785            &client,
786            "bad-key",
787            &cache,
788            &endpoints,
789            Duration::from_secs(0),
790        )
791        .await
792        .unwrap_err();
793        assert!(
794            matches!(err, AppError::Http { status, .. } if status == 401),
795            "expected 401, got {err:?}"
796        );
797    }
798
799    #[tokio::test]
800    async fn transport_error_with_stale_cache_uses_cache() {
801        // Use a URL that will not resolve to trigger a transport error.
802        let (_td, cache) = cache_fixture();
803        cache
804            .write_payload(sample_seed().to_string().as_bytes())
805            .unwrap();
806
807        let client = reqwest::Client::new();
808        let endpoints = test_endpoints("http://localhost:1");
809        let out = fetch_snapshot(
810            &client,
811            "sk-test",
812            &cache,
813            &endpoints,
814            Duration::from_secs(0),
815        )
816        .await
817        .unwrap();
818        assert!(out.stale);
819        assert_eq!(out.snapshot.weekly_used, 30);
820    }
821
822    #[tokio::test]
823    async fn missing_counters_with_seeded_cache_preserves_snapshot() {
824        let mut server = mockito::Server::new_async().await;
825        server
826            .mock("GET", "/coding/v1/usages")
827            .with_status(200)
828            .with_body(r#"{"usage":{"limit":100}}"#)
829            .create_async()
830            .await;
831        let (_td, cache) = cache_fixture();
832        let seeded = sample_seed().to_string();
833        cache.write_payload(seeded.as_bytes()).unwrap();
834        let out = fetch_snapshot(
835            &reqwest::Client::new(),
836            "sk-test",
837            &cache,
838            &test_endpoints(&server.url()),
839            Duration::ZERO,
840        )
841        .await
842        .unwrap();
843        assert!(out.stale);
844        assert_eq!(out.snapshot.weekly_used, 30);
845        assert_eq!(
846            std::fs::read_to_string(cache.payload_path()).unwrap(),
847            seeded
848        );
849    }
850
851    #[tokio::test]
852    async fn unrecognized_window_with_seeded_cache_preserves_snapshot() {
853        let mut server = mockito::Server::new_async().await;
854        server.mock("GET", "/coding/v1/usages").with_status(200)
855            .with_body(r#"{"usage":{"limit":100,"used":10},"limits":[{"window":{"duration":4,"timeUnit":"TIME_UNIT_HOUR"},"detail":{"limit":100,"used":10}}]}"#).create_async().await;
856        let (_td, cache) = cache_fixture();
857        let seeded = sample_seed().to_string();
858        cache.write_payload(seeded.as_bytes()).unwrap();
859        let out = fetch_snapshot(
860            &reqwest::Client::new(),
861            "sk-test",
862            &cache,
863            &test_endpoints(&server.url()),
864            Duration::ZERO,
865        )
866        .await
867        .unwrap();
868        assert!(out.stale);
869        assert_eq!(out.snapshot.window_used, 20);
870        assert_eq!(
871            std::fs::read_to_string(cache.payload_path()).unwrap(),
872            seeded
873        );
874    }
875
876    #[tokio::test]
877    async fn http_error_body_is_redacted() {
878        let mut server = mockito::Server::new_async().await;
879        server
880            .mock("GET", "/coding/v1/usages")
881            .with_status(500)
882            .with_body("proxy secret: <token>")
883            .create_async()
884            .await;
885        let (_td, cache) = cache_fixture();
886        let err = fetch_snapshot(
887            &reqwest::Client::new(),
888            "sk-test",
889            &cache,
890            &test_endpoints(&server.url()),
891            Duration::ZERO,
892        )
893        .await
894        .unwrap_err();
895        assert!(
896            matches!(err, AppError::Http { status: 500, ref body } if body == "Kimi API returned HTTP 500")
897        );
898    }
899
900    // ---- Kimi Code CLI (subscription) credential path ----
901
902    /// A kimi-code home with a stored login. `expires_at` is relative to
903    /// `NOW_SECS`, the instant every OAuth test below passes in.
904    const NOW_SECS: i64 = 1_800_000_000;
905
906    fn kimi_code_home(td: &TempDir, expires_in: i64) -> (PathBuf, KimiCodeAuth) {
907        let home = td.path().join(".kimi-code");
908        let auth = KimiCodeAuth::in_home(&home);
909        std::fs::create_dir_all(auth.credentials_path.parent().unwrap()).unwrap();
910        std::fs::write(
911            &auth.credentials_path,
912            serde_json::json!({
913                "access_token": "cli-at",
914                "refresh_token": "cli-rt",
915                "expires_at": NOW_SECS + expires_in,
916                "expires_in": 900,
917                "scope": "kimi-code",
918                "token_type": "Bearer",
919            })
920            .to_string(),
921        )
922        .unwrap();
923        (home, auth)
924    }
925
926    fn now() -> DateTime<Utc> {
927        DateTime::from_timestamp(NOW_SECS, 0).unwrap()
928    }
929
930    #[tokio::test]
931    async fn a_valid_cli_token_is_used_as_is_and_never_refreshed() {
932        let mut server = mockito::Server::new_async().await;
933        let usages = server
934            .mock("GET", "/coding/v1/usages")
935            .match_header("authorization", "Bearer cli-at")
936            .with_status(200)
937            .with_body(sample_json())
938            .create_async()
939            .await;
940        let refresh = server
941            .mock("POST", "/api/oauth/token")
942            .expect(0)
943            .create_async()
944            .await;
945
946        let (td, cache) = cache_fixture();
947        let (_home, auth) = kimi_code_home(&td, 600);
948        let out = fetch_snapshot_at(
949            &reqwest::Client::new(),
950            &Auth::KimiCode(auth.clone()),
951            &cache,
952            &test_endpoints(&server.url()),
953            Duration::ZERO,
954            now(),
955        )
956        .await
957        .unwrap();
958
959        usages.assert_async().await;
960        refresh.assert_async().await;
961        assert_eq!(out.snapshot.weekly_used, 26);
962        let stored = std::fs::read_to_string(&auth.credentials_path).unwrap();
963        assert!(stored.contains("cli-rt"), "an unused token must not rotate");
964    }
965
966    #[tokio::test]
967    async fn an_expiring_cli_token_is_refreshed_and_the_rotation_is_written_back() {
968        let mut server = mockito::Server::new_async().await;
969        let refresh = server
970            .mock("POST", "/api/oauth/token")
971            .match_body(mockito::Matcher::UrlEncoded(
972                "refresh_token".into(),
973                "cli-rt".into(),
974            ))
975            .with_status(200)
976            .with_body(
977                r#"{"access_token":"fresh-at","refresh_token":"fresh-rt","expires_in":900,
978                    "scope":"kimi-code","token_type":"Bearer"}"#,
979            )
980            .create_async()
981            .await;
982        let usages = server
983            .mock("GET", "/coding/v1/usages")
984            .match_header("authorization", "Bearer fresh-at")
985            .with_status(200)
986            .with_body(sample_json())
987            .create_async()
988            .await;
989
990        let (td, cache) = cache_fixture();
991        // Inside the refresh buffer: still valid, but not for long enough.
992        let (home, auth) = kimi_code_home(&td, 30);
993        let out = fetch_snapshot_at(
994            &reqwest::Client::new(),
995            &Auth::KimiCode(auth.clone()),
996            &cache,
997            &test_endpoints(&server.url()),
998            Duration::ZERO,
999            now(),
1000        )
1001        .await
1002        .unwrap();
1003
1004        refresh.assert_async().await;
1005        usages.assert_async().await;
1006        assert_eq!(out.snapshot.weekly_used, 26);
1007
1008        let stored: serde_json::Value =
1009            serde_json::from_slice(&std::fs::read(&auth.credentials_path).unwrap()).unwrap();
1010        assert_eq!(stored["access_token"], "fresh-at");
1011        assert_eq!(
1012            stored["refresh_token"], "fresh-rt",
1013            "the CLI's own store must carry the rotated token, or its next run is dead"
1014        );
1015        assert_eq!(stored["expires_at"], NOW_SECS + 900);
1016        // The lock is the CLI's own target, and it must not be left behind.
1017        assert!(!super::super::lock::lock_dir_for(&auth.lock_target).exists());
1018        assert!(home.join("oauth").is_dir());
1019    }
1020
1021    #[tokio::test]
1022    async fn a_rejected_refresh_reports_a_credential_error_naming_the_cli() {
1023        let mut server = mockito::Server::new_async().await;
1024        server
1025            .mock("POST", "/api/oauth/token")
1026            .with_status(401)
1027            .with_body(r#"{"error":"invalid_grant"}"#)
1028            .create_async()
1029            .await;
1030
1031        let (td, cache) = cache_fixture();
1032        let (_home, auth) = kimi_code_home(&td, -60);
1033        let err = fetch_snapshot_at(
1034            &reqwest::Client::new(),
1035            &Auth::KimiCode(auth),
1036            &cache,
1037            &test_endpoints(&server.url()),
1038            Duration::ZERO,
1039            now(),
1040        )
1041        .await
1042        .unwrap_err();
1043        let message = err.to_string();
1044        assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
1045        assert!(message.contains("log in again"), "{message}");
1046    }
1047
1048    #[tokio::test]
1049    async fn a_logged_out_cli_falls_back_to_cache_with_a_credential_warning() {
1050        let (td, cache) = cache_fixture();
1051        let (_home, auth) = kimi_code_home(&td, 600);
1052        std::fs::write(
1053            &auth.credentials_path,
1054            r#"{"access_token":"","refresh_token":"","expires_at":0}"#,
1055        )
1056        .unwrap();
1057        cache
1058            .write_payload(sample_seed().to_string().as_bytes())
1059            .unwrap();
1060
1061        let out = fetch_snapshot_at(
1062            &reqwest::Client::new(),
1063            &Auth::KimiCode(auth),
1064            &cache,
1065            &test_endpoints("http://localhost:1"),
1066            Duration::ZERO,
1067            now(),
1068        )
1069        .await
1070        .unwrap();
1071        assert!(out.stale);
1072        assert_eq!(out.snapshot.weekly_used, 30);
1073        let (code, message) = out.last_error.unwrap();
1074        assert_eq!(code, 0);
1075        assert!(message.contains("logged out"), "{message}");
1076    }
1077
1078    #[tokio::test]
1079    async fn a_peer_refresh_during_the_wait_is_picked_up_instead_of_rotating_again() {
1080        let mut server = mockito::Server::new_async().await;
1081        let refresh = server
1082            .mock("POST", "/api/oauth/token")
1083            .expect(0)
1084            .create_async()
1085            .await;
1086        let usages = server
1087            .mock("GET", "/coding/v1/usages")
1088            .match_header("authorization", "Bearer peer-at")
1089            .with_status(200)
1090            .with_body(sample_json())
1091            .create_async()
1092            .await;
1093
1094        let (td, cache) = cache_fixture();
1095        let (_home, auth) = kimi_code_home(&td, -60);
1096        // Stand in for the CLI finishing its own refresh while we queued: the
1097        // re-read under the lock must win over the copy read before it.
1098        let peer = serde_json::json!({
1099            "access_token": "peer-at",
1100            "refresh_token": "peer-rt",
1101            "expires_at": NOW_SECS + 900,
1102            "expires_in": 900,
1103            "scope": "kimi-code",
1104            "token_type": "Bearer",
1105        });
1106        std::fs::write(&auth.credentials_path, peer.to_string()).unwrap();
1107
1108        let out = fetch_snapshot_at(
1109            &reqwest::Client::new(),
1110            &Auth::KimiCode(auth),
1111            &cache,
1112            &test_endpoints(&server.url()),
1113            Duration::ZERO,
1114            now(),
1115        )
1116        .await
1117        .unwrap();
1118        refresh.assert_async().await;
1119        usages.assert_async().await;
1120        assert_eq!(out.snapshot.weekly_used, 26);
1121    }
1122
1123    #[test]
1124    fn endpoints_follow_the_region() {
1125        let cn = Endpoints::for_region(Region::MainlandCn);
1126        assert_eq!(cn.usages, "https://api.kimi.com/coding/v1/usages");
1127        assert_eq!(cn.token, "https://auth.kimi.com/api/oauth/token");
1128        let global = Endpoints::for_region(Region::Global);
1129        assert_eq!(global.usages, "https://api.kimi.ai/coding/v1/usages");
1130        assert_eq!(global.token, "https://auth.kimi.ai/api/oauth/token");
1131        // The default stays the endpoint the API-key path has always used.
1132        assert_eq!(Endpoints::default().usages, cn.usages);
1133    }
1134
1135    #[tokio::test]
1136    async fn the_vendors_own_tier_name_replaces_the_wire_enum() {
1137        let mut server = mockito::Server::new_async().await;
1138        server
1139            .mock("GET", "/coding/v1/usages")
1140            .with_status(200)
1141            .with_body(sample_json())
1142            .create_async()
1143            .await;
1144        let me = server
1145            .mock("GET", "/coding/v1/me")
1146            .match_header("authorization", "Bearer sk-test")
1147            .with_status(200)
1148            .with_body(r#"{"user_id":"u-1","user_level":25,"user_level_name":"Allegretto"}"#)
1149            .create_async()
1150            .await;
1151
1152        let (_td, cache) = cache_fixture();
1153        let out = fetch_snapshot(
1154            &reqwest::Client::new(),
1155            "sk-test",
1156            &cache,
1157            &test_endpoints(&server.url()),
1158            Duration::ZERO,
1159        )
1160        .await
1161        .unwrap();
1162        me.assert_async().await;
1163        assert_eq!(out.snapshot.plan, Some("Allegretto".into()));
1164        // …and it survives the cache round-trip, not just the live fetch.
1165        let cached: serde_json::Value =
1166            serde_json::from_slice(&std::fs::read(cache.payload_path()).unwrap()).unwrap();
1167        assert_eq!(cached["plan"], "Allegretto");
1168    }
1169
1170    #[tokio::test]
1171    async fn a_profile_endpoint_that_fails_costs_the_label_and_nothing_else() {
1172        // 404 is documented by kimi-code's own error text for accounts with no
1173        // coding profile; the quota numbers must still come through.
1174        for status in [404, 401, 500] {
1175            let mut server = mockito::Server::new_async().await;
1176            server
1177                .mock("GET", "/coding/v1/usages")
1178                .with_status(200)
1179                .with_body(sample_json())
1180                .create_async()
1181                .await;
1182            server
1183                .mock("GET", "/coding/v1/me")
1184                .with_status(status)
1185                .with_body(r#"{"error":"nope"}"#)
1186                .create_async()
1187                .await;
1188
1189            let (_td, cache) = cache_fixture();
1190            let out = fetch_snapshot(
1191                &reqwest::Client::new(),
1192                "sk-test",
1193                &cache,
1194                &test_endpoints(&server.url()),
1195                Duration::ZERO,
1196            )
1197            .await
1198            .unwrap();
1199            assert_eq!(out.snapshot.plan, Some("Intermediate".into()), "{status}");
1200            assert_eq!(out.snapshot.weekly_used, 26, "{status}");
1201            assert!(out.last_error.is_none(), "{status}: must not warn");
1202        }
1203    }
1204
1205    #[tokio::test]
1206    async fn an_unreachable_profile_endpoint_does_not_fail_the_fetch() {
1207        let mut server = mockito::Server::new_async().await;
1208        server
1209            .mock("GET", "/coding/v1/usages")
1210            .with_status(200)
1211            .with_body(sample_json())
1212            .create_async()
1213            .await;
1214        let mut endpoints = test_endpoints(&server.url());
1215        endpoints.me = "http://localhost:1/coding/v1/me".into();
1216
1217        let (_td, cache) = cache_fixture();
1218        let out = fetch_snapshot(
1219            &reqwest::Client::new(),
1220            "sk-test",
1221            &cache,
1222            &endpoints,
1223            Duration::ZERO,
1224        )
1225        .await
1226        .unwrap();
1227        assert_eq!(out.snapshot.plan, Some("Intermediate".into()));
1228        assert!(!out.stale);
1229    }
1230
1231    #[test]
1232    fn a_relocated_credential_file_keeps_the_homes_lock_target() {
1233        let home = Path::new("/home/u/.kimi-code");
1234        let auth =
1235            KimiCodeAuth::with_credentials_path(home, PathBuf::from("/elsewhere/kimi-code.json"));
1236        assert_eq!(
1237            auth.credentials_path,
1238            PathBuf::from("/elsewhere/kimi-code.json")
1239        );
1240        assert_eq!(auth.lock_target, oauth::lock_target_in(home));
1241    }
1242
1243    fn monthly_shape_snap() -> KimiSnapshot {
1244        KimiSnapshot {
1245            plan: Some("Allegretto".into()),
1246            weekly_limit: 0,
1247            weekly_used: 0,
1248            weekly_remaining: 0,
1249            weekly_reset_at: None,
1250            has_weekly: false,
1251            monthly_pct: Some(42),
1252            monthly_reset_at: Some(
1253                DateTime::parse_from_rfc3339("2026-10-16T00:00:00Z")
1254                    .unwrap()
1255                    .into(),
1256            ),
1257            window_limit: 100,
1258            window_used: 15,
1259            window_remaining: 85,
1260            window_reset_at: Some(
1261                DateTime::parse_from_rfc3339("2026-09-16T20:11:32Z")
1262                    .unwrap()
1263                    .into(),
1264            ),
1265        }
1266    }
1267
1268    #[test]
1269    fn a_monthly_shape_snapshot_survives_the_cache_round_trip() {
1270        let bytes = serde_json::to_vec(&snap_to_json(&monthly_shape_snap())).unwrap();
1271        let snap = parse_cache(&bytes).unwrap();
1272        assert!(!snap.has_weekly);
1273        assert_eq!(snap.monthly_pct, Some(42));
1274        assert_eq!(
1275            snap.monthly_reset_at.map(|dt| dt.to_rfc3339()),
1276            Some("2026-10-16T00:00:00+00:00".to_string())
1277        );
1278        assert_eq!(snap.weekly_used, 0);
1279        assert_eq!(snap.window_used, 15);
1280        assert_eq!(snap, monthly_shape_snap());
1281    }
1282
1283    #[test]
1284    fn a_legacy_cache_without_the_new_keys_still_parses() {
1285        // sample_seed predates the monthly shape: no has_weekly/monthly keys.
1286        let bytes = sample_seed().to_string();
1287        let snap = parse_cache(bytes.as_bytes()).unwrap();
1288        assert!(snap.has_weekly);
1289        assert_eq!(snap.monthly_pct, None);
1290        assert_eq!(snap.monthly_reset_at, None);
1291        assert_eq!(snap.weekly_used, 30);
1292    }
1293
1294    #[test]
1295    fn a_cache_with_weekly_counters_on_a_monthly_shape_is_rejected() {
1296        let mut v = sample_seed();
1297        v["has_weekly"] = serde_json::json!(false);
1298        let err = parse_cache(v.to_string().as_bytes()).unwrap_err();
1299        assert!(err.to_string().contains("monthly-shape"), "{err}");
1300    }
1301
1302    #[test]
1303    fn a_cache_with_an_out_of_range_monthly_pct_is_rejected() {
1304        for bad in [150, -1] {
1305            let mut v: serde_json::Value = serde_json::from_slice(
1306                &serde_json::to_vec(&snap_to_json(&monthly_shape_snap())).unwrap(),
1307            )
1308            .unwrap();
1309            v["monthly_pct"] = serde_json::json!(bad);
1310            let err = parse_cache(v.to_string().as_bytes()).unwrap_err();
1311            assert!(err.to_string().contains("monthly_pct"), "{bad}: {err}");
1312        }
1313    }
1314}