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::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 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 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 pub fn input_modalities(&self) -> &'static [&'static str] {
392 self.generated_capabilities().map(|meta| meta.input_modalities).unwrap_or(&[])
393 }
394
395 pub fn generation(&self) -> &'static str {
397 if let Some(meta) = self.openrouter_metadata() {
398 return meta.generation;
399 }
400 match self {
401 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 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 ModelId::DeepSeekFlash => "4",
417 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 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 ModelId::MinimaxM3 => "M3",
451 ModelId::StepFun37Flash => "3.7",
453 ModelId::StepFun5Preview => "5",
454 ModelId::MoonshotKimiK3 => "k3",
456 ModelId::MergeGatewayMoonshotKimiK3 => "k3",
457 ModelId::HuggingFaceOpenAIGptOss20b => "oss",
459 ModelId::HuggingFaceOpenAIGptOss120b => "oss",
460 ModelId::HuggingFaceMinimaxM3Novita => "m3",
461 ModelId::HuggingFaceGlm53FlashTogether => "5.3",
462 ModelId::HuggingFaceGlm53Together => "5.3",
463 ModelId::XaiGrok46 => "4.6",
465 ModelId::XaiGrok47 => "4.7",
466 ModelId::MergeGatewayXaiGrok46 => "4.6",
467 ModelId::MergeGatewayXaiGrok47 => "4.7",
468 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 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 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 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 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 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}