use anyhow::{Context, Result};
use async_trait::async_trait;
use super::{
CatalogEntry, ModelCatalogSource, OPENROUTER_URL, PricingTier, build_feed_client,
merge_band_over_base,
};
use crate::model_capabilities::{PricePoint, Pricing};
#[derive(serde::Deserialize)]
struct OpenRouterPricing {
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
completion: Option<String>,
#[serde(default)]
input_cache_read: Option<String>,
#[serde(default)]
input_cache_write: Option<String>,
#[serde(default)]
internal_reasoning: Option<String>,
#[serde(default)]
overrides: Vec<OpenRouterPricingOverride>,
}
#[derive(serde::Deserialize)]
struct OpenRouterPricingOverride {
#[serde(default)]
min_prompt_tokens: Option<u32>,
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
completion: Option<String>,
#[serde(default)]
input_cache_read: Option<String>,
#[serde(default)]
input_cache_write: Option<String>,
#[serde(default)]
internal_reasoning: Option<String>,
}
#[derive(serde::Deserialize)]
struct OpenRouterTopProvider {
#[serde(default)]
max_completion_tokens: Option<u32>,
}
#[derive(serde::Deserialize)]
struct OpenRouterModel {
id: String,
#[serde(default)]
context_length: Option<u32>,
#[serde(default)]
pricing: Option<OpenRouterPricing>,
#[serde(default)]
top_provider: Option<OpenRouterTopProvider>,
}
#[derive(serde::Deserialize)]
struct OpenRouterResponse {
#[serde(default)]
data: Vec<OpenRouterModel>,
}
const OPENROUTER_PROVIDER: &str = "openrouter";
fn openrouter_price_per_million(value: &str) -> Option<PricePoint> {
let per_token: f64 = value.trim().parse().ok()?;
if !per_token.is_finite() || per_token <= 0.0 {
return None;
}
Some(PricePoint::new(per_token * 1_000_000.0))
}
pub fn parse_openrouter(json: &str) -> Result<Vec<CatalogEntry>> {
let parsed: OpenRouterResponse =
serde_json::from_str(json).context("failed to parse OpenRouter models response")?;
Ok(parsed
.data
.into_iter()
.map(|model| {
let base = model.pricing.as_ref().and_then(base_pricing);
let tiers = model.pricing.as_ref().map_or_else(
|| Some(Vec::new()),
|p| tiers_from_openrouter_pricing(p, base),
);
let (pricing, pricing_tiers) =
tiers.map_or_else(|| (None, Vec::new()), |tiers| (base, tiers));
let max_output_tokens = model.top_provider.and_then(|tp| tp.max_completion_tokens);
CatalogEntry {
provider: OPENROUTER_PROVIDER.to_owned(),
model_id: model.id,
context_window: model.context_length,
max_output_tokens,
pricing,
pricing_tiers,
supports_thinking: None,
}
})
.collect())
}
fn base_pricing(pricing: &OpenRouterPricing) -> Option<Pricing> {
pricing_from_rates(
pricing.prompt.as_deref(),
pricing.completion.as_deref(),
pricing.input_cache_read.as_deref(),
pricing.input_cache_write.as_deref(),
pricing.internal_reasoning.as_deref(),
)
}
fn pricing_from_rates(
prompt: Option<&str>,
completion: Option<&str>,
input_cache_read: Option<&str>,
input_cache_write: Option<&str>,
internal_reasoning: Option<&str>,
) -> Option<Pricing> {
let input = prompt.and_then(openrouter_price_per_million);
let output = completion.and_then(openrouter_price_per_million);
let cached_input = input_cache_read.and_then(openrouter_price_per_million);
let cache_write = input_cache_write.and_then(openrouter_price_per_million);
let reasoning = internal_reasoning.and_then(openrouter_price_per_million);
if input.is_none()
&& output.is_none()
&& cached_input.is_none()
&& cache_write.is_none()
&& reasoning.is_none()
{
return None;
}
Some(Pricing {
input,
output,
cached_input,
cache_write,
reasoning,
notes: None,
})
}
fn tiers_from_openrouter_pricing(
pricing: &OpenRouterPricing,
base: Option<Pricing>,
) -> Option<Vec<PricingTier>> {
let mut bands: Vec<(u32, Pricing)> = Vec::with_capacity(pricing.overrides.len());
for band in &pricing.overrides {
let rates = pricing_from_rates(
band.prompt.as_deref(),
band.completion.as_deref(),
band.input_cache_read.as_deref(),
band.input_cache_write.as_deref(),
band.internal_reasoning.as_deref(),
)?;
if rates.input.is_none() || rates.output.is_none() {
return None;
}
bands.push((band.min_prompt_tokens?, rates));
}
let mut thresholds: Vec<u32> = bands.iter().map(|(threshold, _)| *threshold).collect();
thresholds.sort_unstable();
thresholds.dedup();
Some(
thresholds
.into_iter()
.filter_map(|threshold| {
let mut pricing = base;
for (min, rates) in &bands {
if *min <= threshold {
pricing = Some(merge_band_over_base(pricing, *rates));
}
}
pricing.map(|pricing| PricingTier {
min_input_tokens: threshold,
pricing,
})
})
.collect(),
)
}
pub struct OpenRouterSource {
client: reqwest::Client,
url: String,
}
impl Default for OpenRouterSource {
fn default() -> Self {
let client = match build_feed_client() {
Ok(c) => c,
Err(e) => {
log::warn!("model-catalog feed client build failed, using default client: {e}");
reqwest::Client::new()
}
};
Self {
client,
url: OPENROUTER_URL.to_owned(),
}
}
}
impl OpenRouterSource {
pub fn new() -> Result<Self> {
Ok(Self {
client: build_feed_client()?,
url: OPENROUTER_URL.to_owned(),
})
}
#[must_use]
pub fn with_url(mut self, url: impl Into<String>) -> Self {
self.url = url.into();
self
}
}
#[async_trait]
impl ModelCatalogSource for OpenRouterSource {
async fn fetch(&self) -> Result<Vec<CatalogEntry>> {
let body = self
.client
.get(&self.url)
.send()
.await
.context("OpenRouter request failed")?
.error_for_status()
.context("OpenRouter returned an error status")?
.text()
.await
.context("failed to read OpenRouter body")?;
parse_openrouter(&body)
}
}
#[cfg(test)]
mod tests {
use super::super::ModelRegistry;
use super::*;
use agent_sdk_foundation::llm::Usage;
struct StaticSource(Vec<CatalogEntry>);
#[async_trait]
impl ModelCatalogSource for StaticSource {
async fn fetch(&self) -> Result<Vec<CatalogEntry>> {
Ok(self.0.clone())
}
}
const OPENROUTER_FIXTURE: &str = r#"{
"data": [
{
"id": "anthropic/claude-opus-4.8",
"name": "Anthropic: Claude Opus 4.8",
"context_length": 1000000,
"pricing": {
"prompt": "0.000005",
"completion": "0.000025",
"input_cache_read": "0.0000005"
},
"top_provider": { "max_completion_tokens": 128000 }
},
{
"id": "google/gemini-2.5-pro",
"name": "Google: Gemini 2.5 Pro",
"context_length": 1048576,
"pricing": { "prompt": "0.00000125", "completion": "0.00001" },
"top_provider": { "max_completion_tokens": 65536 }
}
]
}"#;
const OPENROUTER_SENTINEL_FIXTURE: &str = r#"{
"data": [
{
"id": "openrouter/auto",
"name": "Auto Router",
"context_length": 2000000,
"pricing": {
"prompt": "-1",
"completion": "-1",
"input_cache_read": "-1"
}
}
]
}"#;
fn find<'a>(
entries: &'a [CatalogEntry],
provider: &str,
model: &str,
) -> Result<&'a CatalogEntry> {
entries
.iter()
.find(|e| e.provider == provider && e.model_id == model)
.with_context(|| format!("missing {provider}/{model}"))
}
#[test]
fn parse_openrouter_converts_per_token_to_per_million_and_keys_rows_by_route() -> Result<()> {
let entries = parse_openrouter(OPENROUTER_FIXTURE)?;
assert_eq!(entries.len(), 2);
assert!(entries.iter().all(|e| e.provider == "openrouter"));
assert!(
!entries
.iter()
.any(|e| e.provider == "anthropic" || e.provider == "gemini"),
);
let opus = find(&entries, "openrouter", "anthropic/claude-opus-4.8")?;
assert_eq!(opus.context_window, Some(1_000_000));
assert_eq!(opus.max_output_tokens, Some(128_000));
let pricing = opus.pricing.context("opus pricing missing")?;
assert!(
(pricing.input.context("input")?.usd_per_million_tokens - 5.0).abs() < f64::EPSILON
);
assert!(
(pricing.output.context("output")?.usd_per_million_tokens - 25.0).abs() < f64::EPSILON
);
assert!(
(pricing
.cached_input
.context("cache")?
.usd_per_million_tokens
- 0.5)
.abs()
< f64::EPSILON
);
let gemini = find(&entries, "openrouter", "google/gemini-2.5-pro")?;
assert_eq!(gemini.context_window, Some(1_048_576));
Ok(())
}
#[tokio::test]
async fn parse_openrouter_treats_minus_one_sentinel_prices_as_absent() -> Result<()> {
let entries = parse_openrouter(OPENROUTER_SENTINEL_FIXTURE)?;
assert_eq!(entries.len(), 1);
let auto = find(&entries, "openrouter", "openrouter/auto")?;
assert!(
auto.pricing.is_none(),
"sentinel `-1` prices must yield None pricing, got {:?}",
auto.pricing
);
assert_eq!(auto.context_window, Some(2_000_000));
let registry = ModelRegistry::new();
registry.refresh(&StaticSource(entries)).await?;
let usage = Usage {
input_tokens: 1_000,
output_tokens: 1_000,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
};
assert_eq!(
registry.estimate_cost_usd("openrouter", "openrouter/auto", &usage),
None
);
Ok(())
}
const OPENROUTER_OVERRIDES_FIXTURE: &str = r#"{
"data": [
{
"id": "google/gemini-2.5-pro",
"context_length": 1048576,
"pricing": {
"prompt": "0.00000125",
"completion": "0.00001",
"input_cache_read": "0.000000125",
"input_cache_write": "0.000000375",
"overrides": [
{
"min_prompt_tokens": 200000,
"prompt": "0.0000025",
"completion": "0.000015",
"input_cache_read": "0.00000025"
}
]
}
},
{
"id": "openai/gpt-5.6-luna",
"context_length": 400000,
"pricing": {
"prompt": "0.000001",
"completion": "0.000006",
"input_cache_read": "0.0000001",
"input_cache_write": "0.00000125",
"overrides": [
{
"min_prompt_tokens": 272000,
"prompt": "0.000002",
"completion": "0.000009",
"input_cache_read": "0.0000002",
"input_cache_write": "0.0000025"
}
]
}
}
]
}"#;
#[test]
fn parse_openrouter_reads_long_context_overrides() -> Result<()> {
let entries = parse_openrouter(OPENROUTER_OVERRIDES_FIXTURE)?;
let gemini = find(&entries, "openrouter", "google/gemini-2.5-pro")?;
assert_eq!(gemini.pricing_tiers.len(), 1);
let band = gemini.pricing_tiers[0];
assert_eq!(band.min_input_tokens, 200_000);
assert!(
(band.pricing.input.context("input")?.usd_per_million_tokens - 2.5).abs()
< f64::EPSILON
);
assert!(
(band
.pricing
.output
.context("output")?
.usd_per_million_tokens
- 15.0)
.abs()
< f64::EPSILON
);
assert!(
(band
.pricing
.cache_write
.context("cache_write inherited from base")?
.usd_per_million_tokens
- 0.375)
.abs()
< f64::EPSILON
);
Ok(())
}
#[tokio::test]
async fn long_context_route_bills_at_the_override_rate() -> Result<()> {
let registry = ModelRegistry::new();
registry
.refresh(&StaticSource(parse_openrouter(
OPENROUTER_OVERRIDES_FIXTURE,
)?))
.await?;
let short = Usage {
input_tokens: 100_000,
output_tokens: 100_000,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
};
let short_cost = registry
.estimate_cost_usd("openrouter", "google/gemini-2.5-pro", &short)
.context("cost estimate missing")?;
assert!(
(short_cost - 1.125).abs() < 1e-9,
"unexpected cost: {short_cost}"
);
for input_tokens in [200_000, 300_000] {
let long = Usage {
input_tokens,
output_tokens: 100_000,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
};
let cost = registry
.estimate_cost_usd("openrouter", "google/gemini-2.5-pro", &long)
.context("cost estimate missing")?;
let expected = (f64::from(input_tokens) / 1_000_000.0).mul_add(2.5, 1.5);
assert!(
(cost - expected).abs() < 1e-9,
"unexpected cost at {input_tokens}: {cost}"
);
}
let just_under = Usage {
input_tokens: 199_999,
output_tokens: 0,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
};
let under_cost = registry
.estimate_cost_usd("openrouter", "google/gemini-2.5-pro", &just_under)
.context("cost estimate missing")?;
assert!(
(under_cost - 0.249_998_75).abs() < 1e-9,
"unexpected cost: {under_cost}"
);
Ok(())
}
#[tokio::test]
async fn aggregate_repricing_ignores_openrouter_overrides() -> Result<()> {
let registry = ModelRegistry::new();
registry
.refresh(&StaticSource(parse_openrouter(
OPENROUTER_OVERRIDES_FIXTURE,
)?))
.await?;
let aggregate = Usage {
input_tokens: 300_000,
output_tokens: 0,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
};
let base = registry
.estimate_dynamic_base_cost_usd("openrouter", "google/gemini-2.5-pro", &aggregate)
.context("cost estimate missing")?;
assert!((base - 0.375).abs() < 1e-9, "unexpected cost: {base}");
Ok(())
}
#[tokio::test]
async fn non_monotonic_overrides_fold_later_wins() -> Result<()> {
const NON_MONOTONIC_FIXTURE: &str = r#"{
"data": [
{
"id": "vendor/model",
"pricing": {
"prompt": "0.000001",
"completion": "0.000001",
"overrides": [
{ "min_prompt_tokens": 200000, "prompt": "0.000005", "completion": "0.000005" },
{ "min_prompt_tokens": 100000, "prompt": "0.000003", "completion": "0.000003" }
]
}
}
]
}"#;
let entries = parse_openrouter(NON_MONOTONIC_FIXTURE)?;
let model = find(&entries, "openrouter", "vendor/model")?;
assert_eq!(model.pricing_tiers.len(), 2);
let registry = ModelRegistry::new();
registry.refresh(&StaticSource(entries)).await?;
let out = |n: u32| Usage {
input_tokens: n,
output_tokens: 0,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
};
let base = registry
.estimate_cost_usd("openrouter", "vendor/model", &out(50_000))
.context("cost estimate missing")?;
assert!((base - 0.05).abs() < 1e-9, "unexpected cost: {base}");
let mid = registry
.estimate_cost_usd("openrouter", "vendor/model", &out(150_000))
.context("cost estimate missing")?;
assert!((mid - 0.45).abs() < 1e-9, "unexpected cost: {mid}");
let high = registry
.estimate_cost_usd("openrouter", "vendor/model", &out(250_000))
.context("cost estimate missing")?;
assert!((high - 0.75).abs() < 1e-9, "unexpected cost: {high}");
Ok(())
}
#[test]
fn parse_openrouter_survives_a_drifted_override() -> Result<()> {
const DRIFTED_FIXTURE: &str = r#"{
"data": [
{
"id": "vendor/healthy",
"pricing": { "prompt": "0.000001", "completion": "0.000002" }
},
{
"id": "vendor/no-threshold",
"pricing": {
"prompt": "0.000001",
"completion": "0.000002",
"overrides": [{ "prompt": "0.000009", "completion": "0.000009" }]
}
},
{
"id": "vendor/no-rates",
"pricing": {
"prompt": "0.000001",
"completion": "0.000002",
"overrides": [{ "min_prompt_tokens": 200000, "input_cache_read": "0.0000001" }]
}
}
]
}"#;
let entries = parse_openrouter(DRIFTED_FIXTURE)?;
assert_eq!(
entries.len(),
3,
"the parse must not abort on a drifted row"
);
let healthy = find(&entries, "openrouter", "vendor/healthy")?;
assert!(healthy.pricing.is_some());
assert!(healthy.pricing_tiers.is_empty());
for model in ["vendor/no-threshold", "vendor/no-rates"] {
let drifted = find(&entries, "openrouter", model)?;
assert!(drifted.pricing.is_none(), "{model} must drop its pricing");
assert!(drifted.pricing_tiers.is_empty());
}
Ok(())
}
}