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