1use std::collections::BTreeMap;
17
18use serde::{Deserialize, Serialize};
19
20use crate::route::{
21 CapabilityState, ModelId, ProviderId, ProviderModelOffering, RouteCapabilities, RouteLimits,
22 WireModelId,
23};
24
25pub const MODELS_DEV_API_URL: &str = "https://models.dev/api.json";
27pub const MODELS_DEV_MODELS_URL: &str = "https://models.dev/models.json";
29pub const MODELS_DEV_CATALOG_URL: &str = "https://models.dev/catalog.json";
31
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
34pub struct ModelsDevCatalog {
35 #[serde(default)]
37 pub models: BTreeMap<String, ModelsDevModel>,
38 #[serde(default)]
40 pub providers: BTreeMap<String, ModelsDevProvider>,
41}
42
43impl ModelsDevCatalog {
44 pub fn parse_json(raw: &str) -> serde_json::Result<Self> {
49 serde_json::from_str(raw)
50 }
51
52 #[must_use]
54 pub fn model(&self, model_id: &str) -> Option<&ModelsDevModel> {
55 self.models.get(model_id.trim())
56 }
57
58 #[must_use]
60 pub fn provider(&self, provider_id: &str) -> Option<&ModelsDevProvider> {
61 self.providers.get(provider_id.trim())
62 }
63
64 #[must_use]
66 pub fn provider_model(
67 &self,
68 provider_id: &str,
69 wire_model_id: &str,
70 ) -> Option<&ModelsDevProviderModel> {
71 self.provider(provider_id)?.models.get(wire_model_id.trim())
72 }
73
74 #[must_use]
80 pub fn provider_offering(
81 &self,
82 provider_id: &str,
83 wire_model_id: &str,
84 ) -> Option<ProviderModelOffering> {
85 let provider_key = provider_id.trim();
86 let provider = self.provider(provider_key)?;
87 let model = provider.models.get(wire_model_id.trim())?;
88 let provider_id = provider.effective_id(provider_key);
89 Some(ProviderModelOffering {
90 provider: ProviderId::from(provider_id.clone()),
91 canonical_model: model.base_model.clone().map(ModelId::from),
92 wire_model_id: WireModelId::from(model.id.clone()),
93 endpoint_key: "chat".to_string(),
94 default_for_provider: model.default_for_provider,
95 limits: model
96 .limit
97 .as_ref()
98 .map(RouteLimits::from)
99 .unwrap_or_default(),
100 capabilities: route_capabilities(&provider_id, model),
101 pricing: crate::pricing::route_pricing_sku_from_cost(model.cost.as_ref()),
102 })
103 }
104
105 #[must_use]
111 pub fn provider_offerings(&self, provider_id: &str) -> Option<Vec<ProviderModelOffering>> {
112 let provider_key = provider_id.trim();
113 let provider = self.provider(provider_key)?;
114 let provider_id = provider.effective_id(provider_key);
115 Some(
116 provider
117 .models
118 .values()
119 .filter(|model| model.supports_text_chat())
120 .map(|model| ProviderModelOffering {
121 provider: ProviderId::from(provider_id.clone()),
122 canonical_model: model.base_model.clone().map(ModelId::from),
123 wire_model_id: WireModelId::from(model.id.clone()),
124 endpoint_key: "chat".to_string(),
125 default_for_provider: model.default_for_provider,
126 limits: model
127 .limit
128 .as_ref()
129 .map(RouteLimits::from)
130 .unwrap_or_default(),
131 capabilities: route_capabilities(&provider_id, model),
132 pricing: crate::pricing::route_pricing_sku_from_cost(model.cost.as_ref()),
133 })
134 .collect(),
135 )
136 }
137}
138
139fn route_capabilities(provider_id: &str, model: &ModelsDevProviderModel) -> RouteCapabilities {
140 RouteCapabilities {
141 attachments: CapabilityState::from_optional_bool(model.attachment),
142 reasoning: CapabilityState::from_optional_bool(model.reasoning),
143 native_tool_calls: CapabilityState::from_optional_bool(model.tool_call),
144 structured_output: CapabilityState::from_optional_bool(model.structured_output),
145 server_side_web_search: crate::route::documented_server_side_web_search(
146 provider_id,
147 &model.id,
148 ),
149 ..RouteCapabilities::default()
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
155pub struct ModelsDevModel {
156 #[serde(default)]
158 pub id: String,
159 #[serde(default)]
161 pub name: Option<String>,
162 #[serde(default)]
164 pub family: Option<String>,
165 #[serde(default)]
167 pub attachment: Option<bool>,
168 #[serde(default)]
170 pub reasoning: Option<bool>,
171 #[serde(default)]
173 pub tool_call: Option<bool>,
174 #[serde(default)]
176 pub structured_output: Option<bool>,
177 #[serde(default)]
179 pub temperature: Option<bool>,
180 #[serde(default)]
182 pub open_weights: Option<bool>,
183 #[serde(default)]
185 pub limit: Option<ModelsDevLimit>,
186 #[serde(default)]
188 pub modalities: Option<ModelsDevModalities>,
189}
190
191impl ModelsDevModel {
192 #[must_use]
194 pub fn supports_text_chat(&self) -> bool {
195 supports_text_chat(self.modalities.as_ref())
196 }
197}
198
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
201pub struct ModelsDevProviderModel {
202 #[serde(default)]
204 pub id: String,
205 #[serde(default)]
207 pub base_model: Option<String>,
208 #[serde(default)]
210 pub name: Option<String>,
211 #[serde(default)]
213 pub family: Option<String>,
214 #[serde(default, alias = "default")]
216 pub default_for_provider: bool,
217 #[serde(default)]
219 pub attachment: Option<bool>,
220 #[serde(default)]
222 pub reasoning: Option<bool>,
223 #[serde(default)]
225 pub reasoning_options: Vec<serde_json::Value>,
226 #[serde(default)]
228 pub tool_call: Option<bool>,
229 #[serde(default)]
231 pub structured_output: Option<bool>,
232 #[serde(default)]
234 pub temperature: Option<bool>,
235 #[serde(default)]
237 pub open_weights: Option<bool>,
238 #[serde(default)]
240 pub limit: Option<ModelsDevLimit>,
241 #[serde(default)]
243 pub modalities: Option<ModelsDevModalities>,
244 #[serde(default)]
246 pub cost: Option<ModelsDevCost>,
247 #[serde(default)]
249 pub interleaved: Option<ModelsDevInterleaved>,
250}
251
252impl ModelsDevProviderModel {
253 #[must_use]
255 pub fn supports_text_chat(&self) -> bool {
256 supports_text_chat(self.modalities.as_ref())
257 }
258}
259
260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
262pub struct ModelsDevProvider {
263 #[serde(default)]
265 pub id: String,
266 #[serde(default)]
268 pub name: Option<String>,
269 #[serde(default)]
271 pub api: Option<String>,
272 #[serde(default)]
274 pub npm: Option<String>,
275 #[serde(default)]
277 pub doc: Option<String>,
278 #[serde(default)]
280 pub env: Vec<String>,
281 #[serde(default)]
283 pub models: BTreeMap<String, ModelsDevProviderModel>,
284}
285
286impl ModelsDevProvider {
287 #[must_use]
293 fn effective_id(&self, provider_key: &str) -> String {
294 if self.id.trim().is_empty() {
295 provider_key.to_string()
296 } else {
297 self.id.trim().to_string()
298 }
299 }
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
304pub struct ModelsDevLimit {
305 #[serde(default)]
306 pub context: Option<u64>,
307 #[serde(default)]
308 pub input: Option<u64>,
309 #[serde(default)]
310 pub output: Option<u64>,
311}
312
313impl From<&ModelsDevLimit> for RouteLimits {
314 fn from(limit: &ModelsDevLimit) -> Self {
315 Self {
316 context_tokens: limit.context,
317 input_tokens: limit.input,
318 output_tokens: limit.output,
319 }
320 }
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
325pub struct ModelsDevModalities {
326 #[serde(default)]
327 pub input: Vec<String>,
328 #[serde(default)]
329 pub output: Vec<String>,
330}
331
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
336pub struct ModelsDevCost {
337 #[serde(default)]
338 pub input: Option<f64>,
339 #[serde(default)]
340 pub output: Option<f64>,
341 #[serde(default)]
342 pub cache_read: Option<f64>,
343 #[serde(default)]
344 pub cache_write: Option<f64>,
345}
346
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362#[serde(untagged)]
363pub enum ModelsDevInterleaved {
364 Enabled(bool),
366 Field {
371 #[serde(default)]
372 field: Option<String>,
373 },
374}
375
376impl ModelsDevInterleaved {
377 #[must_use]
383 pub fn is_enabled(&self) -> bool {
384 match self {
385 Self::Enabled(enabled) => *enabled,
386 Self::Field { .. } => true,
387 }
388 }
389
390 #[must_use]
395 pub fn field(&self) -> Option<&str> {
396 match self {
397 Self::Enabled(_) => None,
398 Self::Field { field } => field.as_deref(),
399 }
400 }
401}
402
403fn supports_text_chat(modalities: Option<&ModelsDevModalities>) -> bool {
404 let Some(modalities) = modalities else {
405 return true;
406 };
407 let input_ok = modalities.input.is_empty()
414 || modalities
415 .input
416 .iter()
417 .any(|modality| modality.eq_ignore_ascii_case("text"));
418 let output_ok = modalities.output.is_empty()
419 || modalities
420 .output
421 .iter()
422 .any(|modality| modality.eq_ignore_ascii_case("text"));
423 input_ok && output_ok
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 const GLM_FIXTURE: &str = r#"{
431 "models": {
432 "zhipuai/glm-5.2": {
433 "id": "zhipuai/glm-5.2",
434 "name": "GLM-5.2",
435 "family": "glm",
436 "reasoning": true,
437 "tool_call": true,
438 "structured_output": true,
439 "modalities": { "input": ["text"], "output": ["text"] },
440 "limit": { "context": 1000000, "output": 131072 },
441 "open_weights": true
442 }
443 },
444 "providers": {
445 "zhipuai": {
446 "id": "zhipuai",
447 "name": "Zhipu AI",
448 "api": "https://open.bigmodel.cn/api/paas/v4",
449 "npm": "@ai-sdk/openai-compatible",
450 "env": ["ZHIPU_API_KEY"],
451 "models": {
452 "glm-5.2": {
453 "id": "glm-5.2",
454 "name": "GLM-5.2",
455 "family": "glm",
456 "reasoning": true,
457 "reasoning_options": [{ "type": "effort", "values": ["high", "max"] }],
458 "tool_call": true,
459 "structured_output": true,
460 "modalities": { "input": ["text"], "output": ["text"] },
461 "limit": { "context": 1000000, "output": 131072 },
462 "cost": { "input": 1.4, "output": 4.4, "cache_read": 0.26 }
463 }
464 }
465 },
466 "zai": {
467 "id": "zai",
468 "name": "Z.AI",
469 "api": "https://api.z.ai/api/paas/v4",
470 "npm": "@ai-sdk/openai-compatible",
471 "env": ["ZHIPU_API_KEY"],
472 "models": {
473 "glm-5.2": {
474 "id": "glm-5.2",
475 "family": "glm",
476 "reasoning": true,
477 "tool_call": true,
478 "modalities": { "input": ["text"], "output": ["text"] },
479 "cost": { "input": 1.4, "output": 4.4 }
480 }
481 }
482 }
483 }
484 }"#;
485
486 #[test]
487 fn parses_models_dev_catalog_layers_without_joining_by_prefix() {
488 let catalog = ModelsDevCatalog::parse_json(GLM_FIXTURE).expect("fixture parses");
489
490 let canonical = catalog.model("zhipuai/glm-5.2").expect("canonical model");
491 assert_eq!(canonical.family.as_deref(), Some("glm"));
492 assert_eq!(
493 canonical.limit.as_ref().and_then(|limit| limit.context),
494 Some(1_000_000)
495 );
496 assert!(canonical.supports_text_chat());
497
498 let provider = catalog.provider("zhipuai").expect("provider");
499 assert_eq!(
500 provider.api.as_deref(),
501 Some("https://open.bigmodel.cn/api/paas/v4")
502 );
503 assert_eq!(provider.npm.as_deref(), Some("@ai-sdk/openai-compatible"));
504 assert_eq!(provider.env, ["ZHIPU_API_KEY"]);
505
506 let offering = catalog
507 .provider_model("zhipuai", "glm-5.2")
508 .expect("provider model");
509 assert_eq!(offering.id, "glm-5.2");
510 assert_eq!(offering.reasoning, Some(true));
511 assert_eq!(
512 offering.cost.as_ref().and_then(|cost| cost.cache_read),
513 Some(0.26)
514 );
515 assert!(offering.supports_text_chat());
516 assert_eq!(
517 offering.base_model, None,
518 "generated JSON does not prove a canonical join"
519 );
520
521 let route_offering = catalog
522 .provider_offering("zhipuai", "glm-5.2")
523 .expect("route offering");
524 assert_eq!(route_offering.limits.context_tokens, Some(1_000_000));
525 assert_eq!(route_offering.limits.output_tokens, Some(131_072));
526 assert_eq!(
527 route_offering.capabilities.reasoning,
528 CapabilityState::Supported
529 );
530 assert_eq!(
531 route_offering.capabilities.native_tool_calls,
532 CapabilityState::Supported
533 );
534 assert_eq!(
535 route_offering.capabilities.structured_output,
536 CapabilityState::Supported
537 );
538 assert_eq!(
539 route_offering.capabilities.streaming,
540 CapabilityState::Unknown
541 );
542 }
543
544 #[test]
545 fn provider_offering_preserves_wire_id_without_inferred_canonical_model() {
546 let catalog = ModelsDevCatalog::parse_json(GLM_FIXTURE).expect("fixture parses");
547 let offering = catalog
548 .provider_offering("zai", "glm-5.2")
549 .expect("offering");
550
551 assert_eq!(offering.provider.as_str(), "zai");
552 assert_eq!(offering.wire_model_id.as_str(), "glm-5.2");
553 assert_eq!(offering.canonical_model, None);
554 assert_eq!(offering.endpoint_key, "chat");
555 }
556
557 #[test]
558 fn provider_offering_uses_explicit_base_model_when_present() {
559 let raw = r#"{
560 "providers": {
561 "openrouter": {
562 "id": "openrouter",
563 "models": {
564 "z-ai/glm-5.2": {
565 "id": "z-ai/glm-5.2",
566 "base_model": "zhipuai/glm-5.2"
567 }
568 }
569 }
570 }
571 }"#;
572 let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
573 let offering = catalog
574 .provider_offering("openrouter", "z-ai/glm-5.2")
575 .expect("offering");
576
577 assert_eq!(
578 offering.canonical_model.as_ref().map(ModelId::as_str),
579 Some("zhipuai/glm-5.2")
580 );
581 assert_eq!(offering.wire_model_id.as_str(), "z-ai/glm-5.2");
582 }
583
584 #[test]
585 fn provider_offerings_emit_chat_rows_and_skip_non_text_outputs() {
586 let raw = r#"{
587 "providers": {
588 "zai": {
589 "models": {
590 "glm-5.2": {
591 "id": "glm-5.2",
592 "base_model": "zhipuai/glm-5.2",
593 "default": true,
594 "modalities": { "input": ["text"], "output": ["text"] }
595 },
596 "glm-voice": {
597 "id": "glm-voice",
598 "modalities": { "input": ["text"], "output": ["audio"] }
599 }
600 }
601 }
602 }
603 }"#;
604 let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
605 let offerings = catalog
606 .provider_offerings("zai")
607 .expect("provider offerings");
608
609 assert_eq!(offerings.len(), 1);
610 assert_eq!(offerings[0].provider.as_str(), "zai");
611 assert_eq!(offerings[0].wire_model_id.as_str(), "glm-5.2");
612 assert_eq!(
613 offerings[0].canonical_model.as_ref().map(ModelId::as_str),
614 Some("zhipuai/glm-5.2")
615 );
616 assert!(offerings[0].default_for_provider);
617 }
618
619 #[test]
620 fn non_text_output_is_not_a_chat_model() {
621 let model = ModelsDevProviderModel {
622 id: "mimo-v2.5-tts".to_string(),
623 modalities: Some(ModelsDevModalities {
624 input: vec!["text".to_string()],
625 output: vec!["audio".to_string()],
626 }),
627 ..Default::default()
628 };
629
630 assert!(!model.supports_text_chat());
631 }
632
633 #[test]
634 fn empty_modalities_struct_is_chat_capable() {
635 let provider_model = ModelsDevProviderModel {
639 modalities: Some(ModelsDevModalities::default()),
640 ..Default::default()
641 };
642 assert!(provider_model.supports_text_chat());
643
644 let canonical = ModelsDevModel {
645 modalities: Some(ModelsDevModalities::default()),
646 ..Default::default()
647 };
648 assert!(canonical.supports_text_chat());
649
650 let audio_only = ModelsDevProviderModel {
652 modalities: Some(ModelsDevModalities {
653 input: vec!["text".to_string()],
654 output: vec!["audio".to_string()],
655 }),
656 ..Default::default()
657 };
658 assert!(!audio_only.supports_text_chat());
659 }
660
661 #[test]
662 fn interleaved_boolean_true_parses_and_reports_enabled() {
663 let raw = r#"{
666 "providers": {
667 "vercel": {
668 "models": {
669 "zai/glm-4.7": { "id": "zai/glm-4.7", "interleaved": true }
670 }
671 }
672 }
673 }"#;
674 let catalog = ModelsDevCatalog::parse_json(raw).expect("boolean interleaved parses");
675 let model = catalog
676 .provider_model("vercel", "zai/glm-4.7")
677 .expect("provider model");
678 let interleaved = model.interleaved.as_ref().expect("interleaved present");
679 assert_eq!(interleaved, &ModelsDevInterleaved::Enabled(true));
680 assert!(interleaved.is_enabled());
681 assert_eq!(interleaved.field(), None);
682 }
683
684 #[test]
685 fn interleaved_boolean_false_parses_and_reports_disabled() {
686 let raw = r#"{
687 "providers": {
688 "custom": {
689 "models": {
690 "house-model": { "id": "house-model", "interleaved": false }
691 }
692 }
693 }
694 }"#;
695 let catalog = ModelsDevCatalog::parse_json(raw).expect("boolean interleaved parses");
696 let model = catalog
697 .provider_model("custom", "house-model")
698 .expect("provider model");
699 let interleaved = model.interleaved.as_ref().expect("interleaved present");
700 assert_eq!(interleaved, &ModelsDevInterleaved::Enabled(false));
701 assert!(!interleaved.is_enabled());
702 assert_eq!(interleaved.field(), None);
703 }
704
705 #[test]
706 fn interleaved_object_form_preserves_field_metadata() {
707 let raw = r#"{
710 "providers": {
711 "alibaba-cn": {
712 "models": {
713 "glm-5.2": {
714 "id": "glm-5.2",
715 "interleaved": { "field": "reasoning_content" }
716 }
717 }
718 }
719 }
720 }"#;
721 let catalog = ModelsDevCatalog::parse_json(raw).expect("object interleaved parses");
722 let model = catalog
723 .provider_model("alibaba-cn", "glm-5.2")
724 .expect("provider model");
725 let interleaved = model.interleaved.as_ref().expect("interleaved present");
726 assert_eq!(interleaved.field(), Some("reasoning_content"));
727 assert!(interleaved.is_enabled());
728 }
729
730 #[test]
731 fn interleaved_object_tolerates_empty_and_unknown_keys() {
732 let raw = r#"{
735 "providers": {
736 "custom": {
737 "models": {
738 "empty-obj": { "id": "empty-obj", "interleaved": {} },
739 "future-obj": {
740 "id": "future-obj",
741 "interleaved": { "future_hint": "x" }
742 }
743 }
744 }
745 }
746 }"#;
747 let catalog = ModelsDevCatalog::parse_json(raw).expect("tolerant interleaved parses");
748
749 let empty = catalog
750 .provider_model("custom", "empty-obj")
751 .and_then(|m| m.interleaved.clone())
752 .expect("empty object interleaved present");
753 assert_eq!(empty, ModelsDevInterleaved::Field { field: None });
754 assert_eq!(empty.field(), None);
755 assert!(empty.is_enabled());
756
757 let future = catalog
758 .provider_model("custom", "future-obj")
759 .and_then(|m| m.interleaved.clone())
760 .expect("future object interleaved present");
761 assert_eq!(future.field(), None);
762 }
763
764 #[test]
765 fn live_ish_mixed_interleaved_sample_deserializes() {
766 let raw = r#"{
771 "providers": {
772 "amazon-bedrock": {
773 "id": "amazon-bedrock",
774 "doc": "https://docs.aws.amazon.com/bedrock/",
775 "models": {
776 "anthropic.claude-opus": {
777 "id": "anthropic.claude-opus",
778 "reasoning": true,
779 "interleaved": true,
780 "some_future_flag": 7,
781 "modalities": { "input": ["text"], "output": ["text"] }
782 }
783 }
784 },
785 "alibaba-cn": {
786 "id": "alibaba-cn",
787 "models": {
788 "deepseek-v4-flash": {
789 "id": "deepseek-v4-flash",
790 "interleaved": { "field": "reasoning_content" },
791 "modalities": { "input": ["text"], "output": ["text"] }
792 }
793 }
794 }
795 }
796 }"#;
797 let catalog = ModelsDevCatalog::parse_json(raw).expect("live-ish sample parses");
798
799 let bedrock = catalog
800 .provider_model("amazon-bedrock", "anthropic.claude-opus")
801 .expect("bedrock row");
802 assert_eq!(
803 bedrock.interleaved,
804 Some(ModelsDevInterleaved::Enabled(true))
805 );
806
807 let alibaba = catalog
808 .provider_model("alibaba-cn", "deepseek-v4-flash")
809 .expect("alibaba row");
810 assert_eq!(
811 alibaba.interleaved.as_ref().and_then(|i| i.field()),
812 Some("reasoning_content")
813 );
814
815 assert_eq!(
818 catalog
819 .provider_offerings("amazon-bedrock")
820 .map(|rows| rows.len()),
821 Some(1)
822 );
823 }
824
825 #[test]
826 fn provider_offerings_keep_rows_with_empty_modalities_object() {
827 let raw = r#"{
831 "providers": {
832 "custom": {
833 "models": {
834 "house-model": { "id": "house-model", "modalities": {} }
835 }
836 }
837 }
838 }"#;
839 let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
840 let offerings = catalog
841 .provider_offerings("custom")
842 .expect("provider offerings");
843
844 assert_eq!(offerings.len(), 1);
845 assert_eq!(offerings[0].wire_model_id.as_str(), "house-model");
846 assert_eq!(offerings[0].provider.as_str(), "custom");
848 }
849}