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