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 #[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#[derive(serde::Deserialize)]
52struct ModelsDevTierBound {
53 #[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
93fn 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
143fn 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 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 min_input_tokens: bound.size?,
181 pricing: merge_band_over_base(base, band),
182 })
183 })
184 .collect()
185}
186
187pub 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 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
229pub 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 pub fn new() -> Result<Self> {
258 Ok(Self {
259 client: build_feed_client()?,
260 url: MODELS_DEV_URL.to_owned(),
261 })
262 }
263
264 #[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 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 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 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 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 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 #[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 #[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 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 #[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 #[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 let healthy = find(&entries, "openai", "healthy-model")?;
645 assert!(healthy.pricing.is_some());
646
647 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 let free = find(&entries, "openai", "free-model")?;
665 assert!(free.pricing.is_none());
666
667 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}