Skip to content
Draft
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
107 changes: 97 additions & 10 deletions rust/src/providers/openrouter/activity.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, HashMap, HashSet};

use chrono::{DateTime, Duration, Utc};
use serde_json::Value;
Expand All @@ -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<Utc>,
) -> Result<CostSnapshot, ProviderError> {
) -> Result<ActivityReport, ProviderError> {
let latest_completed = now.date_naive() - Duration::days(1);
let cutoff = latest_completed - Duration::days(29);
let mut seen: HashMap<String, String> = HashMap::new();
let mut daily: BTreeMap<String, f64> = 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<String> = HashSet::new();
let mut rows_seen = 0usize;

for payload in payloads {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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> {
Expand Down Expand Up @@ -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)");
Expand All @@ -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]
Expand All @@ -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);
}

Expand All @@ -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");
}
Expand All @@ -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"));
}
}
190 changes: 190 additions & 0 deletions rust/src/providers/openrouter/diagnostics.rs
Original file line number Diff line number Diff line change
@@ -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<reqwest::Error> 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<CreditsData, String>,
pub(super) key: &'a Result<KeyData, String>,
pub(super) activity: &'a Result<ActivitySummary, String>,
}

pub(super) fn build_display_details(observed: &Observations<'_>) -> Vec<ProviderDisplayDetail> {
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<Option<ProviderDisplayDetail>> {
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> {
ProviderDisplayDetail::new(id, title, value)
}

fn unavailable(id: &str, title: &str, reason: &str) -> Option<ProviderDisplayDetail> {
ProviderDisplayDetail::new(id, title, UNAVAILABLE)
.and_then(|row| row.with_secondary_value(reason))
}

fn currency(value: f64) -> String {
format!("${:.2}", value.max(0.0))
}
Loading