From 581cae07666a2bec353a0feb934b3884914d05f6 Mon Sep 17 00:00:00 2001 From: shidevil Date: Fri, 1 May 2026 13:22:48 +0000 Subject: [PATCH 01/19] feat: add subscription usage command with Claude, Codex, and Z.ai providers Adds tokscale usage CLI command and TUI tab for monitoring subscription quotas across Claude, Codex, and Z.ai. Supports --json and --light output modes, token refresh, and keychain credential fallback. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 8 +- crates/tokscale-cli/src/commands/mod.rs | 1 + crates/tokscale-cli/src/commands/usage.rs | 592 ++++++++++++++++++++++ crates/tokscale-cli/src/main.rs | 10 + crates/tokscale-cli/src/tui/app.rs | 40 +- crates/tokscale-cli/src/tui/ui/mod.rs | 2 + crates/tokscale-cli/src/tui/ui/usage.rs | 144 ++++++ 7 files changed, 790 insertions(+), 7 deletions(-) create mode 100644 crates/tokscale-cli/src/commands/usage.rs create mode 100644 crates/tokscale-cli/src/tui/ui/usage.rs diff --git a/Cargo.lock b/Cargo.lock index 4a7841a8d..779aaa81d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2120,9 +2120,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-src" -version = "300.5.5+3.5.5" +version = "300.6.0+3.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" dependencies = [ "cc", ] @@ -3372,7 +3372,7 @@ dependencies = [ [[package]] name = "tokscale-cli" -version = "2.0.27" +version = "2.1.0" dependencies = [ "ab_glyph", "anyhow", @@ -3411,7 +3411,7 @@ dependencies = [ [[package]] name = "tokscale-core" -version = "2.0.27" +version = "2.1.0" dependencies = [ "bincode", "chrono", diff --git a/crates/tokscale-cli/src/commands/mod.rs b/crates/tokscale-cli/src/commands/mod.rs index 10ecad686..06b9ec621 100644 --- a/crates/tokscale-cli/src/commands/mod.rs +++ b/crates/tokscale-cli/src/commands/mod.rs @@ -1 +1,2 @@ +pub mod usage; pub mod wrapped; diff --git a/crates/tokscale-cli/src/commands/usage.rs b/crates/tokscale-cli/src/commands/usage.rs new file mode 100644 index 000000000..122af4090 --- /dev/null +++ b/crates/tokscale-cli/src/commands/usage.rs @@ -0,0 +1,592 @@ +use anyhow::Result; +use chrono::{DateTime, Duration, TimeZone, Utc}; +use serde::Deserialize; +use serde_json; + +// ── Shared types ── + +#[derive(Debug, Clone, serde::Serialize)] +pub struct UsageMetric { + pub label: String, + pub used_percent: f64, + pub remaining_percent: f64, + pub remaining_label: Option, + pub resets_at: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct UsageOutput { + pub provider: String, + pub plan: Option, + pub email: Option, + pub metrics: Vec, +} + +// ── Claude ── + +const CLAUDE_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +const CLAUDE_BETA: &str = "oauth-2025-04-20"; + +#[derive(Debug, Deserialize)] +struct ClaudeCredentials { + #[serde(rename = "claudeAiOauth")] + claude_ai_oauth: Option, +} + +#[derive(Debug, Deserialize)] +struct ClaudeOauth { + #[serde(rename = "accessToken")] + access_token: Option, + #[serde(rename = "refreshToken")] + refresh_token: Option, + #[serde(rename = "subscriptionType")] + subscription_type: Option, + #[serde(rename = "rateLimitTier")] + rate_limit_tier: Option, +} + +#[derive(Debug, Deserialize)] +struct ClaudeUsageResponse { + five_hour: Option, + seven_day: Option, + seven_day_opus: Option, + #[allow(dead_code)] + extra_usage: Option, +} + +#[derive(Debug, Deserialize)] +struct Window { + utilization: f64, + resets_at: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct ClaudeExtraUsage { + is_enabled: Option, + used_credits: Option, + monthly_limit: Option, + currency: Option, +} + +#[derive(Debug, Deserialize)] +struct ClaudeTokenRefresh { + access_token: Option, +} + +fn read_claude_keychain() -> Result { + let out = std::process::Command::new("security") + .args(["find-generic-password", "-s", "Claude Code-credentials", "-w"]) + .output()?; + if !out.status.success() { + anyhow::bail!("Keychain lookup failed"); + } + Ok(String::from_utf8(out.stdout)?.trim_end().to_string()) +} + +fn read_claude_credentials() -> Result { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + let path = home.join(".claude").join(".credentials.json"); + let content = if path.exists() { + std::fs::read_to_string(&path)? + } else { + read_claude_keychain()? + }; + Ok(serde_json::from_str(&content)?) +} + +async fn claude_refresh(client: &reqwest::Client, rt: &str) -> Result { + let resp = client + .post("https://platform.claude.com/v1/oauth/token") + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "grant_type": "refresh_token", + "refresh_token": rt, + "client_id": CLAUDE_CLIENT_ID, + "scope": "user:profile user:inference user:sessions:claude_code user:mcp_servers" + })) + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!("Claude token refresh failed (HTTP {})", resp.status()); + } + Ok(resp.json().await?) +} + +async fn claude_fetch(client: &reqwest::Client, token: &str) -> Result { + let resp = client + .get("https://api.anthropic.com/api/oauth/usage") + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .header("anthropic-beta", CLAUDE_BETA) + .send() + .await?; + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + anyhow::bail!("NEEDS_AUTH"); + } + if !status.is_success() { + anyhow::bail!("Claude usage request failed (HTTP {status})"); + } + Ok(resp.json().await?) +} + +fn fetch_claude() -> Result { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + let creds = read_claude_credentials()?; + let oauth = creds.claude_ai_oauth.ok_or_else(|| { + anyhow::anyhow!("No Claude OAuth credentials. Run 'claude' to log in.") + })?; + let access_token = oauth + .access_token + .ok_or_else(|| anyhow::anyhow!("No Claude access token."))?; + let plan = oauth.subscription_type.map(|s| { + let tier = oauth.rate_limit_tier.as_deref().and_then(|t| { + // "default_claude_max_20x" -> "20x", "default_claude_max_5x" -> "5x" + t.rsplit('_').next() + }); + match tier { + Some(mult) => format!("{} {}", capitalize(&s), mult), + None => capitalize(&s), + } + }); + + let client = reqwest::Client::new(); + let resp = match claude_fetch(&client, &access_token).await { + Ok(r) => r, + Err(e) if e.to_string().contains("NEEDS_AUTH") => { + let rt = oauth + .refresh_token + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No refresh token."))?; + let refreshed = claude_refresh(&client, rt).await?; + let new = refreshed + .access_token + .ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?; + claude_fetch(&client, &new).await? + } + Err(e) => return Err(e), + }; + + let mut metrics = Vec::new(); + if let Some(ref w) = resp.five_hour { + metrics.push(window_metric("Session", w)); + } + if let Some(ref w) = resp.seven_day { + metrics.push(window_metric("Weekly", w)); + } + if let Some(ref w) = resp.seven_day_opus { + metrics.push(window_metric("Opus", w)); + } + + Ok(UsageOutput { + provider: "Claude".into(), + plan, + email: None, + metrics, + }) + }) +} + +// ── Codex (OpenAI) ── + +const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; + +#[derive(Debug, Deserialize)] +struct CodexAuth { + tokens: Option, +} + +#[derive(Debug, Deserialize)] +struct CodexTokens { + access_token: Option, + refresh_token: Option, + account_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct CodexUsage { + email: Option, + plan_type: Option, + rate_limit: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct CodexRateLimit { + primary_window: Option, + secondary_window: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct CodexWindow { + used_percent: Option, + reset_at: Option, + #[allow(dead_code)] + limit_window_seconds: Option, +} + +#[derive(Debug, Deserialize)] +struct CodexRefresh { + access_token: Option, +} + +fn read_codex_credentials() -> Result { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + let paths = [ + home.join(".config").join("codex").join("auth.json"), + home.join(".codex").join("auth.json"), + ]; + for p in &paths { + if p.exists() { + let content = std::fs::read_to_string(p)?; + if let Ok(auth) = serde_json::from_str::(&content) { + if auth.tokens.is_some() { + return Ok(auth); + } + } + } + } + anyhow::bail!("No Codex credentials found. Run 'codex' to log in.") +} + +async fn codex_refresh(client: &reqwest::Client, rt: &str) -> Result { + let resp = client + .post("https://auth.openai.com/oauth/token") + .header("Content-Type", "application/x-www-form-urlencoded") + .body(format!( + "grant_type=refresh_token&client_id={CODEX_CLIENT_ID}&refresh_token={rt}" + )) + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!("Codex token refresh failed (HTTP {})", resp.status()); + } + Ok(resp.json().await?) +} + +async fn codex_fetch(client: &reqwest::Client, token: &str, account_id: Option<&str>) -> Result { + let mut req = client + .get("https://chatgpt.com/backend-api/wham/usage") + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/json") + .header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"); + if let Some(id) = account_id { + req = req.header("ChatGPT-Account-Id", id); + } + let resp = req.send().await?; + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + anyhow::bail!("NEEDS_AUTH"); + } + if !status.is_success() { + anyhow::bail!("Codex usage request failed (HTTP {status})"); + } + let body = resp.text().await?; + if body.trim().starts_with('<') { + anyhow::bail!("NEEDS_AUTH"); + } + Ok(serde_json::from_str(&body)?) +} + +fn fetch_codex() -> Result { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + let auth = read_codex_credentials()?; + let tokens = auth + .tokens + .ok_or_else(|| anyhow::anyhow!("No Codex tokens."))?; + let access_token = tokens + .access_token + .ok_or_else(|| anyhow::anyhow!("No Codex access token."))?; + let account_id = tokens.account_id.as_deref(); + + let client = reqwest::Client::new(); + let resp = match codex_fetch(&client, &access_token, account_id).await { + Ok(r) => r, + Err(e) if e.to_string().contains("NEEDS_AUTH") => { + let rt = tokens + .refresh_token + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No refresh token."))?; + let refreshed = codex_refresh(&client, rt).await?; + let new = refreshed + .access_token + .ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?; + codex_fetch(&client, &new, account_id).await? + } + Err(e) => return Err(e), + }; + + let plan = resp.plan_type.as_deref().map(capitalize); + let mut metrics = Vec::new(); + if let Some(ref rl) = resp.rate_limit { + if let Some(ref w) = rl.primary_window { + let pct = w.used_percent.unwrap_or(0).clamp(0, 100) as f64; + metrics.push(UsageMetric { + label: "Session".into(), + used_percent: pct, + remaining_percent: 100.0 - pct, + remaining_label: None, + resets_at: w.reset_at.and_then(|ts| Utc.timestamp_opt(ts, 0).single()) + .map(|dt| dt.to_rfc3339()), + }); + } + if let Some(ref w) = rl.secondary_window { + let pct = w.used_percent.unwrap_or(0).clamp(0, 100) as f64; + metrics.push(UsageMetric { + label: "Weekly".into(), + used_percent: pct, + remaining_percent: 100.0 - pct, + remaining_label: None, + resets_at: w.reset_at.and_then(|ts| Utc.timestamp_opt(ts, 0).single()) + .map(|dt| dt.to_rfc3339()), + }); + } + } + + Ok(UsageOutput { + provider: "Codex".into(), + plan, + email: resp.email, + metrics, + }) + }) +} + +// ── Z.ai ── + +#[derive(Debug, Deserialize)] +struct ZaiQuotaResp { + data: Option, +} + +#[derive(Debug, Deserialize)] +struct ZaiQuotaData { + limits: Option>, + level: Option, +} + +#[derive(Debug, Deserialize)] +struct ZaiLimit { + #[serde(rename = "type")] + limit_type: Option, + #[allow(dead_code)] + usage: Option, + #[allow(dead_code)] + remaining: Option, + percentage: Option, + #[allow(dead_code)] + current_value: Option, + number: Option, + unit: Option, +} + +#[derive(Debug, Deserialize)] +struct ZaiSubResp { + data: Option>, +} + +#[derive(Debug, Deserialize)] +struct ZaiSub { + product_name: Option, + next_renew_time: Option, +} + +async fn zai_fetch_quota(client: &reqwest::Client, key: &str) -> Result { + let resp = client + .get("https://api.z.ai/api/monitor/usage/quota/limit") + .header("Authorization", format!("Bearer {key}")) + .header("Accept", "application/json") + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!("Z.ai quota request failed (HTTP {})", resp.status()); + } + Ok(resp.json().await?) +} + +async fn zai_fetch_sub(client: &reqwest::Client, key: &str) -> Result { + let resp = client + .get("https://api.z.ai/api/biz/subscription/list") + .header("Authorization", format!("Bearer {key}")) + .header("Accept", "application/json") + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!("Z.ai subscription request failed (HTTP {})", resp.status()); + } + Ok(resp.json().await?) +} + +fn fetch_zai() -> Result { + let api_key = std::env::var("ZAI_API_KEY") + .or_else(|_| std::env::var("GLM_API_KEY")) + .map_err(|_| anyhow::anyhow!("No ZAI_API_KEY or GLM_API_KEY set."))?; + + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + let client = reqwest::Client::new(); + let quota = zai_fetch_quota(&client, &api_key).await?; + let sub = zai_fetch_sub(&client, &api_key).await.ok(); + + let plan = sub + .as_ref() + .and_then(|s| s.data.as_ref()) + .and_then(|d| d.first()) + .and_then(|s| s.product_name.clone()) + .or_else(|| quota.data.as_ref().and_then(|d| d.level.clone()).map(|l| capitalize(&l))); + + let mut metrics = Vec::new(); + if let Some(ref limits) = quota.data.as_ref().and_then(|d| d.limits.as_ref()) { + for limit in limits.iter() { + let pct = limit.percentage.unwrap_or(0.0).clamp(0.0, 100.0); + + match limit.limit_type.as_deref() { + Some("TOKENS_LIMIT") => { + let label = match (limit.unit, limit.number) { + (Some(3), Some(5)) => "Session", + (Some(6), Some(1)) => "Monthly", + _ => "Tokens", + }; + metrics.push(UsageMetric { + label: label.into(), + used_percent: pct, + remaining_percent: 100.0 - pct, + remaining_label: None, + resets_at: None, + }); + } + Some("TIME_LIMIT") => { + let remaining_label = limit.remaining.map(|r| format!("{:.0} left", r)); + metrics.push(UsageMetric { + label: "Web Searches".into(), + used_percent: pct, + remaining_percent: 100.0 - pct, + remaining_label, + resets_at: sub + .as_ref() + .and_then(|s| s.data.as_ref()) + .and_then(|d| d.first()) + .and_then(|s| s.next_renew_time.clone()), + }); + } + _ => {} + } + } + } + + Ok(UsageOutput { + provider: "Z.ai".into(), + plan, + email: None, + metrics, + }) + }) +} + +// ── Helpers ── + +fn capitalize(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + Some(f) => f.to_uppercase().collect::() + c.as_str(), + None => s.to_string(), + } +} + +fn window_metric(label: &str, w: &Window) -> UsageMetric { + let used = w.utilization.clamp(0.0, 100.0); + UsageMetric { + label: label.into(), + used_percent: used, + remaining_percent: 100.0 - used, + remaining_label: None, + resets_at: w.resets_at.clone(), + } +} + +// ── Public API ── + +pub fn fetch_all() -> Vec { + let mut results = Vec::new(); + + match fetch_claude() { + Ok(o) => results.push(o), + Err(e) => eprintln!("Claude: {e}"), + } + match fetch_codex() { + Ok(o) => results.push(o), + Err(e) => eprintln!("Codex: {e}"), + } + match fetch_zai() { + Ok(o) => results.push(o), + Err(e) => eprintln!("Z.ai: {e}"), + } + + results +} + +const BAR_WIDTH: usize = 12; +const CARD_WIDTH: usize = 58; + +fn format_reset_time(resets_at: &str) -> String { + let dt = match DateTime::parse_from_rfc3339(resets_at) { + Ok(d) => d.with_timezone(&Utc), + Err(_) => return resets_at.into(), + }; + let diff = dt - Utc::now(); + if diff <= Duration::zero() { + return "resets now".into(); + } + let total_mins = diff.num_minutes(); + if total_mins < 60 { + format!("resets in {total_mins}m") + } else if total_mins < 24 * 60 { + let h = diff.num_hours(); + let m = (diff - Duration::hours(h)).num_minutes(); + if m > 0 { format!("resets in {h}h {m}m") } else { format!("resets in {h}h") } + } else if diff.num_days() < 7 { + format!("resets {} {}", dt.format("%a"), dt.format("%-I%P")) + } else { + format!("resets {}", dt.format("%b %-d")) + } +} + +fn render_ascii_bar(pct: f64) -> String { + let filled = (pct.clamp(0.0, 100.0) / 100.0 * BAR_WIDTH as f64).round() as usize; + format!("[{}{}]", "=".repeat(filled), "-".repeat(BAR_WIDTH - filled)) +} + +fn render_light(output: &UsageOutput) { + println!("╭{}╮", "─".repeat(CARD_WIDTH)); + for m in &output.metrics { + let rem = m.remaining_label.clone().unwrap_or_else(|| format!("{:.0}% left", m.remaining_percent)); + let bar = render_ascii_bar(m.remaining_percent); + let reset = m.resets_at.as_ref().map(|r| format_reset_time(r)).unwrap_or_default(); + println!("│ {:<10}{:<11}{:<14}{:<20}│", m.label, rem, bar, reset); + } + if let Some(ref email) = output.email { + println!("│ {: Result<()> { + let outputs = fetch_all(); + if json { + println!("{}", serde_json::to_string_pretty(&outputs)?); + } else { + for o in &outputs { + render_light(o); + } + } + Ok(()) +} diff --git a/crates/tokscale-cli/src/main.rs b/crates/tokscale-cli/src/main.rs index 999f5c7bf..142d6045f 100644 --- a/crates/tokscale-cli/src/main.rs +++ b/crates/tokscale-cli/src/main.rs @@ -242,6 +242,13 @@ enum Commands { #[arg(long, help = "Disable loading spinner (for scripting)")] no_spinner: bool, }, + #[command(about = "Show subscription usage and quota for AI providers")] + Usage { + #[arg(long, help = "Output as JSON")] + json: bool, + #[arg(long, help = "Light terminal output (no TUI)")] + light: bool, + }, #[command(about = "Cursor IDE integration commands")] Cursor { #[command(subcommand)] @@ -580,6 +587,9 @@ fn main() -> Result<()> { reject_unsupported_home_override(&cli.home, "antigravity")?; run_antigravity_command(subcommand) } + Some(Commands::Usage { json, light }) => { + commands::usage::run(json, light) + } Some(Commands::DeleteSubmittedData) => { reject_unsupported_home_override(&cli.home, "delete-submitted-data")?; run_delete_data_command() diff --git a/crates/tokscale-cli/src/tui/app.rs b/crates/tokscale-cli/src/tui/app.rs index fcfd328ee..4eea9b71d 100644 --- a/crates/tokscale-cli/src/tui/app.rs +++ b/crates/tokscale-cli/src/tui/app.rs @@ -33,6 +33,7 @@ pub struct TuiConfig { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Tab { Overview, + Usage, Models, Daily, Hourly, @@ -44,6 +45,7 @@ impl Tab { pub fn all() -> &'static [Tab] { &[ Tab::Overview, + Tab::Usage, Tab::Models, Tab::Daily, Tab::Hourly, @@ -55,6 +57,7 @@ impl Tab { pub fn as_str(&self) -> &'static str { match self { Tab::Overview => "Overview", + Tab::Usage => "Usage", Tab::Models => "Models", Tab::Daily => "Daily", Tab::Hourly => "Hourly", @@ -66,6 +69,7 @@ impl Tab { pub fn short_name(&self) -> &'static str { match self { Tab::Overview => "Ovw", + Tab::Usage => "Use", Tab::Models => "Mod", Tab::Daily => "Day", Tab::Hourly => "Hr", @@ -76,7 +80,8 @@ impl Tab { pub fn next(self) -> Tab { match self { - Tab::Overview => Tab::Models, + Tab::Overview => Tab::Usage, + Tab::Usage => Tab::Models, Tab::Models => Tab::Daily, Tab::Daily => Tab::Hourly, Tab::Hourly => Tab::Stats, @@ -88,7 +93,8 @@ impl Tab { pub fn prev(self) -> Tab { match self { Tab::Overview => Tab::Agents, - Tab::Models => Tab::Overview, + Tab::Usage => Tab::Overview, + Tab::Models => Tab::Usage, Tab::Daily => Tab::Models, Tab::Hourly => Tab::Daily, Tab::Stats => Tab::Hourly, @@ -189,6 +195,8 @@ pub struct App { pub hourly_view_mode: HourlyViewMode, pub model_shade_map: HashMap, + + pub subscription_usage: Vec, } impl App { @@ -277,6 +285,7 @@ impl App { dialog_needs_reload, hourly_view_mode: HourlyViewMode::default(), model_shade_map: HashMap::new(), + subscription_usage: Vec::new(), }; app.build_model_shade_map(); Ok(app) @@ -430,6 +439,9 @@ impl App { self.set_status("Refresh already in progress"); } else { self.needs_reload = true; + if self.current_tab == Tab::Usage { + self.fetch_subscription_usage(); + } } } KeyCode::Char('R') if key.modifiers.contains(KeyModifiers::SHIFT) => { @@ -466,6 +478,9 @@ impl App { KeyCode::Char('g') => { self.open_group_by_picker(); } + KeyCode::Char('u') if self.current_tab == Tab::Usage => { + self.fetch_subscription_usage(); + } KeyCode::Enter if self.current_tab == Tab::Stats => { self.handle_graph_selection(); } @@ -480,6 +495,16 @@ impl App { false } + pub fn fetch_subscription_usage(&mut self) { + self.subscription_usage = crate::commands::usage::fetch_all(); + if !self.subscription_usage.is_empty() { + self.status_message = Some("Usage data loaded".into()); + } else { + self.status_message = Some("No usage data available".into()); + } + self.status_message_time = Some(std::time::Instant::now()); + } + pub fn handle_mouse_event(&mut self, event: MouseEvent) { if self.dialog_stack.is_active() { self.dialog_stack.handle_mouse(event); @@ -581,6 +606,10 @@ impl App { self.current_tab = target; + if target == Tab::Usage && self.subscription_usage.is_empty() { + self.fetch_subscription_usage(); + } + let (field, dir) = self.tab_sort_state .get(&target) @@ -714,6 +743,11 @@ impl App { 0 } } + Tab::Usage => self + .subscription_usage + .iter() + .map(|u| u.metrics.len()) + .sum(), } } @@ -894,7 +928,7 @@ impl App { h.cost ) }), - Tab::Stats => None, + Tab::Stats | Tab::Usage => None, }; if let Some(text) = text { diff --git a/crates/tokscale-cli/src/tui/ui/mod.rs b/crates/tokscale-cli/src/tui/ui/mod.rs index 917c9832c..7d247a83f 100644 --- a/crates/tokscale-cli/src/tui/ui/mod.rs +++ b/crates/tokscale-cli/src/tui/ui/mod.rs @@ -10,6 +10,7 @@ mod models; mod overview; pub mod spinner; mod stats; +mod usage; pub(crate) mod widgets; use ratatui::prelude::*; @@ -49,6 +50,7 @@ pub fn render(frame: &mut Frame, app: &mut App) { Tab::Daily => daily::render(frame, app, chunks[1]), Tab::Hourly => hourly::render(frame, app, chunks[1]), Tab::Stats => stats::render(frame, app, chunks[1]), + Tab::Usage => usage::render(frame, app, chunks[1]), } } diff --git a/crates/tokscale-cli/src/tui/ui/usage.rs b/crates/tokscale-cli/src/tui/ui/usage.rs new file mode 100644 index 000000000..5d64935d2 --- /dev/null +++ b/crates/tokscale-cli/src/tui/ui/usage.rs @@ -0,0 +1,144 @@ +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::tui::app::App; + +const BAR_WIDTH: usize = 20; + +fn render_ascii_bar(remaining_percent: f64) -> String { + let pct = remaining_percent.clamp(0.0, 100.0) / 100.0; + let filled = (pct * BAR_WIDTH as f64).round() as usize; + let empty = BAR_WIDTH - filled; + format!("[{}{}]", "=".repeat(filled), "-".repeat(empty)) +} + +fn format_reset_time(resets_at: &str) -> String { + use chrono::{DateTime, Duration, Utc}; + let dt = match DateTime::parse_from_rfc3339(resets_at) { + Ok(d) => d.with_timezone(&Utc), + Err(_) => return format!("resets {resets_at}"), + }; + let diff = dt - Utc::now(); + if diff <= Duration::zero() { + return "resets now".into(); + } + let total_mins = diff.num_minutes(); + if total_mins < 60 { + format!("resets in {total_mins}m") + } else if total_mins < 24 * 60 { + let hours = diff.num_hours(); + let mins = (diff - Duration::hours(hours)).num_minutes(); + if mins > 0 { + format!("resets in {hours}h {mins}m") + } else { + format!("resets in {hours}h") + } + } else if diff.num_days() < 7 { + format!("resets {} {}", dt.format("%a"), dt.format("%-I%P")) + } else { + format!("resets {}", dt.format("%b %-d")) + } +} + +pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(app.theme.border)) + .title(" Subscription Usage ") + .title_style(Style::default().fg(app.theme.foreground)) + .style(Style::default().bg(app.theme.background)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + if app.subscription_usage.is_empty() { + render_loading(frame, app, inner); + } else { + render_loaded(frame, app, inner, &app.subscription_usage); + } +} + +fn render_loading(frame: &mut Frame, app: &App, area: Rect) { + let center = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(40), + Constraint::Length(3), + Constraint::Percentage(40), + ]) + .split(area)[1]; + + let msg = if app.data.loading { + "Loading subscription data..." + } else { + "Press 'u' to fetch subscription usage" + }; + let paragraph = Paragraph::new(msg) + .style(Style::default().fg(app.theme.muted)) + .alignment(Alignment::Center); + frame.render_widget(paragraph, center); +} + +fn render_loaded(frame: &mut Frame, app: &App, area: Rect, outputs: &[crate::commands::usage::UsageOutput]) { + let mut lines: Vec = Vec::new(); + + for (i, output) in outputs.iter().enumerate() { + if i > 0 { + lines.push(Line::from("")); + } + + lines.push(Line::from(Span::styled( + format!(" {} ", output.provider), + Style::default().fg(app.theme.foreground).add_modifier(Modifier::BOLD), + ))); + + for m in &output.metrics { + let remaining = m.remaining_label.clone().unwrap_or_else(|| format!("{:.0}% left", m.remaining_percent)); + let bar = render_ascii_bar(m.remaining_percent); + let reset = m + .resets_at + .as_ref() + .map(|r| format_reset_time(r)) + .unwrap_or_default(); + + let label = Span::styled( + format!(" {:<12}", m.label), + Style::default().fg(app.theme.foreground), + ); + let value = Span::styled( + format!("{:<11}", remaining), + Style::default().fg(app.theme.foreground), + ); + let bar_span = Span::styled( + format!("{:<24}", bar), + Style::default() + .fg(if m.remaining_percent < 10.0 { + Color::Red + } else if m.remaining_percent < 25.0 { + Color::Yellow + } else { + app.theme.accent + }), + ); + let reset_span = Span::styled(reset, Style::default().fg(app.theme.muted)); + + lines.push(Line::from(vec![label, value, bar_span, reset_span])); + } + + if let Some(ref email) = output.email { + lines.push(Line::from(Span::styled( + format!(" {:<12}{email}", "Account"), + Style::default().fg(app.theme.muted), + ))); + } + if let Some(ref plan) = output.plan { + lines.push(Line::from(Span::styled( + format!(" {:<12}{plan}", "Plan"), + Style::default().fg(app.theme.muted), + ))); + } + } + + let paragraph = Paragraph::new(lines); + frame.render_widget(paragraph, area); +} From d1cba85f676f896aa17818311770b45c1604fe58 Mon Sep 17 00:00:00 2001 From: shidevil Date: Fri, 1 May 2026 14:26:41 +0000 Subject: [PATCH 02/19] feat(usage): add Amp, Copilot, Kimi, MiniMax providers; refactor to module directory Refactor usage.rs into commands/usage/ directory with per-provider modules for better maintainability. Add four new subscription quota providers: - Amp: reads API key from ~/.local/share/amp/secrets.json, parses displayText from ampcode.com/api/internal - GitHub Copilot: reads token from macOS keychain or ~/.config/gh/hosts.yml, fetches quota from api.github.com/copilot_internal/user - Kimi: reads OAuth credentials from ~/.kimi/credentials/kimi-code.json, fetches usage from api.kimi.com/coding/v1/usages with token refresh - MiniMax: reads API key from MINIMAX_API_KEY env var, fetches from api.minimax.io/v1/api/openplatform/coding_plan/remains Update README.md with subscription usage documentation and provider table. Co-Authored-By: Claude Opus 4.7 --- README.md | 50 +- crates/tokscale-cli/src/commands/usage.rs | 592 ------------------ crates/tokscale-cli/src/commands/usage/amp.rs | 151 +++++ .../tokscale-cli/src/commands/usage/claude.rs | 164 +++++ .../tokscale-cli/src/commands/usage/codex.rs | 170 +++++ .../src/commands/usage/copilot.rs | 237 +++++++ .../src/commands/usage/helpers.rs | 52 ++ .../tokscale-cli/src/commands/usage/kimi.rs | 214 +++++++ .../src/commands/usage/minimax.rs | 165 +++++ crates/tokscale-cli/src/commands/usage/mod.rs | 88 +++ crates/tokscale-cli/src/commands/usage/zai.rs | 133 ++++ 11 files changed, 1423 insertions(+), 593 deletions(-) delete mode 100644 crates/tokscale-cli/src/commands/usage.rs create mode 100644 crates/tokscale-cli/src/commands/usage/amp.rs create mode 100644 crates/tokscale-cli/src/commands/usage/claude.rs create mode 100644 crates/tokscale-cli/src/commands/usage/codex.rs create mode 100644 crates/tokscale-cli/src/commands/usage/copilot.rs create mode 100644 crates/tokscale-cli/src/commands/usage/helpers.rs create mode 100644 crates/tokscale-cli/src/commands/usage/kimi.rs create mode 100644 crates/tokscale-cli/src/commands/usage/minimax.rs create mode 100644 crates/tokscale-cli/src/commands/usage/mod.rs create mode 100644 crates/tokscale-cli/src/commands/usage/zai.rs diff --git a/README.md b/README.md index 08ee5bb1e..23b95d2f0 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ In the age of AI-assisted development, **tokens are the new energy**. They power - [Social](#social) - [Cursor IDE Commands](#cursor-ide-commands) - [Antigravity Commands](#antigravity-commands) + - [Subscription Usage](#subscription-usage) - [Example Output](#example-output---light-version) - [Configuration](#configuration) - [Environment Variables](#environment-variables) @@ -242,7 +243,7 @@ tokscale models --json > report.json # Save to file The interactive TUI mode provides: -- **6 Views**: Overview (chart + top models), Models, Daily, Hourly, Stats (contribution graph), Agents +- **6 Views**: Overview (chart + top models), Usage (subscription quotas), Models, Daily, Hourly, Stats (contribution graph), Agents - **Keyboard Navigation**: - `1-6` or `←/→/Tab`: Switch views - `↑/↓`: Navigate lists @@ -469,6 +470,53 @@ tokscale antigravity purge-cache **How it works**: `tokscale antigravity sync` discovers local Antigravity session candidates, fetches confirmed usage data from the local language server RPC, and stores normalized JSONL artifacts for tokscale-core to parse later. Run sync before reports if you want the freshest Antigravity data. +### Subscription Usage + +Tokscale can fetch and display your real-time subscription quota across AI providers. This shows how much of your plan you've used and when limits reset. + +```bash +# Show subscription usage for all detected providers +tokscale usage + +# Output as JSON (for scripting) +tokscale usage --json + +# Lightweight terminal output (no TUI) +tokscale usage --light +``` + +In the TUI, press `2` or navigate to the **Usage** tab to see subscription data. Press `u` or `r` to refresh. + +#### Supported Providers + +| Provider | Auth Method | Metrics | Setup | +|----------|-------------|---------|-------| +| **Claude** | OAuth (credentials file or macOS Keychain) | Session (5hr), Weekly, Opus quotas | Run `claude` to log in | +| **Codex** (OpenAI) | OAuth (`~/.config/codex/auth.json` or `~/.codex/auth.json`) | Session, Weekly quotas | Run `codex` to log in | +| **Z.ai** | API key (env var) | Token limits, Web Searches | Set `ZAI_API_KEY` or `GLM_API_KEY` | +| **Amp** | API key (`~/.local/share/amp/secrets.json`) | Free tier balance, Credits | Run `amp` to log in | +| **GitHub Copilot** | GitHub token (keychain or `~/.config/gh/hosts.yml`) | Premium interactions, Chat quotas | Run `gh auth login` | +| **Kimi** | OAuth (`~/.kimi/credentials/kimi-code.json`) | Session, Weekly quotas | Run `kimi` to log in | +| **MiniMax** | API key (env var) | Prompt quotas per model | Set `MINIMAX_API_KEY` or `MINIMAX_API_TOKEN` | + +Providers are auto-detected — only those with valid credentials are shown. If a provider is missing, ensure you've logged in or set the required environment variable. + +#### Example Output + +``` +╭──────────────────────────────────────────────────────────╮ +│ Session 85% left [=========---] resets in 2h 15m │ +│ Weekly 72% left [========----] resets Fri 3pm │ +│ Plan Max 20x │ +╰──────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────╮ +│ Session 40% left [=====-------] resets in 4h 30m │ +│ Weekly 90% left [==========--] resets Mon 12am │ +│ Account user@example.com │ +│ Plan Pro │ +╰──────────────────────────────────────────────────────────╯ +``` + ### Example Output (`--light` version) CLI Light diff --git a/crates/tokscale-cli/src/commands/usage.rs b/crates/tokscale-cli/src/commands/usage.rs deleted file mode 100644 index 122af4090..000000000 --- a/crates/tokscale-cli/src/commands/usage.rs +++ /dev/null @@ -1,592 +0,0 @@ -use anyhow::Result; -use chrono::{DateTime, Duration, TimeZone, Utc}; -use serde::Deserialize; -use serde_json; - -// ── Shared types ── - -#[derive(Debug, Clone, serde::Serialize)] -pub struct UsageMetric { - pub label: String, - pub used_percent: f64, - pub remaining_percent: f64, - pub remaining_label: Option, - pub resets_at: Option, -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct UsageOutput { - pub provider: String, - pub plan: Option, - pub email: Option, - pub metrics: Vec, -} - -// ── Claude ── - -const CLAUDE_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; -const CLAUDE_BETA: &str = "oauth-2025-04-20"; - -#[derive(Debug, Deserialize)] -struct ClaudeCredentials { - #[serde(rename = "claudeAiOauth")] - claude_ai_oauth: Option, -} - -#[derive(Debug, Deserialize)] -struct ClaudeOauth { - #[serde(rename = "accessToken")] - access_token: Option, - #[serde(rename = "refreshToken")] - refresh_token: Option, - #[serde(rename = "subscriptionType")] - subscription_type: Option, - #[serde(rename = "rateLimitTier")] - rate_limit_tier: Option, -} - -#[derive(Debug, Deserialize)] -struct ClaudeUsageResponse { - five_hour: Option, - seven_day: Option, - seven_day_opus: Option, - #[allow(dead_code)] - extra_usage: Option, -} - -#[derive(Debug, Deserialize)] -struct Window { - utilization: f64, - resets_at: Option, -} - -#[derive(Debug, Deserialize)] -#[allow(dead_code)] -struct ClaudeExtraUsage { - is_enabled: Option, - used_credits: Option, - monthly_limit: Option, - currency: Option, -} - -#[derive(Debug, Deserialize)] -struct ClaudeTokenRefresh { - access_token: Option, -} - -fn read_claude_keychain() -> Result { - let out = std::process::Command::new("security") - .args(["find-generic-password", "-s", "Claude Code-credentials", "-w"]) - .output()?; - if !out.status.success() { - anyhow::bail!("Keychain lookup failed"); - } - Ok(String::from_utf8(out.stdout)?.trim_end().to_string()) -} - -fn read_claude_credentials() -> Result { - let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); - let path = home.join(".claude").join(".credentials.json"); - let content = if path.exists() { - std::fs::read_to_string(&path)? - } else { - read_claude_keychain()? - }; - Ok(serde_json::from_str(&content)?) -} - -async fn claude_refresh(client: &reqwest::Client, rt: &str) -> Result { - let resp = client - .post("https://platform.claude.com/v1/oauth/token") - .header("Content-Type", "application/json") - .json(&serde_json::json!({ - "grant_type": "refresh_token", - "refresh_token": rt, - "client_id": CLAUDE_CLIENT_ID, - "scope": "user:profile user:inference user:sessions:claude_code user:mcp_servers" - })) - .send() - .await?; - if !resp.status().is_success() { - anyhow::bail!("Claude token refresh failed (HTTP {})", resp.status()); - } - Ok(resp.json().await?) -} - -async fn claude_fetch(client: &reqwest::Client, token: &str) -> Result { - let resp = client - .get("https://api.anthropic.com/api/oauth/usage") - .header("Authorization", format!("Bearer {token}")) - .header("Accept", "application/json") - .header("Content-Type", "application/json") - .header("anthropic-beta", CLAUDE_BETA) - .send() - .await?; - let status = resp.status(); - if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - anyhow::bail!("NEEDS_AUTH"); - } - if !status.is_success() { - anyhow::bail!("Claude usage request failed (HTTP {status})"); - } - Ok(resp.json().await?) -} - -fn fetch_claude() -> Result { - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(async { - let creds = read_claude_credentials()?; - let oauth = creds.claude_ai_oauth.ok_or_else(|| { - anyhow::anyhow!("No Claude OAuth credentials. Run 'claude' to log in.") - })?; - let access_token = oauth - .access_token - .ok_or_else(|| anyhow::anyhow!("No Claude access token."))?; - let plan = oauth.subscription_type.map(|s| { - let tier = oauth.rate_limit_tier.as_deref().and_then(|t| { - // "default_claude_max_20x" -> "20x", "default_claude_max_5x" -> "5x" - t.rsplit('_').next() - }); - match tier { - Some(mult) => format!("{} {}", capitalize(&s), mult), - None => capitalize(&s), - } - }); - - let client = reqwest::Client::new(); - let resp = match claude_fetch(&client, &access_token).await { - Ok(r) => r, - Err(e) if e.to_string().contains("NEEDS_AUTH") => { - let rt = oauth - .refresh_token - .as_ref() - .ok_or_else(|| anyhow::anyhow!("No refresh token."))?; - let refreshed = claude_refresh(&client, rt).await?; - let new = refreshed - .access_token - .ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?; - claude_fetch(&client, &new).await? - } - Err(e) => return Err(e), - }; - - let mut metrics = Vec::new(); - if let Some(ref w) = resp.five_hour { - metrics.push(window_metric("Session", w)); - } - if let Some(ref w) = resp.seven_day { - metrics.push(window_metric("Weekly", w)); - } - if let Some(ref w) = resp.seven_day_opus { - metrics.push(window_metric("Opus", w)); - } - - Ok(UsageOutput { - provider: "Claude".into(), - plan, - email: None, - metrics, - }) - }) -} - -// ── Codex (OpenAI) ── - -const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; - -#[derive(Debug, Deserialize)] -struct CodexAuth { - tokens: Option, -} - -#[derive(Debug, Deserialize)] -struct CodexTokens { - access_token: Option, - refresh_token: Option, - account_id: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -struct CodexUsage { - email: Option, - plan_type: Option, - rate_limit: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -struct CodexRateLimit { - primary_window: Option, - secondary_window: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -struct CodexWindow { - used_percent: Option, - reset_at: Option, - #[allow(dead_code)] - limit_window_seconds: Option, -} - -#[derive(Debug, Deserialize)] -struct CodexRefresh { - access_token: Option, -} - -fn read_codex_credentials() -> Result { - let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); - let paths = [ - home.join(".config").join("codex").join("auth.json"), - home.join(".codex").join("auth.json"), - ]; - for p in &paths { - if p.exists() { - let content = std::fs::read_to_string(p)?; - if let Ok(auth) = serde_json::from_str::(&content) { - if auth.tokens.is_some() { - return Ok(auth); - } - } - } - } - anyhow::bail!("No Codex credentials found. Run 'codex' to log in.") -} - -async fn codex_refresh(client: &reqwest::Client, rt: &str) -> Result { - let resp = client - .post("https://auth.openai.com/oauth/token") - .header("Content-Type", "application/x-www-form-urlencoded") - .body(format!( - "grant_type=refresh_token&client_id={CODEX_CLIENT_ID}&refresh_token={rt}" - )) - .send() - .await?; - if !resp.status().is_success() { - anyhow::bail!("Codex token refresh failed (HTTP {})", resp.status()); - } - Ok(resp.json().await?) -} - -async fn codex_fetch(client: &reqwest::Client, token: &str, account_id: Option<&str>) -> Result { - let mut req = client - .get("https://chatgpt.com/backend-api/wham/usage") - .header("Authorization", format!("Bearer {token}")) - .header("Accept", "application/json") - .header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"); - if let Some(id) = account_id { - req = req.header("ChatGPT-Account-Id", id); - } - let resp = req.send().await?; - let status = resp.status(); - if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - anyhow::bail!("NEEDS_AUTH"); - } - if !status.is_success() { - anyhow::bail!("Codex usage request failed (HTTP {status})"); - } - let body = resp.text().await?; - if body.trim().starts_with('<') { - anyhow::bail!("NEEDS_AUTH"); - } - Ok(serde_json::from_str(&body)?) -} - -fn fetch_codex() -> Result { - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(async { - let auth = read_codex_credentials()?; - let tokens = auth - .tokens - .ok_or_else(|| anyhow::anyhow!("No Codex tokens."))?; - let access_token = tokens - .access_token - .ok_or_else(|| anyhow::anyhow!("No Codex access token."))?; - let account_id = tokens.account_id.as_deref(); - - let client = reqwest::Client::new(); - let resp = match codex_fetch(&client, &access_token, account_id).await { - Ok(r) => r, - Err(e) if e.to_string().contains("NEEDS_AUTH") => { - let rt = tokens - .refresh_token - .as_ref() - .ok_or_else(|| anyhow::anyhow!("No refresh token."))?; - let refreshed = codex_refresh(&client, rt).await?; - let new = refreshed - .access_token - .ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?; - codex_fetch(&client, &new, account_id).await? - } - Err(e) => return Err(e), - }; - - let plan = resp.plan_type.as_deref().map(capitalize); - let mut metrics = Vec::new(); - if let Some(ref rl) = resp.rate_limit { - if let Some(ref w) = rl.primary_window { - let pct = w.used_percent.unwrap_or(0).clamp(0, 100) as f64; - metrics.push(UsageMetric { - label: "Session".into(), - used_percent: pct, - remaining_percent: 100.0 - pct, - remaining_label: None, - resets_at: w.reset_at.and_then(|ts| Utc.timestamp_opt(ts, 0).single()) - .map(|dt| dt.to_rfc3339()), - }); - } - if let Some(ref w) = rl.secondary_window { - let pct = w.used_percent.unwrap_or(0).clamp(0, 100) as f64; - metrics.push(UsageMetric { - label: "Weekly".into(), - used_percent: pct, - remaining_percent: 100.0 - pct, - remaining_label: None, - resets_at: w.reset_at.and_then(|ts| Utc.timestamp_opt(ts, 0).single()) - .map(|dt| dt.to_rfc3339()), - }); - } - } - - Ok(UsageOutput { - provider: "Codex".into(), - plan, - email: resp.email, - metrics, - }) - }) -} - -// ── Z.ai ── - -#[derive(Debug, Deserialize)] -struct ZaiQuotaResp { - data: Option, -} - -#[derive(Debug, Deserialize)] -struct ZaiQuotaData { - limits: Option>, - level: Option, -} - -#[derive(Debug, Deserialize)] -struct ZaiLimit { - #[serde(rename = "type")] - limit_type: Option, - #[allow(dead_code)] - usage: Option, - #[allow(dead_code)] - remaining: Option, - percentage: Option, - #[allow(dead_code)] - current_value: Option, - number: Option, - unit: Option, -} - -#[derive(Debug, Deserialize)] -struct ZaiSubResp { - data: Option>, -} - -#[derive(Debug, Deserialize)] -struct ZaiSub { - product_name: Option, - next_renew_time: Option, -} - -async fn zai_fetch_quota(client: &reqwest::Client, key: &str) -> Result { - let resp = client - .get("https://api.z.ai/api/monitor/usage/quota/limit") - .header("Authorization", format!("Bearer {key}")) - .header("Accept", "application/json") - .send() - .await?; - if !resp.status().is_success() { - anyhow::bail!("Z.ai quota request failed (HTTP {})", resp.status()); - } - Ok(resp.json().await?) -} - -async fn zai_fetch_sub(client: &reqwest::Client, key: &str) -> Result { - let resp = client - .get("https://api.z.ai/api/biz/subscription/list") - .header("Authorization", format!("Bearer {key}")) - .header("Accept", "application/json") - .send() - .await?; - if !resp.status().is_success() { - anyhow::bail!("Z.ai subscription request failed (HTTP {})", resp.status()); - } - Ok(resp.json().await?) -} - -fn fetch_zai() -> Result { - let api_key = std::env::var("ZAI_API_KEY") - .or_else(|_| std::env::var("GLM_API_KEY")) - .map_err(|_| anyhow::anyhow!("No ZAI_API_KEY or GLM_API_KEY set."))?; - - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(async { - let client = reqwest::Client::new(); - let quota = zai_fetch_quota(&client, &api_key).await?; - let sub = zai_fetch_sub(&client, &api_key).await.ok(); - - let plan = sub - .as_ref() - .and_then(|s| s.data.as_ref()) - .and_then(|d| d.first()) - .and_then(|s| s.product_name.clone()) - .or_else(|| quota.data.as_ref().and_then(|d| d.level.clone()).map(|l| capitalize(&l))); - - let mut metrics = Vec::new(); - if let Some(ref limits) = quota.data.as_ref().and_then(|d| d.limits.as_ref()) { - for limit in limits.iter() { - let pct = limit.percentage.unwrap_or(0.0).clamp(0.0, 100.0); - - match limit.limit_type.as_deref() { - Some("TOKENS_LIMIT") => { - let label = match (limit.unit, limit.number) { - (Some(3), Some(5)) => "Session", - (Some(6), Some(1)) => "Monthly", - _ => "Tokens", - }; - metrics.push(UsageMetric { - label: label.into(), - used_percent: pct, - remaining_percent: 100.0 - pct, - remaining_label: None, - resets_at: None, - }); - } - Some("TIME_LIMIT") => { - let remaining_label = limit.remaining.map(|r| format!("{:.0} left", r)); - metrics.push(UsageMetric { - label: "Web Searches".into(), - used_percent: pct, - remaining_percent: 100.0 - pct, - remaining_label, - resets_at: sub - .as_ref() - .and_then(|s| s.data.as_ref()) - .and_then(|d| d.first()) - .and_then(|s| s.next_renew_time.clone()), - }); - } - _ => {} - } - } - } - - Ok(UsageOutput { - provider: "Z.ai".into(), - plan, - email: None, - metrics, - }) - }) -} - -// ── Helpers ── - -fn capitalize(s: &str) -> String { - let mut c = s.chars(); - match c.next() { - Some(f) => f.to_uppercase().collect::() + c.as_str(), - None => s.to_string(), - } -} - -fn window_metric(label: &str, w: &Window) -> UsageMetric { - let used = w.utilization.clamp(0.0, 100.0); - UsageMetric { - label: label.into(), - used_percent: used, - remaining_percent: 100.0 - used, - remaining_label: None, - resets_at: w.resets_at.clone(), - } -} - -// ── Public API ── - -pub fn fetch_all() -> Vec { - let mut results = Vec::new(); - - match fetch_claude() { - Ok(o) => results.push(o), - Err(e) => eprintln!("Claude: {e}"), - } - match fetch_codex() { - Ok(o) => results.push(o), - Err(e) => eprintln!("Codex: {e}"), - } - match fetch_zai() { - Ok(o) => results.push(o), - Err(e) => eprintln!("Z.ai: {e}"), - } - - results -} - -const BAR_WIDTH: usize = 12; -const CARD_WIDTH: usize = 58; - -fn format_reset_time(resets_at: &str) -> String { - let dt = match DateTime::parse_from_rfc3339(resets_at) { - Ok(d) => d.with_timezone(&Utc), - Err(_) => return resets_at.into(), - }; - let diff = dt - Utc::now(); - if diff <= Duration::zero() { - return "resets now".into(); - } - let total_mins = diff.num_minutes(); - if total_mins < 60 { - format!("resets in {total_mins}m") - } else if total_mins < 24 * 60 { - let h = diff.num_hours(); - let m = (diff - Duration::hours(h)).num_minutes(); - if m > 0 { format!("resets in {h}h {m}m") } else { format!("resets in {h}h") } - } else if diff.num_days() < 7 { - format!("resets {} {}", dt.format("%a"), dt.format("%-I%P")) - } else { - format!("resets {}", dt.format("%b %-d")) - } -} - -fn render_ascii_bar(pct: f64) -> String { - let filled = (pct.clamp(0.0, 100.0) / 100.0 * BAR_WIDTH as f64).round() as usize; - format!("[{}{}]", "=".repeat(filled), "-".repeat(BAR_WIDTH - filled)) -} - -fn render_light(output: &UsageOutput) { - println!("╭{}╮", "─".repeat(CARD_WIDTH)); - for m in &output.metrics { - let rem = m.remaining_label.clone().unwrap_or_else(|| format!("{:.0}% left", m.remaining_percent)); - let bar = render_ascii_bar(m.remaining_percent); - let reset = m.resets_at.as_ref().map(|r| format_reset_time(r)).unwrap_or_default(); - println!("│ {:<10}{:<11}{:<14}{:<20}│", m.label, rem, bar, reset); - } - if let Some(ref email) = output.email { - println!("│ {: Result<()> { - let outputs = fetch_all(); - if json { - println!("{}", serde_json::to_string_pretty(&outputs)?); - } else { - for o in &outputs { - render_light(o); - } - } - Ok(()) -} diff --git a/crates/tokscale-cli/src/commands/usage/amp.rs b/crates/tokscale-cli/src/commands/usage/amp.rs new file mode 100644 index 000000000..d882800f9 --- /dev/null +++ b/crates/tokscale-cli/src/commands/usage/amp.rs @@ -0,0 +1,151 @@ +use anyhow::Result; +use serde::Deserialize; + +use super::{UsageMetric, UsageOutput}; + +#[derive(Debug, Deserialize)] +struct Secrets { + #[serde(rename = "apiKey@https://ampcode.com/")] + api_key: Option, +} + +#[derive(Debug, Deserialize)] +struct ApiResponse { + #[allow(dead_code)] + ok: Option, + result: Option, +} + +#[derive(Debug, Deserialize)] +struct ApiResult { + display_text: Option, +} + +fn read_credentials() -> Result { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + let path = home.join(".local").join("share").join("amp").join("secrets.json"); + if !path.exists() { + anyhow::bail!("No Amp credentials found. Run 'amp' to log in."); + } + let content = std::fs::read_to_string(&path)?; + let secrets: Secrets = serde_json::from_str(&content)?; + secrets + .api_key + .ok_or_else(|| anyhow::anyhow!("No Amp API key in secrets.json")) +} + +/// Parse a dollar amount like "$4.50" or "$1,200.00" from text starting at the given prefix. +fn parse_dollar_after(text: &str, prefix: &str) -> Option { + let start = text.find(prefix)? + prefix.len(); + let rest = &text[start..]; + let end = rest + .find(|c: char| !c.is_ascii_digit() && c != '.' && c != ',') + .unwrap_or(rest.len()); + let num_str = &rest[..end]; + num_str.replace(',', "").parse().ok() +} + +fn parse_display_text(text: &str) -> Vec { + let mut metrics = Vec::new(); + + // Parse free tier: "$X/$Y remaining" + // Look for pattern like "$4.50/$20.00 remaining" + if let Some(slash_pos) = text.find("/$") { + if let Some(dollar_before) = text[..slash_pos].rfind('$') { + let before = &text[dollar_before + 1..slash_pos]; + if let Ok(remaining) = before.replace(',', "").parse::() { + // Find the total after /$ + let after = &text[slash_pos + 2..]; + if let Some(space_pos) = after.find(|c: char| c.is_ascii_whitespace()) { + if let Ok(total) = after[..space_pos].replace(',', "").parse::() { + if total > 0.0 { + let used = (total - remaining).max(0.0); + let used_pct = (used / total * 100.0).clamp(0.0, 100.0); + let remaining_pct = 100.0 - used_pct; + let mut resets_at = None; + + // Estimate reset time from hourly replenish rate + if let Some(rate) = parse_dollar_after(text, "+$") { + if rate > 0.0 && used > 0.0 { + let secs = (used / rate * 3600.0) as i64; + let resets = chrono::Utc::now() + chrono::Duration::seconds(secs); + resets_at = Some(resets.to_rfc3339()); + } + } + + metrics.push(UsageMetric { + label: "Free".into(), + used_percent: used_pct, + remaining_percent: remaining_pct, + remaining_label: Some(format!("${remaining:.2}/${total:.2}")), + resets_at, + }); + } + } + } + } + } + } + + // Parse credits: "Individual credits: $X remaining" + if let Some(credits) = parse_dollar_after(text, "Individual credits: $") { + metrics.push(UsageMetric { + label: "Credits".into(), + used_percent: 0.0, + remaining_percent: 100.0, + remaining_label: Some(format!("${credits:.2} left")), + resets_at: None, + }); + } + + metrics +} + +fn detect_plan(metrics: &[UsageMetric]) -> Option { + let has_free = metrics.iter().any(|m| m.label == "Free"); + let has_credits = metrics.iter().any(|m| m.label == "Credits"); + match (has_free, has_credits) { + (true, _) => Some("Free".into()), + (false, true) => Some("Credits".into()), + _ => None, + } +} + +pub fn fetch() -> Result { + let api_key = read_credentials()?; + + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + let client = reqwest::Client::new(); + let resp = client + .post("https://ampcode.com/api/internal") + .header("Authorization", format!("Bearer {api_key}")) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "method": "userDisplayBalanceInfo", + "params": {} + })) + .send() + .await?; + + if !resp.status().is_success() { + anyhow::bail!("Amp usage request failed (HTTP {})", resp.status()); + } + + let body: ApiResponse = resp.json().await?; + let display_text = body + .result + .and_then(|r| r.display_text) + .unwrap_or_default(); + + let metrics = parse_display_text(&display_text); + let plan = detect_plan(&metrics); + + Ok(UsageOutput { + provider: "Amp".into(), + plan, + email: None, + metrics, + }) + }) +} diff --git a/crates/tokscale-cli/src/commands/usage/claude.rs b/crates/tokscale-cli/src/commands/usage/claude.rs new file mode 100644 index 000000000..360b01f33 --- /dev/null +++ b/crates/tokscale-cli/src/commands/usage/claude.rs @@ -0,0 +1,164 @@ +use anyhow::Result; +use serde::Deserialize; + +use super::{UsageMetric, UsageOutput}; +use super::helpers::capitalize; + +const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +const BETA_HEADER: &str = "oauth-2025-04-20"; + +#[derive(Debug, Deserialize)] +struct Credentials { + #[serde(rename = "claudeAiOauth")] + claude_ai_oauth: Option, +} + +#[derive(Debug, Deserialize)] +struct Oauth { + #[serde(rename = "accessToken")] + access_token: Option, + #[serde(rename = "refreshToken")] + refresh_token: Option, + #[serde(rename = "subscriptionType")] + subscription_type: Option, + #[serde(rename = "rateLimitTier")] + rate_limit_tier: Option, +} + +#[derive(Debug, Deserialize)] +struct UsageResponse { + five_hour: Option, + seven_day: Option, + seven_day_opus: Option, +} + +#[derive(Debug, Deserialize)] +struct Window { + utilization: f64, + resets_at: Option, +} + +#[derive(Debug, Deserialize)] +struct TokenRefresh { + access_token: Option, +} + +fn read_keychain() -> Result { + super::helpers::read_keychain("Claude Code-credentials") +} + +fn read_credentials() -> Result { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + let path = home.join(".claude").join(".credentials.json"); + let content = if path.exists() { + std::fs::read_to_string(&path)? + } else { + read_keychain()? + }; + Ok(serde_json::from_str(&content)?) +} + +async fn refresh_token(client: &reqwest::Client, rt: &str) -> Result { + let resp = client + .post("https://platform.claude.com/v1/oauth/token") + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "grant_type": "refresh_token", + "refresh_token": rt, + "client_id": CLIENT_ID, + "scope": "user:profile user:inference user:sessions:claude_code user:mcp_servers" + })) + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!("Claude token refresh failed (HTTP {})", resp.status()); + } + Ok(resp.json().await?) +} + +async fn fetch_usage(client: &reqwest::Client, token: &str) -> Result { + let resp = client + .get("https://api.anthropic.com/api/oauth/usage") + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .header("anthropic-beta", BETA_HEADER) + .send() + .await?; + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + anyhow::bail!("NEEDS_AUTH"); + } + if !status.is_success() { + anyhow::bail!("Claude usage request failed (HTTP {status})"); + } + Ok(resp.json().await?) +} + +fn window_metric(label: &str, w: &Window) -> UsageMetric { + let used = w.utilization.clamp(0.0, 100.0); + UsageMetric { + label: label.into(), + used_percent: used, + remaining_percent: 100.0 - used, + remaining_label: None, + resets_at: w.resets_at.clone(), + } +} + +pub fn fetch() -> Result { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + let creds = read_credentials()?; + let oauth = creds.claude_ai_oauth.ok_or_else(|| { + anyhow::anyhow!("No Claude OAuth credentials. Run 'claude' to log in.") + })?; + let access_token = oauth + .access_token + .ok_or_else(|| anyhow::anyhow!("No Claude access token."))?; + let plan = oauth.subscription_type.map(|s| { + let tier = oauth.rate_limit_tier.as_deref().and_then(|t| { + t.rsplit('_').next() + }); + match tier { + Some(mult) => format!("{} {}", capitalize(&s), mult), + None => capitalize(&s), + } + }); + + let client = reqwest::Client::new(); + let resp = match fetch_usage(&client, &access_token).await { + Ok(r) => r, + Err(e) if e.to_string().contains("NEEDS_AUTH") => { + let rt = oauth + .refresh_token + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No refresh token."))?; + let refreshed = refresh_token(&client, rt).await?; + let new = refreshed + .access_token + .ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?; + fetch_usage(&client, &new).await? + } + Err(e) => return Err(e), + }; + + let mut metrics = Vec::new(); + if let Some(ref w) = resp.five_hour { + metrics.push(window_metric("Session", w)); + } + if let Some(ref w) = resp.seven_day { + metrics.push(window_metric("Weekly", w)); + } + if let Some(ref w) = resp.seven_day_opus { + metrics.push(window_metric("Opus", w)); + } + + Ok(UsageOutput { + provider: "Claude".into(), + plan, + email: None, + metrics, + }) + }) +} diff --git a/crates/tokscale-cli/src/commands/usage/codex.rs b/crates/tokscale-cli/src/commands/usage/codex.rs new file mode 100644 index 000000000..cb28ed117 --- /dev/null +++ b/crates/tokscale-cli/src/commands/usage/codex.rs @@ -0,0 +1,170 @@ +use anyhow::Result; +use chrono::{TimeZone, Utc}; +use serde::Deserialize; + +use super::{UsageMetric, UsageOutput}; +use super::helpers::capitalize; + +const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; + +#[derive(Debug, Deserialize)] +struct Auth { + tokens: Option, +} + +#[derive(Debug, Deserialize)] +struct Tokens { + access_token: Option, + refresh_token: Option, + account_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct Usage { + email: Option, + plan_type: Option, + rate_limit: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct RateLimit { + primary_window: Option, + secondary_window: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct Window { + used_percent: Option, + reset_at: Option, +} + +#[derive(Debug, Deserialize)] +struct Refresh { + access_token: Option, +} + +fn read_credentials() -> Result { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + let paths = [ + home.join(".config").join("codex").join("auth.json"), + home.join(".codex").join("auth.json"), + ]; + for p in &paths { + if p.exists() { + let content = std::fs::read_to_string(p)?; + if let Ok(auth) = serde_json::from_str::(&content) { + if auth.tokens.is_some() { + return Ok(auth); + } + } + } + } + anyhow::bail!("No Codex credentials found. Run 'codex' to log in.") +} + +async fn refresh_token(client: &reqwest::Client, rt: &str) -> Result { + let resp = client + .post("https://auth.openai.com/oauth/token") + .header("Content-Type", "application/x-www-form-urlencoded") + .body(format!( + "grant_type=refresh_token&client_id={CLIENT_ID}&refresh_token={rt}" + )) + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!("Codex token refresh failed (HTTP {})", resp.status()); + } + Ok(resp.json().await?) +} + +async fn fetch_usage(client: &reqwest::Client, token: &str, account_id: Option<&str>) -> Result { + let mut req = client + .get("https://chatgpt.com/backend-api/wham/usage") + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/json") + .header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"); + if let Some(id) = account_id { + req = req.header("ChatGPT-Account-Id", id); + } + let resp = req.send().await?; + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + anyhow::bail!("NEEDS_AUTH"); + } + if !status.is_success() { + anyhow::bail!("Codex usage request failed (HTTP {status})"); + } + let body = resp.text().await?; + if body.trim().starts_with('<') { + anyhow::bail!("NEEDS_AUTH"); + } + Ok(serde_json::from_str(&body)?) +} + +pub fn fetch() -> Result { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + let auth = read_credentials()?; + let tokens = auth + .tokens + .ok_or_else(|| anyhow::anyhow!("No Codex tokens."))?; + let access_token = tokens + .access_token + .ok_or_else(|| anyhow::anyhow!("No Codex access token."))?; + let account_id = tokens.account_id.as_deref(); + + let client = reqwest::Client::new(); + let resp = match fetch_usage(&client, &access_token, account_id).await { + Ok(r) => r, + Err(e) if e.to_string().contains("NEEDS_AUTH") => { + let rt = tokens + .refresh_token + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No refresh token."))?; + let refreshed = refresh_token(&client, rt).await?; + let new = refreshed + .access_token + .ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?; + fetch_usage(&client, &new, account_id).await? + } + Err(e) => return Err(e), + }; + + let plan = resp.plan_type.as_deref().map(capitalize); + let mut metrics = Vec::new(); + if let Some(ref rl) = resp.rate_limit { + if let Some(ref w) = rl.primary_window { + let pct = w.used_percent.unwrap_or(0).clamp(0, 100) as f64; + metrics.push(UsageMetric { + label: "Session".into(), + used_percent: pct, + remaining_percent: 100.0 - pct, + remaining_label: None, + resets_at: w.reset_at.and_then(|ts| Utc.timestamp_opt(ts, 0).single()) + .map(|dt| dt.to_rfc3339()), + }); + } + if let Some(ref w) = rl.secondary_window { + let pct = w.used_percent.unwrap_or(0).clamp(0, 100) as f64; + metrics.push(UsageMetric { + label: "Weekly".into(), + used_percent: pct, + remaining_percent: 100.0 - pct, + remaining_label: None, + resets_at: w.reset_at.and_then(|ts| Utc.timestamp_opt(ts, 0).single()) + .map(|dt| dt.to_rfc3339()), + }); + } + } + + Ok(UsageOutput { + provider: "Codex".into(), + plan, + email: resp.email, + metrics, + }) + }) +} diff --git a/crates/tokscale-cli/src/commands/usage/copilot.rs b/crates/tokscale-cli/src/commands/usage/copilot.rs new file mode 100644 index 000000000..84caea9c7 --- /dev/null +++ b/crates/tokscale-cli/src/commands/usage/copilot.rs @@ -0,0 +1,237 @@ +use anyhow::Result; +use serde::Deserialize; + +use super::{UsageMetric, UsageOutput}; +use super::helpers::{capitalize, read_keychain}; + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct PaidQuotaSnapshot { + percent_remaining: Option, + remaining: Option, + entitlement: Option, + #[allow(dead_code)] + quota_id: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct PaidResponse { + copilot_plan: Option, + quota_reset_date: Option, + quota_snapshots: Option>, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct FreeResponse { + copilot_plan: Option, + limited_user_quotas: Option>, + monthly_quotas: Option>, + limited_user_reset_date: Option, +} + +fn read_token_from_keychain() -> Result { + let raw = read_keychain("gh:github.com")?; + // go-keyring may base64-encode the value + if raw.starts_with("go-keyring-base64:") { + let encoded = &raw["go-keyring-base64:".len()..]; + let decoded = base64_decode(encoded)?; + Ok(decoded) + } else { + Ok(raw) + } +} + +fn read_token_from_hosts() -> Result { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + let path = home.join(".config").join("gh").join("hosts.yml"); + if !path.exists() { + anyhow::bail!("No gh hosts file"); + } + let content = std::fs::read_to_string(&path)?; + // Parse YAML-like: look for "oauth_token: " under "github.com:" + let mut in_github = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed == "github.com:" { + in_github = true; + continue; + } + if in_github && trimmed.starts_with("oauth_token:") { + let token = trimmed.trim_start_matches("oauth_token:").trim(); + if !token.is_empty() { + return Ok(token.to_string()); + } + } + if in_github && !trimmed.is_empty() && !trimmed.starts_with("oauth_token") && !trimmed.starts_with('#') { + in_github = false; + } + } + anyhow::bail!("No oauth_token found in hosts.yml") +} + +fn read_credentials() -> Result { + read_token_from_keychain().or_else(|_| read_token_from_hosts()).map_err(|_| { + anyhow::anyhow!("No GitHub Copilot credentials found. Run 'gh auth login' to authenticate.") + }) +} + +fn base64_decode(input: &str) -> Result { + // Minimal base64 decode without adding a dependency + const TABLE: &[Option; 128] = &{ + let mut table = [None; 128]; + let mut i = 0u8; + while i < 26 { + table[(b'A' + i) as usize] = Some(i); + i += 1; + } + let mut i = 0u8; + while i < 26 { + table[(b'a' + i) as usize] = Some(26 + i); + i += 1; + } + let mut i = 0u8; + while i < 10 { + table[(b'0' + i) as usize] = Some(52 + i); + i += 1; + } + table[b'+' as usize] = Some(62); + table[b'/' as usize] = Some(63); + table + }; + + let bytes = input.as_bytes(); + let mut result = Vec::with_capacity(bytes.len() * 3 / 4); + let mut buf = 0u32; + let mut bits = 0u32; + for &b in bytes { + if b == b'=' { break; } + if (b as usize) >= TABLE.len() { continue; } + if let Some(v) = TABLE[b as usize] { + buf = (buf << 6) | v as u32; + bits += 6; + if bits >= 8 { + bits -= 8; + result.push((buf >> bits) as u8); + } + } + } + Ok(String::from_utf8(result)?) +} + +fn pretty_category(key: &str) -> String { + match key { + "premium_interactions" => "Premium".into(), + "chat" => "Chat".into(), + "completions" => "Completions".into(), + other => capitalize(other.replace('_', " ").as_str()), + } +} + +async fn fetch_api(client: &reqwest::Client, token: &str) -> Result { + let resp = client + .get("https://api.github.com/copilot_internal/user") + .header("Authorization", format!("token {token}")) + .header("Accept", "application/json") + .header("Editor-Version", "vscode/1.96.2") + .header("Editor-Plugin-Version", "copilot-chat/0.26.7") + .header("User-Agent", "GitHubCopilotChat/0.26.7") + .header("X-Github-Api-Version", "2025-04-01") + .send() + .await?; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + anyhow::bail!("NEEDS_AUTH"); + } + if !status.is_success() { + anyhow::bail!("Copilot usage request failed (HTTP {status})"); + } + Ok(resp.json().await?) +} + +pub fn fetch() -> Result { + let token = read_credentials()?; + + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + let client = reqwest::Client::new(); + let resp = fetch_api(&client, &token).await?; + + let plan = resp.get("copilot_plan") + .and_then(|v| v.as_str()) + .map(capitalize); + + let mut metrics = Vec::new(); + + // Try paid tier response (quota_snapshots) + if let Some(snapshots) = resp.get("quota_snapshots").and_then(|v| v.as_object()) { + let reset_date = resp.get("quota_reset_date") + .and_then(|v| v.as_str()) + .map(String::from); + + for (key, value) in snapshots { + let pct_remaining = value.get("percent_remaining") + .and_then(|v| v.as_i64()) + .unwrap_or(100) + .clamp(0, 100); + let remaining = value.get("remaining").and_then(|v| v.as_i64()); + let entitlement = value.get("entitlement").and_then(|v| v.as_i64()); + + let used_pct = (100 - pct_remaining) as f64; + let remaining_pct = pct_remaining as f64; + + let remaining_label = match (remaining, entitlement) { + (Some(r), Some(e)) => Some(format!("{r}/{e} left")), + _ => None, + }; + + metrics.push(UsageMetric { + label: pretty_category(key), + used_percent: used_pct, + remaining_percent: remaining_pct, + remaining_label, + resets_at: reset_date.clone(), + }); + } + } + + // Try free tier response (limited_user_quotas) + if metrics.is_empty() { + if let Some(quotas) = resp.get("limited_user_quotas").and_then(|v| v.as_object()) { + let monthly = resp.get("monthly_quotas").and_then(|v| v.as_object()); + let reset_date = resp.get("limited_user_reset_date") + .and_then(|v| v.as_str()) + .map(String::from); + + for (key, value) in quotas { + let remaining = value.as_i64().unwrap_or(0); + let total = monthly + .and_then(|m| m.get(key)) + .and_then(|v| v.as_i64()) + .unwrap_or(remaining); + + if total > 0 { + let used = (total - remaining).max(0); + let used_pct = (used as f64 / total as f64 * 100.0).clamp(0.0, 100.0); + metrics.push(UsageMetric { + label: pretty_category(key), + used_percent: used_pct, + remaining_percent: 100.0 - used_pct, + remaining_label: Some(format!("{remaining}/{total} left")), + resets_at: reset_date.clone(), + }); + } + } + } + } + + Ok(UsageOutput { + provider: "Copilot".into(), + plan, + email: None, + metrics, + }) + }) +} diff --git a/crates/tokscale-cli/src/commands/usage/helpers.rs b/crates/tokscale-cli/src/commands/usage/helpers.rs new file mode 100644 index 000000000..25f0fcaa5 --- /dev/null +++ b/crates/tokscale-cli/src/commands/usage/helpers.rs @@ -0,0 +1,52 @@ +use anyhow::Result; +use chrono::{DateTime, Duration, Utc}; + +pub fn capitalize(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + Some(f) => f.to_uppercase().collect::() + c.as_str(), + None => s.to_string(), + } +} + +pub fn read_keychain(service: &str) -> Result { + let out = std::process::Command::new("security") + .args(["find-generic-password", "-s", service, "-w"]) + .output()?; + if !out.status.success() { + anyhow::bail!("Keychain lookup failed for service '{service}'"); + } + Ok(String::from_utf8(out.stdout)?.trim_end().to_string()) +} + +pub fn format_reset_time(resets_at: &str) -> String { + let dt = match DateTime::parse_from_rfc3339(resets_at) { + Ok(d) => d.with_timezone(&Utc), + Err(_) => return resets_at.into(), + }; + let diff = dt - Utc::now(); + if diff <= Duration::zero() { + return "resets now".into(); + } + let total_mins = diff.num_minutes(); + if total_mins < 60 { + format!("resets in {total_mins}m") + } else if total_mins < 24 * 60 { + let h = diff.num_hours(); + let m = (diff - Duration::hours(h)).num_minutes(); + if m > 0 { + format!("resets in {h}h {m}m") + } else { + format!("resets in {h}h") + } + } else if diff.num_days() < 7 { + format!("resets {} {}", dt.format("%a"), dt.format("%-I%P")) + } else { + format!("resets {}", dt.format("%b %-d")) + } +} + +pub fn render_ascii_bar(remaining_percent: f64, width: usize) -> String { + let filled = (remaining_percent.clamp(0.0, 100.0) / 100.0 * width as f64).round() as usize; + format!("[{}{}]", "=".repeat(filled), "-".repeat(width - filled)) +} diff --git a/crates/tokscale-cli/src/commands/usage/kimi.rs b/crates/tokscale-cli/src/commands/usage/kimi.rs new file mode 100644 index 000000000..6604d0628 --- /dev/null +++ b/crates/tokscale-cli/src/commands/usage/kimi.rs @@ -0,0 +1,214 @@ +use anyhow::Result; +use serde::Deserialize; + +use super::{UsageMetric, UsageOutput}; +use super::helpers::capitalize; + +const CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098"; + +#[derive(Debug, Deserialize)] +struct Credentials { + access_token: Option, + refresh_token: Option, + expires_at: Option, +} + +#[derive(Debug, Deserialize)] +struct UsageResponse { + usage: Option, + limits: Option>, + user: Option, +} + +#[derive(Debug, Deserialize)] +struct QuotaDetail { + limit: Option, + remaining: Option, + #[serde(rename = "resetTime")] + reset_time: Option, +} + +#[derive(Debug, Deserialize)] +struct LimitEntry { + #[allow(dead_code)] + window: Option, + detail: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct LimitWindow { + duration: Option, + time_unit: Option, +} + +#[derive(Debug, Deserialize)] +struct UserInfo { + membership: Option, +} + +#[derive(Debug, Deserialize)] +struct Membership { + level: Option, +} + +#[derive(Debug, Deserialize)] +struct RefreshResponse { + access_token: Option, + #[allow(dead_code)] + refresh_token: Option, + #[allow(dead_code)] + expires_in: Option, +} + +fn read_credentials() -> Result { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + let path = home.join(".kimi").join("credentials").join("kimi-code.json"); + if !path.exists() { + anyhow::bail!("No Kimi credentials found. Run 'kimi' to log in."); + } + let content = std::fs::read_to_string(&path)?; + Ok(serde_json::from_str(&content)?) +} + +fn needs_refresh(expires_at: Option) -> bool { + if let Some(expires_at) = expires_at { + let now = chrono::Utc::now().timestamp() as f64; + now + 300.0 > expires_at // 5 min buffer + } else { + false + } +} + +async fn refresh_token(client: &reqwest::Client, rt: &str) -> Result { + let resp = client + .post("https://auth.kimi.com/api/oauth/token") + .header("Content-Type", "application/x-www-form-urlencoded") + .body(format!( + "client_id={CLIENT_ID}&grant_type=refresh_token&refresh_token={rt}" + )) + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!("Kimi token refresh failed (HTTP {})", resp.status()); + } + Ok(resp.json().await?) +} + +async fn fetch_usage(client: &reqwest::Client, token: &str) -> Result { + let resp = client + .get("https://api.kimi.com/coding/v1/usages") + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/json") + .header("User-Agent", "OpenUsage") + .send() + .await?; + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + anyhow::bail!("NEEDS_AUTH"); + } + if !status.is_success() { + anyhow::bail!("Kimi usage request failed (HTTP {status})"); + } + Ok(resp.json().await?) +} + +fn parse_quota_detail(label: &str, detail: &QuotaDetail) -> Option { + let limit: i64 = detail.limit.as_ref()?.parse().ok()?; + let remaining: i64 = detail.remaining.as_ref()?.parse().ok()?; + if limit <= 0 { + return None; + } + let used = (limit - remaining).max(0); + let used_pct = (used as f64 / limit as f64 * 100.0).clamp(0.0, 100.0); + Some(UsageMetric { + label: label.into(), + used_percent: used_pct, + remaining_percent: 100.0 - used_pct, + remaining_label: Some(format!("{remaining}/{limit} left")), + resets_at: detail.reset_time.clone(), + }) +} + +pub fn fetch() -> Result { + let creds = read_credentials()?; + let mut access_token = creds + .access_token + .clone() + .ok_or_else(|| anyhow::anyhow!("No Kimi access token."))?; + let stored_refresh_token = creds.refresh_token.clone(); + let expires_at = creds.expires_at; + + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + let client = reqwest::Client::new(); + + // Proactive refresh if token is about to expire + if needs_refresh(expires_at) { + if let Some(ref rt_str) = stored_refresh_token { + if let Ok(refreshed) = refresh_token(&client, rt_str).await { + if let Some(new_token) = refreshed.access_token { + access_token = new_token; + } + } + } + } + + let resp = match fetch_usage(&client, &access_token).await { + Ok(r) => r, + Err(e) if e.to_string().contains("NEEDS_AUTH") => { + let rt_str = stored_refresh_token + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No refresh token."))?; + let refreshed = refresh_token(&client, rt_str).await?; + let new = refreshed + .access_token + .ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?; + fetch_usage(&client, &new).await? + } + Err(e) => return Err(e), + }; + + let plan = resp.user.as_ref() + .and_then(|u| u.membership.as_ref()) + .and_then(|m| m.level.as_ref()) + .map(|l| { + capitalize(l.trim_start_matches("LEVEL_").replace('_', " ").as_str()) + }); + + let mut metrics = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + // Parse limits[] (sorted by period ascending, first is "Session") + if let Some(ref limits) = resp.limits { + for (i, entry) in limits.iter().enumerate() { + if let Some(ref detail) = entry.detail { + let label = if i == 0 { "Session" } else { "Weekly" }; + if let Some(metric) = parse_quota_detail(label, detail) { + let key = format!("{}:{}", metric.used_percent, metric.remaining_label.as_deref().unwrap_or("")); + if seen.insert(key) { + metrics.push(metric); + } + } + } + } + } + + // Parse top-level usage as "Weekly" (deduplicate against session) + if let Some(ref usage) = resp.usage { + if let Some(metric) = parse_quota_detail("Weekly", usage) { + let key = format!("{}:{}", metric.used_percent, metric.remaining_label.as_deref().unwrap_or("")); + if seen.insert(key) { + metrics.push(metric); + } + } + } + + Ok(UsageOutput { + provider: "Kimi".into(), + plan, + email: None, + metrics, + }) + }) +} diff --git a/crates/tokscale-cli/src/commands/usage/minimax.rs b/crates/tokscale-cli/src/commands/usage/minimax.rs new file mode 100644 index 000000000..3fc312e92 --- /dev/null +++ b/crates/tokscale-cli/src/commands/usage/minimax.rs @@ -0,0 +1,165 @@ +use anyhow::Result; +use chrono::{TimeZone, Utc}; +use serde::Deserialize; + +use super::{UsageMetric, UsageOutput}; +use super::helpers::capitalize; + +#[derive(Debug, Deserialize)] +struct ApiResponse { + base_resp: Option, + model_remains: Option>, + data: Option, +} + +#[derive(Debug, Deserialize)] +struct BaseResp { + status_code: Option, + status_msg: Option, +} + +#[derive(Debug, Deserialize)] +struct ApiData { + model_remains: Option>, +} + +#[derive(Debug, Deserialize)] +struct ModelRemains { + current_interval_total_count: Option, + current_interval_usage_count: Option, + current_interval_remaining_count: Option, + current_subscribe_title: Option, + #[allow(dead_code)] + start_time: Option, + end_time: Option, + #[allow(dead_code)] + remains_time: Option, +} + +fn read_api_key() -> Result { + std::env::var("MINIMAX_API_KEY") + .or_else(|_| std::env::var("MINIMAX_API_TOKEN")) + .map_err(|_| anyhow::anyhow!("No MINIMAX_API_KEY or MINIMAX_API_TOKEN set.")) +} + +fn is_error(resp: &ApiResponse) -> bool { + if let Some(ref base) = resp.base_resp { + if base.status_code.unwrap_or(0) != 0 { + return true; + } + if let Some(ref msg) = base.status_msg { + let lower = msg.to_lowercase(); + if lower.contains("cookie") || lower.contains("log in") || lower.contains("login") { + return true; + } + } + } + false +} + +fn infer_plan(total: i64) -> String { + match total { + 0..=15 => "Starter".into(), + 16..=300 => "Plus".into(), + 301..=1000 => "Max".into(), + _ => "Ultra".into(), + } +} + +fn parse_end_time(ts: i64) -> String { + // Auto-detect seconds vs milliseconds + let secs = if ts > 1_000_000_000_0 { ts / 1000 } else { ts }; + Utc.timestamp_opt(secs, 0) + .single() + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| ts.to_string()) +} + +async fn fetch_api(client: &reqwest::Client, key: &str) -> Result { + let resp = client + .get("https://api.minimax.io/v1/api/openplatform/coding_plan/remains") + .header("Authorization", format!("Bearer {key}")) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .send() + .await?; + + if !resp.status().is_success() { + anyhow::bail!("MiniMax usage request failed (HTTP {})", resp.status()); + } + Ok(resp.json().await?) +} + +pub fn fetch() -> Result { + let api_key = read_api_key()?; + + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + let client = reqwest::Client::new(); + let resp = fetch_api(&client, &api_key).await?; + + if is_error(&resp) { + let msg = resp.base_resp.as_ref() + .and_then(|b| b.status_msg.clone()) + .unwrap_or_else(|| "Unknown error".into()); + anyhow::bail!("MiniMax API error: {msg}"); + } + + // model_remains can be top-level or nested under "data" + let remains = resp.model_remains.as_ref() + .or_else(|| resp.data.as_ref().and_then(|d| d.model_remains.as_ref())) + .map(|v| v.as_slice()) + .unwrap_or(&[]); + + let mut metrics = Vec::new(); + let mut plan: Option = None; + + for model in remains.iter() { + // Try explicit plan title first + if plan.is_none() { + plan = model.current_subscribe_title.as_ref() + .map(|t| { + let cleaned = t.trim_start_matches("MiniMax Coding Plan").trim(); + if cleaned.is_empty() { t.clone() } else { capitalize(cleaned) } + }); + } + + let total = model.current_interval_total_count.unwrap_or(0); + if total <= 0 { + continue; + } + + // MiniMax's usage_count is often actually remaining count + let remaining = model.current_interval_remaining_count + .or_else(|| model.current_interval_usage_count.map(|u| if u <= total { total - u } else { u })) + .unwrap_or(0); + + let used = (total - remaining).max(0); + let used_pct = (used as f64 / total as f64 * 100.0).clamp(0.0, 100.0); + + let resets_at = model.end_time.map(|ts| parse_end_time(ts)); + + metrics.push(UsageMetric { + label: "Prompts".into(), + used_percent: used_pct, + remaining_percent: 100.0 - used_pct, + remaining_label: Some(format!("{remaining}/{total} left")), + resets_at, + }); + } + + // Infer plan from total count if not explicitly provided + if plan.is_none() { + if let Some(first) = remains.first() { + plan = first.current_interval_total_count.map(infer_plan); + } + } + + Ok(UsageOutput { + provider: "MiniMax".into(), + plan, + email: None, + metrics, + }) + }) +} diff --git a/crates/tokscale-cli/src/commands/usage/mod.rs b/crates/tokscale-cli/src/commands/usage/mod.rs new file mode 100644 index 000000000..31b7b3f1d --- /dev/null +++ b/crates/tokscale-cli/src/commands/usage/mod.rs @@ -0,0 +1,88 @@ +mod amp; +mod claude; +mod codex; +mod copilot; +mod helpers; +mod kimi; +mod minimax; +mod zai; + +use anyhow::Result; + +// ── Shared types ── + +#[derive(Debug, Clone, serde::Serialize)] +pub struct UsageMetric { + pub label: String, + pub used_percent: f64, + pub remaining_percent: f64, + pub remaining_label: Option, + pub resets_at: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct UsageOutput { + pub provider: String, + pub plan: Option, + pub email: Option, + pub metrics: Vec, +} + +// ── Public API ── + +pub fn fetch_all() -> Vec { + let mut results = Vec::new(); + + macro_rules! try_fetch { + ($name:expr, $func:expr) => { + match $func() { + Ok(o) => results.push(o), + Err(e) => eprintln!("{}: {e}", $name), + } + }; + } + + try_fetch!("Claude", claude::fetch); + try_fetch!("Codex", codex::fetch); + try_fetch!("Z.ai", zai::fetch); + try_fetch!("Amp", amp::fetch); + try_fetch!("Copilot", copilot::fetch); + try_fetch!("Kimi", kimi::fetch); + try_fetch!("MiniMax", minimax::fetch); + + results +} + +// ── Light-mode rendering ── + +const BAR_WIDTH: usize = 12; +const CARD_WIDTH: usize = 58; + +fn render_light(output: &UsageOutput) { + println!("╭{}╮", "─".repeat(CARD_WIDTH)); + for m in &output.metrics { + let rem = m.remaining_label.clone().unwrap_or_else(|| format!("{:.0}% left", m.remaining_percent)); + let bar = helpers::render_ascii_bar(m.remaining_percent, BAR_WIDTH); + let reset = m.resets_at.as_ref().map(|r| helpers::format_reset_time(r)).unwrap_or_default(); + println!("│ {:<10}{:<11}{:<14}{:<20}│", m.label, rem, bar, reset); + } + if let Some(ref email) = output.email { + println!("│ {: Result<()> { + let outputs = fetch_all(); + if json { + println!("{}", serde_json::to_string_pretty(&outputs)?); + } else { + for o in &outputs { + render_light(o); + } + } + Ok(()) +} diff --git a/crates/tokscale-cli/src/commands/usage/zai.rs b/crates/tokscale-cli/src/commands/usage/zai.rs new file mode 100644 index 000000000..24c98bb39 --- /dev/null +++ b/crates/tokscale-cli/src/commands/usage/zai.rs @@ -0,0 +1,133 @@ +use anyhow::Result; +use serde::Deserialize; + +use super::{UsageMetric, UsageOutput}; +use super::helpers::capitalize; + +#[derive(Debug, Deserialize)] +struct QuotaResp { + data: Option, +} + +#[derive(Debug, Deserialize)] +struct QuotaData { + limits: Option>, + level: Option, +} + +#[derive(Debug, Deserialize)] +struct Limit { + #[serde(rename = "type")] + limit_type: Option, + #[allow(dead_code)] + usage: Option, + remaining: Option, + percentage: Option, + #[allow(dead_code)] + current_value: Option, + number: Option, + unit: Option, +} + +#[derive(Debug, Deserialize)] +struct SubResp { + data: Option>, +} + +#[derive(Debug, Deserialize)] +struct Sub { + product_name: Option, + next_renew_time: Option, +} + +async fn fetch_quota(client: &reqwest::Client, key: &str) -> Result { + let resp = client + .get("https://api.z.ai/api/monitor/usage/quota/limit") + .header("Authorization", format!("Bearer {key}")) + .header("Accept", "application/json") + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!("Z.ai quota request failed (HTTP {})", resp.status()); + } + Ok(resp.json().await?) +} + +async fn fetch_sub(client: &reqwest::Client, key: &str) -> Result { + let resp = client + .get("https://api.z.ai/api/biz/subscription/list") + .header("Authorization", format!("Bearer {key}")) + .header("Accept", "application/json") + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!("Z.ai subscription request failed (HTTP {})", resp.status()); + } + Ok(resp.json().await?) +} + +pub fn fetch() -> Result { + let api_key = std::env::var("ZAI_API_KEY") + .or_else(|_| std::env::var("GLM_API_KEY")) + .map_err(|_| anyhow::anyhow!("No ZAI_API_KEY or GLM_API_KEY set."))?; + + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + let client = reqwest::Client::new(); + let quota = fetch_quota(&client, &api_key).await?; + let sub = fetch_sub(&client, &api_key).await.ok(); + + let plan = sub + .as_ref() + .and_then(|s| s.data.as_ref()) + .and_then(|d| d.first()) + .and_then(|s| s.product_name.clone()) + .or_else(|| quota.data.as_ref().and_then(|d| d.level.clone()).map(|l| capitalize(&l))); + + let mut metrics = Vec::new(); + if let Some(ref limits) = quota.data.as_ref().and_then(|d| d.limits.as_ref()) { + for limit in limits.iter() { + let pct = limit.percentage.unwrap_or(0.0).clamp(0.0, 100.0); + + match limit.limit_type.as_deref() { + Some("TOKENS_LIMIT") => { + let label = match (limit.unit, limit.number) { + (Some(3), Some(5)) => "Session", + (Some(6), Some(1)) => "Monthly", + _ => "Tokens", + }; + metrics.push(UsageMetric { + label: label.into(), + used_percent: pct, + remaining_percent: 100.0 - pct, + remaining_label: None, + resets_at: None, + }); + } + Some("TIME_LIMIT") => { + let remaining_label = limit.remaining.map(|r| format!("{:.0} left", r)); + metrics.push(UsageMetric { + label: "Web Searches".into(), + used_percent: pct, + remaining_percent: 100.0 - pct, + remaining_label, + resets_at: sub + .as_ref() + .and_then(|s| s.data.as_ref()) + .and_then(|d| d.first()) + .and_then(|s| s.next_renew_time.clone()), + }); + } + _ => {} + } + } + } + + Ok(UsageOutput { + provider: "Z.ai".into(), + plan, + email: None, + metrics, + }) + }) +} From f1ca00dfba7a7a3fad411f66a6182e3917544a9e Mon Sep 17 00:00:00 2001 From: shidevil Date: Sat, 2 May 2026 12:47:05 +0000 Subject: [PATCH 03/19] fix(minimax): correct usage_count inversion and match openusage behavior current_interval_usage_count is a remaining count despite its name. Prefer explicit current_interval_used_count when available, and treat usage_count as remaining in the fallback path. Also adds: - remains_time fallback for reset timestamps - data-level plan fields (data.current_subscribe_title, data.plan_name) - Plan inference that divides total by MODEL_CALLS_PER_PROMPT (15) - Separate is_auth_error() detection (status 1004) - epoch_to_ms() helper for proper timestamp normalization - normalize_plan_name() that strips "MiniMax Coding Plan" prefix Co-Authored-By: Claude Opus 4.7 --- .../src/commands/usage/minimax.rs | 135 ++++++++++++------ 1 file changed, 93 insertions(+), 42 deletions(-) diff --git a/crates/tokscale-cli/src/commands/usage/minimax.rs b/crates/tokscale-cli/src/commands/usage/minimax.rs index 3fc312e92..16727336a 100644 --- a/crates/tokscale-cli/src/commands/usage/minimax.rs +++ b/crates/tokscale-cli/src/commands/usage/minimax.rs @@ -5,11 +5,15 @@ use serde::Deserialize; use super::{UsageMetric, UsageOutput}; use super::helpers::capitalize; +const MODEL_CALLS_PER_PROMPT: i64 = 15; + #[derive(Debug, Deserialize)] struct ApiResponse { base_resp: Option, model_remains: Option>, data: Option, + current_subscribe_title: Option, + plan_name: Option, } #[derive(Debug, Deserialize)] @@ -21,6 +25,8 @@ struct BaseResp { #[derive(Debug, Deserialize)] struct ApiData { model_remains: Option>, + current_subscribe_title: Option, + plan_name: Option, } #[derive(Debug, Deserialize)] @@ -28,11 +34,11 @@ struct ModelRemains { current_interval_total_count: Option, current_interval_usage_count: Option, current_interval_remaining_count: Option, + current_interval_used_count: Option, current_subscribe_title: Option, #[allow(dead_code)] start_time: Option, end_time: Option, - #[allow(dead_code)] remains_time: Option, } @@ -42,9 +48,9 @@ fn read_api_key() -> Result { .map_err(|_| anyhow::anyhow!("No MINIMAX_API_KEY or MINIMAX_API_TOKEN set.")) } -fn is_error(resp: &ApiResponse) -> bool { +fn is_auth_error(resp: &ApiResponse) -> bool { if let Some(ref base) = resp.base_resp { - if base.status_code.unwrap_or(0) != 0 { + if base.status_code == Some(1004) { return true; } if let Some(ref msg) = base.status_msg { @@ -57,19 +63,47 @@ fn is_error(resp: &ApiResponse) -> bool { false } -fn infer_plan(total: i64) -> String { - match total { - 0..=15 => "Starter".into(), - 16..=300 => "Plus".into(), - 301..=1000 => "Max".into(), - _ => "Ultra".into(), +fn is_api_error(resp: &ApiResponse) -> bool { + if let Some(ref base) = resp.base_resp { + if base.status_code.unwrap_or(0) != 0 { + return true; + } + } + false +} + +fn normalize_plan_name(raw: &str) -> String { + let without_prefix = raw.trim_start_matches("MiniMax Coding Plan").trim() + .trim_start_matches(':').trim_start_matches('-').trim(); + if without_prefix.is_empty() { + capitalize(raw.trim()) + } else { + capitalize(without_prefix) } } +fn infer_plan(total: i64) -> Option { + let prompt_limit = if total % MODEL_CALLS_PER_PROMPT == 0 { + total / MODEL_CALLS_PER_PROMPT + } else { + total + }; + Some(match prompt_limit { + 100 => "Starter".into(), + 300 => "Plus".into(), + 1000 => "Max".into(), + 2000 => "Ultra".into(), + _ => return None, + }) +} + +fn epoch_to_ms(ts: i64) -> i64 { + if ts.abs() < 1_000_000_000 { ts * 1000 } else { ts } +} + fn parse_end_time(ts: i64) -> String { - // Auto-detect seconds vs milliseconds - let secs = if ts > 1_000_000_000_0 { ts / 1000 } else { ts }; - Utc.timestamp_opt(secs, 0) + let ms = epoch_to_ms(ts); + Utc.timestamp_millis_opt(ms) .single() .map(|dt| dt.to_rfc3339()) .unwrap_or_else(|| ts.to_string()) @@ -84,8 +118,12 @@ async fn fetch_api(client: &reqwest::Client, key: &str) -> Result { .send() .await?; - if !resp.status().is_success() { - anyhow::bail!("MiniMax usage request failed (HTTP {})", resp.status()); + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + anyhow::bail!("Session expired. Check your MiniMax API key."); + } + if !status.is_success() { + anyhow::bail!("MiniMax usage request failed (HTTP {status})"); } Ok(resp.json().await?) } @@ -98,7 +136,10 @@ pub fn fetch() -> Result { let client = reqwest::Client::new(); let resp = fetch_api(&client, &api_key).await?; - if is_error(&resp) { + if is_auth_error(&resp) { + anyhow::bail!("Session expired. Check your MiniMax API key."); + } + if is_api_error(&resp) { let msg = resp.base_resp.as_ref() .and_then(|b| b.status_msg.clone()) .unwrap_or_else(|| "Unknown error".into()); @@ -111,47 +152,57 @@ pub fn fetch() -> Result { .map(|v| v.as_slice()) .unwrap_or(&[]); + // Pick the first entry with a valid total + let chosen = remains.iter().find(|m| { + m.current_interval_total_count.unwrap_or(0) > 0 + }); + let mut metrics = Vec::new(); - let mut plan: Option = None; + let mut plan: Option = resp.data.as_ref() + .and_then(|d| d.current_subscribe_title.as_ref().or(d.plan_name.as_ref())) + .or_else(|| resp.current_subscribe_title.as_ref().or(resp.plan_name.as_ref())) + .map(|s| normalize_plan_name(s)); - for model in remains.iter() { - // Try explicit plan title first + if let Some(model) = chosen { if plan.is_none() { - plan = model.current_subscribe_title.as_ref() - .map(|t| { - let cleaned = t.trim_start_matches("MiniMax Coding Plan").trim(); - if cleaned.is_empty() { t.clone() } else { capitalize(cleaned) } - }); + plan = model.current_subscribe_title.as_ref().map(|s| normalize_plan_name(s)); } let total = model.current_interval_total_count.unwrap_or(0); - if total <= 0 { - continue; - } - - // MiniMax's usage_count is often actually remaining count - let remaining = model.current_interval_remaining_count - .or_else(|| model.current_interval_usage_count.map(|u| if u <= total { total - u } else { u })) - .unwrap_or(0); - let used = (total - remaining).max(0); - let used_pct = (used as f64 / total as f64 * 100.0).clamp(0.0, 100.0); - - let resets_at = model.end_time.map(|ts| parse_end_time(ts)); + // Prefer explicit used_count, then compute from remaining + let used = model.current_interval_used_count + .map(|u| u.clamp(0, total)) + .unwrap_or_else(|| { + // Both remaining_count and usage_count represent remaining prompts + let remaining = model.current_interval_remaining_count + .or(model.current_interval_usage_count) + .unwrap_or(0); + (total - remaining).max(0) + }); + + let used_pct = if total > 0 { (used as f64 / total as f64 * 100.0).clamp(0.0, 100.0) } else { 0.0 }; + + // Reset time: prefer end_time, fallback to remains_time + let resets_at = model.end_time.map(|ts| parse_end_time(ts)) + .or_else(|| { + model.remains_time.map(|rt| { + let ms = if rt > 1_000_000_000 { rt } else { rt * 1000 }; + let dt = Utc::now() + chrono::Duration::milliseconds(ms); + dt.to_rfc3339() + }) + }); metrics.push(UsageMetric { - label: "Prompts".into(), + label: "Session".into(), used_percent: used_pct, remaining_percent: 100.0 - used_pct, - remaining_label: Some(format!("{remaining}/{total} left")), + remaining_label: Some(format!("{}/{} prompts left", total - used, total)), resets_at, }); - } - // Infer plan from total count if not explicitly provided - if plan.is_none() { - if let Some(first) = remains.first() { - plan = first.current_interval_total_count.map(infer_plan); + if plan.is_none() { + plan = infer_plan(total); } } From aca3b06374dd386e32238fb9017eed718aa816fc Mon Sep 17 00:00:00 2001 From: shidevil Date: Sat, 2 May 2026 13:01:36 +0000 Subject: [PATCH 04/19] fix(kimi): persist rotated refresh token and expires_at to disk The OAuth server rotates refresh tokens on each refresh, but the old code only updated the in-memory access_token and discarded the new refresh_token/expires_in from the response. This meant the on-disk refresh token would go stale after one rotation, forcing re-login. Add save_credentials() that writes the full credential JSON back to ~/.kimi/credentials/kimi-code.json after a successful token refresh. Both the proactive (near-expiry) and reactive (401 fallback) refresh paths now persist the updated tokens. Co-Authored-By: Claude Opus 4.7 --- .../tokscale-cli/src/commands/usage/kimi.rs | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/crates/tokscale-cli/src/commands/usage/kimi.rs b/crates/tokscale-cli/src/commands/usage/kimi.rs index 6604d0628..1eccfad54 100644 --- a/crates/tokscale-cli/src/commands/usage/kimi.rs +++ b/crates/tokscale-cli/src/commands/usage/kimi.rs @@ -13,6 +13,13 @@ struct Credentials { expires_at: Option, } +#[derive(Debug, Deserialize)] +struct RefreshResponse { + access_token: Option, + refresh_token: Option, + expires_in: Option, +} + #[derive(Debug, Deserialize)] struct UsageResponse { usage: Option, @@ -52,15 +59,6 @@ struct Membership { level: Option, } -#[derive(Debug, Deserialize)] -struct RefreshResponse { - access_token: Option, - #[allow(dead_code)] - refresh_token: Option, - #[allow(dead_code)] - expires_in: Option, -} - fn read_credentials() -> Result { let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); let path = home.join(".kimi").join("credentials").join("kimi-code.json"); @@ -71,6 +69,20 @@ fn read_credentials() -> Result { Ok(serde_json::from_str(&content)?) } +fn save_credentials(access_token: &str, refresh_token: &str, expires_in: i64) { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + let path = home.join(".kimi").join("credentials").join("kimi-code.json"); + let expires_at = chrono::Utc::now().timestamp() as f64 + expires_in as f64; + let json = serde_json::json!({ + "access_token": access_token, + "refresh_token": refresh_token, + "expires_at": expires_at, + "scope": "kimi-code", + "token_type": "Bearer" + }); + let _ = std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap_or_default()); +} + fn needs_refresh(expires_at: Option) -> bool { if let Some(expires_at) = expires_at { let now = chrono::Utc::now().timestamp() as f64; @@ -147,8 +159,11 @@ pub fn fetch() -> Result { if needs_refresh(expires_at) { if let Some(ref rt_str) = stored_refresh_token { if let Ok(refreshed) = refresh_token(&client, rt_str).await { - if let Some(new_token) = refreshed.access_token { + if let Some(new_token) = refreshed.access_token.clone() { access_token = new_token; + if let (Some(new_rt), Some(expires_in)) = (&refreshed.refresh_token, refreshed.expires_in) { + save_credentials(&access_token, new_rt, expires_in); + } } } } @@ -163,7 +178,11 @@ pub fn fetch() -> Result { let refreshed = refresh_token(&client, rt_str).await?; let new = refreshed .access_token + .clone() .ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?; + if let (Some(new_rt), Some(expires_in)) = (&refreshed.refresh_token, refreshed.expires_in) { + save_credentials(&new, new_rt, expires_in); + } fetch_usage(&client, &new).await? } Err(e) => return Err(e), From 88842322d6a279e7fe566c653815f88c1e2b0026 Mon Sep 17 00:00:00 2001 From: shidevil Date: Sat, 2 May 2026 13:42:31 +0000 Subject: [PATCH 05/19] fix(copilot): respect GH_CONFIG_DIR for gh hosts path resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gh CLI supports GH_CONFIG_DIR to override its config directory, but read_token_from_hosts() hardcoded ~/.config/gh/hosts.yml. Users with a custom GH_CONFIG_DIR would fail to find the token. Extract gh_config_dir() that checks GH_CONFIG_DIR first, then falls back to ~/.config/gh — matching the gh CLI own resolution logic. Co-Authored-By: Claude Opus 4.7 --- crates/tokscale-cli/src/commands/usage/copilot.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/tokscale-cli/src/commands/usage/copilot.rs b/crates/tokscale-cli/src/commands/usage/copilot.rs index 84caea9c7..0158d55eb 100644 --- a/crates/tokscale-cli/src/commands/usage/copilot.rs +++ b/crates/tokscale-cli/src/commands/usage/copilot.rs @@ -43,9 +43,17 @@ fn read_token_from_keychain() -> Result { } } +fn gh_config_dir() -> std::path::PathBuf { + std::env::var("GH_CONFIG_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + home.join(".config").join("gh") + }) +} + fn read_token_from_hosts() -> Result { - let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); - let path = home.join(".config").join("gh").join("hosts.yml"); + let path = gh_config_dir().join("hosts.yml"); if !path.exists() { anyhow::bail!("No gh hosts file"); } From d8ebd071f5f593492762d9fc40cd83707128adce Mon Sep 17 00:00:00 2001 From: shidevil Date: Sat, 2 May 2026 13:57:42 +0000 Subject: [PATCH 06/19] fix(usage): reject unsupported --home flag instead of silently ignoring it The usage command accepted the global --home flag but did nothing with it, silently ignoring the override. Add reject_unsupported_home_override() to match the pattern used by other commands that don't support --home. Co-Authored-By: Claude Opus 4.7 --- crates/tokscale-cli/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tokscale-cli/src/main.rs b/crates/tokscale-cli/src/main.rs index 142d6045f..a123cc5b1 100644 --- a/crates/tokscale-cli/src/main.rs +++ b/crates/tokscale-cli/src/main.rs @@ -588,6 +588,7 @@ fn main() -> Result<()> { run_antigravity_command(subcommand) } Some(Commands::Usage { json, light }) => { + reject_unsupported_home_override(&cli.home, "usage")?; commands::usage::run(json, light) } Some(Commands::DeleteSubmittedData) => { From 0f6f56769c831aa71e5af1a3898f61140c01f394 Mon Sep 17 00:00:00 2001 From: shidevil Date: Sat, 2 May 2026 14:08:33 +0000 Subject: [PATCH 07/19] fix(codex): require access_token in credential lookup and add CODEX_HOME/keychain read_credentials() accepted the first file with any tokens value, even if access_token was null inside. This meant a stale primary auth file (Codex nulls out access_token when switching to keyring storage) would prevent falling back to a valid secondary one. Now only accepts a credential source if tokens.access_token is present. Also adds: - CODEX_HOME env var as first path to check (matches Codex CLI) - macOS keychain fallback for service "Codex Auth" (matches openusage) Co-Authored-By: Claude Opus 4.7 --- .../tokscale-cli/src/commands/usage/codex.rs | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/tokscale-cli/src/commands/usage/codex.rs b/crates/tokscale-cli/src/commands/usage/codex.rs index cb28ed117..cd2cf47d4 100644 --- a/crates/tokscale-cli/src/commands/usage/codex.rs +++ b/crates/tokscale-cli/src/commands/usage/codex.rs @@ -48,20 +48,36 @@ struct Refresh { fn read_credentials() -> Result { let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); - let paths = [ - home.join(".config").join("codex").join("auth.json"), - home.join(".codex").join("auth.json"), - ]; + let mut paths: Vec = Vec::new(); + + // CODEX_HOME takes precedence + if let Ok(codex_home) = std::env::var("CODEX_HOME") { + paths.push(std::path::PathBuf::from(codex_home).join("auth.json")); + } + paths.push(home.join(".config").join("codex").join("auth.json")); + paths.push(home.join(".codex").join("auth.json")); + for p in &paths { if p.exists() { let content = std::fs::read_to_string(p)?; if let Ok(auth) = serde_json::from_str::(&content) { - if auth.tokens.is_some() { + // Only accept if tokens contains a usable access_token + if auth.tokens.as_ref().and_then(|t| t.access_token.as_ref()).is_some() { return Ok(auth); } } } } + + // macOS keychain fallback + if let Ok(raw) = super::helpers::read_keychain("Codex Auth") { + if let Ok(auth) = serde_json::from_str::(&raw) { + if auth.tokens.as_ref().and_then(|t| t.access_token.as_ref()).is_some() { + return Ok(auth); + } + } + } + anyhow::bail!("No Codex credentials found. Run 'codex' to log in.") } From 20357895af1b042f7c383291d6347fe22833035a Mon Sep 17 00:00:00 2001 From: shidevil Date: Sat, 2 May 2026 14:21:01 +0000 Subject: [PATCH 08/19] fix: batch-fix six remaining usage-provider issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. README: correct tab count from 6 to 7, remove non-existent 1-6 numeric shortcut claim 2. app.rs tests: update tab assertions for 7 tabs (Usage added at index 1), fixing test_tab_all/next/prev expectations 3. usage.rs: add empty-state rendering so a successful no-data fetch shows "No subscription data available" instead of the loading prompt 4. helpers.rs: guard read_keychain with cfg!(not(target_os = "macos")) so it fails cleanly on non-macOS instead of spawning a missing binary 5. kimi.rs + codex.rs: use reqwest .form() for OAuth refresh payloads instead of manual string formatting, ensuring proper URL encoding of refresh tokens that may contain reserved characters 6. copilot.rs: fix hosts.yml parsing to handle other fields (user, git_protocol) appearing before oauth_token under github.com: — only exit the section on a non-indented top-level key All 451 tests pass. Co-Authored-By: Claude Opus 4.7 --- README.md | 4 ++-- .../tokscale-cli/src/commands/usage/codex.rs | 9 +++++---- .../src/commands/usage/copilot.rs | 7 ++++--- .../src/commands/usage/helpers.rs | 3 +++ .../tokscale-cli/src/commands/usage/kimi.rs | 9 +++++---- crates/tokscale-cli/src/tui/app.rs | 19 +++++++++++-------- crates/tokscale-cli/src/tui/ui/usage.rs | 18 ++++++++++++++++++ 7 files changed, 48 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 23b95d2f0..519ca2b69 100644 --- a/README.md +++ b/README.md @@ -243,9 +243,9 @@ tokscale models --json > report.json # Save to file The interactive TUI mode provides: -- **6 Views**: Overview (chart + top models), Usage (subscription quotas), Models, Daily, Hourly, Stats (contribution graph), Agents +- **7 Views**: Overview (chart + top models), Usage (subscription quotas), Models, Daily, Hourly, Stats (contribution graph), Agents - **Keyboard Navigation**: - - `1-6` or `←/→/Tab`: Switch views + - `←/→/Tab`: Switch views - `↑/↓`: Navigate lists - `c/d/t`: Sort by cost/date/tokens - `s`: Open source picker dialog diff --git a/crates/tokscale-cli/src/commands/usage/codex.rs b/crates/tokscale-cli/src/commands/usage/codex.rs index cd2cf47d4..d4595f35f 100644 --- a/crates/tokscale-cli/src/commands/usage/codex.rs +++ b/crates/tokscale-cli/src/commands/usage/codex.rs @@ -84,10 +84,11 @@ fn read_credentials() -> Result { async fn refresh_token(client: &reqwest::Client, rt: &str) -> Result { let resp = client .post("https://auth.openai.com/oauth/token") - .header("Content-Type", "application/x-www-form-urlencoded") - .body(format!( - "grant_type=refresh_token&client_id={CLIENT_ID}&refresh_token={rt}" - )) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", CLIENT_ID), + ("refresh_token", rt), + ]) .send() .await?; if !resp.status().is_success() { diff --git a/crates/tokscale-cli/src/commands/usage/copilot.rs b/crates/tokscale-cli/src/commands/usage/copilot.rs index 0158d55eb..468e586f4 100644 --- a/crates/tokscale-cli/src/commands/usage/copilot.rs +++ b/crates/tokscale-cli/src/commands/usage/copilot.rs @@ -66,15 +66,16 @@ fn read_token_from_hosts() -> Result { in_github = true; continue; } + // A non-indented, non-empty, non-comment line starts a new section + if in_github && !line.starts_with(' ') && !line.starts_with('\t') && !trimmed.is_empty() && !trimmed.starts_with('#') { + in_github = false; + } if in_github && trimmed.starts_with("oauth_token:") { let token = trimmed.trim_start_matches("oauth_token:").trim(); if !token.is_empty() { return Ok(token.to_string()); } } - if in_github && !trimmed.is_empty() && !trimmed.starts_with("oauth_token") && !trimmed.starts_with('#') { - in_github = false; - } } anyhow::bail!("No oauth_token found in hosts.yml") } diff --git a/crates/tokscale-cli/src/commands/usage/helpers.rs b/crates/tokscale-cli/src/commands/usage/helpers.rs index 25f0fcaa5..7a5d6d10b 100644 --- a/crates/tokscale-cli/src/commands/usage/helpers.rs +++ b/crates/tokscale-cli/src/commands/usage/helpers.rs @@ -10,6 +10,9 @@ pub fn capitalize(s: &str) -> String { } pub fn read_keychain(service: &str) -> Result { + if cfg!(not(target_os = "macos")) { + anyhow::bail!("Keychain lookup is only available on macOS"); + } let out = std::process::Command::new("security") .args(["find-generic-password", "-s", service, "-w"]) .output()?; diff --git a/crates/tokscale-cli/src/commands/usage/kimi.rs b/crates/tokscale-cli/src/commands/usage/kimi.rs index 1eccfad54..de81359e8 100644 --- a/crates/tokscale-cli/src/commands/usage/kimi.rs +++ b/crates/tokscale-cli/src/commands/usage/kimi.rs @@ -95,10 +95,11 @@ fn needs_refresh(expires_at: Option) -> bool { async fn refresh_token(client: &reqwest::Client, rt: &str) -> Result { let resp = client .post("https://auth.kimi.com/api/oauth/token") - .header("Content-Type", "application/x-www-form-urlencoded") - .body(format!( - "client_id={CLIENT_ID}&grant_type=refresh_token&refresh_token={rt}" - )) + .form(&[ + ("client_id", CLIENT_ID), + ("grant_type", "refresh_token"), + ("refresh_token", rt), + ]) .send() .await?; if !resp.status().is_success() { diff --git a/crates/tokscale-cli/src/tui/app.rs b/crates/tokscale-cli/src/tui/app.rs index 4eea9b71d..370e29cd3 100644 --- a/crates/tokscale-cli/src/tui/app.rs +++ b/crates/tokscale-cli/src/tui/app.rs @@ -1124,18 +1124,20 @@ mod tests { #[test] fn test_tab_all() { let tabs = Tab::all(); - assert_eq!(tabs.len(), 6); + assert_eq!(tabs.len(), 7); assert_eq!(tabs[0], Tab::Overview); - assert_eq!(tabs[1], Tab::Models); - assert_eq!(tabs[2], Tab::Daily); - assert_eq!(tabs[3], Tab::Hourly); - assert_eq!(tabs[4], Tab::Stats); - assert_eq!(tabs[5], Tab::Agents); + assert_eq!(tabs[1], Tab::Usage); + assert_eq!(tabs[2], Tab::Models); + assert_eq!(tabs[3], Tab::Daily); + assert_eq!(tabs[4], Tab::Hourly); + assert_eq!(tabs[5], Tab::Stats); + assert_eq!(tabs[6], Tab::Agents); } #[test] fn test_tab_next() { - assert_eq!(Tab::Overview.next(), Tab::Models); + assert_eq!(Tab::Overview.next(), Tab::Usage); + assert_eq!(Tab::Usage.next(), Tab::Models); assert_eq!(Tab::Models.next(), Tab::Daily); assert_eq!(Tab::Daily.next(), Tab::Hourly); assert_eq!(Tab::Hourly.next(), Tab::Stats); @@ -1146,7 +1148,8 @@ mod tests { #[test] fn test_tab_prev() { assert_eq!(Tab::Overview.prev(), Tab::Agents); - assert_eq!(Tab::Models.prev(), Tab::Overview); + assert_eq!(Tab::Usage.prev(), Tab::Overview); + assert_eq!(Tab::Models.prev(), Tab::Usage); assert_eq!(Tab::Daily.prev(), Tab::Models); assert_eq!(Tab::Hourly.prev(), Tab::Daily); assert_eq!(Tab::Stats.prev(), Tab::Hourly); diff --git a/crates/tokscale-cli/src/tui/ui/usage.rs b/crates/tokscale-cli/src/tui/ui/usage.rs index 5d64935d2..efc09488f 100644 --- a/crates/tokscale-cli/src/tui/ui/usage.rs +++ b/crates/tokscale-cli/src/tui/ui/usage.rs @@ -53,6 +53,8 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { if app.subscription_usage.is_empty() { render_loading(frame, app, inner); + } else if app.subscription_usage.iter().all(|o| o.metrics.is_empty()) { + render_empty(frame, app, inner); } else { render_loaded(frame, app, inner, &app.subscription_usage); } @@ -79,6 +81,22 @@ fn render_loading(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(paragraph, center); } +fn render_empty(frame: &mut Frame, app: &App, area: Rect) { + let center = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(40), + Constraint::Length(3), + Constraint::Percentage(40), + ]) + .split(area)[1]; + + let paragraph = Paragraph::new("No subscription data available") + .style(Style::default().fg(app.theme.muted)) + .alignment(Alignment::Center); + frame.render_widget(paragraph, center); +} + fn render_loaded(frame: &mut Frame, app: &App, area: Rect, outputs: &[crate::commands::usage::UsageOutput]) { let mut lines: Vec = Vec::new(); From 122c12ad357f291364d65c01f4f18a086a744060 Mon Sep 17 00:00:00 2001 From: shidevil Date: Sat, 2 May 2026 14:52:06 +0000 Subject: [PATCH 09/19] perf(usage): skip providers without credentials and fetch in parallel Previously fetch_all() tried all 7 providers sequentially, causing slow tab switches and noisy error messages for providers the user does not have credentials for. Add has_credentials() fast local checks to each provider (file exists, env var set, keychain lookup) that skip providers entirely when no credentials are on disk. Active providers now run in parallel via std::thread::scope, so multiple providers fetch simultaneously. Also fix remaining tab navigation tests (backtab, left/right) that missed the Usage tab insertion. Co-Authored-By: Claude Opus 4.7 --- crates/tokscale-cli/src/commands/usage/amp.rs | 5 ++ .../tokscale-cli/src/commands/usage/claude.rs | 6 +++ .../tokscale-cli/src/commands/usage/codex.rs | 16 ++++++ .../src/commands/usage/copilot.rs | 7 +++ .../tokscale-cli/src/commands/usage/kimi.rs | 5 ++ .../src/commands/usage/minimax.rs | 4 ++ crates/tokscale-cli/src/commands/usage/mod.rs | 50 ++++++++++++------- crates/tokscale-cli/src/commands/usage/zai.rs | 4 ++ crates/tokscale-cli/src/tui/app.rs | 14 +++++- 9 files changed, 93 insertions(+), 18 deletions(-) diff --git a/crates/tokscale-cli/src/commands/usage/amp.rs b/crates/tokscale-cli/src/commands/usage/amp.rs index d882800f9..c9fc6a6b7 100644 --- a/crates/tokscale-cli/src/commands/usage/amp.rs +++ b/crates/tokscale-cli/src/commands/usage/amp.rs @@ -111,6 +111,11 @@ fn detect_plan(metrics: &[UsageMetric]) -> Option { } } +pub fn has_credentials() -> bool { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + home.join(".local").join("share").join("amp").join("secrets.json").exists() +} + pub fn fetch() -> Result { let api_key = read_credentials()?; diff --git a/crates/tokscale-cli/src/commands/usage/claude.rs b/crates/tokscale-cli/src/commands/usage/claude.rs index 360b01f33..4d2557d0a 100644 --- a/crates/tokscale-cli/src/commands/usage/claude.rs +++ b/crates/tokscale-cli/src/commands/usage/claude.rs @@ -47,6 +47,12 @@ fn read_keychain() -> Result { super::helpers::read_keychain("Claude Code-credentials") } +pub fn has_credentials() -> bool { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + home.join(".claude").join(".credentials.json").exists() + || super::helpers::read_keychain("Claude Code-credentials").is_ok() +} + fn read_credentials() -> Result { let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); let path = home.join(".claude").join(".credentials.json"); diff --git a/crates/tokscale-cli/src/commands/usage/codex.rs b/crates/tokscale-cli/src/commands/usage/codex.rs index d4595f35f..5a904493e 100644 --- a/crates/tokscale-cli/src/commands/usage/codex.rs +++ b/crates/tokscale-cli/src/commands/usage/codex.rs @@ -81,6 +81,22 @@ fn read_credentials() -> Result { anyhow::bail!("No Codex credentials found. Run 'codex' to log in.") } +pub fn has_credentials() -> bool { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + if let Ok(codex_home) = std::env::var("CODEX_HOME") { + if std::path::PathBuf::from(codex_home).join("auth.json").exists() { + return true; + } + } + if home.join(".config").join("codex").join("auth.json").exists() { + return true; + } + if home.join(".codex").join("auth.json").exists() { + return true; + } + super::helpers::read_keychain("Codex Auth").is_ok() +} + async fn refresh_token(client: &reqwest::Client, rt: &str) -> Result { let resp = client .post("https://auth.openai.com/oauth/token") diff --git a/crates/tokscale-cli/src/commands/usage/copilot.rs b/crates/tokscale-cli/src/commands/usage/copilot.rs index 468e586f4..9cd3a93a3 100644 --- a/crates/tokscale-cli/src/commands/usage/copilot.rs +++ b/crates/tokscale-cli/src/commands/usage/copilot.rs @@ -160,6 +160,13 @@ async fn fetch_api(client: &reqwest::Client, token: &str) -> Result bool { + if super::helpers::read_keychain("gh:github.com").is_ok() { + return true; + } + gh_config_dir().join("hosts.yml").exists() +} + pub fn fetch() -> Result { let token = read_credentials()?; diff --git a/crates/tokscale-cli/src/commands/usage/kimi.rs b/crates/tokscale-cli/src/commands/usage/kimi.rs index de81359e8..1850b8081 100644 --- a/crates/tokscale-cli/src/commands/usage/kimi.rs +++ b/crates/tokscale-cli/src/commands/usage/kimi.rs @@ -143,6 +143,11 @@ fn parse_quota_detail(label: &str, detail: &QuotaDetail) -> Option }) } +pub fn has_credentials() -> bool { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + home.join(".kimi").join("credentials").join("kimi-code.json").exists() +} + pub fn fetch() -> Result { let creds = read_credentials()?; let mut access_token = creds diff --git a/crates/tokscale-cli/src/commands/usage/minimax.rs b/crates/tokscale-cli/src/commands/usage/minimax.rs index 16727336a..be70a5600 100644 --- a/crates/tokscale-cli/src/commands/usage/minimax.rs +++ b/crates/tokscale-cli/src/commands/usage/minimax.rs @@ -128,6 +128,10 @@ async fn fetch_api(client: &reqwest::Client, key: &str) -> Result { Ok(resp.json().await?) } +pub fn has_credentials() -> bool { + std::env::var("MINIMAX_API_KEY").or_else(|_| std::env::var("MINIMAX_API_TOKEN")).is_ok() +} + pub fn fetch() -> Result { let api_key = read_api_key()?; diff --git a/crates/tokscale-cli/src/commands/usage/mod.rs b/crates/tokscale-cli/src/commands/usage/mod.rs index 31b7b3f1d..92f802a59 100644 --- a/crates/tokscale-cli/src/commands/usage/mod.rs +++ b/crates/tokscale-cli/src/commands/usage/mod.rs @@ -31,26 +31,42 @@ pub struct UsageOutput { // ── Public API ── pub fn fetch_all() -> Vec { - let mut results = Vec::new(); + let providers: Vec<(&str, fn() -> bool, fn() -> Result)> = vec![ + ("Claude", claude::has_credentials, claude::fetch), + ("Codex", codex::has_credentials, codex::fetch), + ("Z.ai", zai::has_credentials, zai::fetch), + ("Amp", amp::has_credentials, amp::fetch), + ("Copilot", copilot::has_credentials, copilot::fetch), + ("Kimi", kimi::has_credentials, kimi::fetch), + ("MiniMax", minimax::has_credentials, minimax::fetch), + ]; - macro_rules! try_fetch { - ($name:expr, $func:expr) => { - match $func() { - Ok(o) => results.push(o), - Err(e) => eprintln!("{}: {e}", $name), - } - }; - } + let active: Vec<_> = providers + .into_iter() + .filter(|(_, has, _)| has()) + .collect(); - try_fetch!("Claude", claude::fetch); - try_fetch!("Codex", codex::fetch); - try_fetch!("Z.ai", zai::fetch); - try_fetch!("Amp", amp::fetch); - try_fetch!("Copilot", copilot::fetch); - try_fetch!("Kimi", kimi::fetch); - try_fetch!("MiniMax", minimax::fetch); + if active.is_empty() { + return vec![]; + } - results + std::thread::scope(|s| { + active + .into_iter() + .map(|(name, _, fetch)| { + s.spawn(move || match fetch() { + Ok(o) => Some(o), + Err(e) => { + eprintln!("{name}: {e}"); + None + } + }) + }) + .collect::>() + .into_iter() + .filter_map(|h| h.join().ok().flatten()) + .collect() + }) } // ── Light-mode rendering ── diff --git a/crates/tokscale-cli/src/commands/usage/zai.rs b/crates/tokscale-cli/src/commands/usage/zai.rs index 24c98bb39..ee57280c9 100644 --- a/crates/tokscale-cli/src/commands/usage/zai.rs +++ b/crates/tokscale-cli/src/commands/usage/zai.rs @@ -66,6 +66,10 @@ async fn fetch_sub(client: &reqwest::Client, key: &str) -> Result { Ok(resp.json().await?) } +pub fn has_credentials() -> bool { + std::env::var("ZAI_API_KEY").or_else(|_| std::env::var("GLM_API_KEY")).is_ok() +} + pub fn fetch() -> Result { let api_key = std::env::var("ZAI_API_KEY") .or_else(|_| std::env::var("GLM_API_KEY")) diff --git a/crates/tokscale-cli/src/tui/app.rs b/crates/tokscale-cli/src/tui/app.rs index 370e29cd3..1111dd411 100644 --- a/crates/tokscale-cli/src/tui/app.rs +++ b/crates/tokscale-cli/src/tui/app.rs @@ -1470,6 +1470,9 @@ mod tests { let mut app = make_app(); assert_eq!(app.current_tab, Tab::Overview); + app.handle_key_event(key(KeyCode::Tab)); + assert_eq!(app.current_tab, Tab::Usage); + app.handle_key_event(key(KeyCode::Tab)); assert_eq!(app.current_tab, Tab::Models); @@ -1508,6 +1511,12 @@ mod tests { app.handle_key_event(key(KeyCode::BackTab)); assert_eq!(app.current_tab, Tab::Models); + + app.handle_key_event(key(KeyCode::BackTab)); + assert_eq!(app.current_tab, Tab::Usage); + + app.handle_key_event(key(KeyCode::BackTab)); + assert_eq!(app.current_tab, Tab::Overview); } #[test] @@ -1589,11 +1598,14 @@ mod tests { #[test] fn test_handle_key_left_right_switch() { let mut app = make_app(); + app.handle_key_event(key(KeyCode::Right)); + assert_eq!(app.current_tab, Tab::Usage); + app.handle_key_event(key(KeyCode::Right)); assert_eq!(app.current_tab, Tab::Models); app.handle_key_event(key(KeyCode::Left)); - assert_eq!(app.current_tab, Tab::Overview); + assert_eq!(app.current_tab, Tab::Usage); } #[test] From 2dcc24fc1631365544dbf18ee9e8d2794e90546e Mon Sep 17 00:00:00 2001 From: shidevil Date: Sat, 2 May 2026 15:18:16 +0000 Subject: [PATCH 10/19] feat(usage): cache subscription data to disk with 5-minute TTL Subscription usage now persists to the same cache directory as other TUI data (~/.cache/tokscale/subscription-usage-cache.json). On startup the cached data is loaded instantly, making the Usage tab appear immediately on first switch. A fresh fetch is triggered when the cache is older than 5 minutes or when the user presses u/r to refresh. Co-Authored-By: Claude Opus 4.7 --- crates/tokscale-cli/src/commands/usage/mod.rs | 44 ++++++++++++++++++- crates/tokscale-cli/src/tui/app.rs | 3 +- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/tokscale-cli/src/commands/usage/mod.rs b/crates/tokscale-cli/src/commands/usage/mod.rs index 92f802a59..cffe96cde 100644 --- a/crates/tokscale-cli/src/commands/usage/mod.rs +++ b/crates/tokscale-cli/src/commands/usage/mod.rs @@ -11,7 +11,7 @@ use anyhow::Result; // ── Shared types ── -#[derive(Debug, Clone, serde::Serialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct UsageMetric { pub label: String, pub used_percent: f64, @@ -20,7 +20,7 @@ pub struct UsageMetric { pub resets_at: Option, } -#[derive(Debug, Clone, serde::Serialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct UsageOutput { pub provider: String, pub plan: Option, @@ -28,6 +28,46 @@ pub struct UsageOutput { pub metrics: Vec, } +// ── Cache ── + +fn cache_path() -> Option { + let dir = crate::paths::get_cache_dir(); + if std::fs::create_dir_all(&dir).is_err() { + return None; + } + Some(dir.join("subscription-usage-cache.json")) +} + +pub fn save_cache(data: &[UsageOutput]) { + let Some(path) = cache_path() else { return }; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let json = serde_json::json!({ + "timestamp": timestamp, + "data": data, + }); + let _ = std::fs::write(&path, serde_json::to_string(&json).unwrap_or_default()); +} + +pub fn load_cache() -> Option> { + let path = cache_path()?; + let content = std::fs::read_to_string(&path).ok()?; + let doc: serde_json::Value = serde_json::from_str(&content).ok()?; + let timestamp = doc.get("timestamp")?.as_u64()?; + let age = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .saturating_sub(timestamp); + // Cache expires after 5 minutes + if age > 300 { + return None; + } + serde_json::from_value(doc.get("data")?.clone()).ok() +} + // ── Public API ── pub fn fetch_all() -> Vec { diff --git a/crates/tokscale-cli/src/tui/app.rs b/crates/tokscale-cli/src/tui/app.rs index 1111dd411..6d061a9e0 100644 --- a/crates/tokscale-cli/src/tui/app.rs +++ b/crates/tokscale-cli/src/tui/app.rs @@ -285,7 +285,7 @@ impl App { dialog_needs_reload, hourly_view_mode: HourlyViewMode::default(), model_shade_map: HashMap::new(), - subscription_usage: Vec::new(), + subscription_usage: crate::commands::usage::load_cache().unwrap_or_default(), }; app.build_model_shade_map(); Ok(app) @@ -498,6 +498,7 @@ impl App { pub fn fetch_subscription_usage(&mut self) { self.subscription_usage = crate::commands::usage::fetch_all(); if !self.subscription_usage.is_empty() { + crate::commands::usage::save_cache(&self.subscription_usage); self.status_message = Some("Usage data loaded".into()); } else { self.status_message = Some("No usage data available".into()); From b8c7acb8242f771f72a4e75de240667024afe32b Mon Sep 17 00:00:00 2001 From: shidevil Date: Sat, 2 May 2026 15:38:45 +0000 Subject: [PATCH 11/19] fix(zai): order metrics as Session/Weekly/Web Search and widen label column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename Monthly to Weekly and Web Searches to Web Search. Collect metrics into named variables instead of pushing in API order, so the output is always Session → Weekly → Web Search regardless of API response ordering. Widen label column to 14 chars for cleaner bar alignment across all providers. Co-Authored-By: Claude Opus 4.7 --- crates/tokscale-cli/src/commands/usage/mod.rs | 4 +-- crates/tokscale-cli/src/commands/usage/zai.rs | 34 +++++++++++++------ crates/tokscale-cli/src/tui/ui/usage.rs | 2 +- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/crates/tokscale-cli/src/commands/usage/mod.rs b/crates/tokscale-cli/src/commands/usage/mod.rs index cffe96cde..002e03fef 100644 --- a/crates/tokscale-cli/src/commands/usage/mod.rs +++ b/crates/tokscale-cli/src/commands/usage/mod.rs @@ -112,7 +112,7 @@ pub fn fetch_all() -> Vec { // ── Light-mode rendering ── const BAR_WIDTH: usize = 12; -const CARD_WIDTH: usize = 58; +const CARD_WIDTH: usize = 62; fn render_light(output: &UsageOutput) { println!("╭{}╮", "─".repeat(CARD_WIDTH)); @@ -120,7 +120,7 @@ fn render_light(output: &UsageOutput) { let rem = m.remaining_label.clone().unwrap_or_else(|| format!("{:.0}% left", m.remaining_percent)); let bar = helpers::render_ascii_bar(m.remaining_percent, BAR_WIDTH); let reset = m.resets_at.as_ref().map(|r| helpers::format_reset_time(r)).unwrap_or_default(); - println!("│ {:<10}{:<11}{:<14}{:<20}│", m.label, rem, bar, reset); + println!("│ {:<14}{:<11}{:<14}{:<20}│", m.label, rem, bar, reset); } if let Some(ref email) = output.email { println!("│ {: Result { .and_then(|s| s.product_name.clone()) .or_else(|| quota.data.as_ref().and_then(|d| d.level.clone()).map(|l| capitalize(&l))); - let mut metrics = Vec::new(); + let mut session_metric = None; + let mut weekly_metric = None; + let mut search_metric = None; + if let Some(ref limits) = quota.data.as_ref().and_then(|d| d.limits.as_ref()) { for limit in limits.iter() { let pct = limit.percentage.unwrap_or(0.0).clamp(0.0, 100.0); match limit.limit_type.as_deref() { Some("TOKENS_LIMIT") => { - let label = match (limit.unit, limit.number) { - (Some(3), Some(5)) => "Session", - (Some(6), Some(1)) => "Monthly", - _ => "Tokens", - }; - metrics.push(UsageMetric { - label: label.into(), + let metric = UsageMetric { + label: String::new(), used_percent: pct, remaining_percent: 100.0 - pct, remaining_label: None, resets_at: None, - }); + }; + match (limit.unit, limit.number) { + (Some(3), Some(5)) => { + session_metric = Some(UsageMetric { label: "Session".into(), ..metric }); + } + (Some(6), Some(1)) => { + weekly_metric = Some(UsageMetric { label: "Weekly".into(), ..metric }); + } + _ => {} + } } Some("TIME_LIMIT") => { let remaining_label = limit.remaining.map(|r| format!("{:.0} left", r)); - metrics.push(UsageMetric { - label: "Web Searches".into(), + search_metric = Some(UsageMetric { + label: "Web Search".into(), used_percent: pct, remaining_percent: 100.0 - pct, remaining_label, @@ -127,6 +134,11 @@ pub fn fetch() -> Result { } } + let mut metrics = Vec::new(); + if let Some(m) = session_metric { metrics.push(m); } + if let Some(m) = weekly_metric { metrics.push(m); } + if let Some(m) = search_metric { metrics.push(m); } + Ok(UsageOutput { provider: "Z.ai".into(), plan, diff --git a/crates/tokscale-cli/src/tui/ui/usage.rs b/crates/tokscale-cli/src/tui/ui/usage.rs index efc09488f..6015763b2 100644 --- a/crates/tokscale-cli/src/tui/ui/usage.rs +++ b/crates/tokscale-cli/src/tui/ui/usage.rs @@ -120,7 +120,7 @@ fn render_loaded(frame: &mut Frame, app: &App, area: Rect, outputs: &[crate::com .unwrap_or_default(); let label = Span::styled( - format!(" {:<12}", m.label), + format!(" {:<14}", m.label), Style::default().fg(app.theme.foreground), ); let value = Span::styled( From 3369e6ac579f6405bafc64567e0e974611542ce1 Mon Sep 17 00:00:00 2001 From: shidevil Date: Sun, 3 May 2026 00:31:41 +0000 Subject: [PATCH 12/19] fix(usage): batch reliability and security fixes - Persist OAuth refresh tokens to disk (Claude, Codex, Kimi) - Atomic credential writes with 0o600 permissions - Fix copilot has_credentials() to properly parse hosts.yml - Fix copilot percent_remaining to use f64 not i64 - Fix minimax epoch_to_ms() heuristic for 2026 timestamps - Add is_finite() guards on f64 arithmetic (amp.rs) - Make helpers module public for TUI import - Remove duplicate helpers from tui/ui/usage.rs - Remove eprintln! error spam from worker threads - Add provider name header + truncation to light-mode cards - Fix README Usage tab shortcut instruction - Make TUI tests hermetic (skip real cache in test builds) - Remove auto-fetch on tab switch (cache + u key) - Clear cache when fetch returns empty results - Fix codex CredentialSource Copy derive (PathBuf not Copy) Co-Authored-By: Claude Opus 4.7 --- README.md | 2 +- crates/tokscale-cli/src/commands/usage/amp.rs | 12 ++- .../tokscale-cli/src/commands/usage/claude.rs | 92 +++++++++++++++++-- .../tokscale-cli/src/commands/usage/codex.rs | 92 +++++++++++++++++-- .../src/commands/usage/copilot.rs | 38 ++++++-- .../tokscale-cli/src/commands/usage/kimi.rs | 35 ++++++- .../src/commands/usage/minimax.rs | 2 +- crates/tokscale-cli/src/commands/usage/mod.rs | 29 ++++-- crates/tokscale-cli/src/tui/app.rs | 16 +++- crates/tokscale-cli/src/tui/ui/usage.rs | 40 +------- 10 files changed, 282 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 519ca2b69..934d98969 100644 --- a/README.md +++ b/README.md @@ -485,7 +485,7 @@ tokscale usage --json tokscale usage --light ``` -In the TUI, press `2` or navigate to the **Usage** tab to see subscription data. Press `u` or `r` to refresh. +In the TUI, navigate to the **Usage** tab to see subscription data. Press `u` or `r` to refresh. #### Supported Providers diff --git a/crates/tokscale-cli/src/commands/usage/amp.rs b/crates/tokscale-cli/src/commands/usage/amp.rs index c9fc6a6b7..8f3498168 100644 --- a/crates/tokscale-cli/src/commands/usage/amp.rs +++ b/crates/tokscale-cli/src/commands/usage/amp.rs @@ -58,15 +58,19 @@ fn parse_display_text(text: &str) -> Vec { let after = &text[slash_pos + 2..]; if let Some(space_pos) = after.find(|c: char| c.is_ascii_whitespace()) { if let Ok(total) = after[..space_pos].replace(',', "").parse::() { - if total > 0.0 { + if total > 0.0 && total.is_finite() && remaining.is_finite() { let used = (total - remaining).max(0.0); - let used_pct = (used / total * 100.0).clamp(0.0, 100.0); - let remaining_pct = 100.0 - used_pct; + let used_pct = if used.is_finite() { + (used / total * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + }; + let remaining_pct = (100.0 - used_pct).clamp(0.0, 100.0); let mut resets_at = None; // Estimate reset time from hourly replenish rate if let Some(rate) = parse_dollar_after(text, "+$") { - if rate > 0.0 && used > 0.0 { + if rate > 0.0 && used > 0.0 && rate.is_finite() { let secs = (used / rate * 3600.0) as i64; let resets = chrono::Utc::now() + chrono::Duration::seconds(secs); resets_at = Some(resets.to_rfc3339()); diff --git a/crates/tokscale-cli/src/commands/usage/claude.rs b/crates/tokscale-cli/src/commands/usage/claude.rs index 4d2557d0a..7873b1d07 100644 --- a/crates/tokscale-cli/src/commands/usage/claude.rs +++ b/crates/tokscale-cli/src/commands/usage/claude.rs @@ -41,6 +41,13 @@ struct Window { #[derive(Debug, Deserialize)] struct TokenRefresh { access_token: Option, + refresh_token: Option, +} + +#[derive(Debug, Clone, Copy)] +enum CredentialSource { + File, + Keychain, } fn read_keychain() -> Result { @@ -53,15 +60,70 @@ pub fn has_credentials() -> bool { || super::helpers::read_keychain("Claude Code-credentials").is_ok() } -fn read_credentials() -> Result { +fn read_credentials() -> Result<(Credentials, CredentialSource)> { let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); let path = home.join(".claude").join(".credentials.json"); - let content = if path.exists() { - std::fs::read_to_string(&path)? + if path.exists() { + let content = std::fs::read_to_string(&path)?; + let creds: Credentials = serde_json::from_str(&content)?; + Ok((creds, CredentialSource::File)) } else { - read_keychain()? + let content = read_keychain()?; + let creds: Credentials = serde_json::from_str(&content)?; + Ok((creds, CredentialSource::Keychain)) + } +} + +fn save_credentials(access_token: &str, refresh_token: &str, subscription_type: Option<&str>, rate_limit_tier: Option<&str>) { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + let path = home.join(".claude").join(".credentials.json"); + let mut oauth = serde_json::json!({ + "accessToken": access_token, + "refreshToken": refresh_token, + }); + if let Some(st) = subscription_type { + oauth["subscriptionType"] = serde_json::Value::String(st.to_string()); + } + if let Some(rlt) = rate_limit_tier { + oauth["rateLimitTier"] = serde_json::Value::String(rlt.to_string()); + } + let json = serde_json::json!({ + "claudeAiOauth": oauth + }); + let content = match serde_json::to_string_pretty(&json) { + Ok(c) => c, + Err(e) => { + eprintln!("warning: failed to serialize Claude credentials: {e}"); + return; + } }; - Ok(serde_json::from_str(&content)?) + if let Err(e) = atomic_write_secret(&path, content.as_bytes()) { + eprintln!("warning: failed to save Claude credentials: {e}"); + } +} + +fn atomic_write_secret(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> { + if path.parent().is_none() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "path has no parent directory", + )); + } + let temp_path = path.with_extension("tmp"); + { + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + f.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + std::io::Write::write_all(&mut f, data)?; + } + std::fs::rename(&temp_path, path)?; + Ok(()) } async fn refresh_token(client: &reqwest::Client, rt: &str) -> Result { @@ -115,20 +177,21 @@ fn window_metric(label: &str, w: &Window) -> UsageMetric { pub fn fetch() -> Result { let rt = tokio::runtime::Runtime::new()?; rt.block_on(async { - let creds = read_credentials()?; + let (creds, source) = read_credentials()?; let oauth = creds.claude_ai_oauth.ok_or_else(|| { anyhow::anyhow!("No Claude OAuth credentials. Run 'claude' to log in.") })?; let access_token = oauth .access_token + .clone() .ok_or_else(|| anyhow::anyhow!("No Claude access token."))?; - let plan = oauth.subscription_type.map(|s| { + let plan = oauth.subscription_type.as_ref().map(|s| { let tier = oauth.rate_limit_tier.as_deref().and_then(|t| { t.rsplit('_').next() }); match tier { - Some(mult) => format!("{} {}", capitalize(&s), mult), - None => capitalize(&s), + Some(mult) => format!("{} {}", capitalize(s), mult), + None => capitalize(s), } }); @@ -143,7 +206,18 @@ pub fn fetch() -> Result { let refreshed = refresh_token(&client, rt).await?; let new = refreshed .access_token + .clone() .ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?; + if matches!(source, CredentialSource::File) { + if let Some(new_rt) = refreshed.refresh_token.as_deref() { + save_credentials( + &new, + new_rt, + oauth.subscription_type.as_deref(), + oauth.rate_limit_tier.as_deref(), + ); + } + } fetch_usage(&client, &new).await? } Err(e) => return Err(e), diff --git a/crates/tokscale-cli/src/commands/usage/codex.rs b/crates/tokscale-cli/src/commands/usage/codex.rs index 5a904493e..883395fd4 100644 --- a/crates/tokscale-cli/src/commands/usage/codex.rs +++ b/crates/tokscale-cli/src/commands/usage/codex.rs @@ -17,6 +17,7 @@ struct Tokens { access_token: Option, refresh_token: Option, account_id: Option, + id_token: Option, } #[derive(Debug, Deserialize)] @@ -44,9 +45,18 @@ struct Window { #[derive(Debug, Deserialize)] struct Refresh { access_token: Option, + refresh_token: Option, + #[allow(dead_code)] + expires_in: Option, +} + +#[derive(Debug, Clone)] +enum CredentialSource { + File(std::path::PathBuf), + Keychain, } -fn read_credentials() -> Result { +fn read_credentials() -> Result<(Auth, CredentialSource)> { let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); let mut paths: Vec = Vec::new(); @@ -63,7 +73,7 @@ fn read_credentials() -> Result { if let Ok(auth) = serde_json::from_str::(&content) { // Only accept if tokens contains a usable access_token if auth.tokens.as_ref().and_then(|t| t.access_token.as_ref()).is_some() { - return Ok(auth); + return Ok((auth, CredentialSource::File(p.clone()))); } } } @@ -73,7 +83,7 @@ fn read_credentials() -> Result { if let Ok(raw) = super::helpers::read_keychain("Codex Auth") { if let Ok(auth) = serde_json::from_str::(&raw) { if auth.tokens.as_ref().and_then(|t| t.access_token.as_ref()).is_some() { - return Ok(auth); + return Ok((auth, CredentialSource::Keychain)); } } } @@ -81,6 +91,63 @@ fn read_credentials() -> Result { anyhow::bail!("No Codex credentials found. Run 'codex' to log in.") } +fn save_credentials( + path: &std::path::Path, + access_token: &str, + refresh_token: &str, + account_id: Option<&str>, + id_token: Option<&str>, +) { + let mut tokens = serde_json::json!({ + "access_token": access_token, + "refresh_token": refresh_token, + }); + if let Some(aid) = account_id { + tokens["account_id"] = serde_json::Value::String(aid.to_string()); + } + if let Some(it) = id_token { + tokens["id_token"] = serde_json::Value::String(it.to_string()); + } + let json = serde_json::json!({ + "tokens": tokens, + "last_refresh": chrono::Utc::now().to_rfc3339(), + }); + let content = match serde_json::to_string_pretty(&json) { + Ok(c) => c, + Err(e) => { + eprintln!("warning: failed to serialize Codex credentials: {e}"); + return; + } + }; + if let Err(e) = atomic_write_secret(path, content.as_bytes()) { + eprintln!("warning: failed to save Codex credentials: {e}"); + } +} + +fn atomic_write_secret(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> { + if path.parent().is_none() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "path has no parent directory", + )); + } + let temp_path = path.with_extension("tmp"); + { + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + f.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + std::io::Write::write_all(&mut f, data)?; + } + std::fs::rename(&temp_path, path)?; + Ok(()) +} + pub fn has_credentials() -> bool { let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); if let Ok(codex_home) = std::env::var("CODEX_HOME") { @@ -140,12 +207,13 @@ async fn fetch_usage(client: &reqwest::Client, token: &str, account_id: Option<& pub fn fetch() -> Result { let rt = tokio::runtime::Runtime::new()?; rt.block_on(async { - let auth = read_credentials()?; + let (auth, source) = read_credentials()?; let tokens = auth .tokens .ok_or_else(|| anyhow::anyhow!("No Codex tokens."))?; let access_token = tokens .access_token + .clone() .ok_or_else(|| anyhow::anyhow!("No Codex access token."))?; let account_id = tokens.account_id.as_deref(); @@ -153,14 +221,26 @@ pub fn fetch() -> Result { let resp = match fetch_usage(&client, &access_token, account_id).await { Ok(r) => r, Err(e) if e.to_string().contains("NEEDS_AUTH") => { - let rt = tokens + let rt_str = tokens .refresh_token .as_ref() .ok_or_else(|| anyhow::anyhow!("No refresh token."))?; - let refreshed = refresh_token(&client, rt).await?; + let refreshed = refresh_token(&client, rt_str).await?; let new = refreshed .access_token + .clone() .ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?; + if let CredentialSource::File(ref path) = source { + if let Some(new_rt) = refreshed.refresh_token.as_deref() { + save_credentials( + path, + &new, + new_rt, + tokens.account_id.as_deref(), + tokens.id_token.as_deref(), + ); + } + } fetch_usage(&client, &new, account_id).await? } Err(e) => return Err(e), diff --git a/crates/tokscale-cli/src/commands/usage/copilot.rs b/crates/tokscale-cli/src/commands/usage/copilot.rs index 9cd3a93a3..42d26c2cf 100644 --- a/crates/tokscale-cli/src/commands/usage/copilot.rs +++ b/crates/tokscale-cli/src/commands/usage/copilot.rs @@ -164,7 +164,33 @@ pub fn has_credentials() -> bool { if super::helpers::read_keychain("gh:github.com").is_ok() { return true; } - gh_config_dir().join("hosts.yml").exists() + // Check that hosts.yml exists and contains a github.com: section with an oauth_token + let path = gh_config_dir().join("hosts.yml"); + if !path.exists() { + return false; + } + let Ok(content) = std::fs::read_to_string(&path) else { + return false; + }; + let mut in_github = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed == "github.com:" { + in_github = true; + continue; + } + // A non-indented, non-empty, non-comment line starts a new section + if in_github && !line.starts_with(' ') && !line.starts_with('\t') && !trimmed.is_empty() && !trimmed.starts_with('#') { + in_github = false; + } + if in_github && trimmed.starts_with("oauth_token:") { + let token = trimmed.trim_start_matches("oauth_token:").trim(); + if !token.is_empty() { + return true; + } + } + } + false } pub fn fetch() -> Result { @@ -189,14 +215,14 @@ pub fn fetch() -> Result { for (key, value) in snapshots { let pct_remaining = value.get("percent_remaining") - .and_then(|v| v.as_i64()) - .unwrap_or(100) - .clamp(0, 100); + .and_then(|v| v.as_f64()) + .unwrap_or(100.0) + .clamp(0.0, 100.0); let remaining = value.get("remaining").and_then(|v| v.as_i64()); let entitlement = value.get("entitlement").and_then(|v| v.as_i64()); - let used_pct = (100 - pct_remaining) as f64; - let remaining_pct = pct_remaining as f64; + let used_pct = 100.0 - pct_remaining; + let remaining_pct = pct_remaining; let remaining_label = match (remaining, entitlement) { (Some(r), Some(e)) => Some(format!("{r}/{e} left")), diff --git a/crates/tokscale-cli/src/commands/usage/kimi.rs b/crates/tokscale-cli/src/commands/usage/kimi.rs index 1850b8081..8a2caaf58 100644 --- a/crates/tokscale-cli/src/commands/usage/kimi.rs +++ b/crates/tokscale-cli/src/commands/usage/kimi.rs @@ -80,7 +80,40 @@ fn save_credentials(access_token: &str, refresh_token: &str, expires_in: i64) { "scope": "kimi-code", "token_type": "Bearer" }); - let _ = std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap_or_default()); + let content = match serde_json::to_string_pretty(&json) { + Ok(c) => c, + Err(e) => { + eprintln!("warning: failed to serialize Kimi credentials: {e}"); + return; + } + }; + if let Err(e) = atomic_write_secret(&path, content.as_bytes()) { + eprintln!("warning: failed to save Kimi credentials: {e}"); + } +} + +fn atomic_write_secret(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> { + if path.parent().is_none() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "path has no parent directory", + )); + } + let temp_path = path.with_extension("tmp"); + { + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + f.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + std::io::Write::write_all(&mut f, data)?; + } + std::fs::rename(&temp_path, path)?; + Ok(()) } fn needs_refresh(expires_at: Option) -> bool { diff --git a/crates/tokscale-cli/src/commands/usage/minimax.rs b/crates/tokscale-cli/src/commands/usage/minimax.rs index be70a5600..95a441f15 100644 --- a/crates/tokscale-cli/src/commands/usage/minimax.rs +++ b/crates/tokscale-cli/src/commands/usage/minimax.rs @@ -98,7 +98,7 @@ fn infer_plan(total: i64) -> Option { } fn epoch_to_ms(ts: i64) -> i64 { - if ts.abs() < 1_000_000_000 { ts * 1000 } else { ts } + if ts.abs() > 10_000_000_000 { ts } else { ts * 1000 } } fn parse_end_time(ts: i64) -> String { diff --git a/crates/tokscale-cli/src/commands/usage/mod.rs b/crates/tokscale-cli/src/commands/usage/mod.rs index 002e03fef..edbd4dbc2 100644 --- a/crates/tokscale-cli/src/commands/usage/mod.rs +++ b/crates/tokscale-cli/src/commands/usage/mod.rs @@ -2,7 +2,7 @@ mod amp; mod claude; mod codex; mod copilot; -mod helpers; +pub mod helpers; mod kimi; mod minimax; mod zai; @@ -51,6 +51,12 @@ pub fn save_cache(data: &[UsageOutput]) { let _ = std::fs::write(&path, serde_json::to_string(&json).unwrap_or_default()); } +pub fn clear_cache() { + if let Some(path) = cache_path() { + let _ = std::fs::remove_file(&path); + } +} + pub fn load_cache() -> Option> { let path = cache_path()?; let content = std::fs::read_to_string(&path).ok()?; @@ -93,13 +99,10 @@ pub fn fetch_all() -> Vec { std::thread::scope(|s| { active .into_iter() - .map(|(name, _, fetch)| { + .map(|(_, _, fetch)| { s.spawn(move || match fetch() { Ok(o) => Some(o), - Err(e) => { - eprintln!("{name}: {e}"); - None - } + Err(_) => None, }) }) .collect::>() @@ -114,18 +117,32 @@ pub fn fetch_all() -> Vec { const BAR_WIDTH: usize = 12; const CARD_WIDTH: usize = 62; +fn truncate(s: &str, max_len: usize) -> String { + let truncated: String = s.chars().take(max_len).collect(); + if truncated.len() < s.len() { + format!("{}…", truncated) + } else { + truncated + } +} + fn render_light(output: &UsageOutput) { println!("╭{}╮", "─".repeat(CARD_WIDTH)); + // Provider header + println!("│ {: String { - let pct = remaining_percent.clamp(0.0, 100.0) / 100.0; - let filled = (pct * BAR_WIDTH as f64).round() as usize; - let empty = BAR_WIDTH - filled; - format!("[{}{}]", "=".repeat(filled), "-".repeat(empty)) -} - -fn format_reset_time(resets_at: &str) -> String { - use chrono::{DateTime, Duration, Utc}; - let dt = match DateTime::parse_from_rfc3339(resets_at) { - Ok(d) => d.with_timezone(&Utc), - Err(_) => return format!("resets {resets_at}"), - }; - let diff = dt - Utc::now(); - if diff <= Duration::zero() { - return "resets now".into(); - } - let total_mins = diff.num_minutes(); - if total_mins < 60 { - format!("resets in {total_mins}m") - } else if total_mins < 24 * 60 { - let hours = diff.num_hours(); - let mins = (diff - Duration::hours(hours)).num_minutes(); - if mins > 0 { - format!("resets in {hours}h {mins}m") - } else { - format!("resets in {hours}h") - } - } else if diff.num_days() < 7 { - format!("resets {} {}", dt.format("%a"), dt.format("%-I%P")) - } else { - format!("resets {}", dt.format("%b %-d")) - } -} - pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { let block = Block::default() .borders(Borders::ALL) @@ -112,11 +78,11 @@ fn render_loaded(frame: &mut Frame, app: &App, area: Rect, outputs: &[crate::com for m in &output.metrics { let remaining = m.remaining_label.clone().unwrap_or_else(|| format!("{:.0}% left", m.remaining_percent)); - let bar = render_ascii_bar(m.remaining_percent); + let bar = helpers::render_ascii_bar(m.remaining_percent, BAR_WIDTH); let reset = m .resets_at .as_ref() - .map(|r| format_reset_time(r)) + .map(|r| helpers::format_reset_time(r)) .unwrap_or_default(); let label = Span::styled( From 9609d387826a03818ca2dfda9fd256bdeb2b9d86 Mon Sep 17 00:00:00 2001 From: shidevil Date: Sun, 3 May 2026 00:40:11 +0000 Subject: [PATCH 13/19] fix(copilot): support Windows and XDG config directories for gh hosts Co-Authored-By: Claude Opus 4.7 --- .../src/commands/usage/copilot.rs | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/tokscale-cli/src/commands/usage/copilot.rs b/crates/tokscale-cli/src/commands/usage/copilot.rs index 42d26c2cf..1dd783fe5 100644 --- a/crates/tokscale-cli/src/commands/usage/copilot.rs +++ b/crates/tokscale-cli/src/commands/usage/copilot.rs @@ -44,12 +44,20 @@ fn read_token_from_keychain() -> Result { } fn gh_config_dir() -> std::path::PathBuf { - std::env::var("GH_CONFIG_DIR") - .map(std::path::PathBuf::from) - .unwrap_or_else(|_| { - let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); - home.join(".config").join("gh") - }) + if let Ok(dir) = std::env::var("GH_CONFIG_DIR") { + return std::path::PathBuf::from(dir); + } + if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") { + return std::path::PathBuf::from(dir).join("gh"); + } + if cfg!(windows) { + return std::env::var_os("APPDATA") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."))) + .join("GitHub CLI"); + } + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + home.join(".config").join("gh") } fn read_token_from_hosts() -> Result { From 8a24115863db748abd9911f345ba9fb14c9ad102 Mon Sep 17 00:00:00 2001 From: shidevil Date: Sun, 3 May 2026 01:01:43 +0000 Subject: [PATCH 14/19] =?UTF-8?q?fix(usage):=20batch=202=20=E2=80=94=20sec?= =?UTF-8?q?urity,=20correctness,=20and=20UX=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move atomic_write_secret to shared helpers with mode(0o600) at open time (P1) - Add temp file cleanup on rename failure - Check Amp ok field instead of silently returning empty metrics - Persist Codex access token even when no new refresh token returned - Fix TUI empty state to distinguish never-fetched from fetched-empty - Fix truncate() off-by-one with ellipsis - Fix light-mode card width consistency across header/metrics/border - Deduplicate copilot hosts.yml parsing into shared helper Co-Authored-By: Claude Opus 4.7 --- PR_BODY.md | 86 +++++++++++++++++++ crates/tokscale-cli/src/commands/usage/amp.rs | 8 ++ .../tokscale-cli/src/commands/usage/claude.rs | 26 +----- .../tokscale-cli/src/commands/usage/codex.rs | 44 +++------- .../src/commands/usage/copilot.rs | 34 ++------ .../src/commands/usage/helpers.rs | 35 ++++++++ .../tokscale-cli/src/commands/usage/kimi.rs | 26 +----- crates/tokscale-cli/src/commands/usage/mod.rs | 21 +++-- crates/tokscale-cli/src/tui/app.rs | 4 + crates/tokscale-cli/src/tui/ui/usage.rs | 6 +- 10 files changed, 166 insertions(+), 124 deletions(-) create mode 100644 PR_BODY.md diff --git a/PR_BODY.md b/PR_BODY.md new file mode 100644 index 000000000..3f04ed9f8 --- /dev/null +++ b/PR_BODY.md @@ -0,0 +1,86 @@ +## Summary + +Adds a `tokscale usage` CLI command and TUI **Usage** tab that displays live subscription quota and remaining usage for AI coding assistants. + +### Quick start + +```bash +tokscale usage # light-mode card output +tokscale usage --json # JSON for scripting +tokscale tui # switch to Usage tab (2nd tab) +``` + +## Supported Providers (7) + +| Provider | Auth Source | Metrics | +|---|---|---| +| **Claude** | `~/.claude/.credentials.json` / macOS Keychain | Session (5h), Weekly (7d), Opus (7d) | +| **Codex** | `CODEX_HOME/auth.json`, `~/.config/codex/auth.json`, `~/.codex/auth.json`, macOS Keychain | Session (5h), Weekly (7d) | +| **Z.ai** | `ZAI_API_KEY` / `GLM_API_KEY` env var | Session, Weekly, Web Search | +| **Amp** | `~/.local/share/amp/secrets.json` | Free tier ($remaining/$total), Credits | +| **GitHub Copilot** | macOS Keychain `gh:github.com`, `hosts.yml` (respects `GH_CONFIG_DIR`) | Premium, Chat, Completions (paid + free) | +| **Kimi Code** | `~/.kimi/credentials/kimi-code.json` | Session, Weekly | +| **MiniMax** | `MINIMAX_API_KEY` / `MINIMAX_API_TOKEN` env var | Session (prompts) | + +Only providers with valid credentials are queried — the rest are silently skipped. + +## Architecture + +Refactored `commands/usage.rs` into a `commands/usage/` module directory: + +``` +commands/usage/ +├── mod.rs # Shared types, fetch_all(), disk cache, CLI rendering +├── helpers.rs # capitalize(), format_reset_time(), read_keychain(), render_ascii_bar() +├── claude.rs # Claude OAuth provider +├── codex.rs # Codex/OpenAI provider +├── zai.rs # Z.ai provider +├── amp.rs # Amp provider +├── copilot.rs # GitHub Copilot provider +├── kimi.rs # Kimi Code provider +└── minimax.rs # MiniMax provider +``` + +Each provider exports `has_credentials() -> bool` and `fetch() -> Result`. + +## Performance + +- **Credential pre-check**: Fast local file/env checks skip providers without credentials entirely (no network calls) +- **Parallel fetching**: Active providers run concurrently via `std::thread::scope` +- **Disk cache**: Data cached to `~/.cache/tokscale/subscription-usage-cache.json` with 5-minute TTL — the Usage tab loads instantly on startup like other tabs +- **No new dependencies**: Uses only crates already in the workspace (`reqwest`, `serde`, `serde_json`, `chrono`, `anyhow`, `dirs`, `tokio`) + +## Bug fixes included + +- **MiniMax**: `current_interval_usage_count` is a remaining count despite its name — now handled correctly with `current_interval_used_count` preferred when available +- **Kimi**: OAuth refresh tokens (which rotate) are now persisted back to disk after each refresh, preventing stale tokens on next run +- **Codex**: Credential lookup now requires `tokens.access_token` to be present (not just the `tokens` object), supports `CODEX_HOME` env var and macOS Keychain fallback +- **Copilot**: Respects `GH_CONFIG_DIR` env var for hosts.yml path; YAML parser correctly handles other fields appearing before `oauth_token` under `github.com:` +- **OAuth payloads**: Kimi and Codex refresh payloads use `reqwest .form()` for proper URL encoding of tokens that may contain reserved characters +- **Platform compat**: `read_keychain()` returns a clean error on non-macOS instead of spawning a missing binary +- **TUI**: Empty usage state shows a proper message instead of the loading prompt; tab navigation tests updated for 7-tab layout +- **Z.ai**: Metrics ordered as Session → Weekly → Web Search regardless of API response order; renamed Monthly→Weekly, Web Searches→Web Search + +## Files changed + +- **New**: `commands/usage/` directory (9 provider + helper files, ~1700 lines) +- **New**: `tui/ui/usage.rs` (TUI rendering for Usage tab) +- **Modified**: `tui/app.rs` (Usage tab, disk cache, updated tab tests) +- **Modified**: `main.rs` (Usage subcommand + --home rejection) +- **Modified**: `README.md` (provider docs, corrected tab count) +- **Unchanged**: `Cargo.lock` (no new dependencies) + +## Test plan + +- [x] `cargo build --release` compiles cleanly with no warnings +- [x] All 451 unit tests pass (including updated tab navigation tests) +- [x] `tokscale usage --json` outputs valid JSON +- [x] `tokscale usage` renders light-mode cards with aligned columns +- [x] TUI Usage tab renders quota bars with cache + live refresh +- [x] Switching tabs is instant after first fetch (disk cache works) +- [x] Providers without credentials are silently skipped (no error spam) +- [x] `tokscale --home /tmp usage` produces clear error + +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +Co-Authored-By: Claude Opus 4.7 diff --git a/crates/tokscale-cli/src/commands/usage/amp.rs b/crates/tokscale-cli/src/commands/usage/amp.rs index 8f3498168..9b1e9a011 100644 --- a/crates/tokscale-cli/src/commands/usage/amp.rs +++ b/crates/tokscale-cli/src/commands/usage/amp.rs @@ -142,6 +142,14 @@ pub fn fetch() -> Result { } let body: ApiResponse = resp.json().await?; + if body.ok == Some(false) { + let msg = body + .result + .as_ref() + .and_then(|r| r.display_text.as_deref()) + .unwrap_or("unknown error"); + anyhow::bail!("Amp API returned an error: {msg}"); + } let display_text = body .result .and_then(|r| r.display_text) diff --git a/crates/tokscale-cli/src/commands/usage/claude.rs b/crates/tokscale-cli/src/commands/usage/claude.rs index 7873b1d07..84f9cead8 100644 --- a/crates/tokscale-cli/src/commands/usage/claude.rs +++ b/crates/tokscale-cli/src/commands/usage/claude.rs @@ -97,35 +97,11 @@ fn save_credentials(access_token: &str, refresh_token: &str, subscription_type: return; } }; - if let Err(e) = atomic_write_secret(&path, content.as_bytes()) { + if let Err(e) = super::helpers::atomic_write_secret(&path, content.as_bytes()) { eprintln!("warning: failed to save Claude credentials: {e}"); } } -fn atomic_write_secret(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> { - if path.parent().is_none() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "path has no parent directory", - )); - } - let temp_path = path.with_extension("tmp"); - { - let mut f = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&temp_path)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - f.set_permissions(std::fs::Permissions::from_mode(0o600))?; - } - std::io::Write::write_all(&mut f, data)?; - } - std::fs::rename(&temp_path, path)?; - Ok(()) -} - async fn refresh_token(client: &reqwest::Client, rt: &str) -> Result { let resp = client .post("https://platform.claude.com/v1/oauth/token") diff --git a/crates/tokscale-cli/src/commands/usage/codex.rs b/crates/tokscale-cli/src/commands/usage/codex.rs index 883395fd4..71be8de03 100644 --- a/crates/tokscale-cli/src/commands/usage/codex.rs +++ b/crates/tokscale-cli/src/commands/usage/codex.rs @@ -119,35 +119,11 @@ fn save_credentials( return; } }; - if let Err(e) = atomic_write_secret(path, content.as_bytes()) { + if let Err(e) = super::helpers::atomic_write_secret(path, content.as_bytes()) { eprintln!("warning: failed to save Codex credentials: {e}"); } } -fn atomic_write_secret(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> { - if path.parent().is_none() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "path has no parent directory", - )); - } - let temp_path = path.with_extension("tmp"); - { - let mut f = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&temp_path)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - f.set_permissions(std::fs::Permissions::from_mode(0o600))?; - } - std::io::Write::write_all(&mut f, data)?; - } - std::fs::rename(&temp_path, path)?; - Ok(()) -} - pub fn has_credentials() -> bool { let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); if let Ok(codex_home) = std::env::var("CODEX_HOME") { @@ -231,15 +207,15 @@ pub fn fetch() -> Result { .clone() .ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?; if let CredentialSource::File(ref path) = source { - if let Some(new_rt) = refreshed.refresh_token.as_deref() { - save_credentials( - path, - &new, - new_rt, - tokens.account_id.as_deref(), - tokens.id_token.as_deref(), - ); - } + let new_rt = refreshed.refresh_token.as_deref() + .unwrap_or_else(|| tokens.refresh_token.as_deref().unwrap_or("")); + save_credentials( + path, + &new, + new_rt, + tokens.account_id.as_deref(), + tokens.id_token.as_deref(), + ); } fetch_usage(&client, &new, account_id).await? } diff --git a/crates/tokscale-cli/src/commands/usage/copilot.rs b/crates/tokscale-cli/src/commands/usage/copilot.rs index 1dd783fe5..edfbffe24 100644 --- a/crates/tokscale-cli/src/commands/usage/copilot.rs +++ b/crates/tokscale-cli/src/commands/usage/copilot.rs @@ -60,7 +60,7 @@ fn gh_config_dir() -> std::path::PathBuf { home.join(".config").join("gh") } -fn read_token_from_hosts() -> Result { +fn parse_token_from_hosts() -> Result { let path = gh_config_dir().join("hosts.yml"); if !path.exists() { anyhow::bail!("No gh hosts file"); @@ -88,6 +88,10 @@ fn read_token_from_hosts() -> Result { anyhow::bail!("No oauth_token found in hosts.yml") } +fn read_token_from_hosts() -> Result { + parse_token_from_hosts() +} + fn read_credentials() -> Result { read_token_from_keychain().or_else(|_| read_token_from_hosts()).map_err(|_| { anyhow::anyhow!("No GitHub Copilot credentials found. Run 'gh auth login' to authenticate.") @@ -172,33 +176,7 @@ pub fn has_credentials() -> bool { if super::helpers::read_keychain("gh:github.com").is_ok() { return true; } - // Check that hosts.yml exists and contains a github.com: section with an oauth_token - let path = gh_config_dir().join("hosts.yml"); - if !path.exists() { - return false; - } - let Ok(content) = std::fs::read_to_string(&path) else { - return false; - }; - let mut in_github = false; - for line in content.lines() { - let trimmed = line.trim(); - if trimmed == "github.com:" { - in_github = true; - continue; - } - // A non-indented, non-empty, non-comment line starts a new section - if in_github && !line.starts_with(' ') && !line.starts_with('\t') && !trimmed.is_empty() && !trimmed.starts_with('#') { - in_github = false; - } - if in_github && trimmed.starts_with("oauth_token:") { - let token = trimmed.trim_start_matches("oauth_token:").trim(); - if !token.is_empty() { - return true; - } - } - } - false + parse_token_from_hosts().is_ok() } pub fn fetch() -> Result { diff --git a/crates/tokscale-cli/src/commands/usage/helpers.rs b/crates/tokscale-cli/src/commands/usage/helpers.rs index 7a5d6d10b..05fa3d478 100644 --- a/crates/tokscale-cli/src/commands/usage/helpers.rs +++ b/crates/tokscale-cli/src/commands/usage/helpers.rs @@ -53,3 +53,38 @@ pub fn render_ascii_bar(remaining_percent: f64, width: usize) -> String { let filled = (remaining_percent.clamp(0.0, 100.0) / 100.0 * width as f64).round() as usize; format!("[{}{}]", "=".repeat(filled), "-".repeat(width - filled)) } + +pub fn atomic_write_secret(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> { + let dir = path.parent().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent directory") + })?; + std::fs::create_dir_all(dir)?; + let temp_path = path.with_extension("tmp"); + { + #[cfg(unix)] + let mut opts = { + use std::os::unix::fs::OpenOptionsExt; + let mut o = std::fs::OpenOptions::new(); + o.mode(0o600); + o + }; + #[cfg(not(unix))] + let mut opts = std::fs::OpenOptions::new(); + let mut f = match opts.write(true).create_new(true).open(&temp_path) { + Ok(f) => f, + Err(e) => { + let _ = std::fs::remove_file(&temp_path); + return Err(e); + } + }; + if let Err(e) = std::io::Write::write_all(&mut f, data) { + let _ = std::fs::remove_file(&temp_path); + return Err(e); + } + } + if let Err(e) = std::fs::rename(&temp_path, path) { + let _ = std::fs::remove_file(&temp_path); + return Err(e); + } + Ok(()) +} diff --git a/crates/tokscale-cli/src/commands/usage/kimi.rs b/crates/tokscale-cli/src/commands/usage/kimi.rs index 8a2caaf58..46b1b8a6b 100644 --- a/crates/tokscale-cli/src/commands/usage/kimi.rs +++ b/crates/tokscale-cli/src/commands/usage/kimi.rs @@ -87,35 +87,11 @@ fn save_credentials(access_token: &str, refresh_token: &str, expires_in: i64) { return; } }; - if let Err(e) = atomic_write_secret(&path, content.as_bytes()) { + if let Err(e) = super::helpers::atomic_write_secret(&path, content.as_bytes()) { eprintln!("warning: failed to save Kimi credentials: {e}"); } } -fn atomic_write_secret(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> { - if path.parent().is_none() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "path has no parent directory", - )); - } - let temp_path = path.with_extension("tmp"); - { - let mut f = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&temp_path)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - f.set_permissions(std::fs::Permissions::from_mode(0o600))?; - } - std::io::Write::write_all(&mut f, data)?; - } - std::fs::rename(&temp_path, path)?; - Ok(()) -} - fn needs_refresh(expires_at: Option) -> bool { if let Some(expires_at) = expires_at { let now = chrono::Utc::now().timestamp() as f64; diff --git a/crates/tokscale-cli/src/commands/usage/mod.rs b/crates/tokscale-cli/src/commands/usage/mod.rs index edbd4dbc2..0cd994567 100644 --- a/crates/tokscale-cli/src/commands/usage/mod.rs +++ b/crates/tokscale-cli/src/commands/usage/mod.rs @@ -118,32 +118,31 @@ const BAR_WIDTH: usize = 12; const CARD_WIDTH: usize = 62; fn truncate(s: &str, max_len: usize) -> String { - let truncated: String = s.chars().take(max_len).collect(); - if truncated.len() < s.len() { - format!("{}…", truncated) - } else { - truncated + if s.chars().count() <= max_len { + return s.to_string(); } + let truncated: String = s.chars().take(max_len - 1).collect(); + format!("{truncated}…") } fn render_light(output: &UsageOutput) { println!("╭{}╮", "─".repeat(CARD_WIDTH)); // Provider header - println!("│ {:, pub subscription_usage: Vec, + + pub usage_fetch_attempted: bool, } impl App { @@ -295,6 +297,7 @@ impl App { Vec::new() } }, + usage_fetch_attempted: false, }; app.build_model_shade_map(); Ok(app) @@ -506,6 +509,7 @@ impl App { pub fn fetch_subscription_usage(&mut self) { self.subscription_usage = crate::commands::usage::fetch_all(); + self.usage_fetch_attempted = true; if !self.subscription_usage.is_empty() { crate::commands::usage::save_cache(&self.subscription_usage); self.status_message = Some("Usage data loaded".into()); diff --git a/crates/tokscale-cli/src/tui/ui/usage.rs b/crates/tokscale-cli/src/tui/ui/usage.rs index 3e83e0dd5..9b7d22b83 100644 --- a/crates/tokscale-cli/src/tui/ui/usage.rs +++ b/crates/tokscale-cli/src/tui/ui/usage.rs @@ -18,7 +18,11 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { frame.render_widget(block, area); if app.subscription_usage.is_empty() { - render_loading(frame, app, inner); + if app.usage_fetch_attempted { + render_empty(frame, app, inner); + } else { + render_loading(frame, app, inner); + } } else if app.subscription_usage.iter().all(|o| o.metrics.is_empty()) { render_empty(frame, app, inner); } else { From 01160fd80ebce531699e6902637e51ccc7de75d6 Mon Sep 17 00:00:00 2001 From: shidevil Date: Sun, 3 May 2026 04:33:36 +0000 Subject: [PATCH 15/19] fix(tui): run usage fetch in background thread to prevent UI freeze Pressing 'u' now spawns fetch_all() on a background thread and shows a spinner while waiting, instead of blocking the event loop. Co-Authored-By: Claude Opus 4.7 --- crates/tokscale-cli/src/tui/app.rs | 40 ++++++++++++++++++++----- crates/tokscale-cli/src/tui/ui/usage.rs | 21 ++++++++++++- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/crates/tokscale-cli/src/tui/app.rs b/crates/tokscale-cli/src/tui/app.rs index d96c510f1..575a6eb60 100644 --- a/crates/tokscale-cli/src/tui/app.rs +++ b/crates/tokscale-cli/src/tui/app.rs @@ -199,6 +199,7 @@ pub struct App { pub subscription_usage: Vec, pub usage_fetch_attempted: bool, + usage_rx: Option>>, } impl App { @@ -298,6 +299,7 @@ impl App { } }, usage_fetch_attempted: false, + usage_rx: None, }; app.build_model_shade_map(); Ok(app) @@ -375,6 +377,22 @@ impl App { *self.dialog_needs_reload.borrow_mut() = false; self.needs_reload = true; } + + // Poll background usage fetch + if let Some(ref rx) = self.usage_rx { + if let Ok(results) = rx.try_recv() { + self.usage_rx = None; + self.subscription_usage = results; + if !self.subscription_usage.is_empty() { + crate::commands::usage::save_cache(&self.subscription_usage); + self.status_message = Some("Usage data loaded".into()); + } else { + crate::commands::usage::clear_cache(); + self.status_message = Some("No usage data available".into()); + } + self.status_message_time = Some(std::time::Instant::now()); + } + } } pub fn handle_key_event(&mut self, key: KeyEvent) -> bool { @@ -508,16 +526,22 @@ impl App { } pub fn fetch_subscription_usage(&mut self) { - self.subscription_usage = crate::commands::usage::fetch_all(); - self.usage_fetch_attempted = true; - if !self.subscription_usage.is_empty() { - crate::commands::usage::save_cache(&self.subscription_usage); - self.status_message = Some("Usage data loaded".into()); - } else { - crate::commands::usage::clear_cache(); - self.status_message = Some("No usage data available".into()); + if self.usage_rx.is_some() { + return; // already fetching } + self.usage_fetch_attempted = true; + self.status_message = Some("Fetching usage data...".into()); self.status_message_time = Some(std::time::Instant::now()); + let (tx, rx) = std::sync::mpsc::channel(); + self.usage_rx = Some(rx); + std::thread::spawn(move || { + let results = crate::commands::usage::fetch_all(); + let _ = tx.send(results); + }); + } + + pub fn is_fetching_usage(&self) -> bool { + self.usage_rx.is_some() } pub fn handle_mouse_event(&mut self, event: MouseEvent) { diff --git a/crates/tokscale-cli/src/tui/ui/usage.rs b/crates/tokscale-cli/src/tui/ui/usage.rs index 9b7d22b83..9b9cda19f 100644 --- a/crates/tokscale-cli/src/tui/ui/usage.rs +++ b/crates/tokscale-cli/src/tui/ui/usage.rs @@ -18,7 +18,9 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { frame.render_widget(block, area); if app.subscription_usage.is_empty() { - if app.usage_fetch_attempted { + if app.is_fetching_usage() { + render_fetching(frame, app, inner); + } else if app.usage_fetch_attempted { render_empty(frame, app, inner); } else { render_loading(frame, app, inner); @@ -30,6 +32,23 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { } } +fn render_fetching(frame: &mut Frame, app: &App, area: Rect) { + let center = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(40), + Constraint::Length(3), + Constraint::Percentage(40), + ]) + .split(area)[1]; + + let spin = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'][app.spinner_frame % 10]; + let paragraph = Paragraph::new(format!("{spin} Fetching subscription data...")) + .style(Style::default().fg(app.theme.muted)) + .alignment(Alignment::Center); + frame.render_widget(paragraph, center); +} + fn render_loading(frame: &mut Frame, app: &App, area: Rect) { let center = Layout::default() .direction(Direction::Vertical) From 14324485a6936e6885a71d3669c0a753577ee2ad Mon Sep 17 00:00:00 2001 From: shidevil Date: Sun, 3 May 2026 04:54:57 +0000 Subject: [PATCH 16/19] fix(usage): runtime, credential fallback, and concurrency fixes - Switch all 7 providers to current-thread Tokio runtime to reduce thread count when fetched in parallel - Claude: fallback from stale/corrupt credentials file to keychain - Claude: always persist refreshed tokens (not just file-sourced) - atomic_write_secret: use PID-scoped temp file to avoid concurrent write collisions Co-Authored-By: Claude Opus 4.7 --- crates/tokscale-cli/src/commands/usage/amp.rs | 4 +- .../tokscale-cli/src/commands/usage/claude.rs | 37 ++++++++++--------- .../tokscale-cli/src/commands/usage/codex.rs | 4 +- .../src/commands/usage/copilot.rs | 4 +- .../src/commands/usage/helpers.rs | 2 +- .../tokscale-cli/src/commands/usage/kimi.rs | 4 +- .../src/commands/usage/minimax.rs | 4 +- crates/tokscale-cli/src/commands/usage/zai.rs | 4 +- 8 files changed, 38 insertions(+), 25 deletions(-) diff --git a/crates/tokscale-cli/src/commands/usage/amp.rs b/crates/tokscale-cli/src/commands/usage/amp.rs index 9b1e9a011..845463211 100644 --- a/crates/tokscale-cli/src/commands/usage/amp.rs +++ b/crates/tokscale-cli/src/commands/usage/amp.rs @@ -123,7 +123,9 @@ pub fn has_credentials() -> bool { pub fn fetch() -> Result { let api_key = read_credentials()?; - let rt = tokio::runtime::Runtime::new()?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; rt.block_on(async { let client = reqwest::Client::new(); let resp = client diff --git a/crates/tokscale-cli/src/commands/usage/claude.rs b/crates/tokscale-cli/src/commands/usage/claude.rs index 84f9cead8..72b6fc2b8 100644 --- a/crates/tokscale-cli/src/commands/usage/claude.rs +++ b/crates/tokscale-cli/src/commands/usage/claude.rs @@ -64,14 +64,15 @@ fn read_credentials() -> Result<(Credentials, CredentialSource)> { let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); let path = home.join(".claude").join(".credentials.json"); if path.exists() { - let content = std::fs::read_to_string(&path)?; - let creds: Credentials = serde_json::from_str(&content)?; - Ok((creds, CredentialSource::File)) - } else { - let content = read_keychain()?; - let creds: Credentials = serde_json::from_str(&content)?; - Ok((creds, CredentialSource::Keychain)) + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(creds) = serde_json::from_str::(&content) { + return Ok((creds, CredentialSource::File)); + } + } } + let content = read_keychain()?; + let creds: Credentials = serde_json::from_str(&content)?; + Ok((creds, CredentialSource::Keychain)) } fn save_credentials(access_token: &str, refresh_token: &str, subscription_type: Option<&str>, rate_limit_tier: Option<&str>) { @@ -151,9 +152,11 @@ fn window_metric(label: &str, w: &Window) -> UsageMetric { } pub fn fetch() -> Result { - let rt = tokio::runtime::Runtime::new()?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; rt.block_on(async { - let (creds, source) = read_credentials()?; + let (creds, _source) = read_credentials()?; let oauth = creds.claude_ai_oauth.ok_or_else(|| { anyhow::anyhow!("No Claude OAuth credentials. Run 'claude' to log in.") })?; @@ -184,15 +187,13 @@ pub fn fetch() -> Result { .access_token .clone() .ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?; - if matches!(source, CredentialSource::File) { - if let Some(new_rt) = refreshed.refresh_token.as_deref() { - save_credentials( - &new, - new_rt, - oauth.subscription_type.as_deref(), - oauth.rate_limit_tier.as_deref(), - ); - } + if let Some(new_rt) = refreshed.refresh_token.as_deref() { + save_credentials( + &new, + new_rt, + oauth.subscription_type.as_deref(), + oauth.rate_limit_tier.as_deref(), + ); } fetch_usage(&client, &new).await? } diff --git a/crates/tokscale-cli/src/commands/usage/codex.rs b/crates/tokscale-cli/src/commands/usage/codex.rs index 71be8de03..a02b36e53 100644 --- a/crates/tokscale-cli/src/commands/usage/codex.rs +++ b/crates/tokscale-cli/src/commands/usage/codex.rs @@ -181,7 +181,9 @@ async fn fetch_usage(client: &reqwest::Client, token: &str, account_id: Option<& } pub fn fetch() -> Result { - let rt = tokio::runtime::Runtime::new()?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; rt.block_on(async { let (auth, source) = read_credentials()?; let tokens = auth diff --git a/crates/tokscale-cli/src/commands/usage/copilot.rs b/crates/tokscale-cli/src/commands/usage/copilot.rs index edfbffe24..72729e675 100644 --- a/crates/tokscale-cli/src/commands/usage/copilot.rs +++ b/crates/tokscale-cli/src/commands/usage/copilot.rs @@ -182,7 +182,9 @@ pub fn has_credentials() -> bool { pub fn fetch() -> Result { let token = read_credentials()?; - let rt = tokio::runtime::Runtime::new()?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; rt.block_on(async { let client = reqwest::Client::new(); let resp = fetch_api(&client, &token).await?; diff --git a/crates/tokscale-cli/src/commands/usage/helpers.rs b/crates/tokscale-cli/src/commands/usage/helpers.rs index 05fa3d478..21bf3d8a7 100644 --- a/crates/tokscale-cli/src/commands/usage/helpers.rs +++ b/crates/tokscale-cli/src/commands/usage/helpers.rs @@ -59,7 +59,7 @@ pub fn atomic_write_secret(path: &std::path::Path, data: &[u8]) -> std::io::Resu std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent directory") })?; std::fs::create_dir_all(dir)?; - let temp_path = path.with_extension("tmp"); + let temp_path = path.with_extension(format!("{}.tmp", std::process::id())); { #[cfg(unix)] let mut opts = { diff --git a/crates/tokscale-cli/src/commands/usage/kimi.rs b/crates/tokscale-cli/src/commands/usage/kimi.rs index 46b1b8a6b..d9e5b007a 100644 --- a/crates/tokscale-cli/src/commands/usage/kimi.rs +++ b/crates/tokscale-cli/src/commands/usage/kimi.rs @@ -166,7 +166,9 @@ pub fn fetch() -> Result { let stored_refresh_token = creds.refresh_token.clone(); let expires_at = creds.expires_at; - let rt = tokio::runtime::Runtime::new()?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; rt.block_on(async { let client = reqwest::Client::new(); diff --git a/crates/tokscale-cli/src/commands/usage/minimax.rs b/crates/tokscale-cli/src/commands/usage/minimax.rs index 95a441f15..1aff6402c 100644 --- a/crates/tokscale-cli/src/commands/usage/minimax.rs +++ b/crates/tokscale-cli/src/commands/usage/minimax.rs @@ -135,7 +135,9 @@ pub fn has_credentials() -> bool { pub fn fetch() -> Result { let api_key = read_api_key()?; - let rt = tokio::runtime::Runtime::new()?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; rt.block_on(async { let client = reqwest::Client::new(); let resp = fetch_api(&client, &api_key).await?; diff --git a/crates/tokscale-cli/src/commands/usage/zai.rs b/crates/tokscale-cli/src/commands/usage/zai.rs index 2b60f6867..a0ca6b90e 100644 --- a/crates/tokscale-cli/src/commands/usage/zai.rs +++ b/crates/tokscale-cli/src/commands/usage/zai.rs @@ -75,7 +75,9 @@ pub fn fetch() -> Result { .or_else(|_| std::env::var("GLM_API_KEY")) .map_err(|_| anyhow::anyhow!("No ZAI_API_KEY or GLM_API_KEY set."))?; - let rt = tokio::runtime::Runtime::new()?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; rt.block_on(async { let client = reqwest::Client::new(); let quota = fetch_quota(&client, &api_key).await?; From 3c3e1c13fff9ee74ca709b9da59eb08356abd5b3 Mon Sep 17 00:00:00 2001 From: shidevil Date: Sun, 3 May 2026 11:43:50 +0000 Subject: [PATCH 17/19] fix(kimi): update stored refresh token after proactive refresh The reactive retry path was using the original cached refresh token instead of the freshly rotated one from the proactive refresh. Co-Authored-By: Claude Opus 4.7 --- crates/tokscale-cli/src/commands/usage/kimi.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tokscale-cli/src/commands/usage/kimi.rs b/crates/tokscale-cli/src/commands/usage/kimi.rs index d9e5b007a..e122df1d4 100644 --- a/crates/tokscale-cli/src/commands/usage/kimi.rs +++ b/crates/tokscale-cli/src/commands/usage/kimi.rs @@ -163,7 +163,7 @@ pub fn fetch() -> Result { .access_token .clone() .ok_or_else(|| anyhow::anyhow!("No Kimi access token."))?; - let stored_refresh_token = creds.refresh_token.clone(); + let mut stored_refresh_token = creds.refresh_token.clone(); let expires_at = creds.expires_at; let rt = tokio::runtime::Builder::new_current_thread() @@ -179,6 +179,7 @@ pub fn fetch() -> Result { if let Some(new_token) = refreshed.access_token.clone() { access_token = new_token; if let (Some(new_rt), Some(expires_in)) = (&refreshed.refresh_token, refreshed.expires_in) { + stored_refresh_token = Some(new_rt.clone()); save_credentials(&access_token, new_rt, expires_in); } } From 430ca9beabbf1c57cabc57c254399ea0752c4c68 Mon Sep 17 00:00:00 2001 From: shidevil Date: Sun, 3 May 2026 12:33:53 +0000 Subject: [PATCH 18/19] fix(usage): kimi labels, dedup, disconnected handling, copilot fallback - Kimi: use window duration metadata instead of index for labels - Kimi: include label in dedup key to avoid collapsing distinct windows - TUI: handle Disconnected from background fetch thread (prevents stuck spinner) - Copilot: compute pct from remaining/entitlement when percent_remaining missing - Light-mode: truncate metric labels to prevent column overflow Co-Authored-By: Claude Opus 4.7 --- .../src/commands/usage/copilot.rs | 11 ++++++-- .../tokscale-cli/src/commands/usage/kimi.rs | 14 ++++++---- crates/tokscale-cli/src/commands/usage/mod.rs | 3 +- crates/tokscale-cli/src/tui/app.rs | 28 ++++++++++++------- 4 files changed, 36 insertions(+), 20 deletions(-) diff --git a/crates/tokscale-cli/src/commands/usage/copilot.rs b/crates/tokscale-cli/src/commands/usage/copilot.rs index 72729e675..6c7ceaf9a 100644 --- a/crates/tokscale-cli/src/commands/usage/copilot.rs +++ b/crates/tokscale-cli/src/commands/usage/copilot.rs @@ -202,12 +202,17 @@ pub fn fetch() -> Result { .map(String::from); for (key, value) in snapshots { + let remaining = value.get("remaining").and_then(|v| v.as_i64()); + let entitlement = value.get("entitlement").and_then(|v| v.as_i64()); let pct_remaining = value.get("percent_remaining") .and_then(|v| v.as_f64()) - .unwrap_or(100.0) + .unwrap_or_else(|| { + match (remaining, entitlement) { + (Some(r), Some(e)) if e > 0 => (r as f64 / e as f64 * 100.0).clamp(0.0, 100.0), + _ => 100.0, + } + }) .clamp(0.0, 100.0); - let remaining = value.get("remaining").and_then(|v| v.as_i64()); - let entitlement = value.get("entitlement").and_then(|v| v.as_i64()); let used_pct = 100.0 - pct_remaining; let remaining_pct = pct_remaining; diff --git a/crates/tokscale-cli/src/commands/usage/kimi.rs b/crates/tokscale-cli/src/commands/usage/kimi.rs index e122df1d4..b0137dc0c 100644 --- a/crates/tokscale-cli/src/commands/usage/kimi.rs +++ b/crates/tokscale-cli/src/commands/usage/kimi.rs @@ -37,7 +37,6 @@ struct QuotaDetail { #[derive(Debug, Deserialize)] struct LimitEntry { - #[allow(dead_code)] window: Option, detail: Option, } @@ -216,13 +215,16 @@ pub fn fetch() -> Result { let mut metrics = Vec::new(); let mut seen = std::collections::HashSet::new(); - // Parse limits[] (sorted by period ascending, first is "Session") + // Parse limits[] — use window duration to determine label if let Some(ref limits) = resp.limits { - for (i, entry) in limits.iter().enumerate() { + for entry in limits.iter() { if let Some(ref detail) = entry.detail { - let label = if i == 0 { "Session" } else { "Weekly" }; + let label = match entry.window.as_ref().and_then(|w| w.duration) { + Some(d) if d <= 3600 => "Session", + _ => "Weekly", + }; if let Some(metric) = parse_quota_detail(label, detail) { - let key = format!("{}:{}", metric.used_percent, metric.remaining_label.as_deref().unwrap_or("")); + let key = format!("{}:{}:{}", label, metric.used_percent, metric.remaining_label.as_deref().unwrap_or("")); if seen.insert(key) { metrics.push(metric); } @@ -234,7 +236,7 @@ pub fn fetch() -> Result { // Parse top-level usage as "Weekly" (deduplicate against session) if let Some(ref usage) = resp.usage { if let Some(metric) = parse_quota_detail("Weekly", usage) { - let key = format!("{}:{}", metric.used_percent, metric.remaining_label.as_deref().unwrap_or("")); + let key = format!("{}:{}:{}", "Weekly", metric.used_percent, metric.remaining_label.as_deref().unwrap_or("")); if seen.insert(key) { metrics.push(metric); } diff --git a/crates/tokscale-cli/src/commands/usage/mod.rs b/crates/tokscale-cli/src/commands/usage/mod.rs index 0cd994567..13825f9e0 100644 --- a/crates/tokscale-cli/src/commands/usage/mod.rs +++ b/crates/tokscale-cli/src/commands/usage/mod.rs @@ -134,7 +134,8 @@ fn render_light(output: &UsageOutput) { let rem = truncate(&rem, 11); let bar = helpers::render_ascii_bar(m.remaining_percent, BAR_WIDTH); let reset = m.resets_at.as_ref().map(|r| helpers::format_reset_time(r)).unwrap_or_default(); - println!("│ {:<14}{:<11}{:<14}{:<22}│", m.label, rem, bar, reset); + let label = truncate(&m.label, 14); + println!("│ {:<14}{:<11}{:<14}{:<22}│", label, rem, bar, reset); } if let Some(ref email) = output.email { let email = truncate(email, CARD_WIDTH - 11); diff --git a/crates/tokscale-cli/src/tui/app.rs b/crates/tokscale-cli/src/tui/app.rs index 575a6eb60..39de21e6c 100644 --- a/crates/tokscale-cli/src/tui/app.rs +++ b/crates/tokscale-cli/src/tui/app.rs @@ -380,17 +380,25 @@ impl App { // Poll background usage fetch if let Some(ref rx) = self.usage_rx { - if let Ok(results) = rx.try_recv() { - self.usage_rx = None; - self.subscription_usage = results; - if !self.subscription_usage.is_empty() { - crate::commands::usage::save_cache(&self.subscription_usage); - self.status_message = Some("Usage data loaded".into()); - } else { - crate::commands::usage::clear_cache(); - self.status_message = Some("No usage data available".into()); + match rx.try_recv() { + Ok(results) => { + self.usage_rx = None; + self.subscription_usage = results; + if !self.subscription_usage.is_empty() { + crate::commands::usage::save_cache(&self.subscription_usage); + self.status_message = Some("Usage data loaded".into()); + } else { + crate::commands::usage::clear_cache(); + self.status_message = Some("No usage data available".into()); + } + self.status_message_time = Some(std::time::Instant::now()); + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + self.usage_rx = None; + self.status_message = Some("Usage fetch failed".into()); + self.status_message_time = Some(std::time::Instant::now()); } - self.status_message_time = Some(std::time::Instant::now()); + Err(std::sync::mpsc::TryRecvError::Empty) => {} } } } From dcf8c25d21d1b993f08bbc49aa0d5360ad8f7d60 Mon Sep 17 00:00:00 2001 From: shidevil Date: Mon, 4 May 2026 05:13:51 +0000 Subject: [PATCH 19/19] fix(tui): 'r' refreshes all tabs including usage Co-Authored-By: Claude Opus 4.7 --- crates/tokscale-cli/src/tui/app.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/tokscale-cli/src/tui/app.rs b/crates/tokscale-cli/src/tui/app.rs index 39de21e6c..21a0f4ff8 100644 --- a/crates/tokscale-cli/src/tui/app.rs +++ b/crates/tokscale-cli/src/tui/app.rs @@ -477,9 +477,7 @@ impl App { self.set_status("Refresh already in progress"); } else { self.needs_reload = true; - if self.current_tab == Tab::Usage { - self.fetch_subscription_usage(); - } + self.fetch_subscription_usage(); } } KeyCode::Char('R') if key.modifiers.contains(KeyModifiers::SHIFT) => {