Skip to main content

llm_codegen/
lib.rs

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