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