From 3e63802c616e7d3e4705c70a817d5e9d5eeb3116 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:22:04 +0700 Subject: [PATCH] Port upstream 0.61.0: OpenRouter diagnostics and Activity summary --- rust/src/providers/openrouter/activity.rs | 107 +++++++++- rust/src/providers/openrouter/diagnostics.rs | 190 +++++++++++++++++ rust/src/providers/openrouter/mod.rs | 203 ++++++++++--------- rust/src/providers/openrouter/tests.rs | 164 +++++++++++++++ 4 files changed, 560 insertions(+), 104 deletions(-) create mode 100644 rust/src/providers/openrouter/diagnostics.rs diff --git a/rust/src/providers/openrouter/activity.rs b/rust/src/providers/openrouter/activity.rs index 9fcd2bec9f..2ee7dd79db 100644 --- a/rust/src/providers/openrouter/activity.rs +++ b/rust/src/providers/openrouter/activity.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use chrono::{DateTime, Duration, Utc}; use serde_json::Value; @@ -10,15 +10,49 @@ const MAX_ACTIVITY_ROWS: usize = 20_000; const MAX_DISTINCT_ROWS: usize = 10_000; const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; +/// Aggregate of the deduplicated, in-window Activity rows (upstream +/// `activityDetails`: Tokens = prompt + completion, Requests, distinct Models). +/// Reasoning tokens are validated but never added to `tokens` a second time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ActivitySummary { + pub(super) tokens: u64, + pub(super) requests: u64, + pub(super) models: usize, +} + +#[derive(Debug, Clone)] +pub(super) struct ActivityReport { + pub(super) cost: CostSnapshot, + pub(super) summary: ActivitySummary, +} + +/// Add `amount` to `total`, rejecting aggregates beyond the JS safe-integer +/// range like upstream (`Number.isSafeInteger`). +fn checked_aggregate(total: &mut u64, amount: u64) -> Result<(), ProviderError> { + *total = total + .checked_add(amount) + .filter(|sum| *sum <= MAX_SAFE_INTEGER) + .ok_or_else(|| { + ProviderError::Parse( + "OpenRouter Activity aggregate must be within the safe integer range".into(), + ) + })?; + Ok(()) +} + pub(super) fn parse_activity_cost( payloads: &[Value], now: DateTime, -) -> Result { +) -> Result { let latest_completed = now.date_naive() - Duration::days(1); let cutoff = latest_completed - Duration::days(29); let mut seen: HashMap = HashMap::new(); let mut daily: BTreeMap = BTreeMap::new(); let mut total = 0.0; + let mut tokens = 0u64; + let mut reasoning_tokens = 0u64; + let mut requests_total = 0u64; + let mut models: HashSet = HashSet::new(); let mut rows_seen = 0usize; for payload in payloads { @@ -138,6 +172,12 @@ pub(super) fn parse_activity_cost( "OpenRouter activity.data exceeds 10000 distinct rows".into(), )); } + checked_aggregate(&mut tokens, prompt + completion)?; + checked_aggregate(&mut reasoning_tokens, reasoning)?; + checked_aggregate(&mut requests_total, requests)?; + if !model.is_empty() { + models.insert(model.to_string()); + } total += cost; *daily.entry(day.to_string()).or_default() += cost; } @@ -148,14 +188,22 @@ pub(super) fn parse_activity_cost( "OpenRouter Activity spend overflowed".into(), )); } - Ok(CostSnapshot::new(total, "USD", "Last 30 days (UTC)") + let cost = CostSnapshot::new(total, "USD", "Last 30 days (UTC)") .with_daily( daily .into_iter() .map(|(day, amount)| CostDailyPoint { day, amount }) .collect(), ) - .always_visible()) + .always_visible(); + Ok(ActivityReport { + cost, + summary: ActivitySummary { + tokens, + requests: requests_total, + models: models.len(), + }, + }) } fn normalize_activity_day(raw: &str) -> Option<&str> { @@ -241,7 +289,17 @@ mod tests { let latest_completed = serde_json::json!({"data":[ {"date":"2026-08-21","model":"m1","prompt_tokens":10,"completion_tokens":5,"reasoning_tokens":2,"requests":1,"usage":1.25,"byok_usage_inference":0.25} ]}); - let cost = parse_activity_cost(&[history, latest_completed], now()).unwrap(); + let report = parse_activity_cost(&[history, latest_completed], now()).unwrap(); + let cost = report.cost; + // The duplicated latest-completed row is counted once. + assert_eq!( + report.summary, + ActivitySummary { + tokens: 22, + requests: 2, + models: 2 + } + ); assert!((cost.used - 2.0).abs() < 1e-12); assert_eq!(cost.daily.len(), 2); assert_eq!(cost.period, "Last 30 days (UTC)"); @@ -254,10 +312,12 @@ mod tests { "completion_tokens":2,"reasoning_tokens":8,"requests":1,"usage":1.0} ]}); - let cost = parse_activity_cost(&[payload], now()).unwrap(); + let report = parse_activity_cost(&[payload], now()).unwrap(); - assert_eq!(cost.used, 1.0); - assert_eq!(cost.daily.len(), 1); + assert_eq!(report.cost.used, 1.0); + assert_eq!(report.cost.daily.len(), 1); + // Tokens stay prompt + completion; reasoning is not added again. + assert_eq!(report.summary.tokens, 12); } #[test] @@ -277,7 +337,7 @@ mod tests { {"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} ]}); - let cost = parse_activity_cost(&[payload], now()).unwrap(); + let cost = parse_activity_cost(&[payload], now()).unwrap().cost; assert_eq!(cost.used, 1.0); } @@ -287,7 +347,7 @@ mod tests { let payload = serde_json::json!({"data":[ {"date":date,"model":"m","prompt_tokens":10,"completion_tokens":5,"requests":1,"usage":1.0} ]}); - let cost = parse_activity_cost(&[payload], now()).unwrap(); + let cost = parse_activity_cost(&[payload], now()).unwrap().cost; assert_eq!(cost.daily.len(), 1); assert_eq!(cost.daily[0].day, "2026-08-21"); } @@ -314,4 +374,31 @@ mod tests { assert!(error.to_string().contains("completed UTC day")); } + + #[test] + fn rows_without_a_model_do_not_count_as_models() { + let payload = serde_json::json!({"data":[ + {"date":"2026-08-21","prompt_tokens":1,"completion_tokens":1,"requests":1,"usage":0.1}, + {"date":"2026-08-21","model":" ","endpoint_id":"e","prompt_tokens":1,"completion_tokens":1,"requests":1,"usage":0.1}, + {"date":"2026-08-21","model_permaslug":"a/b","prompt_tokens":1,"completion_tokens":1,"requests":1,"usage":0.1} + ]}); + + let summary = parse_activity_cost(&[payload], now()).unwrap().summary; + + assert_eq!(summary.models, 1); + assert_eq!(summary.requests, 3); + } + + #[test] + fn aggregate_beyond_the_safe_integer_range_is_rejected() { + let big = MAX_SAFE_INTEGER / 2 + 1; + let payload = serde_json::json!({"data":[ + {"date":"2026-08-21","model":"a","prompt_tokens":big,"completion_tokens":0,"requests":1,"usage":0.1}, + {"date":"2026-08-20","model":"b","prompt_tokens":big,"completion_tokens":0,"requests":1,"usage":0.1} + ]}); + + let error = parse_activity_cost(&[payload], now()).unwrap_err(); + + assert!(error.to_string().contains("safe integer")); + } } diff --git a/rust/src/providers/openrouter/diagnostics.rs b/rust/src/providers/openrouter/diagnostics.rs new file mode 100644 index 0000000000..850d7370ac --- /dev/null +++ b/rust/src/providers/openrouter/diagnostics.rs @@ -0,0 +1,190 @@ +//! Optional-request diagnostics and display rows for the OpenRouter provider +//! (upstream `openrouter.js` at v0.61.0: safe degradation reasons plus the +//! Credits, API key, and Activity detail sections). +//! +//! Reasons are fixed strings chosen from the failure class, never raw +//! response bodies, headers, or keys. + +use reqwest::StatusCode; + +use super::activity::ActivitySummary; +use super::{CreditsData, KeyData, OpenRouterProvider}; +use crate::core::{ProviderDisplayDetail, ProviderError}; + +const UNAVAILABLE: &str = "Unavailable right now"; +pub(super) const ACTIVITY_NOT_CONFIGURED: &str = "Management API key not configured"; +pub(super) const ACTIVITY_KEY_REQUIRED: &str = "Management API key required"; + +/// One optional request that did not produce usable data: the typed error the +/// refresh pipeline acts on, plus the safe reason shown to the user. +#[derive(Debug)] +pub(super) struct Degraded { + pub(super) error: ProviderError, + pub(super) reason: String, +} + +impl Degraded { + /// A non-success HTTP status. Statuses in `auth_statuses` are typed as + /// [`ProviderError::AuthRequired`]; the reason always names the status. + pub(super) fn http(label: &str, status: StatusCode, auth_statuses: &[StatusCode]) -> Self { + let error = if auth_statuses.contains(&status) { + ProviderError::AuthRequired + } else { + ProviderError::Other(format!("OpenRouter {label} request returned HTTP {status}")) + }; + Self { + error, + reason: format!("Request returned HTTP {}", status.as_u16()), + } + } + + /// A successful HTTP response whose body failed parsing or validation. + pub(super) fn invalid(error: ProviderError) -> Self { + Self { + error, + reason: "Response was invalid".to_string(), + } + } + + /// A response body that failed to decode, unless the body read itself + /// timed out (then it is a timeout, not an invalid response). + pub(super) fn body(label: &str, error: reqwest::Error) -> Self { + if error.is_timeout() { + return Self::from(error); + } + Self::invalid(ProviderError::Parse(format!( + "OpenRouter {label} response was invalid: {error}" + ))) + } + + pub(super) fn with_reason(mut self, reason: &str) -> Self { + self.reason = reason.to_string(); + self + } +} + +impl From for Degraded { + fn from(error: reqwest::Error) -> Self { + let reason = if error.is_timeout() { + "Request timed out" + } else { + "Request failed" + }; + Self { + error: ProviderError::Network(error), + reason: reason.to_string(), + } + } +} + +/// What each optional source reported, with the safe reason when it did not. +pub(super) struct Observations<'a> { + pub(super) credits: &'a Result, + pub(super) key: &'a Result, + pub(super) activity: &'a Result, +} + +pub(super) fn build_display_details(observed: &Observations<'_>) -> Vec { + let mut rows = Vec::new(); + match observed.credits { + Ok(credits) => { + rows.push(row( + "credits-remaining", + "Credits remaining", + currency(credits.balance()), + )); + rows.push(row( + "credits-used", + "Credits used", + currency(credits.total_usage), + )); + rows.push(row( + "credits-total", + "Credits total added", + currency(credits.total_credits), + )); + } + Err(reason) => rows.push(unavailable("credits-balance", "Credits balance", reason)), + } + match observed.key { + Ok(key) => rows.extend(key_rows(key)), + Err(reason) => rows.push(unavailable("key-limit", "API key limit", reason)), + } + match observed.activity { + Ok(summary) => { + rows.push( + ProviderDisplayDetail::new( + "activity-tokens", + "Activity tokens", + summary.tokens.to_string(), + ) + .and_then(|row| row.with_secondary_value("Last 30 completed UTC days")), + ); + rows.push(row( + "activity-requests", + "Activity requests", + summary.requests.to_string(), + )); + rows.push(row( + "activity-models", + "Activity models", + summary.models.to_string(), + )); + } + Err(reason) => rows.push(unavailable( + "spend-history", + "Spend history (last 30 days)", + reason, + )), + } + rows.into_iter().flatten().collect() +} + +fn key_rows(key: &KeyData) -> Vec> { + let mut rows = Vec::new(); + match key.limit.filter(|limit| *limit > 0.0) { + Some(limit) => { + rows.push( + ProviderDisplayDetail::new("key-limit", "API key limit", currency(limit)) + .and_then(|row| row.with_secondary_value("Spending cap, not balance")), + ); + if let Some((_, used, limit)) = OpenRouterProvider::key_quota_metrics(key) { + rows.push(row( + "key-remaining", + "API key remaining", + currency((limit - used).max(0.0)), + )); + } + if let Some(usage) = key.usage { + rows.push(row("key-used", "API key used", currency(usage))); + } + } + None => rows.push(row( + "key-limit", + "API key limit", + "No limit configured".to_string(), + )), + } + if let Some(window) = key + .limit_reset + .as_deref() + .map(str::trim) + .filter(|window| !window.is_empty()) + { + rows.push(row("key-reset-window", "Reset window", window.to_string())); + } + rows +} + +fn row(id: &str, title: &str, value: String) -> Option { + ProviderDisplayDetail::new(id, title, value) +} + +fn unavailable(id: &str, title: &str, reason: &str) -> Option { + ProviderDisplayDetail::new(id, title, UNAVAILABLE) + .and_then(|row| row.with_secondary_value(reason)) +} + +fn currency(value: f64) -> String { + format!("${:.2}", value.max(0.0)) +} diff --git a/rust/src/providers/openrouter/mod.rs b/rust/src/providers/openrouter/mod.rs index 1c15164c82..e0186bfcfa 100755 --- a/rust/src/providers/openrouter/mod.rs +++ b/rust/src/providers/openrouter/mod.rs @@ -4,6 +4,7 @@ //! Requires API key for authentication mod activity; +mod diagnostics; #[cfg(test)] #[path = "tests.rs"] @@ -18,6 +19,8 @@ use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, }; +use activity::ActivityReport; +use diagnostics::{Degraded, Observations}; /// OpenRouter API base URL — the bare `/api/v1` prefix, matching upstream /// (steipete/CodexBar `OpenRouterSettingsReader.apiURL`). @@ -26,17 +29,31 @@ use crate::core::{ /// The fork's original bug baked `/auth` into the base (`.../api/v1/auth`), /// which turned the credits call into `/api/v1/auth/credits` -> 404. const OPENROUTER_API_BASE: &str = "https://openrouter.ai/api/v1"; -const OPENROUTER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); -/// Optional key-quota enrichment joins on a one-second fast deadline -/// (upstream 0.49.0 #2778) so a slow `/key` endpoint can never stall the -/// refresh; degraded enrichment is logged and skipped, never fatal. -const OPENROUTER_KEY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); +/// Per-request deadline for `/credits`, `/key`, and Activity. Upstream +/// `openrouter.js` (v0.61.0) gives each optional request four seconds; a +/// degraded request is reported with a safe reason, never fatal on its own. +const OPENROUTER_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4); const OPENROUTER_ACTIVITY_URL: &str = "https://openrouter.ai/api/v1/activity"; const OPENROUTER_MANAGEMENT_ENV: &str = "OPENROUTER_MANAGEMENT_API_KEY"; /// Windows Credential Manager target for OpenRouter API token const OPENROUTER_CREDENTIAL_TARGET: &str = "codexbar-openrouter"; +/// Statuses that mean the credential itself was rejected. +const AUTH_REJECTED: &[reqwest::StatusCode] = &[ + reqwest::StatusCode::UNAUTHORIZED, + reqwest::StatusCode::FORBIDDEN, +]; + +/// Snapshot one optional request for the display rows: the usable value, or +/// the safe reason it degraded. +fn observe(result: &Result, value: impl FnOnce(&T) -> U) -> Result { + match result { + Ok(ok) => Ok(value(ok)), + Err(degraded) => Err(degraded.reason.clone()), + } +} + /// OpenRouter /credits response #[derive(Debug, Clone, Deserialize)] struct CreditsResponse { @@ -205,30 +222,33 @@ impl OpenRouterProvider { async fn fetch_usage_api( &self, ctx: &FetchContext, - ) -> Result<(UsageSnapshot, Option), ProviderError> { + ) -> Result { let api_key = Self::get_api_token(ctx.api_key.as_deref())?; - let client = Self::build_client(OPENROUTER_TIMEOUT)?; + let client = Self::build_client()?; // OpenRouter can reject the account-level `/credits` request while // still returning the selected key's current spend/quota from `/key`. // Fetch both independent sources together, then let the pure resolver // choose the stable primary/secondary lanes. - let (credits_result, key_data_result) = tokio::join!( + let (credits_result, key_result) = tokio::join!( Self::fetch_credits(&client, &api_key), - Self::fetch_key_data(&api_key), + Self::fetch_key_data(&client, &api_key), ); - if let Err(error) = &credits_result { - tracing::debug!(error = %error, "OpenRouter credits endpoint degraded"); + let credits_observed = observe(&credits_result, |credits| credits.data.clone()); + let key_observed = observe(&key_result, Clone::clone); + if let Err(degraded) = &credits_result { + tracing::debug!(reason = %degraded.reason, "OpenRouter credits endpoint degraded"); } - let key_data = match key_data_result { + let key_data = match key_result { Ok(key_data) => Some(key_data), - Err(error) => { + Err(degraded) => { tracing::debug!( - error = %error, + reason = %degraded.reason, "OpenRouter key endpoint degraded; preserving independent credits data" ); None } }; + let credits_result = credits_result.map_err(|degraded| degraded.error); 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())?; @@ -240,43 +260,64 @@ impl OpenRouterProvider { .is_some_and(|key_data| key_data.is_management_key == Some(true)) .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 = %error, - "OpenRouter management Activity degraded; preserving credits/quota" - ); - None - } - }, + let activity_result = match activity_key { + Some(key) => Some(Self::fetch_activity(&client, key).await), + None => None, + }; + let activity_observed = match &activity_result { + Some(result) => observe(result, |report| report.summary), + None => Err(diagnostics::ACTIVITY_NOT_CONFIGURED.to_string()), + }; + let activity_cost = match activity_result { + Some(Ok(report)) => Some(report.cost), + Some(Err(degraded)) => { + tracing::debug!( + reason = %degraded.reason, + "OpenRouter management Activity degraded; preserving credits/quota" + ); + None + } None => None, }; - Ok((usage, activity_cost.or(fallback_cost))) + let mut result = ProviderFetchResult::new(usage, "api"); + if let Some(cost) = activity_cost.or(fallback_cost) { + result = result.with_cost(cost); + } + let details = diagnostics::build_display_details(&Observations { + credits: &credits_observed, + key: &key_observed, + activity: &activity_observed, + }); + for detail in details { + result = result.with_display_detail(Some(detail)); + } + Ok(result) } - async fn fetch_activity_cost(management_key: &str) -> Result { - let client = Self::build_client(OPENROUTER_KEY_TIMEOUT)?; + async fn fetch_activity( + client: &reqwest::Client, + management_key: &str, + ) -> Result { let now = Utc::now(); let latest_completed = (now.date_naive() - chrono::Duration::days(1)) .format("%Y-%m-%d") .to_string(); 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)), + 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) + .map_err(Degraded::invalid) } async fn fetch_activity_payload( client: &reqwest::Client, management_key: &str, date: Option<&str>, - ) -> Result { + ) -> Result { let mut request = client .get(OPENROUTER_ACTIVITY_URL) .header("Authorization", format!("Bearer {management_key}")) @@ -285,25 +326,23 @@ impl OpenRouterProvider { request = request.query(&[("date", date)]); } let response = request.send().await?; - if response.status() == reqwest::StatusCode::UNAUTHORIZED - || response.status() == reqwest::StatusCode::FORBIDDEN - { - return Err(ProviderError::AuthRequired); + let status = response.status(); + if status == reqwest::StatusCode::FORBIDDEN { + return Err(Degraded::http("Activity", status, AUTH_REJECTED) + .with_reason(diagnostics::ACTIVITY_KEY_REQUIRED)); } - if !response.status().is_success() { - return Err(ProviderError::Other(format!( - "OpenRouter Activity request returned HTTP {}", - response.status() - ))); + if !status.is_success() { + return Err(Degraded::http("Activity", status, AUTH_REJECTED)); } - response.json::().await.map_err(|error| { - ProviderError::Parse(format!("Invalid OpenRouter Activity response: {error}")) - }) + response + .json::() + .await + .map_err(|error| Degraded::body("Activity", error)) } - fn build_client(timeout: std::time::Duration) -> Result { + fn build_client() -> Result { crate::core::credentialed_http_client_builder() - .timeout(timeout) + .timeout(OPENROUTER_REQUEST_TIMEOUT) .build() .map_err(|e| ProviderError::Other(e.to_string())) } @@ -311,7 +350,7 @@ impl OpenRouterProvider { async fn fetch_credits( client: &reqwest::Client, api_key: &str, - ) -> Result { + ) -> Result { let credits_url = format!("{}/credits", OPENROUTER_API_BASE); let resp = client .get(&credits_url) @@ -320,21 +359,19 @@ impl OpenRouterProvider { .send() .await?; - if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - return Err(ProviderError::AuthRequired); - } - if !resp.status().is_success() { - return Err(ProviderError::Other(format!( - "OpenRouter credits request returned HTTP {}", - resp.status() - ))); + return Err(Degraded::http( + "credits", + resp.status(), + &[reqwest::StatusCode::UNAUTHORIZED], + )); } - let response = resp.json::().await.map_err(|error| { - ProviderError::Parse(format!("OpenRouter credits response was invalid: {error}")) - })?; - response.data.validate()?; + let response = resp + .json::() + .await + .map_err(|error| Degraded::body("credits", error))?; + response.data.validate().map_err(Degraded::invalid)?; Ok(response) } @@ -413,40 +450,25 @@ impl OpenRouterProvider { Some(usage) } - async fn fetch_key_data(api_key: &str) -> Result { - let key_client = Self::build_client(OPENROUTER_KEY_TIMEOUT)?; - 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() { - return Err(ProviderError::Other(format!( - "OpenRouter key request returned HTTP {}", - key_resp.status() - ))); - } - - 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( - client: &reqwest::Client, - api_key: &str, - ) -> Result { + async fn fetch_key_data(client: &reqwest::Client, api_key: &str) -> Result { let key_url = format!("{}/key", OPENROUTER_API_BASE); - client + let key_resp = client .get(&key_url) .header("Authorization", format!("Bearer {}", api_key)) .header("Accept", "application/json") .send() + .await?; + + if !key_resp.status().is_success() { + return Err(Degraded::http("key", key_resp.status(), AUTH_REJECTED)); + } + + let response = key_resp + .json::() .await + .map_err(|error| Degraded::body("key", error))?; + response.data.validate().map_err(Degraded::invalid)?; + Ok(response.data) } fn apply_key_lanes(usage: &mut UsageSnapshot, key_data: &KeyData, quota_suffix: &str) { @@ -565,14 +587,7 @@ impl Provider for OpenRouterProvider { tracing::debug!("Fetching OpenRouter usage"); match ctx.source_mode { - SourceMode::Auto | SourceMode::OAuth => { - let (usage, cost) = self.fetch_usage_api(ctx).await?; - let mut result = ProviderFetchResult::new(usage, "api"); - if let Some(cost) = cost { - result = result.with_cost(cost); - } - Ok(result) - } + SourceMode::Auto | SourceMode::OAuth => self.fetch_usage_api(ctx).await, SourceMode::Web | SourceMode::Cli => { Err(ProviderError::UnsupportedSource(ctx.source_mode)) } diff --git a/rust/src/providers/openrouter/tests.rs b/rust/src/providers/openrouter/tests.rs index a7728bde20..bc8e08ae30 100644 --- a/rust/src/providers/openrouter/tests.rs +++ b/rust/src/providers/openrouter/tests.rs @@ -387,3 +387,167 @@ fn parsed_key_wire_fields_decode() { assert_eq!(parsed.data.limit_remaining, Some(12.5)); assert_eq!(parsed.data.limit_reset.as_deref(), Some("monthly")); } + +// ── 0.61.0 (#3272, #3733): optional-request diagnostics and detail rows ── + +use super::activity::ActivitySummary; +use super::diagnostics::{ + ACTIVITY_KEY_REQUIRED, ACTIVITY_NOT_CONFIGURED, Observations, build_display_details, +}; + +type DetailRow = (String, String, Option); + +fn detail_rows( + credits: Result, + key: Result, + activity: Result, +) -> Vec { + build_display_details(&Observations { + credits: &credits, + key: &key, + activity: &activity, + }) + .iter() + .map(|row| { + ( + row.title().to_string(), + row.value().to_string(), + row.secondary_value().map(str::to_string), + ) + }) + .collect() +} + +fn row_of<'a>(rows: &'a [DetailRow], title: &str) -> &'a DetailRow { + rows.iter() + .find(|row| row.0 == title) + .unwrap_or_else(|| panic!("missing row {title}: {rows:?}")) +} + +#[test] +fn optional_request_deadline_is_four_seconds() { + assert_eq!( + OPENROUTER_REQUEST_TIMEOUT, + std::time::Duration::from_secs(4) + ); +} + +#[test] +fn successful_sources_render_credits_key_and_activity_rows() { + let rows = detail_rows( + Ok(CreditsData { + total_credits: 5.0, + total_usage: 3.1, + }), + Ok(key_data( + Some(30.0), + Some(30.0), + Some(" monthly "), + Some(0.0), + None, + None, + None, + )), + Ok(ActivitySummary { + tokens: 22, + requests: 2, + models: 2, + }), + ); + + assert_eq!(row_of(&rows, "Credits remaining").1, "$1.90"); + assert_eq!(row_of(&rows, "Credits used").1, "$3.10"); + assert_eq!(row_of(&rows, "Credits total added").1, "$5.00"); + let limit = row_of(&rows, "API key limit"); + assert_eq!(limit.1, "$30.00"); + assert_eq!(limit.2.as_deref(), Some("Spending cap, not balance")); + assert_eq!(row_of(&rows, "API key remaining").1, "$30.00"); + assert_eq!(row_of(&rows, "API key used").1, "$0.00"); + assert_eq!(row_of(&rows, "Reset window").1, "monthly"); + assert_eq!(row_of(&rows, "Activity tokens").1, "22"); + assert_eq!(row_of(&rows, "Activity requests").1, "2"); + assert_eq!(row_of(&rows, "Activity models").1, "2"); + assert!( + rows.iter() + .all(|row| row.0 != "Spend history (last 30 days)") + ); +} + +#[test] +fn uncapped_key_reports_no_limit_and_omits_remaining() { + let rows = detail_rows( + Err("Request failed".into()), + Ok(key_data(None, None, None, Some(1.0), None, None, None)), + Err(ACTIVITY_NOT_CONFIGURED.into()), + ); + + assert_eq!(row_of(&rows, "API key limit").1, "No limit configured"); + assert!(rows.iter().all(|row| row.0 != "API key remaining")); + assert!(rows.iter().all(|row| row.0 != "Reset window")); +} + +#[test] +fn degraded_sources_keep_safe_reasons_beside_the_unavailable_marker() { + let rows = detail_rows( + Err("Request returned HTTP 503".into()), + Err("Request timed out".into()), + Err(ACTIVITY_KEY_REQUIRED.into()), + ); + + for (title, reason) in [ + ("Credits balance", "Request returned HTTP 503"), + ("API key limit", "Request timed out"), + ("Spend history (last 30 days)", ACTIVITY_KEY_REQUIRED), + ] { + let row = row_of(&rows, title); + assert_eq!(row.1, "Unavailable right now"); + assert_eq!(row.2.as_deref(), Some(reason)); + } + assert_eq!(rows.len(), 3); +} + +#[test] +fn http_failures_keep_their_status_and_auth_typing() { + use reqwest::StatusCode; + + let rejected = Degraded::http("key", StatusCode::FORBIDDEN, AUTH_REJECTED); + assert!(matches!(rejected.error, ProviderError::AuthRequired)); + assert_eq!(rejected.reason, "Request returned HTTP 403"); + + // Credits only treats 401 as a rejected credential. + let unavailable = Degraded::http( + "credits", + StatusCode::SERVICE_UNAVAILABLE, + &[StatusCode::UNAUTHORIZED], + ); + assert!(matches!(unavailable.error, ProviderError::Other(_))); + assert_eq!(unavailable.reason, "Request returned HTTP 503"); + + let activity = Degraded::http("Activity", StatusCode::FORBIDDEN, AUTH_REJECTED) + .with_reason(ACTIVITY_KEY_REQUIRED); + assert!(matches!(activity.error, ProviderError::AuthRequired)); + assert_eq!(activity.reason, "Management API key required"); +} + +#[test] +fn invalid_bodies_are_labelled_without_leaking_the_payload() { + let degraded = Degraded::invalid(ProviderError::Parse("secret-body".into())); + assert_eq!(degraded.reason, "Response was invalid"); +} + +#[tokio::test] +async fn slow_response_reports_a_timeout_and_other_transport_errors_a_failure() { + // A bound listener that never accepts or replies: the request stalls. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/", listener.local_addr().unwrap()); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_millis(100)) + .build() + .unwrap(); + let stalled = Degraded::from(client.get(&url).send().await.unwrap_err()); + assert_eq!(stalled.reason, "Request timed out"); + + // A non-timeout transport error (unsupported scheme) is a plain failure. + let failed = Degraded::from(client.get("ftp://127.0.0.1/").send().await.unwrap_err()); + assert_eq!(failed.reason, "Request failed"); +}