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 models.sort_by(|a, b| a.model_id.cmp(&b.model_id));
428 provider_models.insert(cfg.dev_id, models);
429 }
430
431 Ok(provider_models)
432}
433
434fn validate_provider_config(cfg: &ProviderConfig, provider: &ProviderData) -> Result<(), CodegenError> {
435 let Some(explicit_models) = cfg.explicit_models else {
436 return Ok(());
437 };
438 let mut seen = HashSet::new();
439 for configured in explicit_models {
440 if !seen.insert(configured.id) {
441 return Err(CodegenError::DuplicateConfiguredModel {
442 provider_id: cfg.dev_id.to_string(),
443 model_id: configured.id.to_string(),
444 });
445 }
446 let Some(model) = provider.models.get(configured.id) else {
447 return Err(CodegenError::ConfiguredModelNotFound {
448 provider_id: cfg.dev_id.to_string(),
449 model_id: configured.id.to_string(),
450 });
451 };
452 if model.tool_call != Some(true) {
453 return Err(CodegenError::ConfiguredModelUnavailable {
454 provider_id: cfg.dev_id.to_string(),
455 model_id: configured.id.to_string(),
456 });
457 }
458 }
459 Ok(())
460}
461
462fn collect_models_from(
463 cfg: &ProviderConfig,
464 models: &HashMap<String, ModelData>,
465) -> Result<Vec<ModelInfo>, CodegenError> {
466 models
467 .values()
468 .filter(|m| m.tool_call == Some(true))
469 .filter(|m| !is_alias(&m.id))
470 .filter(|m| cfg.explicit_models.is_none() || cfg.explicit_model(&m.id).is_some())
471 .map(|m| {
472 let reasoning_levels =
473 if m.reasoning.unwrap_or(false) { reasoning_levels_for_model(cfg, m)? } else { Vec::new() };
474 let input_modalities =
475 m.modalities.as_ref().map_or_else(|| vec!["text".to_string()], |md| md.input.clone());
476 let source_context_window = m.limit.as_ref().map_or(0, |l| l.context);
477 let context_window =
478 cfg.explicit_model(&m.id).map_or(source_context_window, |explicit| explicit.context_window);
479 let pricing = if cfg.dev_id == "codex" { None } else { m.cost.clone() };
480 Ok(ModelInfo {
481 variant_name: model_id_to_variant(&m.id),
482 model_id: m.id.clone(),
483 display_name: m.name.clone(),
484 context_window,
485 reasoning_levels,
486 disabled_support: disabled_support_for_model(cfg, m),
487 input_modalities,
488 supports_prompt_caching: m.cost.as_ref().is_some_and(CostData::has_prompt_caching),
489 pricing,
490 transport: transport_for_model(cfg, m)?,
491 })
492 })
493 .collect()
494}
495
496fn transport_for_model(cfg: &ProviderConfig, model: &ModelData) -> Result<Option<TransportInfo>, CodegenError> {
497 if !cfg.use_model_transport {
498 return Ok(None);
499 }
500 let Some(provider) = &model.provider else {
501 return Ok(None);
502 };
503
504 match (&provider.api, provider.shape.as_deref()) {
505 (None, None) => Ok(None),
506 (Some(base_url_template), Some("responses")) => {
507 Ok(Some(TransportInfo::OpenAiResponses { base_url_template: base_url_template.clone() }))
508 }
509 (_, Some(shape)) if shape != "responses" => {
510 Err(CodegenError::UnsupportedWireShape { model_id: model.id.clone(), shape: shape.to_string() })
511 }
512 _ => Err(CodegenError::IncompleteTransport { model_id: model.id.clone() }),
513 }
514}
515
516fn disabled_support_for_model(cfg: &ProviderConfig, model: &ModelData) -> &'static str {
517 if !model.reasoning.unwrap_or(false) {
518 return "Unsupported";
519 }
520 if let Some(explicit) = cfg.explicit_model(&model.id) {
521 return if explicit.supports_reasoning_off { "Effort" } else { "Unsupported" };
522 }
523 if model.reasoning_options.iter().any(|option| {
524 matches!(option,
525 ReasoningOption::Effort { values } if values.iter().any(|value| value.as_deref() == Some("none"))
526 )
527 }) {
528 "Effort"
529 } else if model.reasoning_options.iter().any(|option| matches!(option, ReasoningOption::Toggle)) {
530 "Toggle"
531 } else {
532 "Unsupported"
533 }
534}
535
536fn reasoning_levels_for_model(cfg: &ProviderConfig, model: &ModelData) -> Result<Vec<String>, CodegenError> {
537 let values = model
538 .reasoning_options
539 .iter()
540 .filter_map(|option| match option {
541 ReasoningOption::Effort { values } => Some(values),
542 ReasoningOption::Toggle | ReasoningOption::BudgetTokens => None,
543 })
544 .collect::<Vec<_>>();
545 let mut levels = Vec::new();
546 if values.is_empty() {
547 levels.extend(cfg.fallback_reasoning_levels.iter().map(|level| (*level).to_string()));
548 } else {
549 for effort in values.into_iter().flatten().filter_map(|value| value.as_deref()) {
550 if matches!(effort, "none" | "default") {
551 continue;
552 }
553 let parsed =
554 effort.parse::<utils::ReasoningEffort>().ok().filter(|effort| effort.is_enabled()).ok_or_else(
555 || CodegenError::UnsupportedReasoningEffort {
556 model_id: model.id.clone(),
557 effort: effort.to_string(),
558 },
559 )?;
560 levels.push(parsed.as_str().to_string());
561 }
562 }
563 if disabled_support_for_model(cfg, model) != "Unsupported" {
564 levels.push("disabled".to_string());
565 }
566 Ok(utils::ReasoningEffort::selectable_levels()
567 .iter()
568 .filter(|level| levels.iter().any(|value| value == level.as_str()))
569 .map(|level| level.as_str().to_string())
570 .collect())
571}
572
573fn is_alias(id: &str) -> bool {
575 id.ends_with("-latest")
576}
577
578fn model_id_to_variant(id: &str) -> String {
581 let mut result = String::new();
582 let mut capitalize_next = true;
583
584 for ch in id.chars() {
585 if ch == '-' || ch == '.' || ch == '/' || ch == ':' {
586 capitalize_next = true;
587 } else if capitalize_next {
588 result.push(ch.to_ascii_uppercase());
589 capitalize_next = false;
590 } else {
591 result.push(ch);
592 }
593 }
594
595 if result.starts_with(|c: char| c.is_ascii_digit()) {
596 result.insert(0, '_');
597 }
598
599 result
600}
601
602fn emit_generated_source(ctx: &CodegenCtx) -> String {
603 let provider_enum = emit_provider_enum();
604 let provider_enum_impl = emit_provider_enum_impl();
605 let provider_enum_display = emit_provider_enum_display();
606 let provider_enum_fromstr = emit_provider_enum_fromstr();
607 let provider_enums = emit_provider_enums(&ctx.provider_models);
608 let provider_impls = emit_provider_impls(&ctx.provider_models);
609 let llm_model_enum = emit_llm_model_enum();
610 let from_impls = emit_from_impls();
611 let llm_model_impl = emit_llm_model_impl();
612 let display_impl = emit_display_impl();
613 let fromstr_impl = emit_fromstr_impl();
614
615 let file_tokens = quote! {
616 use std::borrow::Cow;
617 use std::sync::LazyLock;
618 use crate::ReasoningEffort;
619
620 #provider_enum
621 #provider_enum_impl
622 #provider_enum_display
623 #provider_enum_fromstr
624 #provider_enums
625 #provider_impls
626 #llm_model_enum
627 #from_impls
628 #llm_model_impl
629 #display_impl
630 #fromstr_impl
631 };
632
633 let file: syn::File = syn::parse2(file_tokens).expect("generated tokens parse as Rust");
634 let formatted = prettyplease::unparse(&file);
635 format!(
636 "// Auto-generated from models.dev — do not edit manually\n// Regenerated automatically by build.rs\n\n{formatted}"
637 )
638}
639
640fn emit_provider_enum() -> TokenStream {
641 let catalog_variants = PROVIDERS.iter().map(|cfg| format_ident!("{}", cfg.enum_name));
642 let dynamic_variants = DYNAMIC_PROVIDERS.iter().map(|d| format_ident!("{}", d.enum_name));
643 quote! {
644 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
648 pub enum Provider {
649 #(#catalog_variants,)*
650 #(#dynamic_variants,)*
651 }
652 }
653}
654
655fn emit_provider_enum_impl() -> TokenStream {
656 let parser_arms = provider_match_arms(|cfg| cfg.parser_name, |d| d.parser_name);
657 let genai_provider_name_arms = provider_match_arms(|cfg| cfg.genai_provider_name, |d| d.genai_provider_name);
658 let display_arms = provider_match_arms(|cfg| cfg.display_name, |d| d.display_name);
659
660 let env_var_some = PROVIDERS.iter().filter_map(|cfg| {
661 cfg.env_var.map(|var| {
662 let v = format_ident!("{}", cfg.enum_name);
663 quote! { Self::#v => Some(#var), }
664 })
665 });
666
667 let env_var_none = provider_or_pats(|cfg| cfg.env_var.is_none(), |_| true);
668 let oauth_some = PROVIDERS.iter().filter_map(|cfg| {
669 cfg.oauth_provider_id.map(|id| {
670 let v = format_ident!("{}", cfg.enum_name);
671 quote! { Self::#v => Some(#id), }
672 })
673 });
674 let oauth_none = provider_or_pats(|cfg| cfg.oauth_provider_id.is_none(), |_| true);
675
676 let is_local_true = provider_or_pats(|_| false, |_| true);
677 let is_local_false = provider_or_pats(|_| true, |_| false);
678 let all_variants = PROVIDERS
679 .iter()
680 .map(|cfg| format_ident!("{}", cfg.enum_name))
681 .chain(DYNAMIC_PROVIDERS.iter().map(|d| format_ident!("{}", d.enum_name)));
682
683 quote! {
684 impl Provider {
685 pub const ALL: &[Provider] = &[#(Self::#all_variants),*];
687
688 pub fn parser_name(self) -> &'static str {
690 match self { #parser_arms }
691 }
692
693 #[allow(clippy::match_same_arms)]
695 pub fn genai_provider_name(self) -> &'static str {
696 match self { #genai_provider_name_arms }
697 }
698
699 pub fn display_name(self) -> &'static str {
701 match self { #display_arms }
702 }
703
704 pub fn required_env_var(self) -> Option<&'static str> {
706 match self {
707 #(#env_var_some)*
708 #env_var_none => None,
709 }
710 }
711
712 pub fn oauth_provider_id(self) -> Option<&'static str> {
714 match self {
715 #(#oauth_some)*
716 #oauth_none => None,
717 }
718 }
719
720 pub fn is_local(self) -> bool {
723 match self {
724 #is_local_true => true,
725 #is_local_false => false,
726 }
727 }
728 }
729 }
730}
731
732fn emit_provider_enum_display() -> TokenStream {
733 quote! {
734 impl std::fmt::Display for Provider {
735 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
736 f.write_str(self.parser_name())
737 }
738 }
739 }
740}
741
742fn emit_provider_enum_fromstr() -> TokenStream {
743 let catalog_arms = PROVIDERS.iter().map(|cfg| {
744 let v = format_ident!("{}", cfg.enum_name);
745 let name = cfg.parser_name;
746 quote! { #name => Ok(Self::#v), }
747 });
748
749 let dynamic_arms = DYNAMIC_PROVIDERS.iter().map(|d| {
750 let v = format_ident!("{}", d.enum_name);
751 let name = d.parser_name;
752 quote! { #name => Ok(Self::#v), }
753 });
754
755 quote! {
756 impl std::str::FromStr for Provider {
757 type Err = String;
758 fn from_str(s: &str) -> Result<Self, Self::Err> {
759 match s {
760 #(#catalog_arms)*
761 #(#dynamic_arms)*
762 other => Err(format!("Unknown provider: '{other}'")),
763 }
764 }
765 }
766 }
767}
768
769fn emit_provider_enums(provider_models: &ProviderModels) -> TokenStream {
770 let enums = PROVIDERS.iter().map(|cfg| {
771 let inner = format_ident!("{}", cfg.inner_enum_name());
772 let variants = provider_models[cfg.dev_id].iter().map(|m| format_ident!("{}", m.variant_name));
773 quote! {
774 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
775 pub enum #inner {
776 #(#variants,)*
777 }
778 }
779 });
780 quote! { #(#enums)* }
781}
782
783fn emit_provider_impls(provider_models: &ProviderModels) -> TokenStream {
784 let impls = PROVIDERS.iter().map(|cfg| {
785 let models = &provider_models[cfg.dev_id];
786 let enum_ident = format_ident!("{}", cfg.inner_enum_name());
787
788 let model_id_arms = models.iter().map(|m| {
789 let v = format_ident!("{}", m.variant_name);
790 let id = &m.model_id;
791 quote! { Self::#v => #id, }
792 });
793
794 let display_name_arms = grouped_arms(
795 models,
796 |m| m.display_name.clone(),
797 |m| {
798 let s = &m.display_name;
799 quote! { #s }
800 },
801 );
802
803 let context_window_arms =
804 grouped_arms(models, |m| m.context_window, |m| num_lit_with_underscores(m.context_window));
805
806 let reasoning_levels_arms = emit_reasoning_levels_arms(models);
807 let disabled_support_arms = grouped_arms(
808 models,
809 |m| m.disabled_support,
810 |m| {
811 let variant = format_ident!("{}", m.disabled_support);
812 quote! { crate::reasoning::ReasoningDisabledSupport::#variant }
813 },
814 );
815
816 let prompt_caching_arms = grouped_arms(
817 models,
818 |m| m.supports_prompt_caching,
819 |m| {
820 let b = m.supports_prompt_caching;
821 quote! { #b }
822 },
823 );
824
825 let pricing_arms = emit_pricing_arms(models);
826
827 let modality_methods = ["image", "audio"].iter().map(|modality| {
828 let method = format_ident!("supports_{}", modality);
829 let mod_owned = (*modality).to_string();
830 let arms = grouped_arms(models, move |m| m.input_modalities.contains(&mod_owned), {
831 let mod_owned = (*modality).to_string();
832 move |m| {
833 let b = m.input_modalities.contains(&mod_owned);
834 quote! { #b }
835 }
836 });
837 quote! {
838 #[allow(clippy::too_many_lines)]
839 pub fn #method(self) -> bool {
840 match self { #arms }
841 }
842 }
843 });
844
845 let transport_arms = emit_transport_arms(models);
846
847 let all_variants = models.iter().map(|m| format_ident!("{}", m.variant_name));
848
849 let from_str_impl = emit_from_str_impl(&enum_ident, cfg.parser_name, models);
850
851 quote! {
852 impl #enum_ident {
853 #[allow(clippy::too_many_lines)]
854 fn model_id(self) -> &'static str {
855 match self { #(#model_id_arms)* }
856 }
857
858 #[allow(clippy::too_many_lines)]
859 fn display_name(self) -> &'static str {
860 match self { #display_name_arms }
861 }
862
863 #[allow(clippy::too_many_lines)]
864 fn context_window(self) -> u32 {
865 match self { #context_window_arms }
866 }
867
868 #[allow(clippy::too_many_lines)]
869 pub fn reasoning_levels(self) -> &'static [ReasoningEffort] {
870 match self { #reasoning_levels_arms }
871 }
872
873 #[allow(clippy::too_many_lines)]
874 pub fn reasoning_disabled_support(self) -> crate::reasoning::ReasoningDisabledSupport {
875 match self { #disabled_support_arms }
876 }
877
878 pub fn supports_reasoning(self) -> bool {
879 self.reasoning_levels().iter().any(|effort| effort.is_enabled())
880 }
881
882 #[allow(clippy::too_many_lines)]
883 pub fn supports_prompt_caching(self) -> bool {
884 match self { #prompt_caching_arms }
885 }
886
887 #[allow(clippy::too_many_lines, clippy::match_same_arms, clippy::unreadable_literal)]
888 pub fn pricing(self) -> Option<ModelPricing> {
889 match self { #pricing_arms }
890 }
891
892 #(#modality_methods)*
893
894 #[allow(clippy::too_many_lines)]
895 pub fn transport(self) -> Option<ModelTransport> {
896 match self { #transport_arms }
897 }
898
899 const ALL: &[#enum_ident] = &[#(Self::#all_variants),*];
900 }
901
902 #from_str_impl
903 }
904 });
905 quote! { #(#impls)* }
906}
907
908fn emit_pricing_arms(models: &[ModelInfo]) -> TokenStream {
909 let arms = models.iter().map(|model| {
910 let variant = format_ident!("{}", model.variant_name);
911 let Some(pricing) = &model.pricing else {
912 return quote! { Self::#variant => None, };
913 };
914 let input = pricing.input;
915 let output = pricing.output;
916 let cache_read = pricing.cache_read.map_or_else(|| quote! { None }, |value| quote! { Some(#value) });
917 let cache_write = pricing.cache_write.map_or_else(|| quote! { None }, |value| quote! { Some(#value) });
918 quote! {
919 Self::#variant => Some(ModelPricing {
920 input_per_million: #input,
921 output_per_million: #output,
922 cache_read_per_million: #cache_read,
923 cache_write_per_million: #cache_write,
924 }),
925 }
926 });
927 quote! { #(#arms)* }
928}
929
930fn emit_from_str_impl(enum_ident: &proc_macro2::Ident, parser_name: &str, models: &[ModelInfo]) -> TokenStream {
931 let arms = models.iter().map(|m| {
932 let id = &m.model_id;
933 let v = format_ident!("{}", m.variant_name);
934 quote! { #id => Ok(Self::#v), }
935 });
936 let err_msg = format!("Unknown {parser_name} model: '{{s}}'");
937 quote! {
938 impl std::str::FromStr for #enum_ident {
939 type Err = String;
940
941 #[allow(clippy::too_many_lines)]
942 fn from_str(s: &str) -> Result<Self, Self::Err> {
943 match s {
944 #(#arms)*
945 _ => Err(format!(#err_msg)),
946 }
947 }
948 }
949 }
950}
951
952fn grouped_arms<K, R>(
954 models: &[ModelInfo],
955 key_fn: impl Fn(&ModelInfo) -> K,
956 rhs_fn: impl Fn(&ModelInfo) -> R,
957) -> TokenStream
958where
959 K: Eq + Ord,
960 R: ToTokens,
961{
962 let mut groups: BTreeMap<K, Vec<&ModelInfo>> = BTreeMap::new();
963 for m in models {
964 groups.entry(key_fn(m)).or_default().push(m);
965 }
966 let arms = groups.values().map(|members| {
967 let pats = members.iter().map(|m| {
968 let v = format_ident!("{}", m.variant_name);
969 quote! { Self::#v }
970 });
971 let rhs = rhs_fn(members[0]);
972 quote! { #(#pats)|* => #rhs, }
973 });
974 quote! { #(#arms)* }
975}
976
977fn emit_reasoning_levels_arms(models: &[ModelInfo]) -> TokenStream {
978 grouped_arms(
979 models,
980 |m| m.reasoning_levels.clone(),
981 |m| {
982 if m.reasoning_levels.is_empty() {
983 quote! { &[] }
984 } else {
985 let items = m.reasoning_levels.iter().map(|l| {
986 let variant = format_ident!("{}", level_str_to_variant(l));
987 quote! { ReasoningEffort::#variant }
988 });
989 quote! { &[#(#items),*] }
990 }
991 },
992 )
993}
994
995fn emit_transport_arms(models: &[ModelInfo]) -> TokenStream {
996 grouped_arms(
997 models,
998 |m| m.transport.clone(),
999 |m| match m.transport.as_ref() {
1000 Some(TransportInfo::OpenAiResponses { base_url_template }) => {
1001 quote! { Some(ModelTransport::OpenAiResponses { base_url_template: #base_url_template }) }
1002 }
1003 None => quote! { None },
1004 },
1005 )
1006}
1007
1008fn level_str_to_variant(level: &str) -> String {
1011 let canonical =
1012 level.parse::<utils::ReasoningEffort>().unwrap_or_else(|_| panic!("Unknown reasoning level: {level}")).as_str();
1013 let mut variant = canonical.to_string();
1014 variant[..1].make_ascii_uppercase();
1015 variant
1016}
1017
1018fn emit_llm_model_enum() -> TokenStream {
1019 let catalog_variants = PROVIDERS.iter().map(|cfg| {
1020 let v = format_ident!("{}", cfg.enum_name);
1021 let inner = format_ident!("{}Model", cfg.enum_name);
1022 quote! { #v(#inner) }
1023 });
1024 let dynamic_variants = DYNAMIC_PROVIDERS.iter().map(|d| {
1025 let v = format_ident!("{}", d.enum_name);
1026 quote! { #v(String) }
1027 });
1028 quote! {
1029 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
1031 pub enum LlmModel {
1032 #(#catalog_variants,)*
1033 #(#dynamic_variants,)*
1034 }
1035 }
1036}
1037
1038fn emit_from_impls() -> TokenStream {
1039 let impls = PROVIDERS.iter().map(|cfg| {
1040 let outer = format_ident!("{}Model", cfg.enum_name);
1041 let v = format_ident!("{}", cfg.enum_name);
1042 quote! {
1043 impl From<#outer> for LlmModel {
1044 fn from(m: #outer) -> Self {
1045 LlmModel::#v(m)
1046 }
1047 }
1048 }
1049 });
1050 quote! { #(#impls)* }
1051}
1052
1053fn emit_llm_model_impl() -> TokenStream {
1054 let model_id = emit_llm_model_id();
1055 let display_name = emit_llm_display_name();
1056 let provider = emit_llm_provider();
1057 let provider_enum = emit_llm_provider_enum();
1058 let provider_display_name = emit_llm_provider_display_name();
1059 let context_window = emit_llm_context_window();
1060 let required_env_var = emit_llm_required_env_var();
1061 let all_required_env_vars = emit_llm_all_required_env_vars();
1062 let oauth_provider_id = emit_llm_oauth_provider_id();
1063 let reasoning_levels = emit_llm_reasoning_levels();
1064 let disabled_support = llm_delegate_with_dynamic_default(
1065 "reasoning_disabled_support",
1066 "e! { crate::reasoning::ReasoningDisabledSupport::Unsupported },
1067 );
1068 let supports_reasoning = emit_llm_supports_reasoning();
1069 let supports_reasoning_off_transport = emit_llm_supports_reasoning_off_transport();
1070 let supports_prompt_caching = emit_llm_supports_prompt_caching();
1071 let pricing = emit_llm_pricing();
1072 let modality_methods = ["image", "audio"].iter().map(|m| emit_llm_supports_modality(m));
1073 let transport = emit_llm_transport();
1074 let all = emit_llm_all();
1075
1076 quote! {
1077 impl LlmModel {
1078 #model_id
1079 #display_name
1080 #provider
1081 #provider_enum
1082 #provider_display_name
1083 #context_window
1084 #required_env_var
1085 #all_required_env_vars
1086 #oauth_provider_id
1087 #reasoning_levels
1088 pub fn reasoning_disabled_support(&self) -> crate::reasoning::ReasoningDisabledSupport {
1089 #disabled_support
1090 }
1091
1092 pub fn supports_reasoning_off(&self) -> bool {
1094 self.reasoning_levels().contains(&ReasoningEffort::Disabled)
1095 }
1096
1097 #supports_reasoning_off_transport
1098
1099 pub fn effective_reasoning_levels(&self) -> Vec<ReasoningEffort> {
1101 self.reasoning_levels()
1102 .iter()
1103 .copied()
1104 .filter(|effort| *effort != ReasoningEffort::Disabled || self.supports_reasoning_off_transport())
1105 .collect()
1106 }
1107
1108 pub fn validate_reasoning_effort(&self, effort: ReasoningEffort) -> Result<(), crate::catalog::ReasoningEffortError> {
1109 let supported = self.effective_reasoning_levels();
1110 if effort == ReasoningEffort::Default || supported.contains(&effort) {
1111 Ok(())
1112 } else {
1113 Err(crate::catalog::ReasoningEffortError::Unsupported { model: self.to_string(), effort, supported })
1114 }
1115 }
1116
1117 #supports_reasoning
1118 #supports_prompt_caching
1119 #pricing
1120 #(#modality_methods)*
1121 #transport
1122 #all
1123 }
1124 }
1125}
1126
1127fn emit_llm_model_id() -> TokenStream {
1128 let catalog_arms = PROVIDERS.iter().map(|cfg| {
1129 let v = format_ident!("{}", cfg.enum_name);
1130 if cfg.is_hybrid_dynamic {
1131 quote! { Self::#v(m) => m.model_id(), }
1132 } else {
1133 quote! { Self::#v(m) => Cow::Borrowed(m.model_id()), }
1134 }
1135 });
1136 let dyn_pats = dynamic_pattern_with_binding("s");
1137 quote! {
1138 pub fn model_id(&self) -> Cow<'static, str> {
1140 match self {
1141 #(#catalog_arms)*
1142 #dyn_pats => Cow::Owned(s.clone()),
1143 }
1144 }
1145 }
1146}
1147
1148fn emit_llm_display_name() -> TokenStream {
1149 let catalog_arms = PROVIDERS.iter().map(|cfg| {
1150 let v = format_ident!("{}", cfg.enum_name);
1151 if cfg.is_hybrid_dynamic {
1152 quote! { Self::#v(m) => m.display_name(), }
1153 } else {
1154 quote! { Self::#v(m) => Cow::Borrowed(m.display_name()), }
1155 }
1156 });
1157 let dyn_arms = DYNAMIC_PROVIDERS.iter().map(|d| {
1158 let v = format_ident!("{}", d.enum_name);
1159 let fmt = format!("{} {{s}}", d.enum_name);
1160 quote! { Self::#v(s) => Cow::Owned(format!(#fmt)), }
1161 });
1162 quote! {
1163 pub fn display_name(&self) -> Cow<'static, str> {
1165 match self {
1166 #(#catalog_arms)*
1167 #(#dyn_arms)*
1168 }
1169 }
1170 }
1171}
1172
1173fn emit_llm_provider() -> TokenStream {
1174 let arms = llm_match_arms_ignored(|cfg| cfg.parser_name, |d| d.parser_name);
1175 quote! {
1176 pub fn provider(&self) -> &'static str {
1178 match self { #arms }
1179 }
1180 }
1181}
1182
1183fn emit_llm_provider_enum() -> TokenStream {
1184 let arms = llm_match_arms_ignored(
1185 |cfg| {
1186 let v = format_ident!("{}", cfg.enum_name);
1187 quote! { Provider::#v }
1188 },
1189 |d| {
1190 let v = format_ident!("{}", d.enum_name);
1191 quote! { Provider::#v }
1192 },
1193 );
1194 quote! {
1195 pub fn provider_enum(&self) -> Provider {
1197 match self { #arms }
1198 }
1199 }
1200}
1201
1202fn emit_llm_provider_display_name() -> TokenStream {
1203 let arms = llm_match_arms_ignored(|cfg| cfg.display_name, |d| d.display_name);
1204 quote! {
1205 pub fn provider_display_name(&self) -> &'static str {
1207 match self { #arms }
1208 }
1209 }
1210}
1211
1212fn emit_llm_context_window() -> TokenStream {
1213 let catalog_arms = PROVIDERS.iter().map(|cfg| {
1214 let v = format_ident!("{}", cfg.enum_name);
1215 if cfg.is_hybrid_dynamic {
1216 quote! { Self::#v(m) => m.context_window(), }
1217 } else {
1218 quote! { Self::#v(m) => Some(m.context_window()), }
1219 }
1220 });
1221 let dyn_pats = dynamic_pattern_with_binding("_");
1222 quote! {
1223 pub fn context_window(&self) -> Option<u32> {
1225 match self {
1226 #(#catalog_arms)*
1227 #dyn_pats => None,
1228 }
1229 }
1230 }
1231}
1232
1233fn emit_llm_required_env_var() -> TokenStream {
1234 let some_arms = PROVIDERS.iter().filter_map(|cfg| {
1235 cfg.env_var.map(|var| {
1236 let v = format_ident!("{}", cfg.enum_name);
1237 quote! { Self::#v(_) => Some(#var), }
1238 })
1239 });
1240 let none_pats = llm_or_pats(|cfg| cfg.env_var.is_none(), |_| true);
1241 quote! {
1242 pub fn required_env_var(&self) -> Option<&'static str> {
1244 match self {
1245 #(#some_arms)*
1246 #none_pats => None,
1247 }
1248 }
1249 }
1250}
1251
1252fn emit_llm_all_required_env_vars() -> TokenStream {
1253 let vars = PROVIDERS.iter().filter_map(|cfg| cfg.env_var);
1254 quote! {
1255 pub const ALL_REQUIRED_ENV_VARS: &[&str] = &[#(#vars),*];
1257 }
1258}
1259
1260fn emit_llm_oauth_provider_id() -> TokenStream {
1261 let some_arms = PROVIDERS.iter().filter_map(|cfg| {
1262 cfg.oauth_provider_id.map(|id| {
1263 let v = format_ident!("{}", cfg.enum_name);
1264 quote! { Self::#v(_) => Some(#id), }
1265 })
1266 });
1267 let none_pats = llm_or_pats(|cfg| cfg.oauth_provider_id.is_none(), |_| true);
1268 quote! {
1269 pub fn oauth_provider_id(&self) -> Option<&'static str> {
1271 match self {
1272 #(#some_arms)*
1273 #none_pats => None,
1274 }
1275 }
1276 }
1277}
1278
1279fn emit_llm_reasoning_levels() -> TokenStream {
1280 let body = llm_delegate_with_dynamic_default("reasoning_levels", "e! { &[] });
1281 quote! {
1282 pub fn reasoning_levels(&self) -> &'static [ReasoningEffort] {
1284 #body
1285 }
1286 }
1287}
1288
1289fn emit_llm_supports_reasoning() -> TokenStream {
1290 quote! {
1291 pub fn supports_reasoning(&self) -> bool {
1293 self.reasoning_levels().iter().any(|effort| effort.is_enabled())
1294 }
1295 }
1296}
1297
1298fn emit_llm_supports_reasoning_off_transport() -> TokenStream {
1299 let variants = |filter: fn(&ProviderConfig) -> bool| {
1300 let pats = PROVIDERS.iter().filter(|cfg| filter(cfg)).map(|cfg| {
1301 let v = format_ident!("{}", cfg.enum_name);
1302 quote! { Provider::#v }
1303 });
1304 quote! { #(#pats)|* }
1305 };
1306 let effort_only = variants(|cfg| cfg.uses_openai_compatible_api);
1307 let model_transport = variants(|cfg| cfg.use_model_transport);
1308 let always = variants(|cfg| !cfg.uses_openai_compatible_api && !cfg.use_model_transport);
1309 let dynamic = DYNAMIC_PROVIDERS.iter().map(|d| {
1310 let v = format_ident!("{}", d.enum_name);
1311 quote! { Provider::#v }
1312 });
1313 quote! {
1314 pub fn supports_reasoning_off_transport(&self) -> bool {
1315 if !self.supports_reasoning_off() {
1316 return false;
1317 }
1318 match self.provider_enum() {
1319 #always => true,
1320 #model_transport => self.transport().is_some(),
1321 #effort_only => {
1322 self.reasoning_disabled_support() == crate::reasoning::ReasoningDisabledSupport::Effort
1323 }
1324 #(#dynamic)|* => false,
1325 }
1326 }
1327 }
1328}
1329
1330fn emit_llm_supports_prompt_caching() -> TokenStream {
1331 let body = llm_delegate_with_dynamic_default("supports_prompt_caching", "e! { false });
1332 quote! {
1333 pub fn supports_prompt_caching(&self) -> bool {
1335 #body
1336 }
1337 }
1338}
1339
1340fn emit_llm_pricing() -> TokenStream {
1341 let body = llm_delegate_with_dynamic_default("pricing", "e! { None });
1342 quote! {
1343 pub fn pricing(&self) -> Option<ModelPricing> {
1344 #body
1345 }
1346 }
1347}
1348
1349fn emit_llm_transport() -> TokenStream {
1350 let body = llm_delegate_with_dynamic_default("transport", "e! { None });
1351 quote! {
1352 pub fn transport(&self) -> Option<ModelTransport> {
1355 #body
1356 }
1357 }
1358}
1359
1360fn emit_llm_supports_modality(modality: &str) -> TokenStream {
1361 let method = format!("supports_{modality}");
1362 let method_ident = format_ident!("{}", method);
1363 let doc = format!(" Whether this model supports {modality} input");
1364 let body = llm_delegate_with_dynamic_default(&method, "e! { false });
1365 quote! {
1366 #[doc = #doc]
1367 pub fn #method_ident(&self) -> bool {
1368 #body
1369 }
1370 }
1371}
1372
1373fn emit_llm_all() -> TokenStream {
1374 let pushes = PROVIDERS.iter().map(|cfg| {
1375 let inner = format_ident!("{}", cfg.inner_enum_name());
1376 let outer = format_ident!("{}", cfg.outer_enum_name());
1377 let v = format_ident!("{}", cfg.enum_name);
1378 if cfg.is_hybrid_dynamic {
1379 quote! {
1380 v.extend(#inner::ALL.iter().copied().map(#outer::Foundation).map(LlmModel::#v));
1381 }
1382 } else {
1383 quote! {
1384 v.extend(#inner::ALL.iter().copied().map(LlmModel::#v));
1385 }
1386 }
1387 });
1388 quote! {
1389 pub fn all() -> &'static [LlmModel] {
1391 static ALL: LazyLock<Vec<LlmModel>> = LazyLock::new(|| {
1392 let mut v = Vec::new();
1393 #(#pushes)*
1394 v
1395 });
1396 &ALL
1397 }
1398 }
1399}
1400
1401fn emit_display_impl() -> TokenStream {
1402 quote! {
1403 impl std::fmt::Display for LlmModel {
1404 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1405 write!(f, "{}:{}", self.provider(), self.model_id())
1406 }
1407 }
1408 }
1409}
1410
1411fn emit_fromstr_impl() -> TokenStream {
1412 let catalog_arms = PROVIDERS.iter().map(|cfg| {
1413 let name = cfg.parser_name;
1414 let outer = format_ident!("{}Model", cfg.enum_name);
1415 let v = format_ident!("{}", cfg.enum_name);
1416 quote! { #name => model_str.parse::<#outer>().map(Self::#v), }
1417 });
1418 let dyn_arms = DYNAMIC_PROVIDERS.iter().map(|d| {
1419 let name = d.parser_name;
1420 let v = format_ident!("{}", d.enum_name);
1421 quote! { #name => Ok(Self::#v(model_str.to_string())), }
1422 });
1423 quote! {
1424 impl std::str::FromStr for LlmModel {
1425 type Err = String;
1426
1427 fn from_str(s: &str) -> Result<Self, Self::Err> {
1429 let (provider_str, model_str) = s.split_once(':').unwrap_or((s, ""));
1430 match provider_str {
1431 #(#catalog_arms)*
1432 #(#dyn_arms)*
1433 _ => Err(format!("Unknown provider: '{provider_str}'")),
1434 }
1435 }
1436 }
1437 }
1438}
1439
1440fn dynamic_pattern_with_binding(binding: &str) -> TokenStream {
1442 let binding_ident = if binding == "_" {
1443 quote! { _ }
1444 } else {
1445 let b = format_ident!("{}", binding);
1446 quote! { #b }
1447 };
1448 let pats = DYNAMIC_PROVIDERS.iter().map(|d| {
1449 let v = format_ident!("{}", d.enum_name);
1450 quote! { Self::#v(#binding_ident) }
1451 });
1452 quote! { #(#pats)|* }
1453}
1454
1455fn provider_match_arms<V: ToTokens>(
1459 catalog_value: impl Fn(&ProviderConfig) -> V,
1460 dynamic_value: impl Fn(&DynamicProviderConfig) -> V,
1461) -> TokenStream {
1462 let catalog = PROVIDERS.iter().map(|cfg| {
1463 let v = format_ident!("{}", cfg.enum_name);
1464 let val = catalog_value(cfg);
1465 quote! { Self::#v => #val, }
1466 });
1467 let dynamic = DYNAMIC_PROVIDERS.iter().map(|d| {
1468 let v = format_ident!("{}", d.enum_name);
1469 let val = dynamic_value(d);
1470 quote! { Self::#v => #val, }
1471 });
1472 quote! { #(#catalog)* #(#dynamic)* }
1473}
1474
1475fn provider_or_pats(
1478 include_catalog: impl Fn(&ProviderConfig) -> bool,
1479 include_dynamic: impl Fn(&DynamicProviderConfig) -> bool,
1480) -> TokenStream {
1481 let catalog = PROVIDERS.iter().filter(|cfg| include_catalog(cfg)).map(|cfg| {
1482 let v = format_ident!("{}", cfg.enum_name);
1483 quote! { Self::#v }
1484 });
1485 let dynamic = DYNAMIC_PROVIDERS.iter().filter(|d| include_dynamic(d)).map(|d| {
1486 let v = format_ident!("{}", d.enum_name);
1487 quote! { Self::#v }
1488 });
1489 let pats = catalog.chain(dynamic);
1490 quote! { #(#pats)|* }
1491}
1492
1493fn llm_match_arms_ignored<V: ToTokens>(
1496 catalog_value: impl Fn(&ProviderConfig) -> V,
1497 dynamic_value: impl Fn(&DynamicProviderConfig) -> V,
1498) -> TokenStream {
1499 let catalog = PROVIDERS.iter().map(|cfg| {
1500 let v = format_ident!("{}", cfg.enum_name);
1501 let val = catalog_value(cfg);
1502 quote! { Self::#v(_) => #val, }
1503 });
1504 let dynamic = DYNAMIC_PROVIDERS.iter().map(|d| {
1505 let v = format_ident!("{}", d.enum_name);
1506 let val = dynamic_value(d);
1507 quote! { Self::#v(_) => #val, }
1508 });
1509 quote! { #(#catalog)* #(#dynamic)* }
1510}
1511
1512fn llm_or_pats(
1515 include_catalog: impl Fn(&ProviderConfig) -> bool,
1516 include_dynamic: impl Fn(&DynamicProviderConfig) -> bool,
1517) -> TokenStream {
1518 let catalog = PROVIDERS.iter().filter(|cfg| include_catalog(cfg)).map(|cfg| {
1519 let v = format_ident!("{}", cfg.enum_name);
1520 quote! { Self::#v(_) }
1521 });
1522 let dynamic = DYNAMIC_PROVIDERS.iter().filter(|d| include_dynamic(d)).map(|d| {
1523 let v = format_ident!("{}", d.enum_name);
1524 quote! { Self::#v(_) }
1525 });
1526 let pats = catalog.chain(dynamic);
1527 quote! { #(#pats)|* }
1528}
1529
1530fn llm_delegate_with_dynamic_default(method: &str, dynamic_value: &TokenStream) -> TokenStream {
1534 let method_ident = format_ident!("{}", method);
1535 let catalog_arms = PROVIDERS.iter().map(|cfg| {
1536 let v = format_ident!("{}", cfg.enum_name);
1537 quote! { Self::#v(m) => m.#method_ident(), }
1538 });
1539 let dyn_pat = dynamic_pattern_with_binding("_");
1540 quote! {
1541 match self {
1542 #(#catalog_arms)*
1543 #dyn_pat => #dynamic_value,
1544 }
1545 }
1546}
1547
1548fn num_lit_with_underscores(n: u32) -> TokenStream {
1550 format_number(n).parse().expect("formatted number parses as a token")
1551}
1552
1553fn format_number(n: u32) -> String {
1555 let s = n.to_string();
1556 if s.len() <= 4 {
1557 return s;
1558 }
1559 let mut result = String::with_capacity(s.len() + s.len() / 3);
1560 for (i, ch) in s.chars().enumerate() {
1561 if i > 0 && (s.len() - i).is_multiple_of(3) {
1562 result.push('_');
1563 }
1564 result.push(ch);
1565 }
1566 result
1567}
1568
1569fn emit_provider_docs(ctx: &CodegenCtx) -> HashMap<String, String> {
1570 let mut docs = HashMap::new();
1571
1572 for cfg in PROVIDERS {
1573 let models = &ctx.provider_models[cfg.dev_id];
1574 let mut doc = String::new();
1575
1576 pushln(&mut doc, format!("`{}` LLM provider.", cfg.display_name));
1577 blank(&mut doc);
1578
1579 pushln(&mut doc, "# Authentication");
1580 blank(&mut doc);
1581 match cfg.env_var {
1582 Some(var) => pushln(&mut doc, format!("Set the `{var}` environment variable.")),
1583 None if cfg.oauth_provider_id.is_some() => {
1584 pushln(&mut doc, "This provider uses OAuth authentication.");
1585 }
1586 None => {
1587 pushln(
1588 &mut doc,
1589 "Uses the default AWS credential chain (environment variables, config files, IAM roles).",
1590 );
1591 pushln(
1592 &mut doc,
1593 "Models served from a dedicated endpoint also accept a Bedrock API key in `AWS_BEARER_TOKEN_BEDROCK`.",
1594 );
1595 }
1596 }
1597 blank(&mut doc);
1598
1599 pushln(&mut doc, "# Supported models");
1600 blank(&mut doc);
1601 pushln(&mut doc, "| Model ID | Name | Context | Reasoning | Image | Audio |");
1602 pushln(&mut doc, "|----------|------|---------|-----------|-------|-------|");
1603 for model in models {
1604 let ctx_str = format_context_window(model.context_window);
1605 let reasoning = if model.reasoning_levels.iter().any(|level| level != "disabled") { "yes" } else { "" };
1606 let image = if model.input_modalities.contains(&"image".to_string()) { "yes" } else { "" };
1607 let audio = if model.input_modalities.contains(&"audio".to_string()) { "yes" } else { "" };
1608 pushln(
1609 &mut doc,
1610 format!(
1611 "| `{}` | `{}` | `{}` | {} | {} | {} |",
1612 model.model_id, model.display_name, ctx_str, reasoning, image, audio
1613 ),
1614 );
1615 }
1616
1617 for model in models.iter().filter(|model| model.disabled_support != "Unsupported") {
1618 pushln(
1619 &mut doc,
1620 format!("Model `{}` advertises `disabled` reasoning (subject to adapter support).", model.model_id),
1621 );
1622 }
1623 push_transport_section(&mut doc, models);
1624
1625 docs.insert(cfg.dev_id.to_string(), doc);
1626 }
1627
1628 for dyn_cfg in DYNAMIC_PROVIDERS {
1629 let mut doc = String::new();
1630 pushln(&mut doc, format!("`{}` LLM provider.", dyn_cfg.display_name));
1631 blank(&mut doc);
1632 pushln(
1633 &mut doc,
1634 format!("This provider accepts any model name at runtime (e.g. `{}:my-model`).", dyn_cfg.parser_name),
1635 );
1636 pushln(&mut doc, "No API key is required.");
1637 docs.insert(dyn_cfg.parser_name.to_string(), doc);
1638 }
1639
1640 docs
1641}
1642
1643fn push_transport_section(doc: &mut String, models: &[ModelInfo]) {
1645 let overridden: Vec<&ModelInfo> = models.iter().filter(|m| m.transport.is_some()).collect();
1646 if overridden.is_empty() {
1647 return;
1648 }
1649
1650 blank(doc);
1651 pushln(doc, "# Models with a dedicated endpoint");
1652 blank(doc);
1653 pushln(doc, "These models are served from their own endpoint and wire protocol");
1654 pushln(doc, "rather than the provider's default. `${VAR}` placeholders are resolved");
1655 pushln(doc, "at request time.");
1656 blank(doc);
1657 pushln(doc, "| Model ID | Endpoint | Wire shape |");
1658 pushln(doc, "|----------|----------|------------|");
1659 for model in overridden {
1660 let transport = model.transport.as_ref().expect("filtered to models with a transport");
1661 let (api, shape) = match transport {
1662 TransportInfo::OpenAiResponses { base_url_template } => (base_url_template.as_str(), "responses"),
1663 };
1664 pushln(doc, format!("| `{}` | `{api}` | `{shape}` |", model.model_id));
1665 }
1666}
1667
1668fn format_context_window(tokens: u32) -> String {
1670 if tokens == 0 {
1671 return "unknown".to_string();
1672 }
1673 if tokens >= 1_000_000 && tokens.is_multiple_of(1_000_000) {
1674 format!("{}M", tokens / 1_000_000)
1675 } else if tokens >= 1_000 && tokens.is_multiple_of(1_000) {
1676 format!("{}k", tokens / 1_000)
1677 } else {
1678 format_number(tokens)
1679 }
1680}
1681
1682fn pushln(out: &mut String, line: impl AsRef<str>) {
1683 writeln!(out, "{}", line.as_ref()).expect("writing to String should not fail");
1684}
1685
1686fn blank(out: &mut String) {
1687 pushln(out, "");
1688}
1689
1690#[cfg(test)]
1691mod tests {
1692 use super::*;
1693 use serde_json::Value;
1694 use serde_json::json;
1695 use tempfile::NamedTempFile;
1696
1697 #[test]
1698 fn model_id_to_variant_pascal_cases_segments() {
1699 assert_eq!(model_id_to_variant("claude-sonnet-4-5-20250929"), "ClaudeSonnet4520250929");
1700 assert_eq!(model_id_to_variant("gemini-2.5-flash"), "Gemini25Flash");
1701 assert_eq!(model_id_to_variant("deepseek-chat"), "DeepseekChat");
1702 assert_eq!(model_id_to_variant("glm-4.5"), "Glm45");
1703 }
1704
1705 #[test]
1706 fn model_id_to_variant_handles_slash_and_colon() {
1707 assert_eq!(model_id_to_variant("anthropic/claude-opus-4.6"), "AnthropicClaudeOpus46");
1708 assert_eq!(model_id_to_variant("openai/gpt-5.1-codex-max"), "OpenaiGpt51CodexMax");
1709 assert_eq!(model_id_to_variant("deepseek/deepseek-r1:free"), "DeepseekDeepseekR1Free");
1710 }
1711
1712 #[test]
1713 fn is_alias_detects_latest_suffix() {
1714 assert!(is_alias("claude-sonnet-4-5-latest"));
1715 assert!(is_alias("claude-3-7-sonnet-latest"));
1716 assert!(!is_alias("claude-sonnet-4-5-20250929"));
1717 }
1718
1719 #[test]
1720 fn build_uses_explicit_context_windows_for_codex_models() {
1721 let data = minimal_models_dev_json();
1722
1723 let models = build_from_value(&data);
1724 let window = |id: &str| models["codex"].iter().find(|model| model.model_id == id).unwrap().context_window;
1725 for model_id in [
1726 "gpt-6-sol",
1727 "gpt-6-astra",
1728 "gpt-6-luna",
1729 "gpt-5.6-sol",
1730 "gpt-5.6-terra",
1731 "gpt-5.6-luna",
1732 "gpt-5.5",
1733 "gpt-5.4",
1734 "gpt-5.4-mini",
1735 "gpt-5.2",
1736 ] {
1737 assert_eq!(window(model_id), 272_000);
1738 }
1739 }
1740
1741 #[test]
1742 fn transport_override_is_preserved_from_model_metadata() {
1743 let mut data = minimal_models_dev_json();
1744 insert_models(
1745 &mut data,
1746 "amazon-bedrock",
1747 json!({
1748 "with-transport": {
1749 "id": "with-transport", "name": "With Transport", "tool_call": true,
1750 "limit": {"context": 1000, "output": 0},
1751 "provider": {
1752 "npm": "@ai-sdk/amazon-bedrock/mantle",
1753 "api": "https://example.${AWS_REGION}.api.aws/openai/v1",
1754 "shape": "responses"
1755 }
1756 },
1757 "without-transport": {
1758 "id": "without-transport", "name": "Without Transport", "tool_call": true,
1759 "limit": {"context": 1000, "output": 0}
1760 }
1761 }),
1762 );
1763
1764 let models = build_from_value(&data);
1765 let transport =
1766 |id: &str| models["amazon-bedrock"].iter().find(|m| m.model_id == id).unwrap().transport.clone();
1767
1768 assert_eq!(
1769 transport("with-transport"),
1770 Some(TransportInfo::OpenAiResponses {
1771 base_url_template: "https://example.${AWS_REGION}.api.aws/openai/v1".to_string(),
1772 })
1773 );
1774 assert_eq!(transport("without-transport"), None);
1775 }
1776
1777 #[test]
1778 fn transport_override_with_only_an_npm_package_is_ignored() {
1779 let mut data = minimal_models_dev_json();
1780 anthropic_models(
1781 &mut data,
1782 json!({
1783 "npm-only": {
1784 "id": "npm-only", "name": "Npm Only", "tool_call": true,
1785 "limit": {"context": 1000, "output": 0},
1786 "provider": {"npm": "@ai-sdk/anthropic"}
1787 }
1788 }),
1789 );
1790
1791 let models = build_from_value(&data);
1792
1793 assert_eq!(models["anthropic"].iter().find(|m| m.model_id == "npm-only").unwrap().transport, None);
1794 }
1795
1796 #[test]
1797 fn unknown_wire_shape_is_rejected() {
1798 let mut data = minimal_models_dev_json();
1799 insert_models(
1800 &mut data,
1801 "amazon-bedrock",
1802 json!({
1803 "weird": {
1804 "id": "weird", "name": "Weird", "tool_call": true,
1805 "limit": {"context": 1000, "output": 0},
1806 "provider": {"api": "https://example.com/v1", "shape": "telepathy"}
1807 }
1808 }),
1809 );
1810 let parsed: ModelsDevData = serde_json::from_value(data).expect("parse fixture");
1811
1812 let error = build_provider_models(&parsed).unwrap_err();
1813
1814 assert!(
1815 matches!(error, CodegenError::UnsupportedWireShape { ref model_id, ref shape }
1816 if model_id == "weird" && shape == "telepathy"),
1817 "unexpected error: {error}"
1818 );
1819 }
1820
1821 #[test]
1822 fn incomplete_bedrock_transport_is_rejected() {
1823 let mut data = minimal_models_dev_json();
1824 insert_models(
1825 &mut data,
1826 "amazon-bedrock",
1827 json!({
1828 "incomplete": {
1829 "id": "incomplete", "name": "Incomplete", "tool_call": true,
1830 "limit": {"context": 1000, "output": 0},
1831 "provider": {"api": "https://example.com/v1"}
1832 }
1833 }),
1834 );
1835 let parsed: ModelsDevData = serde_json::from_value(data).expect("parse fixture");
1836
1837 let error = build_provider_models(&parsed).unwrap_err();
1838
1839 assert!(matches!(error, CodegenError::IncompleteTransport { ref model_id } if model_id == "incomplete"));
1840 }
1841
1842 #[test]
1843 fn format_context_window_formats_correctly() {
1844 assert_eq!(format_context_window(1_000_000), "1M");
1845 assert_eq!(format_context_window(200_000), "200k");
1846 assert_eq!(format_context_window(8_000), "8k");
1847 assert_eq!(format_context_window(0), "unknown");
1848 }
1849
1850 #[test]
1851 fn level_str_to_variant_covers_all_reasoning_efforts() {
1852 for effort in utils::ReasoningEffort::all() {
1853 let _ = level_str_to_variant(effort.as_str());
1854 }
1855 }
1856
1857 #[test]
1858 fn build_sorts_models_and_filters_aliases_and_non_tool_call() {
1859 let mut data = minimal_models_dev_json();
1860 anthropic_models(
1861 &mut data,
1862 json!({
1863 "b-model": {"id": "b-model", "name": "B Model", "tool_call": true, "limit": {"context": 2000, "output": 0}},
1864 "a-model": {"id": "a-model", "name": "A Model", "tool_call": true, "limit": {"context": 1000, "output": 0}},
1865 "alpha-latest": {"id": "alpha-latest", "name": "Alias", "tool_call": true, "limit": {"context": 500, "output": 0}},
1866 "no-tools": {"id": "no-tools", "name": "No Tools", "tool_call": false, "limit": {"context": 500, "output": 0}}
1867 }),
1868 );
1869
1870 let models = build_from_value(&data);
1871 let ids: Vec<&str> = models["anthropic"].iter().map(|m| m.model_id.as_str()).collect();
1872 assert_eq!(ids, vec!["a-model", "b-model"]);
1873 }
1874
1875 #[test]
1876 fn build_extra_source_ids_merges_unique_models_into_provider() {
1877 let mut data = minimal_models_dev_json();
1878 zai_extra_models(
1879 &mut data,
1880 json!({
1881 "extra-model": {"id": "extra-model", "name": "Extra Model", "tool_call": true, "limit": {"context": 4000, "output": 0}}
1882 }),
1883 );
1884
1885 let models = build_from_value(&data);
1886 assert!(models["zai"].iter().any(|m| m.model_id == "extra-model"));
1887 }
1888
1889 #[test]
1890 fn build_extra_source_ids_does_not_duplicate_existing_models() {
1891 let mut data = minimal_models_dev_json();
1892 let shared = json!({
1893 "shared-model": {"id": "shared-model", "name": "Shared Model", "tool_call": true, "limit": {"context": 1000, "output": 0}}
1894 });
1895 insert_models(&mut data, "zai", shared.clone());
1896 insert_models(&mut data, "zai-coding-plan", shared);
1897
1898 let models = build_from_value(&data);
1899 let count = models["zai"].iter().filter(|m| m.model_id == "shared-model").count();
1900 assert_eq!(count, 1);
1901 }
1902
1903 #[test]
1904 fn build_derives_reasoning_levels_from_source_metadata() {
1905 let mut data = minimal_models_dev_json();
1906 anthropic_models(
1907 &mut data,
1908 json!({
1909 "claude-test": {
1910 "id": "claude-test", "name": "Claude Test", "tool_call": true, "reasoning": true,
1911 "reasoning_options": [{"type": "effort", "values": ["low", "high", "max"]}],
1912 "limit": {"context": 200_000, "output": 0}
1913 }
1914 }),
1915 );
1916
1917 let models = build_from_value(&data);
1918 let model = models["anthropic"].iter().find(|model| model.model_id == "claude-test").unwrap();
1919 assert_eq!(model.reasoning_levels, ["low", "high", "max"]);
1920 }
1921
1922 #[test]
1923 fn generate_rejects_unknown_reasoning_effort_metadata() {
1924 let mut data = minimal_models_dev_json();
1925 anthropic_models(
1926 &mut data,
1927 json!({
1928 "claude-test": {
1929 "id": "claude-test", "name": "Claude Test", "tool_call": true, "reasoning": true,
1930 "reasoning_options": [{"type": "effort", "values": ["ultra"]}],
1931 "limit": {"context": 200_000, "output": 0}
1932 }
1933 }),
1934 );
1935 let tmp = NamedTempFile::new().unwrap();
1936 std::fs::write(tmp.path(), serde_json::to_string(&data).unwrap()).unwrap();
1937 assert!(matches!(generate(tmp.path()), Err(CodegenError::UnsupportedReasoningEffort { .. })));
1938 }
1939
1940 #[test]
1941 fn build_preserves_model_pricing_and_omits_codex_subscription_pricing() {
1942 let mut data = minimal_models_dev_json();
1943 anthropic_models(
1944 &mut data,
1945 json!({
1946 "priced": {
1947 "id": "priced", "name": "Priced", "tool_call": true,
1948 "limit": {"context": 200_000, "output": 0},
1949 "cost": {"input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_write": 3.75}
1950 }
1951 }),
1952 );
1953 insert_models(
1954 &mut data,
1955 "openai",
1956 json!({
1957 "gpt-5.5": {
1958 "id": "gpt-5.5", "name": "GPT-5.5", "tool_call": true,
1959 "limit": {"context": 1_050_000, "output": 128_000},
1960 "cost": {"input": 1.25, "output": 10.0, "cache_read": 0.125}
1961 }
1962 }),
1963 );
1964
1965 let models = build_from_value(&data);
1966 let priced = models["anthropic"].iter().find(|model| model.model_id == "priced").unwrap();
1967 assert_eq!(priced.pricing.as_ref().map(|pricing| pricing.input), Some(3.0));
1968 assert_eq!(priced.pricing.as_ref().map(|pricing| pricing.output), Some(15.0));
1969 assert_eq!(priced.pricing.as_ref().and_then(|pricing| pricing.cache_read), Some(0.3));
1970 assert_eq!(priced.pricing.as_ref().and_then(|pricing| pricing.cache_write), Some(3.75));
1971
1972 let codex = models["codex"].iter().find(|model| model.model_id == "gpt-5.5").unwrap();
1973 assert_eq!(codex.pricing, None);
1974 assert!(codex.supports_prompt_caching);
1975 }
1976
1977 #[test]
1978 fn build_derives_prompt_caching_from_cost_fields() {
1979 let mut data = minimal_models_dev_json();
1980 insert_models(
1981 &mut data,
1982 "amazon-bedrock",
1983 json!({
1984 "cached": {
1985 "id": "cached", "name": "Cached", "tool_call": true,
1986 "limit": {"context": 200_000, "output": 0},
1987 "cost": {"input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_write": 3.75}
1988 },
1989 "uncached": {
1990 "id": "uncached", "name": "Uncached", "tool_call": true,
1991 "limit": {"context": 200_000, "output": 0},
1992 "cost": {"input": 3.0, "output": 15.0}
1993 }
1994 }),
1995 );
1996
1997 let models = build_from_value(&data);
1998 let bedrock = &models["amazon-bedrock"];
1999 let cached = bedrock.iter().find(|m| m.model_id == "cached").unwrap();
2000 let uncached = bedrock.iter().find(|m| m.model_id == "uncached").unwrap();
2001 assert!(cached.supports_prompt_caching);
2002 assert!(!uncached.supports_prompt_caching);
2003 }
2004
2005 #[test]
2006 fn build_assigns_codex_model_specific_reasoning_levels() {
2007 let mut data = minimal_models_dev_json();
2008 insert_models(
2009 &mut data,
2010 "openai",
2011 json!({
2012 "gpt-5.6-sol": {
2013 "id": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "tool_call": true, "reasoning": true,
2014 "reasoning_options": [{"type": "effort", "values": ["none", "low", "medium", "high", "xhigh", "max"]}],
2015 "limit": {"context": 200_000, "output": 0}
2016 },
2017 "gpt-5.6-luna": {
2018 "id": "gpt-5.6-luna", "name": "GPT-5.6 Luna", "tool_call": true, "reasoning": true,
2019 "reasoning_options": [{"type": "effort", "values": ["none", "low", "medium", "high", "xhigh", "max"]}],
2020 "limit": {"context": 200_000, "output": 0}
2021 },
2022 "gpt-5.4": {
2023 "id": "gpt-5.4", "name": "GPT-5.4", "tool_call": true, "reasoning": true,
2024 "limit": {"context": 200_000, "output": 0}
2025 }
2026 }),
2027 );
2028
2029 let models = build_from_value(&data);
2030 let levels = |id: &str| models["codex"].iter().find(|m| m.model_id == id).unwrap().reasoning_levels.clone();
2031 assert_eq!(levels("gpt-5.6-sol"), vec!["low", "medium", "high", "xhigh", "max"]);
2032 assert_eq!(levels("gpt-5.6-luna"), vec!["low", "medium", "high", "xhigh", "max"]);
2033 assert_eq!(levels("gpt-5.4"), vec!["low", "medium", "high", "xhigh"]);
2034 }
2035
2036 #[test]
2037 fn build_applies_codex_subscription_context_window_override() {
2038 let mut data = minimal_models_dev_json();
2039 insert_models(
2040 &mut data,
2041 "openai",
2042 json!({
2043 "gpt-5.5": {
2044 "id": "gpt-5.5", "name": "GPT-5.5", "tool_call": true, "reasoning": true,
2045 "limit": {"context": 1_050_000, "output": 128_000}
2046 }
2047 }),
2048 );
2049
2050 let models = build_from_value(&data);
2051 let codex = models["codex"].iter().find(|m| m.model_id == "gpt-5.5").unwrap();
2052 let openai = models["openai"].iter().find(|m| m.model_id == "gpt-5.5").unwrap();
2053 assert_eq!(codex.context_window, 272_000);
2054 assert_eq!(openai.context_window, 1_050_000);
2055 }
2056
2057 #[test]
2058 fn generate_uses_codex_subscription_model_ids() {
2059 let mut data = minimal_models_dev_json();
2060 insert_models(
2061 &mut data,
2062 "openai",
2063 json!({
2064 "gpt-5.1-codex": {
2065 "id": "gpt-5.1-codex", "name": "GPT-5.1 Codex", "tool_call": true, "reasoning": true,
2066 "limit": {"context": 400_000, "output": 128_000}
2067 },
2068 "gpt-5.6": {
2069 "id": "gpt-5.6", "name": "GPT-5.6 Sol", "tool_call": true, "reasoning": true,
2070 "limit": {"context": 1_050_000, "output": 128_000}
2071 },
2072 "gpt-5.6-sol": {
2073 "id": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "tool_call": true, "reasoning": true,
2074 "limit": {"context": 1_050_000, "output": 128_000}
2075 },
2076 "gpt-5.6-terra": {
2077 "id": "gpt-5.6-terra", "name": "GPT-5.6 Terra", "tool_call": true, "reasoning": true,
2078 "limit": {"context": 1_050_000, "output": 128_000}
2079 },
2080 "gpt-5.6-luna": {
2081 "id": "gpt-5.6-luna", "name": "GPT-5.6 Luna", "tool_call": true, "reasoning": true,
2082 "limit": {"context": 1_050_000, "output": 128_000}
2083 }
2084 }),
2085 );
2086
2087 let tmp = NamedTempFile::new().unwrap();
2088 std::fs::write(tmp.path(), serde_json::to_string(&data).unwrap()).unwrap();
2089 let output = generate(tmp.path()).unwrap();
2090
2091 let codex_doc = &output.provider_docs["codex"];
2092 assert!(!codex_doc.contains("`gpt-5.6`"));
2093 assert!(!codex_doc.contains("`gpt-5.1-codex`"));
2094 assert!(codex_doc.contains("| `gpt-5.6-sol` | `GPT-5.6 Sol` | `272k` |"));
2095 assert!(codex_doc.contains("| `gpt-5.6-terra` | `GPT-5.6 Terra` | `272k` |"));
2096 assert!(codex_doc.contains("| `gpt-5.6-luna` | `GPT-5.6 Luna` | `272k` |"));
2097
2098 let openai_doc = &output.provider_docs["openai"];
2099 assert!(openai_doc.contains("`gpt-5.6`"));
2100 assert!(openai_doc.contains("`gpt-5.1-codex`"));
2101 assert!(openai_doc.contains("`gpt-5.6-sol`"));
2102 }
2103
2104 #[test]
2105 fn generate_emits_provider_docs() {
2106 let mut data = minimal_models_dev_json();
2107 anthropic_models(
2108 &mut data,
2109 json!({
2110 "claude-test": {
2111 "id": "claude-test", "name": "Claude Test", "tool_call": true, "reasoning": true,
2112 "limit": {"context": 200_000, "output": 0},
2113 "modalities": {"input": ["text", "image"]}
2114 }
2115 }),
2116 );
2117
2118 let tmp = NamedTempFile::new().unwrap();
2119 std::fs::write(tmp.path(), serde_json::to_string(&data).unwrap()).unwrap();
2120 let output = generate(tmp.path()).unwrap();
2121
2122 let anthropic_doc = &output.provider_docs["anthropic"];
2123 assert!(anthropic_doc.contains("`Anthropic` LLM provider."));
2124 assert!(anthropic_doc.contains("`ANTHROPIC_API_KEY`"));
2125 assert!(anthropic_doc.contains("| `claude-test` | `Claude Test` | `200k` | yes | yes | |"));
2126
2127 let ollama_doc = &output.provider_docs["ollama"];
2128 assert!(ollama_doc.contains("`Ollama` LLM provider."));
2129 assert!(ollama_doc.contains("any model name at runtime"));
2130 }
2131
2132 #[test]
2133 fn generate_preserves_disabled_separately_from_default() {
2134 let mut data = minimal_models_dev_json();
2135 data["openai"]["models"]["gpt-5.4"]["reasoning_options"] = json!([
2136 {"type": "effort", "values": [null, "default", "none", "low", "high"]}
2137 ]);
2138 anthropic_models(
2139 &mut data,
2140 json!({
2141 "claude-toggle": {
2142 "id": "claude-toggle", "name": "Toggle", "tool_call": true, "reasoning": true,
2143 "reasoning_options": [{"type": "toggle"}, {"type": "effort", "values": ["low", "high"]}]
2144 }
2145 }),
2146 );
2147 let tmp = NamedTempFile::new().unwrap();
2148 std::fs::write(tmp.path(), serde_json::to_string(&data).unwrap()).unwrap();
2149 let output = generate(tmp.path()).unwrap();
2150 assert!(output.rust_source.contains("ReasoningEffort::Disabled"));
2151 assert_default_is_not_capability(&output.rust_source);
2152 assert!(output.provider_docs["openai"].contains("disabled"));
2153 assert!(output.provider_docs["anthropic"].contains("disabled"));
2154 assert!(!output.provider_docs["codex"].contains("disabled"));
2155 }
2156
2157 #[test]
2158 fn generate_reasoning_capability_edge_cases() {
2159 let mut data = minimal_models_dev_json();
2160 for (id, reasoning, options, disabled) in [
2161 ("toggle", true, json!([{"type": "toggle"}]), true),
2162 (
2163 "both",
2164 true,
2165 json!([{"type": "toggle"}, {"type": "effort", "values": ["none", "high", "none", "low", "low"]}]),
2166 true,
2167 ),
2168 ("defaults", true, json!([{"type": "effort", "values": [null, "default"]}]), false),
2169 ("budget", true, json!([{"type": "budget_tokens"}]), false),
2170 ("missing", true, json!([]), false),
2171 ("plain", false, json!([{"type": "toggle"}]), false),
2172 ] {
2173 data["anthropic"]["models"] = json!({id: {"id": id, "name": id, "reasoning": reasoning, "tool_call": true, "reasoning_options": options}});
2174 let tmp = NamedTempFile::new().unwrap();
2175 std::fs::write(tmp.path(), serde_json::to_string(&data).unwrap()).unwrap();
2176 let output = generate(tmp.path()).unwrap();
2177 assert_eq!(output.provider_docs["anthropic"].contains("advertises `disabled`"), disabled, "{id}");
2178 assert_default_is_not_capability(&output.rust_source);
2179 if id == "both" {
2180 assert!(output.rust_source.contains("ReasoningDisabledSupport::Effort"));
2181 assert!(!output.rust_source.contains("ReasoningEffort::Disabled, ReasoningEffort::Disabled"));
2182 }
2183 }
2184 }
2185
2186 fn assert_default_is_not_capability(source: &str) {
2187 let file = syn::parse_file(source).unwrap();
2188 for item in file.items {
2189 if let syn::Item::Impl(implementation) = item {
2190 for item in implementation.items {
2191 if let syn::ImplItem::Fn(method) = item
2192 && method.sig.ident == "reasoning_levels"
2193 {
2194 let body = method.block;
2195 assert!(!quote! { #body }.to_string().contains("ReasoningEffort :: Default"));
2196 }
2197 }
2198 }
2199 }
2200 }
2201
2202 fn build_from_value(data: &Value) -> ProviderModels {
2203 let parsed: ModelsDevData = serde_json::from_value(data.clone()).expect("parse fixture");
2204 build_provider_models(&parsed).expect("build provider models")
2205 }
2206
2207 fn anthropic_models(data: &mut Value, models: Value) {
2208 insert_models(data, "anthropic", models);
2209 }
2210
2211 fn zai_extra_models(data: &mut Value, models: Value) {
2212 insert_models(data, "zai-coding-plan", models);
2213 }
2214
2215 fn insert_models(data: &mut Value, provider_key: &str, models: Value) {
2216 let provider = data.as_object_mut().unwrap().get_mut(provider_key).unwrap().as_object_mut().unwrap();
2217 let target = provider.get_mut("models").unwrap().as_object_mut().unwrap();
2218 let Value::Object(models) = models else {
2219 panic!("models fixture must be an object");
2220 };
2221 target.extend(models);
2222 }
2223
2224 fn minimal_models_dev_json() -> Value {
2225 let mut root = serde_json::Map::new();
2226 for cfg in PROVIDERS {
2227 let json_key = cfg.json_key();
2228 root.entry(json_key.to_string())
2229 .or_insert_with(|| json!({"id": json_key, "name": json_key, "env": [], "models": {}}));
2230 for &extra in cfg.extra_source_ids {
2231 root.entry(extra.to_string())
2232 .or_insert_with(|| json!({"id": extra, "name": extra, "env": [], "models": {}}));
2233 }
2234 }
2235 let openai = root.get_mut("openai").unwrap()["models"].as_object_mut().unwrap();
2236 for model in CODEX_SUBSCRIPTION_MODELS {
2237 openai.insert(
2238 model.id.to_string(),
2239 json!({
2240 "id": model.id,
2241 "name": model.id,
2242 "tool_call": true,
2243 "reasoning": true,
2244 "reasoning_options": [{"type": "effort", "values": ["low", "medium", "high", "xhigh"]}],
2245 "limit": {"context": 1_050_000, "output": 0}
2246 }),
2247 );
2248 }
2249 Value::Object(root)
2250 }
2251}