Skip to main content

agent_sdk_providers/model_catalog/
modelsdev.rs

1use 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
62/// Parse the models.dev `api.json` body into catalog entries.
63///
64/// # Errors
65///
66/// Returns an error if the body is not valid models.dev JSON.
67pub 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
90/// The default capability/pricing feed: <https://models.dev/api.json>.
91pub 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    /// Create a source pointing at the canonical models.dev endpoint.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if the feed HTTP client cannot be constructed.
118    pub fn new() -> Result<Self> {
119        Ok(Self {
120            client: build_feed_client()?,
121            url: MODELS_DEV_URL.to_owned(),
122        })
123    }
124
125    /// Override the feed URL (e.g. for a mirror or a local test server).
126    #[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        // `google` is remapped to our `gemini` provider name.
235        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        // No model is keyed under the raw `google` provider name.
240        assert!(!entries.iter().any(|e| e.provider == "google"));
241        Ok(())
242    }
243}