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