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#[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 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 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 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 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 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::MergeGatewayZaiGlm53Flash
268 | ModelId::MergeGatewayZaiGlm53Flashx
269 | ModelId::HuggingFaceGlm53FlashTogether
270 | ModelId::MiMoV26Flash
271 | ModelId::MergeGatewayXiaomimimoMimoV26Flash
272 )
273 }
274
275 pub fn is_pro_variant(&self) -> bool {
277 self.generated_capabilities().is_some_and(|entry| entry.is_pro)
278 }
279
280 pub fn is_efficient_variant(&self) -> bool {
282 if let Some(meta) = self.openrouter_metadata() {
283 return meta.efficient;
284 }
285 matches!(
286 self,
287 ModelId::Gemini38Flash
288 | ModelId::MergeGatewayGoogleGemini38Flash
289 | ModelId::GPT56Luna
290 | ModelId::GPT6Luna
291 | ModelId::MergeGatewayOpenAIGpt56Luna
292 | ModelId::MergeGatewayOpenAIGpt6Luna
293 | ModelId::CopilotGPT54Mini
294 | ModelId::ClaudeHaiku55
295 | ModelId::MergeGatewayAnthropicClaudeHaiku55
296 | ModelId::DeepSeekFlash
297 | ModelId::OllamaDeepSeekV41FlashCloud
298 | ModelId::MergeGatewayDeepseekFlash
299 | ModelId::MetaMuseSpark11
300 | ModelId::MergeGatewayMinimaxH3
301 | ModelId::MiMoV26Flash
302 | ModelId::ZaiGlm53Flash
303 | ModelId::ZaiGlm53Flashx
304 | ModelId::OllamaGlm53FlashCloud
305 | ModelId::MergeGatewayZaiGlm53Flash
306 | ModelId::MergeGatewayZaiGlm53Flashx
307 | ModelId::HuggingFaceGlm53FlashTogether
308 | ModelId::VercelDeepseekFlash
309 | ModelId::MergeGatewayXiaomimimoMimoV26Flash
310 )
311 }
312
313 pub fn is_top_tier(&self) -> bool {
315 if let Some(meta) = self.openrouter_metadata() {
316 return meta.top_tier;
317 }
318 matches!(
319 self,
320 ModelId::Gemini38Flash
321 | ModelId::MergeGatewayGoogleGemini38Flash
322 | ModelId::Gemini4Argon
323 | ModelId::MergeGatewayGoogleGemini4Argon
324 | ModelId::GPT6Astra
325 | ModelId::GPT6Sol
326 | ModelId::GPT61Sol
327 | ModelId::GPT56Sol
328 | ModelId::MergeGatewayOpenAIGpt56Sol
329 | ModelId::MergeGatewayOpenAIGpt56Terra
330 | ModelId::MergeGatewayOpenAIGpt56Luna
331 | ModelId::MergeGatewayOpenAIGpt6Astra
332 | ModelId::MergeGatewayOpenAIGpt6Sol
333 | ModelId::MergeGatewayOpenAIGpt61Sol
334 | ModelId::MergeGatewayOpenAIGpt6Luna
335 | ModelId::ClaudeSonnet5
336 | ModelId::ClaudeSonnet55
337 | ModelId::ClaudeFable5
338 | ModelId::ClaudeFable51
339 | ModelId::ClaudeOpus5
340 | ModelId::ClaudeOpus55
341 | ModelId::MistralLarge4
342 | ModelId::OpenCodeGoGlm53
343 | ModelId::MiMoV26Pro
344 | ModelId::MiMoV26ProUltraspeed
345 | ModelId::OpenCodeGoMinimaxM3
346 | ModelId::MergeGatewayDeepseekFlash
347 | ModelId::VercelDeepseekFlash
348 | ModelId::MetaMuseSpark13
349 | ModelId::MetaMuseSpark13Contributor
350 | ModelId::MetaMuseWonder10
351 | ModelId::MetaMuseWonderFull10
352 | ModelId::ZaiGlm53
353 | ModelId::ZaiGlm53Flash
354 | ModelId::ZaiGlm53Flashx
355 | ModelId::MergeGatewayZaiGlm53Flash
356 | ModelId::MergeGatewayZaiGlm53Flashx
357 | ModelId::HuggingFaceGlm53FlashTogether
358 | ModelId::HuggingFaceGlm53Together
359 | ModelId::HuggingFaceMinimaxM3Novita
360 | ModelId::StepFun5Preview
361 | ModelId::OpenRouterMoonshotaiKimiK3
362 | ModelId::MoonshotKimiK3
363 | ModelId::MergeGatewayMoonshotKimiK3
364 | ModelId::OllamaGlm53Cloud
365 | ModelId::OllamaGlm53FlashCloud
366 | ModelId::OllamaDeepSeekV41FlashCloud
367 | ModelId::OllamaMistralLarge4Cloud
368 | ModelId::OllamaMuseSparkOpenCloud
369 | ModelId::XaiGrok46
370 | ModelId::XaiGrok47
371 | ModelId::MergeGatewayXaiGrok46
372 | ModelId::MergeGatewayXaiGrok47
373 | ModelId::VercelSpacexaiGrok47
374 | ModelId::MergeGatewayXiaomimimoMimoV26Pro
375 | ModelId::MergeGatewayXiaomimimoMimoV26Flash
376 | ModelId::MergeGatewayMistralLarge4
377 | ModelId::MergeGatewayMetaMuseSparkOpen
378 | ModelId::HuggingFaceMuseSparkOpenBaseten
379 )
380 }
381
382 pub fn is_reasoning_variant(&self) -> bool {
384 if let Some(meta) = self.openrouter_metadata() {
385 return meta.reasoning;
386 }
387 self.provider().supports_reasoning(&self.as_str())
388 }
389
390 pub fn supports_tool_calls(&self) -> bool {
392 if let Some(meta) = self.generated_capabilities() {
393 return meta.tool_call;
394 }
395 if let Some(meta) = self.openrouter_metadata() {
396 return meta.tool_call;
397 }
398 true
399 }
400
401 pub fn input_modalities(&self) -> &'static [&'static str] {
403 self.generated_capabilities().map(|meta| meta.input_modalities).unwrap_or(&[])
404 }
405
406 pub fn generation(&self) -> &'static str {
408 if let Some(meta) = self.openrouter_metadata() {
409 return meta.generation;
410 }
411 match self {
412 ModelId::GPT6Astra | ModelId::GPT6Sol | ModelId::GPT6Luna => "6",
415 ModelId::GPT61Sol => "6.1",
416 ModelId::GPT56Sol | ModelId::GPT56Terra | ModelId::GPT56Luna => "5.6",
417 ModelId::OpenAIGptOss20b | ModelId::OpenAIGptOss120b => "5",
418 ModelId::ClaudeSonnet5 => "5",
420 ModelId::ClaudeSonnet55 => "5.5",
421 ModelId::ClaudeHaiku55 => "5.5",
422 ModelId::ClaudeFable5 => "5",
423 ModelId::ClaudeFable51 => "5.1",
424 ModelId::ClaudeOpus5 => "5",
425 ModelId::ClaudeOpus55 => "5.5",
426 ModelId::DeepSeekFlash => "4",
428 ModelId::OllamaDeepSeekV41FlashCloud => "4.1",
429 ModelId::MistralLarge3 => "3",
431 ModelId::MistralLarge4 => "4",
432 ModelId::OllamaMistralLarge4Cloud => "4",
433 ModelId::MergeGatewayDeepseekFlash => "4.1",
434 ModelId::MetaMuseSpark11 => "Muse-Spark-1.1",
435 ModelId::MetaMuseSpark13 | ModelId::MetaMuseSpark13Contributor => "Muse-Spark-1.3",
436 ModelId::MergeGatewayMetaMuseSparkOpen | ModelId::HuggingFaceMuseSparkOpenBaseten => "Muse-Spark-Open",
437 ModelId::OllamaMuseSparkOpenCloud => "Muse-Spark-Open",
438 ModelId::MetaMuseWonder10 => "Muse-Wonder-1.0",
439 ModelId::MetaMuseWonderFull10 => "Muse-Wonder-Full-1.0",
440 ModelId::ZaiGlm53
442 | ModelId::ZaiGlm53Flash
443 | ModelId::ZaiGlm53Flashx
444 | ModelId::MergeGatewayZaiGlm53Flash
445 | ModelId::MergeGatewayZaiGlm53Flashx => "5.3",
446 ModelId::OllamaGlm53FlashCloud => "5.3",
447 ModelId::Gemini38Flash => "3.8",
448 ModelId::MergeGatewayGoogleGemini38Flash => "3.8",
449 ModelId::Gemini4Argon => "4",
450 ModelId::MergeGatewayGoogleGemini4Argon => "4",
451 ModelId::OpenCodeGoGlm53 => "5.3",
452 ModelId::OpenCodeGoGpt56Luna => "5.6-luna",
453 ModelId::OpenCodeGoKimiK3 => "k3",
454 ModelId::MiMoV26Pro | ModelId::MiMoV26Flash | ModelId::MiMoV26ProUltraspeed => "v2.6",
455 ModelId::MergeGatewayXiaomimimoMimoV26Pro | ModelId::MergeGatewayXiaomimimoMimoV26Flash => "v2.6",
456 ModelId::MergeGatewayMistralLarge4 => "4",
457 ModelId::OpenCodeGoMinimaxM3 => "m3",
458 ModelId::OllamaGptOss20b => "oss",
459 ModelId::OllamaGptOss20bCloud => "oss-cloud",
460 ModelId::OllamaGptOss120bCloud => "oss-cloud",
461 ModelId::OllamaMinimaxM3Cloud => "minimax-m3",
462 ModelId::OllamaGlm53Cloud => "glm-5.3",
463 ModelId::OllamaGemma4 => "gemma-4",
464 ModelId::LlamaCppGemma426bA4b => "4",
465 ModelId::LlamaCppGemma4E4b => "4",
466 ModelId::LlamaCppGptOss20b => "oss",
467 ModelId::MinimaxM3 => "M3",
469 ModelId::StepFun37Flash => "3.7",
471 ModelId::StepFun5Preview => "5",
472 ModelId::MoonshotKimiK3 => "k3",
474 ModelId::MergeGatewayMoonshotKimiK3 => "k3",
475 ModelId::HuggingFaceOpenAIGptOss20b => "oss",
477 ModelId::HuggingFaceOpenAIGptOss120b => "oss",
478 ModelId::HuggingFaceMinimaxM3Novita => "m3",
479 ModelId::HuggingFaceGlm53FlashTogether => "5.3",
480 ModelId::HuggingFaceGlm53Together => "5.3",
481 ModelId::XaiGrok46 => "4.6",
483 ModelId::XaiGrok47 => "4.7",
484 ModelId::MergeGatewayXaiGrok46 => "4.6",
485 ModelId::MergeGatewayXaiGrok47 => "4.7",
486 ModelId::MergeGatewayDefaultRouting => "routing",
488 ModelId::MergeGatewayAnthropicClaudeOpus5 => "5",
489 ModelId::MergeGatewayAnthropicClaudeOpus55 => "5.5",
490 ModelId::MergeGatewayAnthropicClaudeSonnet5 => "5",
491 ModelId::MergeGatewayAnthropicClaudeSonnet55 => "5.5",
492 ModelId::MergeGatewayAnthropicClaudeHaiku55 => "5.5",
493 ModelId::MergeGatewayAnthropicClaudeFable51 => "5.1",
494 ModelId::MergeGatewayAnthropicClaudeHaiku4520251001 => "4.5",
495 ModelId::MergeGatewayMinimaxH3 => "H3",
496 ModelId::MergeGatewayThinkingMachinesInkling => "Inkling",
497 ModelId::MergeGatewayOpenAIGpt56Luna
498 | ModelId::MergeGatewayOpenAIGpt56Sol
499 | ModelId::MergeGatewayOpenAIGpt56Terra => "5.6",
500 ModelId::MergeGatewayOpenAIGpt6Astra
501 | ModelId::MergeGatewayOpenAIGpt6Sol
502 | ModelId::MergeGatewayOpenAIGpt6Luna => "6",
503 ModelId::MergeGatewayOpenAIGpt61Sol => "6.1",
504 ModelId::VercelAnthropicClaudeSonnet5 => "5",
506 ModelId::VercelAnthropicClaudeOpus5 => "5",
507 ModelId::VercelAnthropicClaudeOpus55 => "5.5",
508 ModelId::VercelAnthropicClaudeHaiku45 => "4.5",
509 ModelId::VercelOpenAiGpt56Sol | ModelId::VercelOpenAiGpt56Luna => "5.6",
510 ModelId::VercelOpenAiGpt6Sol | ModelId::VercelOpenAiGpt6Luna => "6",
511 ModelId::VercelOpenAiGpt6Astra => "6",
512 ModelId::VercelGoogleGemini38Flash => "3.8",
513 ModelId::VercelDeepseekFlash => "4.1",
514 ModelId::VercelMoonshotaiKimiK3 => "k3",
515 ModelId::VercelMinimaxM3 => "M3",
516 ModelId::VercelSpacexaiGrok47 => "4.7",
517 _ => "unknown",
518 }
519 }
520
521 pub(crate) fn supports_shell_tool(&self) -> bool {
523 matches!(
524 self,
525 ModelId::GPT6Astra
526 | ModelId::GPT6Sol
527 | ModelId::GPT61Sol
528 | ModelId::GPT6Luna
529 | ModelId::GPT56Sol
530 | ModelId::GPT56Terra
531 | ModelId::GPT56Luna
532 )
533 }
534
535 pub fn supports_apply_patch_tool(&self) -> bool {
537 false
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 #[test]
546 fn prefixed_request_model_resolves_on_bare_provider() {
547 let bare = model_catalog_entry("openai", "gpt-5.6-luna").expect("bare openai entry exists");
552 let prefixed = model_catalog_entry("openai", "openai/gpt-5.6-luna").expect("prefixed fallback must resolve");
553 assert_eq!(bare.reasoning_efforts, prefixed.reasoning_efforts);
554 assert!(!prefixed.reasoning_efforts.is_empty());
555
556 let gateway =
557 model_catalog_entry("merge-gateway", "openai/gpt-5.6-luna").expect("gateway prefixed entry exists");
558 assert!(!gateway.reasoning_efforts.is_empty());
559
560 assert!(model_catalog_entry("openai", "no-such-model-xyz").is_none());
563 assert!(model_catalog_entry("openai", "openai/no-such-model-xyz").is_none());
564 assert!(model_catalog_entry("openai", "other-org/gpt-5.6-luna").is_none());
565 }
566}