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