use anyhow::{Context, Result};
use async_trait::async_trait;
use std::collections::HashMap;
use super::{
CatalogEntry, MODELS_DEV_URL, ModelCatalogSource, PricingTier, build_feed_client,
merge_band_over_base,
};
use crate::model_capabilities::{PricePoint, Pricing};
#[derive(serde::Deserialize)]
struct ModelsDevCost {
#[serde(default)]
input: Option<f64>,
#[serde(default)]
output: Option<f64>,
#[serde(default)]
cache_read: Option<f64>,
#[serde(default)]
cache_write: Option<f64>,
#[serde(default)]
reasoning: Option<f64>,
#[serde(default)]
tiers: Vec<ModelsDevTier>,
}
#[derive(serde::Deserialize)]
struct ModelsDevTier {
#[serde(default)]
input: Option<f64>,
#[serde(default)]
output: Option<f64>,
#[serde(default)]
cache_read: Option<f64>,
#[serde(default)]
cache_write: Option<f64>,
#[serde(default)]
reasoning: Option<f64>,
#[serde(default)]
tier: Option<ModelsDevTierBound>,
}
#[derive(serde::Deserialize)]
struct ModelsDevTierBound {
#[serde(rename = "type", default)]
bound_type: Option<String>,
#[serde(default)]
size: Option<u32>,
}
#[derive(serde::Deserialize)]
struct ModelsDevLimit {
#[serde(default)]
context: Option<u32>,
#[serde(default)]
output: Option<u32>,
}
#[derive(serde::Deserialize)]
struct ModelsDevModel {
id: String,
#[serde(default)]
reasoning: Option<bool>,
#[serde(default)]
cost: Option<ModelsDevCost>,
#[serde(default)]
limit: Option<ModelsDevLimit>,
}
#[derive(serde::Deserialize)]
struct ModelsDevProvider {
#[serde(default)]
models: HashMap<String, ModelsDevModel>,
}
fn map_modelsdev_provider(key: &str) -> String {
match key {
"google" => "gemini".to_owned(),
other => other.to_owned(),
}
}
fn modelsdev_price_per_million(usd_per_million_tokens: f64) -> Option<PricePoint> {
(usd_per_million_tokens.is_finite() && usd_per_million_tokens > 0.0)
.then(|| PricePoint::new(usd_per_million_tokens))
}
fn pricing_from_rates(
input: Option<f64>,
output: Option<f64>,
cache_read: Option<f64>,
cache_write: Option<f64>,
reasoning: Option<f64>,
) -> Option<Pricing> {
let input = input.and_then(modelsdev_price_per_million);
let output = output.and_then(modelsdev_price_per_million);
let cached_input = cache_read.and_then(modelsdev_price_per_million);
let cache_write = cache_write.and_then(modelsdev_price_per_million);
let reasoning = reasoning.and_then(modelsdev_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 pricing_from_modelsdev_cost(cost: &ModelsDevCost) -> Option<Pricing> {
pricing_from_rates(
cost.input,
cost.output,
cost.cache_read,
cost.cache_write,
cost.reasoning,
)
}
fn tiers_from_modelsdev_cost(cost: &ModelsDevCost) -> Option<Vec<PricingTier>> {
let base = pricing_from_modelsdev_cost(cost);
cost.tiers
.iter()
.map(|tier| {
let bound = tier.tier.as_ref()?;
if bound.bound_type.as_deref() != Some("context") {
return None;
}
let band = pricing_from_rates(
tier.input,
tier.output,
tier.cache_read,
tier.cache_write,
tier.reasoning,
)?;
Some(PricingTier {
min_input_tokens: bound.size?,
pricing: merge_band_over_base(base, band),
})
})
.collect()
}
pub fn parse_modelsdev(json: &str) -> Result<Vec<CatalogEntry>> {
let providers: HashMap<String, ModelsDevProvider> =
serde_json::from_str(json).context("failed to parse models.dev api.json")?;
let mut entries = Vec::new();
for (provider_key, provider_obj) in providers {
let provider = map_modelsdev_provider(&provider_key);
for model in provider_obj.models.into_values() {
let tiers = model
.cost
.as_ref()
.map_or_else(|| Some(Vec::new()), tiers_from_modelsdev_cost);
let (pricing, pricing_tiers) = match tiers {
Some(tiers) => (
model.cost.as_ref().and_then(pricing_from_modelsdev_cost),
tiers,
),
None => (None, Vec::new()),
};
let context_window = model.limit.as_ref().and_then(|limit| limit.context);
let max_output_tokens = model.limit.and_then(|limit| limit.output);
entries.push(CatalogEntry {
provider: provider.clone(),
model_id: model.id,
context_window,
max_output_tokens,
pricing,
pricing_tiers,
supports_thinking: model.reasoning,
});
}
}
Ok(entries)
}
pub struct ModelsDevSource {
client: reqwest::Client,
url: String,
}
impl Default for ModelsDevSource {
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: MODELS_DEV_URL.to_owned(),
}
}
}
impl ModelsDevSource {
pub fn new() -> Result<Self> {
Ok(Self {
client: build_feed_client()?,
url: MODELS_DEV_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 ModelsDevSource {
async fn fetch(&self) -> Result<Vec<CatalogEntry>> {
let body = self
.client
.get(&self.url)
.send()
.await
.context("models.dev request failed")?
.error_for_status()
.context("models.dev returned an error status")?
.text()
.await
.context("failed to read models.dev body")?;
parse_modelsdev(&body)
}
}
#[cfg(test)]
mod tests {
use super::*;
const MODELSDEV_FIXTURE: &str = r#"{
"anthropic": {
"id": "anthropic",
"name": "Anthropic",
"models": {
"claude-sonnet-4-5": {
"id": "claude-sonnet-4-5",
"name": "Claude Sonnet 4.5",
"reasoning": true,
"limit": { "context": 1000000, "output": 64000 },
"cost": { "input": 3, "output": 15, "cache_read": 0.3, "cache_write": 3.75 }
}
}
},
"openai": {
"id": "openai",
"name": "OpenAI",
"models": {
"gpt-5.2": {
"id": "gpt-5.2",
"name": "GPT-5.2",
"reasoning": true,
"limit": { "context": 400000, "output": 128000 },
"cost": { "input": 1.75, "output": 14, "cache_read": 0.175 }
}
}
},
"google": {
"id": "google",
"name": "Google",
"models": {
"gemini-2.5-pro": {
"id": "gemini-2.5-pro",
"name": "Gemini 2.5 Pro",
"reasoning": true,
"limit": { "context": 1048576, "output": 65536 },
"cost": { "input": 1.25, "output": 10, "cache_read": 0.31, "cache_write": 2.375 }
}
}
}
}"#;
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_modelsdev_maps_pricing_limits_and_provider() -> Result<()> {
let entries = parse_modelsdev(MODELSDEV_FIXTURE)?;
assert_eq!(entries.len(), 3);
let claude = find(&entries, "anthropic", "claude-sonnet-4-5")?;
assert_eq!(claude.context_window, Some(1_000_000));
assert_eq!(claude.max_output_tokens, Some(64_000));
assert_eq!(claude.supports_thinking, Some(true));
let pricing = claude.pricing.context("claude pricing missing")?;
assert!(
(pricing.input.context("input")?.usd_per_million_tokens - 3.0).abs() < f64::EPSILON
);
assert!(
(pricing.output.context("output")?.usd_per_million_tokens - 15.0).abs() < f64::EPSILON
);
assert!(
(pricing
.cached_input
.context("cache")?
.usd_per_million_tokens
- 0.3)
.abs()
< f64::EPSILON
);
let gemini = find(&entries, "gemini", "gemini-2.5-pro")?;
assert_eq!(gemini.context_window, Some(1_048_576));
assert_eq!(gemini.max_output_tokens, Some(65_536));
assert!(!entries.iter().any(|e| e.provider == "google"));
Ok(())
}
const ZERO_COST_FIXTURE: &str = r#"{
"openai": {
"id": "openai",
"name": "OpenAI",
"models": {
"free-model": {
"id": "free-model",
"cost": { "input": 0, "output": 0 }
},
"half-priced-model": {
"id": "half-priced-model",
"cost": { "input": 2, "output": 0 }
}
}
}
}"#;
const MODELSDEV_TIERS_FIXTURE: &str = r#"{
"openai": {
"id": "openai",
"name": "OpenAI",
"models": {
"gpt-5.4": {
"id": "gpt-5.4",
"cost": {
"input": 2.5,
"output": 15,
"cache_read": 0.25,
"tiers": [
{
"input": 5,
"output": 22.5,
"cache_read": 0.5,
"tier": { "type": "context", "size": 272000 }
}
]
}
},
"unknown-tier-model": {
"id": "unknown-tier-model",
"cost": {
"input": 1,
"output": 2,
"tiers": [
{
"input": 9,
"output": 9,
"tier": { "type": "throughput", "size": 100 }
}
]
}
}
}
},
"anthropic": {
"id": "anthropic",
"name": "Anthropic",
"models": {
"claude-haiku-4-5": {
"id": "claude-haiku-4-5",
"cost": { "input": 1, "output": 5, "cache_read": 0.1, "cache_write": 1.25 }
}
}
}
}"#;
#[test]
fn parse_modelsdev_reads_tiers_and_cache_write() -> Result<()> {
let entries = parse_modelsdev(MODELSDEV_TIERS_FIXTURE)?;
let tiered = find(&entries, "openai", "gpt-5.4")?;
let base = tiered.pricing.context("base pricing missing")?;
assert!((base.input.context("input")?.usd_per_million_tokens - 2.5).abs() < f64::EPSILON);
assert_eq!(tiered.pricing_tiers.len(), 1);
let tier = tiered.pricing_tiers[0];
assert_eq!(tier.min_input_tokens, 272_000);
assert!(
(tier
.pricing
.output
.context("tier output")?
.usd_per_million_tokens
- 22.5)
.abs()
< f64::EPSILON
);
let haiku = find(&entries, "anthropic", "claude-haiku-4-5")?;
let pricing = haiku.pricing.context("pricing missing")?;
assert!(
(pricing
.cache_write
.context("cache_write")?
.usd_per_million_tokens
- 1.25)
.abs()
< f64::EPSILON
);
Ok(())
}
#[test]
fn parse_modelsdev_reads_reasoning_rate() -> Result<()> {
const REASONING_FIXTURE: &str = r#"{
"alibaba": {
"id": "alibaba",
"models": {
"qwen3-32b": {
"id": "qwen3-32b",
"cost": { "input": 0.7, "output": 2.8, "reasoning": 8.4 }
}
}
}
}"#;
let entries = parse_modelsdev(REASONING_FIXTURE)?;
let row = find(&entries, "alibaba", "qwen3-32b")?;
let pricing = row.pricing.context("pricing missing")?;
assert!(
(pricing
.reasoning
.context("reasoning")?
.usd_per_million_tokens
- 8.4)
.abs()
< f64::EPSILON
);
Ok(())
}
#[test]
fn parse_modelsdev_reads_tier_reasoning_rate() -> Result<()> {
use agent_sdk_foundation::llm::Usage;
const TIER_REASONING_FIXTURE: &str = r#"{
"openai": {
"id": "openai",
"models": {
"reasoner": {
"id": "reasoner",
"cost": {
"input": 1,
"output": 2,
"reasoning": 3,
"tiers": [
{
"input": 2,
"output": 4,
"reasoning": 9,
"tier": { "type": "context", "size": 200000 }
}
]
}
}
}
}
}"#;
let entries = parse_modelsdev(TIER_REASONING_FIXTURE)?;
let row = find(&entries, "openai", "reasoner")?;
assert_eq!(row.pricing_tiers.len(), 1);
let tier = row.pricing_tiers[0];
assert!(
(tier
.pricing
.reasoning
.context("tier reasoning")?
.usd_per_million_tokens
- 9.0)
.abs()
< f64::EPSILON,
"the tier's own reasoning rate must win over the base's",
);
let usage = Usage {
input_tokens: 300_000,
output_tokens: 100_000,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
};
let cost = tier
.pricing
.estimate_cost_usd(&usage)
.context("tier prices the call")?;
assert!((cost - 1.5).abs() < 1e-9, "unexpected cost: {cost}");
Ok(())
}
#[test]
fn parse_modelsdev_drops_a_row_with_an_uninterpretable_tier() -> Result<()> {
let entries = parse_modelsdev(MODELSDEV_TIERS_FIXTURE)?;
let unknown = find(&entries, "openai", "unknown-tier-model")?;
assert!(unknown.pricing.is_none());
assert!(unknown.pricing_tiers.is_empty());
Ok(())
}
#[test]
fn parse_modelsdev_survives_a_tier_with_a_missing_bound() -> Result<()> {
const DRIFTED_FIXTURE: &str = r#"{
"openai": {
"id": "openai",
"models": {
"healthy-model": {
"id": "healthy-model",
"cost": { "input": 1, "output": 2 }
},
"drifted-tier-model": {
"id": "drifted-tier-model",
"cost": {
"input": 1,
"output": 2,
"tiers": [
{ "input": 9, "output": 9, "tier": { "type": "context" } }
]
}
},
"bound-less-tier-model": {
"id": "bound-less-tier-model",
"cost": {
"input": 1,
"output": 2,
"tiers": [{ "input": 9, "output": 9 }]
}
}
}
}
}"#;
let entries = parse_modelsdev(DRIFTED_FIXTURE)?;
assert_eq!(
entries.len(),
3,
"the parse must not abort on a drifted row"
);
let healthy = find(&entries, "openai", "healthy-model")?;
assert!(healthy.pricing.is_some());
for model in ["drifted-tier-model", "bound-less-tier-model"] {
let drifted = find(&entries, "openai", model)?;
assert!(drifted.pricing.is_none(), "{model} must drop its pricing");
assert!(drifted.pricing_tiers.is_empty());
}
Ok(())
}
#[test]
fn parse_modelsdev_drops_zero_rates() -> Result<()> {
let entries = parse_modelsdev(ZERO_COST_FIXTURE)?;
let free = find(&entries, "openai", "free-model")?;
assert!(free.pricing.is_none());
let half = find(&entries, "openai", "half-priced-model")?;
let pricing = half.pricing.context("input rate must survive")?;
assert!(
(pricing.input.context("input")?.usd_per_million_tokens - 2.0).abs() < f64::EPSILON
);
assert!(pricing.output.is_none());
Ok(())
}
}