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        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/// Provider-agnostic model facts from `models.json` / `catalog.models`.
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
155pub struct ModelsDevModel {
156    /// Canonical Models.dev model id, such as `zhipuai/glm-5.2`.
157    #[serde(default)]
158    pub id: String,
159    /// Human-friendly model name.
160    #[serde(default)]
161    pub name: Option<String>,
162    /// Model family, such as `glm`, `gpt`, or `claude`.
163    #[serde(default)]
164    pub family: Option<String>,
165    /// Whether attachments are accepted.
166    #[serde(default)]
167    pub attachment: Option<bool>,
168    /// Whether the model supports reasoning.
169    #[serde(default)]
170    pub reasoning: Option<bool>,
171    /// Whether tool calling is supported.
172    #[serde(default)]
173    pub tool_call: Option<bool>,
174    /// Whether structured output is supported.
175    #[serde(default)]
176    pub structured_output: Option<bool>,
177    /// Whether temperature is supported.
178    #[serde(default)]
179    pub temperature: Option<bool>,
180    /// Whether weights are open.
181    #[serde(default)]
182    pub open_weights: Option<bool>,
183    /// Token limits.
184    #[serde(default)]
185    pub limit: Option<ModelsDevLimit>,
186    /// Input/output modalities.
187    #[serde(default)]
188    pub modalities: Option<ModelsDevModalities>,
189}
190
191impl ModelsDevModel {
192    /// True when the model can be used for normal text chat.
193    #[must_use]
194    pub fn supports_text_chat(&self) -> bool {
195        supports_text_chat(self.modalities.as_ref())
196    }
197}
198
199/// Provider-scoped model row from `api.json` / `catalog.providers.*.models`.
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
201pub struct ModelsDevProviderModel {
202    /// Provider wire model id.
203    #[serde(default)]
204    pub id: String,
205    /// Optional explicit canonical model link from source TOML.
206    #[serde(default)]
207    pub base_model: Option<String>,
208    /// Human-friendly model name.
209    #[serde(default)]
210    pub name: Option<String>,
211    /// Model family as exposed for this provider row.
212    #[serde(default)]
213    pub family: Option<String>,
214    /// Whether this is the provider's default model in a CodeWhale snapshot.
215    #[serde(default, alias = "default")]
216    pub default_for_provider: bool,
217    /// Whether attachments are accepted.
218    #[serde(default)]
219    pub attachment: Option<bool>,
220    /// Whether the model supports reasoning.
221    #[serde(default)]
222    pub reasoning: Option<bool>,
223    /// Flexible reasoning-control metadata.
224    #[serde(default)]
225    pub reasoning_options: Vec<serde_json::Value>,
226    /// Whether tool calling is supported.
227    #[serde(default)]
228    pub tool_call: Option<bool>,
229    /// Whether structured output is supported.
230    #[serde(default)]
231    pub structured_output: Option<bool>,
232    /// Whether temperature is supported.
233    #[serde(default)]
234    pub temperature: Option<bool>,
235    /// Whether weights are open through this offering.
236    #[serde(default)]
237    pub open_weights: Option<bool>,
238    /// Token limits for this provider offering.
239    #[serde(default)]
240    pub limit: Option<ModelsDevLimit>,
241    /// Input/output modalities for this provider offering.
242    #[serde(default)]
243    pub modalities: Option<ModelsDevModalities>,
244    /// Provider-scoped pricing.
245    #[serde(default)]
246    pub cost: Option<ModelsDevCost>,
247    /// Interleaved reasoning field hints.
248    #[serde(default)]
249    pub interleaved: Option<ModelsDevInterleaved>,
250}
251
252impl ModelsDevProviderModel {
253    /// True when the provider offering can be used for normal text chat.
254    #[must_use]
255    pub fn supports_text_chat(&self) -> bool {
256        supports_text_chat(self.modalities.as_ref())
257    }
258}
259
260/// Provider row from Models.dev.
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
262pub struct ModelsDevProvider {
263    /// Provider id, such as `zai`, `zhipuai`, or `openrouter`.
264    #[serde(default)]
265    pub id: String,
266    /// Human-friendly provider name.
267    #[serde(default)]
268    pub name: Option<String>,
269    /// Default API base URL, if published.
270    #[serde(default)]
271    pub api: Option<String>,
272    /// AI SDK package identifier, useful as a protocol hint.
273    #[serde(default)]
274    pub npm: Option<String>,
275    /// Documentation URL, if published.
276    #[serde(default)]
277    pub doc: Option<String>,
278    /// Environment variable names for credentials.
279    #[serde(default)]
280    pub env: Vec<String>,
281    /// Provider-scoped wire model rows.
282    #[serde(default)]
283    pub models: BTreeMap<String, ModelsDevProviderModel>,
284}
285
286impl ModelsDevProvider {
287    /// Resolve the effective provider id for this row.
288    ///
289    /// Models.dev snapshots usually repeat the catalog key in the `id` field,
290    /// but generated JSON can omit it. Fall back to the catalog key so callers
291    /// never emit an empty [`ProviderId`].
292    #[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/// Token limits.
303#[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/// Input/output modalities.
324#[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/// Provider-scoped cost fields. Values are per million tokens unless a future
333/// Models.dev row specifies a richer tiering object in fields CodeWhale does
334/// not yet model.
335#[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/// Interleaved reasoning metadata from a Models.dev provider row.
348///
349/// Live Models.dev uses two shapes for this field, verified against
350/// `https://models.dev/catalog.json` on 2026-07-07:
351///
352/// - a bare boolean (`interleaved: true`) on ~32 provider rows, signalling the
353///   provider supports interleaved reasoning without naming a wire field, and
354/// - an object (`interleaved: { "field": "reasoning_content" }`) on the
355///   majority of rows, naming the wire field that carries reasoning deltas.
356///
357/// Modeling only the object shape made `serde_json::from_str::<ModelsDevCatalog>`
358/// reject every boolean row before the live catalog could be used at all
359/// (#4185). This untagged enum accepts both shapes while preserving the `field`
360/// hint whenever the object form supplies one.
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362#[serde(untagged)]
363pub enum ModelsDevInterleaved {
364    /// Boolean form: `interleaved: true` / `interleaved: false`.
365    Enabled(bool),
366    /// Object form: `interleaved: { "field": "reasoning_content" }`.
367    ///
368    /// `field` stays optional so an empty or partial object still parses, and
369    /// unknown sibling keys are ignored rather than rejected.
370    Field {
371        #[serde(default)]
372        field: Option<String>,
373    },
374}
375
376impl ModelsDevInterleaved {
377    /// Whether interleaved reasoning is enabled for this row.
378    ///
379    /// The boolean form reports its literal value. The object form is treated as
380    /// enabled because upstream only emits the object (naming a wire field) for
381    /// interleaved-capable rows.
382    #[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    /// The provider wire field carrying reasoning deltas, when upstream names
391    /// one.
392    ///
393    /// Only the object form supplies this; the boolean form returns `None`.
394    #[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    // Treat an empty modality list the same as absent metadata. An incomplete
408    // catalog snapshot can deserialize to `Some({ input: [], output: [] })`,
409    // and `Iterator::any` over an empty slice is `false` — without this guard
410    // such rows would be silently dropped from chat offerings even though the
411    // `None` branch above defaults them to chat-capable. Only an explicitly
412    // populated, non-text list excludes the row.
413    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        // `"modalities": {}` deserializes to Some(empty); it must default to
636        // chat-capable just like absent modality metadata (the None branch),
637        // otherwise rows from incomplete snapshots are silently dropped.
638        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        // A list populated with only non-text entries still excludes the row.
651        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        // 32 live provider rows (e.g. `vercel`, `amazon-bedrock`) send
664        // `interleaved: true`; the object-only model rejected all of them.
665        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        // The majority of live rows use `{ "field": "reasoning_content" }`; the
708        // fix must keep parsing them and surface the named wire field.
709        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        // An empty object and an object with only unmodeled sibling keys must
733        // still parse (object form, no named field) rather than erroring.
734        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        // A representative slice of live `catalog.json`: boolean and object
767        // interleaved rows side by side, plus an unmodeled top-level provider
768        // key (`doc`) and an unmodeled model key to prove unknown upstream
769        // fields are ignored safely. This is the acceptance "live-ish sample".
770        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        // Both rows still resolve as chat offerings; interleaved does not
816        // interfere with route resolution.
817        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        // End-to-end guard for the empty-modalities case at the offering layer:
828        // a custom/local provider row with `"modalities": {}` must still emit a
829        // chat offering rather than being filtered out of route resolution.
830        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        // `id` was omitted on the provider row → effective id is the catalog key.
847        assert_eq!(offerings[0].provider.as_str(), "custom");
848    }
849}