1use std::collections::BTreeMap;
17
18use serde::{Deserialize, Serialize};
19
20use crate::route::{ModelId, ProviderId, ProviderModelOffering, RouteLimits, WireModelId};
21
22pub const MODELS_DEV_API_URL: &str = "https://models.dev/api.json";
24pub const MODELS_DEV_MODELS_URL: &str = "https://models.dev/models.json";
26pub const MODELS_DEV_CATALOG_URL: &str = "https://models.dev/catalog.json";
28
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
31pub struct ModelsDevCatalog {
32 #[serde(default)]
34 pub models: BTreeMap<String, ModelsDevModel>,
35 #[serde(default)]
37 pub providers: BTreeMap<String, ModelsDevProvider>,
38}
39
40impl ModelsDevCatalog {
41 pub fn parse_json(raw: &str) -> serde_json::Result<Self> {
46 serde_json::from_str(raw)
47 }
48
49 #[must_use]
51 pub fn model(&self, model_id: &str) -> Option<&ModelsDevModel> {
52 self.models.get(model_id.trim())
53 }
54
55 #[must_use]
57 pub fn provider(&self, provider_id: &str) -> Option<&ModelsDevProvider> {
58 self.providers.get(provider_id.trim())
59 }
60
61 #[must_use]
63 pub fn provider_model(
64 &self,
65 provider_id: &str,
66 wire_model_id: &str,
67 ) -> Option<&ModelsDevProviderModel> {
68 self.provider(provider_id)?.models.get(wire_model_id.trim())
69 }
70
71 #[must_use]
77 pub fn provider_offering(
78 &self,
79 provider_id: &str,
80 wire_model_id: &str,
81 ) -> Option<ProviderModelOffering> {
82 let provider_key = provider_id.trim();
83 let provider = self.provider(provider_key)?;
84 let model = provider.models.get(wire_model_id.trim())?;
85 let provider_id = provider.effective_id(provider_key);
86 Some(ProviderModelOffering {
87 provider: ProviderId::from(provider_id),
88 canonical_model: model.base_model.clone().map(ModelId::from),
89 wire_model_id: WireModelId::from(model.id.clone()),
90 endpoint_key: "chat".to_string(),
91 default_for_provider: model.default_for_provider,
92 limits: model
93 .limit
94 .as_ref()
95 .map(RouteLimits::from)
96 .unwrap_or_default(),
97 pricing: crate::pricing::route_pricing_sku_from_cost(model.cost.as_ref()),
98 })
99 }
100
101 #[must_use]
107 pub fn provider_offerings(&self, provider_id: &str) -> Option<Vec<ProviderModelOffering>> {
108 let provider_key = provider_id.trim();
109 let provider = self.provider(provider_key)?;
110 let provider_id = provider.effective_id(provider_key);
111 Some(
112 provider
113 .models
114 .values()
115 .filter(|model| model.supports_text_chat())
116 .map(|model| ProviderModelOffering {
117 provider: ProviderId::from(provider_id.clone()),
118 canonical_model: model.base_model.clone().map(ModelId::from),
119 wire_model_id: WireModelId::from(model.id.clone()),
120 endpoint_key: "chat".to_string(),
121 default_for_provider: model.default_for_provider,
122 limits: model
123 .limit
124 .as_ref()
125 .map(RouteLimits::from)
126 .unwrap_or_default(),
127 pricing: crate::pricing::route_pricing_sku_from_cost(model.cost.as_ref()),
128 })
129 .collect(),
130 )
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
136pub struct ModelsDevModel {
137 #[serde(default)]
139 pub id: String,
140 #[serde(default)]
142 pub name: Option<String>,
143 #[serde(default)]
145 pub family: Option<String>,
146 #[serde(default)]
148 pub attachment: Option<bool>,
149 #[serde(default)]
151 pub reasoning: Option<bool>,
152 #[serde(default)]
154 pub tool_call: Option<bool>,
155 #[serde(default)]
157 pub structured_output: Option<bool>,
158 #[serde(default)]
160 pub temperature: Option<bool>,
161 #[serde(default)]
163 pub open_weights: Option<bool>,
164 #[serde(default)]
166 pub limit: Option<ModelsDevLimit>,
167 #[serde(default)]
169 pub modalities: Option<ModelsDevModalities>,
170}
171
172impl ModelsDevModel {
173 #[must_use]
175 pub fn supports_text_chat(&self) -> bool {
176 supports_text_chat(self.modalities.as_ref())
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
182pub struct ModelsDevProviderModel {
183 #[serde(default)]
185 pub id: String,
186 #[serde(default)]
188 pub base_model: Option<String>,
189 #[serde(default)]
191 pub name: Option<String>,
192 #[serde(default)]
194 pub family: Option<String>,
195 #[serde(default, alias = "default")]
197 pub default_for_provider: bool,
198 #[serde(default)]
200 pub attachment: Option<bool>,
201 #[serde(default)]
203 pub reasoning: Option<bool>,
204 #[serde(default)]
206 pub reasoning_options: Vec<serde_json::Value>,
207 #[serde(default)]
209 pub tool_call: Option<bool>,
210 #[serde(default)]
212 pub structured_output: Option<bool>,
213 #[serde(default)]
215 pub temperature: Option<bool>,
216 #[serde(default)]
218 pub open_weights: Option<bool>,
219 #[serde(default)]
221 pub limit: Option<ModelsDevLimit>,
222 #[serde(default)]
224 pub modalities: Option<ModelsDevModalities>,
225 #[serde(default)]
227 pub cost: Option<ModelsDevCost>,
228 #[serde(default)]
230 pub interleaved: Option<ModelsDevInterleaved>,
231}
232
233impl ModelsDevProviderModel {
234 #[must_use]
236 pub fn supports_text_chat(&self) -> bool {
237 supports_text_chat(self.modalities.as_ref())
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
243pub struct ModelsDevProvider {
244 #[serde(default)]
246 pub id: String,
247 #[serde(default)]
249 pub name: Option<String>,
250 #[serde(default)]
252 pub api: Option<String>,
253 #[serde(default)]
255 pub npm: Option<String>,
256 #[serde(default)]
258 pub doc: Option<String>,
259 #[serde(default)]
261 pub env: Vec<String>,
262 #[serde(default)]
264 pub models: BTreeMap<String, ModelsDevProviderModel>,
265}
266
267impl ModelsDevProvider {
268 #[must_use]
274 fn effective_id(&self, provider_key: &str) -> String {
275 if self.id.trim().is_empty() {
276 provider_key.to_string()
277 } else {
278 self.id.trim().to_string()
279 }
280 }
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
285pub struct ModelsDevLimit {
286 #[serde(default)]
287 pub context: Option<u64>,
288 #[serde(default)]
289 pub input: Option<u64>,
290 #[serde(default)]
291 pub output: Option<u64>,
292}
293
294impl From<&ModelsDevLimit> for RouteLimits {
295 fn from(limit: &ModelsDevLimit) -> Self {
296 Self {
297 context_tokens: limit.context,
298 input_tokens: limit.input,
299 output_tokens: limit.output,
300 }
301 }
302}
303
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
306pub struct ModelsDevModalities {
307 #[serde(default)]
308 pub input: Vec<String>,
309 #[serde(default)]
310 pub output: Vec<String>,
311}
312
313#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
317pub struct ModelsDevCost {
318 #[serde(default)]
319 pub input: Option<f64>,
320 #[serde(default)]
321 pub output: Option<f64>,
322 #[serde(default)]
323 pub cache_read: Option<f64>,
324 #[serde(default)]
325 pub cache_write: Option<f64>,
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343#[serde(untagged)]
344pub enum ModelsDevInterleaved {
345 Enabled(bool),
347 Field {
352 #[serde(default)]
353 field: Option<String>,
354 },
355}
356
357impl ModelsDevInterleaved {
358 #[must_use]
364 pub fn is_enabled(&self) -> bool {
365 match self {
366 Self::Enabled(enabled) => *enabled,
367 Self::Field { .. } => true,
368 }
369 }
370
371 #[must_use]
376 pub fn field(&self) -> Option<&str> {
377 match self {
378 Self::Enabled(_) => None,
379 Self::Field { field } => field.as_deref(),
380 }
381 }
382}
383
384fn supports_text_chat(modalities: Option<&ModelsDevModalities>) -> bool {
385 let Some(modalities) = modalities else {
386 return true;
387 };
388 let input_ok = modalities.input.is_empty()
395 || modalities
396 .input
397 .iter()
398 .any(|modality| modality.eq_ignore_ascii_case("text"));
399 let output_ok = modalities.output.is_empty()
400 || modalities
401 .output
402 .iter()
403 .any(|modality| modality.eq_ignore_ascii_case("text"));
404 input_ok && output_ok
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 const GLM_FIXTURE: &str = r#"{
412 "models": {
413 "zhipuai/glm-5.2": {
414 "id": "zhipuai/glm-5.2",
415 "name": "GLM-5.2",
416 "family": "glm",
417 "reasoning": true,
418 "tool_call": true,
419 "structured_output": true,
420 "modalities": { "input": ["text"], "output": ["text"] },
421 "limit": { "context": 1000000, "output": 131072 },
422 "open_weights": true
423 }
424 },
425 "providers": {
426 "zhipuai": {
427 "id": "zhipuai",
428 "name": "Zhipu AI",
429 "api": "https://open.bigmodel.cn/api/paas/v4",
430 "npm": "@ai-sdk/openai-compatible",
431 "env": ["ZHIPU_API_KEY"],
432 "models": {
433 "glm-5.2": {
434 "id": "glm-5.2",
435 "name": "GLM-5.2",
436 "family": "glm",
437 "reasoning": true,
438 "reasoning_options": [{ "type": "effort", "values": ["high", "max"] }],
439 "tool_call": true,
440 "structured_output": true,
441 "modalities": { "input": ["text"], "output": ["text"] },
442 "limit": { "context": 1000000, "output": 131072 },
443 "cost": { "input": 1.4, "output": 4.4, "cache_read": 0.26 }
444 }
445 }
446 },
447 "zai": {
448 "id": "zai",
449 "name": "Z.AI",
450 "api": "https://api.z.ai/api/paas/v4",
451 "npm": "@ai-sdk/openai-compatible",
452 "env": ["ZHIPU_API_KEY"],
453 "models": {
454 "glm-5.2": {
455 "id": "glm-5.2",
456 "family": "glm",
457 "reasoning": true,
458 "tool_call": true,
459 "modalities": { "input": ["text"], "output": ["text"] },
460 "cost": { "input": 1.4, "output": 4.4 }
461 }
462 }
463 }
464 }
465 }"#;
466
467 #[test]
468 fn parses_models_dev_catalog_layers_without_joining_by_prefix() {
469 let catalog = ModelsDevCatalog::parse_json(GLM_FIXTURE).expect("fixture parses");
470
471 let canonical = catalog.model("zhipuai/glm-5.2").expect("canonical model");
472 assert_eq!(canonical.family.as_deref(), Some("glm"));
473 assert_eq!(
474 canonical.limit.as_ref().and_then(|limit| limit.context),
475 Some(1_000_000)
476 );
477 assert!(canonical.supports_text_chat());
478
479 let provider = catalog.provider("zhipuai").expect("provider");
480 assert_eq!(
481 provider.api.as_deref(),
482 Some("https://open.bigmodel.cn/api/paas/v4")
483 );
484 assert_eq!(provider.npm.as_deref(), Some("@ai-sdk/openai-compatible"));
485 assert_eq!(provider.env, ["ZHIPU_API_KEY"]);
486
487 let offering = catalog
488 .provider_model("zhipuai", "glm-5.2")
489 .expect("provider model");
490 assert_eq!(offering.id, "glm-5.2");
491 assert_eq!(offering.reasoning, Some(true));
492 assert_eq!(
493 offering.cost.as_ref().and_then(|cost| cost.cache_read),
494 Some(0.26)
495 );
496 assert!(offering.supports_text_chat());
497 assert_eq!(
498 offering.base_model, None,
499 "generated JSON does not prove a canonical join"
500 );
501
502 let route_offering = catalog
503 .provider_offering("zhipuai", "glm-5.2")
504 .expect("route offering");
505 assert_eq!(route_offering.limits.context_tokens, Some(1_000_000));
506 assert_eq!(route_offering.limits.output_tokens, Some(131_072));
507 }
508
509 #[test]
510 fn provider_offering_preserves_wire_id_without_inferred_canonical_model() {
511 let catalog = ModelsDevCatalog::parse_json(GLM_FIXTURE).expect("fixture parses");
512 let offering = catalog
513 .provider_offering("zai", "glm-5.2")
514 .expect("offering");
515
516 assert_eq!(offering.provider.as_str(), "zai");
517 assert_eq!(offering.wire_model_id.as_str(), "glm-5.2");
518 assert_eq!(offering.canonical_model, None);
519 assert_eq!(offering.endpoint_key, "chat");
520 }
521
522 #[test]
523 fn provider_offering_uses_explicit_base_model_when_present() {
524 let raw = r#"{
525 "providers": {
526 "openrouter": {
527 "id": "openrouter",
528 "models": {
529 "z-ai/glm-5.2": {
530 "id": "z-ai/glm-5.2",
531 "base_model": "zhipuai/glm-5.2"
532 }
533 }
534 }
535 }
536 }"#;
537 let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
538 let offering = catalog
539 .provider_offering("openrouter", "z-ai/glm-5.2")
540 .expect("offering");
541
542 assert_eq!(
543 offering.canonical_model.as_ref().map(ModelId::as_str),
544 Some("zhipuai/glm-5.2")
545 );
546 assert_eq!(offering.wire_model_id.as_str(), "z-ai/glm-5.2");
547 }
548
549 #[test]
550 fn provider_offerings_emit_chat_rows_and_skip_non_text_outputs() {
551 let raw = r#"{
552 "providers": {
553 "zai": {
554 "models": {
555 "glm-5.2": {
556 "id": "glm-5.2",
557 "base_model": "zhipuai/glm-5.2",
558 "default": true,
559 "modalities": { "input": ["text"], "output": ["text"] }
560 },
561 "glm-voice": {
562 "id": "glm-voice",
563 "modalities": { "input": ["text"], "output": ["audio"] }
564 }
565 }
566 }
567 }
568 }"#;
569 let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
570 let offerings = catalog
571 .provider_offerings("zai")
572 .expect("provider offerings");
573
574 assert_eq!(offerings.len(), 1);
575 assert_eq!(offerings[0].provider.as_str(), "zai");
576 assert_eq!(offerings[0].wire_model_id.as_str(), "glm-5.2");
577 assert_eq!(
578 offerings[0].canonical_model.as_ref().map(ModelId::as_str),
579 Some("zhipuai/glm-5.2")
580 );
581 assert!(offerings[0].default_for_provider);
582 }
583
584 #[test]
585 fn non_text_output_is_not_a_chat_model() {
586 let model = ModelsDevProviderModel {
587 id: "mimo-v2.5-tts".to_string(),
588 modalities: Some(ModelsDevModalities {
589 input: vec!["text".to_string()],
590 output: vec!["audio".to_string()],
591 }),
592 ..Default::default()
593 };
594
595 assert!(!model.supports_text_chat());
596 }
597
598 #[test]
599 fn empty_modalities_struct_is_chat_capable() {
600 let provider_model = ModelsDevProviderModel {
604 modalities: Some(ModelsDevModalities::default()),
605 ..Default::default()
606 };
607 assert!(provider_model.supports_text_chat());
608
609 let canonical = ModelsDevModel {
610 modalities: Some(ModelsDevModalities::default()),
611 ..Default::default()
612 };
613 assert!(canonical.supports_text_chat());
614
615 let audio_only = ModelsDevProviderModel {
617 modalities: Some(ModelsDevModalities {
618 input: vec!["text".to_string()],
619 output: vec!["audio".to_string()],
620 }),
621 ..Default::default()
622 };
623 assert!(!audio_only.supports_text_chat());
624 }
625
626 #[test]
627 fn interleaved_boolean_true_parses_and_reports_enabled() {
628 let raw = r#"{
631 "providers": {
632 "vercel": {
633 "models": {
634 "zai/glm-4.7": { "id": "zai/glm-4.7", "interleaved": true }
635 }
636 }
637 }
638 }"#;
639 let catalog = ModelsDevCatalog::parse_json(raw).expect("boolean interleaved parses");
640 let model = catalog
641 .provider_model("vercel", "zai/glm-4.7")
642 .expect("provider model");
643 let interleaved = model.interleaved.as_ref().expect("interleaved present");
644 assert_eq!(interleaved, &ModelsDevInterleaved::Enabled(true));
645 assert!(interleaved.is_enabled());
646 assert_eq!(interleaved.field(), None);
647 }
648
649 #[test]
650 fn interleaved_boolean_false_parses_and_reports_disabled() {
651 let raw = r#"{
652 "providers": {
653 "custom": {
654 "models": {
655 "house-model": { "id": "house-model", "interleaved": false }
656 }
657 }
658 }
659 }"#;
660 let catalog = ModelsDevCatalog::parse_json(raw).expect("boolean interleaved parses");
661 let model = catalog
662 .provider_model("custom", "house-model")
663 .expect("provider model");
664 let interleaved = model.interleaved.as_ref().expect("interleaved present");
665 assert_eq!(interleaved, &ModelsDevInterleaved::Enabled(false));
666 assert!(!interleaved.is_enabled());
667 assert_eq!(interleaved.field(), None);
668 }
669
670 #[test]
671 fn interleaved_object_form_preserves_field_metadata() {
672 let raw = r#"{
675 "providers": {
676 "alibaba-cn": {
677 "models": {
678 "glm-5.2": {
679 "id": "glm-5.2",
680 "interleaved": { "field": "reasoning_content" }
681 }
682 }
683 }
684 }
685 }"#;
686 let catalog = ModelsDevCatalog::parse_json(raw).expect("object interleaved parses");
687 let model = catalog
688 .provider_model("alibaba-cn", "glm-5.2")
689 .expect("provider model");
690 let interleaved = model.interleaved.as_ref().expect("interleaved present");
691 assert_eq!(interleaved.field(), Some("reasoning_content"));
692 assert!(interleaved.is_enabled());
693 }
694
695 #[test]
696 fn interleaved_object_tolerates_empty_and_unknown_keys() {
697 let raw = r#"{
700 "providers": {
701 "custom": {
702 "models": {
703 "empty-obj": { "id": "empty-obj", "interleaved": {} },
704 "future-obj": {
705 "id": "future-obj",
706 "interleaved": { "future_hint": "x" }
707 }
708 }
709 }
710 }
711 }"#;
712 let catalog = ModelsDevCatalog::parse_json(raw).expect("tolerant interleaved parses");
713
714 let empty = catalog
715 .provider_model("custom", "empty-obj")
716 .and_then(|m| m.interleaved.clone())
717 .expect("empty object interleaved present");
718 assert_eq!(empty, ModelsDevInterleaved::Field { field: None });
719 assert_eq!(empty.field(), None);
720 assert!(empty.is_enabled());
721
722 let future = catalog
723 .provider_model("custom", "future-obj")
724 .and_then(|m| m.interleaved.clone())
725 .expect("future object interleaved present");
726 assert_eq!(future.field(), None);
727 }
728
729 #[test]
730 fn live_ish_mixed_interleaved_sample_deserializes() {
731 let raw = r#"{
736 "providers": {
737 "amazon-bedrock": {
738 "id": "amazon-bedrock",
739 "doc": "https://docs.aws.amazon.com/bedrock/",
740 "models": {
741 "anthropic.claude-opus": {
742 "id": "anthropic.claude-opus",
743 "reasoning": true,
744 "interleaved": true,
745 "some_future_flag": 7,
746 "modalities": { "input": ["text"], "output": ["text"] }
747 }
748 }
749 },
750 "alibaba-cn": {
751 "id": "alibaba-cn",
752 "models": {
753 "deepseek-v4-flash": {
754 "id": "deepseek-v4-flash",
755 "interleaved": { "field": "reasoning_content" },
756 "modalities": { "input": ["text"], "output": ["text"] }
757 }
758 }
759 }
760 }
761 }"#;
762 let catalog = ModelsDevCatalog::parse_json(raw).expect("live-ish sample parses");
763
764 let bedrock = catalog
765 .provider_model("amazon-bedrock", "anthropic.claude-opus")
766 .expect("bedrock row");
767 assert_eq!(
768 bedrock.interleaved,
769 Some(ModelsDevInterleaved::Enabled(true))
770 );
771
772 let alibaba = catalog
773 .provider_model("alibaba-cn", "deepseek-v4-flash")
774 .expect("alibaba row");
775 assert_eq!(
776 alibaba.interleaved.as_ref().and_then(|i| i.field()),
777 Some("reasoning_content")
778 );
779
780 assert_eq!(
783 catalog
784 .provider_offerings("amazon-bedrock")
785 .map(|rows| rows.len()),
786 Some(1)
787 );
788 }
789
790 #[test]
791 fn provider_offerings_keep_rows_with_empty_modalities_object() {
792 let raw = r#"{
796 "providers": {
797 "custom": {
798 "models": {
799 "house-model": { "id": "house-model", "modalities": {} }
800 }
801 }
802 }
803 }"#;
804 let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
805 let offerings = catalog
806 .provider_offerings("custom")
807 .expect("provider offerings");
808
809 assert_eq!(offerings.len(), 1);
810 assert_eq!(offerings[0].wire_model_id.as_str(), "house-model");
811 assert_eq!(offerings[0].provider.as_str(), "custom");
813 }
814}