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
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    /// Refresh each profile independently so one slow harness cannot delay the
99    /// others. `on_report` runs per profile in completion order, so fast
100    /// harnesses report without waiting for the slowest one in the batch.
101    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    /// Stop the cached clients whose profiles are not in `keep`. Every batch
141    /// carries the whole configured set, so a client left over from an earlier
142    /// batch belongs to a profile the configuration no longer has. Each one
143    /// owns a live `codex app-server` child that nothing would ever hand back
144    /// to a refresh again, so it would run until the controller exits.
145    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        // A Codex profile that authenticates with an API key against a custom
188        // provider has no ChatGPT login to refresh and no ChatGPT rate-limit
189        // windows to read. Its quota, when the provider publishes one, comes
190        // from the provider's own endpoint.
191        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                // A provider that publishes no quota endpoint bills by usage,
216                // so there is no allowance to report. Saying "API" rather than
217                // raising an error keeps the dashboard from showing the profile
218                // as unavailable and lets the utility ranker treat it as
219                // healthy, which matches how it actually behaves.
220                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        // Grok Build publishes no HTTP quota endpoint. Its own usage view polls
318        // an ACP billing extension, and so does Mjolnir.
319        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                        // Grok Build reports a share of the allowance, not the
329                        // credit amounts behind it.
330                        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
388/// Shortest gap to expiry Hel will leave a Codex login sitting at. A token with
389/// a long life gets a proportionally wider margin, because the poll interval
390/// buys nothing once the whole life is short.
391const CODEX_MINIMUM_REFRESH_MARGIN_MS: i64 = 60 * 60 * 1000;
392
393/// Whether the profile's Codex login is close enough to expiry that a container
394/// copy of it could reach the single-use refresh race before the next poll.
395async 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
413/// The margin is the larger of one hour and a tenth of the token's life, where
414/// the life is what the last refresh bought. A credential that says nothing
415/// about its own age falls back to the flat hour.
416fn 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    /// Whether this is a different pair from `other`. A refresh rotates the
502    /// access token, the refresh token and the expiry together, so those three
503    /// fields are what tells two pairs apart; the rest only describes them.
504    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
566/// Hand back a usable Kimi Code access token, refreshing the stored pair when
567/// it is stale or when the server rejected it. The refresh runs under the
568/// lock the Kimi Code CLI also takes, and the pair is re-read once the lock is
569/// held, so a refresh another process just finished is used instead of
570/// spending its new refresh token again.
571async 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    // Held until this function returns, released on drop.
585    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
680/// The Kimi Code CLI serializes token refreshes with `proper-lockfile` on the
681/// directory `oauth/kimi-code.lock` (`stale: 5_000`): a holder keeps the
682/// directory's modification time moving, and a lock whose time stopped for
683/// longer than the stale window is abandoned and may be removed. Mjolnir takes
684/// the same directory the same way, so its refresh and the CLI's never spend
685/// the same single-use refresh token.
686struct KimiRefreshLock {
687    path: std::path::PathBuf,
688    heartbeat: tokio::task::JoinHandle<()>,
689}
690
691/// How often a holder republishes the lock's modification time. It fits inside
692/// the CLI's 5 second stale window several times over.
693const KIMI_LOCK_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(1);
694/// How long a lock's modification time must be still before Mjolnir removes
695/// it as abandoned. Longer than the CLI's own 5 seconds, so Mjolnir never
696/// breaks a lock the CLI would still consider live.
697const 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        // proper-lockfile locks `<file>.lock` for a file that must exist.
708        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
762/// The lock directory's modification time, or `None` when it is gone.
763fn 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
771/// Publish a lock directory's modification time. Windows opens a directory
772/// handle only under backup semantics.
773fn 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
787/// Remove a lock whose holder stopped heartbeating, so a holder killed
788/// mid-refresh cannot block every later refresh. Returns whether the caller
789/// should retry the create at once.
790async 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    // A modification time in the future (the CLI rounds its writes up) is not
800    // stale.
801    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
916/// Format a Unix reset timestamp as wall-clock time in the machine's local
917/// time zone. Accepts seconds or milliseconds and rejects non-finite or
918/// out-of-range values.
919pub(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
936/// Normalize a provider's textual reset value to the compact 24-hour form
937/// used by the dashboard. A time-only value is the next occurrence of that
938/// wall-clock time; Claude Code uses this shape for its five-hour window.
939pub(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    // Claude has used both `Aug 14 at 4am` and `Aug 14, 4am` across
994    // releases. Keep the provider punctuation out of the date/time parsers.
995    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
1034/// Pure formatter split from local-zone discovery for deterministic tests.
1035fn format_reset_label(reset: DateTime<FixedOffset>) -> String {
1036    reset.format("%H:%M %b %-d").to_string()
1037}
1038
1039#[cfg(test)]
1040mod tests;