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::{
6    CatalogEntry, MODELS_DEV_URL, ModelCatalogSource, PricingTier, build_feed_client,
7    merge_band_over_base,
8};
9use crate::model_capabilities::{PricePoint, Pricing};
10
11#[derive(serde::Deserialize)]
12struct ModelsDevCost {
13    #[serde(default)]
14    input: Option<f64>,
15    #[serde(default)]
16    output: Option<f64>,
17    #[serde(default)]
18    cache_read: Option<f64>,
19    #[serde(default)]
20    cache_write: Option<f64>,
21    #[serde(default)]
22    reasoning: Option<f64>,
23    /// Long-context price bands. The feed publishes these for frontier models
24    /// (GPT-5.x above 272K input tokens, Gemini / Claude above 200K).
25    #[serde(default)]
26    tiers: Vec<ModelsDevTier>,
27}
28
29#[derive(serde::Deserialize)]
30struct ModelsDevTier {
31    #[serde(default)]
32    input: Option<f64>,
33    #[serde(default)]
34    output: Option<f64>,
35    #[serde(default)]
36    cache_read: Option<f64>,
37    #[serde(default)]
38    cache_write: Option<f64>,
39    #[serde(default)]
40    reasoning: Option<f64>,
41    #[serde(default)]
42    tier: Option<ModelsDevTierBound>,
43}
44
45/// Every field is optional so that a feed row whose shape has drifted cannot
46/// abort the parse of the whole document: one required field missing anywhere
47/// in a 3MB body would fail `serde_json::from_str` outright, leaving the
48/// catalog empty and every feed-priced model unpriced on the next refresh.
49/// A bound that does not arrive intact is simply un-interpretable, and the row
50/// carrying it drops its pricing (see [`tiers_from_modelsdev_cost`]).
51#[derive(serde::Deserialize)]
52struct ModelsDevTierBound {
53    /// The only bound the feed publishes today is `context`; a row bounded by
54    /// anything else cannot be priced from data this parser understands.
55    #[serde(rename = "type", default)]
56    bound_type: Option<String>,
57    #[serde(default)]
58    size: Option<u32>,
59}
60
61#[derive(serde::Deserialize)]
62struct ModelsDevLimit {
63    #[serde(default)]
64    context: Option<u32>,
65    #[serde(default)]
66    output: Option<u32>,
67}
68
69#[derive(serde::Deserialize)]
70struct ModelsDevModel {
71    id: String,
72    #[serde(default)]
73    reasoning: Option<bool>,
74    #[serde(default)]
75    cost: Option<ModelsDevCost>,
76    #[serde(default)]
77    limit: Option<ModelsDevLimit>,
78}
79
80#[derive(serde::Deserialize)]
81struct ModelsDevProvider {
82    #[serde(default)]
83    models: HashMap<String, ModelsDevModel>,
84}
85
86fn map_modelsdev_provider(key: &str) -> String {
87    match key {
88        "google" => "gemini".to_owned(),
89        other => other.to_owned(),
90    }
91}
92
93/// A models.dev rate (USD per 1M tokens) is only usable when it is a finite
94/// positive number: the feed reports `0` both for genuinely free models and
95/// for rows whose price it does not know, and either way a zero rate would
96/// price the component as free. Mirrors the `OpenRouter` parser's guard.
97fn modelsdev_price_per_million(usd_per_million_tokens: f64) -> Option<PricePoint> {
98    (usd_per_million_tokens.is_finite() && usd_per_million_tokens > 0.0)
99        .then(|| PricePoint::new(usd_per_million_tokens))
100}
101
102fn pricing_from_rates(
103    input: Option<f64>,
104    output: Option<f64>,
105    cache_read: Option<f64>,
106    cache_write: Option<f64>,
107    reasoning: Option<f64>,
108) -> Option<Pricing> {
109    let input = input.and_then(modelsdev_price_per_million);
110    let output = output.and_then(modelsdev_price_per_million);
111    let cached_input = cache_read.and_then(modelsdev_price_per_million);
112    let cache_write = cache_write.and_then(modelsdev_price_per_million);
113    let reasoning = reasoning.and_then(modelsdev_price_per_million);
114
115    if input.is_none()
116        && output.is_none()
117        && cached_input.is_none()
118        && cache_write.is_none()
119        && reasoning.is_none()
120    {
121        return None;
122    }
123    Some(Pricing {
124        input,
125        output,
126        cached_input,
127        cache_write,
128        reasoning,
129        notes: None,
130    })
131}
132
133fn pricing_from_modelsdev_cost(cost: &ModelsDevCost) -> Option<Pricing> {
134    pricing_from_rates(
135        cost.input,
136        cost.output,
137        cost.cache_read,
138        cost.cache_write,
139        cost.reasoning,
140    )
141}
142
143/// The `context` tiers of a cost row, ascending.
144///
145/// `None` — meaning "do not price this model from this feed at all" — when the
146/// row carries a tier this parser cannot interpret: an unknown bound type, or
147/// a band with no usable rates. A tier exists precisely because the base rates
148/// stop applying above its threshold, so a tier that cannot be understood
149/// cannot be safely ignored — keeping the base rates would price every
150/// long-context call at a fraction of its true cost. Dropping the row instead
151/// lets pricing fall back to another source, or to the documented
152/// "cost unknown" fail-open.
153fn tiers_from_modelsdev_cost(cost: &ModelsDevCost) -> Option<Vec<PricingTier>> {
154    let base = pricing_from_modelsdev_cost(cost);
155    cost.tiers
156        .iter()
157        .map(|tier| {
158            let bound = tier.tier.as_ref()?;
159            if bound.bound_type.as_deref() != Some("context") {
160                return None;
161            }
162            // A models.dev tier is a full cost row (`CostTier extends Cost`),
163            // so it may restate the reasoning rate; when it does not, the base
164            // row's carries over through `merge_band_over_base`.
165            let band = pricing_from_rates(
166                tier.input,
167                tier.output,
168                tier.cache_read,
169                tier.cache_write,
170                tier.reasoning,
171            )?;
172            Some(PricingTier {
173                // The bound is inclusive: the models.dev SDK schema documents
174                // `tier.size` as "Context size (in tokens) at which this tier
175                // starts to apply" / "Pricing that applies from a given context
176                // size upward". (The `context_over_200k` field is a legacy
177                // projection the same schema marks "Prefer `tiers`", not the
178                // definition — an earlier reading that took it as exclusive was
179                // wrong.)
180                min_input_tokens: bound.size?,
181                pricing: merge_band_over_base(base, band),
182            })
183        })
184        .collect()
185}
186
187/// Parse the models.dev `api.json` body into catalog entries.
188///
189/// # Errors
190///
191/// Returns an error if the body is not valid models.dev JSON.
192pub fn parse_modelsdev(json: &str) -> Result<Vec<CatalogEntry>> {
193    let providers: HashMap<String, ModelsDevProvider> =
194        serde_json::from_str(json).context("failed to parse models.dev api.json")?;
195    let mut entries = Vec::new();
196    for (provider_key, provider_obj) in providers {
197        let provider = map_modelsdev_provider(&provider_key);
198        for model in provider_obj.models.into_values() {
199            // An un-interpretable tier drops the row's pricing entirely: its
200            // base rates do not apply above the tier's threshold, so keeping
201            // them would under-price every long-context call.
202            let tiers = model
203                .cost
204                .as_ref()
205                .map_or_else(|| Some(Vec::new()), tiers_from_modelsdev_cost);
206            let (pricing, pricing_tiers) = match tiers {
207                Some(tiers) => (
208                    model.cost.as_ref().and_then(pricing_from_modelsdev_cost),
209                    tiers,
210                ),
211                None => (None, Vec::new()),
212            };
213            let context_window = model.limit.as_ref().and_then(|limit| limit.context);
214            let max_output_tokens = model.limit.and_then(|limit| limit.output);
215            entries.push(CatalogEntry {
216                provider: provider.clone(),
217                model_id: model.id,
218                context_window,
219                max_output_tokens,
220                pricing,
221                pricing_tiers,
222                supports_thinking: model.reasoning,
223            });
224        }
225    }
226    Ok(entries)
227}
228
229/// The default capability/pricing feed: <https://models.dev/api.json>.
230pub struct ModelsDevSource {
231    client: reqwest::Client,
232    url: String,
233}
234
235impl Default for ModelsDevSource {
236    fn default() -> Self {
237        let client = match build_feed_client() {
238            Ok(c) => c,
239            Err(e) => {
240                log::warn!("model-catalog feed client build failed, using default client: {e}");
241                reqwest::Client::new()
242            }
243        };
244        Self {
245            client,
246            url: MODELS_DEV_URL.to_owned(),
247        }
248    }
249}
250
251impl ModelsDevSource {
252    /// Create a source pointing at the canonical models.dev endpoint.
253    ///
254    /// # Errors
255    ///
256    /// Returns an error if the feed HTTP client cannot be constructed.
257    pub fn new() -> Result<Self> {
258        Ok(Self {
259            client: build_feed_client()?,
260            url: MODELS_DEV_URL.to_owned(),
261        })
262    }
263
264    /// Override the feed URL (e.g. for a mirror or a local test server).
265    #[must_use]
266    pub fn with_url(mut self, url: impl Into<String>) -> Self {
267        self.url = url.into();
268        self
269    }
270}
271
272#[async_trait]
273impl ModelCatalogSource for ModelsDevSource {
274    async fn fetch(&self) -> Result<Vec<CatalogEntry>> {
275        let body = self
276            .client
277            .get(&self.url)
278            .send()
279            .await
280            .context("models.dev request failed")?
281            .error_for_status()
282            .context("models.dev returned an error status")?
283            .text()
284            .await
285            .context("failed to read models.dev body")?;
286        parse_modelsdev(&body)
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    const MODELSDEV_FIXTURE: &str = r#"{
295      "anthropic": {
296        "id": "anthropic",
297        "name": "Anthropic",
298        "models": {
299          "claude-sonnet-4-5": {
300            "id": "claude-sonnet-4-5",
301            "name": "Claude Sonnet 4.5",
302            "reasoning": true,
303            "limit": { "context": 1000000, "output": 64000 },
304            "cost": { "input": 3, "output": 15, "cache_read": 0.3, "cache_write": 3.75 }
305          }
306        }
307      },
308      "openai": {
309        "id": "openai",
310        "name": "OpenAI",
311        "models": {
312          "gpt-5.2": {
313            "id": "gpt-5.2",
314            "name": "GPT-5.2",
315            "reasoning": true,
316            "limit": { "context": 400000, "output": 128000 },
317            "cost": { "input": 1.75, "output": 14, "cache_read": 0.175 }
318          }
319        }
320      },
321      "google": {
322        "id": "google",
323        "name": "Google",
324        "models": {
325          "gemini-2.5-pro": {
326            "id": "gemini-2.5-pro",
327            "name": "Gemini 2.5 Pro",
328            "reasoning": true,
329            "limit": { "context": 1048576, "output": 65536 },
330            "cost": { "input": 1.25, "output": 10, "cache_read": 0.31, "cache_write": 2.375 }
331          }
332        }
333      }
334    }"#;
335
336    fn find<'a>(
337        entries: &'a [CatalogEntry],
338        provider: &str,
339        model: &str,
340    ) -> Result<&'a CatalogEntry> {
341        entries
342            .iter()
343            .find(|e| e.provider == provider && e.model_id == model)
344            .with_context(|| format!("missing {provider}/{model}"))
345    }
346
347    #[test]
348    fn parse_modelsdev_maps_pricing_limits_and_provider() -> Result<()> {
349        let entries = parse_modelsdev(MODELSDEV_FIXTURE)?;
350        assert_eq!(entries.len(), 3);
351
352        let claude = find(&entries, "anthropic", "claude-sonnet-4-5")?;
353        assert_eq!(claude.context_window, Some(1_000_000));
354        assert_eq!(claude.max_output_tokens, Some(64_000));
355        assert_eq!(claude.supports_thinking, Some(true));
356        let pricing = claude.pricing.context("claude pricing missing")?;
357        assert!(
358            (pricing.input.context("input")?.usd_per_million_tokens - 3.0).abs() < f64::EPSILON
359        );
360        assert!(
361            (pricing.output.context("output")?.usd_per_million_tokens - 15.0).abs() < f64::EPSILON
362        );
363        assert!(
364            (pricing
365                .cached_input
366                .context("cache")?
367                .usd_per_million_tokens
368                - 0.3)
369                .abs()
370                < f64::EPSILON
371        );
372
373        // `google` is remapped to our `gemini` provider name.
374        let gemini = find(&entries, "gemini", "gemini-2.5-pro")?;
375        assert_eq!(gemini.context_window, Some(1_048_576));
376        assert_eq!(gemini.max_output_tokens, Some(65_536));
377
378        // No model is keyed under the raw `google` provider name.
379        assert!(!entries.iter().any(|e| e.provider == "google"));
380        Ok(())
381    }
382
383    const ZERO_COST_FIXTURE: &str = r#"{
384      "openai": {
385        "id": "openai",
386        "name": "OpenAI",
387        "models": {
388          "free-model": {
389            "id": "free-model",
390            "cost": { "input": 0, "output": 0 }
391          },
392          "half-priced-model": {
393            "id": "half-priced-model",
394            "cost": { "input": 2, "output": 0 }
395          }
396        }
397      }
398    }"#;
399
400    /// Mirrors the live feed's shape for a tiered model (`cost.tiers[].tier =
401    /// { type: "context", size }`), plus a row whose tier is bounded by
402    /// something this parser does not understand.
403    const MODELSDEV_TIERS_FIXTURE: &str = r#"{
404      "openai": {
405        "id": "openai",
406        "name": "OpenAI",
407        "models": {
408          "gpt-5.4": {
409            "id": "gpt-5.4",
410            "cost": {
411              "input": 2.5,
412              "output": 15,
413              "cache_read": 0.25,
414              "tiers": [
415                {
416                  "input": 5,
417                  "output": 22.5,
418                  "cache_read": 0.5,
419                  "tier": { "type": "context", "size": 272000 }
420                }
421              ]
422            }
423          },
424          "unknown-tier-model": {
425            "id": "unknown-tier-model",
426            "cost": {
427              "input": 1,
428              "output": 2,
429              "tiers": [
430                {
431                  "input": 9,
432                  "output": 9,
433                  "tier": { "type": "throughput", "size": 100 }
434                }
435              ]
436            }
437          }
438        }
439      },
440      "anthropic": {
441        "id": "anthropic",
442        "name": "Anthropic",
443        "models": {
444          "claude-haiku-4-5": {
445            "id": "claude-haiku-4-5",
446            "cost": { "input": 1, "output": 5, "cache_read": 0.1, "cache_write": 1.25 }
447          }
448        }
449      }
450    }"#;
451
452    #[test]
453    fn parse_modelsdev_reads_tiers_and_cache_write() -> Result<()> {
454        let entries = parse_modelsdev(MODELSDEV_TIERS_FIXTURE)?;
455
456        let tiered = find(&entries, "openai", "gpt-5.4")?;
457        let base = tiered.pricing.context("base pricing missing")?;
458        assert!((base.input.context("input")?.usd_per_million_tokens - 2.5).abs() < f64::EPSILON);
459        assert_eq!(tiered.pricing_tiers.len(), 1);
460        let tier = tiered.pricing_tiers[0];
461        // The bound is inclusive (SDK schema: "at which this tier starts to
462        // apply"), so `size` maps straight across.
463        assert_eq!(tier.min_input_tokens, 272_000);
464        assert!(
465            (tier
466                .pricing
467                .output
468                .context("tier output")?
469                .usd_per_million_tokens
470                - 22.5)
471                .abs()
472                < f64::EPSILON
473        );
474
475        // The cache-write rate is carried, so cache-creation tokens can be
476        // billed at the premium the provider actually charges.
477        let haiku = find(&entries, "anthropic", "claude-haiku-4-5")?;
478        let pricing = haiku.pricing.context("pricing missing")?;
479        assert!(
480            (pricing
481                .cache_write
482                .context("cache_write")?
483                .usd_per_million_tokens
484                - 1.25)
485                .abs()
486                < f64::EPSILON
487        );
488        Ok(())
489    }
490
491    /// The feed's `cost.reasoning` rate is parsed onto `Pricing`. Live rows
492    /// price reasoning above output (`alibaba/qwen3-32b`: output 2.8, reasoning
493    /// 8.4), which the output band's `max(output, reasoning)` must reach.
494    #[test]
495    fn parse_modelsdev_reads_reasoning_rate() -> Result<()> {
496        const REASONING_FIXTURE: &str = r#"{
497          "alibaba": {
498            "id": "alibaba",
499            "models": {
500              "qwen3-32b": {
501                "id": "qwen3-32b",
502                "cost": { "input": 0.7, "output": 2.8, "reasoning": 8.4 }
503              }
504            }
505          }
506        }"#;
507
508        let entries = parse_modelsdev(REASONING_FIXTURE)?;
509        let row = find(&entries, "alibaba", "qwen3-32b")?;
510        let pricing = row.pricing.context("pricing missing")?;
511        assert!(
512            (pricing
513                .reasoning
514                .context("reasoning")?
515                .usd_per_million_tokens
516                - 8.4)
517                .abs()
518                < f64::EPSILON
519        );
520        Ok(())
521    }
522
523    /// A models.dev tier is a full cost row, so it may restate `reasoning` at a
524    /// different rate than the base. That rate must reach the tier's band, not
525    /// be dropped in favour of the inherited base rate.
526    #[test]
527    fn parse_modelsdev_reads_tier_reasoning_rate() -> Result<()> {
528        use agent_sdk_foundation::llm::Usage;
529
530        const TIER_REASONING_FIXTURE: &str = r#"{
531          "openai": {
532            "id": "openai",
533            "models": {
534              "reasoner": {
535                "id": "reasoner",
536                "cost": {
537                  "input": 1,
538                  "output": 2,
539                  "reasoning": 3,
540                  "tiers": [
541                    {
542                      "input": 2,
543                      "output": 4,
544                      "reasoning": 9,
545                      "tier": { "type": "context", "size": 200000 }
546                    }
547                  ]
548                }
549              }
550            }
551          }
552        }"#;
553
554        let entries = parse_modelsdev(TIER_REASONING_FIXTURE)?;
555        let row = find(&entries, "openai", "reasoner")?;
556        assert_eq!(row.pricing_tiers.len(), 1);
557        let tier = row.pricing_tiers[0];
558        assert!(
559            (tier
560                .pricing
561                .reasoning
562                .context("tier reasoning")?
563                .usd_per_million_tokens
564                - 9.0)
565                .abs()
566                < f64::EPSILON,
567            "the tier's own reasoning rate must win over the base's",
568        );
569
570        // And it bills: a call in the tier band prices output at
571        // max(tier output 4, tier reasoning 9) = 9. 300K in + 100K out =
572        // 0.3*2 + 0.1*9 = 1.5, where the base tier output rate would say
573        // 0.3*2 + 0.1*4 = 1.0.
574        let usage = Usage {
575            input_tokens: 300_000,
576            output_tokens: 100_000,
577            cached_input_tokens: 0,
578            cache_creation_input_tokens: 0,
579        };
580        let cost = tier
581            .pricing
582            .estimate_cost_usd(&usage)
583            .context("tier prices the call")?;
584        assert!((cost - 1.5).abs() < 1e-9, "unexpected cost: {cost}");
585        Ok(())
586    }
587
588    /// A tier this parser cannot interpret means the base rates cannot be
589    /// trusted above *some* unknown threshold, so the row carries no pricing
590    /// at all rather than a base rate that would under-price long calls.
591    #[test]
592    fn parse_modelsdev_drops_a_row_with_an_uninterpretable_tier() -> Result<()> {
593        let entries = parse_modelsdev(MODELSDEV_TIERS_FIXTURE)?;
594
595        let unknown = find(&entries, "openai", "unknown-tier-model")?;
596        assert!(unknown.pricing.is_none());
597        assert!(unknown.pricing_tiers.is_empty());
598        Ok(())
599    }
600
601    /// A drifted tier row must cost that row its pricing — not the whole feed.
602    /// A missing field anywhere in a 3MB body would otherwise fail the parse
603    /// outright, empty the catalog, and leave every feed-priced model unpriced.
604    #[test]
605    fn parse_modelsdev_survives_a_tier_with_a_missing_bound() -> Result<()> {
606        const DRIFTED_FIXTURE: &str = r#"{
607          "openai": {
608            "id": "openai",
609            "models": {
610              "healthy-model": {
611                "id": "healthy-model",
612                "cost": { "input": 1, "output": 2 }
613              },
614              "drifted-tier-model": {
615                "id": "drifted-tier-model",
616                "cost": {
617                  "input": 1,
618                  "output": 2,
619                  "tiers": [
620                    { "input": 9, "output": 9, "tier": { "type": "context" } }
621                  ]
622                }
623              },
624              "bound-less-tier-model": {
625                "id": "bound-less-tier-model",
626                "cost": {
627                  "input": 1,
628                  "output": 2,
629                  "tiers": [{ "input": 9, "output": 9 }]
630                }
631              }
632            }
633          }
634        }"#;
635
636        let entries = parse_modelsdev(DRIFTED_FIXTURE)?;
637        assert_eq!(
638            entries.len(),
639            3,
640            "the parse must not abort on a drifted row"
641        );
642
643        // The healthy row is priced as usual.
644        let healthy = find(&entries, "openai", "healthy-model")?;
645        assert!(healthy.pricing.is_some());
646
647        // A tier with no `size`, and a tier with no bound at all, are both
648        // un-interpretable: their rows drop their pricing rather than keeping
649        // base rates that stop applying above an unknown threshold.
650        for model in ["drifted-tier-model", "bound-less-tier-model"] {
651            let drifted = find(&entries, "openai", model)?;
652            assert!(drifted.pricing.is_none(), "{model} must drop its pricing");
653            assert!(drifted.pricing_tiers.is_empty());
654        }
655        Ok(())
656    }
657
658    #[test]
659    fn parse_modelsdev_drops_zero_rates() -> Result<()> {
660        let entries = parse_modelsdev(ZERO_COST_FIXTURE)?;
661
662        // Every rate is zero: the row carries no pricing at all, so it cannot
663        // shadow another source with a real price.
664        let free = find(&entries, "openai", "free-model")?;
665        assert!(free.pricing.is_none());
666
667        // A zero output rate is dropped while the real input rate is kept;
668        // the registry declines such a row for any call that bills output.
669        let half = find(&entries, "openai", "half-priced-model")?;
670        let pricing = half.pricing.context("input rate must survive")?;
671        assert!(
672            (pricing.input.context("input")?.usd_per_million_tokens - 2.0).abs() < f64::EPSILON
673        );
674        assert!(pricing.output.is_none());
675        Ok(())
676    }
677}