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