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}
43
44#[derive(Debug, Deserialize)]
45#[serde(tag = "type", rename_all = "snake_case")]
46enum ReasoningOption {
47 Effort { values: Vec<Option<String>> },
48 Toggle,
49 BudgetTokens,
50}
51
52#[derive(Debug, Deserialize, Default)]
53struct ModalitiesData {
54 #[serde(default)]
55 input: Vec<String>,
56}
57
58#[derive(Debug, Deserialize)]
59#[allow(dead_code)]
60struct CostData {
61 #[serde(default)]
62 input: f64,
63 #[serde(default)]
64 output: f64,
65 #[serde(default)]
66 cache_read: Option<f64>,
67 #[serde(default)]
68 cache_write: Option<f64>,
69}
70
71#[derive(Debug, Deserialize)]
72struct LimitData {
73 #[serde(default)]
74 context: u32,
75 #[serde(default)]
76 #[allow(dead_code)]
77 output: u32,
78}
79
80impl CostData {
81 fn has_prompt_caching(&self) -> bool {
82 self.cache_read.is_some() || self.cache_write.is_some()
83 }
84}
85
86struct ProviderConfig {
88 dev_id: &'static str,
90 source_dev_id: Option<&'static str>,
92 extra_source_ids: &'static [&'static str],
94 explicit_models: Option<&'static [ExplicitModel]>,
99 enum_name: &'static str,
101 parser_name: &'static str,
103 display_name: &'static str,
105 env_var: Option<&'static str>,
107 oauth_provider_id: Option<&'static str>,
109 fallback_reasoning_levels: &'static [&'static str],
111 is_hybrid_dynamic: bool,
117}
118
119struct ExplicitModel {
121 id: &'static str,
122 context_window: u32,
123}
124
125impl ProviderConfig {
126 const fn standard(
128 dev_id: &'static str,
129 enum_name: &'static str,
130 parser_name: &'static str,
131 display_name: &'static str,
132 env_var: Option<&'static str>,
133 ) -> Self {
134 Self {
135 dev_id,
136 source_dev_id: None,
137 extra_source_ids: &[],
138 explicit_models: None,
139 enum_name,
140 parser_name,
141 display_name,
142 env_var,
143 oauth_provider_id: None,
144 fallback_reasoning_levels: &["low", "medium", "high"],
145 is_hybrid_dynamic: false,
146 }
147 }
148
149 fn explicit_model(&self, model_id: &str) -> Option<&'static ExplicitModel> {
150 self.explicit_models.and_then(|models| models.iter().find(|model| model.id == model_id))
151 }
152
153 fn inner_enum_name(&self) -> String {
156 if self.is_hybrid_dynamic {
157 format!("{}FoundationModel", self.enum_name)
158 } else {
159 format!("{}Model", self.enum_name)
160 }
161 }
162
163 fn outer_enum_name(&self) -> String {
165 format!("{}Model", self.enum_name)
166 }
167
168 fn json_key(&self) -> &'static str {
170 self.source_dev_id.unwrap_or(self.dev_id)
171 }
172}
173
174#[allow(clippy::struct_field_names)]
176struct DynamicProviderConfig {
177 enum_name: &'static str,
179 parser_name: &'static str,
181 display_name: &'static str,
183}
184
185const PROVIDERS: &[ProviderConfig] = &[
186 ProviderConfig::standard("anthropic", "Anthropic", "anthropic", "Anthropic", Some("ANTHROPIC_API_KEY")),
187 ProviderConfig {
188 dev_id: "codex",
189 source_dev_id: Some("openai"),
190 extra_source_ids: &[],
191 explicit_models: Some(CODEX_SUBSCRIPTION_MODELS),
192 enum_name: "Codex",
193 parser_name: "codex",
194 display_name: "Codex",
195 env_var: None,
196 oauth_provider_id: Some("codex"),
197 fallback_reasoning_levels: &["low", "medium", "high", "xhigh"],
198 is_hybrid_dynamic: false,
199 },
200 ProviderConfig::standard("deepseek", "DeepSeek", "deepseek", "DeepSeek", Some("DEEPSEEK_API_KEY")),
201 ProviderConfig::standard("google", "Gemini", "gemini", "Gemini", Some("GEMINI_API_KEY")),
202 ProviderConfig::standard("moonshotai", "Moonshot", "moonshot", "Moonshot", Some("MOONSHOT_API_KEY")),
203 ProviderConfig::standard("openai", "Openai", "openai", "OpenAI", Some("OPENAI_API_KEY")),
204 ProviderConfig::standard("openrouter", "OpenRouter", "openrouter", "OpenRouter", Some("OPENROUTER_API_KEY")),
205 ProviderConfig {
206 extra_source_ids: &["zai-coding-plan"],
207 ..ProviderConfig::standard("zai", "ZAi", "zai", "ZAI", Some("ZAI_API_KEY"))
208 },
209 ProviderConfig {
210 is_hybrid_dynamic: true,
211 ..ProviderConfig::standard("amazon-bedrock", "Bedrock", "bedrock", "AWS Bedrock", None)
212 },
213];
214
215const DYNAMIC_PROVIDERS: &[DynamicProviderConfig] = &[
216 DynamicProviderConfig { enum_name: "Ollama", parser_name: "ollama", display_name: "Ollama" },
217 DynamicProviderConfig { enum_name: "LlamaCpp", parser_name: "llamacpp", display_name: "LlamaCpp" },
218];
219
220const CODEX_SUBSCRIPTION_CONTEXT_WINDOW: u32 = 272_000;
221
222const CODEX_SUBSCRIPTION_MODELS: &[ExplicitModel] = &[
223 ExplicitModel { id: "gpt-5.6-sol", context_window: 372_000 },
224 ExplicitModel { id: "gpt-5.6-terra", context_window: 372_000 },
225 ExplicitModel { id: "gpt-5.6-luna", context_window: 372_000 },
226 ExplicitModel { id: "gpt-5.5", context_window: CODEX_SUBSCRIPTION_CONTEXT_WINDOW },
227 ExplicitModel { id: "gpt-5.4", context_window: CODEX_SUBSCRIPTION_CONTEXT_WINDOW },
228 ExplicitModel { id: "gpt-5.4-mini", context_window: CODEX_SUBSCRIPTION_CONTEXT_WINDOW },
229 ExplicitModel { id: "gpt-5.2", context_window: CODEX_SUBSCRIPTION_CONTEXT_WINDOW },
230];
231
232#[derive(Debug, Clone)]
233struct ModelInfo {
234 variant_name: String,
235 model_id: String,
236 display_name: String,
237 context_window: u32,
238 reasoning_levels: Vec<String>,
239 input_modalities: Vec<String>,
240 supports_prompt_caching: bool,
241}
242
243type ProviderModels = BTreeMap<&'static str, Vec<ModelInfo>>;
244
245struct CodegenCtx {
246 provider_models: ProviderModels,
247}
248
249pub struct GeneratedOutput {
251 pub rust_source: String,
253 pub provider_docs: HashMap<String, String>,
258}
259
260#[derive(Debug, thiserror::Error)]
261pub enum CodegenError {
262 #[error("read: {0}")]
263 Read(#[from] std::io::Error),
264 #[error("parse: {0}")]
265 Parse(#[from] serde_json::Error),
266 #[error("Provider '{0}' not found in models.dev data")]
267 ProviderNotFound(String),
268 #[error("Configured model '{model_id}' was not found in provider '{provider_id}'")]
269 ConfiguredModelNotFound { provider_id: String, model_id: String },
270 #[error("Configured model '{model_id}' is duplicated for provider '{provider_id}'")]
271 DuplicateConfiguredModel { provider_id: String, model_id: String },
272 #[error("Configured model '{model_id}' is not tool-capable in provider '{provider_id}'")]
273 ConfiguredModelUnavailable { provider_id: String, model_id: String },
274 #[error("Model '{model_id}' declares unsupported reasoning effort '{effort}'")]
275 UnsupportedReasoningEffort { model_id: String, effort: String },
276}
277
278pub fn generate(models_json_path: &Path) -> Result<GeneratedOutput, CodegenError> {
280 let json_bytes = std::fs::read_to_string(models_json_path)?;
281 let data: ModelsDevData = serde_json::from_str(&json_bytes)?;
282
283 let provider_models = build_provider_models(&data)?;
284 let ctx = CodegenCtx { provider_models };
285 Ok(GeneratedOutput { rust_source: emit_generated_source(&ctx), provider_docs: emit_provider_docs(&ctx) })
286}
287
288fn build_provider_models(data: &ModelsDevData) -> Result<ProviderModels, CodegenError> {
289 let mut provider_models = ProviderModels::new();
290
291 for cfg in PROVIDERS {
292 let json_key = cfg.json_key();
293 let provider_data = data.get(json_key).ok_or_else(|| CodegenError::ProviderNotFound(json_key.to_string()))?;
294
295 validate_provider_config(cfg, provider_data)?;
296 let mut models: Vec<ModelInfo> = collect_models_from(cfg, &provider_data.models)?;
297
298 for &extra_key in cfg.extra_source_ids {
299 if let Some(extra_data) = data.get(extra_key) {
300 let extra = collect_models_from(cfg, &extra_data.models)?;
301 let existing_ids: std::collections::HashSet<String> =
302 models.iter().map(|m| m.model_id.clone()).collect();
303 models.extend(extra.into_iter().filter(|m| !existing_ids.contains(&m.model_id)));
304 }
305 }
306
307 models.sort_by(|a, b| a.model_id.cmp(&b.model_id));
308 provider_models.insert(cfg.dev_id, models);
309 }
310
311 Ok(provider_models)
312}
313
314fn validate_provider_config(cfg: &ProviderConfig, provider: &ProviderData) -> Result<(), CodegenError> {
315 let Some(explicit_models) = cfg.explicit_models else {
316 return Ok(());
317 };
318 let mut seen = HashSet::new();
319 for configured in explicit_models {
320 if !seen.insert(configured.id) {
321 return Err(CodegenError::DuplicateConfiguredModel {
322 provider_id: cfg.dev_id.to_string(),
323 model_id: configured.id.to_string(),
324 });
325 }
326 let Some(model) = provider.models.get(configured.id) else {
327 return Err(CodegenError::ConfiguredModelNotFound {
328 provider_id: cfg.dev_id.to_string(),
329 model_id: configured.id.to_string(),
330 });
331 };
332 if model.tool_call != Some(true) {
333 return Err(CodegenError::ConfiguredModelUnavailable {
334 provider_id: cfg.dev_id.to_string(),
335 model_id: configured.id.to_string(),
336 });
337 }
338 }
339 Ok(())
340}
341
342fn collect_models_from(
343 cfg: &ProviderConfig,
344 models: &HashMap<String, ModelData>,
345) -> Result<Vec<ModelInfo>, CodegenError> {
346 models
347 .values()
348 .filter(|m| m.tool_call == Some(true))
349 .filter(|m| !is_alias(&m.id))
350 .filter(|m| cfg.explicit_models.is_none() || cfg.explicit_model(&m.id).is_some())
351 .map(|m| {
352 let reasoning_levels =
353 if m.reasoning.unwrap_or(false) { reasoning_levels_for_model(cfg, m)? } else { Vec::new() };
354 let input_modalities =
355 m.modalities.as_ref().map_or_else(|| vec!["text".to_string()], |md| md.input.clone());
356 let source_context_window = m.limit.as_ref().map_or(0, |l| l.context);
357 let context_window =
358 cfg.explicit_model(&m.id).map_or(source_context_window, |explicit| explicit.context_window);
359 Ok(ModelInfo {
360 variant_name: model_id_to_variant(&m.id),
361 model_id: m.id.clone(),
362 display_name: m.name.clone(),
363 context_window,
364 reasoning_levels,
365 input_modalities,
366 supports_prompt_caching: m.cost.as_ref().is_some_and(CostData::has_prompt_caching),
367 })
368 })
369 .collect()
370}
371
372fn reasoning_levels_for_model(cfg: &ProviderConfig, model: &ModelData) -> Result<Vec<String>, CodegenError> {
373 let Some(values) = model.reasoning_options.iter().find_map(|option| match option {
374 ReasoningOption::Effort { values } => Some(values),
375 ReasoningOption::Toggle | ReasoningOption::BudgetTokens => None,
376 }) else {
377 return Ok(cfg.fallback_reasoning_levels.iter().map(|level| (*level).to_string()).collect());
378 };
379
380 values
381 .iter()
382 .filter_map(|value| value.as_deref())
383 .filter(|value| !matches!(*value, "none" | "default"))
384 .map(|effort| {
385 effort.parse::<utils::ReasoningEffort>().map(|parsed| parsed.as_str().to_string()).map_err(|_| {
386 CodegenError::UnsupportedReasoningEffort { model_id: model.id.clone(), effort: effort.to_string() }
387 })
388 })
389 .collect()
390}
391
392fn is_alias(id: &str) -> bool {
394 id.ends_with("-latest")
395}
396
397fn model_id_to_variant(id: &str) -> String {
400 let mut result = String::new();
401 let mut capitalize_next = true;
402
403 for ch in id.chars() {
404 if ch == '-' || ch == '.' || ch == '/' || ch == ':' {
405 capitalize_next = true;
406 } else if capitalize_next {
407 result.push(ch.to_ascii_uppercase());
408 capitalize_next = false;
409 } else {
410 result.push(ch);
411 }
412 }
413
414 if result.starts_with(|c: char| c.is_ascii_digit()) {
415 result.insert(0, '_');
416 }
417
418 result
419}
420
421fn emit_generated_source(ctx: &CodegenCtx) -> String {
422 let provider_enum = emit_provider_enum();
423 let provider_enum_impl = emit_provider_enum_impl();
424 let provider_enum_display = emit_provider_enum_display();
425 let provider_enum_fromstr = emit_provider_enum_fromstr();
426 let provider_enums = emit_provider_enums(&ctx.provider_models);
427 let provider_impls = emit_provider_impls(&ctx.provider_models);
428 let llm_model_enum = emit_llm_model_enum();
429 let from_impls = emit_from_impls();
430 let llm_model_impl = emit_llm_model_impl();
431 let display_impl = emit_display_impl();
432 let fromstr_impl = emit_fromstr_impl();
433
434 let file_tokens = quote! {
435 use std::borrow::Cow;
436 use std::sync::LazyLock;
437 use crate::ReasoningEffort;
438
439 #provider_enum
440 #provider_enum_impl
441 #provider_enum_display
442 #provider_enum_fromstr
443 #provider_enums
444 #provider_impls
445 #llm_model_enum
446 #from_impls
447 #llm_model_impl
448 #display_impl
449 #fromstr_impl
450 };
451
452 let file: syn::File = syn::parse2(file_tokens).expect("generated tokens parse as Rust");
453 let formatted = prettyplease::unparse(&file);
454 format!(
455 "// Auto-generated from models.dev — do not edit manually\n// Regenerated automatically by build.rs\n\n{formatted}"
456 )
457}
458
459fn emit_provider_enum() -> TokenStream {
460 let catalog_variants = PROVIDERS.iter().map(|cfg| format_ident!("{}", cfg.enum_name));
461 let dynamic_variants = DYNAMIC_PROVIDERS.iter().map(|d| format_ident!("{}", d.enum_name));
462 quote! {
463 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
467 pub enum Provider {
468 #(#catalog_variants,)*
469 #(#dynamic_variants,)*
470 }
471 }
472}
473
474fn emit_provider_enum_impl() -> TokenStream {
475 let parser_arms = provider_match_arms(|cfg| cfg.parser_name, |d| d.parser_name);
476 let display_arms = provider_match_arms(|cfg| cfg.display_name, |d| d.display_name);
477
478 let env_var_some = PROVIDERS.iter().filter_map(|cfg| {
479 cfg.env_var.map(|var| {
480 let v = format_ident!("{}", cfg.enum_name);
481 quote! { Self::#v => Some(#var), }
482 })
483 });
484
485 let env_var_none = provider_or_pats(|cfg| cfg.env_var.is_none(), |_| true);
486 let oauth_some = PROVIDERS.iter().filter_map(|cfg| {
487 cfg.oauth_provider_id.map(|id| {
488 let v = format_ident!("{}", cfg.enum_name);
489 quote! { Self::#v => Some(#id), }
490 })
491 });
492 let oauth_none = provider_or_pats(|cfg| cfg.oauth_provider_id.is_none(), |_| true);
493
494 let is_local_true = provider_or_pats(|_| false, |_| true);
495 let is_local_false = provider_or_pats(|_| true, |_| false);
496
497 let all_variants = PROVIDERS
498 .iter()
499 .map(|cfg| format_ident!("{}", cfg.enum_name))
500 .chain(DYNAMIC_PROVIDERS.iter().map(|d| format_ident!("{}", d.enum_name)));
501
502 quote! {
503 impl Provider {
504 pub const ALL: &[Provider] = &[#(Self::#all_variants),*];
506
507 pub fn parser_name(self) -> &'static str {
509 match self { #parser_arms }
510 }
511
512 pub fn display_name(self) -> &'static str {
514 match self { #display_arms }
515 }
516
517 pub fn required_env_var(self) -> Option<&'static str> {
519 match self {
520 #(#env_var_some)*
521 #env_var_none => None,
522 }
523 }
524
525 pub fn oauth_provider_id(self) -> Option<&'static str> {
527 match self {
528 #(#oauth_some)*
529 #oauth_none => None,
530 }
531 }
532
533 pub fn is_local(self) -> bool {
536 match self {
537 #is_local_true => true,
538 #is_local_false => false,
539 }
540 }
541 }
542 }
543}
544
545fn emit_provider_enum_display() -> TokenStream {
546 quote! {
547 impl std::fmt::Display for Provider {
548 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
549 f.write_str(self.parser_name())
550 }
551 }
552 }
553}
554
555fn emit_provider_enum_fromstr() -> TokenStream {
556 let catalog_arms = PROVIDERS.iter().map(|cfg| {
557 let v = format_ident!("{}", cfg.enum_name);
558 let name = cfg.parser_name;
559 quote! { #name => Ok(Self::#v), }
560 });
561
562 let dynamic_arms = DYNAMIC_PROVIDERS.iter().map(|d| {
563 let v = format_ident!("{}", d.enum_name);
564 let name = d.parser_name;
565 quote! { #name => Ok(Self::#v), }
566 });
567
568 quote! {
569 impl std::str::FromStr for Provider {
570 type Err = String;
571 fn from_str(s: &str) -> Result<Self, Self::Err> {
572 match s {
573 #(#catalog_arms)*
574 #(#dynamic_arms)*
575 other => Err(format!("Unknown provider: '{other}'")),
576 }
577 }
578 }
579 }
580}
581
582fn emit_provider_enums(provider_models: &ProviderModels) -> TokenStream {
583 let enums = PROVIDERS.iter().map(|cfg| {
584 let inner = format_ident!("{}", cfg.inner_enum_name());
585 let variants = provider_models[cfg.dev_id].iter().map(|m| format_ident!("{}", m.variant_name));
586 quote! {
587 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
588 pub enum #inner {
589 #(#variants,)*
590 }
591 }
592 });
593 quote! { #(#enums)* }
594}
595
596fn emit_provider_impls(provider_models: &ProviderModels) -> TokenStream {
597 let impls = PROVIDERS.iter().map(|cfg| {
598 let models = &provider_models[cfg.dev_id];
599 let enum_ident = format_ident!("{}", cfg.inner_enum_name());
600
601 let model_id_arms = models.iter().map(|m| {
602 let v = format_ident!("{}", m.variant_name);
603 let id = &m.model_id;
604 quote! { Self::#v => #id, }
605 });
606
607 let display_name_arms = grouped_arms(
608 models,
609 |m| m.display_name.clone(),
610 |m| {
611 let s = &m.display_name;
612 quote! { #s }
613 },
614 );
615
616 let context_window_arms =
617 grouped_arms(models, |m| m.context_window, |m| num_lit_with_underscores(m.context_window));
618
619 let reasoning_levels_arms = emit_reasoning_levels_arms(models);
620
621 let prompt_caching_arms = grouped_arms(
622 models,
623 |m| m.supports_prompt_caching,
624 |m| {
625 let b = m.supports_prompt_caching;
626 quote! { #b }
627 },
628 );
629
630 let modality_methods = ["image", "audio"].iter().map(|modality| {
631 let method = format_ident!("supports_{}", modality);
632 let mod_owned = (*modality).to_string();
633 let arms = grouped_arms(models, move |m| m.input_modalities.contains(&mod_owned), {
634 let mod_owned = (*modality).to_string();
635 move |m| {
636 let b = m.input_modalities.contains(&mod_owned);
637 quote! { #b }
638 }
639 });
640 quote! {
641 #[allow(clippy::too_many_lines)]
642 pub fn #method(self) -> bool {
643 match self { #arms }
644 }
645 }
646 });
647
648 let all_variants = models.iter().map(|m| format_ident!("{}", m.variant_name));
649
650 let from_str_impl = emit_from_str_impl(&enum_ident, cfg.parser_name, models);
651
652 quote! {
653 impl #enum_ident {
654 #[allow(clippy::too_many_lines)]
655 fn model_id(self) -> &'static str {
656 match self { #(#model_id_arms)* }
657 }
658
659 #[allow(clippy::too_many_lines)]
660 fn display_name(self) -> &'static str {
661 match self { #display_name_arms }
662 }
663
664 #[allow(clippy::too_many_lines)]
665 fn context_window(self) -> u32 {
666 match self { #context_window_arms }
667 }
668
669 #[allow(clippy::too_many_lines)]
670 pub fn reasoning_levels(self) -> &'static [ReasoningEffort] {
671 match self { #reasoning_levels_arms }
672 }
673
674 pub fn supports_reasoning(self) -> bool {
675 !self.reasoning_levels().is_empty()
676 }
677
678 #[allow(clippy::too_many_lines)]
679 pub fn supports_prompt_caching(self) -> bool {
680 match self { #prompt_caching_arms }
681 }
682
683 #(#modality_methods)*
684
685 const ALL: &[#enum_ident] = &[#(Self::#all_variants),*];
686 }
687
688 #from_str_impl
689 }
690 });
691 quote! { #(#impls)* }
692}
693
694fn emit_from_str_impl(enum_ident: &proc_macro2::Ident, parser_name: &str, models: &[ModelInfo]) -> TokenStream {
695 let arms = models.iter().map(|m| {
696 let id = &m.model_id;
697 let v = format_ident!("{}", m.variant_name);
698 quote! { #id => Ok(Self::#v), }
699 });
700 let err_msg = format!("Unknown {parser_name} model: '{{s}}'");
701 quote! {
702 impl std::str::FromStr for #enum_ident {
703 type Err = String;
704
705 #[allow(clippy::too_many_lines)]
706 fn from_str(s: &str) -> Result<Self, Self::Err> {
707 match s {
708 #(#arms)*
709 _ => Err(format!(#err_msg)),
710 }
711 }
712 }
713 }
714}
715
716fn grouped_arms<K, R>(
718 models: &[ModelInfo],
719 key_fn: impl Fn(&ModelInfo) -> K,
720 rhs_fn: impl Fn(&ModelInfo) -> R,
721) -> TokenStream
722where
723 K: Eq + Ord,
724 R: ToTokens,
725{
726 let mut groups: BTreeMap<K, Vec<&ModelInfo>> = BTreeMap::new();
727 for m in models {
728 groups.entry(key_fn(m)).or_default().push(m);
729 }
730 let arms = groups.values().map(|members| {
731 let pats = members.iter().map(|m| {
732 let v = format_ident!("{}", m.variant_name);
733 quote! { Self::#v }
734 });
735 let rhs = rhs_fn(members[0]);
736 quote! { #(#pats)|* => #rhs, }
737 });
738 quote! { #(#arms)* }
739}
740
741fn emit_reasoning_levels_arms(models: &[ModelInfo]) -> TokenStream {
742 grouped_arms(
743 models,
744 |m| m.reasoning_levels.clone(),
745 |m| {
746 if m.reasoning_levels.is_empty() {
747 quote! { &[] }
748 } else {
749 let items = m.reasoning_levels.iter().map(|l| {
750 let variant = format_ident!("{}", level_str_to_variant(l));
751 quote! { ReasoningEffort::#variant }
752 });
753 quote! { &[#(#items),*] }
754 }
755 },
756 )
757}
758
759fn level_str_to_variant(level: &str) -> String {
762 let canonical =
763 level.parse::<utils::ReasoningEffort>().unwrap_or_else(|_| panic!("Unknown reasoning level: {level}")).as_str();
764 let mut variant = canonical.to_string();
765 variant[..1].make_ascii_uppercase();
766 variant
767}
768
769fn emit_llm_model_enum() -> TokenStream {
770 let catalog_variants = PROVIDERS.iter().map(|cfg| {
771 let v = format_ident!("{}", cfg.enum_name);
772 let inner = format_ident!("{}Model", cfg.enum_name);
773 quote! { #v(#inner) }
774 });
775 let dynamic_variants = DYNAMIC_PROVIDERS.iter().map(|d| {
776 let v = format_ident!("{}", d.enum_name);
777 quote! { #v(String) }
778 });
779 quote! {
780 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
782 pub enum LlmModel {
783 #(#catalog_variants,)*
784 #(#dynamic_variants,)*
785 }
786 }
787}
788
789fn emit_from_impls() -> TokenStream {
790 let impls = PROVIDERS.iter().map(|cfg| {
791 let outer = format_ident!("{}Model", cfg.enum_name);
792 let v = format_ident!("{}", cfg.enum_name);
793 quote! {
794 impl From<#outer> for LlmModel {
795 fn from(m: #outer) -> Self {
796 LlmModel::#v(m)
797 }
798 }
799 }
800 });
801 quote! { #(#impls)* }
802}
803
804fn emit_llm_model_impl() -> TokenStream {
805 let model_id = emit_llm_model_id();
806 let display_name = emit_llm_display_name();
807 let provider = emit_llm_provider();
808 let provider_enum = emit_llm_provider_enum();
809 let provider_display_name = emit_llm_provider_display_name();
810 let context_window = emit_llm_context_window();
811 let required_env_var = emit_llm_required_env_var();
812 let all_required_env_vars = emit_llm_all_required_env_vars();
813 let oauth_provider_id = emit_llm_oauth_provider_id();
814 let reasoning_levels = emit_llm_reasoning_levels();
815 let supports_reasoning = emit_llm_supports_reasoning();
816 let supports_prompt_caching = emit_llm_supports_prompt_caching();
817 let modality_methods = ["image", "audio"].iter().map(|m| emit_llm_supports_modality(m));
818 let all = emit_llm_all();
819
820 quote! {
821 impl LlmModel {
822 #model_id
823 #display_name
824 #provider
825 #provider_enum
826 #provider_display_name
827 #context_window
828 #required_env_var
829 #all_required_env_vars
830 #oauth_provider_id
831 #reasoning_levels
832 #supports_reasoning
833 #supports_prompt_caching
834 #(#modality_methods)*
835 #all
836 }
837 }
838}
839
840fn emit_llm_model_id() -> TokenStream {
841 let catalog_arms = PROVIDERS.iter().map(|cfg| {
842 let v = format_ident!("{}", cfg.enum_name);
843 if cfg.is_hybrid_dynamic {
844 quote! { Self::#v(m) => m.model_id(), }
845 } else {
846 quote! { Self::#v(m) => Cow::Borrowed(m.model_id()), }
847 }
848 });
849 let dyn_pats = dynamic_pattern_with_binding("s");
850 quote! {
851 pub fn model_id(&self) -> Cow<'static, str> {
853 match self {
854 #(#catalog_arms)*
855 #dyn_pats => Cow::Owned(s.clone()),
856 }
857 }
858 }
859}
860
861fn emit_llm_display_name() -> TokenStream {
862 let catalog_arms = PROVIDERS.iter().map(|cfg| {
863 let v = format_ident!("{}", cfg.enum_name);
864 if cfg.is_hybrid_dynamic {
865 quote! { Self::#v(m) => m.display_name(), }
866 } else {
867 quote! { Self::#v(m) => Cow::Borrowed(m.display_name()), }
868 }
869 });
870 let dyn_arms = DYNAMIC_PROVIDERS.iter().map(|d| {
871 let v = format_ident!("{}", d.enum_name);
872 let fmt = format!("{} {{s}}", d.enum_name);
873 quote! { Self::#v(s) => Cow::Owned(format!(#fmt)), }
874 });
875 quote! {
876 pub fn display_name(&self) -> Cow<'static, str> {
878 match self {
879 #(#catalog_arms)*
880 #(#dyn_arms)*
881 }
882 }
883 }
884}
885
886fn emit_llm_provider() -> TokenStream {
887 let arms = llm_match_arms_ignored(|cfg| cfg.parser_name, |d| d.parser_name);
888 quote! {
889 pub fn provider(&self) -> &'static str {
891 match self { #arms }
892 }
893 }
894}
895
896fn emit_llm_provider_enum() -> TokenStream {
897 let arms = llm_match_arms_ignored(
898 |cfg| {
899 let v = format_ident!("{}", cfg.enum_name);
900 quote! { Provider::#v }
901 },
902 |d| {
903 let v = format_ident!("{}", d.enum_name);
904 quote! { Provider::#v }
905 },
906 );
907 quote! {
908 pub fn provider_enum(&self) -> Provider {
910 match self { #arms }
911 }
912 }
913}
914
915fn emit_llm_provider_display_name() -> TokenStream {
916 let arms = llm_match_arms_ignored(|cfg| cfg.display_name, |d| d.display_name);
917 quote! {
918 pub fn provider_display_name(&self) -> &'static str {
920 match self { #arms }
921 }
922 }
923}
924
925fn emit_llm_context_window() -> TokenStream {
926 let catalog_arms = PROVIDERS.iter().map(|cfg| {
927 let v = format_ident!("{}", cfg.enum_name);
928 if cfg.is_hybrid_dynamic {
929 quote! { Self::#v(m) => m.context_window(), }
930 } else {
931 quote! { Self::#v(m) => Some(m.context_window()), }
932 }
933 });
934 let dyn_pats = dynamic_pattern_with_binding("_");
935 quote! {
936 pub fn context_window(&self) -> Option<u32> {
938 match self {
939 #(#catalog_arms)*
940 #dyn_pats => None,
941 }
942 }
943 }
944}
945
946fn emit_llm_required_env_var() -> TokenStream {
947 let some_arms = PROVIDERS.iter().filter_map(|cfg| {
948 cfg.env_var.map(|var| {
949 let v = format_ident!("{}", cfg.enum_name);
950 quote! { Self::#v(_) => Some(#var), }
951 })
952 });
953 let none_pats = llm_or_pats(|cfg| cfg.env_var.is_none(), |_| true);
954 quote! {
955 pub fn required_env_var(&self) -> Option<&'static str> {
957 match self {
958 #(#some_arms)*
959 #none_pats => None,
960 }
961 }
962 }
963}
964
965fn emit_llm_all_required_env_vars() -> TokenStream {
966 let vars = PROVIDERS.iter().filter_map(|cfg| cfg.env_var);
967 quote! {
968 pub const ALL_REQUIRED_ENV_VARS: &[&str] = &[#(#vars),*];
970 }
971}
972
973fn emit_llm_oauth_provider_id() -> TokenStream {
974 let some_arms = PROVIDERS.iter().filter_map(|cfg| {
975 cfg.oauth_provider_id.map(|id| {
976 let v = format_ident!("{}", cfg.enum_name);
977 quote! { Self::#v(_) => Some(#id), }
978 })
979 });
980 let none_pats = llm_or_pats(|cfg| cfg.oauth_provider_id.is_none(), |_| true);
981 quote! {
982 pub fn oauth_provider_id(&self) -> Option<&'static str> {
984 match self {
985 #(#some_arms)*
986 #none_pats => None,
987 }
988 }
989 }
990}
991
992fn emit_llm_reasoning_levels() -> TokenStream {
993 let body = llm_delegate_with_dynamic_default("reasoning_levels", "e! { &[] });
994 quote! {
995 pub fn reasoning_levels(&self) -> &'static [ReasoningEffort] {
997 #body
998 }
999 }
1000}
1001
1002fn emit_llm_supports_reasoning() -> TokenStream {
1003 quote! {
1004 pub fn supports_reasoning(&self) -> bool {
1006 !self.reasoning_levels().is_empty()
1007 }
1008 }
1009}
1010
1011fn emit_llm_supports_prompt_caching() -> TokenStream {
1012 let body = llm_delegate_with_dynamic_default("supports_prompt_caching", "e! { false });
1013 quote! {
1014 pub fn supports_prompt_caching(&self) -> bool {
1016 #body
1017 }
1018 }
1019}
1020
1021fn emit_llm_supports_modality(modality: &str) -> TokenStream {
1022 let method = format!("supports_{modality}");
1023 let method_ident = format_ident!("{}", method);
1024 let doc = format!(" Whether this model supports {modality} input");
1025 let body = llm_delegate_with_dynamic_default(&method, "e! { false });
1026 quote! {
1027 #[doc = #doc]
1028 pub fn #method_ident(&self) -> bool {
1029 #body
1030 }
1031 }
1032}
1033
1034fn emit_llm_all() -> TokenStream {
1035 let pushes = PROVIDERS.iter().map(|cfg| {
1036 let inner = format_ident!("{}", cfg.inner_enum_name());
1037 let outer = format_ident!("{}", cfg.outer_enum_name());
1038 let v = format_ident!("{}", cfg.enum_name);
1039 if cfg.is_hybrid_dynamic {
1040 quote! {
1041 v.extend(#inner::ALL.iter().copied().map(#outer::Foundation).map(LlmModel::#v));
1042 }
1043 } else {
1044 quote! {
1045 v.extend(#inner::ALL.iter().copied().map(LlmModel::#v));
1046 }
1047 }
1048 });
1049 quote! {
1050 pub fn all() -> &'static [LlmModel] {
1052 static ALL: LazyLock<Vec<LlmModel>> = LazyLock::new(|| {
1053 let mut v = Vec::new();
1054 #(#pushes)*
1055 v
1056 });
1057 &ALL
1058 }
1059 }
1060}
1061
1062fn emit_display_impl() -> TokenStream {
1063 quote! {
1064 impl std::fmt::Display for LlmModel {
1065 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1066 write!(f, "{}:{}", self.provider(), self.model_id())
1067 }
1068 }
1069 }
1070}
1071
1072fn emit_fromstr_impl() -> TokenStream {
1073 let catalog_arms = PROVIDERS.iter().map(|cfg| {
1074 let name = cfg.parser_name;
1075 let outer = format_ident!("{}Model", cfg.enum_name);
1076 let v = format_ident!("{}", cfg.enum_name);
1077 quote! { #name => model_str.parse::<#outer>().map(Self::#v), }
1078 });
1079 let dyn_arms = DYNAMIC_PROVIDERS.iter().map(|d| {
1080 let name = d.parser_name;
1081 let v = format_ident!("{}", d.enum_name);
1082 quote! { #name => Ok(Self::#v(model_str.to_string())), }
1083 });
1084 quote! {
1085 impl std::str::FromStr for LlmModel {
1086 type Err = String;
1087
1088 fn from_str(s: &str) -> Result<Self, Self::Err> {
1090 let (provider_str, model_str) = s.split_once(':').unwrap_or((s, ""));
1091 match provider_str {
1092 #(#catalog_arms)*
1093 #(#dyn_arms)*
1094 _ => Err(format!("Unknown provider: '{provider_str}'")),
1095 }
1096 }
1097 }
1098 }
1099}
1100
1101fn dynamic_pattern_with_binding(binding: &str) -> TokenStream {
1103 let binding_ident = if binding == "_" {
1104 quote! { _ }
1105 } else {
1106 let b = format_ident!("{}", binding);
1107 quote! { #b }
1108 };
1109 let pats = DYNAMIC_PROVIDERS.iter().map(|d| {
1110 let v = format_ident!("{}", d.enum_name);
1111 quote! { Self::#v(#binding_ident) }
1112 });
1113 quote! { #(#pats)|* }
1114}
1115
1116fn provider_match_arms<V: ToTokens>(
1120 catalog_value: impl Fn(&ProviderConfig) -> V,
1121 dynamic_value: impl Fn(&DynamicProviderConfig) -> V,
1122) -> TokenStream {
1123 let catalog = PROVIDERS.iter().map(|cfg| {
1124 let v = format_ident!("{}", cfg.enum_name);
1125 let val = catalog_value(cfg);
1126 quote! { Self::#v => #val, }
1127 });
1128 let dynamic = DYNAMIC_PROVIDERS.iter().map(|d| {
1129 let v = format_ident!("{}", d.enum_name);
1130 let val = dynamic_value(d);
1131 quote! { Self::#v => #val, }
1132 });
1133 quote! { #(#catalog)* #(#dynamic)* }
1134}
1135
1136fn provider_or_pats(
1139 include_catalog: impl Fn(&ProviderConfig) -> bool,
1140 include_dynamic: impl Fn(&DynamicProviderConfig) -> bool,
1141) -> TokenStream {
1142 let catalog = PROVIDERS.iter().filter(|cfg| include_catalog(cfg)).map(|cfg| {
1143 let v = format_ident!("{}", cfg.enum_name);
1144 quote! { Self::#v }
1145 });
1146 let dynamic = DYNAMIC_PROVIDERS.iter().filter(|d| include_dynamic(d)).map(|d| {
1147 let v = format_ident!("{}", d.enum_name);
1148 quote! { Self::#v }
1149 });
1150 let pats = catalog.chain(dynamic);
1151 quote! { #(#pats)|* }
1152}
1153
1154fn llm_match_arms_ignored<V: ToTokens>(
1157 catalog_value: impl Fn(&ProviderConfig) -> V,
1158 dynamic_value: impl Fn(&DynamicProviderConfig) -> V,
1159) -> TokenStream {
1160 let catalog = PROVIDERS.iter().map(|cfg| {
1161 let v = format_ident!("{}", cfg.enum_name);
1162 let val = catalog_value(cfg);
1163 quote! { Self::#v(_) => #val, }
1164 });
1165 let dynamic = DYNAMIC_PROVIDERS.iter().map(|d| {
1166 let v = format_ident!("{}", d.enum_name);
1167 let val = dynamic_value(d);
1168 quote! { Self::#v(_) => #val, }
1169 });
1170 quote! { #(#catalog)* #(#dynamic)* }
1171}
1172
1173fn llm_or_pats(
1176 include_catalog: impl Fn(&ProviderConfig) -> bool,
1177 include_dynamic: impl Fn(&DynamicProviderConfig) -> bool,
1178) -> TokenStream {
1179 let catalog = PROVIDERS.iter().filter(|cfg| include_catalog(cfg)).map(|cfg| {
1180 let v = format_ident!("{}", cfg.enum_name);
1181 quote! { Self::#v(_) }
1182 });
1183 let dynamic = DYNAMIC_PROVIDERS.iter().filter(|d| include_dynamic(d)).map(|d| {
1184 let v = format_ident!("{}", d.enum_name);
1185 quote! { Self::#v(_) }
1186 });
1187 let pats = catalog.chain(dynamic);
1188 quote! { #(#pats)|* }
1189}
1190
1191fn llm_delegate_with_dynamic_default(method: &str, dynamic_value: &TokenStream) -> TokenStream {
1195 let method_ident = format_ident!("{}", method);
1196 let catalog_arms = PROVIDERS.iter().map(|cfg| {
1197 let v = format_ident!("{}", cfg.enum_name);
1198 quote! { Self::#v(m) => m.#method_ident(), }
1199 });
1200 let dyn_pat = dynamic_pattern_with_binding("_");
1201 quote! {
1202 match self {
1203 #(#catalog_arms)*
1204 #dyn_pat => #dynamic_value,
1205 }
1206 }
1207}
1208
1209fn num_lit_with_underscores(n: u32) -> TokenStream {
1211 format_number(n).parse().expect("formatted number parses as a token")
1212}
1213
1214fn format_number(n: u32) -> String {
1216 let s = n.to_string();
1217 if s.len() <= 4 {
1218 return s;
1219 }
1220 let mut result = String::with_capacity(s.len() + s.len() / 3);
1221 for (i, ch) in s.chars().enumerate() {
1222 if i > 0 && (s.len() - i).is_multiple_of(3) {
1223 result.push('_');
1224 }
1225 result.push(ch);
1226 }
1227 result
1228}
1229
1230fn emit_provider_docs(ctx: &CodegenCtx) -> HashMap<String, String> {
1231 let mut docs = HashMap::new();
1232
1233 for cfg in PROVIDERS {
1234 let models = &ctx.provider_models[cfg.dev_id];
1235 let mut doc = String::new();
1236
1237 pushln(&mut doc, format!("`{}` LLM provider.", cfg.display_name));
1238 blank(&mut doc);
1239
1240 pushln(&mut doc, "# Authentication");
1241 blank(&mut doc);
1242 match cfg.env_var {
1243 Some(var) => pushln(&mut doc, format!("Set the `{var}` environment variable.")),
1244 None if cfg.oauth_provider_id.is_some() => {
1245 pushln(&mut doc, "This provider uses OAuth authentication.");
1246 }
1247 None => {
1248 pushln(
1249 &mut doc,
1250 "Uses the default AWS credential chain (environment variables, config files, IAM roles).",
1251 );
1252 }
1253 }
1254 blank(&mut doc);
1255
1256 pushln(&mut doc, "# Supported models");
1257 blank(&mut doc);
1258 pushln(&mut doc, "| Model ID | Name | Context | Reasoning | Image | Audio |");
1259 pushln(&mut doc, "|----------|------|---------|-----------|-------|-------|");
1260 for model in models {
1261 let ctx_str = format_context_window(model.context_window);
1262 let reasoning = if model.reasoning_levels.is_empty() { "" } else { "yes" };
1263 let image = if model.input_modalities.contains(&"image".to_string()) { "yes" } else { "" };
1264 let audio = if model.input_modalities.contains(&"audio".to_string()) { "yes" } else { "" };
1265 pushln(
1266 &mut doc,
1267 format!(
1268 "| `{}` | `{}` | `{}` | {} | {} | {} |",
1269 model.model_id, model.display_name, ctx_str, reasoning, image, audio
1270 ),
1271 );
1272 }
1273
1274 docs.insert(cfg.dev_id.to_string(), doc);
1275 }
1276
1277 for dyn_cfg in DYNAMIC_PROVIDERS {
1278 let mut doc = String::new();
1279 pushln(&mut doc, format!("`{}` LLM provider.", dyn_cfg.display_name));
1280 blank(&mut doc);
1281 pushln(
1282 &mut doc,
1283 format!("This provider accepts any model name at runtime (e.g. `{}:my-model`).", dyn_cfg.parser_name),
1284 );
1285 pushln(&mut doc, "No API key is required.");
1286 docs.insert(dyn_cfg.parser_name.to_string(), doc);
1287 }
1288
1289 docs
1290}
1291
1292fn format_context_window(tokens: u32) -> String {
1294 if tokens == 0 {
1295 return "unknown".to_string();
1296 }
1297 if tokens >= 1_000_000 && tokens.is_multiple_of(1_000_000) {
1298 format!("{}M", tokens / 1_000_000)
1299 } else if tokens >= 1_000 && tokens.is_multiple_of(1_000) {
1300 format!("{}k", tokens / 1_000)
1301 } else {
1302 format_number(tokens)
1303 }
1304}
1305
1306fn pushln(out: &mut String, line: impl AsRef<str>) {
1307 writeln!(out, "{}", line.as_ref()).expect("writing to String should not fail");
1308}
1309
1310fn blank(out: &mut String) {
1311 pushln(out, "");
1312}
1313
1314#[cfg(test)]
1315mod tests {
1316 use super::*;
1317 use serde_json::Value;
1318 use serde_json::json;
1319 use tempfile::NamedTempFile;
1320
1321 #[test]
1324 fn model_id_to_variant_pascal_cases_segments() {
1325 assert_eq!(model_id_to_variant("claude-sonnet-4-5-20250929"), "ClaudeSonnet4520250929");
1326 assert_eq!(model_id_to_variant("gemini-2.5-flash"), "Gemini25Flash");
1327 assert_eq!(model_id_to_variant("deepseek-chat"), "DeepseekChat");
1328 assert_eq!(model_id_to_variant("glm-4.5"), "Glm45");
1329 }
1330
1331 #[test]
1332 fn model_id_to_variant_handles_slash_and_colon() {
1333 assert_eq!(model_id_to_variant("anthropic/claude-opus-4.6"), "AnthropicClaudeOpus46");
1334 assert_eq!(model_id_to_variant("openai/gpt-5.1-codex-max"), "OpenaiGpt51CodexMax");
1335 assert_eq!(model_id_to_variant("deepseek/deepseek-r1:free"), "DeepseekDeepseekR1Free");
1336 }
1337
1338 #[test]
1339 fn is_alias_detects_latest_suffix() {
1340 assert!(is_alias("claude-sonnet-4-5-latest"));
1341 assert!(is_alias("claude-3-7-sonnet-latest"));
1342 assert!(!is_alias("claude-sonnet-4-5-20250929"));
1343 }
1344
1345 #[test]
1346 fn build_uses_explicit_context_windows_for_codex_models() {
1347 let data = minimal_models_dev_json();
1348
1349 let models = build_from_value(&data);
1350 let window = |id: &str| models["codex"].iter().find(|model| model.model_id == id).unwrap().context_window;
1351 for model_id in ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.2"] {
1352 assert_eq!(window(model_id), 272_000);
1353 }
1354 for model_id in ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] {
1355 assert_eq!(window(model_id), 372_000);
1356 }
1357 }
1358
1359 #[test]
1360 fn format_context_window_formats_correctly() {
1361 assert_eq!(format_context_window(1_000_000), "1M");
1362 assert_eq!(format_context_window(200_000), "200k");
1363 assert_eq!(format_context_window(8_000), "8k");
1364 assert_eq!(format_context_window(0), "unknown");
1365 }
1366
1367 #[test]
1368 fn level_str_to_variant_covers_all_reasoning_efforts() {
1369 for effort in utils::ReasoningEffort::all() {
1370 let _ = level_str_to_variant(effort.as_str());
1371 }
1372 }
1373
1374 #[test]
1375 fn build_sorts_models_and_filters_aliases_and_non_tool_call() {
1376 let mut data = minimal_models_dev_json();
1377 anthropic_models(
1378 &mut data,
1379 json!({
1380 "b-model": {"id": "b-model", "name": "B Model", "tool_call": true, "limit": {"context": 2000, "output": 0}},
1381 "a-model": {"id": "a-model", "name": "A Model", "tool_call": true, "limit": {"context": 1000, "output": 0}},
1382 "alpha-latest": {"id": "alpha-latest", "name": "Alias", "tool_call": true, "limit": {"context": 500, "output": 0}},
1383 "no-tools": {"id": "no-tools", "name": "No Tools", "tool_call": false, "limit": {"context": 500, "output": 0}}
1384 }),
1385 );
1386
1387 let models = build_from_value(&data);
1388 let ids: Vec<&str> = models["anthropic"].iter().map(|m| m.model_id.as_str()).collect();
1389 assert_eq!(ids, vec!["a-model", "b-model"]);
1390 }
1391
1392 #[test]
1393 fn build_extra_source_ids_merges_unique_models_into_provider() {
1394 let mut data = minimal_models_dev_json();
1395 zai_extra_models(
1396 &mut data,
1397 json!({
1398 "extra-model": {"id": "extra-model", "name": "Extra Model", "tool_call": true, "limit": {"context": 4000, "output": 0}}
1399 }),
1400 );
1401
1402 let models = build_from_value(&data);
1403 assert!(models["zai"].iter().any(|m| m.model_id == "extra-model"));
1404 }
1405
1406 #[test]
1407 fn build_extra_source_ids_does_not_duplicate_existing_models() {
1408 let mut data = minimal_models_dev_json();
1409 let shared = json!({
1410 "shared-model": {"id": "shared-model", "name": "Shared Model", "tool_call": true, "limit": {"context": 1000, "output": 0}}
1411 });
1412 insert_models(&mut data, "zai", shared.clone());
1413 insert_models(&mut data, "zai-coding-plan", shared);
1414
1415 let models = build_from_value(&data);
1416 let count = models["zai"].iter().filter(|m| m.model_id == "shared-model").count();
1417 assert_eq!(count, 1);
1418 }
1419
1420 #[test]
1421 fn build_derives_reasoning_levels_from_source_metadata() {
1422 let mut data = minimal_models_dev_json();
1423 anthropic_models(
1424 &mut data,
1425 json!({
1426 "claude-test": {
1427 "id": "claude-test", "name": "Claude Test", "tool_call": true, "reasoning": true,
1428 "reasoning_options": [{"type": "effort", "values": ["low", "high", "max"]}],
1429 "limit": {"context": 200_000, "output": 0}
1430 }
1431 }),
1432 );
1433
1434 let models = build_from_value(&data);
1435 let model = models["anthropic"].iter().find(|model| model.model_id == "claude-test").unwrap();
1436 assert_eq!(model.reasoning_levels, ["low", "high", "max"]);
1437 }
1438
1439 #[test]
1440 fn build_rejects_unknown_reasoning_effort_metadata() {
1441 let mut data = minimal_models_dev_json();
1442 anthropic_models(
1443 &mut data,
1444 json!({
1445 "claude-test": {
1446 "id": "claude-test", "name": "Claude Test", "tool_call": true, "reasoning": true,
1447 "reasoning_options": [{"type": "effort", "values": ["ultra"]}],
1448 "limit": {"context": 200_000, "output": 0}
1449 }
1450 }),
1451 );
1452 let parsed: ModelsDevData = serde_json::from_value(data).unwrap();
1453
1454 let error = build_provider_models(&parsed).unwrap_err();
1455
1456 assert!(matches!(error, CodegenError::UnsupportedReasoningEffort { .. }));
1457 }
1458
1459 #[test]
1460 fn build_derives_prompt_caching_from_cost_fields() {
1461 let mut data = minimal_models_dev_json();
1462 insert_models(
1463 &mut data,
1464 "amazon-bedrock",
1465 json!({
1466 "cached": {
1467 "id": "cached", "name": "Cached", "tool_call": true,
1468 "limit": {"context": 200_000, "output": 0},
1469 "cost": {"input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_write": 3.75}
1470 },
1471 "uncached": {
1472 "id": "uncached", "name": "Uncached", "tool_call": true,
1473 "limit": {"context": 200_000, "output": 0},
1474 "cost": {"input": 3.0, "output": 15.0}
1475 }
1476 }),
1477 );
1478
1479 let models = build_from_value(&data);
1480 let bedrock = &models["amazon-bedrock"];
1481 let cached = bedrock.iter().find(|m| m.model_id == "cached").unwrap();
1482 let uncached = bedrock.iter().find(|m| m.model_id == "uncached").unwrap();
1483 assert!(cached.supports_prompt_caching);
1484 assert!(!uncached.supports_prompt_caching);
1485 }
1486
1487 #[test]
1488 fn build_assigns_codex_model_specific_reasoning_levels() {
1489 let mut data = minimal_models_dev_json();
1490 insert_models(
1491 &mut data,
1492 "openai",
1493 json!({
1494 "gpt-5.6-sol": {
1495 "id": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "tool_call": true, "reasoning": true,
1496 "reasoning_options": [{"type": "effort", "values": ["none", "low", "medium", "high", "xhigh", "max"]}],
1497 "limit": {"context": 200_000, "output": 0}
1498 },
1499 "gpt-5.6-luna": {
1500 "id": "gpt-5.6-luna", "name": "GPT-5.6 Luna", "tool_call": true, "reasoning": true,
1501 "reasoning_options": [{"type": "effort", "values": ["none", "low", "medium", "high", "xhigh", "max"]}],
1502 "limit": {"context": 200_000, "output": 0}
1503 },
1504 "gpt-5.4": {
1505 "id": "gpt-5.4", "name": "GPT-5.4", "tool_call": true, "reasoning": true,
1506 "limit": {"context": 200_000, "output": 0}
1507 }
1508 }),
1509 );
1510
1511 let models = build_from_value(&data);
1512 let levels = |id: &str| models["codex"].iter().find(|m| m.model_id == id).unwrap().reasoning_levels.clone();
1513 assert_eq!(levels("gpt-5.6-sol"), vec!["low", "medium", "high", "xhigh", "max"]);
1514 assert_eq!(levels("gpt-5.6-luna"), vec!["low", "medium", "high", "xhigh", "max"]);
1515 assert_eq!(levels("gpt-5.4"), vec!["low", "medium", "high", "xhigh"]);
1516 }
1517
1518 #[test]
1519 fn build_applies_codex_subscription_context_window_override() {
1520 let mut data = minimal_models_dev_json();
1521 insert_models(
1522 &mut data,
1523 "openai",
1524 json!({
1525 "gpt-5.5": {
1526 "id": "gpt-5.5", "name": "GPT-5.5", "tool_call": true, "reasoning": true,
1527 "limit": {"context": 1_050_000, "output": 128_000}
1528 }
1529 }),
1530 );
1531
1532 let models = build_from_value(&data);
1533 let codex = models["codex"].iter().find(|m| m.model_id == "gpt-5.5").unwrap();
1534 let openai = models["openai"].iter().find(|m| m.model_id == "gpt-5.5").unwrap();
1535 assert_eq!(codex.context_window, 272_000);
1536 assert_eq!(openai.context_window, 1_050_000);
1537 }
1538
1539 #[test]
1542 fn generate_uses_codex_subscription_model_ids() {
1543 let mut data = minimal_models_dev_json();
1544 insert_models(
1545 &mut data,
1546 "openai",
1547 json!({
1548 "gpt-5.1-codex": {
1549 "id": "gpt-5.1-codex", "name": "GPT-5.1 Codex", "tool_call": true, "reasoning": true,
1550 "limit": {"context": 400_000, "output": 128_000}
1551 },
1552 "gpt-5.6": {
1553 "id": "gpt-5.6", "name": "GPT-5.6 Sol", "tool_call": true, "reasoning": true,
1554 "limit": {"context": 1_050_000, "output": 128_000}
1555 },
1556 "gpt-5.6-sol": {
1557 "id": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "tool_call": true, "reasoning": true,
1558 "limit": {"context": 1_050_000, "output": 128_000}
1559 },
1560 "gpt-5.6-terra": {
1561 "id": "gpt-5.6-terra", "name": "GPT-5.6 Terra", "tool_call": true, "reasoning": true,
1562 "limit": {"context": 1_050_000, "output": 128_000}
1563 },
1564 "gpt-5.6-luna": {
1565 "id": "gpt-5.6-luna", "name": "GPT-5.6 Luna", "tool_call": true, "reasoning": true,
1566 "limit": {"context": 1_050_000, "output": 128_000}
1567 }
1568 }),
1569 );
1570
1571 let tmp = NamedTempFile::new().unwrap();
1572 std::fs::write(tmp.path(), serde_json::to_string(&data).unwrap()).unwrap();
1573 let output = generate(tmp.path()).unwrap();
1574
1575 let codex_doc = &output.provider_docs["codex"];
1576 assert!(!codex_doc.contains("`gpt-5.6`"));
1577 assert!(!codex_doc.contains("`gpt-5.1-codex`"));
1578 assert!(codex_doc.contains("| `gpt-5.6-sol` | `GPT-5.6 Sol` | `372k` |"));
1579 assert!(codex_doc.contains("| `gpt-5.6-terra` | `GPT-5.6 Terra` | `372k` |"));
1580 assert!(codex_doc.contains("| `gpt-5.6-luna` | `GPT-5.6 Luna` | `372k` |"));
1581
1582 let openai_doc = &output.provider_docs["openai"];
1583 assert!(openai_doc.contains("`gpt-5.6`"));
1584 assert!(openai_doc.contains("`gpt-5.1-codex`"));
1585 assert!(openai_doc.contains("`gpt-5.6-sol`"));
1586 }
1587
1588 #[test]
1589 fn generate_emits_provider_docs() {
1590 let mut data = minimal_models_dev_json();
1591 anthropic_models(
1592 &mut data,
1593 json!({
1594 "claude-test": {
1595 "id": "claude-test", "name": "Claude Test", "tool_call": true, "reasoning": true,
1596 "limit": {"context": 200_000, "output": 0},
1597 "modalities": {"input": ["text", "image"]}
1598 }
1599 }),
1600 );
1601
1602 let tmp = NamedTempFile::new().unwrap();
1603 std::fs::write(tmp.path(), serde_json::to_string(&data).unwrap()).unwrap();
1604 let output = generate(tmp.path()).unwrap();
1605
1606 let anthropic_doc = &output.provider_docs["anthropic"];
1607 assert!(anthropic_doc.contains("`Anthropic` LLM provider."));
1608 assert!(anthropic_doc.contains("`ANTHROPIC_API_KEY`"));
1609 assert!(anthropic_doc.contains("| `claude-test` | `Claude Test` | `200k` | yes | yes | |"));
1610
1611 let ollama_doc = &output.provider_docs["ollama"];
1612 assert!(ollama_doc.contains("`Ollama` LLM provider."));
1613 assert!(ollama_doc.contains("any model name at runtime"));
1614 }
1615
1616 fn build_from_value(data: &Value) -> ProviderModels {
1617 let parsed: ModelsDevData = serde_json::from_value(data.clone()).expect("parse fixture");
1618 build_provider_models(&parsed).expect("build provider models")
1619 }
1620
1621 fn anthropic_models(data: &mut Value, models: Value) {
1622 insert_models(data, "anthropic", models);
1623 }
1624
1625 fn zai_extra_models(data: &mut Value, models: Value) {
1626 insert_models(data, "zai-coding-plan", models);
1627 }
1628
1629 fn insert_models(data: &mut Value, provider_key: &str, models: Value) {
1630 let provider = data.as_object_mut().unwrap().get_mut(provider_key).unwrap().as_object_mut().unwrap();
1631 let target = provider.get_mut("models").unwrap().as_object_mut().unwrap();
1632 let Value::Object(models) = models else {
1633 panic!("models fixture must be an object");
1634 };
1635 target.extend(models);
1636 }
1637
1638 fn minimal_models_dev_json() -> Value {
1639 let mut root = serde_json::Map::new();
1640 for cfg in PROVIDERS {
1641 let json_key = cfg.json_key();
1642 root.entry(json_key.to_string())
1643 .or_insert_with(|| json!({"id": json_key, "name": json_key, "env": [], "models": {}}));
1644 for &extra in cfg.extra_source_ids {
1645 root.entry(extra.to_string())
1646 .or_insert_with(|| json!({"id": extra, "name": extra, "env": [], "models": {}}));
1647 }
1648 }
1649 let openai = root.get_mut("openai").unwrap()["models"].as_object_mut().unwrap();
1650 for model in CODEX_SUBSCRIPTION_MODELS {
1651 openai.insert(
1652 model.id.to_string(),
1653 json!({
1654 "id": model.id,
1655 "name": model.id,
1656 "tool_call": true,
1657 "reasoning": true,
1658 "reasoning_options": [{"type": "effort", "values": ["low", "medium", "high", "xhigh"]}],
1659 "limit": {"context": 1_050_000, "output": 0}
1660 }),
1661 );
1662 }
1663 Value::Object(root)
1664 }
1665}