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};
7use std::fmt::Write;
8use std::path::Path;
9
10type ModelsDevData = HashMap<String, ProviderData>;
11type ContextWindowOverride = fn(&str, u32) -> u32;
12
13#[derive(Debug, Deserialize)]
14struct ProviderData {
15    #[allow(dead_code)]
16    id: String,
17    #[allow(dead_code)]
18    name: String,
19    #[serde(default)]
20    #[allow(dead_code)]
21    env: Vec<String>,
22    #[serde(default)]
23    models: HashMap<String, ModelData>,
24}
25
26#[derive(Debug, Deserialize)]
27struct ModelData {
28    id: String,
29    name: String,
30    #[serde(default)]
31    tool_call: Option<bool>,
32    #[serde(default)]
33    reasoning: Option<bool>,
34    #[serde(default)]
35    #[allow(dead_code)]
36    cost: Option<CostData>,
37    #[serde(default)]
38    limit: Option<LimitData>,
39    #[serde(default)]
40    modalities: Option<ModalitiesData>,
41}
42
43#[derive(Debug, Deserialize, Default)]
44struct ModalitiesData {
45    #[serde(default)]
46    input: Vec<String>,
47}
48
49#[derive(Debug, Deserialize)]
50#[allow(dead_code)]
51struct CostData {
52    #[serde(default)]
53    input: f64,
54    #[serde(default)]
55    output: f64,
56    #[serde(default)]
57    cache_read: Option<f64>,
58    #[serde(default)]
59    cache_write: Option<f64>,
60}
61
62#[derive(Debug, Deserialize)]
63struct LimitData {
64    #[serde(default)]
65    context: u32,
66    #[serde(default)]
67    #[allow(dead_code)]
68    output: u32,
69}
70
71impl CostData {
72    fn has_prompt_caching(&self) -> bool {
73        self.cache_read.is_some() || self.cache_write.is_some()
74    }
75}
76
77/// Provider configuration for codegen (catalog providers with known model lists)
78struct ProviderConfig {
79    /// Unique provider key used in `provider_models` map (e.g. "codex")
80    dev_id: &'static str,
81    /// models.dev provider ID to read models from (defaults to `dev_id` when `None`)
82    source_dev_id: Option<&'static str>,
83    /// Additional models.dev keys whose models are merged into this provider
84    extra_source_ids: &'static [&'static str],
85    /// Only include models whose ID passes this filter (None = include all)
86    model_filter: Option<fn(&str) -> bool>,
87    /// Provider-specific generated context window override
88    context_window_override: Option<ContextWindowOverride>,
89    /// Our Rust enum name (e.g. "Gemini")
90    enum_name: &'static str,
91    /// Our internal provider name used for parsing (e.g. "gemini")
92    parser_name: &'static str,
93    /// Human-readable provider name (e.g. "AWS Bedrock")
94    display_name: &'static str,
95    /// Env var our code actually checks (None for providers with complex credential chains)
96    env_var: Option<&'static str>,
97    /// OAuth provider ID for providers that require OAuth login (e.g. "codex")
98    oauth_provider_id: Option<&'static str>,
99    /// Default reasoning levels for models that support reasoning (empty = use standard 3)
100    default_reasoning_levels: &'static [&'static str],
101    /// When true, the inner catalog enum is named `{Enum}FoundationModel` and
102    /// `LlmModel::{Enum}` carries a hand-written `{Enum}Model` wrapper (defined
103    /// outside of codegen) that adds a `Profile(String)` fall-through plus any
104    /// provider-specific parsing policy. Used for Bedrock to accept arbitrary
105    /// inference profile IDs at runtime while keeping ARNs out of model identity.
106    is_hybrid_dynamic: bool,
107}
108
109impl ProviderConfig {
110    /// Shorthand for providers with default `source_dev_id`, `model_filter`, and `oauth_provider_id`.
111    const fn standard(
112        dev_id: &'static str,
113        enum_name: &'static str,
114        parser_name: &'static str,
115        display_name: &'static str,
116        env_var: Option<&'static str>,
117    ) -> Self {
118        Self {
119            dev_id,
120            source_dev_id: None,
121            extra_source_ids: &[],
122            model_filter: None,
123            context_window_override: None,
124            enum_name,
125            parser_name,
126            display_name,
127            env_var,
128            oauth_provider_id: None,
129            default_reasoning_levels: &["low", "medium", "high"],
130            is_hybrid_dynamic: false,
131        }
132    }
133
134    /// Inner catalog-enum name. For hybrid providers the outer `{enum_name}Model`
135    /// is a wrapper; the catalog enum is `{enum_name}FoundationModel`.
136    fn inner_enum_name(&self) -> String {
137        if self.is_hybrid_dynamic {
138            format!("{}FoundationModel", self.enum_name)
139        } else {
140            format!("{}Model", self.enum_name)
141        }
142    }
143
144    /// Outer enum name as referenced by `LlmModel::{enum_name}(...)`.
145    fn outer_enum_name(&self) -> String {
146        format!("{}Model", self.enum_name)
147    }
148
149    /// The models.dev key to look up in the JSON data.
150    fn json_key(&self) -> &'static str {
151        self.source_dev_id.unwrap_or(self.dev_id)
152    }
153}
154
155/// Dynamic provider — model name is user-supplied at runtime, no fixed enum
156#[allow(clippy::struct_field_names)]
157struct DynamicProviderConfig {
158    /// Rust variant name in `LlmModel` (e.g. "Ollama")
159    enum_name: &'static str,
160    /// Parser name used in "provider:model" strings (e.g. "ollama")
161    parser_name: &'static str,
162    /// Human-readable provider name (e.g. "Ollama")
163    display_name: &'static str,
164}
165
166const PROVIDERS: &[ProviderConfig] = &[
167    ProviderConfig::standard("anthropic", "Anthropic", "anthropic", "Anthropic", Some("ANTHROPIC_API_KEY")),
168    ProviderConfig {
169        dev_id: "codex",
170        source_dev_id: Some("openai"),
171        extra_source_ids: &[],
172        model_filter: Some(|id| id.contains("codex") || id.starts_with("gpt-5.") || id == "gpt-5"),
173        context_window_override: Some(codex_subscription_context_window),
174        enum_name: "Codex",
175        parser_name: "codex",
176        display_name: "Codex",
177        env_var: None,
178        oauth_provider_id: Some("codex"),
179        default_reasoning_levels: &["low", "medium", "high", "xhigh"],
180        is_hybrid_dynamic: false,
181    },
182    ProviderConfig::standard("deepseek", "DeepSeek", "deepseek", "DeepSeek", Some("DEEPSEEK_API_KEY")),
183    ProviderConfig::standard("google", "Gemini", "gemini", "Gemini", Some("GEMINI_API_KEY")),
184    ProviderConfig::standard("moonshotai", "Moonshot", "moonshot", "Moonshot", Some("MOONSHOT_API_KEY")),
185    ProviderConfig::standard("openai", "Openai", "openai", "OpenAI", Some("OPENAI_API_KEY")),
186    ProviderConfig::standard("openrouter", "OpenRouter", "openrouter", "OpenRouter", Some("OPENROUTER_API_KEY")),
187    ProviderConfig {
188        extra_source_ids: &["zai-coding-plan"],
189        ..ProviderConfig::standard("zai", "ZAi", "zai", "ZAI", Some("ZAI_API_KEY"))
190    },
191    ProviderConfig {
192        is_hybrid_dynamic: true,
193        ..ProviderConfig::standard("amazon-bedrock", "Bedrock", "bedrock", "AWS Bedrock", None)
194    },
195];
196
197const DYNAMIC_PROVIDERS: &[DynamicProviderConfig] = &[
198    DynamicProviderConfig { enum_name: "Ollama", parser_name: "ollama", display_name: "Ollama" },
199    DynamicProviderConfig { enum_name: "LlamaCpp", parser_name: "llamacpp", display_name: "LlamaCpp" },
200];
201
202const CODEX_SUBSCRIPTION_CONTEXT_WINDOW: u32 = 272_000;
203
204fn codex_subscription_context_window(model_id: &str, default_context_window: u32) -> u32 {
205    match model_id {
206        "gpt-5.5" | "gpt-5.4" | "gpt-5.4-mini" | "gpt-5.3-codex" | "gpt-5.2" | "codex-auto-review" => {
207            CODEX_SUBSCRIPTION_CONTEXT_WINDOW
208        }
209        _ => default_context_window,
210    }
211}
212
213#[derive(Debug, Clone)]
214struct ModelInfo {
215    variant_name: String,
216    model_id: String,
217    display_name: String,
218    context_window: u32,
219    reasoning_levels: Vec<String>,
220    input_modalities: Vec<String>,
221    supports_prompt_caching: bool,
222}
223
224type ProviderModels = BTreeMap<&'static str, Vec<ModelInfo>>;
225
226struct CodegenCtx {
227    provider_models: ProviderModels,
228}
229
230/// Output of the code generator.
231pub struct GeneratedOutput {
232    /// The generated Rust source (for `generated.rs`).
233    pub rust_source: String,
234    /// Per-provider markdown documentation keyed by provider identifier.
235    ///
236    /// Keys are provider `dev_ids` (e.g. `"anthropic"`, `"ollama"`) and values
237    /// are markdown strings suitable for `#![doc = include_str!(...)]`.
238    pub provider_docs: HashMap<String, String>,
239}
240
241/// Run the codegen, returning the generated Rust source and per-provider docs.
242pub fn generate(models_json_path: &Path) -> Result<GeneratedOutput, String> {
243    let json_bytes = std::fs::read_to_string(models_json_path).map_err(|e| format!("read: {e}"))?;
244    let data: ModelsDevData = serde_json::from_str(&json_bytes).map_err(|e| format!("parse: {e}"))?;
245
246    let provider_models = build_provider_models(&data)?;
247    let ctx = CodegenCtx { provider_models };
248    Ok(GeneratedOutput { rust_source: emit_generated_source(&ctx), provider_docs: emit_provider_docs(&ctx) })
249}
250
251fn build_provider_models(data: &ModelsDevData) -> Result<ProviderModels, String> {
252    let mut provider_models = ProviderModels::new();
253
254    for cfg in PROVIDERS {
255        let json_key = cfg.json_key();
256        let provider_data =
257            data.get(json_key).ok_or_else(|| format!("Provider '{json_key}' not found in models.dev data"))?;
258
259        let mut models: Vec<ModelInfo> = collect_models_from(cfg, &provider_data.models);
260
261        for &extra_key in cfg.extra_source_ids {
262            if let Some(extra_data) = data.get(extra_key) {
263                let extra = collect_models_from(cfg, &extra_data.models);
264                let existing_ids: std::collections::HashSet<String> =
265                    models.iter().map(|m| m.model_id.clone()).collect();
266                models.extend(extra.into_iter().filter(|m| !existing_ids.contains(&m.model_id)));
267            }
268        }
269
270        models.sort_by(|a, b| a.model_id.cmp(&b.model_id));
271        provider_models.insert(cfg.dev_id, models);
272    }
273
274    Ok(provider_models)
275}
276
277fn collect_models_from(cfg: &ProviderConfig, models: &HashMap<String, ModelData>) -> Vec<ModelInfo> {
278    models
279        .values()
280        .filter(|m| m.tool_call == Some(true))
281        .filter(|m| !is_alias(&m.id))
282        .filter(|m| cfg.model_filter.is_none_or(|f| f(&m.id)))
283        .map(|m| {
284            let reasoning_levels = if m.reasoning.unwrap_or(false) {
285                cfg.default_reasoning_levels.iter().map(|s| (*s).to_string()).collect()
286            } else {
287                Vec::new()
288            };
289            let input_modalities =
290                m.modalities.as_ref().map_or_else(|| vec!["text".to_string()], |md| md.input.clone());
291            let source_context_window = m.limit.as_ref().map_or(0, |l| l.context);
292            let context_window = cfg.context_window_override.map_or(source_context_window, |override_context_window| {
293                override_context_window(&m.id, source_context_window)
294            });
295            ModelInfo {
296                variant_name: model_id_to_variant(&m.id),
297                model_id: m.id.clone(),
298                display_name: m.name.clone(),
299                context_window,
300                reasoning_levels,
301                input_modalities,
302                supports_prompt_caching: m.cost.as_ref().is_some_and(CostData::has_prompt_caching),
303            }
304        })
305        .collect()
306}
307
308/// Returns true for "latest" alias IDs that just point to another model
309fn is_alias(id: &str) -> bool {
310    id.ends_with("-latest")
311}
312
313/// Convert a model ID like "claude-sonnet-4-5-20250929" into a `PascalCase` variant name.
314/// Treats `-`, `.`, `/`, and `:` as word separators.
315fn model_id_to_variant(id: &str) -> String {
316    let mut result = String::new();
317    let mut capitalize_next = true;
318
319    for ch in id.chars() {
320        if ch == '-' || ch == '.' || ch == '/' || ch == ':' {
321            capitalize_next = true;
322        } else if capitalize_next {
323            result.push(ch.to_ascii_uppercase());
324            capitalize_next = false;
325        } else {
326            result.push(ch);
327        }
328    }
329
330    if result.starts_with(|c: char| c.is_ascii_digit()) {
331        result.insert(0, '_');
332    }
333
334    result
335}
336
337fn emit_generated_source(ctx: &CodegenCtx) -> String {
338    let provider_enums = emit_provider_enums(&ctx.provider_models);
339    let provider_impls = emit_provider_impls(&ctx.provider_models);
340    let llm_model_enum = emit_llm_model_enum();
341    let from_impls = emit_from_impls();
342    let llm_model_impl = emit_llm_model_impl();
343    let display_impl = emit_display_impl();
344    let fromstr_impl = emit_fromstr_impl();
345
346    let file_tokens = quote! {
347        use std::borrow::Cow;
348        use std::sync::LazyLock;
349        use crate::ReasoningEffort;
350
351        #provider_enums
352        #provider_impls
353        #llm_model_enum
354        #from_impls
355        #llm_model_impl
356        #display_impl
357        #fromstr_impl
358    };
359
360    let file: syn::File = syn::parse2(file_tokens).expect("generated tokens parse as Rust");
361    let formatted = prettyplease::unparse(&file);
362    format!(
363        "// Auto-generated from models.dev — do not edit manually\n// Regenerated automatically by build.rs\n\n{formatted}"
364    )
365}
366
367fn emit_provider_enums(provider_models: &ProviderModels) -> TokenStream {
368    let enums = PROVIDERS.iter().map(|cfg| {
369        let inner = format_ident!("{}", cfg.inner_enum_name());
370        let variants = provider_models[cfg.dev_id].iter().map(|m| format_ident!("{}", m.variant_name));
371        quote! {
372            #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
373            pub enum #inner {
374                #(#variants,)*
375            }
376        }
377    });
378    quote! { #(#enums)* }
379}
380
381fn emit_provider_impls(provider_models: &ProviderModels) -> TokenStream {
382    let impls = PROVIDERS.iter().map(|cfg| {
383        let models = &provider_models[cfg.dev_id];
384        let enum_ident = format_ident!("{}", cfg.inner_enum_name());
385
386        let model_id_arms = models.iter().map(|m| {
387            let v = format_ident!("{}", m.variant_name);
388            let id = &m.model_id;
389            quote! { Self::#v => #id, }
390        });
391
392        let display_name_arms = grouped_arms(
393            models,
394            |m| m.display_name.clone(),
395            |m| {
396                let s = &m.display_name;
397                quote! { #s }
398            },
399        );
400
401        let context_window_arms =
402            grouped_arms(models, |m| m.context_window, |m| num_lit_with_underscores(m.context_window));
403
404        let reasoning_levels_arms = emit_reasoning_levels_arms(models);
405
406        let prompt_caching_arms = grouped_arms(
407            models,
408            |m| m.supports_prompt_caching,
409            |m| {
410                let b = m.supports_prompt_caching;
411                quote! { #b }
412            },
413        );
414
415        let modality_methods = ["image", "audio"].iter().map(|modality| {
416            let method = format_ident!("supports_{}", modality);
417            let mod_owned = (*modality).to_string();
418            let arms = grouped_arms(models, move |m| m.input_modalities.contains(&mod_owned), {
419                let mod_owned = (*modality).to_string();
420                move |m| {
421                    let b = m.input_modalities.contains(&mod_owned);
422                    quote! { #b }
423                }
424            });
425            quote! {
426                #[allow(clippy::too_many_lines)]
427                pub fn #method(self) -> bool {
428                    match self { #arms }
429                }
430            }
431        });
432
433        let all_variants = models.iter().map(|m| format_ident!("{}", m.variant_name));
434
435        let from_str_impl = emit_from_str_impl(&enum_ident, cfg.parser_name, models);
436
437        quote! {
438            impl #enum_ident {
439                #[allow(clippy::too_many_lines)]
440                fn model_id(self) -> &'static str {
441                    match self { #(#model_id_arms)* }
442                }
443
444                #[allow(clippy::too_many_lines)]
445                fn display_name(self) -> &'static str {
446                    match self { #display_name_arms }
447                }
448
449                #[allow(clippy::too_many_lines)]
450                fn context_window(self) -> u32 {
451                    match self { #context_window_arms }
452                }
453
454                #[allow(clippy::too_many_lines)]
455                pub fn reasoning_levels(self) -> &'static [ReasoningEffort] {
456                    match self { #reasoning_levels_arms }
457                }
458
459                pub fn supports_reasoning(self) -> bool {
460                    !self.reasoning_levels().is_empty()
461                }
462
463                #[allow(clippy::too_many_lines)]
464                pub fn supports_prompt_caching(self) -> bool {
465                    match self { #prompt_caching_arms }
466                }
467
468                #(#modality_methods)*
469
470                const ALL: &[#enum_ident] = &[#(Self::#all_variants),*];
471            }
472
473            #from_str_impl
474        }
475    });
476    quote! { #(#impls)* }
477}
478
479fn emit_from_str_impl(enum_ident: &proc_macro2::Ident, parser_name: &str, models: &[ModelInfo]) -> TokenStream {
480    let arms = models.iter().map(|m| {
481        let id = &m.model_id;
482        let v = format_ident!("{}", m.variant_name);
483        quote! { #id => Ok(Self::#v), }
484    });
485    let err_msg = format!("Unknown {parser_name} model: '{{s}}'");
486    quote! {
487        impl std::str::FromStr for #enum_ident {
488            type Err = String;
489
490            #[allow(clippy::too_many_lines)]
491            fn from_str(s: &str) -> Result<Self, Self::Err> {
492                match s {
493                    #(#arms)*
494                    _ => Err(format!(#err_msg)),
495                }
496            }
497        }
498    }
499}
500
501/// Emit match arms grouped by value to avoid clippy `match_same_arms`.
502fn grouped_arms<K, R>(
503    models: &[ModelInfo],
504    key_fn: impl Fn(&ModelInfo) -> K,
505    rhs_fn: impl Fn(&ModelInfo) -> R,
506) -> TokenStream
507where
508    K: Eq + Ord,
509    R: ToTokens,
510{
511    let mut groups: BTreeMap<K, Vec<&ModelInfo>> = BTreeMap::new();
512    for m in models {
513        groups.entry(key_fn(m)).or_default().push(m);
514    }
515    let arms = groups.values().map(|members| {
516        let pats = members.iter().map(|m| {
517            let v = format_ident!("{}", m.variant_name);
518            quote! { Self::#v }
519        });
520        let rhs = rhs_fn(members[0]);
521        quote! { #(#pats)|* => #rhs, }
522    });
523    quote! { #(#arms)* }
524}
525
526fn emit_reasoning_levels_arms(models: &[ModelInfo]) -> TokenStream {
527    grouped_arms(
528        models,
529        |m| m.reasoning_levels.clone(),
530        |m| {
531            if m.reasoning_levels.is_empty() {
532                quote! { &[] }
533            } else {
534                let items = m.reasoning_levels.iter().map(|l| {
535                    let variant = format_ident!("{}", level_str_to_variant(l));
536                    quote! { ReasoningEffort::#variant }
537                });
538                quote! { &[#(#items),*] }
539            }
540        },
541    )
542}
543
544/// Map a reasoning level string to its `ReasoningEffort` variant name.
545fn level_str_to_variant(level: &str) -> &'static str {
546    match level {
547        "low" => "Low",
548        "medium" => "Medium",
549        "high" => "High",
550        "xhigh" => "Xhigh",
551        other => panic!("Unknown reasoning level: {other}"),
552    }
553}
554
555fn emit_llm_model_enum() -> TokenStream {
556    let catalog_variants = PROVIDERS.iter().map(|cfg| {
557        let v = format_ident!("{}", cfg.enum_name);
558        let inner = format_ident!("{}Model", cfg.enum_name);
559        quote! { #v(#inner) }
560    });
561    let dynamic_variants = DYNAMIC_PROVIDERS.iter().map(|d| {
562        let v = format_ident!("{}", d.enum_name);
563        quote! { #v(String) }
564    });
565    quote! {
566        /// A model from a specific provider
567        #[derive(Debug, Clone, PartialEq, Eq, Hash)]
568        pub enum LlmModel {
569            #(#catalog_variants,)*
570            #(#dynamic_variants,)*
571        }
572    }
573}
574
575fn emit_from_impls() -> TokenStream {
576    let impls = PROVIDERS.iter().map(|cfg| {
577        let outer = format_ident!("{}Model", cfg.enum_name);
578        let v = format_ident!("{}", cfg.enum_name);
579        quote! {
580            impl From<#outer> for LlmModel {
581                fn from(m: #outer) -> Self {
582                    LlmModel::#v(m)
583                }
584            }
585        }
586    });
587    quote! { #(#impls)* }
588}
589
590fn emit_llm_model_impl() -> TokenStream {
591    let model_id = emit_llm_model_id();
592    let display_name = emit_llm_display_name();
593    let provider = emit_llm_provider();
594    let provider_display_name = emit_llm_provider_display_name();
595    let context_window = emit_llm_context_window();
596    let required_env_var = emit_llm_required_env_var();
597    let all_required_env_vars = emit_llm_all_required_env_vars();
598    let oauth_provider_id = emit_llm_oauth_provider_id();
599    let reasoning_levels = emit_llm_reasoning_levels();
600    let supports_reasoning = emit_llm_supports_reasoning();
601    let supports_prompt_caching = emit_llm_supports_prompt_caching();
602    let modality_methods = ["image", "audio"].iter().map(|m| emit_llm_supports_modality(m));
603    let all = emit_llm_all();
604
605    quote! {
606        impl LlmModel {
607            #model_id
608            #display_name
609            #provider
610            #provider_display_name
611            #context_window
612            #required_env_var
613            #all_required_env_vars
614            #oauth_provider_id
615            #reasoning_levels
616            #supports_reasoning
617            #supports_prompt_caching
618            #(#modality_methods)*
619            #all
620        }
621    }
622}
623
624fn emit_llm_model_id() -> TokenStream {
625    let catalog_arms = PROVIDERS.iter().map(|cfg| {
626        let v = format_ident!("{}", cfg.enum_name);
627        if cfg.is_hybrid_dynamic {
628            quote! { Self::#v(m) => m.model_id(), }
629        } else {
630            quote! { Self::#v(m) => Cow::Borrowed(m.model_id()), }
631        }
632    });
633    let dyn_pats = dynamic_pattern_with_binding("s");
634    quote! {
635        /// Raw model ID (e.g. `claude-opus-4-6`, `llama3.2`)
636        pub fn model_id(&self) -> Cow<'static, str> {
637            match self {
638                #(#catalog_arms)*
639                #dyn_pats => Cow::Owned(s.clone()),
640            }
641        }
642    }
643}
644
645fn emit_llm_display_name() -> TokenStream {
646    let catalog_arms = PROVIDERS.iter().map(|cfg| {
647        let v = format_ident!("{}", cfg.enum_name);
648        if cfg.is_hybrid_dynamic {
649            quote! { Self::#v(m) => m.display_name(), }
650        } else {
651            quote! { Self::#v(m) => Cow::Borrowed(m.display_name()), }
652        }
653    });
654    let dyn_arms = DYNAMIC_PROVIDERS.iter().map(|d| {
655        let v = format_ident!("{}", d.enum_name);
656        let fmt = format!("{} {{s}}", d.enum_name);
657        quote! { Self::#v(s) => Cow::Owned(format!(#fmt)), }
658    });
659    quote! {
660        /// Human-readable display name (e.g. `Claude Opus 4.6`)
661        pub fn display_name(&self) -> Cow<'static, str> {
662            match self {
663                #(#catalog_arms)*
664                #(#dyn_arms)*
665            }
666        }
667    }
668}
669
670fn emit_llm_provider() -> TokenStream {
671    let catalog_arms = PROVIDERS.iter().map(|cfg| {
672        let v = format_ident!("{}", cfg.enum_name);
673        let name = cfg.parser_name;
674        quote! { Self::#v(_) => #name, }
675    });
676    let dyn_arms = DYNAMIC_PROVIDERS.iter().map(|d| {
677        let v = format_ident!("{}", d.enum_name);
678        let name = d.parser_name;
679        quote! { Self::#v(_) => #name, }
680    });
681    quote! {
682        /// Provider identifier (e.g. `anthropic`)
683        pub fn provider(&self) -> &'static str {
684            match self {
685                #(#catalog_arms)*
686                #(#dyn_arms)*
687            }
688        }
689    }
690}
691
692fn emit_llm_provider_display_name() -> TokenStream {
693    let catalog_arms = PROVIDERS.iter().map(|cfg| {
694        let v = format_ident!("{}", cfg.enum_name);
695        let name = cfg.display_name;
696        quote! { Self::#v(_) => #name, }
697    });
698    let dyn_arms = DYNAMIC_PROVIDERS.iter().map(|d| {
699        let v = format_ident!("{}", d.enum_name);
700        let name = d.display_name;
701        quote! { Self::#v(_) => #name, }
702    });
703    quote! {
704        /// Human-readable provider name (e.g. `AWS Bedrock`)
705        pub fn provider_display_name(&self) -> &'static str {
706            match self {
707                #(#catalog_arms)*
708                #(#dyn_arms)*
709            }
710        }
711    }
712}
713
714fn emit_llm_context_window() -> TokenStream {
715    let catalog_arms = PROVIDERS.iter().map(|cfg| {
716        let v = format_ident!("{}", cfg.enum_name);
717        if cfg.is_hybrid_dynamic {
718            quote! { Self::#v(m) => m.context_window(), }
719        } else {
720            quote! { Self::#v(m) => Some(m.context_window()), }
721        }
722    });
723    let dyn_pats = dynamic_pattern_with_binding("_");
724    quote! {
725        /// Context window size in tokens (None for dynamic providers)
726        pub fn context_window(&self) -> Option<u32> {
727            match self {
728                #(#catalog_arms)*
729                #dyn_pats => None,
730            }
731        }
732    }
733}
734
735fn emit_llm_required_env_var() -> TokenStream {
736    let mut some_arms = Vec::new();
737    let mut none_pats = Vec::new();
738    for cfg in PROVIDERS {
739        let v = format_ident!("{}", cfg.enum_name);
740        match cfg.env_var {
741            Some(var) => some_arms.push(quote! { Self::#v(_) => Some(#var), }),
742            None => none_pats.push(quote! { Self::#v(_) }),
743        }
744    }
745    for d in DYNAMIC_PROVIDERS {
746        let v = format_ident!("{}", d.enum_name);
747        none_pats.push(quote! { Self::#v(_) });
748    }
749    quote! {
750        /// Required env var for this model's provider (None for local providers)
751        pub fn required_env_var(&self) -> Option<&'static str> {
752            match self {
753                #(#some_arms)*
754                #(#none_pats)|* => None,
755            }
756        }
757    }
758}
759
760fn emit_llm_all_required_env_vars() -> TokenStream {
761    let vars = PROVIDERS.iter().filter_map(|cfg| cfg.env_var);
762    quote! {
763        /// All provider API key env var names (deduplicated, static)
764        pub const ALL_REQUIRED_ENV_VARS: &[&str] = &[#(#vars),*];
765    }
766}
767
768fn emit_llm_oauth_provider_id() -> TokenStream {
769    let mut some_arms = Vec::new();
770    let mut none_pats = Vec::new();
771    for cfg in PROVIDERS {
772        let v = format_ident!("{}", cfg.enum_name);
773        match cfg.oauth_provider_id {
774            Some(id) => some_arms.push(quote! { Self::#v(_) => Some(#id), }),
775            None => none_pats.push(quote! { Self::#v(_) }),
776        }
777    }
778    for d in DYNAMIC_PROVIDERS {
779        let v = format_ident!("{}", d.enum_name);
780        none_pats.push(quote! { Self::#v(_) });
781    }
782    quote! {
783        /// OAuth provider ID if this model requires OAuth login (e.g. `"codex"`)
784        pub fn oauth_provider_id(&self) -> Option<&'static str> {
785            match self {
786                #(#some_arms)*
787                #(#none_pats)|* => None,
788            }
789        }
790    }
791}
792
793fn emit_llm_reasoning_levels() -> TokenStream {
794    let catalog_arms = PROVIDERS.iter().map(|cfg| {
795        let v = format_ident!("{}", cfg.enum_name);
796        quote! { Self::#v(m) => m.reasoning_levels(), }
797    });
798    let dyn_pats = dynamic_pattern_with_binding("_");
799    quote! {
800        /// Reasoning levels supported by this model (empty if not a reasoning model)
801        pub fn reasoning_levels(&self) -> &'static [ReasoningEffort] {
802            match self {
803                #(#catalog_arms)*
804                #dyn_pats => &[],
805            }
806        }
807    }
808}
809
810fn emit_llm_supports_reasoning() -> TokenStream {
811    quote! {
812        /// Whether this model supports reasoning/extended thinking
813        pub fn supports_reasoning(&self) -> bool {
814            !self.reasoning_levels().is_empty()
815        }
816    }
817}
818
819fn emit_llm_supports_prompt_caching() -> TokenStream {
820    let catalog_arms = PROVIDERS.iter().map(|cfg| {
821        let v = format_ident!("{}", cfg.enum_name);
822        quote! { Self::#v(m) => m.supports_prompt_caching(), }
823    });
824    let dyn_pats = dynamic_pattern_with_binding("_");
825    quote! {
826        /// Whether this model supports provider-side prompt caching
827        pub fn supports_prompt_caching(&self) -> bool {
828            match self {
829                #(#catalog_arms)*
830                #dyn_pats => false,
831            }
832        }
833    }
834}
835
836fn emit_llm_supports_modality(modality: &str) -> TokenStream {
837    let method = format_ident!("supports_{}", modality);
838    let doc = format!(" Whether this model supports {modality} input");
839    let catalog_arms = PROVIDERS.iter().map(|cfg| {
840        let v = format_ident!("{}", cfg.enum_name);
841        quote! { Self::#v(m) => m.#method(), }
842    });
843    let dyn_pats = dynamic_pattern_with_binding("_");
844    quote! {
845        #[doc = #doc]
846        pub fn #method(&self) -> bool {
847            match self {
848                #(#catalog_arms)*
849                #dyn_pats => false,
850            }
851        }
852    }
853}
854
855fn emit_llm_all() -> TokenStream {
856    let pushes = PROVIDERS.iter().map(|cfg| {
857        let inner = format_ident!("{}", cfg.inner_enum_name());
858        let outer = format_ident!("{}", cfg.outer_enum_name());
859        let v = format_ident!("{}", cfg.enum_name);
860        if cfg.is_hybrid_dynamic {
861            quote! {
862                v.extend(#inner::ALL.iter().copied().map(#outer::Foundation).map(LlmModel::#v));
863            }
864        } else {
865            quote! {
866                v.extend(#inner::ALL.iter().copied().map(LlmModel::#v));
867            }
868        }
869    });
870    quote! {
871        /// All catalog models (excludes dynamic providers)
872        pub fn all() -> &'static [LlmModel] {
873            static ALL: LazyLock<Vec<LlmModel>> = LazyLock::new(|| {
874                let mut v = Vec::new();
875                #(#pushes)*
876                v
877            });
878            &ALL
879        }
880    }
881}
882
883fn emit_display_impl() -> TokenStream {
884    quote! {
885        impl std::fmt::Display for LlmModel {
886            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
887                write!(f, "{}:{}", self.provider(), self.model_id())
888            }
889        }
890    }
891}
892
893fn emit_fromstr_impl() -> TokenStream {
894    let catalog_arms = PROVIDERS.iter().map(|cfg| {
895        let name = cfg.parser_name;
896        let outer = format_ident!("{}Model", cfg.enum_name);
897        let v = format_ident!("{}", cfg.enum_name);
898        quote! { #name => model_str.parse::<#outer>().map(Self::#v), }
899    });
900    let dyn_arms = DYNAMIC_PROVIDERS.iter().map(|d| {
901        let name = d.parser_name;
902        let v = format_ident!("{}", d.enum_name);
903        quote! { #name => Ok(Self::#v(model_str.to_string())), }
904    });
905    quote! {
906        impl std::str::FromStr for LlmModel {
907            type Err = String;
908
909            /// Parse a `provider:model` string into an `LlmModel`
910            fn from_str(s: &str) -> Result<Self, Self::Err> {
911                let (provider_str, model_str) = s.split_once(':').unwrap_or((s, ""));
912                match provider_str {
913                    #(#catalog_arms)*
914                    #(#dyn_arms)*
915                    _ => Err(format!("Unknown provider: '{provider_str}'")),
916                }
917            }
918        }
919    }
920}
921
922/// Build a `Self::Ollama(b) | Self::LlamaCpp(b)` pattern for all dynamic providers.
923fn dynamic_pattern_with_binding(binding: &str) -> TokenStream {
924    let binding_ident = if binding == "_" {
925        quote! { _ }
926    } else {
927        let b = format_ident!("{}", binding);
928        quote! { #b }
929    };
930    let pats = DYNAMIC_PROVIDERS.iter().map(|d| {
931        let v = format_ident!("{}", d.enum_name);
932        quote! { Self::#v(#binding_ident) }
933    });
934    quote! { #(#pats)|* }
935}
936
937/// Emit a `u32` literal with underscore separators (e.g. `200_000`).
938fn num_lit_with_underscores(n: u32) -> TokenStream {
939    format_number(n).parse().expect("formatted number parses as a token")
940}
941
942/// Format a number with underscore separators (e.g. `200000` → `200_000`).
943fn format_number(n: u32) -> String {
944    let s = n.to_string();
945    if s.len() <= 4 {
946        return s;
947    }
948    let mut result = String::with_capacity(s.len() + s.len() / 3);
949    for (i, ch) in s.chars().enumerate() {
950        if i > 0 && (s.len() - i).is_multiple_of(3) {
951            result.push('_');
952        }
953        result.push(ch);
954    }
955    result
956}
957
958fn emit_provider_docs(ctx: &CodegenCtx) -> HashMap<String, String> {
959    let mut docs = HashMap::new();
960
961    for cfg in PROVIDERS {
962        let models = &ctx.provider_models[cfg.dev_id];
963        let mut doc = String::new();
964
965        pushln(&mut doc, format!("`{}` LLM provider.", cfg.display_name));
966        blank(&mut doc);
967
968        pushln(&mut doc, "# Authentication");
969        blank(&mut doc);
970        match cfg.env_var {
971            Some(var) => pushln(&mut doc, format!("Set the `{var}` environment variable.")),
972            None if cfg.oauth_provider_id.is_some() => {
973                pushln(&mut doc, "This provider uses OAuth authentication.");
974            }
975            None => {
976                pushln(
977                    &mut doc,
978                    "Uses the default AWS credential chain (environment variables, config files, IAM roles).",
979                );
980            }
981        }
982        blank(&mut doc);
983
984        pushln(&mut doc, "# Supported models");
985        blank(&mut doc);
986        pushln(&mut doc, "| Model ID | Name | Context | Reasoning | Image | Audio |");
987        pushln(&mut doc, "|----------|------|---------|-----------|-------|-------|");
988        for model in models {
989            let ctx_str = format_context_window(model.context_window);
990            let reasoning = if model.reasoning_levels.is_empty() { "" } else { "yes" };
991            let image = if model.input_modalities.contains(&"image".to_string()) { "yes" } else { "" };
992            let audio = if model.input_modalities.contains(&"audio".to_string()) { "yes" } else { "" };
993            pushln(
994                &mut doc,
995                format!(
996                    "| `{}` | `{}` | `{}` | {} | {} | {} |",
997                    model.model_id, model.display_name, ctx_str, reasoning, image, audio
998                ),
999            );
1000        }
1001
1002        docs.insert(cfg.dev_id.to_string(), doc);
1003    }
1004
1005    for dyn_cfg in DYNAMIC_PROVIDERS {
1006        let mut doc = String::new();
1007        pushln(&mut doc, format!("`{}` LLM provider.", dyn_cfg.display_name));
1008        blank(&mut doc);
1009        pushln(
1010            &mut doc,
1011            format!("This provider accepts any model name at runtime (e.g. `{}:my-model`).", dyn_cfg.parser_name),
1012        );
1013        pushln(&mut doc, "No API key is required.");
1014        docs.insert(dyn_cfg.parser_name.to_string(), doc);
1015    }
1016
1017    docs
1018}
1019
1020/// Format a token count as human-readable (e.g. `1_000_000` → `1M`, `200_000` → `200k`).
1021fn format_context_window(tokens: u32) -> String {
1022    if tokens == 0 {
1023        return "unknown".to_string();
1024    }
1025    if tokens >= 1_000_000 && tokens.is_multiple_of(1_000_000) {
1026        format!("{}M", tokens / 1_000_000)
1027    } else if tokens >= 1_000 && tokens.is_multiple_of(1_000) {
1028        format!("{}k", tokens / 1_000)
1029    } else {
1030        format_number(tokens)
1031    }
1032}
1033
1034fn pushln(out: &mut String, line: impl AsRef<str>) {
1035    writeln!(out, "{}", line.as_ref()).expect("writing to String should not fail");
1036}
1037
1038fn blank(out: &mut String) {
1039    pushln(out, "");
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044    use super::*;
1045    use serde_json::Value;
1046    use serde_json::json;
1047    use tempfile::NamedTempFile;
1048
1049    // ── Helper unit tests ────────────────────────────────────────────────────
1050
1051    #[test]
1052    fn model_id_to_variant_pascal_cases_segments() {
1053        assert_eq!(model_id_to_variant("claude-sonnet-4-5-20250929"), "ClaudeSonnet4520250929");
1054        assert_eq!(model_id_to_variant("gemini-2.5-flash"), "Gemini25Flash");
1055        assert_eq!(model_id_to_variant("deepseek-chat"), "DeepseekChat");
1056        assert_eq!(model_id_to_variant("glm-4.5"), "Glm45");
1057    }
1058
1059    #[test]
1060    fn model_id_to_variant_handles_slash_and_colon() {
1061        assert_eq!(model_id_to_variant("anthropic/claude-opus-4.6"), "AnthropicClaudeOpus46");
1062        assert_eq!(model_id_to_variant("openai/gpt-5.1-codex-max"), "OpenaiGpt51CodexMax");
1063        assert_eq!(model_id_to_variant("deepseek/deepseek-r1:free"), "DeepseekDeepseekR1Free");
1064    }
1065
1066    #[test]
1067    fn is_alias_detects_latest_suffix() {
1068        assert!(is_alias("claude-sonnet-4-5-latest"));
1069        assert!(is_alias("claude-3-7-sonnet-latest"));
1070        assert!(!is_alias("claude-sonnet-4-5-20250929"));
1071    }
1072
1073    #[test]
1074    fn codex_subscription_context_window_overrides_known_codex_models() {
1075        for model_id in ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex", "gpt-5.2", "codex-auto-review"] {
1076            assert_eq!(codex_subscription_context_window(model_id, 1_050_000), 272_000);
1077        }
1078    }
1079
1080    #[test]
1081    fn codex_subscription_context_window_leaves_unknown_models_unchanged() {
1082        assert_eq!(codex_subscription_context_window("gpt-5.3-codex-spark", 128_000), 128_000);
1083        assert_eq!(codex_subscription_context_window("some-future-model", 400_000), 400_000);
1084    }
1085
1086    #[test]
1087    fn format_context_window_formats_correctly() {
1088        assert_eq!(format_context_window(1_000_000), "1M");
1089        assert_eq!(format_context_window(200_000), "200k");
1090        assert_eq!(format_context_window(8_000), "8k");
1091        assert_eq!(format_context_window(0), "unknown");
1092    }
1093
1094    #[test]
1095    fn level_str_to_variant_covers_all_reasoning_efforts() {
1096        for effort in utils::ReasoningEffort::all() {
1097            let _ = level_str_to_variant(effort.as_str());
1098        }
1099    }
1100
1101    #[test]
1102    fn build_sorts_models_and_filters_aliases_and_non_tool_call() {
1103        let mut data = minimal_models_dev_json();
1104        anthropic_models(
1105            &mut data,
1106            json!({
1107                "b-model": {"id": "b-model", "name": "B Model", "tool_call": true, "limit": {"context": 2000, "output": 0}},
1108                "a-model": {"id": "a-model", "name": "A Model", "tool_call": true, "limit": {"context": 1000, "output": 0}},
1109                "alpha-latest": {"id": "alpha-latest", "name": "Alias", "tool_call": true, "limit": {"context": 500, "output": 0}},
1110                "no-tools": {"id": "no-tools", "name": "No Tools", "tool_call": false, "limit": {"context": 500, "output": 0}}
1111            }),
1112        );
1113
1114        let models = build_from_value(&data);
1115        let ids: Vec<&str> = models["anthropic"].iter().map(|m| m.model_id.as_str()).collect();
1116        assert_eq!(ids, vec!["a-model", "b-model"]);
1117    }
1118
1119    #[test]
1120    fn build_extra_source_ids_merges_unique_models_into_provider() {
1121        let mut data = minimal_models_dev_json();
1122        zai_extra_models(
1123            &mut data,
1124            json!({
1125                "extra-model": {"id": "extra-model", "name": "Extra Model", "tool_call": true, "limit": {"context": 4000, "output": 0}}
1126            }),
1127        );
1128
1129        let models = build_from_value(&data);
1130        assert!(models["zai"].iter().any(|m| m.model_id == "extra-model"));
1131    }
1132
1133    #[test]
1134    fn build_extra_source_ids_does_not_duplicate_existing_models() {
1135        let mut data = minimal_models_dev_json();
1136        let shared = json!({
1137            "shared-model": {"id": "shared-model", "name": "Shared Model", "tool_call": true, "limit": {"context": 1000, "output": 0}}
1138        });
1139        insert_models(&mut data, "zai", shared.clone());
1140        insert_models(&mut data, "zai-coding-plan", shared);
1141
1142        let models = build_from_value(&data);
1143        let count = models["zai"].iter().filter(|m| m.model_id == "shared-model").count();
1144        assert_eq!(count, 1);
1145    }
1146
1147    #[test]
1148    fn build_derives_prompt_caching_from_cost_fields() {
1149        let mut data = minimal_models_dev_json();
1150        insert_models(
1151            &mut data,
1152            "amazon-bedrock",
1153            json!({
1154                "cached": {
1155                    "id": "cached", "name": "Cached", "tool_call": true,
1156                    "limit": {"context": 200_000, "output": 0},
1157                    "cost": {"input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_write": 3.75}
1158                },
1159                "uncached": {
1160                    "id": "uncached", "name": "Uncached", "tool_call": true,
1161                    "limit": {"context": 200_000, "output": 0},
1162                    "cost": {"input": 3.0, "output": 15.0}
1163                }
1164            }),
1165        );
1166
1167        let models = build_from_value(&data);
1168        let bedrock = &models["amazon-bedrock"];
1169        let cached = bedrock.iter().find(|m| m.model_id == "cached").unwrap();
1170        let uncached = bedrock.iter().find(|m| m.model_id == "uncached").unwrap();
1171        assert!(cached.supports_prompt_caching);
1172        assert!(!uncached.supports_prompt_caching);
1173    }
1174
1175    #[test]
1176    fn build_assigns_codex_four_reasoning_levels() {
1177        let mut data = minimal_models_dev_json();
1178        insert_models(
1179            &mut data,
1180            "openai",
1181            json!({
1182                "gpt-5.4-codex": {
1183                    "id": "gpt-5.4-codex", "name": "GPT-5.4 Codex", "tool_call": true, "reasoning": true,
1184                    "limit": {"context": 200_000, "output": 0}
1185                }
1186            }),
1187        );
1188
1189        let models = build_from_value(&data);
1190        let codex_model = models["codex"].iter().find(|m| m.model_id == "gpt-5.4-codex").unwrap();
1191        assert_eq!(codex_model.reasoning_levels, vec!["low", "medium", "high", "xhigh"]);
1192    }
1193
1194    #[test]
1195    fn build_applies_codex_subscription_context_window_override() {
1196        let mut data = minimal_models_dev_json();
1197        insert_models(
1198            &mut data,
1199            "openai",
1200            json!({
1201                "gpt-5.5": {
1202                    "id": "gpt-5.5", "name": "GPT-5.5", "tool_call": true, "reasoning": true,
1203                    "limit": {"context": 1_050_000, "output": 128_000}
1204                }
1205            }),
1206        );
1207
1208        let models = build_from_value(&data);
1209        let codex = models["codex"].iter().find(|m| m.model_id == "gpt-5.5").unwrap();
1210        let openai = models["openai"].iter().find(|m| m.model_id == "gpt-5.5").unwrap();
1211        assert_eq!(codex.context_window, 272_000);
1212        assert_eq!(openai.context_window, 1_050_000);
1213    }
1214
1215    // ── Markdown docs ────────────────────────────────────────────────────────
1216
1217    #[test]
1218    fn generate_emits_provider_docs() {
1219        let mut data = minimal_models_dev_json();
1220        anthropic_models(
1221            &mut data,
1222            json!({
1223                "claude-test": {
1224                    "id": "claude-test", "name": "Claude Test", "tool_call": true, "reasoning": true,
1225                    "limit": {"context": 200_000, "output": 0},
1226                    "modalities": {"input": ["text", "image"]}
1227                }
1228            }),
1229        );
1230
1231        let tmp = NamedTempFile::new().unwrap();
1232        std::fs::write(tmp.path(), serde_json::to_string(&data).unwrap()).unwrap();
1233        let output = generate(tmp.path()).unwrap();
1234
1235        let anthropic_doc = &output.provider_docs["anthropic"];
1236        assert!(anthropic_doc.contains("`Anthropic` LLM provider."));
1237        assert!(anthropic_doc.contains("`ANTHROPIC_API_KEY`"));
1238        assert!(anthropic_doc.contains("| `claude-test` | `Claude Test` | `200k` | yes | yes |  |"));
1239
1240        let ollama_doc = &output.provider_docs["ollama"];
1241        assert!(ollama_doc.contains("`Ollama` LLM provider."));
1242        assert!(ollama_doc.contains("any model name at runtime"));
1243    }
1244
1245    fn build_from_value(data: &Value) -> ProviderModels {
1246        let parsed: ModelsDevData = serde_json::from_value(data.clone()).expect("parse fixture");
1247        build_provider_models(&parsed).expect("build provider models")
1248    }
1249
1250    fn anthropic_models(data: &mut Value, models: Value) {
1251        insert_models(data, "anthropic", models);
1252    }
1253
1254    fn zai_extra_models(data: &mut Value, models: Value) {
1255        insert_models(data, "zai-coding-plan", models);
1256    }
1257
1258    fn insert_models(data: &mut Value, provider_key: &str, models: Value) {
1259        let provider = data.as_object_mut().unwrap().get_mut(provider_key).unwrap().as_object_mut().unwrap();
1260        provider.insert("models".to_string(), models);
1261    }
1262
1263    fn minimal_models_dev_json() -> Value {
1264        let mut root = serde_json::Map::new();
1265        for cfg in PROVIDERS {
1266            let json_key = cfg.json_key();
1267            root.entry(json_key.to_string())
1268                .or_insert_with(|| json!({"id": json_key, "name": json_key, "env": [], "models": {}}));
1269            for &extra in cfg.extra_source_ids {
1270                root.entry(extra.to_string())
1271                    .or_insert_with(|| json!({"id": extra, "name": extra, "env": [], "models": {}}));
1272            }
1273        }
1274        Value::Object(root)
1275    }
1276}