返回 CodeWhale
models_dev.rs
根目录 / crates / config / src / 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
16 use std::collections::BTreeMap;
17
18 use serde::{Deserialize, Serialize};
19
20 use crate::route::{
21 CapabilityState, ModelId, ProviderId, ProviderModelOffering, RouteCapabilities, RouteLimits,
22 WireModelId,
23 };
24
25 /// Provider catalog endpoint used by Models.dev.
26 pub const MODELS_DEV_API_URL: &str = "https://models.dev/api.json";
27 /// Provider-agnostic model metadata endpoint used by Models.dev.
28 pub const MODELS_DEV_MODELS_URL: &str = "https://models.dev/models.json";
29 /// Combined `{ models, providers }` endpoint used by Models.dev.
30 pub 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)]
34 pub 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
43 impl 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
139 fn 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]
158 pub 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)]
175 pub 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
211 impl 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)]
221 pub 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
272 impl 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)]
282 pub 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
306 impl 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)]
324 pub 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
333 impl 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)]
345 pub 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)]
356 pub 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)]
383 pub 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
396 impl 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
423 fn 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)]
447 mod 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 }
893
893 lines RUST