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#[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 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 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 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 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 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 pub fn is_pro_variant(&self) -> bool {
276 self.generated_capabilities().is_some_and(|entry| entry.is_pro)
277 }
278
279 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 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::ZaiGlm53
346 | ModelId::ZaiGlm53Flash
347 | ModelId::ZaiGlm53Flashx
348 | ModelId::MergeGatewayZaiGlm53Flash
349 | ModelId::MergeGatewayZaiGlm53Flashx
350 | ModelId::HuggingFaceGlm53FlashTogether
351 | ModelId::HuggingFaceGlm53Together
352 | ModelId::HuggingFaceMinimaxM3Novita
353 | ModelId::StepFun5Preview
354 | ModelId::OpenRouterMoonshotaiKimiK3
355 | ModelId::MoonshotKimiK3
356 | ModelId::MergeGatewayMoonshotKimiK3
357 | ModelId::OllamaGlm53Cloud
358 | ModelId::XaiGrok46
359 | ModelId::XaiGrok47
360 | ModelId::MergeGatewayXaiGrok46
361 | ModelId::MergeGatewayXaiGrok47
362 | ModelId::VercelSpacexaiGrok47
363 | ModelId::MergeGatewayXiaomimimoMimoV26Pro
364 | ModelId::MergeGatewayXiaomimimoMimoV26Flash
365 | ModelId::MergeGatewayMistralLarge4
366 )
367 }
368
369 pub fn is_reasoning_variant(&self) -> bool {
371 if let Some(meta) = self.openrouter_metadata() {
372 return meta.reasoning;
373 }
374 self.provider().supports_reasoning(&self.as_str())
375 }
376
377 pub fn supports_tool_calls(&self) -> bool {
379 if let Some(meta) = self.generated_capabilities() {
380 return meta.tool_call;
381 }
382 if let Some(meta) = self.openrouter_metadata() {
383 return meta.tool_call;
384 }
385 true
386 }
387
388 pub fn input_modalities(&self) -> &'static [&'static str] {
390 self.generated_capabilities().map(|meta| meta.input_modalities).unwrap_or(&[])
391 }
392
393 pub fn generation(&self) -> &'static str {
395 if let Some(meta) = self.openrouter_metadata() {
396 return meta.generation;
397 }
398 match self {
399 ModelId::GPT6Astra | ModelId::GPT6Sol | ModelId::GPT6Luna => "6",
402 ModelId::GPT61Sol => "6.1",
403 ModelId::GPT56Sol | ModelId::GPT56Terra | ModelId::GPT56Luna => "5.6",
404 ModelId::OpenAIGptOss20b | ModelId::OpenAIGptOss120b => "5",
405 ModelId::ClaudeSonnet5 => "5",
407 ModelId::ClaudeSonnet55 => "5.5",
408 ModelId::ClaudeHaiku55 => "5.5",
409 ModelId::ClaudeFable5 => "5",
410 ModelId::ClaudeFable51 => "5.1",
411 ModelId::ClaudeOpus5 => "5",
412 ModelId::ClaudeOpus55 => "5.5",
413 ModelId::DeepSeekFlash => "4",
415 ModelId::MistralLarge3 => "3",
417 ModelId::MistralLarge4 => "4",
418 ModelId::MergeGatewayDeepseekFlash => "4.1",
419 ModelId::MetaMuseSpark11 => "Muse-Spark-1.1",
420 ModelId::MetaMuseSpark13 | ModelId::MetaMuseSpark13Contributor => "Muse-Spark-1.3",
421 ModelId::ZaiGlm53
423 | ModelId::ZaiGlm53Flash
424 | ModelId::ZaiGlm53Flashx
425 | ModelId::MergeGatewayZaiGlm53Flash
426 | ModelId::MergeGatewayZaiGlm53Flashx => "5.3",
427 ModelId::Gemini38Flash => "3.8",
428 ModelId::MergeGatewayGoogleGemini38Flash => "3.8",
429 ModelId::OpenCodeGoGlm53 => "5.3",
430 ModelId::OpenCodeGoGpt56Luna => "5.6-luna",
431 ModelId::OpenCodeGoKimiK3 => "k3",
432 ModelId::MiMoV26Pro | ModelId::MiMoV26Flash | ModelId::MiMoV26ProUltraspeed => "v2.6",
433 ModelId::MergeGatewayXiaomimimoMimoV26Pro | ModelId::MergeGatewayXiaomimimoMimoV26Flash => "v2.6",
434 ModelId::MergeGatewayMistralLarge4 => "4",
435 ModelId::OpenCodeGoMinimaxM3 => "m3",
436 ModelId::OllamaGptOss20b => "oss",
437 ModelId::OllamaGptOss20bCloud => "oss-cloud",
438 ModelId::OllamaGptOss120bCloud => "oss-cloud",
439 ModelId::OllamaMinimaxM3Cloud => "minimax-m3",
440 ModelId::OllamaGlm53Cloud => "glm-5.3",
441 ModelId::OllamaGemma4 => "gemma-4",
442 ModelId::LlamaCppGemma426bA4b => "4",
443 ModelId::LlamaCppGemma4E4b => "4",
444 ModelId::LlamaCppGptOss20b => "oss",
445 ModelId::MinimaxM3 => "M3",
447 ModelId::StepFun37Flash => "3.7",
449 ModelId::StepFun5Preview => "5",
450 ModelId::MoonshotKimiK3 => "k3",
452 ModelId::MergeGatewayMoonshotKimiK3 => "k3",
453 ModelId::HuggingFaceOpenAIGptOss20b => "oss",
455 ModelId::HuggingFaceOpenAIGptOss120b => "oss",
456 ModelId::HuggingFaceMinimaxM3Novita => "m3",
457 ModelId::HuggingFaceGlm53FlashTogether => "5.3",
458 ModelId::HuggingFaceGlm53Together => "5.3",
459 ModelId::XaiGrok46 => "4.6",
461 ModelId::XaiGrok47 => "4.7",
462 ModelId::MergeGatewayXaiGrok46 => "4.6",
463 ModelId::MergeGatewayXaiGrok47 => "4.7",
464 ModelId::MergeGatewayDefaultRouting => "routing",
466 ModelId::MergeGatewayAnthropicClaudeOpus5 => "5",
467 ModelId::MergeGatewayAnthropicClaudeOpus55 => "5.5",
468 ModelId::MergeGatewayAnthropicClaudeSonnet5 => "5",
469 ModelId::MergeGatewayAnthropicClaudeSonnet55 => "5.5",
470 ModelId::MergeGatewayAnthropicClaudeHaiku55 => "5.5",
471 ModelId::MergeGatewayAnthropicClaudeFable51 => "5.1",
472 ModelId::MergeGatewayAnthropicClaudeHaiku4520251001 => "4.5",
473 ModelId::MergeGatewayMinimaxH3 => "H3",
474 ModelId::MergeGatewayThinkingMachinesInkling => "Inkling",
475 ModelId::MergeGatewayOpenAIGpt56Luna
476 | ModelId::MergeGatewayOpenAIGpt56Sol
477 | ModelId::MergeGatewayOpenAIGpt56Terra => "5.6",
478 ModelId::MergeGatewayOpenAIGpt6Astra
479 | ModelId::MergeGatewayOpenAIGpt6Sol
480 | ModelId::MergeGatewayOpenAIGpt6Luna => "6",
481 ModelId::MergeGatewayOpenAIGpt61Sol => "6.1",
482 ModelId::VercelAnthropicClaudeSonnet5 => "5",
484 ModelId::VercelAnthropicClaudeOpus5 => "5",
485 ModelId::VercelAnthropicClaudeOpus55 => "5.5",
486 ModelId::VercelAnthropicClaudeHaiku45 => "4.5",
487 ModelId::VercelOpenAiGpt56Sol | ModelId::VercelOpenAiGpt56Luna => "5.6",
488 ModelId::VercelOpenAiGpt6Astra => "6",
489 ModelId::VercelGoogleGemini38Flash => "3.8",
490 ModelId::VercelDeepseekFlash => "4.1",
491 ModelId::VercelMoonshotaiKimiK3 => "k3",
492 ModelId::VercelMinimaxM3 => "M3",
493 ModelId::VercelSpacexaiGrok47 => "4.7",
494 _ => "unknown",
495 }
496 }
497
498 pub(crate) fn supports_shell_tool(&self) -> bool {
500 matches!(
501 self,
502 ModelId::GPT6Astra
503 | ModelId::GPT6Sol
504 | ModelId::GPT61Sol
505 | ModelId::GPT6Luna
506 | ModelId::GPT56Sol
507 | ModelId::GPT56Terra
508 | ModelId::GPT56Luna
509 )
510 }
511
512 pub fn supports_apply_patch_tool(&self) -> bool {
514 false
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 #[test]
523 fn prefixed_request_model_resolves_on_bare_provider() {
524 let bare = model_catalog_entry("openai", "gpt-5.6-luna").expect("bare openai entry exists");
529 let prefixed = model_catalog_entry("openai", "openai/gpt-5.6-luna").expect("prefixed fallback must resolve");
530 assert_eq!(bare.reasoning_efforts, prefixed.reasoning_efforts);
531 assert!(!prefixed.reasoning_efforts.is_empty());
532
533 let gateway =
534 model_catalog_entry("merge-gateway", "openai/gpt-5.6-luna").expect("gateway prefixed entry exists");
535 assert!(!gateway.reasoning_efforts.is_empty());
536
537 assert!(model_catalog_entry("openai", "no-such-model-xyz").is_none());
540 assert!(model_catalog_entry("openai", "openai/no-such-model-xyz").is_none());
541 assert!(model_catalog_entry("openai", "other-org/gpt-5.6-luna").is_none());
542 }
543}