1use std::collections::{BTreeMap, BTreeSet, HashMap};
4use std::path::Path;
5use std::time::{Duration, SystemTime};
6
7use anyhow::{Context, Result, bail};
8use chrono::{DateTime, Datelike, Days, FixedOffset, Local, NaiveDate, NaiveTime, TimeZone};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use crate::claude_usage;
13use crate::codex_usage::{self, CodexUsageClient, CodexUsageStatus};
14use crate::grok_usage;
15use mj_core::config::{HarnessKind, HarnessProfile, harness_authentication_marker};
16use mj_core::credentials::{
17 MAX_CREDENTIAL_BYTES, credential_expiry, credential_fingerprint, credential_freshness,
18};
19
20pub use mj_client::quota::{API_LABEL, ProfileQuota, QuotaWindow, projects_exhaustion};
21
22#[derive(Debug, Clone)]
23pub struct QuotaRefreshRequest {
24 pub profile_id: String,
25 pub harness: HarnessKind,
26 pub source_home: std::path::PathBuf,
27 pub environment: BTreeMap<String, String>,
28 pub cwd: std::path::PathBuf,
29 pub provider: Option<ProviderCredential>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ProviderCredential {
39 pub id: String,
41 pub host: String,
43 pub api_key: String,
44}
45
46impl QuotaRefreshRequest {
47 pub fn for_profile(
51 profile_id: &str,
52 profile: &HarnessProfile,
53 cwd: std::path::PathBuf,
54 ) -> Self {
55 let mut environment = profile.environment.clone();
56 profile
57 .kind
58 .configure_home_environment(&profile.home, &mut environment);
59 Self {
60 profile_id: profile_id.to_owned(),
61 harness: profile.kind,
62 source_home: profile.home.clone(),
63 environment,
64 cwd,
65 provider: provider_credential(profile),
66 }
67 }
68}
69
70fn provider_credential(profile: &HarnessProfile) -> Option<ProviderCredential> {
71 let provider = profile.codex_provider().ok().flatten()?;
72 let env_key = provider.env_key.as_deref()?;
73 let api_key = profile.environment.get(env_key)?;
74 Some(ProviderCredential {
75 id: provider.id.clone(),
76 host: provider.host()?,
77 api_key: api_key.clone(),
78 })
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct QuotaRefreshOutcome {
83 pub report: ProfileQuota,
84 pub credentials_changed: bool,
85}
86
87#[derive(Default)]
88pub struct QuotaManager {
89 codex_clients: HashMap<String, CodexUsageClient>,
90 reports: BTreeMap<String, ProfileQuota>,
91}
92
93impl QuotaManager {
94 pub fn reports(&self) -> &BTreeMap<String, ProfileQuota> {
95 &self.reports
96 }
97
98 pub async fn refresh_profiles<F, Fut>(
102 &mut self,
103 requests: Vec<QuotaRefreshRequest>,
104 mut on_report: F,
105 ) where
106 F: FnMut(QuotaRefreshOutcome) -> Fut,
107 Fut: Future<Output = ()> + Send,
108 {
109 let batch = requests
110 .iter()
111 .map(|request| request.profile_id.clone())
112 .collect::<BTreeSet<_>>();
113 self.reports
114 .retain(|profile_id, _| batch.contains(profile_id));
115 let mut tasks = tokio::task::JoinSet::new();
116 for request in requests {
117 let client = self.codex_clients.remove(&request.profile_id);
118 tasks.spawn(refresh_profile(request, client));
119 }
120
121 while let Some(result) = tasks.join_next().await {
122 let (outcome, client) = match result {
123 Ok(output) => output,
124 Err(error) => {
125 tracing::warn!(%error, "quota refresh task failed");
126 continue;
127 }
128 };
129 if let Some(client) = client {
130 self.codex_clients
131 .insert(outcome.report.profile_id.clone(), client);
132 }
133 self.reports
134 .insert(outcome.report.profile_id.clone(), outcome.report.clone());
135 on_report(outcome).await;
136 }
137 self.stop_clients_outside_batch(&batch).await;
138 }
139
140 async fn stop_clients_outside_batch(&mut self, keep: &BTreeSet<String>) {
146 let stranded = self
147 .codex_clients
148 .keys()
149 .filter(|profile_id| !keep.contains(*profile_id))
150 .cloned()
151 .collect::<Vec<_>>();
152 for profile_id in stranded {
153 if let Some(client) = self.codex_clients.remove(&profile_id) {
154 tracing::info!(profile_id, "stopping the quota client of a removed profile");
155 client.shutdown().await;
156 }
157 }
158 }
159
160 pub async fn shutdown(mut self) {
161 for (_, client) in self.codex_clients.drain() {
162 client.shutdown().await;
163 }
164 }
165}
166
167async fn refresh_profile(
168 request: QuotaRefreshRequest,
169 mut codex_client: Option<CodexUsageClient>,
170) -> (QuotaRefreshOutcome, Option<CodexUsageClient>) {
171 let credential_path = harness_authentication_marker(request.harness, &request.source_home);
172 let credential_before = credential_marker_fingerprint(&credential_path).await;
173 let QuotaRefreshRequest {
174 profile_id,
175 harness,
176 source_home,
177 environment,
178 cwd,
179 provider,
180 } = request;
181 let environment = environment.into_iter().collect::<HashMap<_, _>>();
182 let refreshed_at_epoch_seconds = SystemTime::now()
183 .duration_since(SystemTime::UNIX_EPOCH)
184 .unwrap_or_default()
185 .as_secs();
186 let result = match harness {
187 HarnessKind::Codex if provider.is_some() => {
192 let provider = provider.expect("guarded by the match arm");
193 if crate::zai_usage::serves_quota(&provider.host) {
194 crate::zai_usage::query(&provider.host, &provider.api_key)
195 .await
196 .map(|windows| ProfileQuota {
197 profile_id: profile_id.clone(),
198 harness,
199 windows: windows
200 .into_iter()
201 .map(|window| QuotaWindow {
202 label: window.label,
203 remaining_percent: Some(window.remaining_percent),
204 used: window.used,
205 limit: window.limit,
206 resets: window.resets_at.and_then(format_reset_local_seconds),
207 resets_at_epoch_seconds: window.resets_at,
208 })
209 .collect(),
210 extra: None,
211 error: None,
212 refreshed_at_epoch_seconds,
213 })
214 } else {
215 Ok(ProfileQuota {
221 profile_id: profile_id.clone(),
222 harness,
223 windows: Vec::new(),
224 extra: Some(API_LABEL.to_owned()),
225 error: None,
226 refreshed_at_epoch_seconds,
227 })
228 }
229 }
230 HarnessKind::Codex => {
231 if codex_login_is_near_expiry(&credential_path).await {
232 match codex_usage::refresh_login(
233 &mut codex_client,
234 cwd.clone(),
235 environment.clone(),
236 )
237 .await
238 {
239 Ok(()) => tracing::info!(
240 profile_id = %profile_id,
241 "refreshed Codex login ahead of expiry"
242 ),
243 Err(error) => tracing::warn!(
244 profile_id = %profile_id,
245 %error,
246 "could not refresh the Codex login ahead of expiry"
247 ),
248 }
249 }
250 let status = codex_usage::refresh(&mut codex_client, cwd, environment).await;
251 match status {
252 CodexUsageStatus::Available(report) => Ok(ProfileQuota {
253 profile_id: profile_id.clone(),
254 harness,
255 windows: [report.primary, report.secondary]
256 .into_iter()
257 .flatten()
258 .map(|window| QuotaWindow {
259 label: window.label,
260 remaining_percent: Some(window.remaining_percent),
261 used: None,
262 limit: None,
263 resets: window.resets_at.and_then(format_reset_local_seconds),
264 resets_at_epoch_seconds: window.resets_at,
265 })
266 .collect(),
267 extra: None,
268 error: None,
269 refreshed_at_epoch_seconds,
270 }),
271 CodexUsageStatus::Unavailable(error) => Err(anyhow::anyhow!(error)),
272 }
273 }
274 HarnessKind::Claude => claude_usage::query(source_home, environment)
275 .await
276 .map(|report| ProfileQuota {
277 profile_id: profile_id.clone(),
278 harness,
279 windows: [
280 report.five_hour.map(|window| ("5H", window)),
281 report.week.map(|window| ("Week", window)),
282 ]
283 .into_iter()
284 .flatten()
285 .map(|(label, window)| QuotaWindow {
286 label: label.to_string(),
287 remaining_percent: Some(window.remaining_percent),
288 used: None,
289 limit: None,
290 resets: window
291 .reset_context
292 .as_deref()
293 .and_then(normalize_reset_text),
294 resets_at_epoch_seconds: window
295 .reset_context
296 .as_deref()
297 .and_then(normalize_reset_epoch_seconds),
298 })
299 .collect(),
300 extra: None,
301 error: None,
302 refreshed_at_epoch_seconds,
303 })
304 .map_err(|error| anyhow::anyhow!(error.to_string())),
305 HarnessKind::Kimi => {
306 query_kimi(&source_home, &environment)
307 .await
308 .map(|(windows, extra)| ProfileQuota {
309 profile_id: profile_id.clone(),
310 harness,
311 windows,
312 extra,
313 error: None,
314 refreshed_at_epoch_seconds,
315 })
316 }
317 HarnessKind::Grok => {
320 grok_usage::query(source_home.clone(), cwd, environment)
321 .await
322 .map(|report| ProfileQuota {
323 profile_id: profile_id.clone(),
324 harness,
325 windows: vec![QuotaWindow {
326 label: report.period_label.clone(),
327 remaining_percent: Some(report.remaining_percent()),
328 used: None,
331 limit: None,
332 resets: report.resets_at.and_then(format_reset_local_seconds),
333 resets_at_epoch_seconds: report.resets_at,
334 }],
335 extra: None,
336 error: None,
337 refreshed_at_epoch_seconds,
338 })
339 .map_err(|error| anyhow::anyhow!(error.to_string()))
340 }
341 HarnessKind::Muse => crate::muse_usage::query(&source_home, &environment)
342 .await
343 .map(|report| ProfileQuota {
344 profile_id: profile_id.clone(),
345 harness,
346 windows: report
347 .windows
348 .into_iter()
349 .map(|window| QuotaWindow {
350 label: window.label,
351 remaining_percent: Some(window.remaining_percent),
352 used: None,
353 limit: None,
354 resets: window.resets_at.and_then(format_reset_local_seconds),
355 resets_at_epoch_seconds: window.resets_at,
356 })
357 .collect(),
358 extra: report.note,
359 error: None,
360 refreshed_at_epoch_seconds,
361 }),
362 };
363 let report = result.unwrap_or_else(|error| ProfileQuota {
364 profile_id,
365 harness,
366 windows: Vec::new(),
367 extra: None,
368 error: Some(error.to_string()),
369 refreshed_at_epoch_seconds,
370 });
371 let credential_after = credential_marker_fingerprint(&credential_path).await;
372 let credentials_changed = match (credential_before, credential_after) {
373 (Ok(before), Ok(after)) => before != after,
374 (Err(error), _) | (_, Err(error)) => {
375 tracing::warn!(path = %credential_path.display(), %error, "could not fingerprint quota credentials");
376 false
377 }
378 };
379 (
380 QuotaRefreshOutcome {
381 report,
382 credentials_changed,
383 },
384 codex_client,
385 )
386}
387
388const CODEX_MINIMUM_REFRESH_MARGIN_MS: i64 = 60 * 60 * 1000;
392
393async fn codex_login_is_near_expiry(marker: &Path) -> bool {
396 let Ok(bytes) = tokio::fs::read(marker).await else {
397 return false;
398 };
399 if bytes.len() > MAX_CREDENTIAL_BYTES {
400 return false;
401 }
402 let now = SystemTime::now()
403 .duration_since(SystemTime::UNIX_EPOCH)
404 .unwrap_or_default()
405 .as_millis() as i64;
406 codex_login_needs_refresh(
407 credential_expiry(HarnessKind::Codex, &bytes),
408 credential_freshness(HarnessKind::Codex, &bytes),
409 now,
410 )
411}
412
413fn codex_login_needs_refresh(
417 expiry_millis: Option<i64>,
418 last_refresh_millis: Option<i64>,
419 now_millis: i64,
420) -> bool {
421 let Some(expiry) = expiry_millis else {
422 return false;
423 };
424 let lifetime = last_refresh_millis
425 .map(|refreshed| expiry.saturating_sub(refreshed))
426 .unwrap_or_default();
427 let margin = CODEX_MINIMUM_REFRESH_MARGIN_MS.max(lifetime / 10);
428 expiry.saturating_sub(now_millis) < margin
429}
430
431async fn credential_marker_fingerprint(path: &Path) -> Result<Option<String>> {
432 let metadata = match tokio::fs::metadata(path).await {
433 Ok(metadata) => metadata,
434 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
435 Err(error) => return Err(error).context("inspect credential marker"),
436 };
437 if metadata.len() > MAX_CREDENTIAL_BYTES as u64 {
438 bail!("credential marker exceeds {MAX_CREDENTIAL_BYTES} bytes");
439 }
440 let bytes = tokio::fs::read(path)
441 .await
442 .context("read credential marker")?;
443 if bytes.len() > MAX_CREDENTIAL_BYTES {
444 bail!("credential marker exceeds {MAX_CREDENTIAL_BYTES} bytes");
445 }
446 Ok(Some(credential_fingerprint(&bytes)))
447}
448
449async fn query_kimi(
450 home: &Path,
451 environment: &HashMap<String, String>,
452) -> Result<(Vec<QuotaWindow>, Option<String>)> {
453 let base = environment
454 .get("KIMI_CODE_BASE_URL")
455 .map(String::as_str)
456 .unwrap_or("https://api.kimi.com/coding/v1")
457 .trim_end_matches('/');
458 let client = reqwest::Client::builder()
459 .timeout(Duration::from_secs(10))
460 .build()
461 .context("build Kimi quota client")?;
462 let credentials_path = home.join("credentials/kimi-code.json");
463 let usage_url = format!("{base}/usages");
464 let response = fetch_bearer_with_auth_retry(&client, &usage_url, |force, rejected_token| {
465 ensure_fresh_kimi_token(
466 &client,
467 home,
468 &credentials_path,
469 environment,
470 force,
471 rejected_token,
472 )
473 })
474 .await?;
475 if !response.status().is_success() {
476 bail!("Kimi Code quota returned HTTP {}", response.status());
477 }
478 let payload: Value = response.json().await.context("decode Kimi Code quota")?;
479 Ok(parse_kimi_usage(&payload))
480}
481
482const KIMI_OAUTH_CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098";
483
484#[derive(Clone, Debug, Deserialize, Serialize)]
485struct KimiCredentials {
486 #[serde(alias = "accessToken")]
487 access_token: String,
488 #[serde(default, alias = "refreshToken")]
489 refresh_token: String,
490 #[serde(default, alias = "expiresAt")]
491 expires_at: i64,
492 #[serde(default)]
493 scope: String,
494 #[serde(default, alias = "tokenType")]
495 token_type: String,
496 #[serde(default, alias = "expiresIn")]
497 expires_in: i64,
498}
499
500impl KimiCredentials {
501 fn differs_from(&self, other: &Self) -> bool {
505 self.access_token != other.access_token
506 || self.refresh_token != other.refresh_token
507 || self.expires_at != other.expires_at
508 }
509
510 fn needs_refresh(&self) -> bool {
511 if self.expires_at == 0 {
512 return false;
513 }
514 let now = SystemTime::now()
515 .duration_since(SystemTime::UNIX_EPOCH)
516 .unwrap_or_default()
517 .as_secs() as i64;
518 let threshold = 300.max(self.expires_in / 2);
519 self.expires_at - now < threshold
520 }
521}
522
523async fn read_kimi_credentials(path: &Path) -> Result<KimiCredentials> {
524 let bytes = tokio::fs::read(path)
525 .await
526 .context("Kimi Code credentials are unavailable")?;
527 let credentials: KimiCredentials =
528 serde_json::from_slice(&bytes).context("Kimi Code credentials are invalid")?;
529 if credentials.access_token.is_empty() {
530 bail!("Kimi Code access token is missing");
531 }
532 Ok(credentials)
533}
534
535async fn fetch_bearer_with_auth_retry<F, Fut>(
536 client: &reqwest::Client,
537 url: &str,
538 mut authenticate: F,
539) -> Result<reqwest::Response>
540where
541 F: FnMut(bool, Option<String>) -> Fut,
542 Fut: std::future::Future<Output = Result<String>>,
543{
544 let token = authenticate(false, None).await?;
545 let response = client
546 .get(url)
547 .bearer_auth(&token)
548 .header(reqwest::header::ACCEPT, "application/json")
549 .send()
550 .await
551 .context("query quota")?;
552 if response.status() != reqwest::StatusCode::UNAUTHORIZED {
553 return Ok(response);
554 }
555
556 let refreshed = authenticate(true, Some(token)).await?;
557 client
558 .get(url)
559 .bearer_auth(refreshed)
560 .header(reqwest::header::ACCEPT, "application/json")
561 .send()
562 .await
563 .context("retry quota after authentication refresh")
564}
565
566async fn ensure_fresh_kimi_token(
572 client: &reqwest::Client,
573 home: &Path,
574 credentials_path: &Path,
575 environment: &HashMap<String, String>,
576 force: bool,
577 rejected_token: Option<String>,
578) -> Result<String> {
579 let initial = read_kimi_credentials(credentials_path).await?;
580 if !force && !initial.needs_refresh() {
581 return Ok(initial.access_token);
582 }
583
584 let _refresh_lock = KimiRefreshLock::acquire(home, KIMI_LOCK_WAIT).await?;
586 let active = read_kimi_credentials(credentials_path).await?;
587 let changed_while_waiting = active.differs_from(&initial);
588 if (!force && !active.needs_refresh())
589 || (force
590 && (changed_while_waiting
591 || rejected_token.is_some_and(|token| token != active.access_token)))
592 {
593 return Ok(active.access_token);
594 }
595 if active.refresh_token.is_empty() {
596 bail!("Kimi Code refresh token is missing; run `kimi login`");
597 }
598
599 let oauth_host = environment
600 .get("KIMI_CODE_OAUTH_HOST")
601 .or_else(|| environment.get("KIMI_OAUTH_HOST"))
602 .map(String::as_str)
603 .unwrap_or("https://auth.kimi.com")
604 .trim_end_matches('/');
605 let response = client
606 .post(format!("{oauth_host}/api/oauth/token"))
607 .header(reqwest::header::ACCEPT, "application/json")
608 .form(&[
609 ("client_id", KIMI_OAUTH_CLIENT_ID),
610 ("grant_type", "refresh_token"),
611 ("refresh_token", active.refresh_token.as_str()),
612 ])
613 .send()
614 .await
615 .context("refresh Kimi Code access token")?;
616 if !response.status().is_success() {
617 let status = response.status();
618 if matches!(
619 status,
620 reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
621 ) {
622 tokio::time::sleep(Duration::from_millis(100)).await;
623 let recovery = read_kimi_credentials(credentials_path).await?;
624 if recovery.refresh_token != active.refresh_token && !recovery.access_token.is_empty() {
625 return Ok(recovery.access_token);
626 }
627 }
628 bail!("Kimi Code token refresh returned HTTP {status}");
629 }
630
631 let payload: Value = response
632 .json()
633 .await
634 .context("decode Kimi Code token refresh")?;
635 let access_token = required_string(&payload, "access_token", "Kimi Code token refresh")?;
636 let refresh_token = required_string(&payload, "refresh_token", "Kimi Code token refresh")?;
637 let expires_in = payload
638 .get("expires_in")
639 .and_then(value_i64)
640 .filter(|value| *value > 0)
641 .context("Kimi Code token refresh is missing expires_in")?;
642 let now = SystemTime::now()
643 .duration_since(SystemTime::UNIX_EPOCH)
644 .unwrap_or_default()
645 .as_secs() as i64;
646 let refreshed = KimiCredentials {
647 access_token: access_token.to_string(),
648 refresh_token: refresh_token.to_string(),
649 expires_at: now + expires_in,
650 scope: payload
651 .get("scope")
652 .and_then(Value::as_str)
653 .unwrap_or_default()
654 .to_string(),
655 token_type: payload
656 .get("token_type")
657 .and_then(Value::as_str)
658 .unwrap_or("Bearer")
659 .to_string(),
660 expires_in,
661 };
662 save_kimi_credentials(credentials_path, &refreshed)?;
663 Ok(refreshed.access_token)
664}
665
666fn save_kimi_credentials(path: &Path, credentials: &KimiCredentials) -> Result<()> {
667 let mut body = serde_json::to_vec_pretty(credentials)?;
668 body.push(b'\n');
669 mj_core::config::atomic_write(path, &body).context("save refreshed Kimi Code credentials")
670}
671
672fn required_string<'a>(payload: &'a Value, key: &str, context: &str) -> Result<&'a str> {
673 payload
674 .get(key)
675 .and_then(Value::as_str)
676 .filter(|value| !value.is_empty())
677 .with_context(|| format!("{context} is missing {key}"))
678}
679
680struct KimiRefreshLock {
687 path: std::path::PathBuf,
688 heartbeat: tokio::task::JoinHandle<()>,
689}
690
691const KIMI_LOCK_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(1);
694const KIMI_LOCK_STALE_AFTER: Duration = Duration::from_secs(10);
698const KIMI_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(500);
699const KIMI_LOCK_WAIT: Duration = Duration::from_secs(60);
700
701impl KimiRefreshLock {
702 async fn acquire(home: &Path, wait: Duration) -> Result<Self> {
703 let oauth_dir = home.join("oauth");
704 tokio::fs::create_dir_all(&oauth_dir)
705 .await
706 .context("prepare Kimi Code OAuth lock")?;
707 tokio::fs::OpenOptions::new()
709 .create(true)
710 .append(true)
711 .open(oauth_dir.join("kimi-code"))
712 .await
713 .context("prepare Kimi Code OAuth lock sentinel")?;
714 let path = oauth_dir.join("kimi-code.lock");
715 let deadline = tokio::time::Instant::now() + wait;
716 loop {
717 match tokio::fs::create_dir(&path).await {
718 Ok(()) => return Ok(Self::held(path)),
719 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
720 if tokio::time::Instant::now() >= deadline {
721 bail!(
722 "timed out waiting for Kimi Code OAuth refresh lock {}",
723 path.display()
724 );
725 }
726 if !break_stale_kimi_lock(&path).await {
727 tokio::time::sleep(KIMI_LOCK_RETRY_INTERVAL).await;
728 }
729 }
730 Err(error) => return Err(error).context("acquire Kimi Code OAuth refresh lock"),
731 }
732 }
733 }
734
735 fn held(path: std::path::PathBuf) -> Self {
736 let heartbeat_path = path.clone();
737 let heartbeat = tokio::spawn(async move {
738 loop {
739 tokio::time::sleep(KIMI_LOCK_HEARTBEAT_INTERVAL).await;
740 if let Err(error) = touch_kimi_lock(&heartbeat_path, SystemTime::now()) {
741 tracing::debug!(path = %heartbeat_path.display(), %error, "heartbeat Kimi Code OAuth refresh lock");
742 }
743 }
744 });
745 Self { path, heartbeat }
746 }
747}
748
749impl Drop for KimiRefreshLock {
750 fn drop(&mut self) {
751 self.heartbeat.abort();
752 match std::fs::remove_dir(&self.path) {
753 Ok(()) => {}
754 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
755 Err(error) => {
756 tracing::warn!(path = %self.path.display(), %error, "release Kimi Code OAuth refresh lock");
757 }
758 }
759 }
760}
761
762fn kimi_lock_mtime(path: &Path) -> std::io::Result<Option<SystemTime>> {
764 match std::fs::metadata(path) {
765 Ok(metadata) => metadata.modified().map(Some),
766 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
767 Err(error) => Err(error),
768 }
769}
770
771fn touch_kimi_lock(path: &Path, modified: SystemTime) -> std::io::Result<()> {
774 let mut options = std::fs::OpenOptions::new();
775 options.read(true);
776 #[cfg(windows)]
777 {
778 use std::os::windows::fs::OpenOptionsExt;
779 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
780 options.custom_flags(FILE_FLAG_BACKUP_SEMANTICS);
781 }
782 options
783 .open(path)?
784 .set_times(std::fs::FileTimes::new().set_modified(modified))
785}
786
787async fn break_stale_kimi_lock(path: &Path) -> bool {
791 let modified = match kimi_lock_mtime(path) {
792 Ok(Some(modified)) => modified,
793 Ok(None) => return true,
794 Err(error) => {
795 tracing::warn!(path = %path.display(), %error, "inspect Kimi Code OAuth refresh lock");
796 return false;
797 }
798 };
799 let Some(age) = SystemTime::now()
802 .duration_since(modified)
803 .ok()
804 .filter(|age| *age >= KIMI_LOCK_STALE_AFTER)
805 else {
806 return false;
807 };
808 match tokio::fs::remove_dir(path).await {
809 Ok(()) => {
810 tracing::warn!(
811 path = %path.display(),
812 age_seconds = age.as_secs(),
813 "removed a Kimi Code OAuth refresh lock whose holder stopped heartbeating"
814 );
815 true
816 }
817 Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
818 Err(error) => {
819 tracing::warn!(path = %path.display(), %error, "remove stale Kimi Code OAuth refresh lock");
820 false
821 }
822 }
823}
824
825fn parse_kimi_usage(payload: &Value) -> (Vec<QuotaWindow>, Option<String>) {
826 let mut windows = Vec::new();
827 if let Some(summary) = payload.get("usage")
828 && let Some(window) = parse_kimi_window(summary, "Weekly limit")
829 {
830 windows.push(window);
831 }
832 if let Some(limits) = payload.get("limits").and_then(Value::as_array) {
833 for (index, item) in limits.iter().enumerate() {
834 let detail = item.get("detail").unwrap_or(item);
835 if let Some(window) = parse_kimi_window(detail, &format!("Limit #{}", index + 1)) {
836 windows.push(window);
837 }
838 }
839 }
840 let extra = payload
841 .pointer("/boosterWallet/balance/amountLeft")
842 .and_then(value_i64)
843 .map(|value| format!("booster {} remaining", value / 1_000_000));
844 (windows, extra)
845}
846
847fn parse_kimi_window(value: &Value, fallback: &str) -> Option<QuotaWindow> {
848 let limit = value.get("limit").and_then(value_i64);
849 let used = value.get("used").and_then(value_i64).or_else(|| {
850 let remaining = value.get("remaining").and_then(value_i64)?;
851 Some(limit? - remaining)
852 });
853 if used.is_none() && limit.is_none() {
854 return None;
855 }
856 let provider_label = value
857 .get("name")
858 .or_else(|| value.get("title"))
859 .and_then(Value::as_str)
860 .unwrap_or(fallback);
861 let label = if provider_label.to_ascii_lowercase().contains("week") {
862 "Week".to_string()
863 } else if provider_label.to_ascii_lowercase().contains("5h") || fallback.starts_with("Limit #")
864 {
865 "5H".to_string()
866 } else {
867 provider_label.to_string()
868 };
869 let reset_value = ["resetAt", "reset_at", "resetTime", "reset_time"]
870 .iter()
871 .find_map(|key| value.get(*key));
872 let resets = reset_value.and_then(normalize_kimi_reset);
873 let resets_at_epoch_seconds = reset_value.and_then(kimi_reset_epoch_seconds);
874 let remaining_percent = match (used, limit) {
875 (Some(used), Some(limit)) if limit > 0 => {
876 Some((100 - used.clamp(0, limit) * 100 / limit) as u8)
877 }
878 _ => None,
879 };
880 Some(QuotaWindow {
881 label,
882 remaining_percent,
883 used,
884 limit,
885 resets,
886 resets_at_epoch_seconds,
887 })
888}
889
890fn value_i64(value: &Value) -> Option<i64> {
891 value
892 .as_i64()
893 .or_else(|| value.as_str()?.parse::<i64>().ok())
894}
895
896fn normalize_kimi_reset(value: &Value) -> Option<String> {
897 value
898 .as_f64()
899 .and_then(format_reset_local)
900 .or_else(|| value.as_str().and_then(normalize_reset_text))
901}
902
903fn kimi_reset_epoch_seconds(value: &Value) -> Option<i64> {
904 value
905 .as_f64()
906 .map(|epoch| {
907 if epoch.abs() >= 1_000_000_000_000.0 {
908 (epoch / 1000.0).trunc() as i64
909 } else {
910 epoch.trunc() as i64
911 }
912 })
913 .or_else(|| value.as_str().and_then(normalize_reset_epoch_seconds))
914}
915
916pub(crate) fn format_reset_local(epoch: f64) -> Option<String> {
920 if !epoch.is_finite() {
921 return None;
922 }
923 let seconds = if epoch.abs() >= 1_000_000_000_000.0 {
924 (epoch / 1000.0).trunc() as i64
925 } else {
926 epoch.trunc() as i64
927 };
928 let local = Local.timestamp_opt(seconds, 0).single()?;
929 Some(format_reset_label(local.fixed_offset()))
930}
931
932pub(crate) fn format_reset_local_seconds(epoch: i64) -> Option<String> {
933 format_reset_local(epoch as f64)
934}
935
936pub(crate) fn normalize_reset_text(value: &str) -> Option<String> {
940 normalize_reset_at(value, Local::now().fixed_offset()).map(format_reset_label)
941}
942
943pub(crate) fn normalize_reset_epoch_seconds(value: &str) -> Option<i64> {
944 normalize_reset_at(value, Local::now().fixed_offset()).map(|reset| reset.timestamp())
945}
946
947fn normalize_reset_at(value: &str, now: DateTime<FixedOffset>) -> Option<DateTime<FixedOffset>> {
948 let value = value.trim();
949 if value.is_empty() {
950 return None;
951 }
952 if let Ok(epoch) = value.parse::<f64>() {
953 let seconds = if epoch.abs() >= 1_000_000_000_000.0 {
954 (epoch / 1000.0).trunc() as i64
955 } else {
956 epoch.trunc() as i64
957 };
958 return Local
959 .timestamp_opt(seconds, 0)
960 .single()
961 .map(|reset| reset.fixed_offset());
962 }
963 if let Ok(timestamp) = DateTime::parse_from_rfc3339(value) {
964 return Some(timestamp.with_timezone(&Local).fixed_offset());
965 }
966
967 let value = value
968 .strip_prefix("at ")
969 .unwrap_or(value)
970 .split('(')
971 .next()
972 .unwrap_or(value)
973 .trim()
974 .trim_end_matches(',');
975 let parse_time = |value: &str| {
976 let value = value
977 .to_ascii_lowercase()
978 .chars()
979 .filter(|ch| !ch.is_whitespace())
980 .collect::<String>();
981 let value = ["am", "pm"]
982 .into_iter()
983 .find_map(|suffix| {
984 let hour = value.strip_suffix(suffix)?;
985 (!hour.contains(':')).then(|| format!("{hour}:00{suffix}"))
986 })
987 .unwrap_or(value);
988 ["%I:%M%P", "%I%P", "%H:%M"]
989 .iter()
990 .find_map(|format| NaiveTime::parse_from_str(&value, format).ok())
991 };
992
993 let dated_time = value.split_once(" at ").or_else(|| {
996 value
997 .split_once(',')
998 .map(|(date, time)| (date, time.trim()))
999 });
1000 if let Some((date, time)) = dated_time {
1001 let time = parse_time(time.trim())?;
1002 let date = date.trim().trim_end_matches(',');
1003 let date = match date.to_ascii_lowercase().as_str() {
1004 "today" => now.date_naive(),
1005 "tomorrow" => now.date_naive().checked_add_days(Days::new(1))?,
1006 _ => NaiveDate::parse_from_str(
1007 &format!("{} {}", date.replace(',', ""), now.year()),
1008 "%b %e %Y",
1009 )
1010 .ok()?,
1011 };
1012 return now
1013 .timezone()
1014 .from_local_datetime(&date.and_time(time))
1015 .single();
1016 }
1017
1018 let time = parse_time(value)?;
1019 let mut date = now.date_naive();
1020 let mut reset = now
1021 .timezone()
1022 .from_local_datetime(&date.and_time(time))
1023 .single()?;
1024 if reset <= now {
1025 date = date.checked_add_days(Days::new(1))?;
1026 reset = now
1027 .timezone()
1028 .from_local_datetime(&date.and_time(time))
1029 .single()?;
1030 }
1031 Some(reset)
1032}
1033
1034fn format_reset_label(reset: DateTime<FixedOffset>) -> String {
1036 reset.format("%H:%M %b %-d").to_string()
1037}
1038
1039#[cfg(test)]
1040mod tests;