diff --git a/rust/src/cli/usage.rs b/rust/src/cli/usage.rs index e0c19618d9..6022b779d1 100755 --- a/rust/src/cli/usage.rs +++ b/rust/src/cli/usage.rs @@ -687,6 +687,15 @@ fn append_cost_line(lines: &mut Vec, cost: Option<&CostSnapshot>) { return; }; + // Provider-supplied OpenRouter Activity is a completed reporting window, + // rather than the ordinary current-cost meter. Keep its 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. + if cost.limit.is_none() && cost.period == "Last 30 days (UTC)" { + lines.push(format!(" {}: {}", cost.period, cost.format_used())); + return; + } + if let Some(limit) = cost.format_limit() { lines.push(format!( " Cost: {} / {} ({})", @@ -972,4 +981,31 @@ mod tests { assert!(output.contains("Plan: Gemini Code Assist in Google One AI Pro")); assert!(!output.contains("Google One Ai Pro")); } + + #[test] + fn openrouter_history_preserves_period_and_known_zero_in_text() { + let result = fetch_result(UsageSnapshot::new(RateWindow::new(0.0))) + .with_cost(CostSnapshot::new(0.0, "USD", "Last 30 days (UTC)")); + + let output = render_text_with_status(ProviderId::OpenRouter, &result, None, false); + + assert!(output.contains("Last 30 days (UTC): $0.00")); + assert!(!output.contains("Cost: $0.00")); + + let json = render_json_result(ProviderId::OpenRouter, result, None); + assert!(json.get("usage").is_some()); + assert!(json.get("cost").is_some()); + assert!(json.get("history").is_none()); + } + + #[test] + fn ordinary_costs_keep_the_existing_cost_line() { + let result = fetch_result(UsageSnapshot::new(RateWindow::new(0.0))) + .with_cost(CostSnapshot::new(2.5, "EUR", "This month (API key)")); + + let output = render_text_with_status(ProviderId::OpenRouter, &result, None, false); + + assert!(output.contains("Cost: €2.50 (This month (API key))")); + assert!(!output.contains("Last 30 days")); + } } diff --git a/rust/src/providers/openrouter/activity.rs b/rust/src/providers/openrouter/activity.rs index 4f8b1dc867..1a3f6e3353 100644 --- a/rust/src/providers/openrouter/activity.rs +++ b/rust/src/providers/openrouter/activity.rs @@ -6,6 +6,7 @@ use serde_json::Value; use crate::core::{CostDailyPoint, CostSnapshot, ProviderError}; const MAX_ACTIVITY_ROWS: usize = 20_000; +const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; pub(super) fn parse_activity_cost( payloads: &[Value], @@ -57,7 +58,12 @@ pub(super) fn parse_activity_cost( "OpenRouter activity.data[{index}].date must be a real calendar date" )) })?; - if parsed_day > latest_completed || parsed_day < cutoff { + if parsed_day > latest_completed { + return Err(ProviderError::Parse(format!( + "OpenRouter activity.data[{index}].date must be a completed UTC day" + ))); + } + if parsed_day < cutoff { continue; } let model = object @@ -78,9 +84,12 @@ pub(super) fn parse_activity_cost( Some(Value::Null) | None => 0, value => nonnegative_integer(value, index, "reasoning_tokens")?, }; - if reasoning > completion { + if prompt + .checked_add(completion) + .is_none_or(|total| total > MAX_SAFE_INTEGER) + { return Err(ProviderError::Parse(format!( - "OpenRouter activity.data[{index}].reasoning_tokens exceeds completion_tokens" + "OpenRouter activity.data[{index}] token total overflowed" ))); } let requests = nonnegative_integer(object.get("requests"), index, "requests")?; @@ -122,6 +131,11 @@ pub(super) fn parse_activity_cost( continue; } seen.insert(identity, signature); + if seen.len() > 10_000 { + return Err(ProviderError::Parse( + "OpenRouter activity.data exceeds 10000 distinct rows".into(), + )); + } total += cost; *daily.entry(day.to_string()).or_default() += cost; } @@ -178,11 +192,17 @@ fn nonnegative_integer( "OpenRouter activity.data[{index}].{field} is missing" )) })?; - value.as_u64().ok_or_else(|| { + let value = value.as_u64().ok_or_else(|| { ProviderError::Parse(format!( "OpenRouter activity.data[{index}].{field} must be a nonnegative integer" )) - }) + })?; + if value > MAX_SAFE_INTEGER { + return Err(ProviderError::Parse(format!( + "OpenRouter activity.data[{index}].{field} must be a nonnegative safe integer" + ))); + } + Ok(value) } fn nonnegative_number( @@ -225,6 +245,19 @@ mod tests { assert_eq!(cost.period, "Last 30 days (UTC)"); } + #[test] + fn preserves_reasoning_tokens_when_they_exceed_completion_tokens() { + let payload = serde_json::json!({"data":[ + {"date":"2026-08-21","model":"reasoning-model","prompt_tokens":10, + "completion_tokens":2,"reasoning_tokens":8,"requests":1,"usage":1.0} + ]}); + + let cost = parse_activity_cost(&[payload], now()).unwrap(); + + assert_eq!(cost.used, 1.0); + assert_eq!(cost.daily.len(), 1); + } + #[test] fn rejects_conflicting_duplicate_activity_rows() { let a = serde_json::json!({"data":[ @@ -240,8 +273,7 @@ mod tests { fn filters_rows_outside_exact_30_day_window() { let payload = serde_json::json!({"data":[ {"date":"2026-07-22","model":"old","prompt_tokens":10,"completion_tokens":5,"requests":1,"usage":99.0}, - {"date":"2026-07-23","model":"in","prompt_tokens":10,"completion_tokens":5,"requests":1,"usage":1.0}, - {"date":"2026-08-22","model":"today","prompt_tokens":10,"completion_tokens":5,"requests":1,"usage":99.0} + {"date":"2026-07-23","model":"in","prompt_tokens":10,"completion_tokens":5,"requests":1,"usage":1.0} ]}); let cost = parse_activity_cost(&[payload], now()).unwrap(); assert_eq!(cost.used, 1.0); @@ -268,4 +300,16 @@ mod tests { assert!(parse_activity_cost(&[payload], now()).is_err()); } } + + #[test] + fn rejects_activity_rows_from_an_incomplete_utc_day() { + let payload = serde_json::json!({"data":[ + {"date":"2026-08-22","model":"today","prompt_tokens":10, + "completion_tokens":5,"requests":1,"usage":1.0} + ]}); + + let error = parse_activity_cost(&[payload], now()).unwrap_err(); + + assert!(error.to_string().contains("completed UTC day")); + } } diff --git a/rust/src/providers/openrouter/mod.rs b/rust/src/providers/openrouter/mod.rs index 7accc49f52..7399817931 100755 --- a/rust/src/providers/openrouter/mod.rs +++ b/rust/src/providers/openrouter/mod.rs @@ -34,12 +34,12 @@ const OPENROUTER_MANAGEMENT_ENV: &str = "OPENROUTER_MANAGEMENT_API_KEY"; const OPENROUTER_CREDENTIAL_TARGET: &str = "codexbar-openrouter"; /// OpenRouter /credits response -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] struct CreditsResponse { data: CreditsData, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] struct CreditsData { total_credits: f64, total_usage: f64, @@ -57,15 +57,29 @@ impl CreditsData { 0.0 } } + + fn validate(&self) -> Result<(), ProviderError> { + for (field, value) in [ + ("total_credits", self.total_credits), + ("total_usage", self.total_usage), + ] { + if !value.is_finite() { + return Err(ProviderError::Parse(format!( + "OpenRouter credits.{field} must be a finite number" + ))); + } + } + Ok(()) + } } /// OpenRouter /key response -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] struct KeyResponse { data: KeyData, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] struct KeyData { limit: Option, /// Server-reported current-period remaining for the key limit @@ -78,13 +92,27 @@ struct KeyData { usage_daily: Option, usage_weekly: Option, usage_monthly: Option, - rate_limit: Option, + is_management_key: Option, } -#[derive(Debug, Deserialize)] -struct RateLimitInfo { - requests: Option, - interval: Option, +impl KeyData { + fn validate(&self) -> Result<(), ProviderError> { + for (field, value) in [ + ("limit", self.limit), + ("limit_remaining", self.limit_remaining), + ("usage", self.usage), + ("usage_daily", self.usage_daily), + ("usage_weekly", self.usage_weekly), + ("usage_monthly", self.usage_monthly), + ] { + if value.is_some_and(|value| !value.is_finite()) { + return Err(ProviderError::Parse(format!( + "OpenRouter key.{field} must be a finite number" + ))); + } + } + Ok(()) + } } /// OpenRouter provider @@ -153,6 +181,39 @@ impl OpenRouterProvider { } } + fn configured_management_key() -> Option { + crate::settings::Settings::load() + .management_api_token(ProviderId::OpenRouter) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| { + std::env::var(OPENROUTER_MANAGEMENT_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }) + } + + fn is_official_api_base(base: &str) -> bool { + matches!( + base, + "https://openrouter.ai/api/v1" | "https://openrouter.ai:443/api/v1" + ) + } + + fn degradation_reason(error: &ProviderError) -> &'static str { + match error { + ProviderError::Parse(_) => "Response was invalid", + ProviderError::Network(error) if error.is_timeout() => "Request timed out", + ProviderError::Timeout => "Request timed out", + ProviderError::Other(message) if message.contains("HTTP") => { + "Request returned an HTTP error" + } + _ => "Request failed", + } + } + /// Fetch usage from OpenRouter API. Management Activity spend is optional /// enrichment: a missing/denied management key never discards credits/quota. async fn fetch_usage_api( @@ -169,29 +230,52 @@ impl OpenRouterProvider { Self::fetch_credits(&client, &api_key), Self::fetch_key_data(&api_key), ); - let usage = Self::resolve_usage(credits_result, key_data_result?)?; - - let management_key = crate::settings::Settings::load() - .management_api_token(ProviderId::OpenRouter) - .map(str::to_string) - .or_else(|| { - std::env::var(OPENROUTER_MANAGEMENT_ENV) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - }); - let cost = match management_key.as_deref() { + if let Err(error) = &credits_result { + tracing::debug!( + reason = Self::degradation_reason(error), + error = %error, + "OpenRouter credits endpoint degraded" + ); + } + let key_data = match key_data_result { + Ok(key_data) => Some(key_data), + Err(error) => { + tracing::debug!( + reason = Self::degradation_reason(&error), + error = %error, + "OpenRouter key endpoint degraded; preserving independent credits data" + ); + None + } + }; + let fallback_cost = + Self::build_uncapped_cost(key_data.as_ref(), credits_result.as_ref().ok()); + let usage = Self::resolve_usage(credits_result, key_data.clone())?; + + let management_key = Self::configured_management_key(); + let primary_management_key = Self::is_official_api_base(OPENROUTER_API_BASE) + && key_data + .as_ref() + .is_some_and(|key_data| key_data.is_management_key == Some(true)); + let activity_key = management_key + .as_deref() + .or_else(|| primary_management_key.then_some(api_key.as_str())); + let activity_cost = match activity_key { Some(key) => match Self::fetch_activity_cost(key).await { Ok(cost) => Some(cost), Err(error) => { - tracing::debug!(%error, "OpenRouter management Activity degraded; preserving credits/quota"); + tracing::debug!( + reason = Self::degradation_reason(&error), + error = %error, + "OpenRouter management Activity degraded; preserving credits/quota" + ); None } }, None => None, }; - Ok((usage, cost)) + Ok((usage, Self::select_cost(activity_cost, fallback_cost))) } async fn fetch_activity_cost(management_key: &str) -> Result { @@ -200,9 +284,12 @@ impl OpenRouterProvider { let latest_completed = (now.date_naive() - chrono::Duration::days(1)) .format("%Y-%m-%d") .to_string(); - let history = Self::fetch_activity_payload(&client, management_key, None).await?; - let latest_completed_payload = - Self::fetch_activity_payload(&client, management_key, Some(&latest_completed)).await?; + let (history_result, latest_completed_result) = tokio::join!( + Self::fetch_activity_payload(&client, management_key, None), + Self::fetch_activity_payload(&client, management_key, Some(&latest_completed)), + ); + let history = history_result?; + let latest_completed_payload = latest_completed_result?; activity::parse_activity_cost(&[history, latest_completed_payload], now) } @@ -226,7 +313,7 @@ impl OpenRouterProvider { } if !response.status().is_success() { return Err(ProviderError::Other(format!( - "OpenRouter Activity returned status {}", + "OpenRouter Activity request returned HTTP {}", response.status() ))); } @@ -260,14 +347,16 @@ impl OpenRouterProvider { if !resp.status().is_success() { return Err(ProviderError::Other(format!( - "OpenRouter API returned status {}", + "OpenRouter credits request returned HTTP {}", resp.status() ))); } - resp.json() - .await - .map_err(|e| ProviderError::Parse(format!("Failed to parse credits response: {}", e))) + let response = resp.json::().await.map_err(|error| { + ProviderError::Parse(format!("OpenRouter credits response was invalid: {error}")) + })?; + response.data.validate()?; + Ok(response) } fn build_credits_usage(credits: &CreditsData) -> UsageSnapshot { @@ -278,6 +367,45 @@ impl OpenRouterProvider { UsageSnapshot::new(primary).with_login_method(format!("${:.2} balance", balance)) } + fn build_uncapped_cost( + key_data: Option<&KeyData>, + credits: Option<&CreditsResponse>, + ) -> Option { + if key_data.is_some_and(|key_data| key_data.is_management_key == Some(true)) { + return None; + } + if key_data + .and_then(|key_data| key_data.limit) + .is_some_and(|limit| limit > 0.0) + { + return None; + } + + let monthly = key_data.and_then(|key_data| key_data.usage_monthly); + let key_usage = key_data.and_then(|key_data| key_data.usage); + let (used, period) = if let Some(monthly) = monthly { + (monthly, "This month (API key)") + } else if let Some(key_usage) = key_usage { + (key_usage, "Total key usage") + } else { + let credits = credits?; + (credits.data.total_usage, "Total account usage") + }; + + let mut cost = CostSnapshot::new(used.max(0.0), "USD", period); + if let Some(credits) = credits { + cost = cost.with_balance(credits.data.balance()); + } + Some(cost) + } + + fn select_cost( + activity_cost: Option, + fallback_cost: Option, + ) -> Option { + activity_cost.or(fallback_cost) + } + fn resolve_usage( credits_result: Result, key_data: Option, @@ -313,35 +441,27 @@ impl OpenRouterProvider { Some(usage) } - async fn fetch_key_data(api_key: &str) -> Result, ProviderError> { + async fn fetch_key_data(api_key: &str) -> Result { let key_client = Self::build_client(OPENROUTER_KEY_TIMEOUT)?; - let key_resp = match Self::send_key_request(&key_client, api_key).await { - Ok(resp) => resp, - // Upstream 0.49.0 #2778: make the degraded fast join explicit — - // core usage stays authoritative, only the optional key meter is - // dropped. - Err(err) => { - tracing::debug!( - error = %err, - "OpenRouter key-quota fast join degraded; continuing without key meter" - ); - return Ok(None); - } - }; + let key_resp = Self::send_key_request(&key_client, api_key).await?; + if key_resp.status() == reqwest::StatusCode::UNAUTHORIZED + || key_resp.status() == reqwest::StatusCode::FORBIDDEN + { + return Err(ProviderError::AuthRequired); + } if !key_resp.status().is_success() { - tracing::debug!( - status = %key_resp.status(), - "OpenRouter key-quota fast join degraded; continuing without key meter" - ); - return Ok(None); + return Err(ProviderError::Other(format!( + "OpenRouter key request returned HTTP {}", + key_resp.status() + ))); } - Ok(key_resp - .json::() - .await - .map(|key_response| key_response.data) - .ok()) + let response = key_resp.json::().await.map_err(|error| { + ProviderError::Parse(format!("OpenRouter key response was invalid: {error}")) + })?; + response.data.validate()?; + Ok(response.data) } async fn send_key_request( @@ -537,6 +657,34 @@ mod tests { ); } + #[test] + fn deprecated_rate_limit_metadata_is_ignored() { + let response: KeyResponse = serde_json::from_value(serde_json::json!({ + "data": { + "rate_limit": "deprecated", + "is_management_key": true, + "usage": 0.0 + } + })) + .expect("deprecated rate_limit must not invalidate /key"); + + assert_eq!(response.data.is_management_key, Some(true)); + assert_eq!(response.data.usage, Some(0.0)); + } + + #[test] + fn primary_management_key_is_restricted_to_the_official_api() { + assert!(OpenRouterProvider::is_official_api_base( + "https://openrouter.ai/api/v1" + )); + assert!(OpenRouterProvider::is_official_api_base( + "https://openrouter.ai:443/api/v1" + )); + assert!(!OpenRouterProvider::is_official_api_base( + "https://proxy.example.test/api/v1" + )); + } + // ── F14: server-reported current-period remaining drives the key meter ── fn key_data( @@ -556,7 +704,7 @@ mod tests { usage_daily: daily, usage_weekly: weekly, usage_monthly: monthly, - rate_limit: None, + is_management_key: None, } } @@ -675,6 +823,85 @@ mod tests { assert_eq!(recovered.login_method.as_deref(), Some("$15.00 balance")); } + #[test] + fn uncapped_cost_prefers_monthly_key_usage_and_keeps_balance() { + let credits = CreditsResponse { + data: CreditsData { + total_credits: 20.0, + total_usage: 7.0, + }, + }; + let key = key_data(Some(0.0), None, None, Some(5.0), None, None, Some(3.5)); + + let cost = OpenRouterProvider::build_uncapped_cost(Some(&key), Some(&credits)) + .expect("uncapped key should expose spend"); + + assert_eq!(cost.used, 3.5); + assert_eq!(cost.period, "This month (API key)"); + assert_eq!(cost.balance, Some(13.0)); + } + + #[test] + fn capped_and_management_keys_do_not_create_payg_costs() { + let credits = CreditsResponse { + data: CreditsData { + total_credits: 20.0, + total_usage: 7.0, + }, + }; + let capped = key_data(Some(10.0), None, None, Some(5.0), None, None, Some(3.5)); + assert!(OpenRouterProvider::build_uncapped_cost(Some(&capped), Some(&credits)).is_none()); + + let mut management = key_data(Some(0.0), None, None, Some(5.0), None, None, Some(3.5)); + management.is_management_key = Some(true); + assert!( + OpenRouterProvider::build_uncapped_cost(Some(&management), Some(&credits)).is_none() + ); + } + + #[test] + fn activity_cost_wins_while_uncapped_key_spend_windows_remain() { + let credits = CreditsResponse { + data: CreditsData { + total_credits: 20.0, + total_usage: 7.0, + }, + }; + let key = key_data( + Some(0.0), + None, + None, + Some(5.0), + Some(1.0), + Some(2.0), + Some(3.0), + ); + let mut usage = OpenRouterProvider::build_credits_usage(&credits.data); + OpenRouterProvider::apply_key_lanes(&mut usage, &key, "Spending cap, not balance"); + + let activity = CostSnapshot::new(4.0, "USD", "Last 30 days (UTC)"); + let selected = OpenRouterProvider::select_cost( + Some(activity), + OpenRouterProvider::build_uncapped_cost(Some(&key), Some(&credits)), + ) + .expect("Activity cost should be selected"); + + assert_eq!(selected.used, 4.0); + assert_eq!(selected.period, "Last 30 days (UTC)"); + for (id, expected) in [ + ("daily-spend", "$1.00 today"), + ("weekly-spend", "$2.00 this week"), + ("monthly-spend", "$3.00 this month"), + ] { + let window = usage + .extra_rate_windows + .iter() + .find(|window| window.id == id) + .expect("key spend window"); + assert_eq!(window.window.reset_description.as_deref(), Some(expected)); + } + } + #[test] fn server_remaining_replaces_lifetime_usage_for_meter() { // limit 50, server says 12.50 left this period → 75% used, even though