agent_sdk_providers/model_catalog/
modelsdev.rs1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use std::collections::HashMap;
4
5use super::{CatalogEntry, MODELS_DEV_URL, ModelCatalogSource, build_feed_client};
6use crate::model_capabilities::{PricePoint, Pricing};
7
8#[derive(serde::Deserialize)]
9struct ModelsDevCost {
10 #[serde(default)]
11 input: Option<f64>,
12 #[serde(default)]
13 output: Option<f64>,
14 #[serde(default)]
15 cache_read: Option<f64>,
16}
17
18#[derive(serde::Deserialize)]
19struct ModelsDevLimit {
20 #[serde(default)]
21 context: Option<u32>,
22 #[serde(default)]
23 output: Option<u32>,
24}
25
26#[derive(serde::Deserialize)]
27struct ModelsDevModel {
28 id: String,
29 #[serde(default)]
30 reasoning: Option<bool>,
31 #[serde(default)]
32 cost: Option<ModelsDevCost>,
33 #[serde(default)]
34 limit: Option<ModelsDevLimit>,
35}
36
37#[derive(serde::Deserialize)]
38struct ModelsDevProvider {
39 #[serde(default)]
40 models: HashMap<String, ModelsDevModel>,
41}
42
43fn map_modelsdev_provider(key: &str) -> String {
44 match key {
45 "google" => "gemini".to_owned(),
46 other => other.to_owned(),
47 }
48}
49
50fn pricing_from_modelsdev_cost(cost: &ModelsDevCost) -> Option<Pricing> {
51 if cost.input.is_none() && cost.output.is_none() && cost.cache_read.is_none() {
52 return None;
53 }
54 Some(Pricing {
55 input: cost.input.map(PricePoint::new),
56 output: cost.output.map(PricePoint::new),
57 cached_input: cost.cache_read.map(PricePoint::new),
58 notes: None,
59 })
60}
61
62pub fn parse_modelsdev(json: &str) -> Result<Vec<CatalogEntry>> {
68 let providers: HashMap<String, ModelsDevProvider> =
69 serde_json::from_str(json).context("failed to parse models.dev api.json")?;
70 let mut entries = Vec::new();
71 for (provider_key, provider_obj) in providers {
72 let provider = map_modelsdev_provider(&provider_key);
73 for model in provider_obj.models.into_values() {
74 let pricing = model.cost.as_ref().and_then(pricing_from_modelsdev_cost);
75 let context_window = model.limit.as_ref().and_then(|limit| limit.context);
76 let max_output_tokens = model.limit.and_then(|limit| limit.output);
77 entries.push(CatalogEntry {
78 provider: provider.clone(),
79 model_id: model.id,
80 context_window,
81 max_output_tokens,
82 pricing,
83 supports_thinking: model.reasoning,
84 });
85 }
86 }
87 Ok(entries)
88}
89
90pub struct ModelsDevSource {
92 client: reqwest::Client,
93 url: String,
94}
95
96impl Default for ModelsDevSource {
97 fn default() -> Self {
98 let client = match build_feed_client() {
99 Ok(c) => c,
100 Err(e) => {
101 log::warn!("model-catalog feed client build failed, using default client: {e}");
102 reqwest::Client::new()
103 }
104 };
105 Self {
106 client,
107 url: MODELS_DEV_URL.to_owned(),
108 }
109 }
110}
111
112impl ModelsDevSource {
113 pub fn new() -> Result<Self> {
119 Ok(Self {
120 client: build_feed_client()?,
121 url: MODELS_DEV_URL.to_owned(),
122 })
123 }
124
125 #[must_use]
127 pub fn with_url(mut self, url: impl Into<String>) -> Self {
128 self.url = url.into();
129 self
130 }
131}
132
133#[async_trait]
134impl ModelCatalogSource for ModelsDevSource {
135 async fn fetch(&self) -> Result<Vec<CatalogEntry>> {
136 let body = self
137 .client
138 .get(&self.url)
139 .send()
140 .await
141 .context("models.dev request failed")?
142 .error_for_status()
143 .context("models.dev returned an error status")?
144 .text()
145 .await
146 .context("failed to read models.dev body")?;
147 parse_modelsdev(&body)
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 const MODELSDEV_FIXTURE: &str = r#"{
156 "anthropic": {
157 "id": "anthropic",
158 "name": "Anthropic",
159 "models": {
160 "claude-sonnet-4-5": {
161 "id": "claude-sonnet-4-5",
162 "name": "Claude Sonnet 4.5",
163 "reasoning": true,
164 "limit": { "context": 1000000, "output": 64000 },
165 "cost": { "input": 3, "output": 15, "cache_read": 0.3, "cache_write": 3.75 }
166 }
167 }
168 },
169 "openai": {
170 "id": "openai",
171 "name": "OpenAI",
172 "models": {
173 "gpt-5.2": {
174 "id": "gpt-5.2",
175 "name": "GPT-5.2",
176 "reasoning": true,
177 "limit": { "context": 400000, "output": 128000 },
178 "cost": { "input": 1.75, "output": 14, "cache_read": 0.175 }
179 }
180 }
181 },
182 "google": {
183 "id": "google",
184 "name": "Google",
185 "models": {
186 "gemini-2.5-pro": {
187 "id": "gemini-2.5-pro",
188 "name": "Gemini 2.5 Pro",
189 "reasoning": true,
190 "limit": { "context": 1048576, "output": 65536 },
191 "cost": { "input": 1.25, "output": 10, "cache_read": 0.31, "cache_write": 2.375 }
192 }
193 }
194 }
195 }"#;
196
197 fn find<'a>(
198 entries: &'a [CatalogEntry],
199 provider: &str,
200 model: &str,
201 ) -> Result<&'a CatalogEntry> {
202 entries
203 .iter()
204 .find(|e| e.provider == provider && e.model_id == model)
205 .with_context(|| format!("missing {provider}/{model}"))
206 }
207
208 #[test]
209 fn parse_modelsdev_maps_pricing_limits_and_provider() -> Result<()> {
210 let entries = parse_modelsdev(MODELSDEV_FIXTURE)?;
211 assert_eq!(entries.len(), 3);
212
213 let claude = find(&entries, "anthropic", "claude-sonnet-4-5")?;
214 assert_eq!(claude.context_window, Some(1_000_000));
215 assert_eq!(claude.max_output_tokens, Some(64_000));
216 assert_eq!(claude.supports_thinking, Some(true));
217 let pricing = claude.pricing.context("claude pricing missing")?;
218 assert!(
219 (pricing.input.context("input")?.usd_per_million_tokens - 3.0).abs() < f64::EPSILON
220 );
221 assert!(
222 (pricing.output.context("output")?.usd_per_million_tokens - 15.0).abs() < f64::EPSILON
223 );
224 assert!(
225 (pricing
226 .cached_input
227 .context("cache")?
228 .usd_per_million_tokens
229 - 0.3)
230 .abs()
231 < f64::EPSILON
232 );
233
234 let gemini = find(&entries, "gemini", "gemini-2.5-pro")?;
236 assert_eq!(gemini.context_window, Some(1_048_576));
237 assert_eq!(gemini.max_output_tokens, Some(65_536));
238
239 assert!(!entries.iter().any(|e| e.provider == "google"));
241 Ok(())
242 }
243}