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