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 metadata from a Models.dev provider row.
329///
330/// Live Models.dev uses two shapes for this field, verified against
331/// `https://models.dev/catalog.json` on 2026-07-07:
332///
333/// - a bare boolean (`interleaved: true`) on ~32 provider rows, signalling the
334///   provider supports interleaved reasoning without naming a wire field, and
335/// - an object (`interleaved: { "field": "reasoning_content" }`) on the
336///   majority of rows, naming the wire field that carries reasoning deltas.
337///
338/// Modeling only the object shape made `serde_json::from_str::<ModelsDevCatalog>`
339/// reject every boolean row before the live catalog could be used at all
340/// (#4185). This untagged enum accepts both shapes while preserving the `field`
341/// hint whenever the object form supplies one.
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343#[serde(untagged)]
344pub enum ModelsDevInterleaved {
345    /// Boolean form: `interleaved: true` / `interleaved: false`.
346    Enabled(bool),
347    /// Object form: `interleaved: { "field": "reasoning_content" }`.
348    ///
349    /// `field` stays optional so an empty or partial object still parses, and
350    /// unknown sibling keys are ignored rather than rejected.
351    Field {
352        #[serde(default)]
353        field: Option<String>,
354    },
355}
356
357impl ModelsDevInterleaved {
358    /// Whether interleaved reasoning is enabled for this row.
359    ///
360    /// The boolean form reports its literal value. The object form is treated as
361    /// enabled because upstream only emits the object (naming a wire field) for
362    /// interleaved-capable rows.
363    #[must_use]
364    pub fn is_enabled(&self) -> bool {
365        match self {
366            Self::Enabled(enabled) => *enabled,
367            Self::Field { .. } => true,
368        }
369    }
370
371    /// The provider wire field carrying reasoning deltas, when upstream names
372    /// one.
373    ///
374    /// Only the object form supplies this; the boolean form returns `None`.
375    #[must_use]
376    pub fn field(&self) -> Option<&str> {
377        match self {
378            Self::Enabled(_) => None,
379            Self::Field { field } => field.as_deref(),
380        }
381    }
382}
383
384fn supports_text_chat(modalities: Option<&ModelsDevModalities>) -> bool {
385    let Some(modalities) = modalities else {
386        return true;
387    };
388    // Treat an empty modality list the same as absent metadata. An incomplete
389    // catalog snapshot can deserialize to `Some({ input: [], output: [] })`,
390    // and `Iterator::any` over an empty slice is `false` — without this guard
391    // such rows would be silently dropped from chat offerings even though the
392    // `None` branch above defaults them to chat-capable. Only an explicitly
393    // populated, non-text list excludes the row.
394    let input_ok = modalities.input.is_empty()
395        || modalities
396            .input
397            .iter()
398            .any(|modality| modality.eq_ignore_ascii_case("text"));
399    let output_ok = modalities.output.is_empty()
400        || modalities
401            .output
402            .iter()
403            .any(|modality| modality.eq_ignore_ascii_case("text"));
404    input_ok && output_ok
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    const GLM_FIXTURE: &str = r#"{
412      "models": {
413        "zhipuai/glm-5.2": {
414          "id": "zhipuai/glm-5.2",
415          "name": "GLM-5.2",
416          "family": "glm",
417          "reasoning": true,
418          "tool_call": true,
419          "structured_output": true,
420          "modalities": { "input": ["text"], "output": ["text"] },
421          "limit": { "context": 1000000, "output": 131072 },
422          "open_weights": true
423        }
424      },
425      "providers": {
426        "zhipuai": {
427          "id": "zhipuai",
428          "name": "Zhipu AI",
429          "api": "https://open.bigmodel.cn/api/paas/v4",
430          "npm": "@ai-sdk/openai-compatible",
431          "env": ["ZHIPU_API_KEY"],
432          "models": {
433            "glm-5.2": {
434              "id": "glm-5.2",
435              "name": "GLM-5.2",
436              "family": "glm",
437              "reasoning": true,
438              "reasoning_options": [{ "type": "effort", "values": ["high", "max"] }],
439              "tool_call": true,
440              "structured_output": true,
441              "modalities": { "input": ["text"], "output": ["text"] },
442              "limit": { "context": 1000000, "output": 131072 },
443              "cost": { "input": 1.4, "output": 4.4, "cache_read": 0.26 }
444            }
445          }
446        },
447        "zai": {
448          "id": "zai",
449          "name": "Z.AI",
450          "api": "https://api.z.ai/api/paas/v4",
451          "npm": "@ai-sdk/openai-compatible",
452          "env": ["ZHIPU_API_KEY"],
453          "models": {
454            "glm-5.2": {
455              "id": "glm-5.2",
456              "family": "glm",
457              "reasoning": true,
458              "tool_call": true,
459              "modalities": { "input": ["text"], "output": ["text"] },
460              "cost": { "input": 1.4, "output": 4.4 }
461            }
462          }
463        }
464      }
465    }"#;
466
467    #[test]
468    fn parses_models_dev_catalog_layers_without_joining_by_prefix() {
469        let catalog = ModelsDevCatalog::parse_json(GLM_FIXTURE).expect("fixture parses");
470
471        let canonical = catalog.model("zhipuai/glm-5.2").expect("canonical model");
472        assert_eq!(canonical.family.as_deref(), Some("glm"));
473        assert_eq!(
474            canonical.limit.as_ref().and_then(|limit| limit.context),
475            Some(1_000_000)
476        );
477        assert!(canonical.supports_text_chat());
478
479        let provider = catalog.provider("zhipuai").expect("provider");
480        assert_eq!(
481            provider.api.as_deref(),
482            Some("https://open.bigmodel.cn/api/paas/v4")
483        );
484        assert_eq!(provider.npm.as_deref(), Some("@ai-sdk/openai-compatible"));
485        assert_eq!(provider.env, ["ZHIPU_API_KEY"]);
486
487        let offering = catalog
488            .provider_model("zhipuai", "glm-5.2")
489            .expect("provider model");
490        assert_eq!(offering.id, "glm-5.2");
491        assert_eq!(offering.reasoning, Some(true));
492        assert_eq!(
493            offering.cost.as_ref().and_then(|cost| cost.cache_read),
494            Some(0.26)
495        );
496        assert!(offering.supports_text_chat());
497        assert_eq!(
498            offering.base_model, None,
499            "generated JSON does not prove a canonical join"
500        );
501
502        let route_offering = catalog
503            .provider_offering("zhipuai", "glm-5.2")
504            .expect("route offering");
505        assert_eq!(route_offering.limits.context_tokens, Some(1_000_000));
506        assert_eq!(route_offering.limits.output_tokens, Some(131_072));
507    }
508
509    #[test]
510    fn provider_offering_preserves_wire_id_without_inferred_canonical_model() {
511        let catalog = ModelsDevCatalog::parse_json(GLM_FIXTURE).expect("fixture parses");
512        let offering = catalog
513            .provider_offering("zai", "glm-5.2")
514            .expect("offering");
515
516        assert_eq!(offering.provider.as_str(), "zai");
517        assert_eq!(offering.wire_model_id.as_str(), "glm-5.2");
518        assert_eq!(offering.canonical_model, None);
519        assert_eq!(offering.endpoint_key, "chat");
520    }
521
522    #[test]
523    fn provider_offering_uses_explicit_base_model_when_present() {
524        let raw = r#"{
525          "providers": {
526            "openrouter": {
527              "id": "openrouter",
528              "models": {
529                "z-ai/glm-5.2": {
530                  "id": "z-ai/glm-5.2",
531                  "base_model": "zhipuai/glm-5.2"
532                }
533              }
534            }
535          }
536        }"#;
537        let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
538        let offering = catalog
539            .provider_offering("openrouter", "z-ai/glm-5.2")
540            .expect("offering");
541
542        assert_eq!(
543            offering.canonical_model.as_ref().map(ModelId::as_str),
544            Some("zhipuai/glm-5.2")
545        );
546        assert_eq!(offering.wire_model_id.as_str(), "z-ai/glm-5.2");
547    }
548
549    #[test]
550    fn provider_offerings_emit_chat_rows_and_skip_non_text_outputs() {
551        let raw = r#"{
552          "providers": {
553            "zai": {
554              "models": {
555                "glm-5.2": {
556                  "id": "glm-5.2",
557                  "base_model": "zhipuai/glm-5.2",
558                  "default": true,
559                  "modalities": { "input": ["text"], "output": ["text"] }
560                },
561                "glm-voice": {
562                  "id": "glm-voice",
563                  "modalities": { "input": ["text"], "output": ["audio"] }
564                }
565              }
566            }
567          }
568        }"#;
569        let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
570        let offerings = catalog
571            .provider_offerings("zai")
572            .expect("provider offerings");
573
574        assert_eq!(offerings.len(), 1);
575        assert_eq!(offerings[0].provider.as_str(), "zai");
576        assert_eq!(offerings[0].wire_model_id.as_str(), "glm-5.2");
577        assert_eq!(
578            offerings[0].canonical_model.as_ref().map(ModelId::as_str),
579            Some("zhipuai/glm-5.2")
580        );
581        assert!(offerings[0].default_for_provider);
582    }
583
584    #[test]
585    fn non_text_output_is_not_a_chat_model() {
586        let model = ModelsDevProviderModel {
587            id: "mimo-v2.5-tts".to_string(),
588            modalities: Some(ModelsDevModalities {
589                input: vec!["text".to_string()],
590                output: vec!["audio".to_string()],
591            }),
592            ..Default::default()
593        };
594
595        assert!(!model.supports_text_chat());
596    }
597
598    #[test]
599    fn empty_modalities_struct_is_chat_capable() {
600        // `"modalities": {}` deserializes to Some(empty); it must default to
601        // chat-capable just like absent modality metadata (the None branch),
602        // otherwise rows from incomplete snapshots are silently dropped.
603        let provider_model = ModelsDevProviderModel {
604            modalities: Some(ModelsDevModalities::default()),
605            ..Default::default()
606        };
607        assert!(provider_model.supports_text_chat());
608
609        let canonical = ModelsDevModel {
610            modalities: Some(ModelsDevModalities::default()),
611            ..Default::default()
612        };
613        assert!(canonical.supports_text_chat());
614
615        // A list populated with only non-text entries still excludes the row.
616        let audio_only = ModelsDevProviderModel {
617            modalities: Some(ModelsDevModalities {
618                input: vec!["text".to_string()],
619                output: vec!["audio".to_string()],
620            }),
621            ..Default::default()
622        };
623        assert!(!audio_only.supports_text_chat());
624    }
625
626    #[test]
627    fn interleaved_boolean_true_parses_and_reports_enabled() {
628        // 32 live provider rows (e.g. `vercel`, `amazon-bedrock`) send
629        // `interleaved: true`; the object-only model rejected all of them.
630        let raw = r#"{
631          "providers": {
632            "vercel": {
633              "models": {
634                "zai/glm-4.7": { "id": "zai/glm-4.7", "interleaved": true }
635              }
636            }
637          }
638        }"#;
639        let catalog = ModelsDevCatalog::parse_json(raw).expect("boolean interleaved parses");
640        let model = catalog
641            .provider_model("vercel", "zai/glm-4.7")
642            .expect("provider model");
643        let interleaved = model.interleaved.as_ref().expect("interleaved present");
644        assert_eq!(interleaved, &ModelsDevInterleaved::Enabled(true));
645        assert!(interleaved.is_enabled());
646        assert_eq!(interleaved.field(), None);
647    }
648
649    #[test]
650    fn interleaved_boolean_false_parses_and_reports_disabled() {
651        let raw = r#"{
652          "providers": {
653            "custom": {
654              "models": {
655                "house-model": { "id": "house-model", "interleaved": false }
656              }
657            }
658          }
659        }"#;
660        let catalog = ModelsDevCatalog::parse_json(raw).expect("boolean interleaved parses");
661        let model = catalog
662            .provider_model("custom", "house-model")
663            .expect("provider model");
664        let interleaved = model.interleaved.as_ref().expect("interleaved present");
665        assert_eq!(interleaved, &ModelsDevInterleaved::Enabled(false));
666        assert!(!interleaved.is_enabled());
667        assert_eq!(interleaved.field(), None);
668    }
669
670    #[test]
671    fn interleaved_object_form_preserves_field_metadata() {
672        // The majority of live rows use `{ "field": "reasoning_content" }`; the
673        // fix must keep parsing them and surface the named wire field.
674        let raw = r#"{
675          "providers": {
676            "alibaba-cn": {
677              "models": {
678                "glm-5.2": {
679                  "id": "glm-5.2",
680                  "interleaved": { "field": "reasoning_content" }
681                }
682              }
683            }
684          }
685        }"#;
686        let catalog = ModelsDevCatalog::parse_json(raw).expect("object interleaved parses");
687        let model = catalog
688            .provider_model("alibaba-cn", "glm-5.2")
689            .expect("provider model");
690        let interleaved = model.interleaved.as_ref().expect("interleaved present");
691        assert_eq!(interleaved.field(), Some("reasoning_content"));
692        assert!(interleaved.is_enabled());
693    }
694
695    #[test]
696    fn interleaved_object_tolerates_empty_and_unknown_keys() {
697        // An empty object and an object with only unmodeled sibling keys must
698        // still parse (object form, no named field) rather than erroring.
699        let raw = r#"{
700          "providers": {
701            "custom": {
702              "models": {
703                "empty-obj": { "id": "empty-obj", "interleaved": {} },
704                "future-obj": {
705                  "id": "future-obj",
706                  "interleaved": { "future_hint": "x" }
707                }
708              }
709            }
710          }
711        }"#;
712        let catalog = ModelsDevCatalog::parse_json(raw).expect("tolerant interleaved parses");
713
714        let empty = catalog
715            .provider_model("custom", "empty-obj")
716            .and_then(|m| m.interleaved.clone())
717            .expect("empty object interleaved present");
718        assert_eq!(empty, ModelsDevInterleaved::Field { field: None });
719        assert_eq!(empty.field(), None);
720        assert!(empty.is_enabled());
721
722        let future = catalog
723            .provider_model("custom", "future-obj")
724            .and_then(|m| m.interleaved.clone())
725            .expect("future object interleaved present");
726        assert_eq!(future.field(), None);
727    }
728
729    #[test]
730    fn live_ish_mixed_interleaved_sample_deserializes() {
731        // A representative slice of live `catalog.json`: boolean and object
732        // interleaved rows side by side, plus an unmodeled top-level provider
733        // key (`doc`) and an unmodeled model key to prove unknown upstream
734        // fields are ignored safely. This is the acceptance "live-ish sample".
735        let raw = r#"{
736          "providers": {
737            "amazon-bedrock": {
738              "id": "amazon-bedrock",
739              "doc": "https://docs.aws.amazon.com/bedrock/",
740              "models": {
741                "anthropic.claude-opus": {
742                  "id": "anthropic.claude-opus",
743                  "reasoning": true,
744                  "interleaved": true,
745                  "some_future_flag": 7,
746                  "modalities": { "input": ["text"], "output": ["text"] }
747                }
748              }
749            },
750            "alibaba-cn": {
751              "id": "alibaba-cn",
752              "models": {
753                "deepseek-v4-flash": {
754                  "id": "deepseek-v4-flash",
755                  "interleaved": { "field": "reasoning_content" },
756                  "modalities": { "input": ["text"], "output": ["text"] }
757                }
758              }
759            }
760          }
761        }"#;
762        let catalog = ModelsDevCatalog::parse_json(raw).expect("live-ish sample parses");
763
764        let bedrock = catalog
765            .provider_model("amazon-bedrock", "anthropic.claude-opus")
766            .expect("bedrock row");
767        assert_eq!(
768            bedrock.interleaved,
769            Some(ModelsDevInterleaved::Enabled(true))
770        );
771
772        let alibaba = catalog
773            .provider_model("alibaba-cn", "deepseek-v4-flash")
774            .expect("alibaba row");
775        assert_eq!(
776            alibaba.interleaved.as_ref().and_then(|i| i.field()),
777            Some("reasoning_content")
778        );
779
780        // Both rows still resolve as chat offerings; interleaved does not
781        // interfere with route resolution.
782        assert_eq!(
783            catalog
784                .provider_offerings("amazon-bedrock")
785                .map(|rows| rows.len()),
786            Some(1)
787        );
788    }
789
790    #[test]
791    fn provider_offerings_keep_rows_with_empty_modalities_object() {
792        // End-to-end guard for the empty-modalities case at the offering layer:
793        // a custom/local provider row with `"modalities": {}` must still emit a
794        // chat offering rather than being filtered out of route resolution.
795        let raw = r#"{
796          "providers": {
797            "custom": {
798              "models": {
799                "house-model": { "id": "house-model", "modalities": {} }
800              }
801            }
802          }
803        }"#;
804        let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
805        let offerings = catalog
806            .provider_offerings("custom")
807            .expect("provider offerings");
808
809        assert_eq!(offerings.len(), 1);
810        assert_eq!(offerings[0].wire_model_id.as_str(), "house-model");
811        // `id` was omitted on the provider row → effective id is the catalog key.
812        assert_eq!(offerings[0].provider.as_str(), "custom");
813    }
814}