Skip to main content

llm_codegen/
lib.rs

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