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