1use 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);
23pub const SCHEMA_DRIFT_MESSAGE: &str = "Kimi API schema drift";
26
27#[derive(Debug, Clone)]
28pub struct Endpoints {
29 pub usages: String,
30 pub me: String,
32 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#[derive(Debug, Clone)]
55pub enum Auth {
56 ApiKey(String),
58 KimiCode(KimiCodeAuth),
60}
61
62#[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 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
90pub type FetchOutcome = crate::outcome::Outcome<KimiSnapshot>;
93
94pub 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
123async 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 if !cache_has_legacy_plan(&bytes)
143 && let Ok(outcome) = reuse_cache(bytes, cache, false)
144 {
145 return Ok(outcome);
146 }
147 }
148 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 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 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 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
257fn 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
294fn usable_access_token(creds: &oauth::Credentials, now: DateTime<Utc>) -> Option<String> {
307 if oauth::needs_refresh(creds.expires_at, now.timestamp()) {
308 return None;
309 }
310 if creds.access_token.trim().is_empty() {
311 return None;
312 }
313 Some(creds.access_token.clone())
314}
315
316async fn bearer_token(
318 client: &reqwest::Client,
319 endpoints: &Endpoints,
320 auth: &Auth,
321 now: DateTime<Utc>,
322) -> Result<String> {
323 let kimi_code = match auth {
324 Auth::ApiKey(key) => return Ok(key.clone()),
325 Auth::KimiCode(kimi_code) => kimi_code,
326 };
327
328 let creds = oauth::read_from(&kimi_code.credentials_path)?;
329 if let Some(token) = usable_access_token(&creds, now) {
330 return Ok(token);
331 }
332
333 let _lock = super::lock::acquire(&kimi_code.lock_target).await?;
334 let creds = oauth::read_from(&kimi_code.credentials_path)?;
338 if let Some(token) = usable_access_token(&creds, now) {
339 return Ok(token);
340 }
341
342 let refreshed = tokio::time::timeout(
343 REFRESH_TIMEOUT,
344 oauth::refresh(
345 client,
346 &endpoints.token,
347 oauth::CLIENT_ID,
348 &creds.refresh_token,
349 ),
350 )
351 .await
352 .map_err(|_| AppError::Transport(format!("kimi token refresh timeout: {}", endpoints.token)))?
353 .map_err(|e| match e {
354 AppError::Transport(msg) => AppError::Transport(msg),
355 e => AppError::Credentials(format!(
356 "Kimi Code CLI token refresh failed ({e}). Run `kimi` and log in again."
357 )),
358 })?;
359
360 let next = oauth::apply_refresh(&creds, refreshed, now.timestamp());
361 oauth::write_to(&kimi_code.credentials_path, &next).map_err(|e| {
362 AppError::Credentials(format!(
366 "the refreshed Kimi Code CLI credentials could not be saved ({e}); run `kimi` and log in again"
367 ))
368 })?;
369 Ok(next.access_token)
370}
371
372async fn plan_label(client: &reqwest::Client, url: &str, token: &str) -> Option<String> {
381 let resp = tokio::time::timeout(
382 HTTP_TIMEOUT,
383 client
384 .get(url)
385 .header("Authorization", format!("Bearer {token}"))
386 .header("Accept", "application/json")
387 .send(),
388 )
389 .await
390 .ok()?
391 .ok()?;
392 if !resp.status().is_success() {
393 return None;
394 }
395 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES)
396 .await
397 .ok()?;
398 serde_json::from_slice::<UserInfoResponse>(&bytes)
399 .ok()?
400 .plan_label()
401}
402
403async fn fetch_live(
404 client: &reqwest::Client,
405 endpoints: &Endpoints,
406 auth: &Auth,
407 now: DateTime<Utc>,
408) -> Result<KimiSnapshot> {
409 let url = &endpoints.usages;
410 let token = bearer_token(client, endpoints, auth, now).await?;
411 let (usages, label) = tokio::join!(
415 tokio::time::timeout(
416 HTTP_TIMEOUT,
417 client
418 .get(url)
419 .header("Authorization", format!("Bearer {token}"))
420 .header("Accept", "application/json")
421 .send(),
422 ),
423 plan_label(client, &endpoints.me, &token),
424 );
425 let resp = usages.map_err(|_| AppError::Transport(format!("kimi timeout: {url}")))??;
426
427 let status = resp.status();
428
429 if !status.is_success() {
430 let body = if matches!(status.as_u16(), 401 | 403) {
433 "Kimi authentication failed".into()
434 } else {
435 format!("Kimi API returned HTTP {}", status.as_u16())
436 };
437 return Err(AppError::Http {
438 status: status.as_u16(),
439 body,
440 });
441 }
442
443 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
444 let r: UsagesResponse = serde_json::from_slice(&bytes)
445 .map_err(|e| AppError::Schema(format!("kimi usages response: {e}")))?;
446 let mut snap = r.into_snapshot()?;
447 if let Some(label) = label {
448 snap.plan = Some(label);
449 }
450 Ok(snap)
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 #[test]
461 fn a_blank_access_token_is_refreshed_rather_than_sent() {
462 let now = chrono::Utc::now();
463 let creds = |access: &str, expires_at: i64| crate::kimi::oauth::Credentials {
464 access_token: access.to_string(),
465 refresh_token: "rt".to_string(),
466 expires_at,
467 expires_in: 900,
468 scope: String::new(),
469 token_type: "Bearer".to_string(),
470 extra: serde_json::Map::new(),
471 };
472 let live = now.timestamp() + 3_600;
473 let expired = now.timestamp() - 3_600;
474
475 assert_eq!(
477 usable_access_token(&creds("at", live), now).as_deref(),
478 Some("at")
479 );
480 assert_eq!(usable_access_token(&creds("", live), now), None);
482 assert_eq!(usable_access_token(&creds(" ", live), now), None);
483 assert_eq!(usable_access_token(&creds("at", expired), now), None);
485 assert_eq!(usable_access_token(&creds("", expired), now), None);
486 }
487 use tempfile::TempDir;
488
489 fn cache_fixture() -> (TempDir, Cache) {
490 let td = TempDir::new().unwrap();
491 let cache = Cache::at(td.path().join("kimi"));
492 cache.ensure_dir().unwrap();
493 (td, cache)
494 }
495
496 fn test_endpoints(base: &str) -> Endpoints {
499 Endpoints {
500 usages: format!("{base}/coding/v1/usages"),
501 me: format!("{base}/coding/v1/me"),
502 token: format!("{base}/api/oauth/token"),
503 }
504 }
505
506 fn sample_json() -> &'static str {
507 r#"{
508 "user": { "membership": { "level": "LEVEL_INTERMEDIATE" } },
509 "usage": { "limit": "100", "used": "26", "remaining": "74", "resetTime": "2026-02-11T17:32:50.757941Z" },
510 "limits": [
511 {
512 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
513 "detail": { "limit": "100", "used": "15", "remaining": "85", "resetTime": "2026-02-07T12:32:50.757941Z" }
514 }
515 ]
516 }"#
517 }
518
519 fn sample_seed() -> serde_json::Value {
520 serde_json::json!({
521 "plan": "LEVEL_INTERMEDIATE",
522 "weekly_limit": 100,
523 "weekly_used": 30,
524 "weekly_remaining": 70,
525 "weekly_reset_at": "2026-02-11T17:32:50.757941Z",
526 "window_limit": 100,
527 "window_used": 20,
528 "window_remaining": 80,
529 "window_reset_at": "2026-02-07T12:32:50.757941Z"
530 })
531 }
532
533 #[tokio::test]
534 async fn live_200_returns_snapshot_and_sends_headers() {
535 let mut server = mockito::Server::new_async().await;
536 let m = server
537 .mock("GET", "/coding/v1/usages")
538 .with_status(200)
539 .with_body(sample_json())
540 .match_header("authorization", "Bearer sk-test")
541 .match_header("accept", "application/json")
542 .create_async()
543 .await;
544
545 let (_td, cache) = cache_fixture();
546 let client = reqwest::Client::new();
547 let endpoints = test_endpoints(&server.url());
548 let out = fetch_snapshot(
549 &client,
550 "sk-test",
551 &cache,
552 &endpoints,
553 Duration::from_secs(0),
554 )
555 .await
556 .unwrap();
557 m.assert_async().await;
558 assert_eq!(out.snapshot.plan, Some("Intermediate".into()));
560 assert_eq!(out.snapshot.weekly_limit, 100);
561 assert_eq!(out.snapshot.weekly_used, 26);
562 assert_eq!(out.snapshot.weekly_remaining, 74);
563 assert_eq!(out.snapshot.window_limit, 100);
564 assert_eq!(out.snapshot.window_used, 15);
565 assert!(!out.stale);
566 }
567
568 #[tokio::test]
569 async fn http_401_falls_back_to_cache() {
570 let mut server = mockito::Server::new_async().await;
571 server
572 .mock("GET", "/coding/v1/usages")
573 .with_status(401)
574 .with_body(r#"{"error": "invalid api key"}"#)
575 .create_async()
576 .await;
577
578 let (_td, cache) = cache_fixture();
579 cache
580 .write_payload(sample_seed().to_string().as_bytes())
581 .unwrap();
582
583 let client = reqwest::Client::new();
584 let endpoints = test_endpoints(&server.url());
585 let out = fetch_snapshot(
586 &client,
587 "bad-key",
588 &cache,
589 &endpoints,
590 Duration::from_secs(0),
591 )
592 .await
593 .unwrap();
594 assert!(out.stale);
595 assert_eq!(out.snapshot.weekly_used, 30);
596 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
597 }
598
599 #[tokio::test]
600 async fn http_500_falls_back_to_cache() {
601 let mut server = mockito::Server::new_async().await;
602 server
603 .mock("GET", "/coding/v1/usages")
604 .with_status(500)
605 .with_body(r#"{"error": "internal server error"}"#)
606 .create_async()
607 .await;
608
609 let (_td, cache) = cache_fixture();
610 cache
611 .write_payload(sample_seed().to_string().as_bytes())
612 .unwrap();
613
614 let client = reqwest::Client::new();
615 let endpoints = test_endpoints(&server.url());
616 let out = fetch_snapshot(
617 &client,
618 "sk-test",
619 &cache,
620 &endpoints,
621 Duration::from_secs(0),
622 )
623 .await
624 .unwrap();
625 assert!(out.stale);
626 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(500));
627 }
628
629 #[tokio::test]
630 async fn http_401_without_cache_returns_http_error() {
631 let mut server = mockito::Server::new_async().await;
632 server
633 .mock("GET", "/coding/v1/usages")
634 .with_status(401)
635 .with_body(r#"{"error": "invalid api key"}"#)
636 .create_async()
637 .await;
638
639 let (_td, cache) = cache_fixture();
640 let client = reqwest::Client::new();
641 let endpoints = test_endpoints(&server.url());
642 let err = fetch_snapshot(
643 &client,
644 "bad-key",
645 &cache,
646 &endpoints,
647 Duration::from_secs(0),
648 )
649 .await
650 .unwrap_err();
651 match err {
652 AppError::Http { status, .. } => assert_eq!(status, 401),
653 other => panic!("expected Http 401, got {other:?}"),
654 }
655 }
656
657 #[tokio::test]
658 async fn malformed_numeric_200_returns_schema_error() {
659 let mut server = mockito::Server::new_async().await;
660 server
661 .mock("GET", "/coding/v1/usages")
662 .with_status(200)
663 .with_body(r#"{"usage": {"limit": "100", "used": "garbage"}}"#)
664 .create_async()
665 .await;
666
667 let (_td, cache) = cache_fixture();
668 let client = reqwest::Client::new();
669 let endpoints = test_endpoints(&server.url());
670 let err = fetch_snapshot(
671 &client,
672 "sk-test",
673 &cache,
674 &endpoints,
675 Duration::from_secs(0),
676 )
677 .await
678 .unwrap_err();
679 assert!(
680 err.to_string().contains("used") || err.to_string().contains("Schema"),
681 "expected schema error, got {err}"
682 );
683 }
684
685 #[tokio::test]
686 async fn malformed_numeric_200_with_seeded_cache_returns_stale_snapshot_and_preserves_cache() {
687 let mut server = mockito::Server::new_async().await;
688 server
689 .mock("GET", "/coding/v1/usages")
690 .with_status(200)
691 .with_body(r#"{"usage": {"limit": "100", "used": "garbage"}}"#)
692 .create_async()
693 .await;
694
695 let (_td, cache) = cache_fixture();
696 let seeded = sample_seed().to_string();
697 cache.write_payload(seeded.as_bytes()).unwrap();
698
699 let client = reqwest::Client::new();
700 let endpoints = test_endpoints(&server.url());
701 let out = fetch_snapshot(
702 &client,
703 "sk-test",
704 &cache,
705 &endpoints,
706 Duration::from_secs(0),
707 )
708 .await
709 .unwrap();
710
711 assert!(out.stale);
712 assert_eq!(out.snapshot.weekly_used, 30);
713 assert_eq!(out.snapshot.window_used, 20);
714 assert_eq!(out.last_error, Some((0, SCHEMA_DRIFT_MESSAGE.into())));
715
716 let payload = std::fs::read_to_string(cache.payload_path()).unwrap();
718 assert_eq!(payload, seeded);
719 }
720
721 #[tokio::test]
722 async fn error_object_200_returns_schema_error() {
723 let mut server = mockito::Server::new_async().await;
724 server
725 .mock("GET", "/coding/v1/usages")
726 .with_status(200)
727 .with_body(r#"{"error": "invalid token"}"#)
728 .create_async()
729 .await;
730
731 let (_td, cache) = cache_fixture();
732 let client = reqwest::Client::new();
733 let endpoints = test_endpoints(&server.url());
734 let err = fetch_snapshot(
735 &client,
736 "sk-test",
737 &cache,
738 &endpoints,
739 Duration::from_secs(0),
740 )
741 .await
742 .unwrap_err();
743 assert!(err.to_string().contains("usage block"), "got {err}");
744 }
745
746 #[tokio::test]
747 async fn corrupt_fresh_cache_ignored() {
748 let mut server = mockito::Server::new_async().await;
749 server
750 .mock("GET", "/coding/v1/usages")
751 .with_status(200)
752 .with_body(sample_json())
753 .create_async()
754 .await;
755
756 let (_td, cache) = cache_fixture();
757 cache.write_payload(b"not valid json".as_slice()).unwrap();
758
759 let client = reqwest::Client::new();
760 let endpoints = test_endpoints(&server.url());
761 let out = fetch_snapshot(
762 &client,
763 "sk-test",
764 &cache,
765 &endpoints,
766 Duration::from_secs(60),
767 )
768 .await
769 .unwrap();
770 assert_eq!(out.snapshot.weekly_used, 26);
771 assert!(!out.stale);
772 }
773
774 #[tokio::test]
775 async fn a_fresh_legacy_plan_cache_is_upgraded_through_the_profile_endpoint() {
776 let mut server = mockito::Server::new_async().await;
777 let usages = server
778 .mock("GET", "/coding/v1/usages")
779 .with_status(200)
780 .with_body(sample_json())
781 .create_async()
782 .await;
783 let me = server
784 .mock("GET", "/coding/v1/me")
785 .with_status(200)
786 .with_body(r#"{"user_level_name":"Allegretto"}"#)
787 .create_async()
788 .await;
789
790 let (_td, cache) = cache_fixture();
791 cache
792 .write_payload(sample_seed().to_string().as_bytes())
793 .unwrap();
794
795 let out = fetch_snapshot(
796 &reqwest::Client::new(),
797 "sk-test",
798 &cache,
799 &test_endpoints(&server.url()),
800 Duration::from_secs(60),
801 )
802 .await
803 .unwrap();
804
805 usages.assert_async().await;
806 me.assert_async().await;
807 assert_eq!(out.snapshot.plan, Some("Allegretto".into()));
808 let cached: serde_json::Value =
809 serde_json::from_slice(&std::fs::read(cache.payload_path()).unwrap()).unwrap();
810 assert_eq!(cached["plan"], "Allegretto");
811 }
812
813 #[test]
814 fn a_legacy_plan_is_humanized_when_only_fallback_cache_is_available() {
815 let bytes = sample_seed().to_string();
816 let snap = parse_cache(bytes.as_bytes()).unwrap();
817 assert_eq!(snap.plan, Some("Intermediate".into()));
818 }
819
820 #[tokio::test]
821 async fn corrupt_stale_cache_returns_error() {
822 let mut server = mockito::Server::new_async().await;
823 server
824 .mock("GET", "/coding/v1/usages")
825 .with_status(401)
826 .with_body(r#"{"error": "invalid api key"}"#)
827 .create_async()
828 .await;
829
830 let (_td, cache) = cache_fixture();
831 cache.write_payload(b"not valid json".as_slice()).unwrap();
832
833 let client = reqwest::Client::new();
834 let endpoints = test_endpoints(&server.url());
835 let err = fetch_snapshot(
836 &client,
837 "bad-key",
838 &cache,
839 &endpoints,
840 Duration::from_secs(0),
841 )
842 .await
843 .unwrap_err();
844 assert!(
845 matches!(err, AppError::Http { status, .. } if status == 401),
846 "expected 401, got {err:?}"
847 );
848 }
849
850 #[tokio::test]
851 async fn transport_error_with_stale_cache_uses_cache() {
852 let (_td, cache) = cache_fixture();
854 cache
855 .write_payload(sample_seed().to_string().as_bytes())
856 .unwrap();
857
858 let client = reqwest::Client::new();
859 let endpoints = test_endpoints("http://localhost:1");
860 let out = fetch_snapshot(
861 &client,
862 "sk-test",
863 &cache,
864 &endpoints,
865 Duration::from_secs(0),
866 )
867 .await
868 .unwrap();
869 assert!(out.stale);
870 assert_eq!(out.snapshot.weekly_used, 30);
871 }
872
873 #[tokio::test]
874 async fn missing_counters_with_seeded_cache_preserves_snapshot() {
875 let mut server = mockito::Server::new_async().await;
876 server
877 .mock("GET", "/coding/v1/usages")
878 .with_status(200)
879 .with_body(r#"{"usage":{"limit":100}}"#)
880 .create_async()
881 .await;
882 let (_td, cache) = cache_fixture();
883 let seeded = sample_seed().to_string();
884 cache.write_payload(seeded.as_bytes()).unwrap();
885 let out = fetch_snapshot(
886 &reqwest::Client::new(),
887 "sk-test",
888 &cache,
889 &test_endpoints(&server.url()),
890 Duration::ZERO,
891 )
892 .await
893 .unwrap();
894 assert!(out.stale);
895 assert_eq!(out.snapshot.weekly_used, 30);
896 assert_eq!(
897 std::fs::read_to_string(cache.payload_path()).unwrap(),
898 seeded
899 );
900 }
901
902 #[tokio::test]
903 async fn unrecognized_window_with_seeded_cache_preserves_snapshot() {
904 let mut server = mockito::Server::new_async().await;
905 server.mock("GET", "/coding/v1/usages").with_status(200)
906 .with_body(r#"{"usage":{"limit":100,"used":10},"limits":[{"window":{"duration":4,"timeUnit":"TIME_UNIT_HOUR"},"detail":{"limit":100,"used":10}}]}"#).create_async().await;
907 let (_td, cache) = cache_fixture();
908 let seeded = sample_seed().to_string();
909 cache.write_payload(seeded.as_bytes()).unwrap();
910 let out = fetch_snapshot(
911 &reqwest::Client::new(),
912 "sk-test",
913 &cache,
914 &test_endpoints(&server.url()),
915 Duration::ZERO,
916 )
917 .await
918 .unwrap();
919 assert!(out.stale);
920 assert_eq!(out.snapshot.window_used, 20);
921 assert_eq!(
922 std::fs::read_to_string(cache.payload_path()).unwrap(),
923 seeded
924 );
925 }
926
927 #[tokio::test]
928 async fn http_error_body_is_redacted() {
929 let mut server = mockito::Server::new_async().await;
930 server
931 .mock("GET", "/coding/v1/usages")
932 .with_status(500)
933 .with_body("proxy secret: <token>")
934 .create_async()
935 .await;
936 let (_td, cache) = cache_fixture();
937 let err = fetch_snapshot(
938 &reqwest::Client::new(),
939 "sk-test",
940 &cache,
941 &test_endpoints(&server.url()),
942 Duration::ZERO,
943 )
944 .await
945 .unwrap_err();
946 assert!(
947 matches!(err, AppError::Http { status: 500, ref body } if body == "Kimi API returned HTTP 500")
948 );
949 }
950
951 const NOW_SECS: i64 = 1_800_000_000;
956
957 fn kimi_code_home(td: &TempDir, expires_in: i64) -> (PathBuf, KimiCodeAuth) {
958 let home = td.path().join(".kimi-code");
959 let auth = KimiCodeAuth::in_home(&home);
960 std::fs::create_dir_all(auth.credentials_path.parent().unwrap()).unwrap();
961 std::fs::write(
962 &auth.credentials_path,
963 serde_json::json!({
964 "access_token": "cli-at",
965 "refresh_token": "cli-rt",
966 "expires_at": NOW_SECS + expires_in,
967 "expires_in": 900,
968 "scope": "kimi-code",
969 "token_type": "Bearer",
970 })
971 .to_string(),
972 )
973 .unwrap();
974 (home, auth)
975 }
976
977 fn now() -> DateTime<Utc> {
978 DateTime::from_timestamp(NOW_SECS, 0).unwrap()
979 }
980
981 #[tokio::test]
982 async fn a_valid_cli_token_is_used_as_is_and_never_refreshed() {
983 let mut server = mockito::Server::new_async().await;
984 let usages = server
985 .mock("GET", "/coding/v1/usages")
986 .match_header("authorization", "Bearer cli-at")
987 .with_status(200)
988 .with_body(sample_json())
989 .create_async()
990 .await;
991 let refresh = server
992 .mock("POST", "/api/oauth/token")
993 .expect(0)
994 .create_async()
995 .await;
996
997 let (td, cache) = cache_fixture();
998 let (_home, auth) = kimi_code_home(&td, 600);
999 let out = fetch_snapshot_at(
1000 &reqwest::Client::new(),
1001 &Auth::KimiCode(auth.clone()),
1002 &cache,
1003 &test_endpoints(&server.url()),
1004 Duration::ZERO,
1005 now(),
1006 )
1007 .await
1008 .unwrap();
1009
1010 usages.assert_async().await;
1011 refresh.assert_async().await;
1012 assert_eq!(out.snapshot.weekly_used, 26);
1013 let stored = std::fs::read_to_string(&auth.credentials_path).unwrap();
1014 assert!(stored.contains("cli-rt"), "an unused token must not rotate");
1015 }
1016
1017 #[tokio::test]
1018 async fn an_expiring_cli_token_is_refreshed_and_the_rotation_is_written_back() {
1019 let mut server = mockito::Server::new_async().await;
1020 let refresh = server
1021 .mock("POST", "/api/oauth/token")
1022 .match_body(mockito::Matcher::UrlEncoded(
1023 "refresh_token".into(),
1024 "cli-rt".into(),
1025 ))
1026 .with_status(200)
1027 .with_body(
1028 r#"{"access_token":"fresh-at","refresh_token":"fresh-rt","expires_in":900,
1029 "scope":"kimi-code","token_type":"Bearer"}"#,
1030 )
1031 .create_async()
1032 .await;
1033 let usages = server
1034 .mock("GET", "/coding/v1/usages")
1035 .match_header("authorization", "Bearer fresh-at")
1036 .with_status(200)
1037 .with_body(sample_json())
1038 .create_async()
1039 .await;
1040
1041 let (td, cache) = cache_fixture();
1042 let (home, auth) = kimi_code_home(&td, 30);
1044 let out = fetch_snapshot_at(
1045 &reqwest::Client::new(),
1046 &Auth::KimiCode(auth.clone()),
1047 &cache,
1048 &test_endpoints(&server.url()),
1049 Duration::ZERO,
1050 now(),
1051 )
1052 .await
1053 .unwrap();
1054
1055 refresh.assert_async().await;
1056 usages.assert_async().await;
1057 assert_eq!(out.snapshot.weekly_used, 26);
1058
1059 let stored: serde_json::Value =
1060 serde_json::from_slice(&std::fs::read(&auth.credentials_path).unwrap()).unwrap();
1061 assert_eq!(stored["access_token"], "fresh-at");
1062 assert_eq!(
1063 stored["refresh_token"], "fresh-rt",
1064 "the CLI's own store must carry the rotated token, or its next run is dead"
1065 );
1066 assert_eq!(stored["expires_at"], NOW_SECS + 900);
1067 assert!(!super::super::lock::lock_dir_for(&auth.lock_target).exists());
1069 assert!(home.join("oauth").is_dir());
1070 }
1071
1072 #[tokio::test]
1073 async fn a_rejected_refresh_reports_a_credential_error_naming_the_cli() {
1074 let mut server = mockito::Server::new_async().await;
1075 server
1076 .mock("POST", "/api/oauth/token")
1077 .with_status(401)
1078 .with_body(r#"{"error":"invalid_grant"}"#)
1079 .create_async()
1080 .await;
1081
1082 let (td, cache) = cache_fixture();
1083 let (_home, auth) = kimi_code_home(&td, -60);
1084 let err = fetch_snapshot_at(
1085 &reqwest::Client::new(),
1086 &Auth::KimiCode(auth),
1087 &cache,
1088 &test_endpoints(&server.url()),
1089 Duration::ZERO,
1090 now(),
1091 )
1092 .await
1093 .unwrap_err();
1094 let message = err.to_string();
1095 assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
1096 assert!(message.contains("log in again"), "{message}");
1097 }
1098
1099 #[tokio::test]
1100 async fn a_logged_out_cli_falls_back_to_cache_with_a_credential_warning() {
1101 let (td, cache) = cache_fixture();
1102 let (_home, auth) = kimi_code_home(&td, 600);
1103 std::fs::write(
1104 &auth.credentials_path,
1105 r#"{"access_token":"","refresh_token":"","expires_at":0}"#,
1106 )
1107 .unwrap();
1108 cache
1109 .write_payload(sample_seed().to_string().as_bytes())
1110 .unwrap();
1111
1112 let out = fetch_snapshot_at(
1113 &reqwest::Client::new(),
1114 &Auth::KimiCode(auth),
1115 &cache,
1116 &test_endpoints("http://localhost:1"),
1117 Duration::ZERO,
1118 now(),
1119 )
1120 .await
1121 .unwrap();
1122 assert!(out.stale);
1123 assert_eq!(out.snapshot.weekly_used, 30);
1124 let (code, message) = out.last_error.unwrap();
1125 assert_eq!(code, 0);
1126 assert!(message.contains("logged out"), "{message}");
1127 }
1128
1129 #[tokio::test]
1130 async fn a_peer_refresh_during_the_wait_is_picked_up_instead_of_rotating_again() {
1131 let mut server = mockito::Server::new_async().await;
1132 let refresh = server
1133 .mock("POST", "/api/oauth/token")
1134 .expect(0)
1135 .create_async()
1136 .await;
1137 let usages = server
1138 .mock("GET", "/coding/v1/usages")
1139 .match_header("authorization", "Bearer peer-at")
1140 .with_status(200)
1141 .with_body(sample_json())
1142 .create_async()
1143 .await;
1144
1145 let (td, cache) = cache_fixture();
1146 let (_home, auth) = kimi_code_home(&td, -60);
1147 let peer = serde_json::json!({
1150 "access_token": "peer-at",
1151 "refresh_token": "peer-rt",
1152 "expires_at": NOW_SECS + 900,
1153 "expires_in": 900,
1154 "scope": "kimi-code",
1155 "token_type": "Bearer",
1156 });
1157 std::fs::write(&auth.credentials_path, peer.to_string()).unwrap();
1158
1159 let out = fetch_snapshot_at(
1160 &reqwest::Client::new(),
1161 &Auth::KimiCode(auth),
1162 &cache,
1163 &test_endpoints(&server.url()),
1164 Duration::ZERO,
1165 now(),
1166 )
1167 .await
1168 .unwrap();
1169 refresh.assert_async().await;
1170 usages.assert_async().await;
1171 assert_eq!(out.snapshot.weekly_used, 26);
1172 }
1173
1174 #[test]
1175 fn endpoints_follow_the_region() {
1176 let cn = Endpoints::for_region(Region::MainlandCn);
1177 assert_eq!(cn.usages, "https://api.kimi.com/coding/v1/usages");
1178 assert_eq!(cn.token, "https://auth.kimi.com/api/oauth/token");
1179 let global = Endpoints::for_region(Region::Global);
1180 assert_eq!(global.usages, "https://api.kimi.ai/coding/v1/usages");
1181 assert_eq!(global.token, "https://auth.kimi.ai/api/oauth/token");
1182 assert_eq!(Endpoints::default().usages, cn.usages);
1184 }
1185
1186 #[tokio::test]
1187 async fn the_vendors_own_tier_name_replaces_the_wire_enum() {
1188 let mut server = mockito::Server::new_async().await;
1189 server
1190 .mock("GET", "/coding/v1/usages")
1191 .with_status(200)
1192 .with_body(sample_json())
1193 .create_async()
1194 .await;
1195 let me = server
1196 .mock("GET", "/coding/v1/me")
1197 .match_header("authorization", "Bearer sk-test")
1198 .with_status(200)
1199 .with_body(r#"{"user_id":"u-1","user_level":25,"user_level_name":"Allegretto"}"#)
1200 .create_async()
1201 .await;
1202
1203 let (_td, cache) = cache_fixture();
1204 let out = fetch_snapshot(
1205 &reqwest::Client::new(),
1206 "sk-test",
1207 &cache,
1208 &test_endpoints(&server.url()),
1209 Duration::ZERO,
1210 )
1211 .await
1212 .unwrap();
1213 me.assert_async().await;
1214 assert_eq!(out.snapshot.plan, Some("Allegretto".into()));
1215 let cached: serde_json::Value =
1217 serde_json::from_slice(&std::fs::read(cache.payload_path()).unwrap()).unwrap();
1218 assert_eq!(cached["plan"], "Allegretto");
1219 }
1220
1221 #[tokio::test]
1222 async fn a_profile_endpoint_that_fails_costs_the_label_and_nothing_else() {
1223 for status in [404, 401, 500] {
1226 let mut server = mockito::Server::new_async().await;
1227 server
1228 .mock("GET", "/coding/v1/usages")
1229 .with_status(200)
1230 .with_body(sample_json())
1231 .create_async()
1232 .await;
1233 server
1234 .mock("GET", "/coding/v1/me")
1235 .with_status(status)
1236 .with_body(r#"{"error":"nope"}"#)
1237 .create_async()
1238 .await;
1239
1240 let (_td, cache) = cache_fixture();
1241 let out = fetch_snapshot(
1242 &reqwest::Client::new(),
1243 "sk-test",
1244 &cache,
1245 &test_endpoints(&server.url()),
1246 Duration::ZERO,
1247 )
1248 .await
1249 .unwrap();
1250 assert_eq!(out.snapshot.plan, Some("Intermediate".into()), "{status}");
1251 assert_eq!(out.snapshot.weekly_used, 26, "{status}");
1252 assert!(out.last_error.is_none(), "{status}: must not warn");
1253 }
1254 }
1255
1256 #[tokio::test]
1257 async fn an_unreachable_profile_endpoint_does_not_fail_the_fetch() {
1258 let mut server = mockito::Server::new_async().await;
1259 server
1260 .mock("GET", "/coding/v1/usages")
1261 .with_status(200)
1262 .with_body(sample_json())
1263 .create_async()
1264 .await;
1265 let mut endpoints = test_endpoints(&server.url());
1266 endpoints.me = "http://localhost:1/coding/v1/me".into();
1267
1268 let (_td, cache) = cache_fixture();
1269 let out = fetch_snapshot(
1270 &reqwest::Client::new(),
1271 "sk-test",
1272 &cache,
1273 &endpoints,
1274 Duration::ZERO,
1275 )
1276 .await
1277 .unwrap();
1278 assert_eq!(out.snapshot.plan, Some("Intermediate".into()));
1279 assert!(!out.stale);
1280 }
1281
1282 #[test]
1283 fn a_relocated_credential_file_keeps_the_homes_lock_target() {
1284 let home = Path::new("/home/u/.kimi-code");
1285 let auth =
1286 KimiCodeAuth::with_credentials_path(home, PathBuf::from("/elsewhere/kimi-code.json"));
1287 assert_eq!(
1288 auth.credentials_path,
1289 PathBuf::from("/elsewhere/kimi-code.json")
1290 );
1291 assert_eq!(auth.lock_target, oauth::lock_target_in(home));
1292 }
1293
1294 fn monthly_shape_snap() -> KimiSnapshot {
1295 KimiSnapshot {
1296 plan: Some("Allegretto".into()),
1297 weekly_limit: 0,
1298 weekly_used: 0,
1299 weekly_remaining: 0,
1300 weekly_reset_at: None,
1301 has_weekly: false,
1302 monthly_pct: Some(42),
1303 monthly_reset_at: Some(
1304 DateTime::parse_from_rfc3339("2026-10-16T00:00:00Z")
1305 .unwrap()
1306 .into(),
1307 ),
1308 window_limit: 100,
1309 window_used: 15,
1310 window_remaining: 85,
1311 window_reset_at: Some(
1312 DateTime::parse_from_rfc3339("2026-09-16T20:11:32Z")
1313 .unwrap()
1314 .into(),
1315 ),
1316 }
1317 }
1318
1319 #[test]
1320 fn a_monthly_shape_snapshot_survives_the_cache_round_trip() {
1321 let bytes = serde_json::to_vec(&snap_to_json(&monthly_shape_snap())).unwrap();
1322 let snap = parse_cache(&bytes).unwrap();
1323 assert!(!snap.has_weekly);
1324 assert_eq!(snap.monthly_pct, Some(42));
1325 assert_eq!(
1326 snap.monthly_reset_at.map(|dt| dt.to_rfc3339()),
1327 Some("2026-10-16T00:00:00+00:00".to_string())
1328 );
1329 assert_eq!(snap.weekly_used, 0);
1330 assert_eq!(snap.window_used, 15);
1331 assert_eq!(snap, monthly_shape_snap());
1332 }
1333
1334 #[test]
1335 fn a_legacy_cache_without_the_new_keys_still_parses() {
1336 let bytes = sample_seed().to_string();
1338 let snap = parse_cache(bytes.as_bytes()).unwrap();
1339 assert!(snap.has_weekly);
1340 assert_eq!(snap.monthly_pct, None);
1341 assert_eq!(snap.monthly_reset_at, None);
1342 assert_eq!(snap.weekly_used, 30);
1343 }
1344
1345 #[test]
1346 fn a_cache_with_weekly_counters_on_a_monthly_shape_is_rejected() {
1347 let mut v = sample_seed();
1348 v["has_weekly"] = serde_json::json!(false);
1349 let err = parse_cache(v.to_string().as_bytes()).unwrap_err();
1350 assert!(err.to_string().contains("monthly-shape"), "{err}");
1351 }
1352
1353 #[test]
1354 fn a_cache_with_an_out_of_range_monthly_pct_is_rejected() {
1355 for bad in [150, -1] {
1356 let mut v: serde_json::Value = serde_json::from_slice(
1357 &serde_json::to_vec(&snap_to_json(&monthly_shape_snap())).unwrap(),
1358 )
1359 .unwrap();
1360 v["monthly_pct"] = serde_json::json!(bad);
1361 let err = parse_cache(v.to_string().as_bytes()).unwrap_err();
1362 assert!(err.to_string().contains("monthly_pct"), "{bad}: {err}");
1363 }
1364 }
1365}