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