diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index fbf421909..376200741 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -33,6 +33,7 @@ pub mod remote_proxy; pub mod remote_workspace; pub mod science; pub mod session_info; +pub mod subscription_quota; pub mod system_settings; pub mod terminal; pub mod token_usage; diff --git a/src-tauri/src/commands/subscription_quota.rs b/src-tauri/src/commands/subscription_quota.rs new file mode 100644 index 000000000..af2cd2a03 --- /dev/null +++ b/src-tauri/src/commands/subscription_quota.rs @@ -0,0 +1,960 @@ +//! Official remaining-subscription reads. +//! +//! Codex publishes remaining plan quota through the documented app-server +//! JSON-RPC method `account/rateLimits/read`. This module talks to that +//! method over `codex app-server --stdio` and returns the official `result` +//! object. It never invents a remaining number. +//! +//! Claude has no `usage` CLI. The `/usage` HUD reads +//! `GET https://api.anthropic.com/api/oauth/usage` with the local Claude +//! Code OAuth token (`~/.claude/.credentials.json`). +//! +//! Grok has no usage CLI. Remaining credits come from +//! `GET https://cli-chat-proxy.grok.com/v1/billing?format=credits` with +//! the Grok CLI OAuth token in `~/.grok/auth.json`. +//! +//! Cursor has no usage CLI. `status` / `about` are identity only. Remaining +//! plan usage is the same DashboardService RPC the official CLI/IDE client +//! already ships: `GetCurrentPeriodUsage` on `api2.cursor.sh`, authenticated +//! with the cursor-agent login token (`%APPDATA%\Cursor\auth.json` or +//! `CURSOR_API_KEY`). +//! +//! OpenCode Go remaining is official +//! `GET https://opencode.ai/zen/go/v1/usage` (anomalyco/opencode#16513, +//! live 2026-08-11) with the Go API key from `auth.json`. Gemini still +//! has no remaining-quota command. OpenCode `stats` is session history. + +use std::fs; +use std::path::Path; +use std::process::Stdio; +use std::time::Duration; + +use serde::Serialize; +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::time::timeout; + +use crate::app_error::{AppCommandError, AppErrorCode}; + +const READ_DEADLINE: Duration = Duration::from_secs(12); +const INIT_ID: u64 = 1; +const LIMITS_ID: u64 = 2; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OfficialQuotaSlot { + pub label: String, + pub payload: Value, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OfficialQuotaRead { + pub family: &'static str, + /// Official JSON from the CLI, or `null` when that CLI did not publish + /// a remaining-quota payload. Missing CLI is not an error. + pub payload: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub extra_slots: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub unavailable_reason: Option, +} + +pub fn extract_rate_limits_result(messages: &[Value]) -> Option { + for message in messages { + let Some(obj) = message.as_object() else { + continue; + }; + if obj.get("id").and_then(Value::as_u64) != Some(LIMITS_ID) { + continue; + } + if obj.contains_key("error") { + return None; + } + if let Some(result) = obj.get("result") { + if result.get("rateLimits").is_some() { + return Some(result.clone()); + } + } + } + None +} + +fn initialize_request() -> Value { + json!({ + "jsonrpc": "2.0", + "id": INIT_ID, + "method": "initialize", + "params": { + "clientInfo": { + "name": "codeg", + "version": env!("CARGO_PKG_VERSION") + }, + "capabilities": {} + } + }) +} + +fn rate_limits_request() -> Value { + json!({ + "jsonrpc": "2.0", + "id": LIMITS_ID, + "method": "account/rateLimits/read", + "params": {} + }) +} + +async fn read_codex_rate_limits_from_child() -> Result, AppCommandError> { + read_codex_rate_limits_from_home(None).await +} + +async fn read_codex_rate_limits_from_home( + home: Option<&Path>, +) -> Result, AppCommandError> { + let mut cmd = crate::process::tokio_command("codex"); + cmd.args(["app-server", "--stdio"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if let Some(home) = home { + cmd.env("CODEX_HOME", home); + } + let mut child = cmd.spawn() + .map_err(|err| { + AppCommandError::new( + AppErrorCode::DependencyMissing, + "Codex CLI is not available", + ) + .with_detail(err.to_string()) + })?; + + let mut stdin = child.stdin.take().ok_or_else(|| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "Codex stdin missing") + })?; + let stdout = child.stdout.take().ok_or_else(|| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "Codex stdout missing") + })?; + + let write = async { + for request in [initialize_request(), rate_limits_request()] { + let mut line = serde_json::to_vec(&request).map_err(|err| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "encode RPC") + .with_detail(err.to_string()) + })?; + line.push(b'\n'); + stdin.write_all(&line).await.map_err(|err| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "write RPC") + .with_detail(err.to_string()) + })?; + } + stdin.flush().await.map_err(|err| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "flush RPC") + .with_detail(err.to_string()) + })?; + Ok::<(), AppCommandError>(()) + }; + + let collect = async { + let mut reader = BufReader::new(stdout); + let mut messages = Vec::new(); + let mut line = String::new(); + loop { + line.clear(); + let n = reader.read_line(&mut line).await.map_err(|err| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "read RPC") + .with_detail(err.to_string()) + })?; + if n == 0 { + break; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Ok(value) = serde_json::from_str::(trimmed) { + let got_limits = value.get("id").and_then(Value::as_u64) == Some(LIMITS_ID); + messages.push(value); + if got_limits { + break; + } + } + } + Ok::, AppCommandError>(messages) + }; + + let result = timeout(READ_DEADLINE, async { + write.await?; + collect.await + }) + .await; + + let _ = child.kill().await; + + match result { + Ok(Ok(messages)) => Ok(extract_rate_limits_result(&messages)), + Ok(Err(err)) => Err(err), + Err(_) => Err(AppCommandError::new( + AppErrorCode::ExternalCommandFailed, + "Codex app-server timed out", + )), + } +} + +pub async fn read_codex_subscription_quota_core() -> OfficialQuotaRead { + let mut extra_slots = Vec::new(); + for (label, home) in extra_homes_for_family("codex") { + if let Ok(Some(payload)) = read_codex_rate_limits_from_home(Some(&home)).await { + extra_slots.push(OfficialQuotaSlot { label, payload }); + } + } + match read_codex_rate_limits_from_child().await { + Ok(Some(payload)) => OfficialQuotaRead { + family: "codex", + payload: Some(payload), + extra_slots, + unavailable_reason: None, + }, + Ok(None) => OfficialQuotaRead { + family: "codex", + payload: None, + extra_slots, + unavailable_reason: Some("codex app-server did not return rateLimits".into()), + }, + Err(err) => OfficialQuotaRead { + family: "codex", + payload: None, + extra_slots, + unavailable_reason: Some(err.message), + }, + } +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn subscription_quota_codex() -> Result { + Ok(read_codex_subscription_quota_core().await) +} + +pub fn claude_oauth_access_token_from_credentials(text: &str) -> Option { + let value: Value = serde_json::from_str(text).ok()?; + value + .get("claudeAiOauth") + .and_then(|oauth| oauth.get("accessToken")) + .and_then(Value::as_str) + .filter(|token| !token.is_empty()) + .map(str::to_string) +} + +fn claude_credentials_path() -> Option { + dirs::home_dir().map(|home| home.join(".claude").join(".credentials.json")) +} + +fn read_claude_oauth_access_token(path: &Path) -> Option { + let text = fs::read_to_string(path).ok()?; + claude_oauth_access_token_from_credentials(&text) +} + +async fn fetch_claude_oauth_usage(token: &str) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|err| { + AppCommandError::new(AppErrorCode::NetworkError, "HTTP client") + .with_detail(err.to_string()) + })?; + let response = client + .get("https://api.anthropic.com/api/oauth/usage") + .header("Authorization", format!("Bearer {token}")) + .header("anthropic-beta", "oauth-2025-04-20") + .header("User-Agent", "codeg") + .header("Accept", "application/json") + .send() + .await + .map_err(|err| { + AppCommandError::new(AppErrorCode::NetworkError, "Claude usage request failed") + .with_detail(err.to_string()) + })?; + let status = response.status(); + if !status.is_success() { + return Err(AppCommandError::new( + AppErrorCode::ExternalCommandFailed, + format!("Claude usage HTTP {status}"), + )); + } + response.json::().await.map_err(|err| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "Claude usage JSON") + .with_detail(err.to_string()) + }) +} + +pub async fn read_claude_subscription_quota_core() -> OfficialQuotaRead { + let extra_slots = extra_claude_slots().await; + let Some(path) = claude_credentials_path() else { + return OfficialQuotaRead { + family: "claude", + payload: None, + extra_slots, + unavailable_reason: Some("home directory unavailable".into()), + }; + }; + let Some(token) = read_claude_oauth_access_token(&path) else { + return OfficialQuotaRead { + family: "claude", + payload: None, + extra_slots, + unavailable_reason: Some("Claude Code is not signed in".into()), + }; + }; + match fetch_claude_oauth_usage(&token).await { + Ok(payload) if payload.get("five_hour").is_some() || payload.get("seven_day").is_some() => { + OfficialQuotaRead { + family: "claude", + payload: Some(payload), + extra_slots, + unavailable_reason: None, + } + } + Ok(_) => OfficialQuotaRead { + family: "claude", + payload: None, + extra_slots, + unavailable_reason: Some("Claude usage payload missing five_hour/seven_day".into()), + }, + Err(err) => OfficialQuotaRead { + family: "claude", + payload: None, + extra_slots, + unavailable_reason: Some(err.message), + }, + } +} + +async fn extra_claude_slots() -> Vec { + let mut slots = Vec::new(); + for (label, home) in extra_homes_for_family("claude") { + let path = home.join(".credentials.json"); + let Some(token) = read_claude_oauth_access_token(&path) else { + continue; + }; + if let Ok(payload) = fetch_claude_oauth_usage(&token).await { + if payload.get("five_hour").is_some() || payload.get("seven_day").is_some() { + slots.push(OfficialQuotaSlot { label, payload }); + } + } + } + slots +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn subscription_quota_claude() -> Result { + Ok(read_claude_subscription_quota_core().await) +} + +pub fn grok_cli_bearer_from_auth_json(text: &str) -> Option { + let value: Value = serde_json::from_str(text).ok()?; + let obj = value.as_object()?; + let mut preferred = None; + let mut fallback = None; + for (key, entry) in obj { + let Some(token) = entry.get("key").and_then(Value::as_str) else { + continue; + }; + if token.is_empty() { + continue; + } + if key.starts_with("https://auth.x.ai") { + preferred = Some(token.to_string()); + } else if fallback.is_none() { + fallback = Some(token.to_string()); + } + } + preferred.or(fallback) +} + +fn grok_auth_path() -> Option { + dirs::home_dir().map(|home| home.join(".grok").join("auth.json")) +} + +fn read_grok_cli_bearer(path: &Path) -> Option { + let text = fs::read_to_string(path).ok()?; + grok_cli_bearer_from_auth_json(&text) +} + +async fn fetch_grok_billing(token: &str) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|err| { + AppCommandError::new(AppErrorCode::NetworkError, "HTTP client") + .with_detail(err.to_string()) + })?; + let response = client + .get("https://cli-chat-proxy.grok.com/v1/billing?format=credits") + .header("Authorization", format!("Bearer {token}")) + .header("x-xai-token-auth", "xai-grok-cli") + .header("Accept", "application/json") + .header("User-Agent", "codeg") + .send() + .await + .map_err(|err| { + AppCommandError::new(AppErrorCode::NetworkError, "Grok billing request failed") + .with_detail(err.to_string()) + })?; + let status = response.status(); + if !status.is_success() { + return Err(AppCommandError::new( + AppErrorCode::ExternalCommandFailed, + format!("Grok billing HTTP {status}"), + )); + } + response.json::().await.map_err(|err| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "Grok billing JSON") + .with_detail(err.to_string()) + }) +} + +pub async fn read_grok_subscription_quota_core() -> OfficialQuotaRead { + let extra_slots = extra_grok_slots().await; + let Some(path) = grok_auth_path() else { + return OfficialQuotaRead { + family: "grok", + payload: None, + extra_slots, + unavailable_reason: Some("home directory unavailable".into()), + }; + }; + let Some(token) = read_grok_cli_bearer(&path) else { + return OfficialQuotaRead { + family: "grok", + payload: None, + extra_slots, + unavailable_reason: Some("Grok CLI is not signed in".into()), + }; + }; + match fetch_grok_billing(&token).await { + Ok(payload) if payload.get("config").and_then(|c| c.get("creditUsagePercent")).is_some() => { + OfficialQuotaRead { + family: "grok", + payload: Some(payload), + extra_slots, + unavailable_reason: None, + } + } + Ok(_) => OfficialQuotaRead { + family: "grok", + payload: None, + extra_slots, + unavailable_reason: Some("Grok billing payload missing creditUsagePercent".into()), + }, + Err(err) => OfficialQuotaRead { + family: "grok", + payload: None, + extra_slots, + unavailable_reason: Some(err.message), + }, + } +} + +async fn extra_grok_slots() -> Vec { + let mut slots = Vec::new(); + for (label, home) in extra_homes_for_family("grok") { + let path = home.join("auth.json"); + let Some(token) = read_grok_cli_bearer(&path) else { + continue; + }; + if let Ok(payload) = fetch_grok_billing(&token).await { + if payload + .get("config") + .and_then(|c| c.get("creditUsagePercent")) + .is_some() + { + slots.push(OfficialQuotaSlot { label, payload }); + } + } + } + slots +} + +pub fn extra_homes_for_family(family: &str) -> Vec<(String, std::path::PathBuf)> { + extra_homes_in( + dirs::home_dir().map(|home| home.join(".codeg-profiles")), + family, + ) +} + +pub fn extra_homes_in( + root: Option, + family: &str, +) -> Vec<(String, std::path::PathBuf)> { + let prefix = match family { + "claude" => "claude-", + "codex" => "codex-", + "grok" => "grok-", + "cursor" => "cursor-", + "opencode" => "opencode-", + _ => return Vec::new(), + }; + let Some(root) = root else { + return Vec::new(); + }; + let Ok(entries) = fs::read_dir(&root) else { + return Vec::new(); + }; + let mut homes = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + if !name.starts_with(prefix) { + continue; + } + homes.push((name, path)); + } + homes.sort_by(|a, b| a.0.cmp(&b.0)); + homes +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn subscription_quota_grok() -> Result { + Ok(read_grok_subscription_quota_core().await) +} + +pub fn cursor_access_token_from_auth_json(text: &str) -> Option { + let value: Value = serde_json::from_str(text).ok()?; + value + .get("accessToken") + .and_then(Value::as_str) + .filter(|token| !token.is_empty()) + .map(str::to_string) + .or_else(|| { + value + .get("apiKey") + .and_then(Value::as_str) + .filter(|token| !token.is_empty()) + .map(str::to_string) + }) +} + +fn cursor_auth_path() -> Option { + if cfg!(windows) { + std::env::var_os("APPDATA") + .map(std::path::PathBuf::from) + .or_else(|| dirs::home_dir().map(|home| home.join("AppData").join("Roaming"))) + .map(|appdata| appdata.join("Cursor").join("auth.json")) + } else if cfg!(target_os = "macos") { + dirs::home_dir().map(|home| home.join(".cursor").join("auth.json")) + } else { + let xdg = std::env::var_os("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .or_else(|| dirs::home_dir().map(|home| home.join(".config"))); + xdg.map(|root| root.join("cursor").join("auth.json")) + } +} + +fn read_cursor_access_token(path: &Path) -> Option { + let text = fs::read_to_string(path).ok()?; + cursor_access_token_from_auth_json(&text) +} + +fn cursor_token_from_env_or_file() -> Option { + if let Ok(key) = std::env::var("CURSOR_API_KEY") { + let trimmed = key.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + let path = cursor_auth_path()?; + read_cursor_access_token(&path) +} + +fn cursor_payload_has_plan_usage(payload: &Value) -> bool { + payload + .get("planUsage") + .or_else(|| payload.get("plan_usage")) + .map(|plan| { + plan.get("remaining").is_some() + || plan.get("limit").is_some() + || plan.get("totalPercentUsed").is_some() + || plan.get("total_percent_used").is_some() + }) + .unwrap_or(false) +} + +async fn fetch_cursor_period_usage(token: &str) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|err| { + AppCommandError::new(AppErrorCode::NetworkError, "HTTP client") + .with_detail(err.to_string()) + })?; + let response = client + .post("https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage") + .header("Authorization", format!("Bearer {token}")) + .header("Connect-Protocol-Version", "1") + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("User-Agent", "codeg") + .json(&json!({})) + .send() + .await + .map_err(|err| { + AppCommandError::new(AppErrorCode::NetworkError, "Cursor usage request failed") + .with_detail(err.to_string()) + })?; + let status = response.status(); + if !status.is_success() { + return Err(AppCommandError::new( + AppErrorCode::ExternalCommandFailed, + format!("Cursor usage HTTP {status}"), + )); + } + response.json::().await.map_err(|err| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "Cursor usage JSON") + .with_detail(err.to_string()) + }) +} + +pub async fn read_cursor_subscription_quota_core() -> OfficialQuotaRead { + let extra_slots = extra_cursor_slots().await; + let Some(token) = cursor_token_from_env_or_file() else { + return OfficialQuotaRead { + family: "cursor", + payload: None, + extra_slots, + unavailable_reason: Some("Cursor CLI is not signed in".into()), + }; + }; + match fetch_cursor_period_usage(&token).await { + Ok(payload) if cursor_payload_has_plan_usage(&payload) => OfficialQuotaRead { + family: "cursor", + payload: Some(payload), + extra_slots, + unavailable_reason: None, + }, + Ok(_) => OfficialQuotaRead { + family: "cursor", + payload: None, + extra_slots, + unavailable_reason: Some( + "Cursor usage payload missing planUsage remaining".into(), + ), + }, + Err(err) => OfficialQuotaRead { + family: "cursor", + payload: None, + extra_slots, + unavailable_reason: Some(err.message), + }, + } +} + +async fn extra_cursor_slots() -> Vec { + let mut slots = Vec::new(); + for (label, home) in extra_homes_for_family("cursor") { + let path = home.join("auth.json"); + let Some(token) = read_cursor_access_token(&path) else { + continue; + }; + if let Ok(payload) = fetch_cursor_period_usage(&token).await { + if cursor_payload_has_plan_usage(&payload) { + slots.push(OfficialQuotaSlot { label, payload }); + } + } + } + slots +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn subscription_quota_cursor() -> Result { + Ok(read_cursor_subscription_quota_core().await) +} + +pub fn opencode_go_key_from_auth_json(text: &str) -> Option { + let value: Value = serde_json::from_str(text).ok()?; + let obj = value.as_object()?; + for provider in ["opencode-go", "opencode"] { + let entry = obj.get(provider)?.as_object()?; + let key = entry.get("key").and_then(Value::as_str)?; + if !key.is_empty() { + return Some(key.to_string()); + } + } + None +} + +fn opencode_auth_path() -> Option { + Some(crate::parsers::opencode::resolve_opencode_base_dir().join("auth.json")) +} + +fn read_opencode_go_key(path: &Path) -> Option { + let text = fs::read_to_string(path).ok()?; + opencode_go_key_from_auth_json(&text) +} + +fn opencode_go_key_from_env_or_file() -> Option { + for name in ["OPENCODE_GO_API_KEY", "OPENCODE_API_KEY"] { + if let Ok(key) = std::env::var(name) { + let trimmed = key.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + let path = opencode_auth_path()?; + read_opencode_go_key(&path) +} + +fn opencode_payload_has_usage(payload: &Value) -> bool { + let usage = payload.get("usage").unwrap_or(payload); + ["rolling", "weekly", "monthly"].iter().any(|window| { + usage + .get(*window) + .and_then(|w| w.get("percent").or_else(|| w.get("usagePercent"))) + .is_some() + }) +} + +async fn fetch_opencode_go_usage(token: &str) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|err| { + AppCommandError::new(AppErrorCode::NetworkError, "HTTP client") + .with_detail(err.to_string()) + })?; + let response = client + .get("https://opencode.ai/zen/go/v1/usage") + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/json") + .header("User-Agent", "codeg") + .send() + .await + .map_err(|err| { + AppCommandError::new(AppErrorCode::NetworkError, "OpenCode usage request failed") + .with_detail(err.to_string()) + })?; + let status = response.status(); + if !status.is_success() { + return Err(AppCommandError::new( + AppErrorCode::ExternalCommandFailed, + format!("OpenCode usage HTTP {status}"), + )); + } + response.json::().await.map_err(|err| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "OpenCode usage JSON") + .with_detail(err.to_string()) + }) +} + +pub async fn read_opencode_subscription_quota_core() -> OfficialQuotaRead { + let extra_slots = extra_opencode_slots().await; + let Some(token) = opencode_go_key_from_env_or_file() else { + return OfficialQuotaRead { + family: "opencode", + payload: None, + extra_slots, + unavailable_reason: Some("OpenCode Go is not signed in".into()), + }; + }; + match fetch_opencode_go_usage(&token).await { + Ok(payload) if opencode_payload_has_usage(&payload) => OfficialQuotaRead { + family: "opencode", + payload: Some(payload), + extra_slots, + unavailable_reason: None, + }, + Ok(_) => OfficialQuotaRead { + family: "opencode", + payload: None, + extra_slots, + unavailable_reason: Some("OpenCode usage payload missing windows".into()), + }, + Err(err) => OfficialQuotaRead { + family: "opencode", + payload: None, + extra_slots, + unavailable_reason: Some(err.message), + }, + } +} + +async fn extra_opencode_slots() -> Vec { + let mut slots = Vec::new(); + for (label, home) in extra_homes_for_family("opencode") { + let path = home.join("auth.json"); + let Some(token) = read_opencode_go_key(&path) else { + continue; + }; + if let Ok(payload) = fetch_opencode_go_usage(&token).await { + if opencode_payload_has_usage(&payload) { + slots.push(OfficialQuotaSlot { label, payload }); + } + } + } + slots +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn subscription_quota_opencode() -> Result { + Ok(read_opencode_subscription_quota_core().await) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_live_account_rate_limits_read_shape() { + let messages = vec![ + json!({"id": 1, "result": {"userAgent": "Codex Desktop"}}), + json!({ + "id": 2, + "result": { + "rateLimits": { + "limitId": "codex", + "primary": { + "usedPercent": 100, + "windowDurationMins": 10080, + "resetsAt": 1787196797 + }, + "secondary": null, + "credits": { + "hasCredits": false, + "unlimited": false, + "balance": "0" + }, + "planType": "pro", + "rateLimitReachedType": "rate_limit_reached" + }, + "rateLimitsByLimitId": { + "codex": { + "limitId": "codex", + "primary": { "usedPercent": 100 } + }, + "codex_spark": { + "limitId": "codex_spark", + "limitName": "GPT-5.3-Codex-Spark", + "primary": { "usedPercent": 0 } + } + } + } + }), + ]; + let result = extract_rate_limits_result(&messages).expect("result"); + assert_eq!( + result["rateLimits"]["primary"]["usedPercent"], + json!(100) + ); + assert_eq!( + result["rateLimitsByLimitId"]["codex_spark"]["primary"]["usedPercent"], + json!(0) + ); + } + + #[test] + fn ignores_rpc_error_and_missing_id() { + let messages = vec![ + json!({"id": 2, "error": {"message": "unauthorized"}}), + json!({"method": "remoteControl/status/changed", "params": {}}), + ]; + assert!(extract_rate_limits_result(&messages).is_none()); + } + + #[test] + fn reads_claude_oauth_access_token_without_logging_it() { + let text = r#"{ + "claudeAiOauth": { "accessToken": "tok_test_value", "subscriptionType": "max" } + }"#; + assert_eq!( + claude_oauth_access_token_from_credentials(text).as_deref(), + Some("tok_test_value") + ); + assert!(claude_oauth_access_token_from_credentials("{}").is_none()); + } + + #[test] + fn prefers_auth_xai_grok_cli_bearer() { + let text = r#"{ + "https://accounts.x.ai/sign-in": { "key": "legacy" }, + "https://auth.x.ai::abc": { "key": "oidc-token", "auth_mode": "oidc" } + }"#; + assert_eq!( + grok_cli_bearer_from_auth_json(text).as_deref(), + Some("oidc-token") + ); + assert!(grok_cli_bearer_from_auth_json("{}").is_none()); + } + + #[test] + fn extra_homes_are_isolated_profile_dirs() { + let root = std::env::temp_dir().join(format!( + "codeg-quota-homes-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("claude-2")).unwrap(); + fs::create_dir_all(root.join("claude-3")).unwrap(); + fs::create_dir_all(root.join("codex-2")).unwrap(); + fs::create_dir_all(root.join("cursor-2")).unwrap(); + fs::create_dir_all(root.join("opencode-2")).unwrap(); + fs::write(root.join("claude-ignore"), "").unwrap(); + let claude = extra_homes_in(Some(root.clone()), "claude"); + let names: Vec<_> = claude.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(names, ["claude-2", "claude-3"]); + let codex = extra_homes_in(Some(root.clone()), "codex"); + assert_eq!(codex.len(), 1); + assert_eq!(codex[0].0, "codex-2"); + let cursor = extra_homes_in(Some(root.clone()), "cursor"); + assert_eq!(cursor.len(), 1); + assert_eq!(cursor[0].0, "cursor-2"); + let opencode = extra_homes_in(Some(root.clone()), "opencode"); + assert_eq!(opencode.len(), 1); + assert_eq!(opencode[0].0, "opencode-2"); + assert!(extra_homes_in(Some(root.clone()), "grok").is_empty()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn reads_cursor_access_token_without_logging_it() { + let text = r#"{ + "accessToken": "tok_cursor_value", + "refreshToken": "ref_cursor_value", + "apiKey": null + }"#; + assert_eq!( + cursor_access_token_from_auth_json(text).as_deref(), + Some("tok_cursor_value") + ); + assert_eq!( + cursor_access_token_from_auth_json(r#"{"apiKey":"key_only"}"#).as_deref(), + Some("key_only") + ); + assert!(cursor_access_token_from_auth_json("{}").is_none()); + } + + #[test] + fn reads_opencode_go_key_without_logging_it() { + let text = r#"{ + "opencode": { "type": "api", "key": "zen-key" }, + "opencode-go": { "type": "api", "key": "go-key" } + }"#; + assert_eq!( + opencode_go_key_from_auth_json(text).as_deref(), + Some("go-key") + ); + assert_eq!( + opencode_go_key_from_auth_json(r#"{"opencode":{"type":"api","key":"zen-only"}}"#) + .as_deref(), + Some("zen-only") + ); + assert!(opencode_go_key_from_auth_json("{}").is_none()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f780ebbb0..8a55bee80 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -75,6 +75,7 @@ mod tauri_app { remote_workspace as remote_workspace_commands, science as science_commands, session_info as session_info_commands, system_settings, terminal as terminal_commands, + subscription_quota as subscription_quota_commands, token_usage as token_usage_commands, version_control, windows, work_task as work_task_commands, workspace_state as workspace_state_commands, @@ -1316,6 +1317,11 @@ mod tauri_app { token_usage_commands::token_usage_facets, token_usage_commands::token_usage_status, token_usage_commands::token_usage_sync, + subscription_quota_commands::subscription_quota_codex, + subscription_quota_commands::subscription_quota_claude, + subscription_quota_commands::subscription_quota_grok, + subscription_quota_commands::subscription_quota_cursor, + subscription_quota_commands::subscription_quota_opencode, work_task_commands::work_task_list, work_task_commands::work_task_get, work_task_commands::work_task_events, diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index 045d1e168..9c1d6b240 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -28,6 +28,7 @@ pub mod question; pub mod quick_messages; pub mod science; pub mod session_info; +pub mod subscription_quota; pub mod system_settings; pub mod terminal; pub mod token_usage; diff --git a/src-tauri/src/web/handlers/subscription_quota.rs b/src-tauri/src/web/handlers/subscription_quota.rs new file mode 100644 index 000000000..bb8301962 --- /dev/null +++ b/src-tauri/src/web/handlers/subscription_quota.rs @@ -0,0 +1,28 @@ +use axum::Json; + +use crate::app_error::AppCommandError; +use crate::commands::subscription_quota::{ + read_claude_subscription_quota_core, read_codex_subscription_quota_core, + read_cursor_subscription_quota_core, read_grok_subscription_quota_core, + read_opencode_subscription_quota_core, OfficialQuotaRead, +}; + +pub async fn subscription_quota_codex() -> Result, AppCommandError> { + Ok(Json(read_codex_subscription_quota_core().await)) +} + +pub async fn subscription_quota_claude() -> Result, AppCommandError> { + Ok(Json(read_claude_subscription_quota_core().await)) +} + +pub async fn subscription_quota_grok() -> Result, AppCommandError> { + Ok(Json(read_grok_subscription_quota_core().await)) +} + +pub async fn subscription_quota_cursor() -> Result, AppCommandError> { + Ok(Json(read_cursor_subscription_quota_core().await)) +} + +pub async fn subscription_quota_opencode() -> Result, AppCommandError> { + Ok(Json(read_opencode_subscription_quota_core().await)) +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index faae6d2cb..752d7d935 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -1265,6 +1265,26 @@ pub fn build_router( "/token_usage_sync", post(handlers::token_usage::token_usage_sync), ) + .route( + "/subscription_quota_codex", + post(handlers::subscription_quota::subscription_quota_codex), + ) + .route( + "/subscription_quota_claude", + post(handlers::subscription_quota::subscription_quota_claude), + ) + .route( + "/subscription_quota_grok", + post(handlers::subscription_quota::subscription_quota_grok), + ) + .route( + "/subscription_quota_cursor", + post(handlers::subscription_quota::subscription_quota_cursor), + ) + .route( + "/subscription_quota_opencode", + post(handlers::subscription_quota::subscription_quota_opencode), + ) // ─── Work tasks ─── .route("/work_task_list", post(handlers::work_task::work_task_list)) .route("/work_task_get", post(handlers::work_task::work_task_get)) diff --git a/src/components/conversations/conversation-detail-header.test.tsx b/src/components/conversations/conversation-detail-header.test.tsx index 64a66bebf..21a70217d 100644 --- a/src/components/conversations/conversation-detail-header.test.tsx +++ b/src/components/conversations/conversation-detail-header.test.tsx @@ -56,6 +56,9 @@ vi.mock("./session-details-dialog", () => ({ vi.mock("@/components/chat/conversation-context-bar", () => ({ ConversationHeaderFolderPicker: () => null, })) +vi.mock("./session-quota-chip", () => ({ + SessionQuotaChip: () => null, +})) import { ConversationDetailHeader } from "./conversation-detail-header" @@ -69,6 +72,7 @@ const A: Props = { folderPath: "/a", title: "conv-a", status: "in_progress", + agentType: "claude_code", } const B: Props = { ...A, diff --git a/src/components/conversations/conversation-detail-header.tsx b/src/components/conversations/conversation-detail-header.tsx index 10c8f5182..58dc2cdb4 100644 --- a/src/components/conversations/conversation-detail-header.tsx +++ b/src/components/conversations/conversation-detail-header.tsx @@ -62,6 +62,8 @@ import { type ActiveSessionDetails, } from "./active-session-details" import { SessionDetailsDialog } from "./session-details-dialog" +import { SessionQuotaChip } from "./session-quota-chip" +import type { AgentType } from "@/lib/types" interface ConversationDetailHeaderProps { tabId: string @@ -75,6 +77,7 @@ interface ConversationDetailHeaderProps { folderPath: string | undefined title: string status: ConversationStatus | undefined + agentType: AgentType } /** @@ -99,6 +102,7 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({ folderPath, title, status, + agentType, }: ConversationDetailHeaderProps) { const t = useTranslations("Folder.conversationCard") const ime = useImeGuard() @@ -259,7 +263,8 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({ {displayTitle} -
+
+
)} @@ -2539,6 +2540,7 @@ export function ConversationDetailPanel() { folderPath={activeTabFolder?.path} title={activeTab.title} status={activeTab.status as ConversationStatus | undefined} + agentType={activeTab.agentType} /> )} diff --git a/src/components/conversations/session-quota-chip.tsx b/src/components/conversations/session-quota-chip.tsx new file mode 100644 index 000000000..aac3d34cd --- /dev/null +++ b/src/components/conversations/session-quota-chip.tsx @@ -0,0 +1,165 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useLocale, useTranslations } from "next-intl" +import { Gauge } from "lucide-react" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Progress } from "@/components/ui/progress" +import { + subscriptionQuotaClaude, + subscriptionQuotaCodex, + subscriptionQuotaCursor, + subscriptionQuotaGrok, + subscriptionQuotaOpencode, +} from "@/lib/api" +import { + familyFromAgentType, + familyQuota, + type FamilyQuota, + type IsolatableFamily, + type OfficialQuotaSlot, +} from "@/lib/subscription-quota" +import type { AgentType } from "@/lib/types" + +/** + * Quiet remaining-quota chip for the current conversation header. + * Same official sources as the Token Usage page; one family only so the + * chat view does not become a second dashboard. + */ +export function SessionQuotaChip({ agentType }: { agentType: AgentType }) { + const family = familyFromAgentType(agentType) + if (!family) return null + return +} + +function SessionQuotaChipInner({ family }: { family: IsolatableFamily }) { + const t = useTranslations("TokenUsage") + const locale = useLocale() + const [row, setRow] = useState(null) + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + let cancelled = false + const fetchers: Record< + IsolatableFamily, + | (() => Promise<{ payload?: unknown; extraSlots?: OfficialQuotaSlot[] }>) + | null + > = { + claude: subscriptionQuotaClaude, + codex: subscriptionQuotaCodex, + grok: subscriptionQuotaGrok, + cursor: subscriptionQuotaCursor, + gemini: null, + opencode: subscriptionQuotaOpencode, + } + const fetch = fetchers[family] + if (!fetch) { + setRow(familyQuota(family)) + setLoaded(true) + return + } + void fetch() + .then((value) => { + if (cancelled) return + setRow(familyQuota(family, value.payload, undefined, value.extraSlots)) + }) + .catch(() => { + if (!cancelled) setRow(familyQuota(family)) + }) + .finally(() => { + if (!cancelled) setLoaded(true) + }) + return () => { + cancelled = true + } + }, [family]) + + const label = useMemo(() => { + if (!loaded || !row) return t("quotaLoading") + if (row.kind === "remaining-subscription") { + return t("quotaRemaining", { + remaining: Math.round(row.remaining), + limit: row.limit, + }) + } + return t("quotaTitle") + }, [loaded, row, t]) + + const windows = + row?.kind === "remaining-subscription" + ? [ + { + label: row.planType ?? family, + remaining: row.remaining, + usedPercent: Math.max(0, Math.min(100, 100 - row.remaining)), + resetsAt: row.resetsAt, + }, + ...(row.extras ?? []), + ] + : [] + + return ( + + + + + +
{t("quotaTitle")}
+

+ {t("quotaHint")} +

+ {row?.kind === "remaining-subscription" ? ( +
    + {windows.map((w) => ( +
  • +
    + + {w.label ?? family} + + + {t("quotaRemaining", { + remaining: Math.round(w.remaining), + limit: 100, + })} + +
    + + {w.resetsAt ? ( +
    + {t("quotaResets", { + when: new Date(w.resetsAt * 1000).toLocaleString(locale, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }), + })} +
    + ) : null} +
  • + ))} +
+ ) : ( +

+ {loaded ? t("quotaProviderLink") : t("quotaLoading")} +

+ )} +
+
+ ) +} diff --git a/src/components/token-usage/subscription-quota-panel.tsx b/src/components/token-usage/subscription-quota-panel.tsx new file mode 100644 index 000000000..79e63043f --- /dev/null +++ b/src/components/token-usage/subscription-quota-panel.tsx @@ -0,0 +1,154 @@ +"use client" + +import { useEffect, useState } from "react" +import { useLocale, useTranslations } from "next-intl" +import { ExternalLink } from "lucide-react" +import { + subscriptionQuotaClaude, + subscriptionQuotaCodex, + subscriptionQuotaCursor, + subscriptionQuotaGrok, + subscriptionQuotaOpencode, +} from "@/lib/api" +import { + inventory, + type IsolatableFamily, + type OfficialQuotaSlot, +} from "@/lib/subscription-quota" +import { openUrl } from "@/lib/platform" + +export function SubscriptionQuotaPanel() { + const t = useTranslations("TokenUsage") + const locale = useLocale() + const [official, setOfficial] = useState< + Partial> + >({}) + const [extraSlots, setExtraSlots] = useState< + Partial> + >({}) + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + let cancelled = false + void Promise.allSettled([ + subscriptionQuotaCodex(), + subscriptionQuotaClaude(), + subscriptionQuotaGrok(), + subscriptionQuotaCursor(), + subscriptionQuotaOpencode(), + ]) + .then((results) => { + if (cancelled) return + const next: Partial> = {} + const slots: Partial> = {} + const [codex, claude, grok, cursor, opencode] = results + if (codex.status === "fulfilled") { + if (codex.value.payload) next.codex = codex.value.payload + if (codex.value.extraSlots?.length) + slots.codex = codex.value.extraSlots + } + if (claude.status === "fulfilled") { + if (claude.value.payload) next.claude = claude.value.payload + if (claude.value.extraSlots?.length) + slots.claude = claude.value.extraSlots + } + if (grok.status === "fulfilled") { + if (grok.value.payload) next.grok = grok.value.payload + if (grok.value.extraSlots?.length) slots.grok = grok.value.extraSlots + } + if (cursor.status === "fulfilled") { + if (cursor.value.payload) next.cursor = cursor.value.payload + if (cursor.value.extraSlots?.length) + slots.cursor = cursor.value.extraSlots + } + if (opencode.status === "fulfilled") { + if (opencode.value.payload) next.opencode = opencode.value.payload + if (opencode.value.extraSlots?.length) + slots.opencode = opencode.value.extraSlots + } + setOfficial(next) + setExtraSlots(slots) + }) + .finally(() => { + if (!cancelled) setLoaded(true) + }) + return () => { + cancelled = true + } + }, []) + + const rows = inventory(official, {}, extraSlots) + + return ( +
+

{t("quotaTitle")}

+

+ {t("quotaHint")} +

+
    + {rows.map((row) => ( +
  • +
    + {row.family} + {row.kind === "remaining-subscription" ? ( + + {t("quotaRemaining", { + remaining: Math.round(row.remaining), + limit: row.limit, + })} + + ) : (row.family === "codex" || + row.family === "claude" || + row.family === "grok" || + row.family === "cursor" || + row.family === "opencode") && + !loaded ? ( + + {t("quotaLoading")} + + ) : ( + + )} +
    + {row.kind === "remaining-subscription" && row.resetsAt ? ( +
    + {t("quotaResets", { + when: new Date(row.resetsAt * 1000).toLocaleString(locale, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }), + })} +
    + ) : null} + {row.kind === "remaining-subscription" + ? row.extras?.map((extra) => ( +
    + {t("quotaExtraRemaining", { + name: extra.label ?? row.family, + remaining: Math.round(extra.remaining), + })} +
    + )) + : null} +
  • + ))} +
+
+ ) +} diff --git a/src/components/token-usage/token-usage-page.tsx b/src/components/token-usage/token-usage-page.tsx index 841ff8e90..9bf88d794 100644 --- a/src/components/token-usage/token-usage-page.tsx +++ b/src/components/token-usage/token-usage-page.tsx @@ -42,6 +42,7 @@ import { import { Progress } from "@/components/ui/progress" import { ScrollArea } from "@/components/ui/scroll-area" import { WorkbenchPageTitle } from "@/components/workbench/workbench-page-title" +import { SubscriptionQuotaPanel } from "@/components/token-usage/subscription-quota-panel" import { FolderAliasLabel } from "@/components/conversations/folder-alias-label" import { formatFolderLabelWithAlias } from "@/lib/folder-display" import { @@ -806,6 +807,11 @@ export function TokenUsagePage() { as segments, the long tail folded into one pill), the dimension filters, and a single overflow menu carrying every data action. Absent entirely on the first-run empty state — see `showToolbar`. */} +
+
+ +
+
{showToolbar && (
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index e671b8c96..e58ee646f 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -4783,6 +4783,13 @@ }, "TokenUsage": { "title": "استهلاك الرموز", + "quotaTitle": "المتبقي من الاشتراك", + "quotaHint": "تعد Codeg الرموز في هذه الصفحة. يظهر رصيد الخطة فقط إذا نشره CLI المزوّد. استخدام ACP هو إشغال السياق وليس رصيد الاشتراك.", + "quotaRemaining": "متبقٍ {remaining} من {limit}", + "quotaProviderLink": "صفحة استخدام المزوّد", + "quotaLoading": "جارٍ قراءة الحصة الرسمية…", + "quotaResets": "يُعاد التعيين {when}", + "quotaExtraRemaining": "{name}: متبقٍ {remaining}", "rangeLabel": "النطاق الزمني", "range7d": "٧ أيام", "range30d": "٣٠ يومًا", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 4259a2357..edd0bdf20 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -4783,6 +4783,13 @@ }, "TokenUsage": { "title": "Token-Verbrauch", + "quotaTitle": "Verbleibendes Abo", + "quotaHint": "Codeg zählt Tokens auf dieser Seite. Verbleibendes Plan-Kontingent nur wenn die CLI es veröffentlicht. ACP-Nutzung ist Kontextbelegung, kein Abo-Rest.", + "quotaRemaining": "{remaining} von {limit} übrig", + "quotaProviderLink": "Anbieter-Nutzungsseite", + "quotaLoading": "Offizielle Quote wird gelesen…", + "quotaResets": "Zurückgesetzt {when}", + "quotaExtraRemaining": "{name}: {remaining} übrig", "rangeLabel": "Zeitraum", "range7d": "7 Tage", "range30d": "30 Tage", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 7573c1ba1..710403da9 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4783,6 +4783,13 @@ }, "TokenUsage": { "title": "Token Usage", + "quotaTitle": "Subscription remaining", + "quotaHint": "Codeg meters tokens on this page. Remaining plan quota is shown only when a provider CLI publishes it. ACP usage is context occupancy, not subscription remaining.", + "quotaRemaining": "{remaining} of {limit} remaining", + "quotaProviderLink": "Provider usage page", + "quotaLoading": "Reading official quota…", + "quotaResets": "Resets {when}", + "quotaExtraRemaining": "{name}: {remaining} remaining", "rangeLabel": "Time range", "range7d": "7 days", "range30d": "30 days", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 6f669a2cb..c9592e6a3 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -4783,6 +4783,13 @@ }, "TokenUsage": { "title": "Uso de tokens", + "quotaTitle": "Suscripción restante", + "quotaHint": "Codeg mide tokens en esta página. El cupo restante del plan solo se muestra si el CLI del proveedor lo publica. El uso ACP es ocupación de contexto, no resto de suscripción.", + "quotaRemaining": "{remaining} de {limit} restantes", + "quotaProviderLink": "Página de uso del proveedor", + "quotaLoading": "Leyendo la cuota oficial…", + "quotaResets": "Se restablece {when}", + "quotaExtraRemaining": "{name}: {remaining} restantes", "rangeLabel": "Periodo", "range7d": "7 días", "range30d": "30 días", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 04d07d722..ed961282d 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -4783,6 +4783,13 @@ }, "TokenUsage": { "title": "Consommation de tokens", + "quotaTitle": "Reste d’abonnement", + "quotaHint": "Codeg compte les tokens sur cette page. Le quota restant n’apparaît que si le CLI du fournisseur le publie. L’usage ACP est l’occupation du contexte, pas le reste d’abonnement.", + "quotaRemaining": "{remaining} sur {limit} restants", + "quotaProviderLink": "Page d’usage du fournisseur", + "quotaLoading": "Lecture du quota officiel…", + "quotaResets": "Réinitialisation {when}", + "quotaExtraRemaining": "{name} : {remaining} restants", "rangeLabel": "Période", "range7d": "7 jours", "range30d": "30 jours", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 4a41acfb0..00b823aed 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -4783,6 +4783,13 @@ }, "TokenUsage": { "title": "トークン使用量", + "quotaTitle": "サブスク残量", + "quotaHint": "このページは Codeg が計測したトークンです。プラン残量は公式 CLI が公開する場合のみ表示します。ACP の usage はコンテキスト占有であり、契約残量ではありません。", + "quotaRemaining": "残り {remaining} / {limit}", + "quotaProviderLink": "プロバイダーの使用量ページ", + "quotaLoading": "公式クォータを読み込み中…", + "quotaResets": "{when} にリセット", + "quotaExtraRemaining": "{name}: 残り {remaining}", "rangeLabel": "期間", "range7d": "7日間", "range30d": "30日間", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 1c861273e..4eb288ca8 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -4783,6 +4783,13 @@ }, "TokenUsage": { "title": "토큰 사용량", + "quotaTitle": "구독 잔여량", + "quotaHint": "이 페이지는 Codeg가 측정한 토큰입니다. 플랜 잔여량은 제공자 CLI가 공개할 때만 표시됩니다. ACP usage는 컨텍스트 점유이며 구독 잔여량이 아닙니다.", + "quotaRemaining": "{limit} 중 {remaining} 남음", + "quotaProviderLink": "제공자 사용량 페이지", + "quotaLoading": "공식 할당량 읽는 중…", + "quotaResets": "{when}에 재설정", + "quotaExtraRemaining": "{name}: {remaining} 남음", "rangeLabel": "기간", "range7d": "7일", "range30d": "30일", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 4768c87f0..02dedf44b 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4783,6 +4783,13 @@ }, "TokenUsage": { "title": "Uso de tokens", + "quotaTitle": "Assinatura restante", + "quotaHint": "O Codeg mede tokens nesta página. A cota restante do plano só aparece se o CLI do provedor a publicar. O uso ACP é ocupação de contexto, não resto de assinatura.", + "quotaRemaining": "{remaining} de {limit} restantes", + "quotaProviderLink": "Página de uso do provedor", + "quotaLoading": "Lendo a cota oficial…", + "quotaResets": "Reinicia {when}", + "quotaExtraRemaining": "{name}: {remaining} restantes", "rangeLabel": "Período", "range7d": "7 dias", "range30d": "30 dias", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 54472386a..26fefc5f9 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -4783,6 +4783,13 @@ }, "TokenUsage": { "title": "Token 用量", + "quotaTitle": "订阅余量", + "quotaHint": "本页显示 Codeg 计量的 token。套餐余量仅在提供商 CLI 公开时显示。ACP usage 是上下文占用,不是订阅余量。", + "quotaRemaining": "剩余 {remaining} / {limit}", + "quotaProviderLink": "提供商用量页", + "quotaLoading": "正在读取官方额度…", + "quotaResets": "{when} 重置", + "quotaExtraRemaining": "{name}:剩余 {remaining}", "rangeLabel": "时间范围", "range7d": "近 7 天", "range30d": "近 30 天", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 874c3b22f..2b7cec5ae 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -4783,6 +4783,13 @@ }, "TokenUsage": { "title": "Token 用量", + "quotaTitle": "訂閱餘量", + "quotaHint": "本頁顯示 Codeg 計量的 token。方案餘量僅在供應商 CLI 公開時顯示。ACP usage 是上下文占用,不是訂閱餘量。", + "quotaRemaining": "剩餘 {remaining} / {limit}", + "quotaProviderLink": "供應商用量頁", + "quotaLoading": "正在讀取官方額度…", + "quotaResets": "{when} 重置", + "quotaExtraRemaining": "{name}:剩餘 {remaining}", "rangeLabel": "時間範圍", "range7d": "近 7 天", "range30d": "近 30 天", diff --git a/src/lib/api.ts b/src/lib/api.ts index 9b547f2cf..86c3f3ee9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2957,6 +2957,43 @@ export async function tokenUsageSync( return getTransport().call("token_usage_sync", { mode }) } +export type OfficialQuotaSlot = { + label: string + payload: unknown +} + +export type OfficialQuotaRead = { + family: string + payload: unknown | null + extraSlots?: OfficialQuotaSlot[] + unavailableReason?: string | null +} + +/** Official Codex app-server `account/rateLimits/read`. Missing CLI is null. */ +export async function subscriptionQuotaCodex(): Promise { + return getTransport().call("subscription_quota_codex") +} + +/** Claude Code `/usage` via local OAuth `GET /api/oauth/usage`. */ +export async function subscriptionQuotaClaude(): Promise { + return getTransport().call("subscription_quota_claude") +} + +/** Grok CLI-proxy `/v1/billing?format=credits`. */ +export async function subscriptionQuotaGrok(): Promise { + return getTransport().call("subscription_quota_grok") +} + +/** Cursor DashboardService GetCurrentPeriodUsage. Missing login is null. */ +export async function subscriptionQuotaCursor(): Promise { + return getTransport().call("subscription_quota_cursor") +} + +/** Official OpenCode Go `GET /zen/go/v1/usage`. Missing key/plan is null. */ +export async function subscriptionQuotaOpencode(): Promise { + return getTransport().call("subscription_quota_opencode") +} + // Automations export async function automationList(): Promise { diff --git a/src/lib/subscription-quota.test.ts b/src/lib/subscription-quota.test.ts new file mode 100644 index 000000000..03b6f4278 --- /dev/null +++ b/src/lib/subscription-quota.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from "vitest" +import { + attachExtraSlots, + emitsRemainingSubscription, + familyFromAgentType, + familyQuota, + inventory, + remainingFromOfficialPayload, +} from "./subscription-quota" + +describe("familyFromAgentType", () => { + it("maps built-ins and extra isolator slots", () => { + expect(familyFromAgentType("claude_code")).toBe("claude") + expect(familyFromAgentType("custom:claude-code-2")).toBe("claude") + expect(familyFromAgentType("codex")).toBe("codex") + expect(familyFromAgentType("custom:codex-2")).toBe("codex") + expect(familyFromAgentType("grok")).toBe("grok") + expect(familyFromAgentType("cursor")).toBe("cursor") + expect(familyFromAgentType("custom:cursor-2")).toBe("cursor") + expect(familyFromAgentType("gemini")).toBe("gemini") + expect(familyFromAgentType("open_code")).toBe("opencode") + expect(familyFromAgentType(null)).toBeNull() + }) +}) + +describe("subscription quota inventory", () => { + it("does not invent remaining-subscription numbers when no official payload exists", () => { + for (const row of inventory()) { + expect(emitsRemainingSubscription(row)).toBe(false) + expect(row.kind).toBe("unavailable") + if (row.kind === "unavailable") { + expect(row.providerUsageUrl.startsWith("https://")).toBe(true) + } + } + }) + + it("treats ACP usage_update as context occupancy, not plan remaining", () => { + const row = familyQuota("claude", undefined, { used: 1200, size: 8000 }) + expect(row.kind).toBe("acp-context") + expect(emitsRemainingSubscription(row)).toBe(false) + if (row.kind === "acp-context") { + expect(row.used).toBe(1200) + expect(row.size).toBe(8000) + } + }) + + it("reads Codex remaining from documented account/rateLimits/read", () => { + const payload = { + rateLimits: { + primary: { usedPercent: 42, resetsAt: 1_775_000_000 }, + secondary: { usedPercent: 10, resetsAt: 1_775_500_000 }, + }, + } + const parsed = remainingFromOfficialPayload("codex", payload) + expect(parsed?.remaining).toBe(58) + expect(parsed?.limit).toBe(100) + expect(parsed?.source).toBe("codex account/rateLimits/read") + expect(parsed?.resetsAt).toBe(1_775_000_000) + expect(familyQuota("codex", payload).kind).toBe("remaining-subscription") + }) + + it("parses the live Codex app-server envelope from this machine", () => { + // Sanitized from `codex app-server --stdio` + account/rateLimits/read + // on 2026-08-15. Numbers are real; ids are generic. + const payload = { + id: 2, + result: { + rateLimits: { + limitId: "codex", + limitName: null, + primary: { + usedPercent: 100, + windowDurationMins: 10080, + resetsAt: 1787196797, + }, + secondary: null, + credits: { hasCredits: false, unlimited: false, balance: "0" }, + planType: "pro", + rateLimitReachedType: "rate_limit_reached", + }, + rateLimitsByLimitId: { + codex: { + limitId: "codex", + primary: { usedPercent: 100 }, + }, + codex_spark: { + limitId: "codex_spark", + limitName: "GPT-5.3-Codex-Spark", + primary: { + usedPercent: 0, + windowDurationMins: 10080, + resetsAt: 1787423547, + }, + }, + }, + }, + } + const parsed = remainingFromOfficialPayload("codex", payload) + expect(parsed?.remaining).toBe(0) + expect(parsed?.planType).toBe("pro") + expect(parsed?.rateLimitReached).toBe(true) + expect(parsed?.windowDurationMins).toBe(10080) + expect(parsed?.extras).toEqual([ + { + remaining: 100, + usedPercent: 0, + windowDurationMins: 10080, + resetsAt: 1787423547, + label: "GPT-5.3-Codex-Spark", + }, + ]) + }) + + it("reads Claude remaining from the /usage HUD payload", () => { + const payload = { + five_hour: { utilization: 42, resets_at: "2026-02-28T17:00:00Z" }, + seven_day: { utilization: 61, resets_at: "2026-03-07T08:00:00Z" }, + } + const parsed = remainingFromOfficialPayload("claude", payload) + expect(parsed?.source).toBe("claude /api/oauth/usage") + expect(parsed?.remaining).toBe(39) + expect(parsed?.extras?.[0]?.label).toBe("5-hour") + expect(familyQuota("claude", payload).kind).toBe("remaining-subscription") + }) + + it("parses the live Claude oauth/usage envelope from this machine", () => { + const payload = { + five_hour: { utilization: 0.0, resets_at: null }, + seven_day: { + utilization: 100.0, + resets_at: "2026-08-16T07:59:59.753195+00:00", + }, + extra_usage: { utilization: 9.4, is_enabled: true }, + } + const parsed = remainingFromOfficialPayload("claude", payload) + expect(parsed?.remaining).toBe(0) + expect(parsed?.resetsAt).toBe( + Math.floor(Date.parse("2026-08-16T07:59:59.753195+00:00") / 1000) + ) + const labels = parsed?.extras?.map((e) => e.label).sort() + expect(labels).toEqual(["5-hour", "extra usage"]) + }) + + it("parses the live Grok CLI-proxy billing envelope from this machine", () => { + const payload = { + config: { + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-08-09T23:32:49.216917+00:00", + end: "2026-08-16T23:32:49.216917+00:00", + }, + creditUsagePercent: 19.0, + billingPeriodEnd: "2026-08-16T23:32:49.216917+00:00", + }, + } + const parsed = remainingFromOfficialPayload("grok", payload) + expect(parsed?.remaining).toBe(81) + expect(parsed?.source).toBe("grok cli-chat-proxy /v1/billing") + expect(parsed?.resetsAt).toBe( + Math.floor(Date.parse("2026-08-16T23:32:49.216917+00:00") / 1000) + ) + }) + + it("reads Cursor remaining from GetCurrentPeriodUsage planUsage", () => { + const payload = { + planUsage: { + remaining: 12, + limit: 20, + total_percent_used: 40, + auto_percent_used: 10, + api_percent_used: 55, + }, + } + const parsed = remainingFromOfficialPayload("cursor", payload) + expect(parsed?.remaining).toBe(60) + expect(parsed?.source).toBe("cursor DashboardService/GetCurrentPeriodUsage") + expect(parsed?.extras?.map((e) => e.label).sort()).toEqual([ + "API", + "Auto / Composer", + ]) + expect(familyQuota("cursor", payload).kind).toBe("remaining-subscription") + }) + + it("falls back to Cursor remaining/limit when percents are absent", () => { + const parsed = remainingFromOfficialPayload("cursor", { + planUsage: { remaining: 5, limit: 20 }, + }) + expect(parsed?.remaining).toBe(25) + }) + + it("parses the live Cursor GetCurrentPeriodUsage envelope from this machine", () => { + // Sanitized from a signed-in Ultra cursor-agent session on 2026-08-16. + // Token never stored. Numbers are the real planUsage fields. + const payload = { + enabled: true, + billingCycleStart: "1786889793000", + billingCycleEnd: "1789568193000", + planUsage: { + remaining: 40000, + limit: 40000, + remainingBonus: false, + autoPercentUsed: 0, + apiPercentUsed: 0, + totalPercentUsed: 0, + }, + } + const parsed = remainingFromOfficialPayload("cursor", payload) + expect(parsed?.remaining).toBe(100) + expect(parsed?.resetsAt).toBe(1_789_568_193) + expect(parsed?.extras?.map((e) => e.label).sort()).toEqual([ + "API", + "Auto / Composer", + ]) + }) + + it("reads OpenCode Go remaining from official /zen/go/v1/usage", () => { + // Shape from anomalyco/opencode#16513 formatUsage(). + const payload = { + usage: { + rolling: { + status: "ok", + percent: 42, + resetsAt: "2026-08-16T18:00:00.000Z", + }, + weekly: { + status: "ok", + percent: 10, + resetsAt: "2026-08-20T00:00:00.000Z", + }, + monthly: { + status: "ok", + percent: 8, + resetsAt: "2026-09-01T00:00:00.000Z", + }, + }, + } + const parsed = remainingFromOfficialPayload("opencode", payload) + expect(parsed?.remaining).toBe(58) + expect(parsed?.source).toBe("opencode /zen/go/v1/usage") + expect(parsed?.resetsAt).toBe( + Math.floor(Date.parse("2026-08-16T18:00:00.000Z") / 1000) + ) + expect(parsed?.extras?.map((e) => e.label).sort()).toEqual([ + "monthly", + "weekly", + ]) + expect(familyQuota("opencode", payload).kind).toBe("remaining-subscription") + }) + + it("does not invent OpenCode remaining from session stats", () => { + expect( + remainingFromOfficialPayload("opencode", { + tokens: 1200, + cost: 1.2, + }) + ).toBeNull() + }) + + it("does not invent Cursor remaining from about/status identity", () => { + expect( + remainingFromOfficialPayload("cursor", { + cliVersion: "2026.07.23-e383d2b", + subscriptionTier: "pro", + userEmail: "dev@example.com", + }) + ).toBeNull() + }) + + it("rejects a payload that is not the official family shape", () => { + expect( + remainingFromOfficialPayload("claude", { remaining: 1, limit: 2 }) + ).toBeNull() + expect( + remainingFromOfficialPayload("grok", { + rateLimits: { primary: { usedPercent: 10 } }, + }) + ).toBeNull() + }) + + it("attaches extra isolated-account remaining as labeled extras", () => { + const primary = remainingFromOfficialPayload("claude", { + five_hour: { utilization: 10, resets_at: null }, + seven_day: { utilization: 20, resets_at: null }, + }) + const attached = attachExtraSlots(primary, "claude", [ + { + label: "claude-2", + payload: { + five_hour: { utilization: 40, resets_at: null }, + seven_day: { utilization: 40, resets_at: null }, + }, + }, + ]) + expect(attached?.remaining).toBe(80) + expect( + attached?.extras?.some( + (e) => e.label === "claude-2" && e.remaining === 60 + ) + ).toBe(true) + const onlyExtra = familyQuota("claude", undefined, undefined, [ + { + label: "claude-2", + payload: { + five_hour: { utilization: 5, resets_at: null }, + seven_day: { utilization: 5, resets_at: null }, + }, + }, + ]) + expect(onlyExtra.kind).toBe("remaining-subscription") + if (onlyExtra.kind === "remaining-subscription") { + expect(onlyExtra.remaining).toBe(95) + } + }) +}) diff --git a/src/lib/subscription-quota.ts b/src/lib/subscription-quota.ts new file mode 100644 index 000000000..fccb136fb --- /dev/null +++ b/src/lib/subscription-quota.ts @@ -0,0 +1,509 @@ +/** + * Remaining-subscription inventory. + * + * ACP `usage_update` is context occupancy `{used, size}`, not plan remaining. + * + * Official remaining-quota sources, verified against live CLIs: + * Codex: documented app-server `account/rateLimits/read`. + * Live result (2026-08-15) is `{ rateLimits.primary.usedPercent, + * windowDurationMins, resetsAt, planType, rateLimitsByLimitId }`. + * `primary` is the current window (here a 10080-minute week), not a + * guaranteed 5-hour window. + * Claude: there is no `claude usage` CLI. The `/usage` HUD reads + * `GET https://api.anthropic.com/api/oauth/usage` with the local + * Claude Code OAuth token (same endpoint community monitors use). + * Live 2026-08-15: `five_hour.utilization` / `seven_day.utilization` + * are 0-100 percents, not 0-1 fractions. + * Grok: no usage CLI. Live 2026-08-15: + * `GET https://cli-chat-proxy.grok.com/v1/billing?format=credits` + * with the Grok CLI OAuth token (`x-xai-token-auth: xai-grok-cli`) + * returns `config.creditUsagePercent` (0-100) and period end. + * Cursor: no `usage` CLI. `status` / `about --format json` are identity + * only. The official CLI/IDE client reads remaining from + * `POST aiserver.v1.DashboardService/GetCurrentPeriodUsage` on + * `api2.cursor.sh`. `planUsage` has `remaining` / `limit` plus + * `total_percent_used` / `auto_percent_used` / `api_percent_used`. + * OpenCode Go: official `GET https://opencode.ai/zen/go/v1/usage` + * (shipped 2026-08-11, anomalyco/opencode#16513) with the Go API + * key. `{ usage.rolling|weekly|monthly: { percent, resetsAt } }`. + * `percent` is used 0-100. `opencode stats` is still historical + * session cost, not this. Zen pay-as-you-go has no remaining API. + * Gemini: no remaining-quota command. + */ + +export type IsolatableFamily = + | "claude" + | "codex" + | "grok" + | "cursor" + | "gemini" + | "opencode" + +/** Map a conversation's agent_type (built-in or `custom:-N`) to the + * remaining-quota family, or null when that agent has no remaining-quota + * source. Extra slots keep the family isolator (`custom:claude-code-2`). */ +export function familyFromAgentType( + agentType: string | null | undefined +): IsolatableFamily | null { + if (!agentType) return null + const s = agentType.toLowerCase() + if (s === "claude_code" || s.startsWith("custom:claude")) return "claude" + if (s === "codex" || s.startsWith("custom:codex")) return "codex" + if (s === "grok" || s.startsWith("custom:grok")) return "grok" + if (s === "cursor" || s.startsWith("custom:cursor")) return "cursor" + if (s === "gemini" || s.startsWith("custom:gemini")) return "gemini" + if ( + s === "open_code" || + s.startsWith("custom:opencode") || + s.startsWith("custom:open-code") || + s.startsWith("custom:open_code") + ) { + return "opencode" + } + return null +} + +export type QuotaKind = "remaining-subscription" | "acp-context" | "unavailable" + +export type QuotaWindow = { + remaining: number + usedPercent: number + windowDurationMins?: number + resetsAt?: number + label?: string +} + +export type FamilyQuota = + | { + family: IsolatableFamily + kind: "remaining-subscription" + remaining: number + limit: number + source: string + planType?: string + rateLimitReached?: boolean + extras?: QuotaWindow[] + resetsAt?: number + windowDurationMins?: number + } + | { + family: IsolatableFamily + kind: "acp-context" + used: number + size: number + } + | { + family: IsolatableFamily + kind: "unavailable" + providerUsageUrl: string + } + +export const PROVIDER_USAGE_URLS: Record = { + claude: "https://claude.ai/settings/usage", + codex: "https://chatgpt.com/#settings", + grok: "https://accounts.x.ai/", + cursor: "https://cursor.com/dashboard/spending", + gemini: "https://aistudio.google.com/", + opencode: "https://opencode.ai/go", +} + +const FAMILIES: IsolatableFamily[] = [ + "claude", + "codex", + "grok", + "cursor", + "gemini", + "opencode", +] + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null +} + +function percentRemaining(usedPercent: unknown): number | null { + if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) { + return null + } + return Math.max(0, Math.min(100, 100 - usedPercent)) +} + +function parseResetsAt(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value > 1e12 ? Math.floor(value / 1000) : value + } + if (typeof value === "string") { + const trimmed = value.trim() + if (/^\d+$/.test(trimmed)) { + const n = Number(trimmed) + if (!Number.isFinite(n)) return undefined + return n > 1e12 ? Math.floor(n / 1000) : n + } + const ms = Date.parse(trimmed) + if (!Number.isNaN(ms)) return Math.floor(ms / 1000) + } + return undefined +} + +/** Live `/api/oauth/usage` returns 0-100 percent, not a 0-1 fraction. */ +function utilizationRemaining(utilization: unknown): number | null { + if (typeof utilization !== "number" || !Number.isFinite(utilization)) { + return null + } + return Math.max(0, Math.min(100, 100 - utilization)) +} + +function windowFromUtilization( + value: unknown, + label: string +): QuotaWindow | null { + const rec = asRecord(value) + if (!rec) return null + const remaining = utilizationRemaining(rec.utilization) + if (remaining == null || typeof rec.utilization !== "number") return null + return { + remaining, + usedPercent: rec.utilization, + resetsAt: parseResetsAt(rec.resets_at), + label, + } +} + +function windowFromLimit(limit: Record): QuotaWindow | null { + const primary = asRecord(limit.primary) + const remaining = percentRemaining(primary?.usedPercent) + if (remaining == null || typeof primary?.usedPercent !== "number") return null + const windowDurationMins = + typeof primary.windowDurationMins === "number" + ? primary.windowDurationMins + : undefined + const resetsAt = + typeof primary.resetsAt === "number" ? primary.resetsAt : undefined + const label = + typeof limit.limitName === "string" + ? limit.limitName + : typeof limit.limitId === "string" + ? limit.limitId + : undefined + return { + remaining, + usedPercent: primary.usedPercent, + windowDurationMins, + resetsAt, + label, + } +} + +/** Documented Codex app-server `account/rateLimits/read` result. */ +export function remainingFromCodexAppServer(payload: unknown): { + remaining: number + limit: number + source: string + planType?: string + rateLimitReached?: boolean + extras?: QuotaWindow[] + resetsAt?: number + windowDurationMins?: number +} | null { + const rec = asRecord(payload) + if (!rec) return null + const result = asRecord(rec.result) ?? rec + const limits = asRecord(result.rateLimits) + if (!limits) return null + const primary = windowFromLimit(limits) + if (!primary) return null + const extras: QuotaWindow[] = [] + const byId = asRecord(result.rateLimitsByLimitId) + const primaryId = typeof limits.limitId === "string" ? limits.limitId : null + if (byId) { + for (const [id, value] of Object.entries(byId)) { + if (primaryId && id === primaryId) continue + const extra = asRecord(value) + if (!extra) continue + const parsed = windowFromLimit(extra) + if (parsed) extras.push(parsed) + } + } + return { + remaining: primary.remaining, + limit: 100, + source: "codex account/rateLimits/read", + planType: typeof limits.planType === "string" ? limits.planType : undefined, + rateLimitReached: limits.rateLimitReachedType === "rate_limit_reached", + extras: extras.length ? extras : undefined, + resetsAt: primary.resetsAt, + windowDurationMins: primary.windowDurationMins, + } +} + +/** Claude Code `/usage` payload from `GET /api/oauth/usage`. */ +export function remainingFromClaudeUsageHud( + payload: unknown +): OfficialRemaining | null { + const rec = asRecord(payload) + if (!rec) return null + const five = windowFromUtilization(rec.five_hour, "5-hour") + const week = windowFromUtilization(rec.seven_day, "weekly") + const extra = windowFromUtilization(rec.extra_usage, "extra usage") + const candidates = [five, week].filter((w): w is QuotaWindow => w != null) + if (candidates.length === 0) return null + const primary = candidates.reduce((a, b) => + a.remaining <= b.remaining ? a : b + ) + const extras = [five, week, extra].filter( + (w): w is QuotaWindow => w != null && w !== primary + ) + return { + remaining: primary.remaining, + limit: 100, + source: "claude /api/oauth/usage", + extras: extras.length ? extras : undefined, + resetsAt: primary.resetsAt, + } +} + +/** + * Read remaining subscription from a recorded official payload. + * Production Codeg never invents this object. + */ +export type OfficialRemaining = { + remaining: number + limit: number + source: string + planType?: string + rateLimitReached?: boolean + extras?: QuotaWindow[] + resetsAt?: number + windowDurationMins?: number +} + +/** Grok CLI-proxy `GET /v1/billing?format=credits`. */ +export function remainingFromGrokBilling( + payload: unknown +): OfficialRemaining | null { + const rec = asRecord(payload) + if (!rec) return null + const config = asRecord(rec.config) ?? rec + const remaining = percentRemaining(config.creditUsagePercent) + if (remaining == null) return null + const period = asRecord(config.currentPeriod) + return { + remaining, + limit: 100, + source: "grok cli-chat-proxy /v1/billing", + resetsAt: parseResetsAt(period?.end ?? config.billingPeriodEnd), + } +} + +function pickNum( + rec: Record, + ...keys: string[] +): number | null { + for (const key of keys) { + const value = rec[key] + if (typeof value === "number" && Number.isFinite(value)) return value + } + return null +} + +function windowFromUsedPercent( + usedPercent: number | null, + label: string +): QuotaWindow | null { + const remaining = percentRemaining(usedPercent) + if (remaining == null || usedPercent == null) return null + return { remaining, usedPercent, label } +} + +/** + * Cursor CLI/IDE `GetCurrentPeriodUsage` `planUsage` object. + * Field names from aiserver.v1.GetCurrentPeriodUsageResponse.PlanUsage. + */ +export function remainingFromCursorPeriodUsage( + payload: unknown +): OfficialRemaining | null { + const rec = asRecord(payload) + if (!rec) return null + const plan = asRecord(rec.planUsage) ?? asRecord(rec.plan_usage) ?? rec + if (!plan) return null + + const usedTotal = pickNum(plan, "totalPercentUsed", "total_percent_used") + const usedAuto = pickNum(plan, "autoPercentUsed", "auto_percent_used") + const usedApi = pickNum(plan, "apiPercentUsed", "api_percent_used") + const leftover = pickNum(plan, "remaining") + const cap = pickNum(plan, "limit") + + let remaining: number | null = percentRemaining(usedTotal) + if (remaining == null && leftover != null && cap != null && cap > 0) { + remaining = Math.max(0, Math.min(100, (100 * leftover) / cap)) + } + if (remaining == null) return null + + const extras = [ + windowFromUsedPercent(usedAuto, "Auto / Composer"), + windowFromUsedPercent(usedApi, "API"), + ].filter((w): w is QuotaWindow => w != null) + + return { + remaining, + limit: 100, + source: "cursor DashboardService/GetCurrentPeriodUsage", + extras: extras.length ? extras : undefined, + resetsAt: parseResetsAt(rec.billingCycleEnd ?? rec.billing_cycle_end), + } +} + +function windowFromOpencodeGo( + value: unknown, + label: string +): QuotaWindow | null { + const rec = asRecord(value) + if (!rec) return null + const used = pickNum(rec, "percent", "usagePercent", "usage_percent") + const remaining = percentRemaining(used) + if (remaining == null || used == null) return null + return { + remaining, + usedPercent: used, + resetsAt: parseResetsAt(rec.resetsAt ?? rec.resets_at), + label, + } +} + +/** + * Official OpenCode Go `GET /zen/go/v1/usage`. + * `percent` is used 0-100. Shipped 2026-08-11 (#16513). + */ +export function remainingFromOpencodeGoUsage( + payload: unknown +): OfficialRemaining | null { + const rec = asRecord(payload) + if (!rec) return null + const usage = asRecord(rec.usage) ?? rec + const rolling = windowFromOpencodeGo(usage.rolling, "5-hour") + const weekly = windowFromOpencodeGo(usage.weekly, "weekly") + const monthly = windowFromOpencodeGo(usage.monthly, "monthly") + const candidates = [rolling, weekly, monthly].filter( + (w): w is QuotaWindow => w != null + ) + if (candidates.length === 0) return null + const primary = candidates.reduce((a, b) => + a.remaining <= b.remaining ? a : b + ) + const extras = candidates.filter((w) => w !== primary) + return { + remaining: primary.remaining, + limit: 100, + source: "opencode /zen/go/v1/usage", + extras: extras.length ? extras : undefined, + resetsAt: primary.resetsAt, + } +} + +export function remainingFromOfficialPayload( + family: IsolatableFamily, + payload: unknown +): OfficialRemaining | null { + if (family === "codex") return remainingFromCodexAppServer(payload) + if (family === "claude") return remainingFromClaudeUsageHud(payload) + if (family === "grok") return remainingFromGrokBilling(payload) + if (family === "cursor") return remainingFromCursorPeriodUsage(payload) + if (family === "opencode") return remainingFromOpencodeGoUsage(payload) + return null +} + +export type OfficialQuotaSlot = { + label: string + payload: unknown +} + +export function attachExtraSlots( + remaining: OfficialRemaining | null, + family: IsolatableFamily, + extraSlots?: OfficialQuotaSlot[] | null +): OfficialRemaining | null { + const more: QuotaWindow[] = [] + for (const slot of extraSlots ?? []) { + const parsed = remainingFromOfficialPayload(family, slot.payload) + if (!parsed) continue + more.push({ + remaining: parsed.remaining, + usedPercent: Math.max(0, Math.min(100, 100 - parsed.remaining)), + resetsAt: parsed.resetsAt, + label: slot.label, + }) + } + if (!remaining) { + if (more.length === 0) return null + const primary = more.reduce((a, b) => (a.remaining <= b.remaining ? a : b)) + return { + remaining: primary.remaining, + limit: 100, + source: `${family} extra slot`, + extras: more.filter((slot) => slot !== primary), + resetsAt: primary.resetsAt, + } + } + if (more.length === 0) return remaining + return { + ...remaining, + extras: [...(remaining.extras ?? []), ...more], + } +} + +export function acpContextFromPayload( + payload: unknown +): { used: number; size: number } | null { + if (!payload || typeof payload !== "object") return null + const rec = payload as Record + if (typeof rec.used !== "number" || typeof rec.size !== "number") return null + if (!Number.isFinite(rec.used) || !Number.isFinite(rec.size)) return null + return { used: rec.used, size: rec.size } +} + +export function familyQuota( + family: IsolatableFamily, + officialPayload?: unknown, + acpUsage?: unknown, + extraSlots?: OfficialQuotaSlot[] | null +): FamilyQuota { + const remaining = attachExtraSlots( + remainingFromOfficialPayload(family, officialPayload), + family, + extraSlots + ) + if (remaining) { + return { family, kind: "remaining-subscription", ...remaining } + } + const context = acpContextFromPayload(acpUsage) + if (context) { + return { family, kind: "acp-context", ...context } + } + return { + family, + kind: "unavailable", + providerUsageUrl: PROVIDER_USAGE_URLS[family], + } +} + +export function inventory( + officialByFamily: Partial> = {}, + acpByFamily: Partial> = {}, + extraSlotsByFamily: Partial< + Record + > = {} +): FamilyQuota[] { + return FAMILIES.map((family) => + familyQuota( + family, + officialByFamily[family], + acpByFamily[family], + extraSlotsByFamily[family] + ) + ) +} + +export function emitsRemainingSubscription(row: FamilyQuota): boolean { + return row.kind === "remaining-subscription" +}