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