-
Notifications
You must be signed in to change notification settings - Fork 452
Show subscription usage in Tokscale #493
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
34 commits
Select commit
Hold shift + click to select a range
581cae0
feat: add subscription usage command with Claude, Codex, and Z.ai pro…
shidevil 5bdc3b4
Merge pull request #1 from shidevil/feat/usage-command
shidevil d1cba85
feat(usage): add Amp, Copilot, Kimi, MiniMax providers; refactor to m…
shidevil cde6ccc
Merge pull request #2 from shidevil/feat/usage-command
shidevil f1ca00d
fix(minimax): correct usage_count inversion and match openusage behavior
shidevil aca3b06
fix(kimi): persist rotated refresh token and expires_at to disk
shidevil 8884232
fix(copilot): respect GH_CONFIG_DIR for gh hosts path resolution
shidevil d8ebd07
fix(usage): reject unsupported --home flag instead of silently ignori…
shidevil 0f6f567
fix(codex): require access_token in credential lookup and add CODEX_H…
shidevil 2035789
fix: batch-fix six remaining usage-provider issues
shidevil 122c12a
perf(usage): skip providers without credentials and fetch in parallel
shidevil 2dcc24f
feat(usage): cache subscription data to disk with 5-minute TTL
shidevil b8c7acb
fix(zai): order metrics as Session/Weekly/Web Search and widen label …
shidevil dcf905a
Merge pull request #3 from shidevil/feat/usage-command
shidevil 3369e6a
fix(usage): batch reliability and security fixes
shidevil f849ba3
Merge pull request #4 from shidevil/feat/usage-command
shidevil 9609d38
fix(copilot): support Windows and XDG config directories for gh hosts
shidevil 9163b82
Merge pull request #5 from shidevil/feat/usage-command
shidevil 8a24115
fix(usage): batch 2 — security, correctness, and UX fixes
shidevil 5758e58
Merge pull request #6 from shidevil/feat/usage-command
shidevil 01160fd
fix(tui): run usage fetch in background thread to prevent UI freeze
shidevil 09b0bab
Merge pull request #7 from shidevil/feat/usage-command
shidevil 1432448
fix(usage): runtime, credential fallback, and concurrency fixes
shidevil 44a70c1
Merge pull request #8 from shidevil/feat/usage-command
shidevil 3c3e1c1
fix(kimi): update stored refresh token after proactive refresh
shidevil 8daaf04
Merge pull request #9 from shidevil/feat/usage-command
shidevil 430ca9b
fix(usage): kimi labels, dedup, disconnected handling, copilot fallback
shidevil ff1722d
Merge pull request #10 from shidevil/feat/usage-command
shidevil dcf8c25
fix(tui): 'r' refreshes all tabs including usage
shidevil 728e713
Merge branch 'junhoyeo:main' into feat/usage-command
shidevil ce65e57
Merge pull request #11 from shidevil/feat/usage-command
shidevil 1741173
Merge branch 'junhoyeo:main' into main
shidevil e84ea37
merge: upstream tokscale v2.1.3 (Minutely tab, daily detail, bug fixes)
shidevil c6572f9
Merge remote-tracking branch 'origin/main' into shidevil/main
junhoyeo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<UsageOutput>`. | ||
|
|
||
| ## 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 <noreply@anthropic.com> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| pub mod usage; | ||
| pub mod wrapped; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| use anyhow::Result; | ||
| use serde::Deserialize; | ||
|
|
||
| use super::{UsageMetric, UsageOutput}; | ||
|
|
||
| #[derive(Debug, Deserialize)] | ||
| struct Secrets { | ||
| #[serde(rename = "apiKey@https://ampcode.com/")] | ||
| api_key: Option<String>, | ||
| } | ||
|
|
||
| #[derive(Debug, Deserialize)] | ||
| struct ApiResponse { | ||
| #[allow(dead_code)] | ||
| ok: Option<bool>, | ||
| result: Option<ApiResult>, | ||
| } | ||
|
|
||
| #[derive(Debug, Deserialize)] | ||
| struct ApiResult { | ||
| display_text: Option<String>, | ||
| } | ||
|
|
||
| fn read_credentials() -> Result<String> { | ||
| 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<f64> { | ||
| 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<UsageMetric> { | ||
| 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::<f64>() { | ||
| // 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::<f64>() { | ||
| if total > 0.0 && total.is_finite() && remaining.is_finite() { | ||
| let used = (total - remaining).max(0.0); | ||
| 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 && 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()); | ||
| } | ||
| } | ||
|
|
||
| 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<String> { | ||
| 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 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<UsageOutput> { | ||
| let api_key = read_credentials()?; | ||
|
|
||
| let rt = tokio::runtime::Builder::new_current_thread() | ||
| .enable_all() | ||
| .build()?; | ||
| 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?; | ||
| 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) | ||
| .unwrap_or_default(); | ||
|
|
||
| let metrics = parse_display_text(&display_text); | ||
| let plan = detect_plan(&metrics); | ||
|
|
||
| Ok(UsageOutput { | ||
| provider: "Amp".into(), | ||
| plan, | ||
| email: None, | ||
| metrics, | ||
| }) | ||
| }) | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.