Skip to main content

vtcode_config/models/model_id/
capabilities.rs

1use crate::models::{Provider, ProviderModelSupport};
2
3use super::ModelId;
4
5#[allow(dead_code, reason = "Intentional compatibility, platform, or test-only suppression.")]
6mod capability_generated {
7    include!(concat!(env!("OUT_DIR"), "/model_capabilities.rs"));
8}
9
10/// Catalog metadata generated from `docs/models.json`.
11#[derive(Clone, Copy, Debug, PartialEq)]
12pub struct ModelPricing {
13    pub input: Option<f64>,
14    pub output: Option<f64>,
15    pub cache_read: Option<f64>,
16    pub cache_write: Option<f64>,
17}
18
19#[derive(Clone, Copy, Debug, PartialEq)]
20pub struct ModelCatalogEntry {
21    pub(crate) provider: &'static str,
22    id: &'static str,
23    pub display_name: &'static str,
24    pub description: &'static str,
25    pub context_window: usize,
26    max_output_tokens: Option<usize>,
27    pub reasoning: bool,
28    pub reasoning_efforts: &'static [&'static str],
29    pub is_pro: bool,
30    pub lightweight_model: Option<&'static str>,
31    pub tool_call: bool,
32    pub vision: bool,
33    pub input_modalities: &'static [&'static str],
34    pub caching: bool,
35    pub structured_output: bool,
36    pub supports_sampling: bool,
37    pub supports_logprobs: bool,
38    pub prompt_cache_ttl: Option<&'static str>,
39    pub prompt_contract: Option<&'static str>,
40    pub pricing: ModelPricing,
41}
42
43fn catalog_provider_key(provider: &str) -> &str {
44    if provider.eq_ignore_ascii_case("google") || provider.eq_ignore_ascii_case("gemini") {
45        "gemini"
46    } else if provider.eq_ignore_ascii_case("openai") {
47        "openai"
48    } else if provider.eq_ignore_ascii_case("anthropic") {
49        "anthropic"
50    } else if provider.eq_ignore_ascii_case("deepseek") {
51        "deepseek"
52    } else if provider.eq_ignore_ascii_case("meta") || provider.eq_ignore_ascii_case("meta-ai") {
53        "meta"
54    } else if provider.eq_ignore_ascii_case("openrouter") {
55        "openrouter"
56    } else if provider.eq_ignore_ascii_case("ollama") {
57        "ollama"
58    } else if provider.eq_ignore_ascii_case("lmstudio") {
59        "lmstudio"
60    } else if provider.eq_ignore_ascii_case("llamacpp") || provider.eq_ignore_ascii_case("llama.cpp") {
61        "llamacpp"
62    } else if provider.eq_ignore_ascii_case("moonshot") {
63        "moonshot"
64    } else if provider.eq_ignore_ascii_case("zai") {
65        "zai"
66    } else if provider.eq_ignore_ascii_case("minimax") {
67        "minimax"
68    } else if provider.eq_ignore_ascii_case("huggingface") {
69        "huggingface"
70    } else if provider.eq_ignore_ascii_case("stepfun") {
71        "stepfun"
72    } else if provider.eq_ignore_ascii_case("evolink") {
73        "evolink"
74    } else if provider.eq_ignore_ascii_case("poolside") {
75        "poolside"
76    } else if provider.eq_ignore_ascii_case("xai") {
77        "xai"
78    } else if provider.eq_ignore_ascii_case("nvidia") {
79        "nvidia"
80    } else if provider.eq_ignore_ascii_case("merge-gateway") {
81        "merge-gateway"
82    } else if provider.eq_ignore_ascii_case("vercel") || provider.eq_ignore_ascii_case("vercel-ai-gateway") {
83        "vercel"
84    } else {
85        provider
86    }
87}
88
89fn capability_provider_key(provider: Provider) -> &'static str {
90    match provider {
91        Provider::Gemini => "gemini",
92        Provider::OpenAI => "openai",
93        Provider::Anthropic => "anthropic",
94        Provider::Copilot => "copilot",
95        Provider::DeepSeek => "deepseek",
96        Provider::Meta => "meta",
97        Provider::OpenRouter => "openrouter",
98        Provider::Ollama => "ollama",
99        Provider::OllamaCloud => "ollama-cloud",
100        Provider::LmStudio => "lmstudio",
101        Provider::LlamaCpp => "llamacpp",
102        Provider::Moonshot => "moonshot",
103        Provider::ZAI => "zai",
104        Provider::Minimax => "minimax",
105        Provider::MiMo => "mimo",
106        Provider::Mistral => "mistral",
107        Provider::HuggingFace => "huggingface",
108        Provider::OpenCodeZen => "opencode-zen",
109        Provider::OpenCodeGo => "opencode-go",
110        Provider::Qwen => "qwen",
111        Provider::StepFun => "stepfun",
112        Provider::Evolink => "evolink",
113        Provider::Poolside => "poolside",
114        Provider::XAI => "xai",
115        Provider::NVIDIA => "nvidia",
116        Provider::MergeGateway => "merge-gateway",
117        Provider::Vercel => "vercel",
118    }
119}
120
121fn catalog_lookup_id<'a>(provider: &str, id: &'a str) -> &'a str {
122    let provider_key = catalog_provider_key(provider);
123    if provider_key == "evolink"
124        && let Some((prefix, model_id)) = id.split_once('/')
125        && prefix.eq_ignore_ascii_case(provider_key)
126    {
127        // Evolink namespaces its ModelId values to avoid collisions with
128        // first-class providers, while its catalog stores the upstream model
129        // id that the gateway receives (for example, `deepseek-v4-pro`).
130        return model_id;
131    }
132    id
133}
134
135fn generated_catalog_entry(provider: &str, id: &str) -> Option<ModelCatalogEntry> {
136    let provider_key = catalog_provider_key(provider);
137    let lookup_id = catalog_lookup_id(provider_key, id);
138    // Exact match wins so gateway providers that store prefixed IDs
139    // (`merge-gateway`/`vercel` keep `openai/gpt-5.6-luna`) keep working.
140    // Fall back to stripping a provider-prefixed request model
141    // (`openai/gpt-5.6-luna` on provider `openai` stores `gpt-5.6-luna`).
142    // The strip is provider-scoped (prefix must equal the provider key) so an
143    // unrelated prefix (`other-org/gpt-5.6-luna`) does not resolve to the bare
144    // model's capabilities. Without this, reasoning-effort and context
145    // lookups miss and the harness omits the effort with an empty
146    // `supported:` diagnostic.
147    let entry = capability_generated::metadata_for(provider_key, lookup_id).or_else(|| {
148        lookup_id
149            .split_once('/')
150            .filter(|(prefix, _)| prefix.eq_ignore_ascii_case(provider_key))
151            .and_then(|(_, rest)| capability_generated::metadata_for(provider_key, rest))
152    });
153    entry.map(|entry| ModelCatalogEntry {
154        provider: entry.provider,
155        id: entry.id,
156        display_name: entry.display_name,
157        description: entry.description,
158        context_window: entry.context_window,
159        max_output_tokens: entry.max_output_tokens,
160        reasoning: entry.reasoning,
161        reasoning_efforts: entry.reasoning_efforts,
162        is_pro: entry.is_pro,
163        lightweight_model: entry.lightweight_model,
164        tool_call: entry.tool_call,
165        vision: entry.vision,
166        input_modalities: entry.input_modalities,
167        caching: entry.caching,
168        structured_output: entry.structured_output,
169        supports_sampling: entry.supports_sampling,
170        supports_logprobs: entry.supports_logprobs,
171        prompt_cache_ttl: entry.prompt_cache_ttl,
172        prompt_contract: entry.prompt_contract,
173        pricing: ModelPricing {
174            input: entry.pricing.input,
175            output: entry.pricing.output,
176            cache_read: entry.pricing.cache_read,
177            cache_write: entry.pricing.cache_write,
178        },
179    })
180}
181
182pub fn model_catalog_entry(provider: &str, id: &str) -> Option<ModelCatalogEntry> {
183    generated_catalog_entry(provider, id)
184}
185
186pub fn supported_models_for_provider(provider: &str) -> Option<&'static [&'static str]> {
187    capability_generated::models_for_provider(catalog_provider_key(provider))
188}
189
190pub fn catalog_provider_keys() -> &'static [&'static str] {
191    capability_generated::PROVIDERS
192}
193
194impl ModelId {
195    fn generated_capabilities(&self) -> Option<ModelCatalogEntry> {
196        generated_catalog_entry(capability_provider_key(self.provider()), &self.as_str())
197    }
198
199    /// Preferred built-in lightweight sibling or lower-tier fallback for this model.
200    pub fn preferred_lightweight_variant(&self) -> Option<Self> {
201        let target_id = self.generated_capabilities()?.lightweight_model?;
202        Self::all_models().into_iter().find(|candidate| {
203            candidate != self && candidate.provider() == self.provider() && candidate.as_str() == target_id
204        })
205    }
206
207    /// Attempt to find a non-reasoning variant for this model.
208    pub fn non_reasoning_variant(&self) -> Option<Self> {
209        if let Some(meta) = self.openrouter_metadata() {
210            if !meta.reasoning {
211                return None;
212            }
213
214            let vendor = meta.vendor;
215            let mut candidates: Vec<Self> = Self::openrouter_vendor_groups()
216                .into_iter()
217                .find(|(candidate_vendor, _)| *candidate_vendor == vendor)
218                .map(|(_, models)| {
219                    models
220                        .iter()
221                        .filter(|&candidate| candidate != self)
222                        .filter(|&candidate| {
223                            candidate.openrouter_metadata().map(|other| !other.reasoning).unwrap_or(false)
224                        })
225                        .cloned()
226                        .collect()
227                })
228                .unwrap_or_default();
229
230            if candidates.is_empty() {
231                return None;
232            }
233
234            candidates.sort_by_key(|candidate| {
235                candidate
236                    .openrouter_metadata()
237                    .map(|data| (!data.efficient, data.display))
238                    .unwrap_or((true, ""))
239            });
240
241            return candidates.into_iter().next();
242        }
243
244        self.preferred_lightweight_variant()
245            .filter(|candidate| !candidate.is_reasoning_variant())
246    }
247
248    /// Check if this is a "flash" variant (optimized for speed)
249    pub fn is_flash_variant(&self) -> bool {
250        matches!(
251            self,
252            ModelId::Gemini38Flash
253                | ModelId::MergeGatewayGoogleGemini38Flash
254                | ModelId::VercelGoogleGemini38Flash
255                | ModelId::VercelDeepseekFlash
256                | ModelId::VercelOpenAiGpt56Luna
257                | ModelId::VercelAnthropicClaudeHaiku45
258                | ModelId::ClaudeHaiku55
259                | ModelId::MergeGatewayAnthropicClaudeHaiku4520251001
260                | ModelId::MergeGatewayAnthropicClaudeHaiku55
261                | ModelId::StepFun37Flash
262                | ModelId::MergeGatewayDeepseekFlash
263                | ModelId::DeepSeekFlash
264                | ModelId::ZaiGlm53Flash
265                | ModelId::ZaiGlm53Flashx
266                | ModelId::MergeGatewayZaiGlm53Flash
267                | ModelId::MergeGatewayZaiGlm53Flashx
268                | ModelId::HuggingFaceGlm53FlashTogether
269                | ModelId::MiMoV26Flash
270                | ModelId::MergeGatewayXiaomimimoMimoV26Flash
271        )
272    }
273
274    /// Check if this is a "pro" variant (optimized for capability)
275    pub fn is_pro_variant(&self) -> bool {
276        self.generated_capabilities().is_some_and(|entry| entry.is_pro)
277    }
278
279    /// Check if this is an optimized/efficient variant
280    pub fn is_efficient_variant(&self) -> bool {
281        if let Some(meta) = self.openrouter_metadata() {
282            return meta.efficient;
283        }
284        matches!(
285            self,
286            ModelId::Gemini38Flash
287                | ModelId::MergeGatewayGoogleGemini38Flash
288                | ModelId::GPT56Luna
289                | ModelId::GPT6Luna
290                | ModelId::MergeGatewayOpenAIGpt56Luna
291                | ModelId::MergeGatewayOpenAIGpt6Luna
292                | ModelId::CopilotGPT54Mini
293                | ModelId::ClaudeHaiku55
294                | ModelId::MergeGatewayAnthropicClaudeHaiku55
295                | ModelId::DeepSeekFlash
296                | ModelId::MergeGatewayDeepseekFlash
297                | ModelId::MetaMuseSpark11
298                | ModelId::MergeGatewayMinimaxH3
299                | ModelId::MiMoV26Flash
300                | ModelId::ZaiGlm53Flash
301                | ModelId::ZaiGlm53Flashx
302                | ModelId::MergeGatewayZaiGlm53Flash
303                | ModelId::MergeGatewayZaiGlm53Flashx
304                | ModelId::HuggingFaceGlm53FlashTogether
305                | ModelId::VercelDeepseekFlash
306                | ModelId::MergeGatewayXiaomimimoMimoV26Flash
307        )
308    }
309
310    /// Check if this is a top-tier model
311    pub fn is_top_tier(&self) -> bool {
312        if let Some(meta) = self.openrouter_metadata() {
313            return meta.top_tier;
314        }
315        matches!(
316            self,
317            ModelId::Gemini38Flash
318                | ModelId::MergeGatewayGoogleGemini38Flash
319                | ModelId::GPT6Astra
320                | ModelId::GPT6Sol
321                | ModelId::GPT61Sol
322                | ModelId::GPT56Sol
323                | ModelId::MergeGatewayOpenAIGpt56Sol
324                | ModelId::MergeGatewayOpenAIGpt56Terra
325                | ModelId::MergeGatewayOpenAIGpt56Luna
326                | ModelId::MergeGatewayOpenAIGpt6Astra
327                | ModelId::MergeGatewayOpenAIGpt6Sol
328                | ModelId::MergeGatewayOpenAIGpt61Sol
329                | ModelId::MergeGatewayOpenAIGpt6Luna
330                | ModelId::ClaudeSonnet5
331                | ModelId::ClaudeSonnet55
332                | ModelId::ClaudeFable5
333                | ModelId::ClaudeFable51
334                | ModelId::ClaudeOpus5
335                | ModelId::ClaudeOpus55
336                | ModelId::MistralLarge4
337                | ModelId::OpenCodeGoGlm53
338                | ModelId::MiMoV26Pro
339                | ModelId::MiMoV26ProUltraspeed
340                | ModelId::OpenCodeGoMinimaxM3
341                | ModelId::MergeGatewayDeepseekFlash
342                | ModelId::VercelDeepseekFlash
343                | ModelId::MetaMuseSpark13
344                | ModelId::MetaMuseSpark13Contributor
345                | ModelId::MetaMuseWonder10
346                | ModelId::MetaMuseWonderFull10
347                | ModelId::ZaiGlm53
348                | ModelId::ZaiGlm53Flash
349                | ModelId::ZaiGlm53Flashx
350                | ModelId::MergeGatewayZaiGlm53Flash
351                | ModelId::MergeGatewayZaiGlm53Flashx
352                | ModelId::HuggingFaceGlm53FlashTogether
353                | ModelId::HuggingFaceGlm53Together
354                | ModelId::HuggingFaceMinimaxM3Novita
355                | ModelId::StepFun5Preview
356                | ModelId::OpenRouterMoonshotaiKimiK3
357                | ModelId::MoonshotKimiK3
358                | ModelId::MergeGatewayMoonshotKimiK3
359                | ModelId::OllamaGlm53Cloud
360                | ModelId::XaiGrok46
361                | ModelId::XaiGrok47
362                | ModelId::MergeGatewayXaiGrok46
363                | ModelId::MergeGatewayXaiGrok47
364                | ModelId::VercelSpacexaiGrok47
365                | ModelId::MergeGatewayXiaomimimoMimoV26Pro
366                | ModelId::MergeGatewayXiaomimimoMimoV26Flash
367                | ModelId::MergeGatewayMistralLarge4
368        )
369    }
370
371    /// Determine whether the model is a reasoning-capable variant
372    pub fn is_reasoning_variant(&self) -> bool {
373        if let Some(meta) = self.openrouter_metadata() {
374            return meta.reasoning;
375        }
376        self.provider().supports_reasoning(&self.as_str())
377    }
378
379    /// Determine whether the model supports tool calls/function execution
380    pub fn supports_tool_calls(&self) -> bool {
381        if let Some(meta) = self.generated_capabilities() {
382            return meta.tool_call;
383        }
384        if let Some(meta) = self.openrouter_metadata() {
385            return meta.tool_call;
386        }
387        true
388    }
389
390    /// Ordered list of supported input modalities when VT Code has metadata for this model.
391    pub fn input_modalities(&self) -> &'static [&'static str] {
392        self.generated_capabilities().map(|meta| meta.input_modalities).unwrap_or(&[])
393    }
394
395    /// Get the generation/version string for this model
396    pub fn generation(&self) -> &'static str {
397        if let Some(meta) = self.openrouter_metadata() {
398            return meta.generation;
399        }
400        match self {
401            // Gemini generations
402            // OpenAI generations
403            ModelId::GPT6Astra | ModelId::GPT6Sol | ModelId::GPT6Luna => "6",
404            ModelId::GPT61Sol => "6.1",
405            ModelId::GPT56Sol | ModelId::GPT56Terra | ModelId::GPT56Luna => "5.6",
406            ModelId::OpenAIGptOss20b | ModelId::OpenAIGptOss120b => "5",
407            // Anthropic generations
408            ModelId::ClaudeSonnet5 => "5",
409            ModelId::ClaudeSonnet55 => "5.5",
410            ModelId::ClaudeHaiku55 => "5.5",
411            ModelId::ClaudeFable5 => "5",
412            ModelId::ClaudeFable51 => "5.1",
413            ModelId::ClaudeOpus5 => "5",
414            ModelId::ClaudeOpus55 => "5.5",
415            // DeepSeek generations
416            ModelId::DeepSeekFlash => "4",
417            // Mistral generations
418            ModelId::MistralLarge3 => "3",
419            ModelId::MistralLarge4 => "4",
420            ModelId::MergeGatewayDeepseekFlash => "4.1",
421            ModelId::MetaMuseSpark11 => "Muse-Spark-1.1",
422            ModelId::MetaMuseSpark13 | ModelId::MetaMuseSpark13Contributor => "Muse-Spark-1.3",
423            ModelId::MetaMuseWonder10 => "Muse-Wonder-1.0",
424            ModelId::MetaMuseWonderFull10 => "Muse-Wonder-Full-1.0",
425            // Z.AI generations
426            ModelId::ZaiGlm53
427            | ModelId::ZaiGlm53Flash
428            | ModelId::ZaiGlm53Flashx
429            | ModelId::MergeGatewayZaiGlm53Flash
430            | ModelId::MergeGatewayZaiGlm53Flashx => "5.3",
431            ModelId::Gemini38Flash => "3.8",
432            ModelId::MergeGatewayGoogleGemini38Flash => "3.8",
433            ModelId::OpenCodeGoGlm53 => "5.3",
434            ModelId::OpenCodeGoGpt56Luna => "5.6-luna",
435            ModelId::OpenCodeGoKimiK3 => "k3",
436            ModelId::MiMoV26Pro | ModelId::MiMoV26Flash | ModelId::MiMoV26ProUltraspeed => "v2.6",
437            ModelId::MergeGatewayXiaomimimoMimoV26Pro | ModelId::MergeGatewayXiaomimimoMimoV26Flash => "v2.6",
438            ModelId::MergeGatewayMistralLarge4 => "4",
439            ModelId::OpenCodeGoMinimaxM3 => "m3",
440            ModelId::OllamaGptOss20b => "oss",
441            ModelId::OllamaGptOss20bCloud => "oss-cloud",
442            ModelId::OllamaGptOss120bCloud => "oss-cloud",
443            ModelId::OllamaMinimaxM3Cloud => "minimax-m3",
444            ModelId::OllamaGlm53Cloud => "glm-5.3",
445            ModelId::OllamaGemma4 => "gemma-4",
446            ModelId::LlamaCppGemma426bA4b => "4",
447            ModelId::LlamaCppGemma4E4b => "4",
448            ModelId::LlamaCppGptOss20b => "oss",
449            // MiniMax models
450            ModelId::MinimaxM3 => "M3",
451            // StepFun models
452            ModelId::StepFun37Flash => "3.7",
453            ModelId::StepFun5Preview => "5",
454            // Moonshot models
455            ModelId::MoonshotKimiK3 => "k3",
456            ModelId::MergeGatewayMoonshotKimiK3 => "k3",
457            // Hugging Face generations
458            ModelId::HuggingFaceOpenAIGptOss20b => "oss",
459            ModelId::HuggingFaceOpenAIGptOss120b => "oss",
460            ModelId::HuggingFaceMinimaxM3Novita => "m3",
461            ModelId::HuggingFaceGlm53FlashTogether => "5.3",
462            ModelId::HuggingFaceGlm53Together => "5.3",
463            // xAI models
464            ModelId::XaiGrok46 => "4.6",
465            ModelId::XaiGrok47 => "4.7",
466            ModelId::MergeGatewayXaiGrok46 => "4.6",
467            ModelId::MergeGatewayXaiGrok47 => "4.7",
468            // Qwen models
469            ModelId::MergeGatewayDefaultRouting => "routing",
470            ModelId::MergeGatewayAnthropicClaudeOpus5 => "5",
471            ModelId::MergeGatewayAnthropicClaudeOpus55 => "5.5",
472            ModelId::MergeGatewayAnthropicClaudeSonnet5 => "5",
473            ModelId::MergeGatewayAnthropicClaudeSonnet55 => "5.5",
474            ModelId::MergeGatewayAnthropicClaudeHaiku55 => "5.5",
475            ModelId::MergeGatewayAnthropicClaudeFable51 => "5.1",
476            ModelId::MergeGatewayAnthropicClaudeHaiku4520251001 => "4.5",
477            ModelId::MergeGatewayMinimaxH3 => "H3",
478            ModelId::MergeGatewayThinkingMachinesInkling => "Inkling",
479            ModelId::MergeGatewayOpenAIGpt56Luna
480            | ModelId::MergeGatewayOpenAIGpt56Sol
481            | ModelId::MergeGatewayOpenAIGpt56Terra => "5.6",
482            ModelId::MergeGatewayOpenAIGpt6Astra
483            | ModelId::MergeGatewayOpenAIGpt6Sol
484            | ModelId::MergeGatewayOpenAIGpt6Luna => "6",
485            ModelId::MergeGatewayOpenAIGpt61Sol => "6.1",
486            // Vercel AI Gateway models
487            ModelId::VercelAnthropicClaudeSonnet5 => "5",
488            ModelId::VercelAnthropicClaudeOpus5 => "5",
489            ModelId::VercelAnthropicClaudeOpus55 => "5.5",
490            ModelId::VercelAnthropicClaudeHaiku45 => "4.5",
491            ModelId::VercelOpenAiGpt56Sol | ModelId::VercelOpenAiGpt56Luna => "5.6",
492            ModelId::VercelOpenAiGpt6Astra => "6",
493            ModelId::VercelGoogleGemini38Flash => "3.8",
494            ModelId::VercelDeepseekFlash => "4.1",
495            ModelId::VercelMoonshotaiKimiK3 => "k3",
496            ModelId::VercelMinimaxM3 => "M3",
497            ModelId::VercelSpacexaiGrok47 => "4.7",
498            _ => "unknown",
499        }
500    }
501
502    /// Determine if this model supports GPT-5.1+/5.2+/5.3+ shell tool type
503    pub(crate) fn supports_shell_tool(&self) -> bool {
504        matches!(
505            self,
506            ModelId::GPT6Astra
507                | ModelId::GPT6Sol
508                | ModelId::GPT61Sol
509                | ModelId::GPT6Luna
510                | ModelId::GPT56Sol
511                | ModelId::GPT56Terra
512                | ModelId::GPT56Luna
513        )
514    }
515
516    /// Determine if this model supports optimized apply_patch tool
517    pub fn supports_apply_patch_tool(&self) -> bool {
518        false
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[test]
527    fn prefixed_request_model_resolves_on_bare_provider() {
528        // Regression for `openai/gpt-5.6-luna` on provider `openai` resolving
529        // to empty `supported:` and omitting reasoning effort: exact bare hit
530        // and prefixed fallback must agree, while gateway prefixed entries
531        // keep their exact match.
532        let bare = model_catalog_entry("openai", "gpt-5.6-luna").expect("bare openai entry exists");
533        let prefixed = model_catalog_entry("openai", "openai/gpt-5.6-luna").expect("prefixed fallback must resolve");
534        assert_eq!(bare.reasoning_efforts, prefixed.reasoning_efforts);
535        assert!(!prefixed.reasoning_efforts.is_empty());
536
537        let gateway =
538            model_catalog_entry("merge-gateway", "openai/gpt-5.6-luna").expect("gateway prefixed entry exists");
539        assert!(!gateway.reasoning_efforts.is_empty());
540
541        // Asymmetric boundary: unknown model stays missing on both shapes,
542        // and an unrelated prefix must not resolve to the bare model.
543        assert!(model_catalog_entry("openai", "no-such-model-xyz").is_none());
544        assert!(model_catalog_entry("openai", "openai/no-such-model-xyz").is_none());
545        assert!(model_catalog_entry("openai", "other-org/gpt-5.6-luna").is_none());
546    }
547}