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}
43
44#[derive(Debug, Deserialize)]
45#[serde(tag = "type", rename_all = "snake_case")]
46enum ReasoningOption {
47 Effort { values: Vec<Option<String>> },
48 Toggle,
49 BudgetTokens,
50}
51
52#[derive(Debug, Deserialize, Default)]
53struct ModalitiesData {
54 #[serde(default)]
55 input: Vec<String>,
56}
57
58#[derive(Debug, Deserialize)]
59#[allow(dead_code)]
60struct CostData {
61 #[serde(default)]
62 input: f64,
63 #[serde(default)]
64 output: f64,
65 #[serde(default)]
66 cache_read: Option<f64>,
67 #[serde(default)]
68 cache_write: Option<f64>,
69}
70
71#[derive(Debug, Deserialize)]
72struct LimitData {
73 #[serde(default)]
74 context: u32,
75 #[serde(default)]
76 #[allow(dead_code)]
77 output: u32,
78}
79
80impl CostData {
81 fn has_prompt_caching(&self) -> bool {
82 self.cache_read.is_some() || self.cache_write.is_some()
83 }
84}
85
86struct ProviderConfig {
88 dev_id: &'static str,
90 source_dev_id: Option<&'static str>,
92 extra_source_ids: &'static [&'static str],
94 explicit_models: Option<&'static [ExplicitModel]>,
99 enum_name: &'static str,
101 parser_name: &'static str,
103 genai_provider_name: &'static str,
105 display_name: &'static str,
107 env_var: Option<&'static str>,
109 oauth_provider_id: Option<&'static str>,
111 fallback_reasoning_levels: &'static [&'static str],
113 is_hybrid_dynamic: bool,
119}
120
121struct ExplicitModel {
123 id: &'static str,
124 context_window: u32,
125}
126
127impl ProviderConfig {
128 const fn standard(
130 dev_id: &'static str,
131 enum_name: &'static str,
132 parser_name: &'static str,
133 display_name: &'static str,
134 env_var: Option<&'static str>,
135 ) -> Self {
136 Self {
137 dev_id,
138 source_dev_id: None,
139 extra_source_ids: &[],
140 explicit_models: None,
141 enum_name,
142 parser_name,
143 genai_provider_name: parser_name,
144 display_name,
145 env_var,
146 oauth_provider_id: None,
147 fallback_reasoning_levels: &["low", "medium", "high"],
148 is_hybrid_dynamic: false,
149 }
150 }
151
152 fn explicit_model(&self, model_id: &str) -> Option<&'static ExplicitModel> {
153 self.explicit_models.and_then(|models| models.iter().find(|model| model.id == model_id))
154 }
155
156 fn inner_enum_name(&self) -> String {
159 if self.is_hybrid_dynamic {
160 format!("{}FoundationModel", self.enum_name)
161 } else {
162 format!("{}Model", self.enum_name)
163 }
164 }
165
166 fn outer_enum_name(&self) -> String {
168 format!("{}Model", self.enum_name)
169 }
170
171 fn json_key(&self) -> &'static str {
173 self.source_dev_id.unwrap_or(self.dev_id)
174 }
175}
176
177#[allow(clippy::struct_field_names)]
179struct DynamicProviderConfig {
180 enum_name: &'static str,
182 parser_name: &'static str,
184 genai_provider_name: &'static str,
186 display_name: &'static str,
188}
189
190const PROVIDERS: &[ProviderConfig] = &[
191 ProviderConfig::standard("anthropic", "Anthropic", "anthropic", "Anthropic", Some("ANTHROPIC_API_KEY")),
192 ProviderConfig {
193 source_dev_id: Some("azure"),
194 genai_provider_name: "azure.ai.openai",
195 ..ProviderConfig::standard(
196 "azure-foundry",
197 "AzureFoundry",
198 "azure-foundry",
199 "Microsoft Foundry",
200 Some("AZURE_OPENAI_API_KEY"),
201 )
202 },
203 ProviderConfig {
204 dev_id: "codex",
205 source_dev_id: Some("openai"),
206 extra_source_ids: &[],
207 explicit_models: Some(CODEX_SUBSCRIPTION_MODELS),
208 enum_name: "Codex",
209 parser_name: "codex",
210 genai_provider_name: "openai",
211 display_name: "Codex",
212 env_var: None,
213 oauth_provider_id: Some("codex"),
214 fallback_reasoning_levels: &["low", "medium", "high", "xhigh"],
215 is_hybrid_dynamic: false,
216 },
217 ProviderConfig::standard("deepseek", "DeepSeek", "deepseek", "DeepSeek", Some("DEEPSEEK_API_KEY")),
218 ProviderConfig {
219 source_dev_id: Some("fireworks-ai"),
220 ..ProviderConfig::standard("fireworks", "Fireworks", "fireworks", "Fireworks AI", Some("FIREWORKS_API_KEY"))
221 },
222 ProviderConfig {
223 genai_provider_name: "gcp.gemini",
224 ..ProviderConfig::standard("google", "Gemini", "gemini", "Gemini", Some("GEMINI_API_KEY"))
225 },
226 ProviderConfig {
227 genai_provider_name: "moonshot_ai",
228 ..ProviderConfig::standard("moonshotai", "Moonshot", "moonshot", "Moonshot", Some("MOONSHOT_API_KEY"))
229 },
230 ProviderConfig::standard("openai", "Openai", "openai", "OpenAI", Some("OPENAI_API_KEY")),
231 ProviderConfig::standard("openrouter", "OpenRouter", "openrouter", "OpenRouter", Some("OPENROUTER_API_KEY")),
232 ProviderConfig {
233 extra_source_ids: &["zai-coding-plan"],
234 ..ProviderConfig::standard("zai", "ZAi", "zai", "ZAI", Some("ZAI_API_KEY"))
235 },
236 ProviderConfig {
237 genai_provider_name: "aws.bedrock",
238 is_hybrid_dynamic: true,
239 ..ProviderConfig::standard("amazon-bedrock", "Bedrock", "bedrock", "AWS Bedrock", None)
240 },
241];
242
243const DYNAMIC_PROVIDERS: &[DynamicProviderConfig] = &[
244 DynamicProviderConfig {
245 enum_name: "Ollama",
246 parser_name: "ollama",
247 genai_provider_name: "ollama",
248 display_name: "Ollama",
249 },
250 DynamicProviderConfig {
251 enum_name: "LlamaCpp",
252 parser_name: "llamacpp",
253 genai_provider_name: "llama.cpp",
254 display_name: "LlamaCpp",
255 },
256];
257
258const CODEX_SUBSCRIPTION_CONTEXT_WINDOW: u32 = 272_000;
259
260const CODEX_SUBSCRIPTION_MODELS: &[ExplicitModel] = &[
261 ExplicitModel { id: "gpt-5.6-sol", context_window: 372_000 },
262 ExplicitModel { id: "gpt-5.6-terra", context_window: 372_000 },
263 ExplicitModel { id: "gpt-5.6-luna", context_window: 372_000 },
264 ExplicitModel { id: "gpt-5.5", context_window: CODEX_SUBSCRIPTION_CONTEXT_WINDOW },
265 ExplicitModel { id: "gpt-5.4", context_window: CODEX_SUBSCRIPTION_CONTEXT_WINDOW },
266 ExplicitModel { id: "gpt-5.4-mini", context_window: CODEX_SUBSCRIPTION_CONTEXT_WINDOW },
267 ExplicitModel { id: "gpt-5.2", context_window: CODEX_SUBSCRIPTION_CONTEXT_WINDOW },
268];
269
270#[derive(Debug, Clone)]
271struct ModelInfo {
272 variant_name: String,
273 model_id: String,
274 display_name: String,
275 context_window: u32,
276 reasoning_levels: Vec<String>,
277 input_modalities: Vec<String>,
278 supports_prompt_caching: bool,
279}
280
281type ProviderModels = BTreeMap<&'static str, Vec<ModelInfo>>;
282
283struct CodegenCtx {
284 provider_models: ProviderModels,
285}
286
287pub struct GeneratedOutput {
289 pub rust_source: String,
291 pub provider_docs: HashMap<String, String>,
296}
297
298#[derive(Debug, thiserror::Error)]
299pub enum CodegenError {
300 #[error("read: {0}")]
301 Read(#[from] std::io::Error),
302 #[error("parse: {0}")]
303 Parse(#[from] serde_json::Error),
304 #[error("Provider '{0}' not found in models.dev data")]
305 ProviderNotFound(String),
306 #[error("Configured model '{model_id}' was not found in provider '{provider_id}'")]
307 ConfiguredModelNotFound { provider_id: String, model_id: String },
308 #[error("Configured model '{model_id}' is duplicated for provider '{provider_id}'")]
309 DuplicateConfiguredModel { provider_id: String, model_id: String },
310 #[error("Configured model '{model_id}' is not tool-capable in provider '{provider_id}'")]
311 ConfiguredModelUnavailable { provider_id: String, model_id: String },
312 #[error("Model '{model_id}' declares unsupported reasoning effort '{effort}'")]
313 UnsupportedReasoningEffort { model_id: String, effort: String },
314}
315
316pub fn generate(models_json_path: &Path) -> Result<GeneratedOutput, CodegenError> {
318 let json_bytes = std::fs::read_to_string(models_json_path)?;
319 let data: ModelsDevData = serde_json::from_str(&json_bytes)?;
320
321 let provider_models = build_provider_models(&data)?;
322 let ctx = CodegenCtx { provider_models };
323 Ok(GeneratedOutput { rust_source: emit_generated_source(&ctx), provider_docs: emit_provider_docs(&ctx) })
324}
325
326fn build_provider_models(data: &ModelsDevData) -> Result<ProviderModels, CodegenError> {
327 let mut provider_models = ProviderModels::new();
328
329 for cfg in PROVIDERS {
330 let json_key = cfg.json_key();
331 let provider_data = data.get(json_key).ok_or_else(|| CodegenError::ProviderNotFound(json_key.to_string()))?;
332
333 validate_provider_config(cfg, provider_data)?;
334 let mut models: Vec<ModelInfo> = collect_models_from(cfg, &provider_data.models)?;
335
336 for &extra_key in cfg.extra_source_ids {
337 if let Some(extra_data) = data.get(extra_key) {
338 let extra = collect_models_from(cfg, &extra_data.models)?;
339 let existing_ids: std::collections::HashSet<String> =
340 models.iter().map(|m| m.model_id.clone()).collect();
341 models.extend(extra.into_iter().filter(|m| !existing_ids.contains(&m.model_id)));
342 }
343 }
344
345 models.sort_by(|a, b| a.model_id.cmp(&b.model_id));
346 provider_models.insert(cfg.dev_id, models);
347 }
348
349 Ok(provider_models)
350}
351
352fn validate_provider_config(cfg: &ProviderConfig, provider: &ProviderData) -> Result<(), CodegenError> {
353 let Some(explicit_models) = cfg.explicit_models else {
354 return Ok(());
355 };
356 let mut seen = HashSet::new();
357 for configured in explicit_models {
358 if !seen.insert(configured.id) {
359 return Err(CodegenError::DuplicateConfiguredModel {
360 provider_id: cfg.dev_id.to_string(),
361 model_id: configured.id.to_string(),
362 });
363 }
364 let Some(model) = provider.models.get(configured.id) else {
365 return Err(CodegenError::ConfiguredModelNotFound {
366 provider_id: cfg.dev_id.to_string(),
367 model_id: configured.id.to_string(),
368 });
369 };
370 if model.tool_call != Some(true) {
371 return Err(CodegenError::ConfiguredModelUnavailable {
372 provider_id: cfg.dev_id.to_string(),
373 model_id: configured.id.to_string(),
374 });
375 }
376 }
377 Ok(())
378}
379
380fn collect_models_from(
381 cfg: &ProviderConfig,
382 models: &HashMap<String, ModelData>,
383) -> Result<Vec<ModelInfo>, CodegenError> {
384 models
385 .values()
386 .filter(|m| m.tool_call == Some(true))
387 .filter(|m| !is_alias(&m.id))
388 .filter(|m| cfg.explicit_models.is_none() || cfg.explicit_model(&m.id).is_some())
389 .map(|m| {
390 let reasoning_levels =
391 if m.reasoning.unwrap_or(false) { reasoning_levels_for_model(cfg, m)? } else { Vec::new() };
392 let input_modalities =
393 m.modalities.as_ref().map_or_else(|| vec!["text".to_string()], |md| md.input.clone());
394 let source_context_window = m.limit.as_ref().map_or(0, |l| l.context);
395 let context_window =
396 cfg.explicit_model(&m.id).map_or(source_context_window, |explicit| explicit.context_window);
397 Ok(ModelInfo {
398 variant_name: model_id_to_variant(&m.id),
399 model_id: m.id.clone(),
400 display_name: m.name.clone(),
401 context_window,
402 reasoning_levels,
403 input_modalities,
404 supports_prompt_caching: m.cost.as_ref().is_some_and(CostData::has_prompt_caching),
405 })
406 })
407 .collect()
408}
409
410fn reasoning_levels_for_model(cfg: &ProviderConfig, model: &ModelData) -> Result<Vec<String>, CodegenError> {
411 let Some(values) = model.reasoning_options.iter().find_map(|option| match option {
412 ReasoningOption::Effort { values } => Some(values),
413 ReasoningOption::Toggle | ReasoningOption::BudgetTokens => None,
414 }) else {
415 return Ok(cfg.fallback_reasoning_levels.iter().map(|level| (*level).to_string()).collect());
416 };
417
418 values
419 .iter()
420 .filter_map(|value| value.as_deref())
421 .filter(|value| !matches!(*value, "none" | "default"))
422 .map(|effort| {
423 effort.parse::<utils::ReasoningEffort>().map(|parsed| parsed.as_str().to_string()).map_err(|_| {
424 CodegenError::UnsupportedReasoningEffort { model_id: model.id.clone(), effort: effort.to_string() }
425 })
426 })
427 .collect()
428}
429
430fn is_alias(id: &str) -> bool {
432 id.ends_with("-latest")
433}
434
435fn model_id_to_variant(id: &str) -> String {
438 let mut result = String::new();
439 let mut capitalize_next = true;
440
441 for ch in id.chars() {
442 if ch == '-' || ch == '.' || ch == '/' || ch == ':' {
443 capitalize_next = true;
444 } else if capitalize_next {
445 result.push(ch.to_ascii_uppercase());
446 capitalize_next = false;
447 } else {
448 result.push(ch);
449 }
450 }
451
452 if result.starts_with(|c: char| c.is_ascii_digit()) {
453 result.insert(0, '_');
454 }
455
456 result
457}
458
459fn emit_generated_source(ctx: &CodegenCtx) -> String {
460 let provider_enum = emit_provider_enum();
461 let provider_enum_impl = emit_provider_enum_impl();
462 let provider_enum_display = emit_provider_enum_display();
463 let provider_enum_fromstr = emit_provider_enum_fromstr();
464 let provider_enums = emit_provider_enums(&ctx.provider_models);
465 let provider_impls = emit_provider_impls(&ctx.provider_models);
466 let llm_model_enum = emit_llm_model_enum();
467 let from_impls = emit_from_impls();
468 let llm_model_impl = emit_llm_model_impl();
469 let display_impl = emit_display_impl();
470 let fromstr_impl = emit_fromstr_impl();
471
472 let file_tokens = quote! {
473 use std::borrow::Cow;
474 use std::sync::LazyLock;
475 use crate::ReasoningEffort;
476
477 #provider_enum
478 #provider_enum_impl
479 #provider_enum_display
480 #provider_enum_fromstr
481 #provider_enums
482 #provider_impls
483 #llm_model_enum
484 #from_impls
485 #llm_model_impl
486 #display_impl
487 #fromstr_impl
488 };
489
490 let file: syn::File = syn::parse2(file_tokens).expect("generated tokens parse as Rust");
491 let formatted = prettyplease::unparse(&file);
492 format!(
493 "// Auto-generated from models.dev — do not edit manually\n// Regenerated automatically by build.rs\n\n{formatted}"
494 )
495}
496
497fn emit_provider_enum() -> TokenStream {
498 let catalog_variants = PROVIDERS.iter().map(|cfg| format_ident!("{}", cfg.enum_name));
499 let dynamic_variants = DYNAMIC_PROVIDERS.iter().map(|d| format_ident!("{}", d.enum_name));
500 quote! {
501 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
505 pub enum Provider {
506 #(#catalog_variants,)*
507 #(#dynamic_variants,)*
508 }
509 }
510}
511
512fn emit_provider_enum_impl() -> TokenStream {
513 let parser_arms = provider_match_arms(|cfg| cfg.parser_name, |d| d.parser_name);
514 let genai_provider_name_arms = provider_match_arms(|cfg| cfg.genai_provider_name, |d| d.genai_provider_name);
515 let display_arms = provider_match_arms(|cfg| cfg.display_name, |d| d.display_name);
516
517 let env_var_some = PROVIDERS.iter().filter_map(|cfg| {
518 cfg.env_var.map(|var| {
519 let v = format_ident!("{}", cfg.enum_name);
520 quote! { Self::#v => Some(#var), }
521 })
522 });
523
524 let env_var_none = provider_or_pats(|cfg| cfg.env_var.is_none(), |_| true);
525 let oauth_some = PROVIDERS.iter().filter_map(|cfg| {
526 cfg.oauth_provider_id.map(|id| {
527 let v = format_ident!("{}", cfg.enum_name);
528 quote! { Self::#v => Some(#id), }
529 })
530 });
531 let oauth_none = provider_or_pats(|cfg| cfg.oauth_provider_id.is_none(), |_| true);
532
533 let is_local_true = provider_or_pats(|_| false, |_| true);
534 let is_local_false = provider_or_pats(|_| true, |_| false);
535
536 let all_variants = PROVIDERS
537 .iter()
538 .map(|cfg| format_ident!("{}", cfg.enum_name))
539 .chain(DYNAMIC_PROVIDERS.iter().map(|d| format_ident!("{}", d.enum_name)));
540
541 quote! {
542 impl Provider {
543 pub const ALL: &[Provider] = &[#(Self::#all_variants),*];
545
546 pub fn parser_name(self) -> &'static str {
548 match self { #parser_arms }
549 }
550
551 #[allow(clippy::match_same_arms)]
553 pub fn genai_provider_name(self) -> &'static str {
554 match self { #genai_provider_name_arms }
555 }
556
557 pub fn display_name(self) -> &'static str {
559 match self { #display_arms }
560 }
561
562 pub fn required_env_var(self) -> Option<&'static str> {
564 match self {
565 #(#env_var_some)*
566 #env_var_none => None,
567 }
568 }
569
570 pub fn oauth_provider_id(self) -> Option<&'static str> {
572 match self {
573 #(#oauth_some)*
574 #oauth_none => None,
575 }
576 }
577
578 pub fn is_local(self) -> bool {
581 match self {
582 #is_local_true => true,
583 #is_local_false => false,
584 }
585 }
586 }
587 }
588}
589
590fn emit_provider_enum_display() -> TokenStream {
591 quote! {
592 impl std::fmt::Display for Provider {
593 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
594 f.write_str(self.parser_name())
595 }
596 }
597 }
598}
599
600fn emit_provider_enum_fromstr() -> TokenStream {
601 let catalog_arms = PROVIDERS.iter().map(|cfg| {
602 let v = format_ident!("{}", cfg.enum_name);
603 let name = cfg.parser_name;
604 quote! { #name => Ok(Self::#v), }
605 });
606
607 let dynamic_arms = DYNAMIC_PROVIDERS.iter().map(|d| {
608 let v = format_ident!("{}", d.enum_name);
609 let name = d.parser_name;
610 quote! { #name => Ok(Self::#v), }
611 });
612
613 quote! {
614 impl std::str::FromStr for Provider {
615 type Err = String;
616 fn from_str(s: &str) -> Result<Self, Self::Err> {
617 match s {
618 #(#catalog_arms)*
619 #(#dynamic_arms)*
620 other => Err(format!("Unknown provider: '{other}'")),
621 }
622 }
623 }
624 }
625}
626
627fn emit_provider_enums(provider_models: &ProviderModels) -> TokenStream {
628 let enums = PROVIDERS.iter().map(|cfg| {
629 let inner = format_ident!("{}", cfg.inner_enum_name());
630 let variants = provider_models[cfg.dev_id].iter().map(|m| format_ident!("{}", m.variant_name));
631 quote! {
632 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
633 pub enum #inner {
634 #(#variants,)*
635 }
636 }
637 });
638 quote! { #(#enums)* }
639}
640
641fn emit_provider_impls(provider_models: &ProviderModels) -> TokenStream {
642 let impls = PROVIDERS.iter().map(|cfg| {
643 let models = &provider_models[cfg.dev_id];
644 let enum_ident = format_ident!("{}", cfg.inner_enum_name());
645
646 let model_id_arms = models.iter().map(|m| {
647 let v = format_ident!("{}", m.variant_name);
648 let id = &m.model_id;
649 quote! { Self::#v => #id, }
650 });
651
652 let display_name_arms = grouped_arms(
653 models,
654 |m| m.display_name.clone(),
655 |m| {
656 let s = &m.display_name;
657 quote! { #s }
658 },
659 );
660
661 let context_window_arms =
662 grouped_arms(models, |m| m.context_window, |m| num_lit_with_underscores(m.context_window));
663
664 let reasoning_levels_arms = emit_reasoning_levels_arms(models);
665
666 let prompt_caching_arms = grouped_arms(
667 models,
668 |m| m.supports_prompt_caching,
669 |m| {
670 let b = m.supports_prompt_caching;
671 quote! { #b }
672 },
673 );
674
675 let modality_methods = ["image", "audio"].iter().map(|modality| {
676 let method = format_ident!("supports_{}", modality);
677 let mod_owned = (*modality).to_string();
678 let arms = grouped_arms(models, move |m| m.input_modalities.contains(&mod_owned), {
679 let mod_owned = (*modality).to_string();
680 move |m| {
681 let b = m.input_modalities.contains(&mod_owned);
682 quote! { #b }
683 }
684 });
685 quote! {
686 #[allow(clippy::too_many_lines)]
687 pub fn #method(self) -> bool {
688 match self { #arms }
689 }
690 }
691 });
692
693 let all_variants = models.iter().map(|m| format_ident!("{}", m.variant_name));
694
695 let from_str_impl = emit_from_str_impl(&enum_ident, cfg.parser_name, models);
696
697 quote! {
698 impl #enum_ident {
699 #[allow(clippy::too_many_lines)]
700 fn model_id(self) -> &'static str {
701 match self { #(#model_id_arms)* }
702 }
703
704 #[allow(clippy::too_many_lines)]
705 fn display_name(self) -> &'static str {
706 match self { #display_name_arms }
707 }
708
709 #[allow(clippy::too_many_lines)]
710 fn context_window(self) -> u32 {
711 match self { #context_window_arms }
712 }
713
714 #[allow(clippy::too_many_lines)]
715 pub fn reasoning_levels(self) -> &'static [ReasoningEffort] {
716 match self { #reasoning_levels_arms }
717 }
718
719 pub fn supports_reasoning(self) -> bool {
720 !self.reasoning_levels().is_empty()
721 }
722
723 #[allow(clippy::too_many_lines)]
724 pub fn supports_prompt_caching(self) -> bool {
725 match self { #prompt_caching_arms }
726 }
727
728 #(#modality_methods)*
729
730 const ALL: &[#enum_ident] = &[#(Self::#all_variants),*];
731 }
732
733 #from_str_impl
734 }
735 });
736 quote! { #(#impls)* }
737}
738
739fn emit_from_str_impl(enum_ident: &proc_macro2::Ident, parser_name: &str, models: &[ModelInfo]) -> TokenStream {
740 let arms = models.iter().map(|m| {
741 let id = &m.model_id;
742 let v = format_ident!("{}", m.variant_name);
743 quote! { #id => Ok(Self::#v), }
744 });
745 let err_msg = format!("Unknown {parser_name} model: '{{s}}'");
746 quote! {
747 impl std::str::FromStr for #enum_ident {
748 type Err = String;
749
750 #[allow(clippy::too_many_lines)]
751 fn from_str(s: &str) -> Result<Self, Self::Err> {
752 match s {
753 #(#arms)*
754 _ => Err(format!(#err_msg)),
755 }
756 }
757 }
758 }
759}
760
761fn grouped_arms<K, R>(
763 models: &[ModelInfo],
764 key_fn: impl Fn(&ModelInfo) -> K,
765 rhs_fn: impl Fn(&ModelInfo) -> R,
766) -> TokenStream
767where
768 K: Eq + Ord,
769 R: ToTokens,
770{
771 let mut groups: BTreeMap<K, Vec<&ModelInfo>> = BTreeMap::new();
772 for m in models {
773 groups.entry(key_fn(m)).or_default().push(m);
774 }
775 let arms = groups.values().map(|members| {
776 let pats = members.iter().map(|m| {
777 let v = format_ident!("{}", m.variant_name);
778 quote! { Self::#v }
779 });
780 let rhs = rhs_fn(members[0]);
781 quote! { #(#pats)|* => #rhs, }
782 });
783 quote! { #(#arms)* }
784}
785
786fn emit_reasoning_levels_arms(models: &[ModelInfo]) -> TokenStream {
787 grouped_arms(
788 models,
789 |m| m.reasoning_levels.clone(),
790 |m| {
791 if m.reasoning_levels.is_empty() {
792 quote! { &[] }
793 } else {
794 let items = m.reasoning_levels.iter().map(|l| {
795 let variant = format_ident!("{}", level_str_to_variant(l));
796 quote! { ReasoningEffort::#variant }
797 });
798 quote! { &[#(#items),*] }
799 }
800 },
801 )
802}
803
804fn level_str_to_variant(level: &str) -> String {
807 let canonical =
808 level.parse::<utils::ReasoningEffort>().unwrap_or_else(|_| panic!("Unknown reasoning level: {level}")).as_str();
809 let mut variant = canonical.to_string();
810 variant[..1].make_ascii_uppercase();
811 variant
812}
813
814fn emit_llm_model_enum() -> TokenStream {
815 let catalog_variants = PROVIDERS.iter().map(|cfg| {
816 let v = format_ident!("{}", cfg.enum_name);
817 let inner = format_ident!("{}Model", cfg.enum_name);
818 quote! { #v(#inner) }
819 });
820 let dynamic_variants = DYNAMIC_PROVIDERS.iter().map(|d| {
821 let v = format_ident!("{}", d.enum_name);
822 quote! { #v(String) }
823 });
824 quote! {
825 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
827 pub enum LlmModel {
828 #(#catalog_variants,)*
829 #(#dynamic_variants,)*
830 }
831 }
832}
833
834fn emit_from_impls() -> TokenStream {
835 let impls = PROVIDERS.iter().map(|cfg| {
836 let outer = format_ident!("{}Model", cfg.enum_name);
837 let v = format_ident!("{}", cfg.enum_name);
838 quote! {
839 impl From<#outer> for LlmModel {
840 fn from(m: #outer) -> Self {
841 LlmModel::#v(m)
842 }
843 }
844 }
845 });
846 quote! { #(#impls)* }
847}
848
849fn emit_llm_model_impl() -> TokenStream {
850 let model_id = emit_llm_model_id();
851 let display_name = emit_llm_display_name();
852 let provider = emit_llm_provider();
853 let provider_enum = emit_llm_provider_enum();
854 let provider_display_name = emit_llm_provider_display_name();
855 let context_window = emit_llm_context_window();
856 let required_env_var = emit_llm_required_env_var();
857 let all_required_env_vars = emit_llm_all_required_env_vars();
858 let oauth_provider_id = emit_llm_oauth_provider_id();
859 let reasoning_levels = emit_llm_reasoning_levels();
860 let supports_reasoning = emit_llm_supports_reasoning();
861 let supports_prompt_caching = emit_llm_supports_prompt_caching();
862 let modality_methods = ["image", "audio"].iter().map(|m| emit_llm_supports_modality(m));
863 let all = emit_llm_all();
864
865 quote! {
866 impl LlmModel {
867 #model_id
868 #display_name
869 #provider
870 #provider_enum
871 #provider_display_name
872 #context_window
873 #required_env_var
874 #all_required_env_vars
875 #oauth_provider_id
876 #reasoning_levels
877 #supports_reasoning
878 #supports_prompt_caching
879 #(#modality_methods)*
880 #all
881 }
882 }
883}
884
885fn emit_llm_model_id() -> TokenStream {
886 let catalog_arms = PROVIDERS.iter().map(|cfg| {
887 let v = format_ident!("{}", cfg.enum_name);
888 if cfg.is_hybrid_dynamic {
889 quote! { Self::#v(m) => m.model_id(), }
890 } else {
891 quote! { Self::#v(m) => Cow::Borrowed(m.model_id()), }
892 }
893 });
894 let dyn_pats = dynamic_pattern_with_binding("s");
895 quote! {
896 pub fn model_id(&self) -> Cow<'static, str> {
898 match self {
899 #(#catalog_arms)*
900 #dyn_pats => Cow::Owned(s.clone()),
901 }
902 }
903 }
904}
905
906fn emit_llm_display_name() -> TokenStream {
907 let catalog_arms = PROVIDERS.iter().map(|cfg| {
908 let v = format_ident!("{}", cfg.enum_name);
909 if cfg.is_hybrid_dynamic {
910 quote! { Self::#v(m) => m.display_name(), }
911 } else {
912 quote! { Self::#v(m) => Cow::Borrowed(m.display_name()), }
913 }
914 });
915 let dyn_arms = DYNAMIC_PROVIDERS.iter().map(|d| {
916 let v = format_ident!("{}", d.enum_name);
917 let fmt = format!("{} {{s}}", d.enum_name);
918 quote! { Self::#v(s) => Cow::Owned(format!(#fmt)), }
919 });
920 quote! {
921 pub fn display_name(&self) -> Cow<'static, str> {
923 match self {
924 #(#catalog_arms)*
925 #(#dyn_arms)*
926 }
927 }
928 }
929}
930
931fn emit_llm_provider() -> TokenStream {
932 let arms = llm_match_arms_ignored(|cfg| cfg.parser_name, |d| d.parser_name);
933 quote! {
934 pub fn provider(&self) -> &'static str {
936 match self { #arms }
937 }
938 }
939}
940
941fn emit_llm_provider_enum() -> TokenStream {
942 let arms = llm_match_arms_ignored(
943 |cfg| {
944 let v = format_ident!("{}", cfg.enum_name);
945 quote! { Provider::#v }
946 },
947 |d| {
948 let v = format_ident!("{}", d.enum_name);
949 quote! { Provider::#v }
950 },
951 );
952 quote! {
953 pub fn provider_enum(&self) -> Provider {
955 match self { #arms }
956 }
957 }
958}
959
960fn emit_llm_provider_display_name() -> TokenStream {
961 let arms = llm_match_arms_ignored(|cfg| cfg.display_name, |d| d.display_name);
962 quote! {
963 pub fn provider_display_name(&self) -> &'static str {
965 match self { #arms }
966 }
967 }
968}
969
970fn emit_llm_context_window() -> TokenStream {
971 let catalog_arms = PROVIDERS.iter().map(|cfg| {
972 let v = format_ident!("{}", cfg.enum_name);
973 if cfg.is_hybrid_dynamic {
974 quote! { Self::#v(m) => m.context_window(), }
975 } else {
976 quote! { Self::#v(m) => Some(m.context_window()), }
977 }
978 });
979 let dyn_pats = dynamic_pattern_with_binding("_");
980 quote! {
981 pub fn context_window(&self) -> Option<u32> {
983 match self {
984 #(#catalog_arms)*
985 #dyn_pats => None,
986 }
987 }
988 }
989}
990
991fn emit_llm_required_env_var() -> TokenStream {
992 let some_arms = PROVIDERS.iter().filter_map(|cfg| {
993 cfg.env_var.map(|var| {
994 let v = format_ident!("{}", cfg.enum_name);
995 quote! { Self::#v(_) => Some(#var), }
996 })
997 });
998 let none_pats = llm_or_pats(|cfg| cfg.env_var.is_none(), |_| true);
999 quote! {
1000 pub fn required_env_var(&self) -> Option<&'static str> {
1002 match self {
1003 #(#some_arms)*
1004 #none_pats => None,
1005 }
1006 }
1007 }
1008}
1009
1010fn emit_llm_all_required_env_vars() -> TokenStream {
1011 let vars = PROVIDERS.iter().filter_map(|cfg| cfg.env_var);
1012 quote! {
1013 pub const ALL_REQUIRED_ENV_VARS: &[&str] = &[#(#vars),*];
1015 }
1016}
1017
1018fn emit_llm_oauth_provider_id() -> TokenStream {
1019 let some_arms = PROVIDERS.iter().filter_map(|cfg| {
1020 cfg.oauth_provider_id.map(|id| {
1021 let v = format_ident!("{}", cfg.enum_name);
1022 quote! { Self::#v(_) => Some(#id), }
1023 })
1024 });
1025 let none_pats = llm_or_pats(|cfg| cfg.oauth_provider_id.is_none(), |_| true);
1026 quote! {
1027 pub fn oauth_provider_id(&self) -> Option<&'static str> {
1029 match self {
1030 #(#some_arms)*
1031 #none_pats => None,
1032 }
1033 }
1034 }
1035}
1036
1037fn emit_llm_reasoning_levels() -> TokenStream {
1038 let body = llm_delegate_with_dynamic_default("reasoning_levels", "e! { &[] });
1039 quote! {
1040 pub fn reasoning_levels(&self) -> &'static [ReasoningEffort] {
1042 #body
1043 }
1044 }
1045}
1046
1047fn emit_llm_supports_reasoning() -> TokenStream {
1048 quote! {
1049 pub fn supports_reasoning(&self) -> bool {
1051 !self.reasoning_levels().is_empty()
1052 }
1053 }
1054}
1055
1056fn emit_llm_supports_prompt_caching() -> TokenStream {
1057 let body = llm_delegate_with_dynamic_default("supports_prompt_caching", "e! { false });
1058 quote! {
1059 pub fn supports_prompt_caching(&self) -> bool {
1061 #body
1062 }
1063 }
1064}
1065
1066fn emit_llm_supports_modality(modality: &str) -> TokenStream {
1067 let method = format!("supports_{modality}");
1068 let method_ident = format_ident!("{}", method);
1069 let doc = format!(" Whether this model supports {modality} input");
1070 let body = llm_delegate_with_dynamic_default(&method, "e! { false });
1071 quote! {
1072 #[doc = #doc]
1073 pub fn #method_ident(&self) -> bool {
1074 #body
1075 }
1076 }
1077}
1078
1079fn emit_llm_all() -> TokenStream {
1080 let pushes = PROVIDERS.iter().map(|cfg| {
1081 let inner = format_ident!("{}", cfg.inner_enum_name());
1082 let outer = format_ident!("{}", cfg.outer_enum_name());
1083 let v = format_ident!("{}", cfg.enum_name);
1084 if cfg.is_hybrid_dynamic {
1085 quote! {
1086 v.extend(#inner::ALL.iter().copied().map(#outer::Foundation).map(LlmModel::#v));
1087 }
1088 } else {
1089 quote! {
1090 v.extend(#inner::ALL.iter().copied().map(LlmModel::#v));
1091 }
1092 }
1093 });
1094 quote! {
1095 pub fn all() -> &'static [LlmModel] {
1097 static ALL: LazyLock<Vec<LlmModel>> = LazyLock::new(|| {
1098 let mut v = Vec::new();
1099 #(#pushes)*
1100 v
1101 });
1102 &ALL
1103 }
1104 }
1105}
1106
1107fn emit_display_impl() -> TokenStream {
1108 quote! {
1109 impl std::fmt::Display for LlmModel {
1110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1111 write!(f, "{}:{}", self.provider(), self.model_id())
1112 }
1113 }
1114 }
1115}
1116
1117fn emit_fromstr_impl() -> TokenStream {
1118 let catalog_arms = PROVIDERS.iter().map(|cfg| {
1119 let name = cfg.parser_name;
1120 let outer = format_ident!("{}Model", cfg.enum_name);
1121 let v = format_ident!("{}", cfg.enum_name);
1122 quote! { #name => model_str.parse::<#outer>().map(Self::#v), }
1123 });
1124 let dyn_arms = DYNAMIC_PROVIDERS.iter().map(|d| {
1125 let name = d.parser_name;
1126 let v = format_ident!("{}", d.enum_name);
1127 quote! { #name => Ok(Self::#v(model_str.to_string())), }
1128 });
1129 quote! {
1130 impl std::str::FromStr for LlmModel {
1131 type Err = String;
1132
1133 fn from_str(s: &str) -> Result<Self, Self::Err> {
1135 let (provider_str, model_str) = s.split_once(':').unwrap_or((s, ""));
1136 match provider_str {
1137 #(#catalog_arms)*
1138 #(#dyn_arms)*
1139 _ => Err(format!("Unknown provider: '{provider_str}'")),
1140 }
1141 }
1142 }
1143 }
1144}
1145
1146fn dynamic_pattern_with_binding(binding: &str) -> TokenStream {
1148 let binding_ident = if binding == "_" {
1149 quote! { _ }
1150 } else {
1151 let b = format_ident!("{}", binding);
1152 quote! { #b }
1153 };
1154 let pats = DYNAMIC_PROVIDERS.iter().map(|d| {
1155 let v = format_ident!("{}", d.enum_name);
1156 quote! { Self::#v(#binding_ident) }
1157 });
1158 quote! { #(#pats)|* }
1159}
1160
1161fn provider_match_arms<V: ToTokens>(
1165 catalog_value: impl Fn(&ProviderConfig) -> V,
1166 dynamic_value: impl Fn(&DynamicProviderConfig) -> V,
1167) -> TokenStream {
1168 let catalog = PROVIDERS.iter().map(|cfg| {
1169 let v = format_ident!("{}", cfg.enum_name);
1170 let val = catalog_value(cfg);
1171 quote! { Self::#v => #val, }
1172 });
1173 let dynamic = DYNAMIC_PROVIDERS.iter().map(|d| {
1174 let v = format_ident!("{}", d.enum_name);
1175 let val = dynamic_value(d);
1176 quote! { Self::#v => #val, }
1177 });
1178 quote! { #(#catalog)* #(#dynamic)* }
1179}
1180
1181fn provider_or_pats(
1184 include_catalog: impl Fn(&ProviderConfig) -> bool,
1185 include_dynamic: impl Fn(&DynamicProviderConfig) -> bool,
1186) -> TokenStream {
1187 let catalog = PROVIDERS.iter().filter(|cfg| include_catalog(cfg)).map(|cfg| {
1188 let v = format_ident!("{}", cfg.enum_name);
1189 quote! { Self::#v }
1190 });
1191 let dynamic = DYNAMIC_PROVIDERS.iter().filter(|d| include_dynamic(d)).map(|d| {
1192 let v = format_ident!("{}", d.enum_name);
1193 quote! { Self::#v }
1194 });
1195 let pats = catalog.chain(dynamic);
1196 quote! { #(#pats)|* }
1197}
1198
1199fn llm_match_arms_ignored<V: ToTokens>(
1202 catalog_value: impl Fn(&ProviderConfig) -> V,
1203 dynamic_value: impl Fn(&DynamicProviderConfig) -> V,
1204) -> TokenStream {
1205 let catalog = PROVIDERS.iter().map(|cfg| {
1206 let v = format_ident!("{}", cfg.enum_name);
1207 let val = catalog_value(cfg);
1208 quote! { Self::#v(_) => #val, }
1209 });
1210 let dynamic = DYNAMIC_PROVIDERS.iter().map(|d| {
1211 let v = format_ident!("{}", d.enum_name);
1212 let val = dynamic_value(d);
1213 quote! { Self::#v(_) => #val, }
1214 });
1215 quote! { #(#catalog)* #(#dynamic)* }
1216}
1217
1218fn llm_or_pats(
1221 include_catalog: impl Fn(&ProviderConfig) -> bool,
1222 include_dynamic: impl Fn(&DynamicProviderConfig) -> bool,
1223) -> TokenStream {
1224 let catalog = PROVIDERS.iter().filter(|cfg| include_catalog(cfg)).map(|cfg| {
1225 let v = format_ident!("{}", cfg.enum_name);
1226 quote! { Self::#v(_) }
1227 });
1228 let dynamic = DYNAMIC_PROVIDERS.iter().filter(|d| include_dynamic(d)).map(|d| {
1229 let v = format_ident!("{}", d.enum_name);
1230 quote! { Self::#v(_) }
1231 });
1232 let pats = catalog.chain(dynamic);
1233 quote! { #(#pats)|* }
1234}
1235
1236fn llm_delegate_with_dynamic_default(method: &str, dynamic_value: &TokenStream) -> TokenStream {
1240 let method_ident = format_ident!("{}", method);
1241 let catalog_arms = PROVIDERS.iter().map(|cfg| {
1242 let v = format_ident!("{}", cfg.enum_name);
1243 quote! { Self::#v(m) => m.#method_ident(), }
1244 });
1245 let dyn_pat = dynamic_pattern_with_binding("_");
1246 quote! {
1247 match self {
1248 #(#catalog_arms)*
1249 #dyn_pat => #dynamic_value,
1250 }
1251 }
1252}
1253
1254fn num_lit_with_underscores(n: u32) -> TokenStream {
1256 format_number(n).parse().expect("formatted number parses as a token")
1257}
1258
1259fn format_number(n: u32) -> String {
1261 let s = n.to_string();
1262 if s.len() <= 4 {
1263 return s;
1264 }
1265 let mut result = String::with_capacity(s.len() + s.len() / 3);
1266 for (i, ch) in s.chars().enumerate() {
1267 if i > 0 && (s.len() - i).is_multiple_of(3) {
1268 result.push('_');
1269 }
1270 result.push(ch);
1271 }
1272 result
1273}
1274
1275fn emit_provider_docs(ctx: &CodegenCtx) -> HashMap<String, String> {
1276 let mut docs = HashMap::new();
1277
1278 for cfg in PROVIDERS {
1279 let models = &ctx.provider_models[cfg.dev_id];
1280 let mut doc = String::new();
1281
1282 pushln(&mut doc, format!("`{}` LLM provider.", cfg.display_name));
1283 blank(&mut doc);
1284
1285 pushln(&mut doc, "# Authentication");
1286 blank(&mut doc);
1287 match cfg.env_var {
1288 Some(var) => pushln(&mut doc, format!("Set the `{var}` environment variable.")),
1289 None if cfg.oauth_provider_id.is_some() => {
1290 pushln(&mut doc, "This provider uses OAuth authentication.");
1291 }
1292 None => {
1293 pushln(
1294 &mut doc,
1295 "Uses the default AWS credential chain (environment variables, config files, IAM roles).",
1296 );
1297 }
1298 }
1299 blank(&mut doc);
1300
1301 pushln(&mut doc, "# Supported models");
1302 blank(&mut doc);
1303 pushln(&mut doc, "| Model ID | Name | Context | Reasoning | Image | Audio |");
1304 pushln(&mut doc, "|----------|------|---------|-----------|-------|-------|");
1305 for model in models {
1306 let ctx_str = format_context_window(model.context_window);
1307 let reasoning = if model.reasoning_levels.is_empty() { "" } else { "yes" };
1308 let image = if model.input_modalities.contains(&"image".to_string()) { "yes" } else { "" };
1309 let audio = if model.input_modalities.contains(&"audio".to_string()) { "yes" } else { "" };
1310 pushln(
1311 &mut doc,
1312 format!(
1313 "| `{}` | `{}` | `{}` | {} | {} | {} |",
1314 model.model_id, model.display_name, ctx_str, reasoning, image, audio
1315 ),
1316 );
1317 }
1318
1319 docs.insert(cfg.dev_id.to_string(), doc);
1320 }
1321
1322 for dyn_cfg in DYNAMIC_PROVIDERS {
1323 let mut doc = String::new();
1324 pushln(&mut doc, format!("`{}` LLM provider.", dyn_cfg.display_name));
1325 blank(&mut doc);
1326 pushln(
1327 &mut doc,
1328 format!("This provider accepts any model name at runtime (e.g. `{}:my-model`).", dyn_cfg.parser_name),
1329 );
1330 pushln(&mut doc, "No API key is required.");
1331 docs.insert(dyn_cfg.parser_name.to_string(), doc);
1332 }
1333
1334 docs
1335}
1336
1337fn format_context_window(tokens: u32) -> String {
1339 if tokens == 0 {
1340 return "unknown".to_string();
1341 }
1342 if tokens >= 1_000_000 && tokens.is_multiple_of(1_000_000) {
1343 format!("{}M", tokens / 1_000_000)
1344 } else if tokens >= 1_000 && tokens.is_multiple_of(1_000) {
1345 format!("{}k", tokens / 1_000)
1346 } else {
1347 format_number(tokens)
1348 }
1349}
1350
1351fn pushln(out: &mut String, line: impl AsRef<str>) {
1352 writeln!(out, "{}", line.as_ref()).expect("writing to String should not fail");
1353}
1354
1355fn blank(out: &mut String) {
1356 pushln(out, "");
1357}
1358
1359#[cfg(test)]
1360mod tests {
1361 use super::*;
1362 use serde_json::Value;
1363 use serde_json::json;
1364 use tempfile::NamedTempFile;
1365
1366 #[test]
1369 fn model_id_to_variant_pascal_cases_segments() {
1370 assert_eq!(model_id_to_variant("claude-sonnet-4-5-20250929"), "ClaudeSonnet4520250929");
1371 assert_eq!(model_id_to_variant("gemini-2.5-flash"), "Gemini25Flash");
1372 assert_eq!(model_id_to_variant("deepseek-chat"), "DeepseekChat");
1373 assert_eq!(model_id_to_variant("glm-4.5"), "Glm45");
1374 }
1375
1376 #[test]
1377 fn model_id_to_variant_handles_slash_and_colon() {
1378 assert_eq!(model_id_to_variant("anthropic/claude-opus-4.6"), "AnthropicClaudeOpus46");
1379 assert_eq!(model_id_to_variant("openai/gpt-5.1-codex-max"), "OpenaiGpt51CodexMax");
1380 assert_eq!(model_id_to_variant("deepseek/deepseek-r1:free"), "DeepseekDeepseekR1Free");
1381 }
1382
1383 #[test]
1384 fn is_alias_detects_latest_suffix() {
1385 assert!(is_alias("claude-sonnet-4-5-latest"));
1386 assert!(is_alias("claude-3-7-sonnet-latest"));
1387 assert!(!is_alias("claude-sonnet-4-5-20250929"));
1388 }
1389
1390 #[test]
1391 fn build_uses_explicit_context_windows_for_codex_models() {
1392 let data = minimal_models_dev_json();
1393
1394 let models = build_from_value(&data);
1395 let window = |id: &str| models["codex"].iter().find(|model| model.model_id == id).unwrap().context_window;
1396 for model_id in ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.2"] {
1397 assert_eq!(window(model_id), 272_000);
1398 }
1399 for model_id in ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] {
1400 assert_eq!(window(model_id), 372_000);
1401 }
1402 }
1403
1404 #[test]
1405 fn format_context_window_formats_correctly() {
1406 assert_eq!(format_context_window(1_000_000), "1M");
1407 assert_eq!(format_context_window(200_000), "200k");
1408 assert_eq!(format_context_window(8_000), "8k");
1409 assert_eq!(format_context_window(0), "unknown");
1410 }
1411
1412 #[test]
1413 fn level_str_to_variant_covers_all_reasoning_efforts() {
1414 for effort in utils::ReasoningEffort::all() {
1415 let _ = level_str_to_variant(effort.as_str());
1416 }
1417 }
1418
1419 #[test]
1420 fn build_sorts_models_and_filters_aliases_and_non_tool_call() {
1421 let mut data = minimal_models_dev_json();
1422 anthropic_models(
1423 &mut data,
1424 json!({
1425 "b-model": {"id": "b-model", "name": "B Model", "tool_call": true, "limit": {"context": 2000, "output": 0}},
1426 "a-model": {"id": "a-model", "name": "A Model", "tool_call": true, "limit": {"context": 1000, "output": 0}},
1427 "alpha-latest": {"id": "alpha-latest", "name": "Alias", "tool_call": true, "limit": {"context": 500, "output": 0}},
1428 "no-tools": {"id": "no-tools", "name": "No Tools", "tool_call": false, "limit": {"context": 500, "output": 0}}
1429 }),
1430 );
1431
1432 let models = build_from_value(&data);
1433 let ids: Vec<&str> = models["anthropic"].iter().map(|m| m.model_id.as_str()).collect();
1434 assert_eq!(ids, vec!["a-model", "b-model"]);
1435 }
1436
1437 #[test]
1438 fn build_extra_source_ids_merges_unique_models_into_provider() {
1439 let mut data = minimal_models_dev_json();
1440 zai_extra_models(
1441 &mut data,
1442 json!({
1443 "extra-model": {"id": "extra-model", "name": "Extra Model", "tool_call": true, "limit": {"context": 4000, "output": 0}}
1444 }),
1445 );
1446
1447 let models = build_from_value(&data);
1448 assert!(models["zai"].iter().any(|m| m.model_id == "extra-model"));
1449 }
1450
1451 #[test]
1452 fn build_extra_source_ids_does_not_duplicate_existing_models() {
1453 let mut data = minimal_models_dev_json();
1454 let shared = json!({
1455 "shared-model": {"id": "shared-model", "name": "Shared Model", "tool_call": true, "limit": {"context": 1000, "output": 0}}
1456 });
1457 insert_models(&mut data, "zai", shared.clone());
1458 insert_models(&mut data, "zai-coding-plan", shared);
1459
1460 let models = build_from_value(&data);
1461 let count = models["zai"].iter().filter(|m| m.model_id == "shared-model").count();
1462 assert_eq!(count, 1);
1463 }
1464
1465 #[test]
1466 fn build_derives_reasoning_levels_from_source_metadata() {
1467 let mut data = minimal_models_dev_json();
1468 anthropic_models(
1469 &mut data,
1470 json!({
1471 "claude-test": {
1472 "id": "claude-test", "name": "Claude Test", "tool_call": true, "reasoning": true,
1473 "reasoning_options": [{"type": "effort", "values": ["low", "high", "max"]}],
1474 "limit": {"context": 200_000, "output": 0}
1475 }
1476 }),
1477 );
1478
1479 let models = build_from_value(&data);
1480 let model = models["anthropic"].iter().find(|model| model.model_id == "claude-test").unwrap();
1481 assert_eq!(model.reasoning_levels, ["low", "high", "max"]);
1482 }
1483
1484 #[test]
1485 fn build_rejects_unknown_reasoning_effort_metadata() {
1486 let mut data = minimal_models_dev_json();
1487 anthropic_models(
1488 &mut data,
1489 json!({
1490 "claude-test": {
1491 "id": "claude-test", "name": "Claude Test", "tool_call": true, "reasoning": true,
1492 "reasoning_options": [{"type": "effort", "values": ["ultra"]}],
1493 "limit": {"context": 200_000, "output": 0}
1494 }
1495 }),
1496 );
1497 let parsed: ModelsDevData = serde_json::from_value(data).unwrap();
1498
1499 let error = build_provider_models(&parsed).unwrap_err();
1500
1501 assert!(matches!(error, CodegenError::UnsupportedReasoningEffort { .. }));
1502 }
1503
1504 #[test]
1505 fn build_derives_prompt_caching_from_cost_fields() {
1506 let mut data = minimal_models_dev_json();
1507 insert_models(
1508 &mut data,
1509 "amazon-bedrock",
1510 json!({
1511 "cached": {
1512 "id": "cached", "name": "Cached", "tool_call": true,
1513 "limit": {"context": 200_000, "output": 0},
1514 "cost": {"input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_write": 3.75}
1515 },
1516 "uncached": {
1517 "id": "uncached", "name": "Uncached", "tool_call": true,
1518 "limit": {"context": 200_000, "output": 0},
1519 "cost": {"input": 3.0, "output": 15.0}
1520 }
1521 }),
1522 );
1523
1524 let models = build_from_value(&data);
1525 let bedrock = &models["amazon-bedrock"];
1526 let cached = bedrock.iter().find(|m| m.model_id == "cached").unwrap();
1527 let uncached = bedrock.iter().find(|m| m.model_id == "uncached").unwrap();
1528 assert!(cached.supports_prompt_caching);
1529 assert!(!uncached.supports_prompt_caching);
1530 }
1531
1532 #[test]
1533 fn build_assigns_codex_model_specific_reasoning_levels() {
1534 let mut data = minimal_models_dev_json();
1535 insert_models(
1536 &mut data,
1537 "openai",
1538 json!({
1539 "gpt-5.6-sol": {
1540 "id": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "tool_call": true, "reasoning": true,
1541 "reasoning_options": [{"type": "effort", "values": ["none", "low", "medium", "high", "xhigh", "max"]}],
1542 "limit": {"context": 200_000, "output": 0}
1543 },
1544 "gpt-5.6-luna": {
1545 "id": "gpt-5.6-luna", "name": "GPT-5.6 Luna", "tool_call": true, "reasoning": true,
1546 "reasoning_options": [{"type": "effort", "values": ["none", "low", "medium", "high", "xhigh", "max"]}],
1547 "limit": {"context": 200_000, "output": 0}
1548 },
1549 "gpt-5.4": {
1550 "id": "gpt-5.4", "name": "GPT-5.4", "tool_call": true, "reasoning": true,
1551 "limit": {"context": 200_000, "output": 0}
1552 }
1553 }),
1554 );
1555
1556 let models = build_from_value(&data);
1557 let levels = |id: &str| models["codex"].iter().find(|m| m.model_id == id).unwrap().reasoning_levels.clone();
1558 assert_eq!(levels("gpt-5.6-sol"), vec!["low", "medium", "high", "xhigh", "max"]);
1559 assert_eq!(levels("gpt-5.6-luna"), vec!["low", "medium", "high", "xhigh", "max"]);
1560 assert_eq!(levels("gpt-5.4"), vec!["low", "medium", "high", "xhigh"]);
1561 }
1562
1563 #[test]
1564 fn build_applies_codex_subscription_context_window_override() {
1565 let mut data = minimal_models_dev_json();
1566 insert_models(
1567 &mut data,
1568 "openai",
1569 json!({
1570 "gpt-5.5": {
1571 "id": "gpt-5.5", "name": "GPT-5.5", "tool_call": true, "reasoning": true,
1572 "limit": {"context": 1_050_000, "output": 128_000}
1573 }
1574 }),
1575 );
1576
1577 let models = build_from_value(&data);
1578 let codex = models["codex"].iter().find(|m| m.model_id == "gpt-5.5").unwrap();
1579 let openai = models["openai"].iter().find(|m| m.model_id == "gpt-5.5").unwrap();
1580 assert_eq!(codex.context_window, 272_000);
1581 assert_eq!(openai.context_window, 1_050_000);
1582 }
1583
1584 #[test]
1587 fn generate_uses_codex_subscription_model_ids() {
1588 let mut data = minimal_models_dev_json();
1589 insert_models(
1590 &mut data,
1591 "openai",
1592 json!({
1593 "gpt-5.1-codex": {
1594 "id": "gpt-5.1-codex", "name": "GPT-5.1 Codex", "tool_call": true, "reasoning": true,
1595 "limit": {"context": 400_000, "output": 128_000}
1596 },
1597 "gpt-5.6": {
1598 "id": "gpt-5.6", "name": "GPT-5.6 Sol", "tool_call": true, "reasoning": true,
1599 "limit": {"context": 1_050_000, "output": 128_000}
1600 },
1601 "gpt-5.6-sol": {
1602 "id": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "tool_call": true, "reasoning": true,
1603 "limit": {"context": 1_050_000, "output": 128_000}
1604 },
1605 "gpt-5.6-terra": {
1606 "id": "gpt-5.6-terra", "name": "GPT-5.6 Terra", "tool_call": true, "reasoning": true,
1607 "limit": {"context": 1_050_000, "output": 128_000}
1608 },
1609 "gpt-5.6-luna": {
1610 "id": "gpt-5.6-luna", "name": "GPT-5.6 Luna", "tool_call": true, "reasoning": true,
1611 "limit": {"context": 1_050_000, "output": 128_000}
1612 }
1613 }),
1614 );
1615
1616 let tmp = NamedTempFile::new().unwrap();
1617 std::fs::write(tmp.path(), serde_json::to_string(&data).unwrap()).unwrap();
1618 let output = generate(tmp.path()).unwrap();
1619
1620 let codex_doc = &output.provider_docs["codex"];
1621 assert!(!codex_doc.contains("`gpt-5.6`"));
1622 assert!(!codex_doc.contains("`gpt-5.1-codex`"));
1623 assert!(codex_doc.contains("| `gpt-5.6-sol` | `GPT-5.6 Sol` | `372k` |"));
1624 assert!(codex_doc.contains("| `gpt-5.6-terra` | `GPT-5.6 Terra` | `372k` |"));
1625 assert!(codex_doc.contains("| `gpt-5.6-luna` | `GPT-5.6 Luna` | `372k` |"));
1626
1627 let openai_doc = &output.provider_docs["openai"];
1628 assert!(openai_doc.contains("`gpt-5.6`"));
1629 assert!(openai_doc.contains("`gpt-5.1-codex`"));
1630 assert!(openai_doc.contains("`gpt-5.6-sol`"));
1631 }
1632
1633 #[test]
1634 fn generate_emits_provider_docs() {
1635 let mut data = minimal_models_dev_json();
1636 anthropic_models(
1637 &mut data,
1638 json!({
1639 "claude-test": {
1640 "id": "claude-test", "name": "Claude Test", "tool_call": true, "reasoning": true,
1641 "limit": {"context": 200_000, "output": 0},
1642 "modalities": {"input": ["text", "image"]}
1643 }
1644 }),
1645 );
1646
1647 let tmp = NamedTempFile::new().unwrap();
1648 std::fs::write(tmp.path(), serde_json::to_string(&data).unwrap()).unwrap();
1649 let output = generate(tmp.path()).unwrap();
1650
1651 let anthropic_doc = &output.provider_docs["anthropic"];
1652 assert!(anthropic_doc.contains("`Anthropic` LLM provider."));
1653 assert!(anthropic_doc.contains("`ANTHROPIC_API_KEY`"));
1654 assert!(anthropic_doc.contains("| `claude-test` | `Claude Test` | `200k` | yes | yes | |"));
1655
1656 let ollama_doc = &output.provider_docs["ollama"];
1657 assert!(ollama_doc.contains("`Ollama` LLM provider."));
1658 assert!(ollama_doc.contains("any model name at runtime"));
1659 }
1660
1661 fn build_from_value(data: &Value) -> ProviderModels {
1662 let parsed: ModelsDevData = serde_json::from_value(data.clone()).expect("parse fixture");
1663 build_provider_models(&parsed).expect("build provider models")
1664 }
1665
1666 fn anthropic_models(data: &mut Value, models: Value) {
1667 insert_models(data, "anthropic", models);
1668 }
1669
1670 fn zai_extra_models(data: &mut Value, models: Value) {
1671 insert_models(data, "zai-coding-plan", models);
1672 }
1673
1674 fn insert_models(data: &mut Value, provider_key: &str, models: Value) {
1675 let provider = data.as_object_mut().unwrap().get_mut(provider_key).unwrap().as_object_mut().unwrap();
1676 let target = provider.get_mut("models").unwrap().as_object_mut().unwrap();
1677 let Value::Object(models) = models else {
1678 panic!("models fixture must be an object");
1679 };
1680 target.extend(models);
1681 }
1682
1683 fn minimal_models_dev_json() -> Value {
1684 let mut root = serde_json::Map::new();
1685 for cfg in PROVIDERS {
1686 let json_key = cfg.json_key();
1687 root.entry(json_key.to_string())
1688 .or_insert_with(|| json!({"id": json_key, "name": json_key, "env": [], "models": {}}));
1689 for &extra in cfg.extra_source_ids {
1690 root.entry(extra.to_string())
1691 .or_insert_with(|| json!({"id": extra, "name": extra, "env": [], "models": {}}));
1692 }
1693 }
1694 let openai = root.get_mut("openai").unwrap()["models"].as_object_mut().unwrap();
1695 for model in CODEX_SUBSCRIPTION_MODELS {
1696 openai.insert(
1697 model.id.to_string(),
1698 json!({
1699 "id": model.id,
1700 "name": model.id,
1701 "tool_call": true,
1702 "reasoning": true,
1703 "reasoning_options": [{"type": "effort", "values": ["low", "medium", "high", "xhigh"]}],
1704 "limit": {"context": 1_050_000, "output": 0}
1705 }),
1706 );
1707 }
1708 Value::Object(root)
1709 }
1710}