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