use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::route::{
CapabilityState, ModelId, ProviderId, ProviderModelOffering, RouteCapabilities, RouteLimits,
WireModelId,
};
pub const MODELS_DEV_API_URL: &str = "https://models.dev/api.json";
pub const MODELS_DEV_MODELS_URL: &str = "https://models.dev/models.json";
pub const MODELS_DEV_CATALOG_URL: &str = "https://models.dev/catalog.json";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ModelsDevCatalog {
#[serde(default)]
pub models: BTreeMap<String, ModelsDevModel>,
#[serde(default)]
pub providers: BTreeMap<String, ModelsDevProvider>,
}
impl ModelsDevCatalog {
pub fn parse_json(raw: &str) -> serde_json::Result<Self> {
serde_json::from_str(raw)
}
#[must_use]
pub fn model(&self, model_id: &str) -> Option<&ModelsDevModel> {
self.models.get(model_id.trim())
}
#[must_use]
pub fn provider(&self, provider_id: &str) -> Option<&ModelsDevProvider> {
self.providers.get(provider_id.trim())
}
#[must_use]
pub fn provider_model(
&self,
provider_id: &str,
wire_model_id: &str,
) -> Option<&ModelsDevProviderModel> {
self.provider(provider_id)?.models.get(wire_model_id.trim())
}
#[must_use]
pub fn provider_offering(
&self,
provider_id: &str,
wire_model_id: &str,
) -> Option<ProviderModelOffering> {
let provider_key = provider_id.trim();
let provider = self.provider(provider_key)?;
let model = provider.models.get(wire_model_id.trim())?;
let provider_id = provider.effective_id(provider_key);
Some(ProviderModelOffering {
provider: ProviderId::from(provider_id.clone()),
canonical_model: model.base_model.clone().map(ModelId::from),
wire_model_id: WireModelId::from(model.id.clone()),
endpoint_key: "chat".to_string(),
default_for_provider: model.default_for_provider,
limits: model
.limit
.as_ref()
.map(RouteLimits::from)
.unwrap_or_default(),
capabilities: route_capabilities(&provider_id, model),
pricing: crate::pricing::route_pricing_sku_from_cost(model.cost.as_ref()),
})
}
#[must_use]
pub fn provider_offerings(&self, provider_id: &str) -> Option<Vec<ProviderModelOffering>> {
let provider_key = provider_id.trim();
let provider = self.provider(provider_key)?;
let provider_id = provider.effective_id(provider_key);
Some(
provider
.models
.values()
.filter(|model| model.supports_text_chat())
.map(|model| ProviderModelOffering {
provider: ProviderId::from(provider_id.clone()),
canonical_model: model.base_model.clone().map(ModelId::from),
wire_model_id: WireModelId::from(model.id.clone()),
endpoint_key: "chat".to_string(),
default_for_provider: model.default_for_provider,
limits: model
.limit
.as_ref()
.map(RouteLimits::from)
.unwrap_or_default(),
capabilities: route_capabilities(&provider_id, model),
pricing: crate::pricing::route_pricing_sku_from_cost(model.cost.as_ref()),
})
.collect(),
)
}
}
fn route_capabilities(provider_id: &str, model: &ModelsDevProviderModel) -> RouteCapabilities {
RouteCapabilities {
attachments: CapabilityState::from_optional_bool(model.attachment),
image_input: image_input_support(model.modalities.as_ref()),
reasoning: CapabilityState::from_optional_bool(model.reasoning),
native_tool_calls: CapabilityState::from_optional_bool(model.tool_call),
structured_output: CapabilityState::from_optional_bool(model.structured_output),
server_side_web_search: crate::route::documented_server_side_web_search(
provider_id,
&model.id,
),
..RouteCapabilities::default()
}
}
#[must_use]
pub fn image_input_support(modalities: Option<&ModelsDevModalities>) -> CapabilityState {
let Some(modalities) = modalities else {
return CapabilityState::Unknown;
};
if modalities.input.is_empty() {
return CapabilityState::Unknown;
}
CapabilityState::from_optional_bool(Some(
modalities
.input
.iter()
.any(|modality| modality.trim().eq_ignore_ascii_case("image")),
))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ModelsDevModel {
#[serde(default)]
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub family: Option<String>,
#[serde(default)]
pub attachment: Option<bool>,
#[serde(default)]
pub reasoning: Option<bool>,
#[serde(default)]
pub tool_call: Option<bool>,
#[serde(default)]
pub structured_output: Option<bool>,
#[serde(default)]
pub temperature: Option<bool>,
#[serde(default)]
pub open_weights: Option<bool>,
#[serde(default)]
pub limit: Option<ModelsDevLimit>,
#[serde(default)]
pub modalities: Option<ModelsDevModalities>,
}
impl ModelsDevModel {
#[must_use]
pub fn supports_text_chat(&self) -> bool {
supports_text_chat(self.modalities.as_ref())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ModelsDevProviderModel {
#[serde(default)]
pub id: String,
#[serde(default)]
pub base_model: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub family: Option<String>,
#[serde(default, alias = "default")]
pub default_for_provider: bool,
#[serde(default)]
pub attachment: Option<bool>,
#[serde(default)]
pub reasoning: Option<bool>,
#[serde(default)]
pub reasoning_options: Vec<serde_json::Value>,
#[serde(default)]
pub tool_call: Option<bool>,
#[serde(default)]
pub structured_output: Option<bool>,
#[serde(default)]
pub temperature: Option<bool>,
#[serde(default)]
pub open_weights: Option<bool>,
#[serde(default)]
pub limit: Option<ModelsDevLimit>,
#[serde(default)]
pub modalities: Option<ModelsDevModalities>,
#[serde(default)]
pub cost: Option<ModelsDevCost>,
#[serde(default)]
pub interleaved: Option<ModelsDevInterleaved>,
}
impl ModelsDevProviderModel {
#[must_use]
pub fn supports_text_chat(&self) -> bool {
supports_text_chat(self.modalities.as_ref())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ModelsDevProvider {
#[serde(default)]
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub api: Option<String>,
#[serde(default)]
pub npm: Option<String>,
#[serde(default)]
pub doc: Option<String>,
#[serde(default)]
pub env: Vec<String>,
#[serde(default)]
pub models: BTreeMap<String, ModelsDevProviderModel>,
}
impl ModelsDevProvider {
#[must_use]
fn effective_id(&self, provider_key: &str) -> String {
if self.id.trim().is_empty() {
provider_key.to_string()
} else {
self.id.trim().to_string()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ModelsDevLimit {
#[serde(default)]
pub context: Option<u64>,
#[serde(default)]
pub input: Option<u64>,
#[serde(default)]
pub output: Option<u64>,
}
impl From<&ModelsDevLimit> for RouteLimits {
fn from(limit: &ModelsDevLimit) -> Self {
Self {
context_tokens: limit.context,
input_tokens: limit.input,
output_tokens: limit.output,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ModelsDevModalities {
#[serde(default)]
pub input: Vec<String>,
#[serde(default)]
pub output: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ModelsDevCost {
#[serde(default)]
pub input: Option<f64>,
#[serde(default)]
pub output: Option<f64>,
#[serde(default)]
pub cache_read: Option<f64>,
#[serde(default)]
pub cache_write: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ModelsDevInterleaved {
Enabled(bool),
Field {
#[serde(default)]
field: Option<String>,
},
}
impl ModelsDevInterleaved {
#[must_use]
pub fn is_enabled(&self) -> bool {
match self {
Self::Enabled(enabled) => *enabled,
Self::Field { .. } => true,
}
}
#[must_use]
pub fn field(&self) -> Option<&str> {
match self {
Self::Enabled(_) => None,
Self::Field { field } => field.as_deref(),
}
}
}
fn supports_text_chat(modalities: Option<&ModelsDevModalities>) -> bool {
let Some(modalities) = modalities else {
return true;
};
let input_ok = modalities.input.is_empty()
|| modalities
.input
.iter()
.any(|modality| modality.eq_ignore_ascii_case("text"));
let output_ok = modalities.output.is_empty()
|| modalities
.output
.iter()
.any(|modality| modality.eq_ignore_ascii_case("text"));
input_ok && output_ok
}
#[cfg(test)]
mod tests {
use super::*;
const GLM_FIXTURE: &str = r#"{
"models": {
"zhipuai/glm-5.2": {
"id": "zhipuai/glm-5.2",
"name": "GLM-5.2",
"family": "glm",
"reasoning": true,
"tool_call": true,
"structured_output": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 131072 },
"open_weights": true
}
},
"providers": {
"zhipuai": {
"id": "zhipuai",
"name": "Zhipu AI",
"api": "https://open.bigmodel.cn/api/paas/v4",
"npm": "@ai-sdk/openai-compatible",
"env": ["ZHIPU_API_KEY"],
"models": {
"glm-5.2": {
"id": "glm-5.2",
"name": "GLM-5.2",
"family": "glm",
"reasoning": true,
"reasoning_options": [{ "type": "effort", "values": ["high", "max"] }],
"tool_call": true,
"structured_output": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 131072 },
"cost": { "input": 1.4, "output": 4.4, "cache_read": 0.26 }
}
}
},
"zai": {
"id": "zai",
"name": "Z.AI",
"api": "https://api.z.ai/api/paas/v4",
"npm": "@ai-sdk/openai-compatible",
"env": ["ZHIPU_API_KEY"],
"models": {
"glm-5.2": {
"id": "glm-5.2",
"family": "glm",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"cost": { "input": 1.4, "output": 4.4 }
}
}
}
}
}"#;
#[test]
fn parses_models_dev_catalog_layers_without_joining_by_prefix() {
let catalog = ModelsDevCatalog::parse_json(GLM_FIXTURE).expect("fixture parses");
let canonical = catalog.model("zhipuai/glm-5.2").expect("canonical model");
assert_eq!(canonical.family.as_deref(), Some("glm"));
assert_eq!(
canonical.limit.as_ref().and_then(|limit| limit.context),
Some(1_000_000)
);
assert!(canonical.supports_text_chat());
let provider = catalog.provider("zhipuai").expect("provider");
assert_eq!(
provider.api.as_deref(),
Some("https://open.bigmodel.cn/api/paas/v4")
);
assert_eq!(provider.npm.as_deref(), Some("@ai-sdk/openai-compatible"));
assert_eq!(provider.env, ["ZHIPU_API_KEY"]);
let offering = catalog
.provider_model("zhipuai", "glm-5.2")
.expect("provider model");
assert_eq!(offering.id, "glm-5.2");
assert_eq!(offering.reasoning, Some(true));
assert_eq!(
offering.cost.as_ref().and_then(|cost| cost.cache_read),
Some(0.26)
);
assert!(offering.supports_text_chat());
assert_eq!(
offering.base_model, None,
"generated JSON does not prove a canonical join"
);
let route_offering = catalog
.provider_offering("zhipuai", "glm-5.2")
.expect("route offering");
assert_eq!(route_offering.limits.context_tokens, Some(1_000_000));
assert_eq!(route_offering.limits.output_tokens, Some(131_072));
assert_eq!(
route_offering.capabilities.reasoning,
CapabilityState::Supported
);
assert_eq!(
route_offering.capabilities.native_tool_calls,
CapabilityState::Supported
);
assert_eq!(
route_offering.capabilities.structured_output,
CapabilityState::Supported
);
assert_eq!(
route_offering.capabilities.streaming,
CapabilityState::Unknown
);
}
#[test]
fn provider_offering_preserves_wire_id_without_inferred_canonical_model() {
let catalog = ModelsDevCatalog::parse_json(GLM_FIXTURE).expect("fixture parses");
let offering = catalog
.provider_offering("zai", "glm-5.2")
.expect("offering");
assert_eq!(offering.provider.as_str(), "zai");
assert_eq!(offering.wire_model_id.as_str(), "glm-5.2");
assert_eq!(offering.canonical_model, None);
assert_eq!(offering.endpoint_key, "chat");
}
#[test]
fn provider_offering_uses_explicit_base_model_when_present() {
let raw = r#"{
"providers": {
"openrouter": {
"id": "openrouter",
"models": {
"z-ai/glm-5.2": {
"id": "z-ai/glm-5.2",
"base_model": "zhipuai/glm-5.2"
}
}
}
}
}"#;
let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
let offering = catalog
.provider_offering("openrouter", "z-ai/glm-5.2")
.expect("offering");
assert_eq!(
offering.canonical_model.as_ref().map(ModelId::as_str),
Some("zhipuai/glm-5.2")
);
assert_eq!(offering.wire_model_id.as_str(), "z-ai/glm-5.2");
}
#[test]
fn provider_offerings_emit_chat_rows_and_skip_non_text_outputs() {
let raw = r#"{
"providers": {
"zai": {
"models": {
"glm-5.2": {
"id": "glm-5.2",
"base_model": "zhipuai/glm-5.2",
"default": true,
"modalities": { "input": ["text"], "output": ["text"] }
},
"glm-voice": {
"id": "glm-voice",
"modalities": { "input": ["text"], "output": ["audio"] }
}
}
}
}
}"#;
let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
let offerings = catalog
.provider_offerings("zai")
.expect("provider offerings");
assert_eq!(offerings.len(), 1);
assert_eq!(offerings[0].provider.as_str(), "zai");
assert_eq!(offerings[0].wire_model_id.as_str(), "glm-5.2");
assert_eq!(
offerings[0].canonical_model.as_ref().map(ModelId::as_str),
Some("zhipuai/glm-5.2")
);
assert!(offerings[0].default_for_provider);
}
#[test]
fn non_text_output_is_not_a_chat_model() {
let model = ModelsDevProviderModel {
id: "mimo-v2.5-tts".to_string(),
modalities: Some(ModelsDevModalities {
input: vec!["text".to_string()],
output: vec!["audio".to_string()],
}),
..Default::default()
};
assert!(!model.supports_text_chat());
}
#[test]
fn empty_modalities_struct_is_chat_capable() {
let provider_model = ModelsDevProviderModel {
modalities: Some(ModelsDevModalities::default()),
..Default::default()
};
assert!(provider_model.supports_text_chat());
let canonical = ModelsDevModel {
modalities: Some(ModelsDevModalities::default()),
..Default::default()
};
assert!(canonical.supports_text_chat());
let audio_only = ModelsDevProviderModel {
modalities: Some(ModelsDevModalities {
input: vec!["text".to_string()],
output: vec!["audio".to_string()],
}),
..Default::default()
};
assert!(!audio_only.supports_text_chat());
}
#[test]
fn image_input_support_preserves_unknown_and_text_only_facts() {
assert_eq!(image_input_support(None), CapabilityState::Unknown);
assert_eq!(
image_input_support(Some(&ModelsDevModalities::default())),
CapabilityState::Unknown
);
assert_eq!(
image_input_support(Some(&ModelsDevModalities {
input: vec!["text".to_string()],
output: vec!["text".to_string()],
})),
CapabilityState::Unsupported
);
assert_eq!(
image_input_support(Some(&ModelsDevModalities {
input: vec!["text".to_string(), "image".to_string()],
output: vec!["text".to_string()],
})),
CapabilityState::Supported
);
}
#[test]
fn interleaved_boolean_true_parses_and_reports_enabled() {
let raw = r#"{
"providers": {
"vercel": {
"models": {
"zai/glm-4.7": { "id": "zai/glm-4.7", "interleaved": true }
}
}
}
}"#;
let catalog = ModelsDevCatalog::parse_json(raw).expect("boolean interleaved parses");
let model = catalog
.provider_model("vercel", "zai/glm-4.7")
.expect("provider model");
let interleaved = model.interleaved.as_ref().expect("interleaved present");
assert_eq!(interleaved, &ModelsDevInterleaved::Enabled(true));
assert!(interleaved.is_enabled());
assert_eq!(interleaved.field(), None);
}
#[test]
fn interleaved_boolean_false_parses_and_reports_disabled() {
let raw = r#"{
"providers": {
"custom": {
"models": {
"house-model": { "id": "house-model", "interleaved": false }
}
}
}
}"#;
let catalog = ModelsDevCatalog::parse_json(raw).expect("boolean interleaved parses");
let model = catalog
.provider_model("custom", "house-model")
.expect("provider model");
let interleaved = model.interleaved.as_ref().expect("interleaved present");
assert_eq!(interleaved, &ModelsDevInterleaved::Enabled(false));
assert!(!interleaved.is_enabled());
assert_eq!(interleaved.field(), None);
}
#[test]
fn interleaved_object_form_preserves_field_metadata() {
let raw = r#"{
"providers": {
"alibaba-cn": {
"models": {
"glm-5.2": {
"id": "glm-5.2",
"interleaved": { "field": "reasoning_content" }
}
}
}
}
}"#;
let catalog = ModelsDevCatalog::parse_json(raw).expect("object interleaved parses");
let model = catalog
.provider_model("alibaba-cn", "glm-5.2")
.expect("provider model");
let interleaved = model.interleaved.as_ref().expect("interleaved present");
assert_eq!(interleaved.field(), Some("reasoning_content"));
assert!(interleaved.is_enabled());
}
#[test]
fn interleaved_object_tolerates_empty_and_unknown_keys() {
let raw = r#"{
"providers": {
"custom": {
"models": {
"empty-obj": { "id": "empty-obj", "interleaved": {} },
"future-obj": {
"id": "future-obj",
"interleaved": { "future_hint": "x" }
}
}
}
}
}"#;
let catalog = ModelsDevCatalog::parse_json(raw).expect("tolerant interleaved parses");
let empty = catalog
.provider_model("custom", "empty-obj")
.and_then(|m| m.interleaved.clone())
.expect("empty object interleaved present");
assert_eq!(empty, ModelsDevInterleaved::Field { field: None });
assert_eq!(empty.field(), None);
assert!(empty.is_enabled());
let future = catalog
.provider_model("custom", "future-obj")
.and_then(|m| m.interleaved.clone())
.expect("future object interleaved present");
assert_eq!(future.field(), None);
}
#[test]
fn live_ish_mixed_interleaved_sample_deserializes() {
let raw = r#"{
"providers": {
"amazon-bedrock": {
"id": "amazon-bedrock",
"doc": "https://docs.aws.amazon.com/bedrock/",
"models": {
"anthropic.claude-opus": {
"id": "anthropic.claude-opus",
"reasoning": true,
"interleaved": true,
"some_future_flag": 7,
"modalities": { "input": ["text"], "output": ["text"] }
}
}
},
"alibaba-cn": {
"id": "alibaba-cn",
"models": {
"deepseek-v4-flash": {
"id": "deepseek-v4-flash",
"interleaved": { "field": "reasoning_content" },
"modalities": { "input": ["text"], "output": ["text"] }
}
}
}
}
}"#;
let catalog = ModelsDevCatalog::parse_json(raw).expect("live-ish sample parses");
let bedrock = catalog
.provider_model("amazon-bedrock", "anthropic.claude-opus")
.expect("bedrock row");
assert_eq!(
bedrock.interleaved,
Some(ModelsDevInterleaved::Enabled(true))
);
let alibaba = catalog
.provider_model("alibaba-cn", "deepseek-v4-flash")
.expect("alibaba row");
assert_eq!(
alibaba.interleaved.as_ref().and_then(|i| i.field()),
Some("reasoning_content")
);
assert_eq!(
catalog
.provider_offerings("amazon-bedrock")
.map(|rows| rows.len()),
Some(1)
);
}
#[test]
fn provider_offerings_keep_rows_with_empty_modalities_object() {
let raw = r#"{
"providers": {
"custom": {
"models": {
"house-model": { "id": "house-model", "modalities": {} }
}
}
}
}"#;
let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
let offerings = catalog
.provider_offerings("custom")
.expect("provider offerings");
assert_eq!(offerings.len(), 1);
assert_eq!(offerings[0].wire_model_id.as_str(), "house-model");
assert_eq!(offerings[0].provider.as_str(), "custom");
}
}