Skip to content
Merged
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
6 changes: 3 additions & 3 deletions crates/tokscale-cli/src/commands/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1328,7 +1328,7 @@ mod tests {

#[test]
fn cluster_titles_merges_near_duplicates() {
let entries = vec![
let entries = [
titled_entry("a", "Enhance API Security"),
titled_entry("b", "Enhance API security with JWT auth middleware"),
titled_entry("c", "Add JWT auth middleware"),
Expand Down Expand Up @@ -1356,7 +1356,7 @@ mod tests {

#[test]
fn cluster_titles_keeps_unrelated_apart() {
let entries = vec![
let entries = [
titled_entry("a", "Add JWT auth middleware"),
titled_entry("b", "Update database migration scripts"),
titled_entry("c", "Refactor pricing service cache"),
Expand All @@ -1377,7 +1377,7 @@ mod tests {
fn cluster_label_prefers_most_frequent_then_shortest() {
// Two identical long titles + one shorter variant: frequency wins, so the
// repeated long title is the label even though a shorter one exists.
let entries = vec![
let entries = [
titled_entry("a", "Add JWT auth middleware"),
titled_entry("b", "Add JWT auth middleware"),
titled_entry("c", "JWT auth"),
Expand Down
116 changes: 82 additions & 34 deletions crates/tokscale-cli/src/commands/usage/amp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,13 @@ fn read_credentials() -> Result<String> {
/// Parse a dollar amount like "$4.50" or "$1,200.00" from text starting at the given prefix.
fn parse_dollar_after(text: &str, prefix: &str) -> Option<f64> {
let start = text.find(prefix)? + prefix.len();
let rest = &text[start..];
// `start` is a valid byte offset (end of a found substring), but slice it
// via `get` so arbitrary display_text can never panic on a bad boundary.
let rest = text.get(start..)?;
let end = rest
.find(|c: char| !c.is_ascii_digit() && c != '.' && c != ',')
.unwrap_or(rest.len());
let num_str = &rest[..end];
let num_str = rest.get(..end)?;
num_str.replace(',', "").parse().ok()
}

Expand All @@ -55,40 +57,48 @@ fn parse_display_text(text: &str) -> Vec<UsageMetric> {
// Parse free tier: "$X/$Y remaining"
// Look for pattern like "$4.50/$20.00 remaining"
if let Some(slash_pos) = text.find("/$") {
if let Some(dollar_before) = text[..slash_pos].rfind('$') {
let before = &text[dollar_before + 1..slash_pos];
if let Ok(remaining) = before.replace(',', "").parse::<f64>() {
// Find the total after /$
let after = &text[slash_pos + 2..];
if let Some(space_pos) = after.find(|c: char| c.is_ascii_whitespace()) {
if let Ok(total) = after[..space_pos].replace(',', "").parse::<f64>() {
if total > 0.0 && total.is_finite() && remaining.is_finite() {
let used = (total - remaining).max(0.0);
let used_pct = if used.is_finite() {
(used / total * 100.0).clamp(0.0, 100.0)
} else {
0.0
};
let remaining_pct = (100.0 - used_pct).clamp(0.0, 100.0);
let mut resets_at = None;

// Estimate reset time from hourly replenish rate
if let Some(rate) = parse_dollar_after(text, "+$") {
if rate > 0.0 && used > 0.0 && rate.is_finite() {
let secs = (used / rate * 3600.0) as i64;
let resets =
chrono::Utc::now() + chrono::Duration::seconds(secs);
resets_at = Some(resets.to_rfc3339());
if let Some(dollar_before) = text.get(..slash_pos).and_then(|s| s.rfind('$')) {
// All byte offsets below come from `find`/`rfind` on `text`, so they
// sit on char boundaries; `get` keeps it panic-free regardless.
if let Some(before) = text.get(dollar_before + 1..slash_pos) {
if let Ok(remaining) = before.replace(',', "").parse::<f64>() {
// Find the total after /$
if let Some(after) = text.get(slash_pos + 2..) {
// `space_pos` comes from `after.find`, so `after[..space_pos]`
// is always on a char boundary.
if let Some(space_pos) = after.find(|c: char| c.is_ascii_whitespace()) {
if let Ok(total) = after[..space_pos].replace(',', "").parse::<f64>() {
if total > 0.0 && total.is_finite() && remaining.is_finite() {
let used = (total - remaining).max(0.0);
let used_pct = if used.is_finite() {
(used / total * 100.0).clamp(0.0, 100.0)
} else {
0.0
};
let remaining_pct = (100.0 - used_pct).clamp(0.0, 100.0);
let mut resets_at = None;

// Estimate reset time from hourly replenish rate
if let Some(rate) = parse_dollar_after(text, "+$") {
if rate > 0.0 && used > 0.0 && rate.is_finite() {
let secs = (used / rate * 3600.0) as i64;
let resets = chrono::Utc::now()
+ chrono::Duration::seconds(secs);
resets_at = Some(resets.to_rfc3339());
}
}

metrics.push(UsageMetric {
label: "Free".into(),
used_percent: used_pct,
remaining_percent: remaining_pct,
remaining_label: Some(format!(
"${remaining:.2}/${total:.2}"
)),
resets_at,
});
}
}

metrics.push(UsageMetric {
label: "Free".into(),
used_percent: used_pct,
remaining_percent: remaining_pct,
remaining_label: Some(format!("${remaining:.2}/${total:.2}")),
resets_at,
});
}
}
}
Expand Down Expand Up @@ -164,6 +174,9 @@ pub fn fetch() -> Result<UsageOutput> {
let display_text = body.result.and_then(|r| r.display_text).unwrap_or_default();

let metrics = parse_display_text(&display_text);
if metrics.is_empty() {
anyhow::bail!("Amp returned no parseable usage (display_text format may have changed)");
}
let plan = detect_plan(&metrics);

Ok(UsageOutput {
Expand All @@ -178,3 +191,38 @@ pub fn fetch() -> Result<UsageOutput> {
})
})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parses_free_tier_balance() {
let metrics = parse_display_text("$4.50/$20.00 remaining");
assert_eq!(metrics.len(), 1);
assert_eq!(metrics[0].label, "Free");
// $4.50 remaining of $20.00 -> 77.5% used, 22.5% left.
assert!((metrics[0].used_percent - 77.5).abs() < 1e-9);
assert!((metrics[0].remaining_percent - 22.5).abs() < 1e-9);
}

#[test]
fn empty_display_text_yields_no_metrics() {
// fetch() bails on an empty metrics set so fetch_all drops the provider
// instead of rendering a bare header row.
assert!(parse_display_text("").is_empty());
assert!(parse_display_text("no dollar figures here").is_empty());
}

#[test]
fn multibyte_display_text_does_not_panic() {
// Byte offsets from find/rfind must stay on char boundaries; arbitrary
// UTF-8 around the markers must never panic.
let _ = parse_display_text("残高 $4.50/$20.00 残り 한국어");
let _ = parse_display_text("Individual credits: $5.00 残り");
let _ = parse_dollar_after("プレフィックス€$1.23é", "€$");
let _ = parse_dollar_after("+$0.50円/hr", "+$");
// Marker present but followed immediately by a multibyte char.
let _ = parse_dollar_after("/$é", "/$");
}
}
6 changes: 6 additions & 0 deletions crates/tokscale-cli/src/commands/usage/copilot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,12 @@ pub fn fetch() -> Result<UsageOutput> {
}
}

if metrics.is_empty() {
anyhow::bail!(
"Copilot returned no parseable usage (quota response format may have changed)"
);
}

Ok(UsageOutput {
provider: "Copilot".into(),
account: None,
Expand Down
10 changes: 6 additions & 4 deletions crates/tokscale-cli/src/commands/usage/kimi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,10 +256,11 @@ pub fn fetch() -> Result<UsageOutput> {
};
if let Some(metric) = parse_quota_detail(label, detail) {
let key = format!(
"{}:{}:{}",
"{}:{}:{}:{}",
label,
metric.used_percent,
metric.remaining_label.as_deref().unwrap_or("")
metric.remaining_label.as_deref().unwrap_or(""),
metric.resets_at.as_deref().unwrap_or("")
);
if seen.insert(key) {
metrics.push(metric);
Expand All @@ -273,10 +274,11 @@ pub fn fetch() -> Result<UsageOutput> {
if let Some(ref usage) = resp.usage {
if let Some(metric) = parse_quota_detail("Weekly", usage) {
let key = format!(
"{}:{}:{}",
"{}:{}:{}:{}",
"Weekly",
metric.used_percent,
metric.remaining_label.as_deref().unwrap_or("")
metric.remaining_label.as_deref().unwrap_or(""),
metric.resets_at.as_deref().unwrap_or("")
);
if seen.insert(key) {
metrics.push(metric);
Expand Down
7 changes: 6 additions & 1 deletion crates/tokscale-cli/src/commands/usage/minimax_tokenplan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ pub fn fetch_all() -> Result<Vec<UsageOutput>> {
}

let remains = resp.model_remains.as_deref().unwrap_or(&[]);
let metrics = build_metrics(remains);
// Skip sites with no renderable windows so we don't emit a bare header row.
if metrics.is_empty() {
continue;
}
outputs.push(UsageOutput {
provider: "MiniMax Token Plan".into(),
account: Some(UsageAccount {
Expand All @@ -199,7 +204,7 @@ pub fn fetch_all() -> Result<Vec<UsageOutput>> {
}),
plan: None,
email: None,
metrics: build_metrics(remains),
metrics,
reset_credits: None,
credit_status: None,
spend_control: None,
Expand Down
87 changes: 76 additions & 11 deletions crates/tokscale-cli/src/commands/usage/warp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,12 @@ pub fn has_credentials() -> bool {
crate::warp::load_usage_cache().is_some()
}

pub fn fetch() -> Result<UsageOutput> {
let cache = crate::warp::load_usage_cache()
.ok_or_else(|| anyhow::anyhow!("Warp aggregate usage cache not found"))?;
fn build_metrics(usage: &crate::warp::WarpAggregateUsage) -> Vec<UsageMetric> {
let mut metrics = Vec::new();

if let Some(used) = cache.usage.requests_used {
if let Some(used) = usage.requests_used {
let (used_percent, remaining_percent, remaining_label) =
if let Some(limit) = cache.usage.request_limit.filter(|limit| *limit > 0) {
if let Some(limit) = usage.request_limit.filter(|limit| *limit > 0) {
let used_percent = (used as f64 / limit as f64 * 100.0).clamp(0.0, 100.0);
let remaining = limit.saturating_sub(used);
(
Expand All @@ -21,35 +19,102 @@ pub fn fetch() -> Result<UsageOutput> {
Some(format!("{remaining} requests left")),
)
} else {
(0.0, 0.0, Some(format!("{used} requests used")))
// No request limit: this is an informational counter, not a
// capped quota. Render the bar as full (100% remaining) rather
// than an empty "exhausted" bar.
(0.0, 100.0, Some(format!("{used} requests used")))
};
metrics.push(UsageMetric {
label: "Requests".to_string(),
used_percent,
remaining_percent,
remaining_label,
resets_at: cache.usage.next_refresh_time.clone(),
resets_at: usage.next_refresh_time.clone(),
});
}

if let Some(spend_cents) = cache.usage.spend_cents {
if let Some(spend_cents) = usage.spend_cents {
metrics.push(UsageMetric {
label: "Spend".to_string(),
used_percent: 0.0,
remaining_percent: 0.0,
// Spend is an informational dollar figure, not a consumed quota, so
// keep the bar full instead of rendering a false "exhausted" bar.
remaining_percent: 100.0,
remaining_label: Some(format!("${:.2}", spend_cents as f64 / 100.0)),
resets_at: cache.usage.next_refresh_time.clone(),
resets_at: usage.next_refresh_time.clone(),
});
}

metrics
}

pub fn fetch() -> Result<UsageOutput> {
let cache = crate::warp::load_usage_cache()
.ok_or_else(|| anyhow::anyhow!("Warp aggregate usage cache not found"))?;
let metrics = build_metrics(&cache.usage);

Ok(UsageOutput {
provider: "Warp/Oz".to_string(),
account: None,
plan: Some("Aggregate API cache".to_string()),
plan: None,
email: None,
metrics,
reset_credits: None,
credit_status: None,
spend_control: None,
})
}

#[cfg(test)]
mod tests {
use super::*;
use crate::warp::WarpAggregateUsage;

#[test]
fn spend_metric_reads_full_not_exhausted() {
let usage = WarpAggregateUsage {
spend_cents: Some(1234),
..Default::default()
};
let metrics = build_metrics(&usage);
let spend = metrics.iter().find(|m| m.label == "Spend").unwrap();
// Informational $ figure: bar must read full, not a false 0%-remaining.
assert_eq!(spend.remaining_percent, 100.0);
assert_eq!(spend.used_percent, 0.0);
assert_eq!(spend.remaining_label.as_deref(), Some("$12.34"));
}

#[test]
fn unlimited_requests_read_full_not_exhausted() {
let usage = WarpAggregateUsage {
requests_used: Some(42),
request_limit: None,
..Default::default()
};
let metrics = build_metrics(&usage);
let requests = metrics.iter().find(|m| m.label == "Requests").unwrap();
assert_eq!(requests.remaining_percent, 100.0);
assert_eq!(requests.used_percent, 0.0);
assert_eq!(
requests.remaining_label.as_deref(),
Some("42 requests used")
);
}

#[test]
fn limited_requests_compute_usage() {
let usage = WarpAggregateUsage {
requests_used: Some(25),
request_limit: Some(100),
..Default::default()
};
let metrics = build_metrics(&usage);
let requests = metrics.iter().find(|m| m.label == "Requests").unwrap();
assert_eq!(requests.used_percent, 25.0);
assert_eq!(requests.remaining_percent, 75.0);
assert_eq!(
requests.remaining_label.as_deref(),
Some("75 requests left")
);
}
}
7 changes: 6 additions & 1 deletion crates/tokscale-cli/src/commands/usage/zai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,12 @@ pub fn fetch() -> Result<UsageOutput> {

if let Some(limits) = quota.data.as_ref().and_then(|d| d.limits.as_ref()) {
for limit in limits.iter() {
let pct = limit.percentage.unwrap_or(0.0).clamp(0.0, 100.0);
// Skip limits with no percentage rather than fabricating
// "0% used / 100% left" from a missing field.
let pct = match limit.percentage {
Some(p) => p.clamp(0.0, 100.0),
None => continue,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Drop Z.ai output when all quota rows are skipped

When Z.ai returns limits that omit percentage (the exact case handled here), every row can be skipped but fetch() still returns Ok(UsageOutput { metrics: vec![] }), so fetch_all() includes it and the renderer shows a bare Z.ai card instead of failing soft like the Amp/Copilot/MiniMax guards added in this commit. Add an empty-metrics bail after assembling metrics (or compute from other fields) so unparseable quota responses are dropped rather than rendered as an empty provider.

Useful? React with 👍 / 👎.

};

match limit.limit_type.as_deref() {
Some("TOKENS_LIMIT") => {
Expand Down
6 changes: 4 additions & 2 deletions crates/tokscale-cli/src/tui/data/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2456,7 +2456,7 @@ after"#,
let expected_cost = expected_message_cost(
&pricing,
"accounts/fireworks/models/deepseek-v3-0324",
"fireworks",
"fireworks_ai",
CoreTokenBreakdown {
input: 10,
output: 5,
Expand All @@ -2468,7 +2468,9 @@ after"#,

assert_eq!(usage.models.len(), 1);
assert_eq!(usage.models[0].client, "opencode");
assert_eq!(usage.models[0].provider, "fireworks");
// opencode now canonicalizes the provider (fireworks -> fireworks_ai),
// matching every other session parser.
assert_eq!(usage.models[0].provider, "fireworks_ai");
assert_eq!(usage.models[0].model, "deepseek-v3-0324");
assert_eq!(usage.models[0].tokens.total(), 15);
assert_cost_matches(usage.models[0].cost, expected_cost);
Expand Down
Loading
Loading