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