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