Skip to main content

vtcode_config/models/model_id/
capabilities.rs

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