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, Default)]
330pub struct ModelsDevInterleaved {
331 #[serde(default)]
332 pub field: Option<String>,
333}
334
335fn supports_text_chat(modalities: Option<&ModelsDevModalities>) -> bool {
336 let Some(modalities) = modalities else {
337 return true;
338 };
339 let input_ok = modalities.input.is_empty()
346 || modalities
347 .input
348 .iter()
349 .any(|modality| modality.eq_ignore_ascii_case("text"));
350 let output_ok = modalities.output.is_empty()
351 || modalities
352 .output
353 .iter()
354 .any(|modality| modality.eq_ignore_ascii_case("text"));
355 input_ok && output_ok
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 const GLM_FIXTURE: &str = r#"{
363 "models": {
364 "zhipuai/glm-5.2": {
365 "id": "zhipuai/glm-5.2",
366 "name": "GLM-5.2",
367 "family": "glm",
368 "reasoning": true,
369 "tool_call": true,
370 "structured_output": true,
371 "modalities": { "input": ["text"], "output": ["text"] },
372 "limit": { "context": 1000000, "output": 131072 },
373 "open_weights": true
374 }
375 },
376 "providers": {
377 "zhipuai": {
378 "id": "zhipuai",
379 "name": "Zhipu AI",
380 "api": "https://open.bigmodel.cn/api/paas/v4",
381 "npm": "@ai-sdk/openai-compatible",
382 "env": ["ZHIPU_API_KEY"],
383 "models": {
384 "glm-5.2": {
385 "id": "glm-5.2",
386 "name": "GLM-5.2",
387 "family": "glm",
388 "reasoning": true,
389 "reasoning_options": [{ "type": "effort", "values": ["high", "max"] }],
390 "tool_call": true,
391 "structured_output": true,
392 "modalities": { "input": ["text"], "output": ["text"] },
393 "limit": { "context": 1000000, "output": 131072 },
394 "cost": { "input": 1.4, "output": 4.4, "cache_read": 0.26 }
395 }
396 }
397 },
398 "zai": {
399 "id": "zai",
400 "name": "Z.AI",
401 "api": "https://api.z.ai/api/paas/v4",
402 "npm": "@ai-sdk/openai-compatible",
403 "env": ["ZHIPU_API_KEY"],
404 "models": {
405 "glm-5.2": {
406 "id": "glm-5.2",
407 "family": "glm",
408 "reasoning": true,
409 "tool_call": true,
410 "modalities": { "input": ["text"], "output": ["text"] },
411 "cost": { "input": 1.4, "output": 4.4 }
412 }
413 }
414 }
415 }
416 }"#;
417
418 #[test]
419 fn parses_models_dev_catalog_layers_without_joining_by_prefix() {
420 let catalog = ModelsDevCatalog::parse_json(GLM_FIXTURE).expect("fixture parses");
421
422 let canonical = catalog.model("zhipuai/glm-5.2").expect("canonical model");
423 assert_eq!(canonical.family.as_deref(), Some("glm"));
424 assert_eq!(
425 canonical.limit.as_ref().and_then(|limit| limit.context),
426 Some(1_000_000)
427 );
428 assert!(canonical.supports_text_chat());
429
430 let provider = catalog.provider("zhipuai").expect("provider");
431 assert_eq!(
432 provider.api.as_deref(),
433 Some("https://open.bigmodel.cn/api/paas/v4")
434 );
435 assert_eq!(provider.npm.as_deref(), Some("@ai-sdk/openai-compatible"));
436 assert_eq!(provider.env, ["ZHIPU_API_KEY"]);
437
438 let offering = catalog
439 .provider_model("zhipuai", "glm-5.2")
440 .expect("provider model");
441 assert_eq!(offering.id, "glm-5.2");
442 assert_eq!(offering.reasoning, Some(true));
443 assert_eq!(
444 offering.cost.as_ref().and_then(|cost| cost.cache_read),
445 Some(0.26)
446 );
447 assert!(offering.supports_text_chat());
448 assert_eq!(
449 offering.base_model, None,
450 "generated JSON does not prove a canonical join"
451 );
452
453 let route_offering = catalog
454 .provider_offering("zhipuai", "glm-5.2")
455 .expect("route offering");
456 assert_eq!(route_offering.limits.context_tokens, Some(1_000_000));
457 assert_eq!(route_offering.limits.output_tokens, Some(131_072));
458 }
459
460 #[test]
461 fn provider_offering_preserves_wire_id_without_inferred_canonical_model() {
462 let catalog = ModelsDevCatalog::parse_json(GLM_FIXTURE).expect("fixture parses");
463 let offering = catalog
464 .provider_offering("zai", "glm-5.2")
465 .expect("offering");
466
467 assert_eq!(offering.provider.as_str(), "zai");
468 assert_eq!(offering.wire_model_id.as_str(), "glm-5.2");
469 assert_eq!(offering.canonical_model, None);
470 assert_eq!(offering.endpoint_key, "chat");
471 }
472
473 #[test]
474 fn provider_offering_uses_explicit_base_model_when_present() {
475 let raw = r#"{
476 "providers": {
477 "openrouter": {
478 "id": "openrouter",
479 "models": {
480 "z-ai/glm-5.2": {
481 "id": "z-ai/glm-5.2",
482 "base_model": "zhipuai/glm-5.2"
483 }
484 }
485 }
486 }
487 }"#;
488 let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
489 let offering = catalog
490 .provider_offering("openrouter", "z-ai/glm-5.2")
491 .expect("offering");
492
493 assert_eq!(
494 offering.canonical_model.as_ref().map(ModelId::as_str),
495 Some("zhipuai/glm-5.2")
496 );
497 assert_eq!(offering.wire_model_id.as_str(), "z-ai/glm-5.2");
498 }
499
500 #[test]
501 fn provider_offerings_emit_chat_rows_and_skip_non_text_outputs() {
502 let raw = r#"{
503 "providers": {
504 "zai": {
505 "models": {
506 "glm-5.2": {
507 "id": "glm-5.2",
508 "base_model": "zhipuai/glm-5.2",
509 "default": true,
510 "modalities": { "input": ["text"], "output": ["text"] }
511 },
512 "glm-voice": {
513 "id": "glm-voice",
514 "modalities": { "input": ["text"], "output": ["audio"] }
515 }
516 }
517 }
518 }
519 }"#;
520 let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
521 let offerings = catalog
522 .provider_offerings("zai")
523 .expect("provider offerings");
524
525 assert_eq!(offerings.len(), 1);
526 assert_eq!(offerings[0].provider.as_str(), "zai");
527 assert_eq!(offerings[0].wire_model_id.as_str(), "glm-5.2");
528 assert_eq!(
529 offerings[0].canonical_model.as_ref().map(ModelId::as_str),
530 Some("zhipuai/glm-5.2")
531 );
532 assert!(offerings[0].default_for_provider);
533 }
534
535 #[test]
536 fn non_text_output_is_not_a_chat_model() {
537 let model = ModelsDevProviderModel {
538 id: "mimo-v2.5-tts".to_string(),
539 modalities: Some(ModelsDevModalities {
540 input: vec!["text".to_string()],
541 output: vec!["audio".to_string()],
542 }),
543 ..Default::default()
544 };
545
546 assert!(!model.supports_text_chat());
547 }
548
549 #[test]
550 fn empty_modalities_struct_is_chat_capable() {
551 let provider_model = ModelsDevProviderModel {
555 modalities: Some(ModelsDevModalities::default()),
556 ..Default::default()
557 };
558 assert!(provider_model.supports_text_chat());
559
560 let canonical = ModelsDevModel {
561 modalities: Some(ModelsDevModalities::default()),
562 ..Default::default()
563 };
564 assert!(canonical.supports_text_chat());
565
566 let audio_only = ModelsDevProviderModel {
568 modalities: Some(ModelsDevModalities {
569 input: vec!["text".to_string()],
570 output: vec!["audio".to_string()],
571 }),
572 ..Default::default()
573 };
574 assert!(!audio_only.supports_text_chat());
575 }
576
577 #[test]
578 fn provider_offerings_keep_rows_with_empty_modalities_object() {
579 let raw = r#"{
583 "providers": {
584 "custom": {
585 "models": {
586 "house-model": { "id": "house-model", "modalities": {} }
587 }
588 }
589 }
590 }"#;
591 let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
592 let offerings = catalog
593 .provider_offerings("custom")
594 .expect("provider offerings");
595
596 assert_eq!(offerings.len(), 1);
597 assert_eq!(offerings[0].wire_model_id.as_str(), "house-model");
598 assert_eq!(offerings[0].provider.as_str(), "custom");
600 }
601}