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