From 3328d6f4c47155ca8daa72293298c8d5c72e77fa Mon Sep 17 00:00:00 2001 From: Nicolas Girardot Date: Wed, 1 Jul 2026 16:28:07 +0200 Subject: [PATCH] feat(statusline): warn when org spend limit is close Polls GET /v1/organizations/:orgId/usage-limit-status (self-serve, resolves to the logged-in user) and appends a colored warning segment to the statusline once usage crosses 80%, so users see the cap coming before they hit it. Cached on disk with the same TTL as session stats; silent no-op on missing auth, network failure, or no limit configured. --- crates/cli/src/api.rs | 38 +++++ crates/cli/src/commands/statusline/render.rs | 144 +++++++++++++++++-- 2 files changed, 172 insertions(+), 10 deletions(-) diff --git a/crates/cli/src/api.rs b/crates/cli/src/api.rs index ded44d8..27b049d 100644 --- a/crates/cli/src/api.rs +++ b/crates/cli/src/api.rs @@ -190,6 +190,23 @@ pub struct ToolCompressionStat { pub after: u64, } +/// Self-serve spend-limit status for the logged-in user on an org +/// (`GET /v1/organizations/{org}/usage-limit-status`). Always resolves to the +/// caller, unlike the admin-only per-member settings endpoint. Used to warn +/// the user before they hit their cap. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct UsageLimitStatus { + pub has_limit: bool, + #[serde(default)] + pub max_usage: Option, + #[serde(default)] + pub used_credits: Option, + #[serde(default)] + pub period: Option, + #[serde(default)] + pub percent_used: Option, +} + impl ApiClient { pub fn new(token: &str) -> Result { let mut headers = reqwest::header::HeaderMap::new(); @@ -399,6 +416,27 @@ impl ApiClient { Ok(()) } + /// Fetches the caller's own spend-limit status on an org + /// (`GET /v1/organizations/{org}/usage-limit-status`). Self-serve — any + /// org role — so this always reports the logged-in user, not an + /// arbitrary member. + pub async fn get_usage_limit_status(&self, org_id: &str) -> Result { + let url = format!( + "{}/v1/organizations/{}/usage-limit-status", + self.base_url, org_id + ); + let resp = self + .http + .get(&url) + .send() + .await + .context("Failed to fetch usage limit status")?; + check_status(&resp, "fetch usage limit status")?; + resp.json() + .await + .context("Invalid usage limit status response") + } + pub async fn get_session_stats(&self, org_id: &str, session_id: &str) -> Result { let url = format!( "{}/v1/organizations/{}/sessions/{}/end", diff --git a/crates/cli/src/commands/statusline/render.rs b/crates/cli/src/commands/statusline/render.rs index 3e0aff9..d598b92 100644 --- a/crates/cli/src/commands/statusline/render.rs +++ b/crates/cli/src/commands/statusline/render.rs @@ -8,6 +8,11 @@ //! Code hides the statusline entirely. When the session ID is present but the //! network call fails and no cached value is available, the bare Edgee marker //! is shown. The renderer must never crash and must always exit 0. +//! +//! Also fetches the caller's org spend-limit status (cached separately, same +//! TTL) and appends a warning segment once usage crosses [`USAGE_WARN_PCT`]. +//! Org id / user token come from the local credentials file +//! (`crate::config::read()`), not the environment. use std::fs; use std::io::Read; @@ -16,12 +21,17 @@ use std::time::{Duration, SystemTime}; use serde::Deserialize; +use crate::api::{ApiClient, UsageLimitStatus}; + const CACHE_MAX_AGE_SECS: u64 = 8; const HTTP_TIMEOUT: Duration = Duration::from_secs(5); +const USAGE_WARN_PCT: f64 = 80.0; const PURPLE: &str = "\x1b[38;5;128m"; const BOLD_PURPLE: &str = "\x1b[1;38;5;128m"; const DIM: &str = "\x1b[2m"; +const YELLOW: &str = "\x1b[38;5;220m"; +const RED: &str = "\x1b[38;5;196m"; const RESET: &str = "\x1b[0m"; #[derive(Debug, Default, Deserialize)] @@ -65,7 +75,8 @@ async fn render_with_separator(prefix: &str) -> String { } let stats = fetch_or_cache(&session_id).await; - format_line(prefix, stats.as_ref()) + let usage = fetch_or_cache_usage().await; + format_line(prefix, stats.as_ref(), usage.as_ref()) } fn drain_stdin() -> std::io::Result<()> { @@ -88,6 +99,12 @@ fn cache_path(session_id: &str) -> PathBuf { .join(format!("statusline-{session_id}.json")) } +fn usage_cache_path(org_id: &str) -> PathBuf { + crate::config::global_config_dir() + .join("cache") + .join(format!("usage-limit-{org_id}.json")) +} + async fn fetch_or_cache(session_id: &str) -> Option { let cache_file = cache_path(session_id); let cache_fresh = cache_age(&cache_file) @@ -128,6 +145,52 @@ fn read_cache(path: &PathBuf) -> Option { serde_json::from_str(&content).ok() } +/// Fetches the org's spend-limit status, cached on disk with the same TTL as +/// session stats. Reads org id / token from the local credentials file +/// (`crate::config::read()`) — no env var needed. Silent no-op (falls back to +/// cache, then `None`) on any missing auth, network, or parse failure. +async fn fetch_or_cache_usage() -> Option { + let creds = crate::config::read().ok()?; + let org_id = creds.org_id.as_deref().filter(|o| !o.is_empty())?; + let user_token = creds.user_token.as_deref().filter(|t| !t.is_empty())?; + + let cache_file = usage_cache_path(org_id); + let cache_fresh = cache_age(&cache_file) + .map(|age| age < Duration::from_secs(CACHE_MAX_AGE_SECS)) + .unwrap_or(false); + + if cache_fresh { + if let Some(u) = read_usage_cache(&cache_file) { + return Some(u); + } + } + + if let Some(status) = fetch_usage_limit_status(user_token, org_id).await { + if let Some(parent) = cache_file.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_vec(&status) { + let _ = fs::write(&cache_file, json); + } + return Some(status); + } + + read_usage_cache(&cache_file) +} + +fn read_usage_cache(path: &PathBuf) -> Option { + let content = fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + +async fn fetch_usage_limit_status(user_token: &str, org_id: &str) -> Option { + let client = ApiClient::new(user_token).ok()?; + tokio::time::timeout(HTTP_TIMEOUT, client.get_usage_limit_status(org_id)) + .await + .ok()? + .ok() +} + async fn fetch_summary(session_id: &str) -> Option { let api_base = std::env::var("EDGEE_CONSOLE_API_URL") .unwrap_or_else(|_| "https://api.edgee.app".to_string()); @@ -145,9 +208,11 @@ async fn fetch_summary(session_id: &str) -> Option { resp.json::().await.ok() } -fn format_line(prefix: &str, stats: Option<&SessionSummary>) -> String { +fn format_line(prefix: &str, stats: Option<&SessionSummary>, usage: Option<&UsageLimitStatus>) -> String { + let warning = format_usage_warning(usage); + let Some(stats) = stats else { - return format!("{prefix}{PURPLE}三 Edgee{RESET}"); + return format!("{prefix}{PURPLE}三 Edgee{RESET}{warning}"); }; let before = stats.total_uncompressed_tools_tokens; @@ -165,13 +230,34 @@ fn format_line(prefix: &str, stats: Option<&SessionSummary>) -> String { bar.push('░'); } format!( - "{prefix}{PURPLE}三 Edgee{RESET} {PURPLE}{bar}{RESET} {BOLD_PURPLE}{pct}%{RESET} tool compression {DIM}{requests} reqs{RESET}" + "{prefix}{PURPLE}三 Edgee{RESET} {PURPLE}{bar}{RESET} {BOLD_PURPLE}{pct}%{RESET} tool compression {DIM}{requests} reqs{RESET}{warning}" ) } else if requests > 0 { - format!("{prefix}{PURPLE}三 Edgee{RESET} {DIM}{requests} reqs{RESET}") + format!("{prefix}{PURPLE}三 Edgee{RESET} {DIM}{requests} reqs{RESET}{warning}") } else { - format!("{prefix}{PURPLE}三 Edgee{RESET}") + format!("{prefix}{PURPLE}三 Edgee{RESET}{warning}") + } +} + +/// Renders a trailing " ⚠ NN% of $limit used" segment once usage crosses +/// [`USAGE_WARN_PCT`]. Empty string when unmetered, unknown, or below the +/// threshold — never adds visual noise for a healthy account. +fn format_usage_warning(usage: Option<&UsageLimitStatus>) -> String { + let Some(usage) = usage else { + return String::new(); + }; + if !usage.has_limit { + return String::new(); + } + let Some(pct) = usage.percent_used else { + return String::new(); + }; + if pct < USAGE_WARN_PCT { + return String::new(); } + let color = if pct >= 100.0 { RED } else { YELLOW }; + let max_usage = usage.max_usage.unwrap_or_default(); + format!(" {color}⚠ {pct:.0}% of ${max_usage:.0} used{RESET}") } #[cfg(test)] @@ -181,7 +267,7 @@ mod tests { #[test] fn format_no_stats() { - let s = format_line("", None); + let s = format_line("", None, None); assert!(s.contains("三 Edgee")); assert!(!s.contains("reqs")); } @@ -193,7 +279,7 @@ mod tests { total_compressed_tools_tokens: 0, total_requests: 12, }; - let s = format_line("", Some(&stats)); + let s = format_line("", Some(&stats), None); assert!(s.contains("12 reqs")); assert!(!s.contains("compression")); } @@ -205,7 +291,7 @@ mod tests { total_compressed_tools_tokens: 600, total_requests: 7, }; - let s = format_line("", Some(&stats)); + let s = format_line("", Some(&stats), None); assert!(s.contains("40%")); assert!(s.contains("compression")); assert!(s.contains("7 reqs")); @@ -213,10 +299,48 @@ mod tests { #[test] fn format_with_separator_prefix() { - let s = format_line("| ", None); + let s = format_line("| ", None, None); assert!(s.starts_with("| ")); } + #[test] + fn format_no_usage_warning_below_threshold() { + let usage = UsageLimitStatus { + has_limit: true, + max_usage: Some(100.0), + used_credits: Some(50_000_000_000), + period: Some("monthly".to_string()), + percent_used: Some(50.0), + }; + let s = format_line("", None, Some(&usage)); + assert!(!s.contains('⚠')); + } + + #[test] + fn format_usage_warning_above_threshold() { + let usage = UsageLimitStatus { + has_limit: true, + max_usage: Some(100.0), + used_credits: Some(85_000_000_000), + period: Some("monthly".to_string()), + percent_used: Some(85.0), + }; + let s = format_line("", None, Some(&usage)); + assert!(s.contains('⚠')); + assert!(s.contains("85%")); + assert!(s.contains("$100")); + } + + #[test] + fn format_no_warning_when_no_limit_configured() { + let usage = UsageLimitStatus { + has_limit: false, + ..Default::default() + }; + let s = format_line("", None, Some(&usage)); + assert!(!s.contains('⚠')); + } + #[tokio::test] async fn render_without_session_id_is_empty() { let _lock = crate::commands::claude_settings::env_test_lock();