1#![doc = include_str!("../README.md")]
2
3use proc_macro2::TokenStream;
4use quote::{ToTokens, format_ident, quote};
5use serde::Deserialize;
6use std::collections::{BTreeMap, HashMap, HashSet};
7use std::fmt::Write;
8use std::path::Path;
9
10type ModelsDevData = HashMap<String, ProviderData>;
11
12#[derive(Debug, Deserialize)]
13struct ProviderData {
14 #[allow(dead_code)]
15 id: String,
16 #[allow(dead_code)]
17 name: String,
18 #[serde(default)]
19 #[allow(dead_code)]
20 env: Vec<String>,
21 #[serde(default)]
22 models: HashMap<String, ModelData>,
23}
24
25#[derive(Debug, Deserialize)]
26struct ModelData {
27 id: String,
28 name: String,
29 #[serde(default)]
30 tool_call: Option<bool>,
31 #[serde(default)]
32 reasoning: Option<bool>,
33 #[serde(default)]
34 reasoning_options: Vec<ReasoningOption>,
35 #[serde(default)]
36 #[allow(dead_code)]
37 cost: Option<CostData>,
38 #[serde(default)]
39 limit: Option<LimitData>,
40 #[serde(default)]
41 modalities: Option<ModalitiesData>,
42 #[serde(default)]
43 provider: Option<ModelProviderData>,
44}
45
46#[derive(Debug, Deserialize)]
48struct ModelProviderData {
49 #[serde(default)]
50 api: Option<String>,
51 #[serde(default)]
52 shape: Option<String>,
53}
54
55#[derive(Debug, Deserialize)]
56#[serde(tag = "type", rename_all = "snake_case")]
57enum ReasoningOption {
58 Effort { values: Vec<Option<String>> },
59 Toggle,
60 BudgetTokens,
61}
62
63#[derive(Debug, Deserialize, Default)]
64struct ModalitiesData {
65 #[serde(default)]
66 input: Vec<String>,
67}
68
69#[derive(Debug, Clone, PartialEq, Deserialize)]
70struct CostData {
71 #[serde(default)]
72 input: f64,
73 #[serde(default)]
74 output: f64,
75 #[serde(default)]
76 cache_read: Option<f64>,
77 #[serde(default)]
78 cache_write: Option<f64>,
79}
80
81#[derive(Debug, Deserialize)]
82struct LimitData {
83 #[serde(default)]
84 context: u32,
85 #[serde(default)]
86 #[allow(dead_code)]
87 output: u32,
88}
89
90impl CostData {
91 fn has_prompt_caching(&self) -> bool {
92 self.cache_read.is_some() || self.cache_write.is_some()
93 }
94}
95
96struct ProviderConfig {
98 dev_id: &'static str,
100 source_dev_id: Option<&'static str>,
102 extra_source_ids: &'static [&'static str],
104 explicit_models: Option<&'static [ExplicitModel]>,
109 enum_name: &'static str,
111 parser_name: &'static str,
113 genai_provider_name: &'static str,
115 display_name: &'static str,
117 env_var: Option<&'static str>,
119 oauth_provider_id: Option<&'static str>,
121 fallback_reasoning_levels: &'static [&'static str],
123 use_model_transport: bool,
127 uses_openai_compatible_api: bool,
129 is_hybrid_dynamic: bool,
135}
136
137struct ExplicitModel {
139 id: &'static str,
140 context_window: u32,
141 supports_reasoning_off: bool,
142}
143
144impl ProviderConfig {
145 const fn standard(
147 dev_id: &'static str,
148 enum_name: &'static str,
149 parser_name: &'static str,
150 display_name: &'static str,
151 env_var: Option<&'static str>,
152 ) -> Self {
153 Self {
154 dev_id,
155 source_dev_id: None,
156 extra_source_ids: &[],
157 explicit_models: None,
158 enum_name,
159 parser_name,
160 genai_provider_name: parser_name,
161 display_name,
162 env_var,
163 oauth_provider_id: None,
164 fallback_reasoning_levels: &["low", "medium", "high"],
165 use_model_transport: false,
166 uses_openai_compatible_api: false,
167 is_hybrid_dynamic: false,
168 }
169 }
170
171 const fn openai_compatible(
172 dev_id: &'static str,
173 enum_name: &'static str,
174 parser_name: &'static str,
175 display_name: &'static str,
176 env_var: &'static str,
177 ) -> Self {
178 let mut config = Self::standard(dev_id, enum_name, parser_name, display_name, Some(env_var));
179 config.uses_openai_compatible_api = true;
180 config
181 }
182
183 fn explicit_model(&self, model_id: &str) -> Option<&'static ExplicitModel> {
184 self.explicit_models.and_then(|models| models.iter().find(|model| model.id == model_id))
185 }
186
187 fn inner_enum_name(&self) -> String {
190 if self.is_hybrid_dynamic {
191 format!("{}FoundationModel", self.enum_name)
192 } else {
193 format!("{}Model", self.enum_name)
194 }
195 }
196
197 fn outer_enum_name(&self) -> String {
199 format!("{}Model", self.enum_name)
200 }
201
202 fn json_key(&self) -> &'static str {
204 self.source_dev_id.unwrap_or(self.dev_id)
205 }
206}
207
208#[allow(clippy::struct_field_names)]
210struct DynamicProviderConfig {
211 enum_name: &'static str,
213 parser_name: &'static str,
215 genai_provider_name: &'static str,
217 display_name: &'static str,
219}
220
221const PROVIDERS: &[ProviderConfig] = &[
222 ProviderConfig::standard("anthropic", "Anthropic", "anthropic", "Anthropic", Some("ANTHROPIC_API_KEY")),
223 ProviderConfig {
224 source_dev_id: Some("azure"),
225 genai_provider_name: "azure.ai.openai",
226 ..ProviderConfig::openai_compatible(
227 "azure-foundry",
228 "AzureFoundry",
229 "azure-foundry",
230 "Microsoft Foundry",
231 "AZURE_OPENAI_API_KEY",
232 )
233 },
234 ProviderConfig {
235 dev_id: "codex",
236 source_dev_id: Some("openai"),
237 extra_source_ids: &[],
238 explicit_models: Some(CODEX_SUBSCRIPTION_MODELS),
239 enum_name: "Codex",
240 parser_name: "codex",
241 genai_provider_name: "openai",
242 display_name: "Codex",
243 env_var: None,
244 oauth_provider_id: Some("codex"),
245 fallback_reasoning_levels: &["low", "medium", "high", "xhigh"],
246 use_model_transport: false,
247 uses_openai_compatible_api: false,
248 is_hybrid_dynamic: false,
249 },
250 ProviderConfig::openai_compatible("deepseek", "DeepSeek", "deepseek", "DeepSeek", "DEEPSEEK_API_KEY"),
251 ProviderConfig {
252 source_dev_id: Some("fireworks-ai"),
253 ..ProviderConfig::openai_compatible("fireworks", "Fireworks", "fireworks", "Fireworks AI", "FIREWORKS_API_KEY")
254 },
255 ProviderConfig {
256 genai_provider_name: "gcp.gemini",
257 ..ProviderConfig::standard("google", "Gemini", "gemini", "Gemini", Some("GEMINI_API_KEY"))
258 },
259 ProviderConfig {
260 genai_provider_name: "moonshot_ai",
261 ..ProviderConfig::openai_compatible("moonshotai", "Moonshot", "moonshot", "Moonshot", "MOONSHOT_API_KEY")
262 },
263 ProviderConfig::standard("openai", "Openai", "openai", "OpenAI", Some("OPENAI_API_KEY")),
264 ProviderConfig::standard("openrouter", "OpenRouter", "openrouter", "OpenRouter", Some("OPENROUTER_API_KEY")),
265 ProviderConfig {
266 extra_source_ids: &["zai-coding-plan"],
267 ..ProviderConfig::openai_compatible("zai", "ZAi", "zai", "ZAI", "ZAI_API_KEY")
268 },
269 ProviderConfig {
270 genai_provider_name: "aws.bedrock",
271 use_model_transport: true,
272 is_hybrid_dynamic: true,
273 ..ProviderConfig::standard("amazon-bedrock", "Bedrock", "bedrock", "AWS Bedrock", None)
274 },
275];
276
277const DYNAMIC_PROVIDERS: &[DynamicProviderConfig] = &[
278 DynamicProviderConfig {
279 enum_name: "Ollama",
280 parser_name: "ollama",
281 genai_provider_name: "ollama",
282 display_name: "Ollama",
283 },
284 DynamicProviderConfig {
285 enum_name: "LlamaCpp",
286 parser_name: "llamacpp",
287 genai_provider_name: "llama.cpp",
288 display_name: "LlamaCpp",
289 },
290];
291
292const CODEX_SUBSCRIPTION_CONTEXT_WINDOW: u32 = 272_000;
293
294const CODEX_SUBSCRIPTION_MODELS: &[ExplicitModel] = &[
295 ExplicitModel {
296 id: "gpt-6-astra",
297 context_window: CODEX_SUBSCRIPTION_CONTEXT_WINDOW,
298 supports_reasoning_off: false,
299 },
300 ExplicitModel {
301 id: "gpt-5.6-sol",
302 context_window: CODEX_SUBSCRIPTION_CONTEXT_WINDOW,
303 supports_reasoning_off: false,
304 },
305 ExplicitModel {
306 id: "gpt-5.6-terra",
307 context_window: CODEX_SUBSCRIPTION_CONTEXT_WINDOW,
308 supports_reasoning_off: false,
309 },
310 ExplicitModel {
311 id: "gpt-5.6-luna",
312 context_window: CODEX_SUBSCRIPTION_CONTEXT_WINDOW,
313 supports_reasoning_off: false,
314 },
315 ExplicitModel { id: "gpt-5.5", context_window: CODEX_SUBSCRIPTION_CONTEXT_WINDOW, supports_reasoning_off: false },
316 ExplicitModel { id: "gpt-5.4", context_window: CODEX_SUBSCRIPTION_CONTEXT_WINDOW, supports_reasoning_off: false },
317 ExplicitModel {
318 id: "gpt-5.4-mini",
319 context_window: CODEX_SUBSCRIPTION_CONTEXT_WINDOW,
320 supports_reasoning_off: false,
321 },
322 ExplicitModel { id: "gpt-5.2", context_window: CODEX_SUBSCRIPTION_CONTEXT_WINDOW, supports_reasoning_off: false },
323];
324
325#[derive(Debug, Clone)]
326struct ModelInfo {
327 variant_name: String,
328 model_id: String,
329 display_name: String,
330 context_window: u32,
331 reasoning_levels: Vec<String>,
332 disabled_support: &'static str,
333 input_modalities: Vec<String>,
334 pricing: Option<CostData>,
335 supports_prompt_caching: bool,
336 transport: Option<TransportInfo>,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
340enum TransportInfo {
341 OpenAiResponses { base_url_template: String },
342}
343
344type ProviderModels = BTreeMap<&'static str, Vec<ModelInfo>>;
345
346struct CodegenCtx {
347 provider_models: ProviderModels,
348}
349
350pub struct GeneratedOutput {
352 pub rust_source: String,
354 pub openai_compatible_provider_ids: Vec<&'static str>,
356 pub provider_docs: HashMap<String, String>,
361}
362
363#[derive(Debug, thiserror::Error)]
364pub enum CodegenError {
365 #[error("read: {0}")]
366 Read(#[from] std::io::Error),
367 #[error("parse: {0}")]
368 Parse(#[from] serde_json::Error),
369 #[error("Provider '{0}' not found in models.dev data")]
370 ProviderNotFound(String),
371 #[error("Configured model '{model_id}' was not found in provider '{provider_id}'")]
372 ConfiguredModelNotFound { provider_id: String, model_id: String },
373 #[error("Configured model '{model_id}' is duplicated for provider '{provider_id}'")]
374 DuplicateConfiguredModel { provider_id: String, model_id: String },
375 #[error("Configured model '{model_id}' is not tool-capable in provider '{provider_id}'")]
376 ConfiguredModelUnavailable { provider_id: String, model_id: String },
377 #[error("Model '{model_id}' declares unsupported reasoning effort '{effort}'")]
378 UnsupportedReasoningEffort { model_id: String, effort: String },
379 #[error("Model '{model_id}' declares unsupported wire shape '{shape}'")]
380 UnsupportedWireShape { model_id: String, shape: String },
381 #[error("Model '{model_id}' must declare both an endpoint and wire shape")]
382 IncompleteTransport { model_id: String },
383}
384
385pub fn generate(models_json_path: &Path) -> Result<GeneratedOutput, CodegenError> {
387 let json_bytes = std::fs::read_to_string(models_json_path)?;
388 let data: ModelsDevData = serde_json::from_str(&json_bytes)?;
389
390 let provider_models = build_provider_models(&data)?;
391 let ctx = CodegenCtx { provider_models };
392 let openai_compatible_provider_ids =
393 PROVIDERS.iter().filter(|config| config.uses_openai_compatible_api).map(|config| config.dev_id).collect();
394 Ok(GeneratedOutput {
395 rust_source: emit_generated_source(&ctx),
396 openai_compatible_provider_ids,
397 provider_docs: emit_provider_docs(&ctx),
398 })
399}
400
401fn build_provider_models(data: &ModelsDevData) -> Result<ProviderModels, CodegenError> {
402 let mut provider_models = ProviderModels::new();
403
404 for cfg in PROVIDERS {
405 let json_key = cfg.json_key();
406 let provider_data = data.get(json_key).ok_or_else(|| CodegenError::ProviderNotFound(json_key.to_string()))?;
407
408 validate_provider_config(cfg, provider_data)?;
409 let mut models: Vec<ModelInfo> = collect_models_from(cfg, &provider_data.models)?;
410
411 for &extra_key in cfg.extra_source_ids {
412 if let Some(extra_data) = data.get(extra_key) {
413 let extra = collect_models_from(cfg, &extra_data.models)?;
414 let existing_ids: std::collections::HashSet<String> =
415 models.iter().map(|m| m.model_id.clone()).collect();
416 models.extend(extra.into_iter().filter(|m| !existing_ids.contains(&m.model_id)));
417 }
418 }
419
420 models.sort_by(|a, b| a.model_id.cmp(&b.model_id));
421 provider_models.insert(cfg.dev_id, models);
422 }
423
424 Ok(provider_models)
425}
426
427fn validate_provider_config(cfg: &ProviderConfig, provider: &ProviderData) -> Result<(), CodegenError> {
428 let Some(explicit_models) = cfg.explicit_models else {
429 return Ok(());
430 };
431 let mut seen = HashSet::new();
432 for configured in explicit_models {
433 if !seen.insert(configured.id) {
434 return Err(CodegenError::DuplicateConfiguredModel {
435 provider_id: cfg.dev_id.to_string(),
436 model_id: configured.id.to_string(),
437 });
438 }
439 let Some(model) = provider.models.get(configured.id) else {
440 return Err(CodegenError::ConfiguredModelNotFound {
441 provider_id: cfg.dev_id.to_string(),
442 model_id: configured.id.to_string(),
443 });
444 };
445 if model.tool_call != Some(true) {
446 return Err(CodegenError::ConfiguredModelUnavailable {
447 provider_id: cfg.dev_id.to_string(),
448 model_id: configured.id.to_string(),
449 });
450 }
451 }
452 Ok(())
453}
454
455fn collect_models_from(
456 cfg: &ProviderConfig,
457 models: &HashMap<String, ModelData>,
458) -> Result<Vec<ModelInfo>, CodegenError> {
459 models
460 .values()
461 .filter(|m| m.tool_call == Some(true))
462 .filter(|m| !is_alias(&m.id))
463 .filter(|m| cfg.explicit_models.is_none() || cfg.explicit_model(&m.id).is_some())
464 .map(|m| {
465 let reasoning_levels =
466 if m.reasoning.unwrap_or(false) { reasoning_levels_for_model(cfg, m)? } else { Vec::new() };
467 let input_modalities =
468 m.modalities.as_ref().map_or_else(|| vec!["text".to_string()], |md| md.input.clone());
469 let source_context_window = m.limit.as_ref().map_or(0, |l| l.context);
470 let context_window =
471 cfg.explicit_model(&m.id).map_or(source_context_window, |explicit| explicit.context_window);
472 let pricing = if cfg.dev_id == "codex" { None } else { m.cost.clone() };
473 Ok(ModelInfo {
474 variant_name: model_id_to_variant(&m.id),
475 model_id: m.id.clone(),
476 display_name: m.name.clone(),
477 context_window,
478 reasoning_levels,
479 disabled_support: disabled_support_for_model(cfg, m),
480 input_modalities,
481 supports_prompt_caching: m.cost.as_ref().is_some_and(CostData::has_prompt_caching),
482 pricing,
483 transport: transport_for_model(cfg, m)?,
484 })
485 })
486 .collect()
487}
488
489fn transport_for_model(cfg: &ProviderConfig, model: &ModelData) -> Result<Option<TransportInfo>, CodegenError> {
490 if !cfg.use_model_transport {
491 return Ok(None);
492 }
493 let Some(provider) = &model.provider else {
494 return Ok(None);
495 };
496
497 match (&provider.api, provider.shape.as_deref()) {
498 (None, None) => Ok(None),
499 (Some(base_url_template), Some("responses")) => {
500 Ok(Some(TransportInfo::OpenAiResponses { base_url_template: base_url_template.clone() }))
501 }
502 (_, Some(shape)) if shape != "responses" => {
503 Err(CodegenError::UnsupportedWireShape { model_id: model.id.clone(), shape: shape.to_string() })
504 }
505 _ => Err(CodegenError::IncompleteTransport { model_id: model.id.clone() }),
506 }
507}
508
509fn disabled_support_for_model(cfg: &ProviderConfig, model: &ModelData) -> &'static str {
510 if !model.reasoning.unwrap_or(false) {
511 return "Unsupported";
512 }
513 if let Some(explicit) = cfg.explicit_model(&model.id) {
514 return if explicit.supports_reasoning_off { "Effort" } else { "Unsupported" };
515 }
516 if model.reasoning_options.iter().any(|option| {
517 matches!(option,
518 ReasoningOption::Effort { values } if values.iter().any(|value| value.as_deref() == Some("none"))
519 )
520 }) {
521 "Effort"
522 } else if model.reasoning_options.iter().any(|option| matches!(option, ReasoningOption::Toggle)) {
523 "Toggle"
524 } else {
525 "Unsupported"
526 }
527}
528
529fn reasoning_levels_for_model(cfg: &ProviderConfig, model: &ModelData) -> Result<Vec<String>, CodegenError> {
530 let values = model
531 .reasoning_options
532 .iter()
533 .filter_map(|option| match option {
534 ReasoningOption::Effort { values } => Some(values),
535 ReasoningOption::Toggle | ReasoningOption::BudgetTokens => None,
536 })
537 .collect::<Vec<_>>();
538 let mut levels = Vec::new();
539 if values.is_empty() {
540 levels.extend(cfg.fallback_reasoning_levels.iter().map(|level| (*level).to_string()));
541 } else {
542 for effort in values.into_iter().flatten().filter_map(|value| value.as_deref()) {
543 if matches!(effort, "none" | "default") {
544 continue;
545 }
546 let parsed =
547 effort.parse::<utils::ReasoningEffort>().ok().filter(|effort| effort.is_enabled()).ok_or_else(
548 || CodegenError::UnsupportedReasoningEffort {
549 model_id: model.id.clone(),
550 effort: effort.to_string(),
551 },
552 )?;
553 levels.push(parsed.as_str().to_string());
554 }
555 }
556 if disabled_support_for_model(cfg, model) != "Unsupported" {
557 levels.push("disabled".to_string());
558 }
559 Ok(utils::ReasoningEffort::selectable_levels()
560 .iter()
561 .filter(|level| levels.iter().any(|value| value == level.as_str()))
562 .map(|level| level.as_str().to_string())
563 .collect())
564}
565
566fn is_alias(id: &str) -> bool {
568 id.ends_with("-latest")
569}
570
571fn model_id_to_variant(id: &str) -> String {
574 let mut result = String::new();
575 let mut capitalize_next = true;
576
577 for ch in id.chars() {
578 if ch == '-' || ch == '.' || ch == '/' || ch == ':' {
579 capitalize_next = true;
580 } else if capitalize_next {
581 result.push(ch.to_ascii_uppercase());
582 capitalize_next = false;
583 } else {
584 result.push(ch);
585 }
586 }
587
588 if result.starts_with(|c: char| c.is_ascii_digit()) {
589 result.insert(0, '_');
590 }
591
592 result
593}
594
595fn emit_generated_source(ctx: &CodegenCtx) -> String {
596 let provider_enum = emit_provider_enum();
597 let provider_enum_impl = emit_provider_enum_impl();
598 let provider_enum_display = emit_provider_enum_display();
599 let provider_enum_fromstr = emit_provider_enum_fromstr();
600 let provider_enums = emit_provider_enums(&ctx.provider_models);
601 let provider_impls = emit_provider_impls(&ctx.provider_models);
602 let llm_model_enum = emit_llm_model_enum();
603 let from_impls = emit_from_impls();
604 let llm_model_impl = emit_llm_model_impl();
605 let display_impl = emit_display_impl();
606 let fromstr_impl = emit_fromstr_impl();
607
608 let file_tokens = quote! {
609 use std::borrow::Cow;
610 use std::sync::LazyLock;
611 use crate::ReasoningEffort;
612
613 #provider_enum
614 #provider_enum_impl
615 #provider_enum_display
616 #provider_enum_fromstr
617 #provider_enums
618 #provider_impls
619 #llm_model_enum
620 #from_impls
621 #llm_model_impl
622 #display_impl
623 #fromstr_impl
624 };
625
626 let file: syn::File = syn::parse2(file_tokens).expect("generated tokens parse as Rust");
627 let formatted = prettyplease::unparse(&file);
628 format!(
629 "// Auto-generated from models.dev — do not edit manually\n// Regenerated automatically by build.rs\n\n{formatted}"
630 )
631}
632
633fn emit_provider_enum() -> TokenStream {
634 let catalog_variants = PROVIDERS.iter().map(|cfg| format_ident!("{}", cfg.enum_name));
635 let dynamic_variants = DYNAMIC_PROVIDERS.iter().map(|d| format_ident!("{}", d.enum_name));
636 quote! {
637 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
641 pub enum Provider {
642 #(#catalog_variants,)*
643 #(#dynamic_variants,)*
644 }
645 }
646}
647
648fn emit_provider_enum_impl() -> TokenStream {
649 let parser_arms = provider_match_arms(|cfg| cfg.parser_name, |d| d.parser_name);
650 let genai_provider_name_arms = provider_match_arms(|cfg| cfg.genai_provider_name, |d| d.genai_provider_name);
651 let display_arms = provider_match_arms(|cfg| cfg.display_name, |d| d.display_name);
652
653 let env_var_some = PROVIDERS.iter().filter_map(|cfg| {
654 cfg.env_var.map(|var| {
655 let v = format_ident!("{}", cfg.enum_name);
656 quote! { Self::#v => Some(#var), }
657 })
658 });
659
660 let env_var_none = provider_or_pats(|cfg| cfg.env_var.is_none(), |_| true);
661 let oauth_some = PROVIDERS.iter().filter_map(|cfg| {
662 cfg.oauth_provider_id.map(|id| {
663 let v = format_ident!("{}", cfg.enum_name);
664 quote! { Self::#v => Some(#id), }
665 })
666 });
667 let oauth_none = provider_or_pats(|cfg| cfg.oauth_provider_id.is_none(), |_| true);
668
669 let is_local_true = provider_or_pats(|_| false, |_| true);
670 let is_local_false = provider_or_pats(|_| true, |_| false);
671 let all_variants = PROVIDERS
672 .iter()
673 .map(|cfg| format_ident!("{}", cfg.enum_name))
674 .chain(DYNAMIC_PROVIDERS.iter().map(|d| format_ident!("{}", d.enum_name)));
675
676 quote! {
677 impl Provider {
678 pub const ALL: &[Provider] = &[#(Self::#all_variants),*];
680
681 pub fn parser_name(self) -> &'static str {
683 match self { #parser_arms }
684 }
685
686 #[allow(clippy::match_same_arms)]
688 pub fn genai_provider_name(self) -> &'static str {
689 match self { #genai_provider_name_arms }
690 }
691
692 pub fn display_name(self) -> &'static str {
694 match self { #display_arms }
695 }
696
697 pub fn required_env_var(self) -> Option<&'static str> {
699 match self {
700 #(#env_var_some)*
701 #env_var_none => None,
702 }
703 }
704
705 pub fn oauth_provider_id(self) -> Option<&'static str> {
707 match self {
708 #(#oauth_some)*
709 #oauth_none => None,
710 }
711 }
712
713 pub fn is_local(self) -> bool {
716 match self {
717 #is_local_true => true,
718 #is_local_false => false,
719 }
720 }
721 }
722 }
723}
724
725fn emit_provider_enum_display() -> TokenStream {
726 quote! {
727 impl std::fmt::Display for Provider {
728 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
729 f.write_str(self.parser_name())
730 }
731 }
732 }
733}
734
735fn emit_provider_enum_fromstr() -> TokenStream {
736 let catalog_arms = PROVIDERS.iter().map(|cfg| {
737 let v = format_ident!("{}", cfg.enum_name);
738 let name = cfg.parser_name;
739 quote! { #name => Ok(Self::#v), }
740 });
741
742 let dynamic_arms = DYNAMIC_PROVIDERS.iter().map(|d| {
743 let v = format_ident!("{}", d.enum_name);
744 let name = d.parser_name;
745 quote! { #name => Ok(Self::#v), }
746 });
747
748 quote! {
749 impl std::str::FromStr for Provider {
750 type Err = String;
751 fn from_str(s: &str) -> Result<Self, Self::Err> {
752 match s {
753 #(#catalog_arms)*
754 #(#dynamic_arms)*
755 other => Err(format!("Unknown provider: '{other}'")),
756 }
757 }
758 }
759 }
760}
761
762fn emit_provider_enums(provider_models: &ProviderModels) -> TokenStream {
763 let enums = PROVIDERS.iter().map(|cfg| {
764 let inner = format_ident!("{}", cfg.inner_enum_name());
765 let variants = provider_models[cfg.dev_id].iter().map(|m| format_ident!("{}", m.variant_name));
766 quote! {
767 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
768 pub enum #inner {
769 #(#variants,)*
770 }
771 }
772 });
773 quote! { #(#enums)* }
774}
775
776fn emit_provider_impls(provider_models: &ProviderModels) -> TokenStream {
777 let impls = PROVIDERS.iter().map(|cfg| {
778 let models = &provider_models[cfg.dev_id];
779 let enum_ident = format_ident!("{}", cfg.inner_enum_name());
780
781 let model_id_arms = models.iter().map(|m| {
782 let v = format_ident!("{}", m.variant_name);
783 let id = &m.model_id;
784 quote! { Self::#v => #id, }
785 });
786
787 let display_name_arms = grouped_arms(
788 models,
789 |m| m.display_name.clone(),
790 |m| {
791 let s = &m.display_name;
792 quote! { #s }
793 },
794 );
795
796 let context_window_arms =
797 grouped_arms(models, |m| m.context_window, |m| num_lit_with_underscores(m.context_window));
798
799 let reasoning_levels_arms = emit_reasoning_levels_arms(models);
800 let disabled_support_arms = grouped_arms(
801 models,
802 |m| m.disabled_support,
803 |m| {
804 let variant = format_ident!("{}", m.disabled_support);
805 quote! { crate::reasoning::ReasoningDisabledSupport::#variant }
806 },
807 );
808
809 let prompt_caching_arms = grouped_arms(
810 models,
811 |m| m.supports_prompt_caching,
812 |m| {
813 let b = m.supports_prompt_caching;
814 quote! { #b }
815 },
816 );
817
818 let pricing_arms = emit_pricing_arms(models);
819
820 let modality_methods = ["image", "audio"].iter().map(|modality| {
821 let method = format_ident!("supports_{}", modality);
822 let mod_owned = (*modality).to_string();
823 let arms = grouped_arms(models, move |m| m.input_modalities.contains(&mod_owned), {
824 let mod_owned = (*modality).to_string();
825 move |m| {
826 let b = m.input_modalities.contains(&mod_owned);
827 quote! { #b }
828 }
829 });
830 quote! {
831 #[allow(clippy::too_many_lines)]
832 pub fn #method(self) -> bool {
833 match self { #arms }
834 }
835 }
836 });
837
838 let transport_arms = emit_transport_arms(models);
839
840 let all_variants = models.iter().map(|m| format_ident!("{}", m.variant_name));
841
842 let from_str_impl = emit_from_str_impl(&enum_ident, cfg.parser_name, models);
843
844 quote! {
845 impl #enum_ident {
846 #[allow(clippy::too_many_lines)]
847 fn model_id(self) -> &'static str {
848 match self { #(#model_id_arms)* }
849 }
850
851 #[allow(clippy::too_many_lines)]
852 fn display_name(self) -> &'static str {
853 match self { #display_name_arms }
854 }
855
856 #[allow(clippy::too_many_lines)]
857 fn context_window(self) -> u32 {
858 match self { #context_window_arms }
859 }
860
861 #[allow(clippy::too_many_lines)]
862 pub fn reasoning_levels(self) -> &'static [ReasoningEffort] {
863 match self { #reasoning_levels_arms }
864 }
865
866 #[allow(clippy::too_many_lines)]
867 pub fn reasoning_disabled_support(self) -> crate::reasoning::ReasoningDisabledSupport {
868 match self { #disabled_support_arms }
869 }
870
871 pub fn supports_reasoning(self) -> bool {
872 self.reasoning_levels().iter().any(|effort| effort.is_enabled())
873 }
874
875 #[allow(clippy::too_many_lines)]
876 pub fn supports_prompt_caching(self) -> bool {
877 match self { #prompt_caching_arms }
878 }
879
880 #[allow(clippy::too_many_lines, clippy::match_same_arms, clippy::unreadable_literal)]
881 pub fn pricing(self) -> Option<ModelPricing> {
882 match self { #pricing_arms }
883 }
884
885 #(#modality_methods)*
886
887 #[allow(clippy::too_many_lines)]
888 pub fn transport(self) -> Option<ModelTransport> {
889 match self { #transport_arms }
890 }
891
892 const ALL: &[#enum_ident] = &[#(Self::#all_variants),*];
893 }
894
895 #from_str_impl
896 }
897 });
898 quote! { #(#impls)* }
899}
900
901fn emit_pricing_arms(models: &[ModelInfo]) -> TokenStream {
902 let arms = models.iter().map(|model| {
903 let variant = format_ident!("{}", model.variant_name);
904 let Some(pricing) = &model.pricing else {
905 return quote! { Self::#variant => None, };
906 };
907 let input = pricing.input;
908 let output = pricing.output;
909 let cache_read = pricing.cache_read.map_or_else(|| quote! { None }, |value| quote! { Some(#value) });
910 let cache_write = pricing.cache_write.map_or_else(|| quote! { None }, |value| quote! { Some(#value) });
911 quote! {
912 Self::#variant => Some(ModelPricing {
913 input_per_million: #input,
914 output_per_million: #output,
915 cache_read_per_million: #cache_read,
916 cache_write_per_million: #cache_write,
917 }),
918 }
919 });
920 quote! { #(#arms)* }
921}
922
923fn emit_from_str_impl(enum_ident: &proc_macro2::Ident, parser_name: &str, models: &[ModelInfo]) -> TokenStream {
924 let arms = models.iter().map(|m| {
925 let id = &m.model_id;
926 let v = format_ident!("{}", m.variant_name);
927 quote! { #id => Ok(Self::#v), }
928 });
929 let err_msg = format!("Unknown {parser_name} model: '{{s}}'");
930 quote! {
931 impl std::str::FromStr for #enum_ident {
932 type Err = String;
933
934 #[allow(clippy::too_many_lines)]
935 fn from_str(s: &str) -> Result<Self, Self::Err> {
936 match s {
937 #(#arms)*
938 _ => Err(format!(#err_msg)),
939 }
940 }
941 }
942 }
943}
944
945fn grouped_arms<K, R>(
947 models: &[ModelInfo],
948 key_fn: impl Fn(&ModelInfo) -> K,
949 rhs_fn: impl Fn(&ModelInfo) -> R,
950) -> TokenStream
951where
952 K: Eq + Ord,
953 R: ToTokens,
954{
955 let mut groups: BTreeMap<K, Vec<&ModelInfo>> = BTreeMap::new();
956 for m in models {
957 groups.entry(key_fn(m)).or_default().push(m);
958 }
959 let arms = groups.values().map(|members| {
960 let pats = members.iter().map(|m| {
961 let v = format_ident!("{}", m.variant_name);
962 quote! { Self::#v }
963 });
964 let rhs = rhs_fn(members[0]);
965 quote! { #(#pats)|* => #rhs, }
966 });
967 quote! { #(#arms)* }
968}
969
970fn emit_reasoning_levels_arms(models: &[ModelInfo]) -> TokenStream {
971 grouped_arms(
972 models,
973 |m| m.reasoning_levels.clone(),
974 |m| {
975 if m.reasoning_levels.is_empty() {
976 quote! { &[] }
977 } else {
978 let items = m.reasoning_levels.iter().map(|l| {
979 let variant = format_ident!("{}", level_str_to_variant(l));
980 quote! { ReasoningEffort::#variant }
981 });
982 quote! { &[#(#items),*] }
983 }
984 },
985 )
986}
987
988fn emit_transport_arms(models: &[ModelInfo]) -> TokenStream {
989 grouped_arms(
990 models,
991 |m| m.transport.clone(),
992 |m| match m.transport.as_ref() {
993 Some(TransportInfo::OpenAiResponses { base_url_template }) => {
994 quote! { Some(ModelTransport::OpenAiResponses { base_url_template: #base_url_template }) }
995 }
996 None => quote! { None },
997 },
998 )
999}
1000
1001fn level_str_to_variant(level: &str) -> String {
1004 let canonical =
1005 level.parse::<utils::ReasoningEffort>().unwrap_or_else(|_| panic!("Unknown reasoning level: {level}")).as_str();
1006 let mut variant = canonical.to_string();
1007 variant[..1].make_ascii_uppercase();
1008 variant
1009}
1010
1011fn emit_llm_model_enum() -> TokenStream {
1012 let catalog_variants = PROVIDERS.iter().map(|cfg| {
1013 let v = format_ident!("{}", cfg.enum_name);
1014 let inner = format_ident!("{}Model", cfg.enum_name);
1015 quote! { #v(#inner) }
1016 });
1017 let dynamic_variants = DYNAMIC_PROVIDERS.iter().map(|d| {
1018 let v = format_ident!("{}", d.enum_name);
1019 quote! { #v(String) }
1020 });
1021 quote! {
1022 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
1024 pub enum LlmModel {
1025 #(#catalog_variants,)*
1026 #(#dynamic_variants,)*
1027 }
1028 }
1029}
1030
1031fn emit_from_impls() -> TokenStream {
1032 let impls = PROVIDERS.iter().map(|cfg| {
1033 let outer = format_ident!("{}Model", cfg.enum_name);
1034 let v = format_ident!("{}", cfg.enum_name);
1035 quote! {
1036 impl From<#outer> for LlmModel {
1037 fn from(m: #outer) -> Self {
1038 LlmModel::#v(m)
1039 }
1040 }
1041 }
1042 });
1043 quote! { #(#impls)* }
1044}
1045
1046fn emit_llm_model_impl() -> TokenStream {
1047 let model_id = emit_llm_model_id();
1048 let display_name = emit_llm_display_name();
1049 let provider = emit_llm_provider();
1050 let provider_enum = emit_llm_provider_enum();
1051 let provider_display_name = emit_llm_provider_display_name();
1052 let context_window = emit_llm_context_window();
1053 let required_env_var = emit_llm_required_env_var();
1054 let all_required_env_vars = emit_llm_all_required_env_vars();
1055 let oauth_provider_id = emit_llm_oauth_provider_id();
1056 let reasoning_levels = emit_llm_reasoning_levels();
1057 let disabled_support = llm_delegate_with_dynamic_default(
1058 "reasoning_disabled_support",
1059 "e! { crate::reasoning::ReasoningDisabledSupport::Unsupported },
1060 );
1061 let supports_reasoning = emit_llm_supports_reasoning();
1062 let supports_prompt_caching = emit_llm_supports_prompt_caching();
1063 let pricing = emit_llm_pricing();
1064 let modality_methods = ["image", "audio"].iter().map(|m| emit_llm_supports_modality(m));
1065 let transport = emit_llm_transport();
1066 let all = emit_llm_all();
1067
1068 quote! {
1069 impl LlmModel {
1070 #model_id
1071 #display_name
1072 #provider
1073 #provider_enum
1074 #provider_display_name
1075 #context_window
1076 #required_env_var
1077 #all_required_env_vars
1078 #oauth_provider_id
1079 #reasoning_levels
1080 pub fn reasoning_disabled_support(&self) -> crate::reasoning::ReasoningDisabledSupport {
1081 #disabled_support
1082 }
1083
1084 pub fn supports_reasoning_off(&self) -> bool {
1086 self.reasoning_levels().contains(&ReasoningEffort::Disabled)
1087 }
1088
1089 pub fn supports_reasoning_off_transport(&self) -> bool {
1091 if !self.supports_reasoning_off() {
1092 return false;
1093 }
1094 match self.provider_enum() {
1095 Provider::Anthropic | Provider::OpenRouter | Provider::Openai | Provider::Codex | Provider::Gemini => true,
1096 Provider::Bedrock => self.transport().is_some(),
1097 Provider::DeepSeek | Provider::Moonshot | Provider::ZAi | Provider::AzureFoundry | Provider::Fireworks => {
1098 self.reasoning_disabled_support() == crate::reasoning::ReasoningDisabledSupport::Effort
1099 }
1100 Provider::Ollama | Provider::LlamaCpp => false,
1101 }
1102 }
1103
1104 pub fn effective_reasoning_levels(&self) -> Vec<ReasoningEffort> {
1106 self.reasoning_levels()
1107 .iter()
1108 .copied()
1109 .filter(|effort| *effort != ReasoningEffort::Disabled || self.supports_reasoning_off_transport())
1110 .collect()
1111 }
1112
1113 pub fn validate_reasoning_effort(&self, effort: ReasoningEffort) -> Result<(), crate::catalog::ReasoningEffortError> {
1114 let supported = self.effective_reasoning_levels();
1115 if effort == ReasoningEffort::Default || supported.contains(&effort) {
1116 Ok(())
1117 } else {
1118 Err(crate::catalog::ReasoningEffortError::Unsupported { model: self.to_string(), effort, supported })
1119 }
1120 }
1121
1122 #supports_reasoning
1123 #supports_prompt_caching
1124 #pricing
1125 #(#modality_methods)*
1126 #transport
1127 #all
1128 }
1129 }
1130}
1131
1132fn emit_llm_model_id() -> TokenStream {
1133 let catalog_arms = PROVIDERS.iter().map(|cfg| {
1134 let v = format_ident!("{}", cfg.enum_name);
1135 if cfg.is_hybrid_dynamic {
1136 quote! { Self::#v(m) => m.model_id(), }
1137 } else {
1138 quote! { Self::#v(m) => Cow::Borrowed(m.model_id()), }
1139 }
1140 });
1141 let dyn_pats = dynamic_pattern_with_binding("s");
1142 quote! {
1143 pub fn model_id(&self) -> Cow<'static, str> {
1145 match self {
1146 #(#catalog_arms)*
1147 #dyn_pats => Cow::Owned(s.clone()),
1148 }
1149 }
1150 }
1151}
1152
1153fn emit_llm_display_name() -> TokenStream {
1154 let catalog_arms = PROVIDERS.iter().map(|cfg| {
1155 let v = format_ident!("{}", cfg.enum_name);
1156 if cfg.is_hybrid_dynamic {
1157 quote! { Self::#v(m) => m.display_name(), }
1158 } else {
1159 quote! { Self::#v(m) => Cow::Borrowed(m.display_name()), }
1160 }
1161 });
1162 let dyn_arms = DYNAMIC_PROVIDERS.iter().map(|d| {
1163 let v = format_ident!("{}", d.enum_name);
1164 let fmt = format!("{} {{s}}", d.enum_name);
1165 quote! { Self::#v(s) => Cow::Owned(format!(#fmt)), }
1166 });
1167 quote! {
1168 pub fn display_name(&self) -> Cow<'static, str> {
1170 match self {
1171 #(#catalog_arms)*
1172 #(#dyn_arms)*
1173 }
1174 }
1175 }
1176}
1177
1178fn emit_llm_provider() -> TokenStream {
1179 let arms = llm_match_arms_ignored(|cfg| cfg.parser_name, |d| d.parser_name);
1180 quote! {
1181 pub fn provider(&self) -> &'static str {
1183 match self { #arms }
1184 }
1185 }
1186}
1187
1188fn emit_llm_provider_enum() -> TokenStream {
1189 let arms = llm_match_arms_ignored(
1190 |cfg| {
1191 let v = format_ident!("{}", cfg.enum_name);
1192 quote! { Provider::#v }
1193 },
1194 |d| {
1195 let v = format_ident!("{}", d.enum_name);
1196 quote! { Provider::#v }
1197 },
1198 );
1199 quote! {
1200 pub fn provider_enum(&self) -> Provider {
1202 match self { #arms }
1203 }
1204 }
1205}
1206
1207fn emit_llm_provider_display_name() -> TokenStream {
1208 let arms = llm_match_arms_ignored(|cfg| cfg.display_name, |d| d.display_name);
1209 quote! {
1210 pub fn provider_display_name(&self) -> &'static str {
1212 match self { #arms }
1213 }
1214 }
1215}
1216
1217fn emit_llm_context_window() -> TokenStream {
1218 let catalog_arms = PROVIDERS.iter().map(|cfg| {
1219 let v = format_ident!("{}", cfg.enum_name);
1220 if cfg.is_hybrid_dynamic {
1221 quote! { Self::#v(m) => m.context_window(), }
1222 } else {
1223 quote! { Self::#v(m) => Some(m.context_window()), }
1224 }
1225 });
1226 let dyn_pats = dynamic_pattern_with_binding("_");
1227 quote! {
1228 pub fn context_window(&self) -> Option<u32> {
1230 match self {
1231 #(#catalog_arms)*
1232 #dyn_pats => None,
1233 }
1234 }
1235 }
1236}
1237
1238fn emit_llm_required_env_var() -> TokenStream {
1239 let some_arms = PROVIDERS.iter().filter_map(|cfg| {
1240 cfg.env_var.map(|var| {
1241 let v = format_ident!("{}", cfg.enum_name);
1242 quote! { Self::#v(_) => Some(#var), }
1243 })
1244 });
1245 let none_pats = llm_or_pats(|cfg| cfg.env_var.is_none(), |_| true);
1246 quote! {
1247 pub fn required_env_var(&self) -> Option<&'static str> {
1249 match self {
1250 #(#some_arms)*
1251 #none_pats => None,
1252 }
1253 }
1254 }
1255}
1256
1257fn emit_llm_all_required_env_vars() -> TokenStream {
1258 let vars = PROVIDERS.iter().filter_map(|cfg| cfg.env_var);
1259 quote! {
1260 pub const ALL_REQUIRED_ENV_VARS: &[&str] = &[#(#vars),*];
1262 }
1263}
1264
1265fn emit_llm_oauth_provider_id() -> TokenStream {
1266 let some_arms = PROVIDERS.iter().filter_map(|cfg| {
1267 cfg.oauth_provider_id.map(|id| {
1268 let v = format_ident!("{}", cfg.enum_name);
1269 quote! { Self::#v(_) => Some(#id), }
1270 })
1271 });
1272 let none_pats = llm_or_pats(|cfg| cfg.oauth_provider_id.is_none(), |_| true);
1273 quote! {
1274 pub fn oauth_provider_id(&self) -> Option<&'static str> {
1276 match self {
1277 #(#some_arms)*
1278 #none_pats => None,
1279 }
1280 }
1281 }
1282}
1283
1284fn emit_llm_reasoning_levels() -> TokenStream {
1285 let body = llm_delegate_with_dynamic_default("reasoning_levels", "e! { &[] });
1286 quote! {
1287 pub fn reasoning_levels(&self) -> &'static [ReasoningEffort] {
1289 #body
1290 }
1291 }
1292}
1293
1294fn emit_llm_supports_reasoning() -> TokenStream {
1295 quote! {
1296 pub fn supports_reasoning(&self) -> bool {
1298 self.reasoning_levels().iter().any(|effort| effort.is_enabled())
1299 }
1300 }
1301}
1302
1303fn emit_llm_supports_prompt_caching() -> TokenStream {
1304 let body = llm_delegate_with_dynamic_default("supports_prompt_caching", "e! { false });
1305 quote! {
1306 pub fn supports_prompt_caching(&self) -> bool {
1308 #body
1309 }
1310 }
1311}
1312
1313fn emit_llm_pricing() -> TokenStream {
1314 let body = llm_delegate_with_dynamic_default("pricing", "e! { None });
1315 quote! {
1316 pub fn pricing(&self) -> Option<ModelPricing> {
1317 #body
1318 }
1319 }
1320}
1321
1322fn emit_llm_transport() -> TokenStream {
1323 let body = llm_delegate_with_dynamic_default("transport", "e! { None });
1324 quote! {
1325 pub fn transport(&self) -> Option<ModelTransport> {
1328 #body
1329 }
1330 }
1331}
1332
1333fn emit_llm_supports_modality(modality: &str) -> TokenStream {
1334 let method = format!("supports_{modality}");
1335 let method_ident = format_ident!("{}", method);
1336 let doc = format!(" Whether this model supports {modality} input");
1337 let body = llm_delegate_with_dynamic_default(&method, "e! { false });
1338 quote! {
1339 #[doc = #doc]
1340 pub fn #method_ident(&self) -> bool {
1341 #body
1342 }
1343 }
1344}
1345
1346fn emit_llm_all() -> TokenStream {
1347 let pushes = PROVIDERS.iter().map(|cfg| {
1348 let inner = format_ident!("{}", cfg.inner_enum_name());
1349 let outer = format_ident!("{}", cfg.outer_enum_name());
1350 let v = format_ident!("{}", cfg.enum_name);
1351 if cfg.is_hybrid_dynamic {
1352 quote! {
1353 v.extend(#inner::ALL.iter().copied().map(#outer::Foundation).map(LlmModel::#v));
1354 }
1355 } else {
1356 quote! {
1357 v.extend(#inner::ALL.iter().copied().map(LlmModel::#v));
1358 }
1359 }
1360 });
1361 quote! {
1362 pub fn all() -> &'static [LlmModel] {
1364 static ALL: LazyLock<Vec<LlmModel>> = LazyLock::new(|| {
1365 let mut v = Vec::new();
1366 #(#pushes)*
1367 v
1368 });
1369 &ALL
1370 }
1371 }
1372}
1373
1374fn emit_display_impl() -> TokenStream {
1375 quote! {
1376 impl std::fmt::Display for LlmModel {
1377 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1378 write!(f, "{}:{}", self.provider(), self.model_id())
1379 }
1380 }
1381 }
1382}
1383
1384fn emit_fromstr_impl() -> TokenStream {
1385 let catalog_arms = PROVIDERS.iter().map(|cfg| {
1386 let name = cfg.parser_name;
1387 let outer = format_ident!("{}Model", cfg.enum_name);
1388 let v = format_ident!("{}", cfg.enum_name);
1389 quote! { #name => model_str.parse::<#outer>().map(Self::#v), }
1390 });
1391 let dyn_arms = DYNAMIC_PROVIDERS.iter().map(|d| {
1392 let name = d.parser_name;
1393 let v = format_ident!("{}", d.enum_name);
1394 quote! { #name => Ok(Self::#v(model_str.to_string())), }
1395 });
1396 quote! {
1397 impl std::str::FromStr for LlmModel {
1398 type Err = String;
1399
1400 fn from_str(s: &str) -> Result<Self, Self::Err> {
1402 let (provider_str, model_str) = s.split_once(':').unwrap_or((s, ""));
1403 match provider_str {
1404 #(#catalog_arms)*
1405 #(#dyn_arms)*
1406 _ => Err(format!("Unknown provider: '{provider_str}'")),
1407 }
1408 }
1409 }
1410 }
1411}
1412
1413fn dynamic_pattern_with_binding(binding: &str) -> TokenStream {
1415 let binding_ident = if binding == "_" {
1416 quote! { _ }
1417 } else {
1418 let b = format_ident!("{}", binding);
1419 quote! { #b }
1420 };
1421 let pats = DYNAMIC_PROVIDERS.iter().map(|d| {
1422 let v = format_ident!("{}", d.enum_name);
1423 quote! { Self::#v(#binding_ident) }
1424 });
1425 quote! { #(#pats)|* }
1426}
1427
1428fn provider_match_arms<V: ToTokens>(
1432 catalog_value: impl Fn(&ProviderConfig) -> V,
1433 dynamic_value: impl Fn(&DynamicProviderConfig) -> V,
1434) -> TokenStream {
1435 let catalog = PROVIDERS.iter().map(|cfg| {
1436 let v = format_ident!("{}", cfg.enum_name);
1437 let val = catalog_value(cfg);
1438 quote! { Self::#v => #val, }
1439 });
1440 let dynamic = DYNAMIC_PROVIDERS.iter().map(|d| {
1441 let v = format_ident!("{}", d.enum_name);
1442 let val = dynamic_value(d);
1443 quote! { Self::#v => #val, }
1444 });
1445 quote! { #(#catalog)* #(#dynamic)* }
1446}
1447
1448fn provider_or_pats(
1451 include_catalog: impl Fn(&ProviderConfig) -> bool,
1452 include_dynamic: impl Fn(&DynamicProviderConfig) -> bool,
1453) -> TokenStream {
1454 let catalog = PROVIDERS.iter().filter(|cfg| include_catalog(cfg)).map(|cfg| {
1455 let v = format_ident!("{}", cfg.enum_name);
1456 quote! { Self::#v }
1457 });
1458 let dynamic = DYNAMIC_PROVIDERS.iter().filter(|d| include_dynamic(d)).map(|d| {
1459 let v = format_ident!("{}", d.enum_name);
1460 quote! { Self::#v }
1461 });
1462 let pats = catalog.chain(dynamic);
1463 quote! { #(#pats)|* }
1464}
1465
1466fn llm_match_arms_ignored<V: ToTokens>(
1469 catalog_value: impl Fn(&ProviderConfig) -> V,
1470 dynamic_value: impl Fn(&DynamicProviderConfig) -> V,
1471) -> TokenStream {
1472 let catalog = PROVIDERS.iter().map(|cfg| {
1473 let v = format_ident!("{}", cfg.enum_name);
1474 let val = catalog_value(cfg);
1475 quote! { Self::#v(_) => #val, }
1476 });
1477 let dynamic = DYNAMIC_PROVIDERS.iter().map(|d| {
1478 let v = format_ident!("{}", d.enum_name);
1479 let val = dynamic_value(d);
1480 quote! { Self::#v(_) => #val, }
1481 });
1482 quote! { #(#catalog)* #(#dynamic)* }
1483}
1484
1485fn llm_or_pats(
1488 include_catalog: impl Fn(&ProviderConfig) -> bool,
1489 include_dynamic: impl Fn(&DynamicProviderConfig) -> bool,
1490) -> TokenStream {
1491 let catalog = PROVIDERS.iter().filter(|cfg| include_catalog(cfg)).map(|cfg| {
1492 let v = format_ident!("{}", cfg.enum_name);
1493 quote! { Self::#v(_) }
1494 });
1495 let dynamic = DYNAMIC_PROVIDERS.iter().filter(|d| include_dynamic(d)).map(|d| {
1496 let v = format_ident!("{}", d.enum_name);
1497 quote! { Self::#v(_) }
1498 });
1499 let pats = catalog.chain(dynamic);
1500 quote! { #(#pats)|* }
1501}
1502
1503fn llm_delegate_with_dynamic_default(method: &str, dynamic_value: &TokenStream) -> TokenStream {
1507 let method_ident = format_ident!("{}", method);
1508 let catalog_arms = PROVIDERS.iter().map(|cfg| {
1509 let v = format_ident!("{}", cfg.enum_name);
1510 quote! { Self::#v(m) => m.#method_ident(), }
1511 });
1512 let dyn_pat = dynamic_pattern_with_binding("_");
1513 quote! {
1514 match self {
1515 #(#catalog_arms)*
1516 #dyn_pat => #dynamic_value,
1517 }
1518 }
1519}
1520
1521fn num_lit_with_underscores(n: u32) -> TokenStream {
1523 format_number(n).parse().expect("formatted number parses as a token")
1524}
1525
1526fn format_number(n: u32) -> String {
1528 let s = n.to_string();
1529 if s.len() <= 4 {
1530 return s;
1531 }
1532 let mut result = String::with_capacity(s.len() + s.len() / 3);
1533 for (i, ch) in s.chars().enumerate() {
1534 if i > 0 && (s.len() - i).is_multiple_of(3) {
1535 result.push('_');
1536 }
1537 result.push(ch);
1538 }
1539 result
1540}
1541
1542fn emit_provider_docs(ctx: &CodegenCtx) -> HashMap<String, String> {
1543 let mut docs = HashMap::new();
1544
1545 for cfg in PROVIDERS {
1546 let models = &ctx.provider_models[cfg.dev_id];
1547 let mut doc = String::new();
1548
1549 pushln(&mut doc, format!("`{}` LLM provider.", cfg.display_name));
1550 blank(&mut doc);
1551
1552 pushln(&mut doc, "# Authentication");
1553 blank(&mut doc);
1554 match cfg.env_var {
1555 Some(var) => pushln(&mut doc, format!("Set the `{var}` environment variable.")),
1556 None if cfg.oauth_provider_id.is_some() => {
1557 pushln(&mut doc, "This provider uses OAuth authentication.");
1558 }
1559 None => {
1560 pushln(
1561 &mut doc,
1562 "Uses the default AWS credential chain (environment variables, config files, IAM roles).",
1563 );
1564 pushln(
1565 &mut doc,
1566 "Models served from a dedicated endpoint also accept a Bedrock API key in `AWS_BEARER_TOKEN_BEDROCK`.",
1567 );
1568 }
1569 }
1570 blank(&mut doc);
1571
1572 pushln(&mut doc, "# Supported models");
1573 blank(&mut doc);
1574 pushln(&mut doc, "| Model ID | Name | Context | Reasoning | Image | Audio |");
1575 pushln(&mut doc, "|----------|------|---------|-----------|-------|-------|");
1576 for model in models {
1577 let ctx_str = format_context_window(model.context_window);
1578 let reasoning = if model.reasoning_levels.iter().any(|level| level != "disabled") { "yes" } else { "" };
1579 let image = if model.input_modalities.contains(&"image".to_string()) { "yes" } else { "" };
1580 let audio = if model.input_modalities.contains(&"audio".to_string()) { "yes" } else { "" };
1581 pushln(
1582 &mut doc,
1583 format!(
1584 "| `{}` | `{}` | `{}` | {} | {} | {} |",
1585 model.model_id, model.display_name, ctx_str, reasoning, image, audio
1586 ),
1587 );
1588 }
1589
1590 for model in models.iter().filter(|model| model.disabled_support != "Unsupported") {
1591 pushln(
1592 &mut doc,
1593 format!("Model `{}` advertises `disabled` reasoning (subject to adapter support).", model.model_id),
1594 );
1595 }
1596 push_transport_section(&mut doc, models);
1597
1598 docs.insert(cfg.dev_id.to_string(), doc);
1599 }
1600
1601 for dyn_cfg in DYNAMIC_PROVIDERS {
1602 let mut doc = String::new();
1603 pushln(&mut doc, format!("`{}` LLM provider.", dyn_cfg.display_name));
1604 blank(&mut doc);
1605 pushln(
1606 &mut doc,
1607 format!("This provider accepts any model name at runtime (e.g. `{}:my-model`).", dyn_cfg.parser_name),
1608 );
1609 pushln(&mut doc, "No API key is required.");
1610 docs.insert(dyn_cfg.parser_name.to_string(), doc);
1611 }
1612
1613 docs
1614}
1615
1616fn push_transport_section(doc: &mut String, models: &[ModelInfo]) {
1618 let overridden: Vec<&ModelInfo> = models.iter().filter(|m| m.transport.is_some()).collect();
1619 if overridden.is_empty() {
1620 return;
1621 }
1622
1623 blank(doc);
1624 pushln(doc, "# Models with a dedicated endpoint");
1625 blank(doc);
1626 pushln(doc, "These models are served from their own endpoint and wire protocol");
1627 pushln(doc, "rather than the provider's default. `${VAR}` placeholders are resolved");
1628 pushln(doc, "at request time.");
1629 blank(doc);
1630 pushln(doc, "| Model ID | Endpoint | Wire shape |");
1631 pushln(doc, "|----------|----------|------------|");
1632 for model in overridden {
1633 let transport = model.transport.as_ref().expect("filtered to models with a transport");
1634 let (api, shape) = match transport {
1635 TransportInfo::OpenAiResponses { base_url_template } => (base_url_template.as_str(), "responses"),
1636 };
1637 pushln(doc, format!("| `{}` | `{api}` | `{shape}` |", model.model_id));
1638 }
1639}
1640
1641fn format_context_window(tokens: u32) -> String {
1643 if tokens == 0 {
1644 return "unknown".to_string();
1645 }
1646 if tokens >= 1_000_000 && tokens.is_multiple_of(1_000_000) {
1647 format!("{}M", tokens / 1_000_000)
1648 } else if tokens >= 1_000 && tokens.is_multiple_of(1_000) {
1649 format!("{}k", tokens / 1_000)
1650 } else {
1651 format_number(tokens)
1652 }
1653}
1654
1655fn pushln(out: &mut String, line: impl AsRef<str>) {
1656 writeln!(out, "{}", line.as_ref()).expect("writing to String should not fail");
1657}
1658
1659fn blank(out: &mut String) {
1660 pushln(out, "");
1661}
1662
1663#[cfg(test)]
1664mod tests {
1665 use super::*;
1666 use serde_json::Value;
1667 use serde_json::json;
1668 use tempfile::NamedTempFile;
1669
1670 #[test]
1671 fn model_id_to_variant_pascal_cases_segments() {
1672 assert_eq!(model_id_to_variant("claude-sonnet-4-5-20250929"), "ClaudeSonnet4520250929");
1673 assert_eq!(model_id_to_variant("gemini-2.5-flash"), "Gemini25Flash");
1674 assert_eq!(model_id_to_variant("deepseek-chat"), "DeepseekChat");
1675 assert_eq!(model_id_to_variant("glm-4.5"), "Glm45");
1676 }
1677
1678 #[test]
1679 fn model_id_to_variant_handles_slash_and_colon() {
1680 assert_eq!(model_id_to_variant("anthropic/claude-opus-4.6"), "AnthropicClaudeOpus46");
1681 assert_eq!(model_id_to_variant("openai/gpt-5.1-codex-max"), "OpenaiGpt51CodexMax");
1682 assert_eq!(model_id_to_variant("deepseek/deepseek-r1:free"), "DeepseekDeepseekR1Free");
1683 }
1684
1685 #[test]
1686 fn is_alias_detects_latest_suffix() {
1687 assert!(is_alias("claude-sonnet-4-5-latest"));
1688 assert!(is_alias("claude-3-7-sonnet-latest"));
1689 assert!(!is_alias("claude-sonnet-4-5-20250929"));
1690 }
1691
1692 #[test]
1693 fn build_uses_explicit_context_windows_for_codex_models() {
1694 let data = minimal_models_dev_json();
1695
1696 let models = build_from_value(&data);
1697 let window = |id: &str| models["codex"].iter().find(|model| model.model_id == id).unwrap().context_window;
1698 for model_id in [
1699 "gpt-6-astra",
1700 "gpt-5.6-sol",
1701 "gpt-5.6-terra",
1702 "gpt-5.6-luna",
1703 "gpt-5.5",
1704 "gpt-5.4",
1705 "gpt-5.4-mini",
1706 "gpt-5.2",
1707 ] {
1708 assert_eq!(window(model_id), 272_000);
1709 }
1710 }
1711
1712 #[test]
1713 fn transport_override_is_preserved_from_model_metadata() {
1714 let mut data = minimal_models_dev_json();
1715 insert_models(
1716 &mut data,
1717 "amazon-bedrock",
1718 json!({
1719 "with-transport": {
1720 "id": "with-transport", "name": "With Transport", "tool_call": true,
1721 "limit": {"context": 1000, "output": 0},
1722 "provider": {
1723 "npm": "@ai-sdk/amazon-bedrock/mantle",
1724 "api": "https://example.${AWS_REGION}.api.aws/openai/v1",
1725 "shape": "responses"
1726 }
1727 },
1728 "without-transport": {
1729 "id": "without-transport", "name": "Without Transport", "tool_call": true,
1730 "limit": {"context": 1000, "output": 0}
1731 }
1732 }),
1733 );
1734
1735 let models = build_from_value(&data);
1736 let transport =
1737 |id: &str| models["amazon-bedrock"].iter().find(|m| m.model_id == id).unwrap().transport.clone();
1738
1739 assert_eq!(
1740 transport("with-transport"),
1741 Some(TransportInfo::OpenAiResponses {
1742 base_url_template: "https://example.${AWS_REGION}.api.aws/openai/v1".to_string(),
1743 })
1744 );
1745 assert_eq!(transport("without-transport"), None);
1746 }
1747
1748 #[test]
1749 fn transport_override_with_only_an_npm_package_is_ignored() {
1750 let mut data = minimal_models_dev_json();
1751 anthropic_models(
1752 &mut data,
1753 json!({
1754 "npm-only": {
1755 "id": "npm-only", "name": "Npm Only", "tool_call": true,
1756 "limit": {"context": 1000, "output": 0},
1757 "provider": {"npm": "@ai-sdk/anthropic"}
1758 }
1759 }),
1760 );
1761
1762 let models = build_from_value(&data);
1763
1764 assert_eq!(models["anthropic"].iter().find(|m| m.model_id == "npm-only").unwrap().transport, None);
1765 }
1766
1767 #[test]
1768 fn unknown_wire_shape_is_rejected() {
1769 let mut data = minimal_models_dev_json();
1770 insert_models(
1771 &mut data,
1772 "amazon-bedrock",
1773 json!({
1774 "weird": {
1775 "id": "weird", "name": "Weird", "tool_call": true,
1776 "limit": {"context": 1000, "output": 0},
1777 "provider": {"api": "https://example.com/v1", "shape": "telepathy"}
1778 }
1779 }),
1780 );
1781 let parsed: ModelsDevData = serde_json::from_value(data).expect("parse fixture");
1782
1783 let error = build_provider_models(&parsed).unwrap_err();
1784
1785 assert!(
1786 matches!(error, CodegenError::UnsupportedWireShape { ref model_id, ref shape }
1787 if model_id == "weird" && shape == "telepathy"),
1788 "unexpected error: {error}"
1789 );
1790 }
1791
1792 #[test]
1793 fn incomplete_bedrock_transport_is_rejected() {
1794 let mut data = minimal_models_dev_json();
1795 insert_models(
1796 &mut data,
1797 "amazon-bedrock",
1798 json!({
1799 "incomplete": {
1800 "id": "incomplete", "name": "Incomplete", "tool_call": true,
1801 "limit": {"context": 1000, "output": 0},
1802 "provider": {"api": "https://example.com/v1"}
1803 }
1804 }),
1805 );
1806 let parsed: ModelsDevData = serde_json::from_value(data).expect("parse fixture");
1807
1808 let error = build_provider_models(&parsed).unwrap_err();
1809
1810 assert!(matches!(error, CodegenError::IncompleteTransport { ref model_id } if model_id == "incomplete"));
1811 }
1812
1813 #[test]
1814 fn format_context_window_formats_correctly() {
1815 assert_eq!(format_context_window(1_000_000), "1M");
1816 assert_eq!(format_context_window(200_000), "200k");
1817 assert_eq!(format_context_window(8_000), "8k");
1818 assert_eq!(format_context_window(0), "unknown");
1819 }
1820
1821 #[test]
1822 fn level_str_to_variant_covers_all_reasoning_efforts() {
1823 for effort in utils::ReasoningEffort::all() {
1824 let _ = level_str_to_variant(effort.as_str());
1825 }
1826 }
1827
1828 #[test]
1829 fn build_sorts_models_and_filters_aliases_and_non_tool_call() {
1830 let mut data = minimal_models_dev_json();
1831 anthropic_models(
1832 &mut data,
1833 json!({
1834 "b-model": {"id": "b-model", "name": "B Model", "tool_call": true, "limit": {"context": 2000, "output": 0}},
1835 "a-model": {"id": "a-model", "name": "A Model", "tool_call": true, "limit": {"context": 1000, "output": 0}},
1836 "alpha-latest": {"id": "alpha-latest", "name": "Alias", "tool_call": true, "limit": {"context": 500, "output": 0}},
1837 "no-tools": {"id": "no-tools", "name": "No Tools", "tool_call": false, "limit": {"context": 500, "output": 0}}
1838 }),
1839 );
1840
1841 let models = build_from_value(&data);
1842 let ids: Vec<&str> = models["anthropic"].iter().map(|m| m.model_id.as_str()).collect();
1843 assert_eq!(ids, vec!["a-model", "b-model"]);
1844 }
1845
1846 #[test]
1847 fn build_extra_source_ids_merges_unique_models_into_provider() {
1848 let mut data = minimal_models_dev_json();
1849 zai_extra_models(
1850 &mut data,
1851 json!({
1852 "extra-model": {"id": "extra-model", "name": "Extra Model", "tool_call": true, "limit": {"context": 4000, "output": 0}}
1853 }),
1854 );
1855
1856 let models = build_from_value(&data);
1857 assert!(models["zai"].iter().any(|m| m.model_id == "extra-model"));
1858 }
1859
1860 #[test]
1861 fn build_extra_source_ids_does_not_duplicate_existing_models() {
1862 let mut data = minimal_models_dev_json();
1863 let shared = json!({
1864 "shared-model": {"id": "shared-model", "name": "Shared Model", "tool_call": true, "limit": {"context": 1000, "output": 0}}
1865 });
1866 insert_models(&mut data, "zai", shared.clone());
1867 insert_models(&mut data, "zai-coding-plan", shared);
1868
1869 let models = build_from_value(&data);
1870 let count = models["zai"].iter().filter(|m| m.model_id == "shared-model").count();
1871 assert_eq!(count, 1);
1872 }
1873
1874 #[test]
1875 fn build_derives_reasoning_levels_from_source_metadata() {
1876 let mut data = minimal_models_dev_json();
1877 anthropic_models(
1878 &mut data,
1879 json!({
1880 "claude-test": {
1881 "id": "claude-test", "name": "Claude Test", "tool_call": true, "reasoning": true,
1882 "reasoning_options": [{"type": "effort", "values": ["low", "high", "max"]}],
1883 "limit": {"context": 200_000, "output": 0}
1884 }
1885 }),
1886 );
1887
1888 let models = build_from_value(&data);
1889 let model = models["anthropic"].iter().find(|model| model.model_id == "claude-test").unwrap();
1890 assert_eq!(model.reasoning_levels, ["low", "high", "max"]);
1891 }
1892
1893 #[test]
1894 fn generate_rejects_unknown_reasoning_effort_metadata() {
1895 let mut data = minimal_models_dev_json();
1896 anthropic_models(
1897 &mut data,
1898 json!({
1899 "claude-test": {
1900 "id": "claude-test", "name": "Claude Test", "tool_call": true, "reasoning": true,
1901 "reasoning_options": [{"type": "effort", "values": ["ultra"]}],
1902 "limit": {"context": 200_000, "output": 0}
1903 }
1904 }),
1905 );
1906 let tmp = NamedTempFile::new().unwrap();
1907 std::fs::write(tmp.path(), serde_json::to_string(&data).unwrap()).unwrap();
1908 assert!(matches!(generate(tmp.path()), Err(CodegenError::UnsupportedReasoningEffort { .. })));
1909 }
1910
1911 #[test]
1912 fn build_preserves_model_pricing_and_omits_codex_subscription_pricing() {
1913 let mut data = minimal_models_dev_json();
1914 anthropic_models(
1915 &mut data,
1916 json!({
1917 "priced": {
1918 "id": "priced", "name": "Priced", "tool_call": true,
1919 "limit": {"context": 200_000, "output": 0},
1920 "cost": {"input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_write": 3.75}
1921 }
1922 }),
1923 );
1924 insert_models(
1925 &mut data,
1926 "openai",
1927 json!({
1928 "gpt-5.5": {
1929 "id": "gpt-5.5", "name": "GPT-5.5", "tool_call": true,
1930 "limit": {"context": 1_050_000, "output": 128_000},
1931 "cost": {"input": 1.25, "output": 10.0, "cache_read": 0.125}
1932 }
1933 }),
1934 );
1935
1936 let models = build_from_value(&data);
1937 let priced = models["anthropic"].iter().find(|model| model.model_id == "priced").unwrap();
1938 assert_eq!(priced.pricing.as_ref().map(|pricing| pricing.input), Some(3.0));
1939 assert_eq!(priced.pricing.as_ref().map(|pricing| pricing.output), Some(15.0));
1940 assert_eq!(priced.pricing.as_ref().and_then(|pricing| pricing.cache_read), Some(0.3));
1941 assert_eq!(priced.pricing.as_ref().and_then(|pricing| pricing.cache_write), Some(3.75));
1942
1943 let codex = models["codex"].iter().find(|model| model.model_id == "gpt-5.5").unwrap();
1944 assert_eq!(codex.pricing, None);
1945 assert!(codex.supports_prompt_caching);
1946 }
1947
1948 #[test]
1949 fn build_derives_prompt_caching_from_cost_fields() {
1950 let mut data = minimal_models_dev_json();
1951 insert_models(
1952 &mut data,
1953 "amazon-bedrock",
1954 json!({
1955 "cached": {
1956 "id": "cached", "name": "Cached", "tool_call": true,
1957 "limit": {"context": 200_000, "output": 0},
1958 "cost": {"input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_write": 3.75}
1959 },
1960 "uncached": {
1961 "id": "uncached", "name": "Uncached", "tool_call": true,
1962 "limit": {"context": 200_000, "output": 0},
1963 "cost": {"input": 3.0, "output": 15.0}
1964 }
1965 }),
1966 );
1967
1968 let models = build_from_value(&data);
1969 let bedrock = &models["amazon-bedrock"];
1970 let cached = bedrock.iter().find(|m| m.model_id == "cached").unwrap();
1971 let uncached = bedrock.iter().find(|m| m.model_id == "uncached").unwrap();
1972 assert!(cached.supports_prompt_caching);
1973 assert!(!uncached.supports_prompt_caching);
1974 }
1975
1976 #[test]
1977 fn build_assigns_codex_model_specific_reasoning_levels() {
1978 let mut data = minimal_models_dev_json();
1979 insert_models(
1980 &mut data,
1981 "openai",
1982 json!({
1983 "gpt-5.6-sol": {
1984 "id": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "tool_call": true, "reasoning": true,
1985 "reasoning_options": [{"type": "effort", "values": ["none", "low", "medium", "high", "xhigh", "max"]}],
1986 "limit": {"context": 200_000, "output": 0}
1987 },
1988 "gpt-5.6-luna": {
1989 "id": "gpt-5.6-luna", "name": "GPT-5.6 Luna", "tool_call": true, "reasoning": true,
1990 "reasoning_options": [{"type": "effort", "values": ["none", "low", "medium", "high", "xhigh", "max"]}],
1991 "limit": {"context": 200_000, "output": 0}
1992 },
1993 "gpt-5.4": {
1994 "id": "gpt-5.4", "name": "GPT-5.4", "tool_call": true, "reasoning": true,
1995 "limit": {"context": 200_000, "output": 0}
1996 }
1997 }),
1998 );
1999
2000 let models = build_from_value(&data);
2001 let levels = |id: &str| models["codex"].iter().find(|m| m.model_id == id).unwrap().reasoning_levels.clone();
2002 assert_eq!(levels("gpt-5.6-sol"), vec!["low", "medium", "high", "xhigh", "max"]);
2003 assert_eq!(levels("gpt-5.6-luna"), vec!["low", "medium", "high", "xhigh", "max"]);
2004 assert_eq!(levels("gpt-5.4"), vec!["low", "medium", "high", "xhigh"]);
2005 }
2006
2007 #[test]
2008 fn build_applies_codex_subscription_context_window_override() {
2009 let mut data = minimal_models_dev_json();
2010 insert_models(
2011 &mut data,
2012 "openai",
2013 json!({
2014 "gpt-5.5": {
2015 "id": "gpt-5.5", "name": "GPT-5.5", "tool_call": true, "reasoning": true,
2016 "limit": {"context": 1_050_000, "output": 128_000}
2017 }
2018 }),
2019 );
2020
2021 let models = build_from_value(&data);
2022 let codex = models["codex"].iter().find(|m| m.model_id == "gpt-5.5").unwrap();
2023 let openai = models["openai"].iter().find(|m| m.model_id == "gpt-5.5").unwrap();
2024 assert_eq!(codex.context_window, 272_000);
2025 assert_eq!(openai.context_window, 1_050_000);
2026 }
2027
2028 #[test]
2029 fn generate_uses_codex_subscription_model_ids() {
2030 let mut data = minimal_models_dev_json();
2031 insert_models(
2032 &mut data,
2033 "openai",
2034 json!({
2035 "gpt-5.1-codex": {
2036 "id": "gpt-5.1-codex", "name": "GPT-5.1 Codex", "tool_call": true, "reasoning": true,
2037 "limit": {"context": 400_000, "output": 128_000}
2038 },
2039 "gpt-5.6": {
2040 "id": "gpt-5.6", "name": "GPT-5.6 Sol", "tool_call": true, "reasoning": true,
2041 "limit": {"context": 1_050_000, "output": 128_000}
2042 },
2043 "gpt-5.6-sol": {
2044 "id": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "tool_call": true, "reasoning": true,
2045 "limit": {"context": 1_050_000, "output": 128_000}
2046 },
2047 "gpt-5.6-terra": {
2048 "id": "gpt-5.6-terra", "name": "GPT-5.6 Terra", "tool_call": true, "reasoning": true,
2049 "limit": {"context": 1_050_000, "output": 128_000}
2050 },
2051 "gpt-5.6-luna": {
2052 "id": "gpt-5.6-luna", "name": "GPT-5.6 Luna", "tool_call": true, "reasoning": true,
2053 "limit": {"context": 1_050_000, "output": 128_000}
2054 }
2055 }),
2056 );
2057
2058 let tmp = NamedTempFile::new().unwrap();
2059 std::fs::write(tmp.path(), serde_json::to_string(&data).unwrap()).unwrap();
2060 let output = generate(tmp.path()).unwrap();
2061
2062 let codex_doc = &output.provider_docs["codex"];
2063 assert!(!codex_doc.contains("`gpt-5.6`"));
2064 assert!(!codex_doc.contains("`gpt-5.1-codex`"));
2065 assert!(codex_doc.contains("| `gpt-5.6-sol` | `GPT-5.6 Sol` | `272k` |"));
2066 assert!(codex_doc.contains("| `gpt-5.6-terra` | `GPT-5.6 Terra` | `272k` |"));
2067 assert!(codex_doc.contains("| `gpt-5.6-luna` | `GPT-5.6 Luna` | `272k` |"));
2068
2069 let openai_doc = &output.provider_docs["openai"];
2070 assert!(openai_doc.contains("`gpt-5.6`"));
2071 assert!(openai_doc.contains("`gpt-5.1-codex`"));
2072 assert!(openai_doc.contains("`gpt-5.6-sol`"));
2073 }
2074
2075 #[test]
2076 fn generate_emits_provider_docs() {
2077 let mut data = minimal_models_dev_json();
2078 anthropic_models(
2079 &mut data,
2080 json!({
2081 "claude-test": {
2082 "id": "claude-test", "name": "Claude Test", "tool_call": true, "reasoning": true,
2083 "limit": {"context": 200_000, "output": 0},
2084 "modalities": {"input": ["text", "image"]}
2085 }
2086 }),
2087 );
2088
2089 let tmp = NamedTempFile::new().unwrap();
2090 std::fs::write(tmp.path(), serde_json::to_string(&data).unwrap()).unwrap();
2091 let output = generate(tmp.path()).unwrap();
2092
2093 let anthropic_doc = &output.provider_docs["anthropic"];
2094 assert!(anthropic_doc.contains("`Anthropic` LLM provider."));
2095 assert!(anthropic_doc.contains("`ANTHROPIC_API_KEY`"));
2096 assert!(anthropic_doc.contains("| `claude-test` | `Claude Test` | `200k` | yes | yes | |"));
2097
2098 let ollama_doc = &output.provider_docs["ollama"];
2099 assert!(ollama_doc.contains("`Ollama` LLM provider."));
2100 assert!(ollama_doc.contains("any model name at runtime"));
2101 }
2102
2103 #[test]
2104 fn generate_preserves_disabled_separately_from_default() {
2105 let mut data = minimal_models_dev_json();
2106 data["openai"]["models"]["gpt-5.4"]["reasoning_options"] = json!([
2107 {"type": "effort", "values": [null, "default", "none", "low", "high"]}
2108 ]);
2109 anthropic_models(
2110 &mut data,
2111 json!({
2112 "claude-toggle": {
2113 "id": "claude-toggle", "name": "Toggle", "tool_call": true, "reasoning": true,
2114 "reasoning_options": [{"type": "toggle"}, {"type": "effort", "values": ["low", "high"]}]
2115 }
2116 }),
2117 );
2118 let tmp = NamedTempFile::new().unwrap();
2119 std::fs::write(tmp.path(), serde_json::to_string(&data).unwrap()).unwrap();
2120 let output = generate(tmp.path()).unwrap();
2121 assert!(output.rust_source.contains("ReasoningEffort::Disabled"));
2122 assert_default_is_not_capability(&output.rust_source);
2123 assert!(output.provider_docs["openai"].contains("disabled"));
2124 assert!(output.provider_docs["anthropic"].contains("disabled"));
2125 assert!(!output.provider_docs["codex"].contains("disabled"));
2126 }
2127
2128 #[test]
2129 fn generate_reasoning_capability_edge_cases() {
2130 let mut data = minimal_models_dev_json();
2131 for (id, reasoning, options, disabled) in [
2132 ("toggle", true, json!([{"type": "toggle"}]), true),
2133 (
2134 "both",
2135 true,
2136 json!([{"type": "toggle"}, {"type": "effort", "values": ["none", "high", "none", "low", "low"]}]),
2137 true,
2138 ),
2139 ("defaults", true, json!([{"type": "effort", "values": [null, "default"]}]), false),
2140 ("budget", true, json!([{"type": "budget_tokens"}]), false),
2141 ("missing", true, json!([]), false),
2142 ("plain", false, json!([{"type": "toggle"}]), false),
2143 ] {
2144 data["anthropic"]["models"] = json!({id: {"id": id, "name": id, "reasoning": reasoning, "tool_call": true, "reasoning_options": options}});
2145 let tmp = NamedTempFile::new().unwrap();
2146 std::fs::write(tmp.path(), serde_json::to_string(&data).unwrap()).unwrap();
2147 let output = generate(tmp.path()).unwrap();
2148 assert_eq!(output.provider_docs["anthropic"].contains("advertises `disabled`"), disabled, "{id}");
2149 assert_default_is_not_capability(&output.rust_source);
2150 if id == "both" {
2151 assert!(output.rust_source.contains("ReasoningDisabledSupport::Effort"));
2152 assert!(!output.rust_source.contains("ReasoningEffort::Disabled, ReasoningEffort::Disabled"));
2153 }
2154 }
2155 }
2156
2157 fn assert_default_is_not_capability(source: &str) {
2158 let file = syn::parse_file(source).unwrap();
2159 for item in file.items {
2160 if let syn::Item::Impl(implementation) = item {
2161 for item in implementation.items {
2162 if let syn::ImplItem::Fn(method) = item
2163 && method.sig.ident == "reasoning_levels"
2164 {
2165 let body = method.block;
2166 assert!(!quote! { #body }.to_string().contains("ReasoningEffort :: Default"));
2167 }
2168 }
2169 }
2170 }
2171 }
2172
2173 fn build_from_value(data: &Value) -> ProviderModels {
2174 let parsed: ModelsDevData = serde_json::from_value(data.clone()).expect("parse fixture");
2175 build_provider_models(&parsed).expect("build provider models")
2176 }
2177
2178 fn anthropic_models(data: &mut Value, models: Value) {
2179 insert_models(data, "anthropic", models);
2180 }
2181
2182 fn zai_extra_models(data: &mut Value, models: Value) {
2183 insert_models(data, "zai-coding-plan", models);
2184 }
2185
2186 fn insert_models(data: &mut Value, provider_key: &str, models: Value) {
2187 let provider = data.as_object_mut().unwrap().get_mut(provider_key).unwrap().as_object_mut().unwrap();
2188 let target = provider.get_mut("models").unwrap().as_object_mut().unwrap();
2189 let Value::Object(models) = models else {
2190 panic!("models fixture must be an object");
2191 };
2192 target.extend(models);
2193 }
2194
2195 fn minimal_models_dev_json() -> Value {
2196 let mut root = serde_json::Map::new();
2197 for cfg in PROVIDERS {
2198 let json_key = cfg.json_key();
2199 root.entry(json_key.to_string())
2200 .or_insert_with(|| json!({"id": json_key, "name": json_key, "env": [], "models": {}}));
2201 for &extra in cfg.extra_source_ids {
2202 root.entry(extra.to_string())
2203 .or_insert_with(|| json!({"id": extra, "name": extra, "env": [], "models": {}}));
2204 }
2205 }
2206 let openai = root.get_mut("openai").unwrap()["models"].as_object_mut().unwrap();
2207 for model in CODEX_SUBSCRIPTION_MODELS {
2208 openai.insert(
2209 model.id.to_string(),
2210 json!({
2211 "id": model.id,
2212 "name": model.id,
2213 "tool_call": true,
2214 "reasoning": true,
2215 "reasoning_options": [{"type": "effort", "values": ["low", "medium", "high", "xhigh"]}],
2216 "limit": {"context": 1_050_000, "output": 0}
2217 }),
2218 );
2219 }
2220 Value::Object(root)
2221 }
2222}