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 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
250async 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 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 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
308async 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 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 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 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 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 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 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 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 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 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 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 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 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 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}