Skip to main content

mj_controller/
quota.rs

1//! One-pane quota collection for Mjolnir harness profiles.
2
3use 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    /// The custom model provider this profile authenticates to with an API
30    /// key, when it has one. A profile using its harness's own login has
31    /// `None` here and keeps the harness's native quota path.
32    pub provider: Option<ProviderCredential>,
33}
34
35/// Where a profile's quota lives when the profile authenticates with an API
36/// key against a provider named in its harness configuration.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ProviderCredential {
39    /// Provider id from the harness configuration, for error messages.
40    pub id: String,
41    /// Host of the provider's base URL, for example `api.z.ai`.
42    pub host: String,
43    pub api_key: String,
44}
45
46impl QuotaRefreshRequest {
47    /// Build the request for one configured profile. The harness home
48    /// environment is composed here so every caller asks for quota the same
49    /// way, and so a provider key is read from exactly one place.
50    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    /// Refresh each profile independently so one slow harness cannot delay the
101    /// others. `on_report` runs per profile in completion order, so fast
102    /// harnesses report without waiting for the slowest one in the batch.
103    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    /// Stop the cached clients whose profiles are not in `keep`. Every batch
143    /// carries the whole configured set, so a client left over from an earlier
144    /// batch belongs to a profile the configuration no longer has. Each one
145    /// owns a live `codex app-server` child that nothing would ever hand back
146    /// to a refresh again, so it would run until the controller exits.
147    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        // A Codex profile that authenticates with an API key against a custom
190        // provider has no ChatGPT login to refresh and no ChatGPT rate-limit
191        // windows to read. Its quota, when the provider publishes one, comes
192        // from the provider's own endpoint.
193        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                // A provider that publishes no quota endpoint bills by usage,
218                // so there is no allowance to report. Saying "API" rather than
219                // raising an error keeps the dashboard from showing the profile
220                // as unavailable and lets the utility ranker treat it as
221                // healthy, which matches how it actually behaves.
222                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        // Grok Build publishes no HTTP quota endpoint. Its own usage view polls
320        // an ACP billing extension, and so does Mjolnir.
321        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                        // Grok Build reports a share of the allowance, not the
331                        // credit amounts behind it.
332                        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
390/// Shortest gap to expiry Hel will leave a Codex login sitting at. A token with
391/// a long life gets a proportionally wider margin, because the poll interval
392/// buys nothing once the whole life is short.
393const CODEX_MINIMUM_REFRESH_MARGIN_MS: i64 = 60 * 60 * 1000;
394
395/// Whether the profile's Codex login is close enough to expiry that a container
396/// copy of it could reach the single-use refresh race before the next poll.
397async 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
415/// The margin is the larger of one hour and a tenth of the token's life, where
416/// the life is what the last refresh bought. A credential that says nothing
417/// about its own age falls back to the flat hour.
418fn 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    /// Whether this is a different pair from `other`. A refresh rotates the
504    /// access token, the refresh token and the expiry together, so those three
505    /// fields are what tells two pairs apart; the rest only describes them.
506    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
568/// Hand back a usable Kimi Code access token, refreshing the stored pair when
569/// it is stale or when the server rejected it. The refresh runs under the
570/// lock the Kimi Code CLI also takes, and the pair is re-read once the lock is
571/// held, so a refresh another process just finished is used instead of
572/// spending its new refresh token again.
573async 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    // Held until this function returns, released on drop.
587    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
682/// The Kimi Code CLI serializes token refreshes with `proper-lockfile` on the
683/// directory `oauth/kimi-code.lock` (`stale: 5_000`): a holder keeps the
684/// directory's modification time moving, and a lock whose time stopped for
685/// longer than the stale window is abandoned and may be removed. Mjolnir takes
686/// the same directory the same way, so its refresh and the CLI's never spend
687/// the same single-use refresh token.
688struct KimiRefreshLock {
689    path: std::path::PathBuf,
690    heartbeat: tokio::task::JoinHandle<()>,
691}
692
693/// How often a holder republishes the lock's modification time. It fits inside
694/// the CLI's 5 second stale window several times over.
695const KIMI_LOCK_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(1);
696/// How long a lock's modification time must be still before Mjolnir removes
697/// it as abandoned. Longer than the CLI's own 5 seconds, so Mjolnir never
698/// breaks a lock the CLI would still consider live.
699const 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        // proper-lockfile locks `<file>.lock` for a file that must exist.
710        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
764/// The lock directory's modification time, or `None` when it is gone.
765fn 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
773/// Publish a lock directory's modification time. Windows opens a directory
774/// handle only under backup semantics.
775fn 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
789/// Remove a lock whose holder stopped heartbeating, so a holder killed
790/// mid-refresh cannot block every later refresh. Returns whether the caller
791/// should retry the create at once.
792async 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    // A modification time in the future (the CLI rounds its writes up) is not
802    // stale.
803    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
918/// Format a Unix reset timestamp as wall-clock time in the machine's local
919/// time zone. Accepts seconds or milliseconds and rejects non-finite or
920/// out-of-range values.
921pub(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
938/// Normalize a provider's textual reset value to the compact 24-hour form
939/// used by the dashboard. A time-only value is the next occurrence of that
940/// wall-clock time; Claude Code uses this shape for its five-hour window.
941pub(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    // Claude has used both `Aug 14 at 4am` and `Aug 14, 4am` across
996    // releases. Keep the provider punctuation out of the date/time parsers.
997    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
1036/// Pure formatter split from local-zone discovery for deterministic tests.
1037fn format_reset_label(reset: DateTime<FixedOffset>) -> String {
1038    reset.format("%H:%M %b %-d").to_string()
1039}
1040
1041#[cfg(test)]
1042mod tests;