use std::collections::BTreeMap;
use std::time::Duration;
use super::{ModelInfo, PriceSource};
use crate::error::Result;
use crate::pricing::{Price, PriceTier};
pub const DEFAULT_REFERENCE_URL: &str = "https://openrouter.ai/api/v1/models";
#[derive(Debug, Clone)]
pub struct Reference {
url: String,
client: reqwest::Client,
cached: std::sync::Arc<std::sync::OnceLock<Vec<ModelInfo>>>,
}
impl Default for Reference {
fn default() -> Self {
Self::new()
}
}
impl Reference {
pub fn new() -> Self {
Self::at(DEFAULT_REFERENCE_URL)
}
pub fn at(url: impl Into<String>) -> Self {
Self {
url: url.into(),
client: crate::net::http_client(),
cached: std::sync::Arc::new(std::sync::OnceLock::new()),
}
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.client = crate::net::http_client_with_timeout(timeout);
self
}
pub fn url(&self) -> &str {
&self.url
}
pub fn host(&self) -> Option<&str> {
let rest = self.url.split("://").nth(1)?;
let host = rest.split(['/', '?', '#']).next()?;
let host = host.rsplit('@').next()?;
let host = host.split(':').next()?;
(!host.is_empty()).then_some(host)
}
pub async fn models(&self) -> Result<Vec<ModelInfo>> {
if let Some(hit) = self.cached.get() {
return Ok(hit.clone());
}
let host = self.host().unwrap_or(&self.url).to_string();
let models = fetch(&self.client, &self.url, &PriceSource::Reference(host)).await?;
let _ = self.cached.set(models.clone());
Ok(models)
}
}
pub(crate) async fn fetch(
client: &reqwest::Client,
url: &str,
source: &PriceSource,
) -> Result<Vec<ModelInfo>> {
let resp = client.get(url).send().await?;
let body: Catalogue = super::ensure_success(resp).await?.json().await?;
Ok(body
.data
.into_iter()
.map(|entry| entry.into_model(source))
.collect())
}
pub(crate) fn fill_missing_prices(vendor: &mut [ModelInfo], reference: &[ModelInfo]) {
let index = Index::new(reference);
for model in vendor.iter_mut() {
if model.price.is_some() {
continue;
}
let Some(hit) = index.find(&model.id) else {
continue;
};
model.price = hit.price;
model.price_tiers = hit.price_tiers.clone();
model.price_source = hit.price_source.clone();
}
}
pub(crate) struct Index<'a> {
exact: BTreeMap<String, &'a ModelInfo>,
normalised: BTreeMap<String, Option<&'a ModelInfo>>,
}
pub(crate) fn normalise(id: &str) -> String {
let lower = id.to_ascii_lowercase();
match lower.split_once('/') {
Some((_vendor, rest)) if !rest.is_empty() && !rest.contains('/') => rest.to_string(),
_ => lower,
}
}
impl<'a> Index<'a> {
pub(crate) fn new(models: &'a [ModelInfo]) -> Self {
let mut exact = BTreeMap::new();
let mut normalised: BTreeMap<String, Option<&'a ModelInfo>> = BTreeMap::new();
for m in models {
exact.insert(m.id.to_ascii_lowercase(), m);
normalised
.entry(normalise(&m.id))
.and_modify(|slot| *slot = None)
.or_insert(Some(m));
}
Self { exact, normalised }
}
pub(crate) fn find(&self, id: &str) -> Option<&'a ModelInfo> {
let lower = id.to_ascii_lowercase();
if let Some(hit) = self.exact.get(&lower) {
return Some(hit);
}
if lower.contains('/') {
return None;
}
self.normalised.get(&lower).copied().flatten()
}
}
#[derive(serde::Deserialize)]
struct Catalogue {
#[serde(default)]
data: Vec<Entry>,
}
#[derive(serde::Deserialize)]
struct Entry {
#[serde(default)]
id: String,
#[serde(default)]
context_length: Option<u64>,
#[serde(default)]
pricing: Option<Pricing>,
#[serde(default)]
top_provider: Option<TopProvider>,
#[serde(default)]
architecture: Option<Architecture>,
#[serde(default)]
supported_parameters: Option<Vec<String>>,
}
#[derive(serde::Deserialize, Default, Clone)]
struct Pricing {
#[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)]
web_search: Option<String>,
#[serde(default)]
overrides: Option<Vec<Override>>,
}
#[derive(serde::Deserialize, Clone)]
struct Override {
#[serde(default)]
min_prompt_tokens: Option<u64>,
#[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)]
web_search: Option<String>,
}
#[derive(serde::Deserialize)]
struct TopProvider {
#[serde(default)]
max_completion_tokens: Option<u64>,
}
#[derive(serde::Deserialize)]
struct Architecture {
#[serde(default)]
input_modalities: Option<Vec<String>>,
}
impl Pricing {
fn price(&self) -> Option<Price> {
let dims = [
&self.prompt,
&self.completion,
&self.input_cache_read,
&self.input_cache_write,
&self.web_search,
];
if dims.iter().all(|d| d.is_none()) {
return None;
}
Some(Price {
input: rate(self.prompt.as_deref()),
output: rate(self.completion.as_deref()),
cache_read: rate(self.input_cache_read.as_deref()),
cache_write: rate(self.input_cache_write.as_deref()),
per_server_tool_request: per_request(self.web_search.as_deref()),
})
}
}
impl Override {
fn tier(&self, base: Price) -> Option<PriceTier> {
Some(PriceTier {
min_prompt_tokens: self.min_prompt_tokens?,
price: Price {
input: self.prompt.as_deref().map_or(base.input, |v| rate(Some(v))),
output: self
.completion
.as_deref()
.map_or(base.output, |v| rate(Some(v))),
cache_read: self
.input_cache_read
.as_deref()
.map_or(base.cache_read, |v| rate(Some(v))),
cache_write: self
.input_cache_write
.as_deref()
.map_or(base.cache_write, |v| rate(Some(v))),
per_server_tool_request: self
.web_search
.as_deref()
.map_or(base.per_server_tool_request, |v| per_request(Some(v))),
},
})
}
}
impl Entry {
fn into_model(self, source: &PriceSource) -> ModelInfo {
let pricing = self.pricing.unwrap_or_default();
let price = pricing.price();
let price_tiers = match price {
Some(base) => pricing
.overrides
.unwrap_or_default()
.iter()
.filter_map(|o| o.tier(base))
.collect::<Vec<_>>(),
None => Vec::new(),
};
ModelInfo {
id: self.id,
context_length: self.context_length,
max_output_tokens: self.top_provider.and_then(|t| t.max_completion_tokens),
accepts_images: self
.architecture
.and_then(|a| a.input_modalities)
.map(|m| m.iter().any(|x| x == "image")),
accepts_tools: self
.supported_parameters
.map(|p| p.iter().any(|x| x == "tools")),
price_source: price.map(|_| source.clone()),
price,
price_tiers,
}
}
}
fn scaled(value: Option<&str>, places: u32) -> u64 {
let Some(raw) = value.map(str::trim).filter(|v| !v.is_empty()) else {
return 0;
};
let (int, frac) = match raw.split_once('.') {
Some((i, f)) => (i, f),
None => (raw, ""),
};
if int.is_empty() && frac.is_empty() {
return 0;
}
if !int.bytes().all(|b| b.is_ascii_digit()) || !frac.bytes().all(|b| b.is_ascii_digit()) {
return 0;
}
let places = places as usize;
let round_up = frac.as_bytes().get(places).is_some_and(|b| *b >= b'5');
let mut digits = String::with_capacity(int.len() + places);
digits.push_str(int);
for i in 0..places {
digits.push(*frac.as_bytes().get(i).unwrap_or(&b'0') as char);
}
let trimmed = digits.trim_start_matches('0');
let base: u64 = if trimmed.is_empty() {
0
} else {
trimmed.parse().unwrap_or(0)
};
base.saturating_add(u64::from(round_up))
}
fn rate(value: Option<&str>) -> u64 {
scaled(value, 12)
}
fn per_request(value: Option<&str>) -> u64 {
scaled(value, 6)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::failures::{json_response, serve};
const CASES: &[(&str, u64)] = &[
("0.0000002574", 257_400),
("0.00000125", 1_250_000),
("0.0000025", 2_500_000),
("0.000003", 3_000_000),
("0.5", 500_000_000_000),
("2", 2_000_000_000_000),
("0", 0),
("0.0000000000005", 1),
("0.0000000000004", 0),
("0.0000006000000000000001", 600_000),
("0.000000015", 15_000),
("0.00000003", 30_000),
("0.00000006", 60_000),
("0.000000063", 63_000),
("0.00000012", 120_000),
("0.00000024", 240_000),
("0.00000096", 960_000),
("0.000000966", 966_000),
("0.00000384", 3_840_000),
];
const FLOAT_TRAPS: &[&str] = &[
"0.0000000000005",
"0.000000015",
"0.00000003",
"0.00000006",
"0.000000063",
"0.00000012",
"0.00000024",
"0.00000096",
"0.000000966",
"0.00000384",
];
#[test]
fn f4_a_decimal_rate_becomes_an_exact_integer() {
for (raw, want) in CASES {
assert_eq!(rate(Some(raw)), *want, "{raw}");
}
}
#[test]
fn f4_the_conversion_is_not_a_float_multiply() {
let disagreeing: Vec<&str> = CASES
.iter()
.filter(|(raw, want)| (raw.parse::<f64>().unwrap() * 1e12) as u64 != *want)
.map(|(raw, _)| *raw)
.collect();
assert_eq!(
disagreeing, FLOAT_TRAPS,
"the table must contain exactly the cases an f64 multiply gets wrong, \
or it has stopped discriminating between the two implementations"
);
assert!(
!FLOAT_TRAPS.is_empty(),
"a control that discriminates on nothing is not a control"
);
for raw in FLOAT_TRAPS {
let naive = (raw.parse::<f64>().unwrap() * 1e12) as u64;
let exact = rate(Some(raw));
assert_eq!(naive + 1, exact, "{raw}: f64 {naive}, exact {exact}");
}
}
#[test]
fn f4_a_malformed_rate_is_absent_rather_than_free() {
assert_eq!(rate(Some("not a number")), 0);
assert_eq!(rate(Some("-0.5")), 0, "a negative rate is not a price");
assert_eq!(rate(Some("")), 0);
assert_eq!(rate(None), 0);
assert_eq!(Pricing::default().price(), None);
let one = Pricing {
prompt: Some("0".into()),
..Default::default()
};
assert_eq!(one.price(), Some(Price::ZERO));
}
#[test]
fn a_per_request_charge_is_not_scaled_to_a_million() {
assert_eq!(per_request(Some("0.014")), 14_000);
assert_eq!(per_request(Some("0.005")), 5_000);
}
fn named(ids: &[&str]) -> Vec<ModelInfo> {
ids.iter()
.map(|id| ModelInfo {
id: (*id).into(),
..Default::default()
})
.collect()
}
#[test]
fn f5_an_exact_slug_matches() {
let models = named(&["deepseek/deepseek-chat", "openai/gpt-5"]);
let index = Index::new(&models);
assert_eq!(
index.find("deepseek/deepseek-chat").map(|m| m.id.as_str()),
Some("deepseek/deepseek-chat")
);
assert!(index.find("DeepSeek/DeepSeek-Chat").is_some());
}
#[test]
fn f5_the_one_normalisation_drops_a_single_leading_vendor_segment() {
let models = named(&["deepseek/deepseek-chat"]);
let index = Index::new(&models);
assert_eq!(
index.find("deepseek-chat").map(|m| m.id.as_str()),
Some("deepseek/deepseek-chat")
);
}
#[test]
fn f5_a_model_the_reference_does_not_carry_stays_none() {
let models = named(&["deepseek/deepseek-chat"]);
assert!(Index::new(&models).find("deepseek-v4-flash").is_none());
}
#[test]
fn f5_an_ambiguous_normalisation_is_a_miss_not_a_first_wins() {
let models = named(&["alpha/shared-name", "beta/shared-name"]);
let index = Index::new(&models);
assert!(index.find("shared-name").is_none());
assert!(index.find("alpha/shared-name").is_some());
assert!(index.find("beta/shared-name").is_some());
}
#[test]
fn f5_normalisation_is_not_applied_in_the_other_direction() {
let models = named(&["deepseek-chat"]);
assert!(Index::new(&models).find("deepseek/deepseek-chat").is_none());
assert_eq!(normalise("a/b/c"), "a/b/c");
assert_eq!(normalise("A/B"), "b");
assert_eq!(normalise("bare"), "bare");
}
const BODY: &str = r#"{"data":[
{"id":"deepseek/deepseek-chat","context_length":163840,
"pricing":{"prompt":"0.0000002574","completion":"0.0000010287"},
"top_provider":{"max_completion_tokens":16000},
"architecture":{"input_modalities":["text"]},
"supported_parameters":["tools","temperature"]},
{"id":"google/gemini-2.5-pro","context_length":1048576,
"pricing":{"prompt":"0.00000125","completion":"0.00001","web_search":"0.014",
"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"}]},
"architecture":{"input_modalities":["text","image"]},
"supported_parameters":["tools"]},
{"id":"someone/unpriced","context_length":8192}
]}"#;
#[tokio::test]
async fn the_live_shape_parses_into_model_info() {
let url = serve(json_response(BODY));
let models = Reference::at(&url).models().await.unwrap();
assert_eq!(models.len(), 3);
let ds = &models[0];
assert_eq!(ds.context_length, Some(163_840));
assert_eq!(ds.max_output_tokens, Some(16_000));
assert_eq!(ds.accepts_images, Some(false));
assert_eq!(ds.accepts_tools, Some(true));
assert_eq!(ds.price.unwrap().input, 257_400);
assert_eq!(ds.price.unwrap().output, 1_028_700);
assert!(ds.price_tiers.is_empty());
let gem = &models[1];
assert_eq!(gem.accepts_images, Some(true));
assert_eq!(gem.price.unwrap().per_server_tool_request, 14_000);
assert_eq!(gem.price_tiers.len(), 1);
let tier = gem.price_tiers[0];
assert_eq!(tier.min_prompt_tokens, 200_000);
assert_eq!(tier.price.input, 2_500_000);
assert_eq!(tier.price.output, 15_000_000);
assert_eq!(tier.price.cache_read, 250_000);
assert_eq!(tier.price.cache_write, 375_000);
let none = &models[2];
assert_eq!(none.price, None);
assert_eq!(none.price_source, None);
assert!(none.price_tiers.is_empty());
}
#[tokio::test]
async fn every_price_carries_its_provenance_and_the_two_cannot_disagree() {
let url = serve(json_response(BODY));
let models = Reference::at(&url).models().await.unwrap();
for m in &models {
assert_eq!(
m.price.is_some(),
m.price_source.is_some(),
"{} reports a price and a source that disagree",
m.id
);
}
assert_eq!(
models[0].price_source.as_ref().unwrap(),
&PriceSource::Reference("127.0.0.1".into()),
"a reference price names the host it came from, never the vendor"
);
}
#[tokio::test]
async fn the_catalogue_is_read_once_per_instance() {
let url = serve(json_response(BODY));
let reference = Reference::at(&url);
let first = reference.models().await.unwrap();
let second = reference.models().await.unwrap();
assert_eq!(first.len(), second.len());
assert_eq!(first[0].id, second[0].id);
}
#[test]
fn the_host_is_knowable_before_any_request_is_made() {
assert_eq!(Reference::new().host(), Some("openrouter.ai"));
assert_eq!(
Reference::at("https://user@mirror.example:8443/v1/models?x=1").host(),
Some("mirror.example")
);
assert_eq!(Reference::at("not a url").host(), None);
}
}