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::{
21    CapabilityState, ModelId, ProviderId, ProviderModelOffering, RouteCapabilities, RouteLimits,
22    WireModelId,
23};
24
25/// Provider catalog endpoint used by Models.dev.
26pub const MODELS_DEV_API_URL: &str = "https://models.dev/api.json";
27/// Provider-agnostic model metadata endpoint used by Models.dev.
28pub const MODELS_DEV_MODELS_URL: &str = "https://models.dev/models.json";
29/// Combined `{ models, providers }` endpoint used by Models.dev.
30pub const MODELS_DEV_CATALOG_URL: &str = "https://models.dev/catalog.json";
31
32/// Combined Models.dev catalog payload.
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
34pub struct ModelsDevCatalog {
35    /// Provider-agnostic model facts, keyed by canonical model id.
36    #[serde(default)]
37    pub models: BTreeMap<String, ModelsDevModel>,
38    /// Provider-scoped catalogs, keyed by provider id.
39    #[serde(default)]
40    pub providers: BTreeMap<String, ModelsDevProvider>,
41}
42
43impl ModelsDevCatalog {
44    /// Parse a Models.dev combined catalog JSON payload.
45    ///
46    /// # Errors
47    /// Returns a serde error when the input is not valid Models.dev JSON.
48    pub fn parse_json(raw: &str) -> serde_json::Result<Self> {
49        serde_json::from_str(raw)
50    }
51
52    /// Look up provider-agnostic model facts by canonical model id.
53    #[must_use]
54    pub fn model(&self, model_id: &str) -> Option<&ModelsDevModel> {
55        self.models.get(model_id.trim())
56    }
57
58    /// Look up a provider catalog by provider id.
59    #[must_use]
60    pub fn provider(&self, provider_id: &str) -> Option<&ModelsDevProvider> {
61        self.providers.get(provider_id.trim())
62    }
63
64    /// Look up a provider-scoped wire model row.
65    #[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    /// Build a route offering from a provider-scoped Models.dev row.
75    ///
76    /// The canonical model is set only when the row carries an explicit
77    /// `base_model` id. Generated Models.dev JSON often inlines inherited facts
78    /// without that link, so callers must not guess one from a prefix.
79    #[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    /// Build route offerings for every normal text-chat model served by a
106    /// provider.
107    ///
108    /// Non-chat rows (for example TTS/audio-only offerings) stay in the parsed
109    /// catalog but are excluded from route resolution lists.
110    #[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        image_input: image_input_support(model.modalities.as_ref()),
143        reasoning: CapabilityState::from_optional_bool(model.reasoning),
144        native_tool_calls: CapabilityState::from_optional_bool(model.tool_call),
145        structured_output: CapabilityState::from_optional_bool(model.structured_output),
146        server_side_web_search: crate::route::documented_server_side_web_search(
147            provider_id,
148            &model.id,
149        ),
150        ..RouteCapabilities::default()
151    }
152}
153
154/// Resolve the exact image-input fact from a provider-owned modality block.
155/// Missing or empty input metadata remains unknown; stated text-only input is
156/// unsupported rather than silently treated as unknown.
157#[must_use]
158pub fn image_input_support(modalities: Option<&ModelsDevModalities>) -> CapabilityState {
159    let Some(modalities) = modalities else {
160        return CapabilityState::Unknown;
161    };
162    if modalities.input.is_empty() {
163        return CapabilityState::Unknown;
164    }
165    CapabilityState::from_optional_bool(Some(
166        modalities
167            .input
168            .iter()
169            .any(|modality| modality.trim().eq_ignore_ascii_case("image")),
170    ))
171}
172
173/// Provider-agnostic model facts from `models.json` / `catalog.models`.
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
175pub struct ModelsDevModel {
176    /// Canonical Models.dev model id, such as `zhipuai/glm-5.2`.
177    #[serde(default)]
178    pub id: String,
179    /// Human-friendly model name.
180    #[serde(default)]
181    pub name: Option<String>,
182    /// Model family, such as `glm`, `gpt`, or `claude`.
183    #[serde(default)]
184    pub family: Option<String>,
185    /// Whether attachments are accepted.
186    #[serde(default)]
187    pub attachment: Option<bool>,
188    /// Whether the model supports reasoning.
189    #[serde(default)]
190    pub reasoning: Option<bool>,
191    /// Whether tool calling is supported.
192    #[serde(default)]
193    pub tool_call: Option<bool>,
194    /// Whether structured output is supported.
195    #[serde(default)]
196    pub structured_output: Option<bool>,
197    /// Whether temperature is supported.
198    #[serde(default)]
199    pub temperature: Option<bool>,
200    /// Whether weights are open.
201    #[serde(default)]
202    pub open_weights: Option<bool>,
203    /// Token limits.
204    #[serde(default)]
205    pub limit: Option<ModelsDevLimit>,
206    /// Input/output modalities.
207    #[serde(default)]
208    pub modalities: Option<ModelsDevModalities>,
209}
210
211impl ModelsDevModel {
212    /// True when the model can be used for normal text chat.
213    #[must_use]
214    pub fn supports_text_chat(&self) -> bool {
215        supports_text_chat(self.modalities.as_ref())
216    }
217}
218
219/// Provider-scoped model row from `api.json` / `catalog.providers.*.models`.
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
221pub struct ModelsDevProviderModel {
222    /// Provider wire model id.
223    #[serde(default)]
224    pub id: String,
225    /// Optional explicit canonical model link from source TOML.
226    #[serde(default)]
227    pub base_model: Option<String>,
228    /// Human-friendly model name.
229    #[serde(default)]
230    pub name: Option<String>,
231    /// Model family as exposed for this provider row.
232    #[serde(default)]
233    pub family: Option<String>,
234    /// Whether this is the provider's default model in a CodeWhale snapshot.
235    #[serde(default, alias = "default")]
236    pub default_for_provider: bool,
237    /// Whether attachments are accepted.
238    #[serde(default)]
239    pub attachment: Option<bool>,
240    /// Whether the model supports reasoning.
241    #[serde(default)]
242    pub reasoning: Option<bool>,
243    /// Flexible reasoning-control metadata.
244    #[serde(default)]
245    pub reasoning_options: Vec<serde_json::Value>,
246    /// Whether tool calling is supported.
247    #[serde(default)]
248    pub tool_call: Option<bool>,
249    /// Whether structured output is supported.
250    #[serde(default)]
251    pub structured_output: Option<bool>,
252    /// Whether temperature is supported.
253    #[serde(default)]
254    pub temperature: Option<bool>,
255    /// Whether weights are open through this offering.
256    #[serde(default)]
257    pub open_weights: Option<bool>,
258    /// Token limits for this provider offering.
259    #[serde(default)]
260    pub limit: Option<ModelsDevLimit>,
261    /// Input/output modalities for this provider offering.
262    #[serde(default)]
263    pub modalities: Option<ModelsDevModalities>,
264    /// Provider-scoped pricing.
265    #[serde(default)]
266    pub cost: Option<ModelsDevCost>,
267    /// Interleaved reasoning field hints.
268    #[serde(default)]
269    pub interleaved: Option<ModelsDevInterleaved>,
270}
271
272impl ModelsDevProviderModel {
273    /// True when the provider offering can be used for normal text chat.
274    #[must_use]
275    pub fn supports_text_chat(&self) -> bool {
276        supports_text_chat(self.modalities.as_ref())
277    }
278}
279
280/// Provider row from Models.dev.
281#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
282pub struct ModelsDevProvider {
283    /// Provider id, such as `zai`, `zhipuai`, or `openrouter`.
284    #[serde(default)]
285    pub id: String,
286    /// Human-friendly provider name.
287    #[serde(default)]
288    pub name: Option<String>,
289    /// Default API base URL, if published.
290    #[serde(default)]
291    pub api: Option<String>,
292    /// AI SDK package identifier, useful as a protocol hint.
293    #[serde(default)]
294    pub npm: Option<String>,
295    /// Documentation URL, if published.
296    #[serde(default)]
297    pub doc: Option<String>,
298    /// Environment variable names for credentials.
299    #[serde(default)]
300    pub env: Vec<String>,
301    /// Provider-scoped wire model rows.
302    #[serde(default)]
303    pub models: BTreeMap<String, ModelsDevProviderModel>,
304}
305
306impl ModelsDevProvider {
307    /// Resolve the effective provider id for this row.
308    ///
309    /// Models.dev snapshots usually repeat the catalog key in the `id` field,
310    /// but generated JSON can omit it. Fall back to the catalog key so callers
311    /// never emit an empty [`ProviderId`].
312    #[must_use]
313    fn effective_id(&self, provider_key: &str) -> String {
314        if self.id.trim().is_empty() {
315            provider_key.to_string()
316        } else {
317            self.id.trim().to_string()
318        }
319    }
320}
321
322/// Token limits.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
324pub struct ModelsDevLimit {
325    #[serde(default)]
326    pub context: Option<u64>,
327    #[serde(default)]
328    pub input: Option<u64>,
329    #[serde(default)]
330    pub output: Option<u64>,
331}
332
333impl From<&ModelsDevLimit> for RouteLimits {
334    fn from(limit: &ModelsDevLimit) -> Self {
335        Self {
336            context_tokens: limit.context,
337            input_tokens: limit.input,
338            output_tokens: limit.output,
339        }
340    }
341}
342
343/// Input/output modalities.
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
345pub struct ModelsDevModalities {
346    #[serde(default)]
347    pub input: Vec<String>,
348    #[serde(default)]
349    pub output: Vec<String>,
350}
351
352/// Provider-scoped cost fields. Values are per million tokens unless a future
353/// Models.dev row specifies a richer tiering object in fields CodeWhale does
354/// not yet model.
355#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
356pub struct ModelsDevCost {
357    #[serde(default)]
358    pub input: Option<f64>,
359    #[serde(default)]
360    pub output: Option<f64>,
361    #[serde(default)]
362    pub cache_read: Option<f64>,
363    #[serde(default)]
364    pub cache_write: Option<f64>,
365}
366
367/// Interleaved reasoning metadata from a Models.dev provider row.
368///
369/// Live Models.dev uses two shapes for this field, verified against
370/// `https://models.dev/catalog.json` on 2026-07-07:
371///
372/// - a bare boolean (`interleaved: true`) on ~32 provider rows, signalling the
373///   provider supports interleaved reasoning without naming a wire field, and
374/// - an object (`interleaved: { "field": "reasoning_content" }`) on the
375///   majority of rows, naming the wire field that carries reasoning deltas.
376///
377/// Modeling only the object shape made `serde_json::from_str::<ModelsDevCatalog>`
378/// reject every boolean row before the live catalog could be used at all
379/// (#4185). This untagged enum accepts both shapes while preserving the `field`
380/// hint whenever the object form supplies one.
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382#[serde(untagged)]
383pub enum ModelsDevInterleaved {
384    /// Boolean form: `interleaved: true` / `interleaved: false`.
385    Enabled(bool),
386    /// Object form: `interleaved: { "field": "reasoning_content" }`.
387    ///
388    /// `field` stays optional so an empty or partial object still parses, and
389    /// unknown sibling keys are ignored rather than rejected.
390    Field {
391        #[serde(default)]
392        field: Option<String>,
393    },
394}
395
396impl ModelsDevInterleaved {
397    /// Whether interleaved reasoning is enabled for this row.
398    ///
399    /// The boolean form reports its literal value. The object form is treated as
400    /// enabled because upstream only emits the object (naming a wire field) for
401    /// interleaved-capable rows.
402    #[must_use]
403    pub fn is_enabled(&self) -> bool {
404        match self {
405            Self::Enabled(enabled) => *enabled,
406            Self::Field { .. } => true,
407        }
408    }
409
410    /// The provider wire field carrying reasoning deltas, when upstream names
411    /// one.
412    ///
413    /// Only the object form supplies this; the boolean form returns `None`.
414    #[must_use]
415    pub fn field(&self) -> Option<&str> {
416        match self {
417            Self::Enabled(_) => None,
418            Self::Field { field } => field.as_deref(),
419        }
420    }
421}
422
423fn supports_text_chat(modalities: Option<&ModelsDevModalities>) -> bool {
424    let Some(modalities) = modalities else {
425        return true;
426    };
427    // Treat an empty modality list the same as absent metadata. An incomplete
428    // catalog snapshot can deserialize to `Some({ input: [], output: [] })`,
429    // and `Iterator::any` over an empty slice is `false` — without this guard
430    // such rows would be silently dropped from chat offerings even though the
431    // `None` branch above defaults them to chat-capable. Only an explicitly
432    // populated, non-text list excludes the row.
433    let input_ok = modalities.input.is_empty()
434        || modalities
435            .input
436            .iter()
437            .any(|modality| modality.eq_ignore_ascii_case("text"));
438    let output_ok = modalities.output.is_empty()
439        || modalities
440            .output
441            .iter()
442            .any(|modality| modality.eq_ignore_ascii_case("text"));
443    input_ok && output_ok
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    const GLM_FIXTURE: &str = r#"{
451      "models": {
452        "zhipuai/glm-5.2": {
453          "id": "zhipuai/glm-5.2",
454          "name": "GLM-5.2",
455          "family": "glm",
456          "reasoning": true,
457          "tool_call": true,
458          "structured_output": true,
459          "modalities": { "input": ["text"], "output": ["text"] },
460          "limit": { "context": 1000000, "output": 131072 },
461          "open_weights": true
462        }
463      },
464      "providers": {
465        "zhipuai": {
466          "id": "zhipuai",
467          "name": "Zhipu AI",
468          "api": "https://open.bigmodel.cn/api/paas/v4",
469          "npm": "@ai-sdk/openai-compatible",
470          "env": ["ZHIPU_API_KEY"],
471          "models": {
472            "glm-5.2": {
473              "id": "glm-5.2",
474              "name": "GLM-5.2",
475              "family": "glm",
476              "reasoning": true,
477              "reasoning_options": [{ "type": "effort", "values": ["high", "max"] }],
478              "tool_call": true,
479              "structured_output": true,
480              "modalities": { "input": ["text"], "output": ["text"] },
481              "limit": { "context": 1000000, "output": 131072 },
482              "cost": { "input": 1.4, "output": 4.4, "cache_read": 0.26 }
483            }
484          }
485        },
486        "zai": {
487          "id": "zai",
488          "name": "Z.AI",
489          "api": "https://api.z.ai/api/paas/v4",
490          "npm": "@ai-sdk/openai-compatible",
491          "env": ["ZHIPU_API_KEY"],
492          "models": {
493            "glm-5.2": {
494              "id": "glm-5.2",
495              "family": "glm",
496              "reasoning": true,
497              "tool_call": true,
498              "modalities": { "input": ["text"], "output": ["text"] },
499              "cost": { "input": 1.4, "output": 4.4 }
500            }
501          }
502        }
503      }
504    }"#;
505
506    #[test]
507    fn parses_models_dev_catalog_layers_without_joining_by_prefix() {
508        let catalog = ModelsDevCatalog::parse_json(GLM_FIXTURE).expect("fixture parses");
509
510        let canonical = catalog.model("zhipuai/glm-5.2").expect("canonical model");
511        assert_eq!(canonical.family.as_deref(), Some("glm"));
512        assert_eq!(
513            canonical.limit.as_ref().and_then(|limit| limit.context),
514            Some(1_000_000)
515        );
516        assert!(canonical.supports_text_chat());
517
518        let provider = catalog.provider("zhipuai").expect("provider");
519        assert_eq!(
520            provider.api.as_deref(),
521            Some("https://open.bigmodel.cn/api/paas/v4")
522        );
523        assert_eq!(provider.npm.as_deref(), Some("@ai-sdk/openai-compatible"));
524        assert_eq!(provider.env, ["ZHIPU_API_KEY"]);
525
526        let offering = catalog
527            .provider_model("zhipuai", "glm-5.2")
528            .expect("provider model");
529        assert_eq!(offering.id, "glm-5.2");
530        assert_eq!(offering.reasoning, Some(true));
531        assert_eq!(
532            offering.cost.as_ref().and_then(|cost| cost.cache_read),
533            Some(0.26)
534        );
535        assert!(offering.supports_text_chat());
536        assert_eq!(
537            offering.base_model, None,
538            "generated JSON does not prove a canonical join"
539        );
540
541        let route_offering = catalog
542            .provider_offering("zhipuai", "glm-5.2")
543            .expect("route offering");
544        assert_eq!(route_offering.limits.context_tokens, Some(1_000_000));
545        assert_eq!(route_offering.limits.output_tokens, Some(131_072));
546        assert_eq!(
547            route_offering.capabilities.reasoning,
548            CapabilityState::Supported
549        );
550        assert_eq!(
551            route_offering.capabilities.native_tool_calls,
552            CapabilityState::Supported
553        );
554        assert_eq!(
555            route_offering.capabilities.structured_output,
556            CapabilityState::Supported
557        );
558        assert_eq!(
559            route_offering.capabilities.streaming,
560            CapabilityState::Unknown
561        );
562    }
563
564    #[test]
565    fn provider_offering_preserves_wire_id_without_inferred_canonical_model() {
566        let catalog = ModelsDevCatalog::parse_json(GLM_FIXTURE).expect("fixture parses");
567        let offering = catalog
568            .provider_offering("zai", "glm-5.2")
569            .expect("offering");
570
571        assert_eq!(offering.provider.as_str(), "zai");
572        assert_eq!(offering.wire_model_id.as_str(), "glm-5.2");
573        assert_eq!(offering.canonical_model, None);
574        assert_eq!(offering.endpoint_key, "chat");
575    }
576
577    #[test]
578    fn provider_offering_uses_explicit_base_model_when_present() {
579        let raw = r#"{
580          "providers": {
581            "openrouter": {
582              "id": "openrouter",
583              "models": {
584                "z-ai/glm-5.2": {
585                  "id": "z-ai/glm-5.2",
586                  "base_model": "zhipuai/glm-5.2"
587                }
588              }
589            }
590          }
591        }"#;
592        let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
593        let offering = catalog
594            .provider_offering("openrouter", "z-ai/glm-5.2")
595            .expect("offering");
596
597        assert_eq!(
598            offering.canonical_model.as_ref().map(ModelId::as_str),
599            Some("zhipuai/glm-5.2")
600        );
601        assert_eq!(offering.wire_model_id.as_str(), "z-ai/glm-5.2");
602    }
603
604    #[test]
605    fn provider_offerings_emit_chat_rows_and_skip_non_text_outputs() {
606        let raw = r#"{
607          "providers": {
608            "zai": {
609              "models": {
610                "glm-5.2": {
611                  "id": "glm-5.2",
612                  "base_model": "zhipuai/glm-5.2",
613                  "default": true,
614                  "modalities": { "input": ["text"], "output": ["text"] }
615                },
616                "glm-voice": {
617                  "id": "glm-voice",
618                  "modalities": { "input": ["text"], "output": ["audio"] }
619                }
620              }
621            }
622          }
623        }"#;
624        let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
625        let offerings = catalog
626            .provider_offerings("zai")
627            .expect("provider offerings");
628
629        assert_eq!(offerings.len(), 1);
630        assert_eq!(offerings[0].provider.as_str(), "zai");
631        assert_eq!(offerings[0].wire_model_id.as_str(), "glm-5.2");
632        assert_eq!(
633            offerings[0].canonical_model.as_ref().map(ModelId::as_str),
634            Some("zhipuai/glm-5.2")
635        );
636        assert!(offerings[0].default_for_provider);
637    }
638
639    #[test]
640    fn non_text_output_is_not_a_chat_model() {
641        let model = ModelsDevProviderModel {
642            id: "mimo-v2.5-tts".to_string(),
643            modalities: Some(ModelsDevModalities {
644                input: vec!["text".to_string()],
645                output: vec!["audio".to_string()],
646            }),
647            ..Default::default()
648        };
649
650        assert!(!model.supports_text_chat());
651    }
652
653    #[test]
654    fn empty_modalities_struct_is_chat_capable() {
655        // `"modalities": {}` deserializes to Some(empty); it must default to
656        // chat-capable just like absent modality metadata (the None branch),
657        // otherwise rows from incomplete snapshots are silently dropped.
658        let provider_model = ModelsDevProviderModel {
659            modalities: Some(ModelsDevModalities::default()),
660            ..Default::default()
661        };
662        assert!(provider_model.supports_text_chat());
663
664        let canonical = ModelsDevModel {
665            modalities: Some(ModelsDevModalities::default()),
666            ..Default::default()
667        };
668        assert!(canonical.supports_text_chat());
669
670        // A list populated with only non-text entries still excludes the row.
671        let audio_only = ModelsDevProviderModel {
672            modalities: Some(ModelsDevModalities {
673                input: vec!["text".to_string()],
674                output: vec!["audio".to_string()],
675            }),
676            ..Default::default()
677        };
678        assert!(!audio_only.supports_text_chat());
679    }
680
681    #[test]
682    fn image_input_support_preserves_unknown_and_text_only_facts() {
683        assert_eq!(image_input_support(None), CapabilityState::Unknown);
684        assert_eq!(
685            image_input_support(Some(&ModelsDevModalities::default())),
686            CapabilityState::Unknown
687        );
688        assert_eq!(
689            image_input_support(Some(&ModelsDevModalities {
690                input: vec!["text".to_string()],
691                output: vec!["text".to_string()],
692            })),
693            CapabilityState::Unsupported
694        );
695        assert_eq!(
696            image_input_support(Some(&ModelsDevModalities {
697                input: vec!["text".to_string(), "image".to_string()],
698                output: vec!["text".to_string()],
699            })),
700            CapabilityState::Supported
701        );
702    }
703
704    #[test]
705    fn interleaved_boolean_true_parses_and_reports_enabled() {
706        // 32 live provider rows (e.g. `vercel`, `amazon-bedrock`) send
707        // `interleaved: true`; the object-only model rejected all of them.
708        let raw = r#"{
709          "providers": {
710            "vercel": {
711              "models": {
712                "zai/glm-4.7": { "id": "zai/glm-4.7", "interleaved": true }
713              }
714            }
715          }
716        }"#;
717        let catalog = ModelsDevCatalog::parse_json(raw).expect("boolean interleaved parses");
718        let model = catalog
719            .provider_model("vercel", "zai/glm-4.7")
720            .expect("provider model");
721        let interleaved = model.interleaved.as_ref().expect("interleaved present");
722        assert_eq!(interleaved, &ModelsDevInterleaved::Enabled(true));
723        assert!(interleaved.is_enabled());
724        assert_eq!(interleaved.field(), None);
725    }
726
727    #[test]
728    fn interleaved_boolean_false_parses_and_reports_disabled() {
729        let raw = r#"{
730          "providers": {
731            "custom": {
732              "models": {
733                "house-model": { "id": "house-model", "interleaved": false }
734              }
735            }
736          }
737        }"#;
738        let catalog = ModelsDevCatalog::parse_json(raw).expect("boolean interleaved parses");
739        let model = catalog
740            .provider_model("custom", "house-model")
741            .expect("provider model");
742        let interleaved = model.interleaved.as_ref().expect("interleaved present");
743        assert_eq!(interleaved, &ModelsDevInterleaved::Enabled(false));
744        assert!(!interleaved.is_enabled());
745        assert_eq!(interleaved.field(), None);
746    }
747
748    #[test]
749    fn interleaved_object_form_preserves_field_metadata() {
750        // The majority of live rows use `{ "field": "reasoning_content" }`; the
751        // fix must keep parsing them and surface the named wire field.
752        let raw = r#"{
753          "providers": {
754            "alibaba-cn": {
755              "models": {
756                "glm-5.2": {
757                  "id": "glm-5.2",
758                  "interleaved": { "field": "reasoning_content" }
759                }
760              }
761            }
762          }
763        }"#;
764        let catalog = ModelsDevCatalog::parse_json(raw).expect("object interleaved parses");
765        let model = catalog
766            .provider_model("alibaba-cn", "glm-5.2")
767            .expect("provider model");
768        let interleaved = model.interleaved.as_ref().expect("interleaved present");
769        assert_eq!(interleaved.field(), Some("reasoning_content"));
770        assert!(interleaved.is_enabled());
771    }
772
773    #[test]
774    fn interleaved_object_tolerates_empty_and_unknown_keys() {
775        // An empty object and an object with only unmodeled sibling keys must
776        // still parse (object form, no named field) rather than erroring.
777        let raw = r#"{
778          "providers": {
779            "custom": {
780              "models": {
781                "empty-obj": { "id": "empty-obj", "interleaved": {} },
782                "future-obj": {
783                  "id": "future-obj",
784                  "interleaved": { "future_hint": "x" }
785                }
786              }
787            }
788          }
789        }"#;
790        let catalog = ModelsDevCatalog::parse_json(raw).expect("tolerant interleaved parses");
791
792        let empty = catalog
793            .provider_model("custom", "empty-obj")
794            .and_then(|m| m.interleaved.clone())
795            .expect("empty object interleaved present");
796        assert_eq!(empty, ModelsDevInterleaved::Field { field: None });
797        assert_eq!(empty.field(), None);
798        assert!(empty.is_enabled());
799
800        let future = catalog
801            .provider_model("custom", "future-obj")
802            .and_then(|m| m.interleaved.clone())
803            .expect("future object interleaved present");
804        assert_eq!(future.field(), None);
805    }
806
807    #[test]
808    fn live_ish_mixed_interleaved_sample_deserializes() {
809        // A representative slice of live `catalog.json`: boolean and object
810        // interleaved rows side by side, plus an unmodeled top-level provider
811        // key (`doc`) and an unmodeled model key to prove unknown upstream
812        // fields are ignored safely. This is the acceptance "live-ish sample".
813        let raw = r#"{
814          "providers": {
815            "amazon-bedrock": {
816              "id": "amazon-bedrock",
817              "doc": "https://docs.aws.amazon.com/bedrock/",
818              "models": {
819                "anthropic.claude-opus": {
820                  "id": "anthropic.claude-opus",
821                  "reasoning": true,
822                  "interleaved": true,
823                  "some_future_flag": 7,
824                  "modalities": { "input": ["text"], "output": ["text"] }
825                }
826              }
827            },
828            "alibaba-cn": {
829              "id": "alibaba-cn",
830              "models": {
831                "deepseek-v4-flash": {
832                  "id": "deepseek-v4-flash",
833                  "interleaved": { "field": "reasoning_content" },
834                  "modalities": { "input": ["text"], "output": ["text"] }
835                }
836              }
837            }
838          }
839        }"#;
840        let catalog = ModelsDevCatalog::parse_json(raw).expect("live-ish sample parses");
841
842        let bedrock = catalog
843            .provider_model("amazon-bedrock", "anthropic.claude-opus")
844            .expect("bedrock row");
845        assert_eq!(
846            bedrock.interleaved,
847            Some(ModelsDevInterleaved::Enabled(true))
848        );
849
850        let alibaba = catalog
851            .provider_model("alibaba-cn", "deepseek-v4-flash")
852            .expect("alibaba row");
853        assert_eq!(
854            alibaba.interleaved.as_ref().and_then(|i| i.field()),
855            Some("reasoning_content")
856        );
857
858        // Both rows still resolve as chat offerings; interleaved does not
859        // interfere with route resolution.
860        assert_eq!(
861            catalog
862                .provider_offerings("amazon-bedrock")
863                .map(|rows| rows.len()),
864            Some(1)
865        );
866    }
867
868    #[test]
869    fn provider_offerings_keep_rows_with_empty_modalities_object() {
870        // End-to-end guard for the empty-modalities case at the offering layer:
871        // a custom/local provider row with `"modalities": {}` must still emit a
872        // chat offering rather than being filtered out of route resolution.
873        let raw = r#"{
874          "providers": {
875            "custom": {
876              "models": {
877                "house-model": { "id": "house-model", "modalities": {} }
878              }
879            }
880          }
881        }"#;
882        let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
883        let offerings = catalog
884            .provider_offerings("custom")
885            .expect("provider offerings");
886
887        assert_eq!(offerings.len(), 1);
888        assert_eq!(offerings[0].wire_model_id.as_str(), "house-model");
889        // `id` was omitted on the provider row → effective id is the catalog key.
890        assert_eq!(offerings[0].provider.as_str(), "custom");
891    }
892}