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