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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ menu-bar layout.
- Grok: Settings and tray **Add account** flow matching Codex/Claude — isolated `grok login --oauth`, save current CLI login, switch, and remove without logging out the active session.
- Replicate: cookie-authenticated monthly spend and optional prepaid credit balance from the billing page, with user and organization account isolation.
- DeepSeek: show reported per-model spend in the provider details while preserving the billing currency, reporting period, zero values, and incomplete-total safeguards.
- CLI: show provider-supplied history in usage text with its source period, known zero, and cost provenance (reported, estimated, or includes estimates), plus input-plus-output token totals for OpenRouter Activity.

### Fixed
- Claude: when Hide Personal Info is enabled, keep saved account rows distinguishable with stable localized `Account N` labels and matching redacted tooltips.
Expand Down
57 changes: 53 additions & 4 deletions rust/src/cli/usage/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::core::{
CostSnapshot, ProviderDisplayDetail, ProviderFetchResult, ProviderId, ProviderInventoryItem,
RateWindow, UsagePace, UsageSnapshot, instantiate_provider,
};
use crate::spend_contract::CostProvenance;
use crate::status::{ProviderStatus as StatusInfo, StatusLevel};

pub fn render_text_error(provider_id: ProviderId, error_msg: &str, use_color: bool) -> String {
Expand Down Expand Up @@ -427,11 +428,12 @@ fn append_cost_line(lines: &mut Vec<String>, cost: Option<&CostSnapshot>) {

// Provider-supplied Activity history is a completed reporting window,
// rather than the ordinary current-cost meter. Providers mark such
// snapshots `always_visible`; keep their source period and known zero
// visible in text output without adding a second generic cost line. The
// daily points remain available in the JSON cost payload.
// snapshots `always_visible`; keep their source period, known zero, token
// total and cost provenance visible in text output without adding a second
// generic cost line or recomputing totals from the daily points. The daily
// points remain available in the JSON cost payload.
if cost.limit.is_none() && cost.always_visible {
lines.push(format!(" {}: {}", cost.period, cost.format_used()));
lines.push(format_history_line(cost));
return;
}

Expand All @@ -451,6 +453,53 @@ fn append_cost_line(lines: &mut Vec<String>, cost: Option<&CostSnapshot>) {
}
}

/// One provider-history line: `<period>: <spend> (<provenance>) · <tokens> tokens`.
///
/// Mirrors upstream `CLIRenderer.liveHistoryLine` (0.61.0). The spend and token
/// totals are the provider's own; nothing is summed from daily points.
fn format_history_line(cost: &CostSnapshot) -> String {
let spend = cost.format_used();
let spend = match cost.provenance {
Some(CostProvenance::VendorMetered) => format!("{spend} (reported)"),
Some(CostProvenance::ListPriceEstimate) => format!("{spend} (estimated)"),
Some(CostProvenance::Mixed) => format!("{spend} (includes estimates)"),
Some(CostProvenance::Unknown) | None => spend,
};
let mut values = vec![spend];
if let Some(tokens) = cost.history_tokens {
let unit = if tokens == 1 { "token" } else { "tokens" };
values.push(format!("{} {unit}", format_token_count(tokens)));
}
format!(" {}: {}", cost.period, values.join(" · "))
}

/// Compact token count: `999`, `1.2K`, `15K`, `2.5M`, `1B`. A unit is promoted
/// once the lower unit would round to 1000 (upstream `tokenCountString`).
fn format_token_count(tokens: u64) -> String {
const UNITS: [(u64, f64, &str); 3] = [
(999_500_000, 1_000_000_000.0, "B"),
(999_500, 1_000_000.0, "M"),
(1_000, 1_000.0, "K"),
];
for (threshold, divisor, suffix) in UNITS {
if tokens >= threshold {
#[expect(
clippy::cast_precision_loss,
reason = "display rounding to at most two significant digits"
)]
let scaled = tokens as f64 / divisor;
let formatted = if scaled >= 10.0 {
format!("{scaled:.0}")
} else {
let one = format!("{scaled:.1}");
one.strip_suffix(".0").unwrap_or(&one).to_string()
};
return format!("{formatted}{suffix}");
}
}
tokens.to_string()
}

/// Render usage as text (backwards compatible version)
pub fn render_text(provider: ProviderId, result: &ProviderFetchResult, use_color: bool) -> String {
render_text_with_status(provider, result, None, use_color)
Expand Down
92 changes: 92 additions & 0 deletions rust/src/cli/usage_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,3 +324,95 @@ fn json_inventory_is_additive_and_contains_no_redemption_token() {
.contains("coupon-token-secret")
);
}

fn history_output(cost: CostSnapshot) -> String {
let result = fetch_result(UsageSnapshot::new(RateWindow::new(0.0))).with_cost(cost);
render_text_with_status(ProviderId::OpenRouter, &result, None, false)
}

#[test]
fn history_line_shows_provenance_and_token_total() {
use crate::spend_contract::CostProvenance;

let cases = [
(CostProvenance::VendorMetered, "$1.25 (reported)"),
(CostProvenance::ListPriceEstimate, "$1.25 (estimated)"),
(CostProvenance::Mixed, "$1.25 (includes estimates)"),
(CostProvenance::Unknown, "$1.25"),
];
for (provenance, spend) in cases {
let output = history_output(
CostSnapshot::new(1.25, "USD", "Last 30 days (UTC)")
.with_history_tokens(15)
.with_provenance(provenance)
.always_visible(),
);
let expected = format!(" Last 30 days (UTC): {spend} · 15 tokens");
assert_eq!(output.matches(&expected).count(), 1, "{output}");
}
}

#[test]
fn history_line_preserves_known_zero_singular_token_and_currency() {
use crate::spend_contract::CostProvenance;

let zero = history_output(
CostSnapshot::new(0.0, "USD", "Last 30 days (UTC)")
.with_history_tokens(0)
.with_provenance(CostProvenance::VendorMetered)
.always_visible(),
);
assert!(zero.contains("Last 30 days (UTC): $0.00 (reported) · 0 tokens"));

let one = history_output(
CostSnapshot::new(0.0, "USD", "Last 1 day")
.with_history_tokens(1)
.always_visible(),
);
assert!(one.ends_with("Last 1 day: $0.00 · 1 token"), "{one}");

let euro = history_output(CostSnapshot::new(2.5, "EUR", "Billing period").always_visible());
assert!(euro.contains("Billing period: €2.50"));
assert!(!euro.contains("token"));
}

#[test]
fn history_line_compacts_large_token_totals() {
let cases = [
(999, "999 tokens"),
(1_000, "1K tokens"),
(1_250, "1.2K tokens"),
(15_400, "15K tokens"),
(999_499, "999K tokens"),
(999_500, "1M tokens"),
(2_500_000, "2.5M tokens"),
(999_500_000, "1B tokens"),
];
for (tokens, expected) in cases {
let output = history_output(
CostSnapshot::new(1.0, "USD", "Last 30 days (UTC)")
.with_history_tokens(tokens)
.always_visible(),
);
assert!(
output.contains(&format!("$1.00 · {expected}")),
"{tokens}: {output}"
);
}
}

#[test]
fn history_fields_do_not_change_the_cost_json_contract() {
use crate::spend_contract::CostProvenance;

let result = fetch_result(UsageSnapshot::new(RateWindow::new(0.0))).with_cost(
CostSnapshot::new(1.25, "USD", "Last 30 days (UTC)")
.with_history_tokens(15)
.with_provenance(CostProvenance::VendorMetered)
.always_visible(),
);
let json = render_json_result(ProviderId::OpenRouter, result, None);
let cost = json.get("cost").and_then(|cost| cost.as_object()).unwrap();
assert!(!cost.contains_key("historyTokens") && !cost.contains_key("history_tokens"));
assert!(!cost.contains_key("provenance"));
}
26 changes: 26 additions & 0 deletions rust/src/core/usage_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};

use super::ProviderDisplayDetail;
use super::RateWindow;
use crate::spend_contract::CostProvenance;
/// Subscription dates explicitly reported by an authenticated provider
/// dashboard or subscription endpoint.
///
Expand Down Expand Up @@ -414,6 +415,17 @@ pub struct CostSnapshot {
/// usage signal and must remain visible when optional local summaries are hidden.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub always_visible: bool,

/// Input-plus-output token total for the same reporting window as `used`,
/// when the provider supplies one. Text rendering only: it is not part of
/// the serialized cost payload, which keeps the ordinary usage JSON contract.
#[serde(skip)]
pub history_tokens: Option<u64>,

/// How the `used` figure was produced, when the provider states it. Text
/// rendering only; not serialized.
#[serde(skip)]
pub provenance: Option<CostProvenance>,
}

impl CostSnapshot {
Expand All @@ -432,6 +444,8 @@ impl CostSnapshot {
account_id: None,
daily: Vec::new(),
always_visible: false,
history_tokens: None,
provenance: None,
}
}

Expand Down Expand Up @@ -541,6 +555,18 @@ impl CostSnapshot {
self
}

/// Attach the provider-supplied token total for this spend window.
pub fn with_history_tokens(mut self, tokens: u64) -> Self {
self.history_tokens = Some(tokens);
self
}

/// Record how the spend figure was produced (reported, estimated, mixed).
pub fn with_provenance(mut self, provenance: CostProvenance) -> Self {
self.provenance = Some(provenance);
self
}

/// Builder pattern: set currency symbol for localized rendering.
pub fn with_currency_symbol(mut self, symbol: impl Into<String>) -> Self {
self.currency_symbol = Some(symbol.into());
Expand Down
62 changes: 61 additions & 1 deletion rust/src/providers/openrouter/activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use chrono::{DateTime, Duration, Utc};
use serde_json::Value;

use crate::core::{CostDailyPoint, CostSnapshot, ProviderError};
use crate::spend_contract::CostProvenance;

const MAX_ACTIVITY_ROWS: usize = 20_000;
/// Distinct identity rows tracked for dedupe; bounds the `seen` map.
Expand All @@ -19,6 +20,8 @@ pub(super) fn parse_activity_cost(
let mut seen: HashMap<String, String> = HashMap::new();
let mut daily: BTreeMap<String, f64> = BTreeMap::new();
let mut total = 0.0;
let mut estimated_total = 0.0;
let mut tokens = 0u64;
let mut rows_seen = 0usize;

for payload in payloads {
Expand Down Expand Up @@ -139,16 +142,32 @@ pub(super) fn parse_activity_cost(
));
}
total += cost;
estimated_total += estimated;
// Prompt plus completion only: reasoning tokens are a separate counter.
tokens = tokens.saturating_add(prompt + completion);
*daily.entry(day.to_string()).or_default() += cost;
}
}

if !total.is_finite() {
if !total.is_finite() || !estimated_total.is_finite() {
return Err(ProviderError::Parse(
"OpenRouter Activity spend overflowed".into(),
));
}
// Matches upstream's plugin snapshot mapper: any BYOK estimate makes the
// window estimated, or mixed when metered spend is also present.
let provenance = if estimated_total > 0.0 {
if total - estimated_total > 0.0 {
CostProvenance::Mixed
} else {
CostProvenance::ListPriceEstimate
}
} else {
CostProvenance::VendorMetered
};
Ok(CostSnapshot::new(total, "USD", "Last 30 days (UTC)")
.with_history_tokens(tokens)
.with_provenance(provenance)
.with_daily(
daily
.into_iter()
Expand Down Expand Up @@ -314,4 +333,45 @@ mod tests {

assert!(error.to_string().contains("completed UTC day"));
}

#[test]
fn records_token_total_and_cost_provenance() {
use crate::spend_contract::CostProvenance;

let cases = [
(1.25, 0.0, CostProvenance::VendorMetered),
(0.0, 0.75, CostProvenance::ListPriceEstimate),
(1.25, 0.75, CostProvenance::Mixed),
];
for (usage, byok, expected) in cases {
let history = serde_json::json!({"data":[
{"date":"2026-08-17","model":"m","prompt_tokens":10,"completion_tokens":5,
"reasoning_tokens":2,"requests":1,"usage":usage,"byok_usage_inference":byok}
]});
let cost = parse_activity_cost(&[history], now()).unwrap();
assert_eq!(cost.history_tokens, Some(15));
assert_eq!(cost.provenance, Some(expected));
assert_eq!(cost.used, usage + byok);
}
}

#[test]
fn empty_activity_is_a_reported_zero_with_zero_tokens() {
use crate::spend_contract::CostProvenance;

let cost = parse_activity_cost(&[serde_json::json!({"data":[]})], now()).unwrap();
assert_eq!(cost.used, 0.0);
assert_eq!(cost.history_tokens, Some(0));
assert_eq!(cost.provenance, Some(CostProvenance::VendorMetered));
}

#[test]
fn duplicate_rows_do_not_double_count_tokens() {
let row = serde_json::json!({"date":"2026-08-21","model":"m","prompt_tokens":10,
"completion_tokens":5,"requests":1,"usage":1.0});
let history = serde_json::json!({"data":[row.clone()]});
let latest = serde_json::json!({"data":[row]});
let cost = parse_activity_cost(&[history, latest], now()).unwrap();
assert_eq!(cost.history_tokens, Some(15));
}
}