use std::collections::HashMap;
use crate::scenario::EndpointConfig;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ModelPricing {
pub input_per_1m: f64,
pub output_per_1m: f64,
pub cached_input_per_1m: Option<f64>,
pub cache_write_per_1m: Option<f64>,
}
#[must_use]
pub fn parse_openrouter_models(json: &serde_json::Value, model: &str) -> Option<ModelPricing> {
let wanted = model.to_ascii_lowercase();
let leaf = wanted.rsplit('/').next().unwrap_or(wanted.as_str());
let data = json["data"].as_array()?;
let entry = data.iter().find(|m| {
let id = m["id"].as_str().unwrap_or_default().to_ascii_lowercase();
id == wanted || id.rsplit('/').next().unwrap_or_default() == leaf
})?;
let pricing = &entry["pricing"];
Some(ModelPricing {
input_per_1m: per_million(pricing, "prompt")?,
output_per_1m: per_million(pricing, "completion")?,
cached_input_per_1m: per_million(pricing, "input_cache_read"),
cache_write_per_1m: per_million(pricing, "input_cache_write"),
})
}
fn per_million(pricing: &serde_json::Value, key: &str) -> Option<f64> {
let value = match &pricing[key] {
serde_json::Value::String(s) => s.parse::<f64>().ok()?,
serde_json::Value::Number(n) => n.as_f64()?,
_ => return None,
};
(value >= 0.0).then_some(value * 1_000_000.0)
}
pub async fn fetch_openrouter_pricing(
client: &reqwest::Client,
model: &str,
) -> Result<ModelPricing, String> {
let resp = client
.get("https://openrouter.ai/api/v1/models")
.send()
.await
.map_err(|e| format!("OpenRouter models request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!(
"OpenRouter models request returned HTTP {}",
resp.status()
));
}
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("OpenRouter models response was not JSON: {e}"))?;
parse_openrouter_models(&json, model)
.ok_or_else(|| format!("model `{model}` not found in OpenRouter pricing"))
}
#[must_use]
fn wants_openrouter_pricing(ec: &EndpointConfig) -> bool {
match ec.pricing_source.as_deref() {
Some("openrouter") => true,
Some("auto") => ec
.url
.as_deref()
.is_some_and(|url| url.to_ascii_lowercase().contains("openrouter.ai")),
_ => false,
}
}
pub fn apply_pricing(ec: &mut EndpointConfig, pricing: &ModelPricing) {
let p = ec.pricing.get_or_insert_with(Default::default);
if p.input_per_1m_tokens == 0.0 {
p.input_per_1m_tokens = pricing.input_per_1m;
}
if p.output_per_1m_tokens == 0.0 {
p.output_per_1m_tokens = pricing.output_per_1m;
}
if p.cached_input_per_1m_tokens.is_none() {
p.cached_input_per_1m_tokens = pricing.cached_input_per_1m;
}
if p.cache_write_per_1m_tokens.is_none() {
p.cache_write_per_1m_tokens = pricing.cache_write_per_1m;
}
}
#[allow(clippy::implicit_hasher)]
pub async fn apply_auto_pricing(
endpoints: &mut HashMap<String, EndpointConfig>,
client: &reqwest::Client,
) -> Result<usize, String> {
let mut priced = 0;
let mut first_error: Option<String> = None;
for ec in endpoints.values_mut() {
if !wants_openrouter_pricing(ec) {
continue;
}
let Some(model) = ec.model.clone() else {
continue;
};
match fetch_openrouter_pricing(client, &model).await {
Ok(pricing) => {
apply_pricing(ec, &pricing);
priced += 1;
}
Err(e) => {
first_error.get_or_insert(e);
}
};
}
match first_error {
Some(e) if priced == 0 => Err(e),
_ => Ok(priced),
}
}
#[cfg(test)]
mod tests {
use super::{apply_pricing, parse_openrouter_models};
use crate::scenario::{EndpointConfig, PricingConfig};
fn catalog() -> serde_json::Value {
serde_json::json!({
"data": [
{"id": "anthropic/claude-3.5-sonnet", "pricing": {
"prompt": "0.000003", "completion": "0.000015",
"input_cache_read": "0.0000003", "input_cache_write": "0.00000375"
}},
{"id": "openrouter/auto", "pricing": {"prompt": "-1", "completion": "-1"}}
]
})
}
#[test]
fn parses_exact_match_with_cache_prices() {
let p = parse_openrouter_models(&catalog(), "anthropic/claude-3.5-sonnet").unwrap();
assert!((p.input_per_1m - 3.0).abs() < 1e-9);
assert!((p.output_per_1m - 15.0).abs() < 1e-9);
assert!((p.cached_input_per_1m.unwrap() - 0.3).abs() < 1e-9);
assert!((p.cache_write_per_1m.unwrap() - 3.75).abs() < 1e-9);
}
#[test]
fn parses_by_leaf_when_namespaced_omitted() {
let p = parse_openrouter_models(&catalog(), "claude-3.5-sonnet").unwrap();
assert!((p.input_per_1m - 3.0).abs() < 1e-9);
}
#[test]
fn dynamic_negative_prices_are_not_usable() {
assert!(parse_openrouter_models(&catalog(), "openrouter/auto").is_none());
}
#[test]
fn unknown_model_is_none() {
assert!(parse_openrouter_models(&catalog(), "does/not-exist").is_none());
}
#[test]
fn apply_pricing_keeps_explicit_values() {
let mut ec = EndpointConfig {
model: Some("anthropic/claude-3.5-sonnet".to_owned()),
pricing: Some(PricingConfig {
input_per_1m_tokens: 9.0,
output_per_1m_tokens: 0.0,
..PricingConfig::default()
}),
..EndpointConfig::default()
};
let p = parse_openrouter_models(&catalog(), "anthropic/claude-3.5-sonnet").unwrap();
apply_pricing(&mut ec, &p);
let pricing = ec.pricing.unwrap();
assert!(
(pricing.input_per_1m_tokens - 9.0).abs() < 1e-9,
"explicit wins"
);
assert!(
(pricing.output_per_1m_tokens - 15.0).abs() < 1e-9,
"fetched fills gap"
);
assert!(pricing.cached_input_per_1m_tokens.is_some());
assert!(pricing.cache_write_per_1m_tokens.is_some());
}
}