Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions crates/cli/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f64>,
#[serde(default)]
pub used_credits: Option<u64>,
#[serde(default)]
pub period: Option<String>,
#[serde(default)]
pub percent_used: Option<f64>,
}

impl ApiClient {
pub fn new(token: &str) -> Result<Self> {
let mut headers = reqwest::header::HeaderMap::new();
Expand Down Expand Up @@ -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<UsageLimitStatus> {
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<SessionStats> {
let url = format!(
"{}/v1/organizations/{}/sessions/{}/end",
Expand Down
144 changes: 134 additions & 10 deletions crates/cli/src/commands/statusline/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)]
Expand Down Expand Up @@ -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<()> {
Expand All @@ -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<SessionSummary> {
let cache_file = cache_path(session_id);
let cache_fresh = cache_age(&cache_file)
Expand Down Expand Up @@ -128,6 +145,52 @@ fn read_cache(path: &PathBuf) -> Option<SessionSummary> {
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<UsageLimitStatus> {
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<UsageLimitStatus> {
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<UsageLimitStatus> {
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<SessionSummary> {
let api_base = std::env::var("EDGEE_CONSOLE_API_URL")
.unwrap_or_else(|_| "https://api.edgee.app".to_string());
Expand All @@ -145,9 +208,11 @@ async fn fetch_summary(session_id: &str) -> Option<SessionSummary> {
resp.json::<SessionSummary>().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;
Expand All @@ -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)]
Expand All @@ -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"));
}
Expand All @@ -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"));
}
Expand All @@ -205,18 +291,56 @@ 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"));
}

#[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();
Expand Down