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