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..284d8d724 --- /dev/null +++ b/src-tauri/src/commands/subscription_quota.rs @@ -0,0 +1,621 @@ +//! 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`. Gemini / OpenCode +//! still have no remaining-quota command. + +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-", + _ => 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) +} + +#[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::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"); + assert!(extra_homes_in(Some(root.clone()), "grok").is_empty()); + let _ = fs::remove_dir_all(&root); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f780ebbb0..9ceef7c3d 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,9 @@ 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, 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..df5e42ad3 --- /dev/null +++ b/src-tauri/src/web/handlers/subscription_quota.rs @@ -0,0 +1,19 @@ +use axum::Json; + +use crate::app_error::AppCommandError; +use crate::commands::subscription_quota::{ + read_claude_subscription_quota_core, read_codex_subscription_quota_core, + read_grok_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)) +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index faae6d2cb..86a53a34b 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -1265,6 +1265,18 @@ 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), + ) // ─── 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} -
+
+
)} @@ -2629,6 +2630,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..0b30e93eb --- /dev/null +++ b/src/components/conversations/session-quota-chip.tsx @@ -0,0 +1,162 @@ +"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, + subscriptionQuotaGrok, +} 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, + gemini: null, + opencode: null, + } + 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..83a64ced7 --- /dev/null +++ b/src/components/token-usage/subscription-quota-panel.tsx @@ -0,0 +1,138 @@ +"use client" + +import { useEffect, useState } from "react" +import { useLocale, useTranslations } from "next-intl" +import { ExternalLink } from "lucide-react" +import { + subscriptionQuotaClaude, + subscriptionQuotaCodex, + subscriptionQuotaGrok, +} 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(), + ]) + .then((results) => { + if (cancelled) return + const next: Partial> = {} + const slots: Partial> = {} + const [codex, claude, grok] = 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 + } + 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") && + !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 bdf81590d..bc8843381 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -4818,6 +4818,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 0c40db5d3..71399330a 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -4818,6 +4818,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 273af3aa0..b81502ca4 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4818,6 +4818,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 9948070a4..27b94e80c 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -4818,6 +4818,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 9638508d6..c4e012e5f 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -4818,6 +4818,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 f44e8c88d..72e5a4dfe 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -4818,6 +4818,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 d8c404715..006572441 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -4818,6 +4818,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 5b31f9ead..f18a22092 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4818,6 +4818,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 f6a2638c1..e8f51df55 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -4818,6 +4818,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 13e7b6eae..a25fbc11c 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -4818,6 +4818,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..f087e3d13 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2957,6 +2957,33 @@ 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") +} + // 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..a6ed60bcf --- /dev/null +++ b/src/lib/subscription-quota.test.ts @@ -0,0 +1,208 @@ +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("gemini")).toBe("gemini") + expect(familyFromAgentType("open_code")).toBe("opencode") + expect(familyFromAgentType("cursor")).toBeNull() + 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("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..d43032380 --- /dev/null +++ b/src/lib/subscription-quota.ts @@ -0,0 +1,381 @@ +/** + * 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. + * Gemini / OpenCode: no remaining-quota command. OpenCode `stats` is + * historical token/cost, not plan remaining. + */ + +export type IsolatableFamily = + | "claude" + | "codex" + | "grok" + | "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 === "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/", + gemini: "https://aistudio.google.com/", + opencode: "https://opencode.ai/", +} + +const FAMILIES: IsolatableFamily[] = [ + "claude", + "codex", + "grok", + "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 + if (typeof value === "string") { + const ms = Date.parse(value) + 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), + } +} + +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) + 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" +}