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