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