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