Skip to main content

codewhale_config/
models_dev.rs

1//! Models.dev catalog schema and helpers.
2//!
3//! Models.dev is the upstream taxonomy CodeWhale should use for model facts,
4//! provider offerings, pricing, limits, and capabilities. This module is
5//! intentionally network-free: callers provide JSON from a bundled snapshot,
6//! live refresh, or tests. Runtime fetch/cache policy belongs above this layer.
7//!
8//! The important boundary is the same one Models.dev uses:
9//! - `models` are provider-agnostic model facts.
10//! - `providers.*.models` are provider-scoped wire offerings.
11//!
12//! A provider row may inline inherited facts without exposing a canonical
13//! `base_model` link. CodeWhale must preserve that distinction instead of
14//! inferring canonical ownership from wire IDs or namespace prefixes.
15
16use std::collections::BTreeMap;
17
18use serde::{Deserialize, Serialize};
19
20use crate::route::{ModelId, ProviderId, ProviderModelOffering, RouteLimits, WireModelId};
21
22/// Provider catalog endpoint used by Models.dev.
23pub const MODELS_DEV_API_URL: &str = "https://models.dev/api.json";
24/// Provider-agnostic model metadata endpoint used by Models.dev.
25pub const MODELS_DEV_MODELS_URL: &str = "https://models.dev/models.json";
26/// Combined `{ models, providers }` endpoint used by Models.dev.
27pub const MODELS_DEV_CATALOG_URL: &str = "https://models.dev/catalog.json";
28
29/// Combined Models.dev catalog payload.
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
31pub struct ModelsDevCatalog {
32    /// Provider-agnostic model facts, keyed by canonical model id.
33    #[serde(default)]
34    pub models: BTreeMap<String, ModelsDevModel>,
35    /// Provider-scoped catalogs, keyed by provider id.
36    #[serde(default)]
37    pub providers: BTreeMap<String, ModelsDevProvider>,
38}
39
40impl ModelsDevCatalog {
41    /// Parse a Models.dev combined catalog JSON payload.
42    ///
43    /// # Errors
44    /// Returns a serde error when the input is not valid Models.dev JSON.
45    pub fn parse_json(raw: &str) -> serde_json::Result<Self> {
46        serde_json::from_str(raw)
47    }
48
49    /// Look up provider-agnostic model facts by canonical model id.
50    #[must_use]
51    pub fn model(&self, model_id: &str) -> Option<&ModelsDevModel> {
52        self.models.get(model_id.trim())
53    }
54
55    /// Look up a provider catalog by provider id.
56    #[must_use]
57    pub fn provider(&self, provider_id: &str) -> Option<&ModelsDevProvider> {
58        self.providers.get(provider_id.trim())
59    }
60
61    /// Look up a provider-scoped wire model row.
62    #[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    /// Build a route offering from a provider-scoped Models.dev row.
72    ///
73    /// The canonical model is set only when the row carries an explicit
74    /// `base_model` id. Generated Models.dev JSON often inlines inherited facts
75    /// without that link, so callers must not guess one from a prefix.
76    #[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    /// Build route offerings for every normal text-chat model served by a
102    /// provider.
103    ///
104    /// Non-chat rows (for example TTS/audio-only offerings) stay in the parsed
105    /// catalog but are excluded from route resolution lists.
106    #[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/// Provider-agnostic model facts from `models.json` / `catalog.models`.
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
136pub struct ModelsDevModel {
137    /// Canonical Models.dev model id, such as `zhipuai/glm-5.2`.
138    #[serde(default)]
139    pub id: String,
140    /// Human-friendly model name.
141    #[serde(default)]
142    pub name: Option<String>,
143    /// Model family, such as `glm`, `gpt`, or `claude`.
144    #[serde(default)]
145    pub family: Option<String>,
146    /// Whether attachments are accepted.
147    #[serde(default)]
148    pub attachment: Option<bool>,
149    /// Whether the model supports reasoning.
150    #[serde(default)]
151    pub reasoning: Option<bool>,
152    /// Whether tool calling is supported.
153    #[serde(default)]
154    pub tool_call: Option<bool>,
155    /// Whether structured output is supported.
156    #[serde(default)]
157    pub structured_output: Option<bool>,
158    /// Whether temperature is supported.
159    #[serde(default)]
160    pub temperature: Option<bool>,
161    /// Whether weights are open.
162    #[serde(default)]
163    pub open_weights: Option<bool>,
164    /// Token limits.
165    #[serde(default)]
166    pub limit: Option<ModelsDevLimit>,
167    /// Input/output modalities.
168    #[serde(default)]
169    pub modalities: Option<ModelsDevModalities>,
170}
171
172impl ModelsDevModel {
173    /// True when the model can be used for normal text chat.
174    #[must_use]
175    pub fn supports_text_chat(&self) -> bool {
176        supports_text_chat(self.modalities.as_ref())
177    }
178}
179
180/// Provider-scoped model row from `api.json` / `catalog.providers.*.models`.
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
182pub struct ModelsDevProviderModel {
183    /// Provider wire model id.
184    #[serde(default)]
185    pub id: String,
186    /// Optional explicit canonical model link from source TOML.
187    #[serde(default)]
188    pub base_model: Option<String>,
189    /// Human-friendly model name.
190    #[serde(default)]
191    pub name: Option<String>,
192    /// Model family as exposed for this provider row.
193    #[serde(default)]
194    pub family: Option<String>,
195    /// Whether this is the provider's default model in a CodeWhale snapshot.
196    #[serde(default, alias = "default")]
197    pub default_for_provider: bool,
198    /// Whether attachments are accepted.
199    #[serde(default)]
200    pub attachment: Option<bool>,
201    /// Whether the model supports reasoning.
202    #[serde(default)]
203    pub reasoning: Option<bool>,
204    /// Flexible reasoning-control metadata.
205    #[serde(default)]
206    pub reasoning_options: Vec<serde_json::Value>,
207    /// Whether tool calling is supported.
208    #[serde(default)]
209    pub tool_call: Option<bool>,
210    /// Whether structured output is supported.
211    #[serde(default)]
212    pub structured_output: Option<bool>,
213    /// Whether temperature is supported.
214    #[serde(default)]
215    pub temperature: Option<bool>,
216    /// Whether weights are open through this offering.
217    #[serde(default)]
218    pub open_weights: Option<bool>,
219    /// Token limits for this provider offering.
220    #[serde(default)]
221    pub limit: Option<ModelsDevLimit>,
222    /// Input/output modalities for this provider offering.
223    #[serde(default)]
224    pub modalities: Option<ModelsDevModalities>,
225    /// Provider-scoped pricing.
226    #[serde(default)]
227    pub cost: Option<ModelsDevCost>,
228    /// Interleaved reasoning field hints.
229    #[serde(default)]
230    pub interleaved: Option<ModelsDevInterleaved>,
231}
232
233impl ModelsDevProviderModel {
234    /// True when the provider offering can be used for normal text chat.
235    #[must_use]
236    pub fn supports_text_chat(&self) -> bool {
237        supports_text_chat(self.modalities.as_ref())
238    }
239}
240
241/// Provider row from Models.dev.
242#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
243pub struct ModelsDevProvider {
244    /// Provider id, such as `zai`, `zhipuai`, or `openrouter`.
245    #[serde(default)]
246    pub id: String,
247    /// Human-friendly provider name.
248    #[serde(default)]
249    pub name: Option<String>,
250    /// Default API base URL, if published.
251    #[serde(default)]
252    pub api: Option<String>,
253    /// AI SDK package identifier, useful as a protocol hint.
254    #[serde(default)]
255    pub npm: Option<String>,
256    /// Documentation URL, if published.
257    #[serde(default)]
258    pub doc: Option<String>,
259    /// Environment variable names for credentials.
260    #[serde(default)]
261    pub env: Vec<String>,
262    /// Provider-scoped wire model rows.
263    #[serde(default)]
264    pub models: BTreeMap<String, ModelsDevProviderModel>,
265}
266
267impl ModelsDevProvider {
268    /// Resolve the effective provider id for this row.
269    ///
270    /// Models.dev snapshots usually repeat the catalog key in the `id` field,
271    /// but generated JSON can omit it. Fall back to the catalog key so callers
272    /// never emit an empty [`ProviderId`].
273    #[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/// Token limits.
284#[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/// Input/output modalities.
305#[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/// Provider-scoped cost fields. Values are per million tokens unless a future
314/// Models.dev row specifies a richer tiering object in fields CodeWhale does
315/// not yet model.
316#[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/// Interleaved reasoning field metadata.
329#[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    // Treat an empty modality list the same as absent metadata. An incomplete
340    // catalog snapshot can deserialize to `Some({ input: [], output: [] })`,
341    // and `Iterator::any` over an empty slice is `false` — without this guard
342    // such rows would be silently dropped from chat offerings even though the
343    // `None` branch above defaults them to chat-capable. Only an explicitly
344    // populated, non-text list excludes the row.
345    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        // `"modalities": {}` deserializes to Some(empty); it must default to
552        // chat-capable just like absent modality metadata (the None branch),
553        // otherwise rows from incomplete snapshots are silently dropped.
554        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        // A list populated with only non-text entries still excludes the row.
567        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        // End-to-end guard for the empty-modalities case at the offering layer:
580        // a custom/local provider row with `"modalities": {}` must still emit a
581        // chat offering rather than being filtered out of route resolution.
582        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        // `id` was omitted on the provider row → effective id is the catalog key.
599        assert_eq!(offerings[0].provider.as_str(), "custom");
600    }
601}