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};
7use std::fmt::Write;
8use std::path::Path;
9
10type ModelsDevData = HashMap<String, ProviderData>;
11type ContextWindowOverride = fn(&str, u32) -> u32;
12
13#[derive(Debug, Deserialize)]
14struct ProviderData {
15 #[allow(dead_code)]
16 id: String,
17 #[allow(dead_code)]
18 name: String,
19 #[serde(default)]
20 #[allow(dead_code)]
21 env: Vec<String>,
22 #[serde(default)]
23 models: HashMap<String, ModelData>,
24}
25
26#[derive(Debug, Deserialize)]
27struct ModelData {
28 id: String,
29 name: String,
30 #[serde(default)]
31 tool_call: Option<bool>,
32 #[serde(default)]
33 reasoning: Option<bool>,
34 #[serde(default)]
35 #[allow(dead_code)]
36 cost: Option<CostData>,
37 #[serde(default)]
38 limit: Option<LimitData>,
39 #[serde(default)]
40 modalities: Option<ModalitiesData>,
41}
42
43#[derive(Debug, Deserialize, Default)]
44struct ModalitiesData {
45 #[serde(default)]
46 input: Vec<String>,
47}
48
49#[derive(Debug, Deserialize)]
50#[allow(dead_code)]
51struct CostData {
52 #[serde(default)]
53 input: f64,
54 #[serde(default)]
55 output: f64,
56 #[serde(default)]
57 cache_read: Option<f64>,
58 #[serde(default)]
59 cache_write: Option<f64>,
60}
61
62#[derive(Debug, Deserialize)]
63struct LimitData {
64 #[serde(default)]
65 context: u32,
66 #[serde(default)]
67 #[allow(dead_code)]
68 output: u32,
69}
70
71impl CostData {
72 fn has_prompt_caching(&self) -> bool {
73 self.cache_read.is_some() || self.cache_write.is_some()
74 }
75}
76
77struct ProviderConfig {
79 dev_id: &'static str,
81 source_dev_id: Option<&'static str>,
83 extra_source_ids: &'static [&'static str],
85 model_filter: Option<fn(&str) -> bool>,
87 context_window_override: Option<ContextWindowOverride>,
89 enum_name: &'static str,
91 parser_name: &'static str,
93 display_name: &'static str,
95 env_var: Option<&'static str>,
97 oauth_provider_id: Option<&'static str>,
99 default_reasoning_levels: &'static [&'static str],
101 is_hybrid_dynamic: bool,
107}
108
109impl ProviderConfig {
110 const fn standard(
112 dev_id: &'static str,
113 enum_name: &'static str,
114 parser_name: &'static str,
115 display_name: &'static str,
116 env_var: Option<&'static str>,
117 ) -> Self {
118 Self {
119 dev_id,
120 source_dev_id: None,
121 extra_source_ids: &[],
122 model_filter: None,
123 context_window_override: None,
124 enum_name,
125 parser_name,
126 display_name,
127 env_var,
128 oauth_provider_id: None,
129 default_reasoning_levels: &["low", "medium", "high"],
130 is_hybrid_dynamic: false,
131 }
132 }
133
134 fn inner_enum_name(&self) -> String {
137 if self.is_hybrid_dynamic {
138 format!("{}FoundationModel", self.enum_name)
139 } else {
140 format!("{}Model", self.enum_name)
141 }
142 }
143
144 fn outer_enum_name(&self) -> String {
146 format!("{}Model", self.enum_name)
147 }
148
149 fn json_key(&self) -> &'static str {
151 self.source_dev_id.unwrap_or(self.dev_id)
152 }
153}
154
155#[allow(clippy::struct_field_names)]
157struct DynamicProviderConfig {
158 enum_name: &'static str,
160 parser_name: &'static str,
162 display_name: &'static str,
164}
165
166const PROVIDERS: &[ProviderConfig] = &[
167 ProviderConfig::standard("anthropic", "Anthropic", "anthropic", "Anthropic", Some("ANTHROPIC_API_KEY")),
168 ProviderConfig {
169 dev_id: "codex",
170 source_dev_id: Some("openai"),
171 extra_source_ids: &[],
172 model_filter: Some(|id| id.contains("codex") || id.starts_with("gpt-5.") || id == "gpt-5"),
173 context_window_override: Some(codex_subscription_context_window),
174 enum_name: "Codex",
175 parser_name: "codex",
176 display_name: "Codex",
177 env_var: None,
178 oauth_provider_id: Some("codex"),
179 default_reasoning_levels: &["low", "medium", "high", "xhigh"],
180 is_hybrid_dynamic: false,
181 },
182 ProviderConfig::standard("deepseek", "DeepSeek", "deepseek", "DeepSeek", Some("DEEPSEEK_API_KEY")),
183 ProviderConfig::standard("google", "Gemini", "gemini", "Gemini", Some("GEMINI_API_KEY")),
184 ProviderConfig::standard("moonshotai", "Moonshot", "moonshot", "Moonshot", Some("MOONSHOT_API_KEY")),
185 ProviderConfig::standard("openai", "Openai", "openai", "OpenAI", Some("OPENAI_API_KEY")),
186 ProviderConfig::standard("openrouter", "OpenRouter", "openrouter", "OpenRouter", Some("OPENROUTER_API_KEY")),
187 ProviderConfig {
188 extra_source_ids: &["zai-coding-plan"],
189 ..ProviderConfig::standard("zai", "ZAi", "zai", "ZAI", Some("ZAI_API_KEY"))
190 },
191 ProviderConfig {
192 is_hybrid_dynamic: true,
193 ..ProviderConfig::standard("amazon-bedrock", "Bedrock", "bedrock", "AWS Bedrock", None)
194 },
195];
196
197const DYNAMIC_PROVIDERS: &[DynamicProviderConfig] = &[
198 DynamicProviderConfig { enum_name: "Ollama", parser_name: "ollama", display_name: "Ollama" },
199 DynamicProviderConfig { enum_name: "LlamaCpp", parser_name: "llamacpp", display_name: "LlamaCpp" },
200];
201
202const CODEX_SUBSCRIPTION_CONTEXT_WINDOW: u32 = 272_000;
203
204fn codex_subscription_context_window(model_id: &str, default_context_window: u32) -> u32 {
205 match model_id {
206 "gpt-5.5" | "gpt-5.4" | "gpt-5.4-mini" | "gpt-5.3-codex" | "gpt-5.2" | "codex-auto-review" => {
207 CODEX_SUBSCRIPTION_CONTEXT_WINDOW
208 }
209 _ => default_context_window,
210 }
211}
212
213#[derive(Debug, Clone)]
214struct ModelInfo {
215 variant_name: String,
216 model_id: String,
217 display_name: String,
218 context_window: u32,
219 reasoning_levels: Vec<String>,
220 input_modalities: Vec<String>,
221 supports_prompt_caching: bool,
222}
223
224type ProviderModels = BTreeMap<&'static str, Vec<ModelInfo>>;
225
226struct CodegenCtx {
227 provider_models: ProviderModels,
228}
229
230pub struct GeneratedOutput {
232 pub rust_source: String,
234 pub provider_docs: HashMap<String, String>,
239}
240
241pub fn generate(models_json_path: &Path) -> Result<GeneratedOutput, String> {
243 let json_bytes = std::fs::read_to_string(models_json_path).map_err(|e| format!("read: {e}"))?;
244 let data: ModelsDevData = serde_json::from_str(&json_bytes).map_err(|e| format!("parse: {e}"))?;
245
246 let provider_models = build_provider_models(&data)?;
247 let ctx = CodegenCtx { provider_models };
248 Ok(GeneratedOutput { rust_source: emit_generated_source(&ctx), provider_docs: emit_provider_docs(&ctx) })
249}
250
251fn build_provider_models(data: &ModelsDevData) -> Result<ProviderModels, String> {
252 let mut provider_models = ProviderModels::new();
253
254 for cfg in PROVIDERS {
255 let json_key = cfg.json_key();
256 let provider_data =
257 data.get(json_key).ok_or_else(|| format!("Provider '{json_key}' not found in models.dev data"))?;
258
259 let mut models: Vec<ModelInfo> = collect_models_from(cfg, &provider_data.models);
260
261 for &extra_key in cfg.extra_source_ids {
262 if let Some(extra_data) = data.get(extra_key) {
263 let extra = collect_models_from(cfg, &extra_data.models);
264 let existing_ids: std::collections::HashSet<String> =
265 models.iter().map(|m| m.model_id.clone()).collect();
266 models.extend(extra.into_iter().filter(|m| !existing_ids.contains(&m.model_id)));
267 }
268 }
269
270 models.sort_by(|a, b| a.model_id.cmp(&b.model_id));
271 provider_models.insert(cfg.dev_id, models);
272 }
273
274 Ok(provider_models)
275}
276
277fn collect_models_from(cfg: &ProviderConfig, models: &HashMap<String, ModelData>) -> Vec<ModelInfo> {
278 models
279 .values()
280 .filter(|m| m.tool_call == Some(true))
281 .filter(|m| !is_alias(&m.id))
282 .filter(|m| cfg.model_filter.is_none_or(|f| f(&m.id)))
283 .map(|m| {
284 let reasoning_levels = if m.reasoning.unwrap_or(false) {
285 cfg.default_reasoning_levels.iter().map(|s| (*s).to_string()).collect()
286 } else {
287 Vec::new()
288 };
289 let input_modalities =
290 m.modalities.as_ref().map_or_else(|| vec!["text".to_string()], |md| md.input.clone());
291 let source_context_window = m.limit.as_ref().map_or(0, |l| l.context);
292 let context_window = cfg.context_window_override.map_or(source_context_window, |override_context_window| {
293 override_context_window(&m.id, source_context_window)
294 });
295 ModelInfo {
296 variant_name: model_id_to_variant(&m.id),
297 model_id: m.id.clone(),
298 display_name: m.name.clone(),
299 context_window,
300 reasoning_levels,
301 input_modalities,
302 supports_prompt_caching: m.cost.as_ref().is_some_and(CostData::has_prompt_caching),
303 }
304 })
305 .collect()
306}
307
308fn is_alias(id: &str) -> bool {
310 id.ends_with("-latest")
311}
312
313fn model_id_to_variant(id: &str) -> String {
316 let mut result = String::new();
317 let mut capitalize_next = true;
318
319 for ch in id.chars() {
320 if ch == '-' || ch == '.' || ch == '/' || ch == ':' {
321 capitalize_next = true;
322 } else if capitalize_next {
323 result.push(ch.to_ascii_uppercase());
324 capitalize_next = false;
325 } else {
326 result.push(ch);
327 }
328 }
329
330 if result.starts_with(|c: char| c.is_ascii_digit()) {
331 result.insert(0, '_');
332 }
333
334 result
335}
336
337fn emit_generated_source(ctx: &CodegenCtx) -> String {
338 let provider_enum = emit_provider_enum();
339 let provider_enum_impl = emit_provider_enum_impl();
340 let provider_enum_display = emit_provider_enum_display();
341 let provider_enum_fromstr = emit_provider_enum_fromstr();
342 let provider_enums = emit_provider_enums(&ctx.provider_models);
343 let provider_impls = emit_provider_impls(&ctx.provider_models);
344 let llm_model_enum = emit_llm_model_enum();
345 let from_impls = emit_from_impls();
346 let llm_model_impl = emit_llm_model_impl();
347 let display_impl = emit_display_impl();
348 let fromstr_impl = emit_fromstr_impl();
349
350 let file_tokens = quote! {
351 use std::borrow::Cow;
352 use std::sync::LazyLock;
353 use crate::ReasoningEffort;
354
355 #provider_enum
356 #provider_enum_impl
357 #provider_enum_display
358 #provider_enum_fromstr
359 #provider_enums
360 #provider_impls
361 #llm_model_enum
362 #from_impls
363 #llm_model_impl
364 #display_impl
365 #fromstr_impl
366 };
367
368 let file: syn::File = syn::parse2(file_tokens).expect("generated tokens parse as Rust");
369 let formatted = prettyplease::unparse(&file);
370 format!(
371 "// Auto-generated from models.dev — do not edit manually\n// Regenerated automatically by build.rs\n\n{formatted}"
372 )
373}
374
375fn emit_provider_enum() -> TokenStream {
376 let catalog_variants = PROVIDERS.iter().map(|cfg| format_ident!("{}", cfg.enum_name));
377 let dynamic_variants = DYNAMIC_PROVIDERS.iter().map(|d| format_ident!("{}", d.enum_name));
378 quote! {
379 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
383 pub enum Provider {
384 #(#catalog_variants,)*
385 #(#dynamic_variants,)*
386 }
387 }
388}
389
390fn emit_provider_enum_impl() -> TokenStream {
391 let parser_arms = provider_match_arms(|cfg| cfg.parser_name, |d| d.parser_name);
392 let display_arms = provider_match_arms(|cfg| cfg.display_name, |d| d.display_name);
393
394 let env_var_some = PROVIDERS.iter().filter_map(|cfg| {
395 cfg.env_var.map(|var| {
396 let v = format_ident!("{}", cfg.enum_name);
397 quote! { Self::#v => Some(#var), }
398 })
399 });
400
401 let env_var_none = provider_or_pats(|cfg| cfg.env_var.is_none(), |_| true);
402 let oauth_some = PROVIDERS.iter().filter_map(|cfg| {
403 cfg.oauth_provider_id.map(|id| {
404 let v = format_ident!("{}", cfg.enum_name);
405 quote! { Self::#v => Some(#id), }
406 })
407 });
408 let oauth_none = provider_or_pats(|cfg| cfg.oauth_provider_id.is_none(), |_| true);
409
410 let is_local_true = provider_or_pats(|_| false, |_| true);
411 let is_local_false = provider_or_pats(|_| true, |_| false);
412
413 let all_variants = PROVIDERS
414 .iter()
415 .map(|cfg| format_ident!("{}", cfg.enum_name))
416 .chain(DYNAMIC_PROVIDERS.iter().map(|d| format_ident!("{}", d.enum_name)));
417
418 quote! {
419 impl Provider {
420 pub const ALL: &[Provider] = &[#(Self::#all_variants),*];
422
423 pub fn parser_name(self) -> &'static str {
425 match self { #parser_arms }
426 }
427
428 pub fn display_name(self) -> &'static str {
430 match self { #display_arms }
431 }
432
433 pub fn required_env_var(self) -> Option<&'static str> {
435 match self {
436 #(#env_var_some)*
437 #env_var_none => None,
438 }
439 }
440
441 pub fn oauth_provider_id(self) -> Option<&'static str> {
443 match self {
444 #(#oauth_some)*
445 #oauth_none => None,
446 }
447 }
448
449 pub fn is_local(self) -> bool {
452 match self {
453 #is_local_true => true,
454 #is_local_false => false,
455 }
456 }
457 }
458 }
459}
460
461fn emit_provider_enum_display() -> TokenStream {
462 quote! {
463 impl std::fmt::Display for Provider {
464 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
465 f.write_str(self.parser_name())
466 }
467 }
468 }
469}
470
471fn emit_provider_enum_fromstr() -> TokenStream {
472 let catalog_arms = PROVIDERS.iter().map(|cfg| {
473 let v = format_ident!("{}", cfg.enum_name);
474 let name = cfg.parser_name;
475 quote! { #name => Ok(Self::#v), }
476 });
477
478 let dynamic_arms = DYNAMIC_PROVIDERS.iter().map(|d| {
479 let v = format_ident!("{}", d.enum_name);
480 let name = d.parser_name;
481 quote! { #name => Ok(Self::#v), }
482 });
483
484 quote! {
485 impl std::str::FromStr for Provider {
486 type Err = String;
487 fn from_str(s: &str) -> Result<Self, Self::Err> {
488 match s {
489 #(#catalog_arms)*
490 #(#dynamic_arms)*
491 other => Err(format!("Unknown provider: '{other}'")),
492 }
493 }
494 }
495 }
496}
497
498fn emit_provider_enums(provider_models: &ProviderModels) -> TokenStream {
499 let enums = PROVIDERS.iter().map(|cfg| {
500 let inner = format_ident!("{}", cfg.inner_enum_name());
501 let variants = provider_models[cfg.dev_id].iter().map(|m| format_ident!("{}", m.variant_name));
502 quote! {
503 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
504 pub enum #inner {
505 #(#variants,)*
506 }
507 }
508 });
509 quote! { #(#enums)* }
510}
511
512fn emit_provider_impls(provider_models: &ProviderModels) -> TokenStream {
513 let impls = PROVIDERS.iter().map(|cfg| {
514 let models = &provider_models[cfg.dev_id];
515 let enum_ident = format_ident!("{}", cfg.inner_enum_name());
516
517 let model_id_arms = models.iter().map(|m| {
518 let v = format_ident!("{}", m.variant_name);
519 let id = &m.model_id;
520 quote! { Self::#v => #id, }
521 });
522
523 let display_name_arms = grouped_arms(
524 models,
525 |m| m.display_name.clone(),
526 |m| {
527 let s = &m.display_name;
528 quote! { #s }
529 },
530 );
531
532 let context_window_arms =
533 grouped_arms(models, |m| m.context_window, |m| num_lit_with_underscores(m.context_window));
534
535 let reasoning_levels_arms = emit_reasoning_levels_arms(models);
536
537 let prompt_caching_arms = grouped_arms(
538 models,
539 |m| m.supports_prompt_caching,
540 |m| {
541 let b = m.supports_prompt_caching;
542 quote! { #b }
543 },
544 );
545
546 let modality_methods = ["image", "audio"].iter().map(|modality| {
547 let method = format_ident!("supports_{}", modality);
548 let mod_owned = (*modality).to_string();
549 let arms = grouped_arms(models, move |m| m.input_modalities.contains(&mod_owned), {
550 let mod_owned = (*modality).to_string();
551 move |m| {
552 let b = m.input_modalities.contains(&mod_owned);
553 quote! { #b }
554 }
555 });
556 quote! {
557 #[allow(clippy::too_many_lines)]
558 pub fn #method(self) -> bool {
559 match self { #arms }
560 }
561 }
562 });
563
564 let all_variants = models.iter().map(|m| format_ident!("{}", m.variant_name));
565
566 let from_str_impl = emit_from_str_impl(&enum_ident, cfg.parser_name, models);
567
568 quote! {
569 impl #enum_ident {
570 #[allow(clippy::too_many_lines)]
571 fn model_id(self) -> &'static str {
572 match self { #(#model_id_arms)* }
573 }
574
575 #[allow(clippy::too_many_lines)]
576 fn display_name(self) -> &'static str {
577 match self { #display_name_arms }
578 }
579
580 #[allow(clippy::too_many_lines)]
581 fn context_window(self) -> u32 {
582 match self { #context_window_arms }
583 }
584
585 #[allow(clippy::too_many_lines)]
586 pub fn reasoning_levels(self) -> &'static [ReasoningEffort] {
587 match self { #reasoning_levels_arms }
588 }
589
590 pub fn supports_reasoning(self) -> bool {
591 !self.reasoning_levels().is_empty()
592 }
593
594 #[allow(clippy::too_many_lines)]
595 pub fn supports_prompt_caching(self) -> bool {
596 match self { #prompt_caching_arms }
597 }
598
599 #(#modality_methods)*
600
601 const ALL: &[#enum_ident] = &[#(Self::#all_variants),*];
602 }
603
604 #from_str_impl
605 }
606 });
607 quote! { #(#impls)* }
608}
609
610fn emit_from_str_impl(enum_ident: &proc_macro2::Ident, parser_name: &str, models: &[ModelInfo]) -> TokenStream {
611 let arms = models.iter().map(|m| {
612 let id = &m.model_id;
613 let v = format_ident!("{}", m.variant_name);
614 quote! { #id => Ok(Self::#v), }
615 });
616 let err_msg = format!("Unknown {parser_name} model: '{{s}}'");
617 quote! {
618 impl std::str::FromStr for #enum_ident {
619 type Err = String;
620
621 #[allow(clippy::too_many_lines)]
622 fn from_str(s: &str) -> Result<Self, Self::Err> {
623 match s {
624 #(#arms)*
625 _ => Err(format!(#err_msg)),
626 }
627 }
628 }
629 }
630}
631
632fn grouped_arms<K, R>(
634 models: &[ModelInfo],
635 key_fn: impl Fn(&ModelInfo) -> K,
636 rhs_fn: impl Fn(&ModelInfo) -> R,
637) -> TokenStream
638where
639 K: Eq + Ord,
640 R: ToTokens,
641{
642 let mut groups: BTreeMap<K, Vec<&ModelInfo>> = BTreeMap::new();
643 for m in models {
644 groups.entry(key_fn(m)).or_default().push(m);
645 }
646 let arms = groups.values().map(|members| {
647 let pats = members.iter().map(|m| {
648 let v = format_ident!("{}", m.variant_name);
649 quote! { Self::#v }
650 });
651 let rhs = rhs_fn(members[0]);
652 quote! { #(#pats)|* => #rhs, }
653 });
654 quote! { #(#arms)* }
655}
656
657fn emit_reasoning_levels_arms(models: &[ModelInfo]) -> TokenStream {
658 grouped_arms(
659 models,
660 |m| m.reasoning_levels.clone(),
661 |m| {
662 if m.reasoning_levels.is_empty() {
663 quote! { &[] }
664 } else {
665 let items = m.reasoning_levels.iter().map(|l| {
666 let variant = format_ident!("{}", level_str_to_variant(l));
667 quote! { ReasoningEffort::#variant }
668 });
669 quote! { &[#(#items),*] }
670 }
671 },
672 )
673}
674
675fn level_str_to_variant(level: &str) -> &'static str {
677 match level {
678 "low" => "Low",
679 "medium" => "Medium",
680 "high" => "High",
681 "xhigh" => "Xhigh",
682 other => panic!("Unknown reasoning level: {other}"),
683 }
684}
685
686fn emit_llm_model_enum() -> TokenStream {
687 let catalog_variants = PROVIDERS.iter().map(|cfg| {
688 let v = format_ident!("{}", cfg.enum_name);
689 let inner = format_ident!("{}Model", cfg.enum_name);
690 quote! { #v(#inner) }
691 });
692 let dynamic_variants = DYNAMIC_PROVIDERS.iter().map(|d| {
693 let v = format_ident!("{}", d.enum_name);
694 quote! { #v(String) }
695 });
696 quote! {
697 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
699 pub enum LlmModel {
700 #(#catalog_variants,)*
701 #(#dynamic_variants,)*
702 }
703 }
704}
705
706fn emit_from_impls() -> TokenStream {
707 let impls = PROVIDERS.iter().map(|cfg| {
708 let outer = format_ident!("{}Model", cfg.enum_name);
709 let v = format_ident!("{}", cfg.enum_name);
710 quote! {
711 impl From<#outer> for LlmModel {
712 fn from(m: #outer) -> Self {
713 LlmModel::#v(m)
714 }
715 }
716 }
717 });
718 quote! { #(#impls)* }
719}
720
721fn emit_llm_model_impl() -> TokenStream {
722 let model_id = emit_llm_model_id();
723 let display_name = emit_llm_display_name();
724 let provider = emit_llm_provider();
725 let provider_enum = emit_llm_provider_enum();
726 let provider_display_name = emit_llm_provider_display_name();
727 let context_window = emit_llm_context_window();
728 let required_env_var = emit_llm_required_env_var();
729 let all_required_env_vars = emit_llm_all_required_env_vars();
730 let oauth_provider_id = emit_llm_oauth_provider_id();
731 let reasoning_levels = emit_llm_reasoning_levels();
732 let supports_reasoning = emit_llm_supports_reasoning();
733 let supports_prompt_caching = emit_llm_supports_prompt_caching();
734 let modality_methods = ["image", "audio"].iter().map(|m| emit_llm_supports_modality(m));
735 let all = emit_llm_all();
736
737 quote! {
738 impl LlmModel {
739 #model_id
740 #display_name
741 #provider
742 #provider_enum
743 #provider_display_name
744 #context_window
745 #required_env_var
746 #all_required_env_vars
747 #oauth_provider_id
748 #reasoning_levels
749 #supports_reasoning
750 #supports_prompt_caching
751 #(#modality_methods)*
752 #all
753 }
754 }
755}
756
757fn emit_llm_model_id() -> TokenStream {
758 let catalog_arms = PROVIDERS.iter().map(|cfg| {
759 let v = format_ident!("{}", cfg.enum_name);
760 if cfg.is_hybrid_dynamic {
761 quote! { Self::#v(m) => m.model_id(), }
762 } else {
763 quote! { Self::#v(m) => Cow::Borrowed(m.model_id()), }
764 }
765 });
766 let dyn_pats = dynamic_pattern_with_binding("s");
767 quote! {
768 pub fn model_id(&self) -> Cow<'static, str> {
770 match self {
771 #(#catalog_arms)*
772 #dyn_pats => Cow::Owned(s.clone()),
773 }
774 }
775 }
776}
777
778fn emit_llm_display_name() -> TokenStream {
779 let catalog_arms = PROVIDERS.iter().map(|cfg| {
780 let v = format_ident!("{}", cfg.enum_name);
781 if cfg.is_hybrid_dynamic {
782 quote! { Self::#v(m) => m.display_name(), }
783 } else {
784 quote! { Self::#v(m) => Cow::Borrowed(m.display_name()), }
785 }
786 });
787 let dyn_arms = DYNAMIC_PROVIDERS.iter().map(|d| {
788 let v = format_ident!("{}", d.enum_name);
789 let fmt = format!("{} {{s}}", d.enum_name);
790 quote! { Self::#v(s) => Cow::Owned(format!(#fmt)), }
791 });
792 quote! {
793 pub fn display_name(&self) -> Cow<'static, str> {
795 match self {
796 #(#catalog_arms)*
797 #(#dyn_arms)*
798 }
799 }
800 }
801}
802
803fn emit_llm_provider() -> TokenStream {
804 let arms = llm_match_arms_ignored(|cfg| cfg.parser_name, |d| d.parser_name);
805 quote! {
806 pub fn provider(&self) -> &'static str {
808 match self { #arms }
809 }
810 }
811}
812
813fn emit_llm_provider_enum() -> TokenStream {
814 let arms = llm_match_arms_ignored(
815 |cfg| {
816 let v = format_ident!("{}", cfg.enum_name);
817 quote! { Provider::#v }
818 },
819 |d| {
820 let v = format_ident!("{}", d.enum_name);
821 quote! { Provider::#v }
822 },
823 );
824 quote! {
825 pub fn provider_enum(&self) -> Provider {
827 match self { #arms }
828 }
829 }
830}
831
832fn emit_llm_provider_display_name() -> TokenStream {
833 let arms = llm_match_arms_ignored(|cfg| cfg.display_name, |d| d.display_name);
834 quote! {
835 pub fn provider_display_name(&self) -> &'static str {
837 match self { #arms }
838 }
839 }
840}
841
842fn emit_llm_context_window() -> TokenStream {
843 let catalog_arms = PROVIDERS.iter().map(|cfg| {
844 let v = format_ident!("{}", cfg.enum_name);
845 if cfg.is_hybrid_dynamic {
846 quote! { Self::#v(m) => m.context_window(), }
847 } else {
848 quote! { Self::#v(m) => Some(m.context_window()), }
849 }
850 });
851 let dyn_pats = dynamic_pattern_with_binding("_");
852 quote! {
853 pub fn context_window(&self) -> Option<u32> {
855 match self {
856 #(#catalog_arms)*
857 #dyn_pats => None,
858 }
859 }
860 }
861}
862
863fn emit_llm_required_env_var() -> TokenStream {
864 let some_arms = PROVIDERS.iter().filter_map(|cfg| {
865 cfg.env_var.map(|var| {
866 let v = format_ident!("{}", cfg.enum_name);
867 quote! { Self::#v(_) => Some(#var), }
868 })
869 });
870 let none_pats = llm_or_pats(|cfg| cfg.env_var.is_none(), |_| true);
871 quote! {
872 pub fn required_env_var(&self) -> Option<&'static str> {
874 match self {
875 #(#some_arms)*
876 #none_pats => None,
877 }
878 }
879 }
880}
881
882fn emit_llm_all_required_env_vars() -> TokenStream {
883 let vars = PROVIDERS.iter().filter_map(|cfg| cfg.env_var);
884 quote! {
885 pub const ALL_REQUIRED_ENV_VARS: &[&str] = &[#(#vars),*];
887 }
888}
889
890fn emit_llm_oauth_provider_id() -> TokenStream {
891 let some_arms = PROVIDERS.iter().filter_map(|cfg| {
892 cfg.oauth_provider_id.map(|id| {
893 let v = format_ident!("{}", cfg.enum_name);
894 quote! { Self::#v(_) => Some(#id), }
895 })
896 });
897 let none_pats = llm_or_pats(|cfg| cfg.oauth_provider_id.is_none(), |_| true);
898 quote! {
899 pub fn oauth_provider_id(&self) -> Option<&'static str> {
901 match self {
902 #(#some_arms)*
903 #none_pats => None,
904 }
905 }
906 }
907}
908
909fn emit_llm_reasoning_levels() -> TokenStream {
910 let body = llm_delegate_with_dynamic_default("reasoning_levels", "e! { &[] });
911 quote! {
912 pub fn reasoning_levels(&self) -> &'static [ReasoningEffort] {
914 #body
915 }
916 }
917}
918
919fn emit_llm_supports_reasoning() -> TokenStream {
920 quote! {
921 pub fn supports_reasoning(&self) -> bool {
923 !self.reasoning_levels().is_empty()
924 }
925 }
926}
927
928fn emit_llm_supports_prompt_caching() -> TokenStream {
929 let body = llm_delegate_with_dynamic_default("supports_prompt_caching", "e! { false });
930 quote! {
931 pub fn supports_prompt_caching(&self) -> bool {
933 #body
934 }
935 }
936}
937
938fn emit_llm_supports_modality(modality: &str) -> TokenStream {
939 let method = format!("supports_{modality}");
940 let method_ident = format_ident!("{}", method);
941 let doc = format!(" Whether this model supports {modality} input");
942 let body = llm_delegate_with_dynamic_default(&method, "e! { false });
943 quote! {
944 #[doc = #doc]
945 pub fn #method_ident(&self) -> bool {
946 #body
947 }
948 }
949}
950
951fn emit_llm_all() -> TokenStream {
952 let pushes = PROVIDERS.iter().map(|cfg| {
953 let inner = format_ident!("{}", cfg.inner_enum_name());
954 let outer = format_ident!("{}", cfg.outer_enum_name());
955 let v = format_ident!("{}", cfg.enum_name);
956 if cfg.is_hybrid_dynamic {
957 quote! {
958 v.extend(#inner::ALL.iter().copied().map(#outer::Foundation).map(LlmModel::#v));
959 }
960 } else {
961 quote! {
962 v.extend(#inner::ALL.iter().copied().map(LlmModel::#v));
963 }
964 }
965 });
966 quote! {
967 pub fn all() -> &'static [LlmModel] {
969 static ALL: LazyLock<Vec<LlmModel>> = LazyLock::new(|| {
970 let mut v = Vec::new();
971 #(#pushes)*
972 v
973 });
974 &ALL
975 }
976 }
977}
978
979fn emit_display_impl() -> TokenStream {
980 quote! {
981 impl std::fmt::Display for LlmModel {
982 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
983 write!(f, "{}:{}", self.provider(), self.model_id())
984 }
985 }
986 }
987}
988
989fn emit_fromstr_impl() -> TokenStream {
990 let catalog_arms = PROVIDERS.iter().map(|cfg| {
991 let name = cfg.parser_name;
992 let outer = format_ident!("{}Model", cfg.enum_name);
993 let v = format_ident!("{}", cfg.enum_name);
994 quote! { #name => model_str.parse::<#outer>().map(Self::#v), }
995 });
996 let dyn_arms = DYNAMIC_PROVIDERS.iter().map(|d| {
997 let name = d.parser_name;
998 let v = format_ident!("{}", d.enum_name);
999 quote! { #name => Ok(Self::#v(model_str.to_string())), }
1000 });
1001 quote! {
1002 impl std::str::FromStr for LlmModel {
1003 type Err = String;
1004
1005 fn from_str(s: &str) -> Result<Self, Self::Err> {
1007 let (provider_str, model_str) = s.split_once(':').unwrap_or((s, ""));
1008 match provider_str {
1009 #(#catalog_arms)*
1010 #(#dyn_arms)*
1011 _ => Err(format!("Unknown provider: '{provider_str}'")),
1012 }
1013 }
1014 }
1015 }
1016}
1017
1018fn dynamic_pattern_with_binding(binding: &str) -> TokenStream {
1020 let binding_ident = if binding == "_" {
1021 quote! { _ }
1022 } else {
1023 let b = format_ident!("{}", binding);
1024 quote! { #b }
1025 };
1026 let pats = DYNAMIC_PROVIDERS.iter().map(|d| {
1027 let v = format_ident!("{}", d.enum_name);
1028 quote! { Self::#v(#binding_ident) }
1029 });
1030 quote! { #(#pats)|* }
1031}
1032
1033fn provider_match_arms<V: ToTokens>(
1037 catalog_value: impl Fn(&ProviderConfig) -> V,
1038 dynamic_value: impl Fn(&DynamicProviderConfig) -> V,
1039) -> TokenStream {
1040 let catalog = PROVIDERS.iter().map(|cfg| {
1041 let v = format_ident!("{}", cfg.enum_name);
1042 let val = catalog_value(cfg);
1043 quote! { Self::#v => #val, }
1044 });
1045 let dynamic = DYNAMIC_PROVIDERS.iter().map(|d| {
1046 let v = format_ident!("{}", d.enum_name);
1047 let val = dynamic_value(d);
1048 quote! { Self::#v => #val, }
1049 });
1050 quote! { #(#catalog)* #(#dynamic)* }
1051}
1052
1053fn provider_or_pats(
1056 include_catalog: impl Fn(&ProviderConfig) -> bool,
1057 include_dynamic: impl Fn(&DynamicProviderConfig) -> bool,
1058) -> TokenStream {
1059 let catalog = PROVIDERS.iter().filter(|cfg| include_catalog(cfg)).map(|cfg| {
1060 let v = format_ident!("{}", cfg.enum_name);
1061 quote! { Self::#v }
1062 });
1063 let dynamic = DYNAMIC_PROVIDERS.iter().filter(|d| include_dynamic(d)).map(|d| {
1064 let v = format_ident!("{}", d.enum_name);
1065 quote! { Self::#v }
1066 });
1067 let pats = catalog.chain(dynamic);
1068 quote! { #(#pats)|* }
1069}
1070
1071fn llm_match_arms_ignored<V: ToTokens>(
1074 catalog_value: impl Fn(&ProviderConfig) -> V,
1075 dynamic_value: impl Fn(&DynamicProviderConfig) -> V,
1076) -> TokenStream {
1077 let catalog = PROVIDERS.iter().map(|cfg| {
1078 let v = format_ident!("{}", cfg.enum_name);
1079 let val = catalog_value(cfg);
1080 quote! { Self::#v(_) => #val, }
1081 });
1082 let dynamic = DYNAMIC_PROVIDERS.iter().map(|d| {
1083 let v = format_ident!("{}", d.enum_name);
1084 let val = dynamic_value(d);
1085 quote! { Self::#v(_) => #val, }
1086 });
1087 quote! { #(#catalog)* #(#dynamic)* }
1088}
1089
1090fn llm_or_pats(
1093 include_catalog: impl Fn(&ProviderConfig) -> bool,
1094 include_dynamic: impl Fn(&DynamicProviderConfig) -> bool,
1095) -> TokenStream {
1096 let catalog = PROVIDERS.iter().filter(|cfg| include_catalog(cfg)).map(|cfg| {
1097 let v = format_ident!("{}", cfg.enum_name);
1098 quote! { Self::#v(_) }
1099 });
1100 let dynamic = DYNAMIC_PROVIDERS.iter().filter(|d| include_dynamic(d)).map(|d| {
1101 let v = format_ident!("{}", d.enum_name);
1102 quote! { Self::#v(_) }
1103 });
1104 let pats = catalog.chain(dynamic);
1105 quote! { #(#pats)|* }
1106}
1107
1108fn llm_delegate_with_dynamic_default(method: &str, dynamic_value: &TokenStream) -> TokenStream {
1112 let method_ident = format_ident!("{}", method);
1113 let catalog_arms = PROVIDERS.iter().map(|cfg| {
1114 let v = format_ident!("{}", cfg.enum_name);
1115 quote! { Self::#v(m) => m.#method_ident(), }
1116 });
1117 let dyn_pat = dynamic_pattern_with_binding("_");
1118 quote! {
1119 match self {
1120 #(#catalog_arms)*
1121 #dyn_pat => #dynamic_value,
1122 }
1123 }
1124}
1125
1126fn num_lit_with_underscores(n: u32) -> TokenStream {
1128 format_number(n).parse().expect("formatted number parses as a token")
1129}
1130
1131fn format_number(n: u32) -> String {
1133 let s = n.to_string();
1134 if s.len() <= 4 {
1135 return s;
1136 }
1137 let mut result = String::with_capacity(s.len() + s.len() / 3);
1138 for (i, ch) in s.chars().enumerate() {
1139 if i > 0 && (s.len() - i).is_multiple_of(3) {
1140 result.push('_');
1141 }
1142 result.push(ch);
1143 }
1144 result
1145}
1146
1147fn emit_provider_docs(ctx: &CodegenCtx) -> HashMap<String, String> {
1148 let mut docs = HashMap::new();
1149
1150 for cfg in PROVIDERS {
1151 let models = &ctx.provider_models[cfg.dev_id];
1152 let mut doc = String::new();
1153
1154 pushln(&mut doc, format!("`{}` LLM provider.", cfg.display_name));
1155 blank(&mut doc);
1156
1157 pushln(&mut doc, "# Authentication");
1158 blank(&mut doc);
1159 match cfg.env_var {
1160 Some(var) => pushln(&mut doc, format!("Set the `{var}` environment variable.")),
1161 None if cfg.oauth_provider_id.is_some() => {
1162 pushln(&mut doc, "This provider uses OAuth authentication.");
1163 }
1164 None => {
1165 pushln(
1166 &mut doc,
1167 "Uses the default AWS credential chain (environment variables, config files, IAM roles).",
1168 );
1169 }
1170 }
1171 blank(&mut doc);
1172
1173 pushln(&mut doc, "# Supported models");
1174 blank(&mut doc);
1175 pushln(&mut doc, "| Model ID | Name | Context | Reasoning | Image | Audio |");
1176 pushln(&mut doc, "|----------|------|---------|-----------|-------|-------|");
1177 for model in models {
1178 let ctx_str = format_context_window(model.context_window);
1179 let reasoning = if model.reasoning_levels.is_empty() { "" } else { "yes" };
1180 let image = if model.input_modalities.contains(&"image".to_string()) { "yes" } else { "" };
1181 let audio = if model.input_modalities.contains(&"audio".to_string()) { "yes" } else { "" };
1182 pushln(
1183 &mut doc,
1184 format!(
1185 "| `{}` | `{}` | `{}` | {} | {} | {} |",
1186 model.model_id, model.display_name, ctx_str, reasoning, image, audio
1187 ),
1188 );
1189 }
1190
1191 docs.insert(cfg.dev_id.to_string(), doc);
1192 }
1193
1194 for dyn_cfg in DYNAMIC_PROVIDERS {
1195 let mut doc = String::new();
1196 pushln(&mut doc, format!("`{}` LLM provider.", dyn_cfg.display_name));
1197 blank(&mut doc);
1198 pushln(
1199 &mut doc,
1200 format!("This provider accepts any model name at runtime (e.g. `{}:my-model`).", dyn_cfg.parser_name),
1201 );
1202 pushln(&mut doc, "No API key is required.");
1203 docs.insert(dyn_cfg.parser_name.to_string(), doc);
1204 }
1205
1206 docs
1207}
1208
1209fn format_context_window(tokens: u32) -> String {
1211 if tokens == 0 {
1212 return "unknown".to_string();
1213 }
1214 if tokens >= 1_000_000 && tokens.is_multiple_of(1_000_000) {
1215 format!("{}M", tokens / 1_000_000)
1216 } else if tokens >= 1_000 && tokens.is_multiple_of(1_000) {
1217 format!("{}k", tokens / 1_000)
1218 } else {
1219 format_number(tokens)
1220 }
1221}
1222
1223fn pushln(out: &mut String, line: impl AsRef<str>) {
1224 writeln!(out, "{}", line.as_ref()).expect("writing to String should not fail");
1225}
1226
1227fn blank(out: &mut String) {
1228 pushln(out, "");
1229}
1230
1231#[cfg(test)]
1232mod tests {
1233 use super::*;
1234 use serde_json::Value;
1235 use serde_json::json;
1236 use tempfile::NamedTempFile;
1237
1238 #[test]
1241 fn model_id_to_variant_pascal_cases_segments() {
1242 assert_eq!(model_id_to_variant("claude-sonnet-4-5-20250929"), "ClaudeSonnet4520250929");
1243 assert_eq!(model_id_to_variant("gemini-2.5-flash"), "Gemini25Flash");
1244 assert_eq!(model_id_to_variant("deepseek-chat"), "DeepseekChat");
1245 assert_eq!(model_id_to_variant("glm-4.5"), "Glm45");
1246 }
1247
1248 #[test]
1249 fn model_id_to_variant_handles_slash_and_colon() {
1250 assert_eq!(model_id_to_variant("anthropic/claude-opus-4.6"), "AnthropicClaudeOpus46");
1251 assert_eq!(model_id_to_variant("openai/gpt-5.1-codex-max"), "OpenaiGpt51CodexMax");
1252 assert_eq!(model_id_to_variant("deepseek/deepseek-r1:free"), "DeepseekDeepseekR1Free");
1253 }
1254
1255 #[test]
1256 fn is_alias_detects_latest_suffix() {
1257 assert!(is_alias("claude-sonnet-4-5-latest"));
1258 assert!(is_alias("claude-3-7-sonnet-latest"));
1259 assert!(!is_alias("claude-sonnet-4-5-20250929"));
1260 }
1261
1262 #[test]
1263 fn codex_subscription_context_window_overrides_known_codex_models() {
1264 for model_id in ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex", "gpt-5.2", "codex-auto-review"] {
1265 assert_eq!(codex_subscription_context_window(model_id, 1_050_000), 272_000);
1266 }
1267 }
1268
1269 #[test]
1270 fn codex_subscription_context_window_leaves_unknown_models_unchanged() {
1271 assert_eq!(codex_subscription_context_window("gpt-5.3-codex-spark", 128_000), 128_000);
1272 assert_eq!(codex_subscription_context_window("some-future-model", 400_000), 400_000);
1273 }
1274
1275 #[test]
1276 fn format_context_window_formats_correctly() {
1277 assert_eq!(format_context_window(1_000_000), "1M");
1278 assert_eq!(format_context_window(200_000), "200k");
1279 assert_eq!(format_context_window(8_000), "8k");
1280 assert_eq!(format_context_window(0), "unknown");
1281 }
1282
1283 #[test]
1284 fn level_str_to_variant_covers_all_reasoning_efforts() {
1285 for effort in utils::ReasoningEffort::all() {
1286 let _ = level_str_to_variant(effort.as_str());
1287 }
1288 }
1289
1290 #[test]
1291 fn build_sorts_models_and_filters_aliases_and_non_tool_call() {
1292 let mut data = minimal_models_dev_json();
1293 anthropic_models(
1294 &mut data,
1295 json!({
1296 "b-model": {"id": "b-model", "name": "B Model", "tool_call": true, "limit": {"context": 2000, "output": 0}},
1297 "a-model": {"id": "a-model", "name": "A Model", "tool_call": true, "limit": {"context": 1000, "output": 0}},
1298 "alpha-latest": {"id": "alpha-latest", "name": "Alias", "tool_call": true, "limit": {"context": 500, "output": 0}},
1299 "no-tools": {"id": "no-tools", "name": "No Tools", "tool_call": false, "limit": {"context": 500, "output": 0}}
1300 }),
1301 );
1302
1303 let models = build_from_value(&data);
1304 let ids: Vec<&str> = models["anthropic"].iter().map(|m| m.model_id.as_str()).collect();
1305 assert_eq!(ids, vec!["a-model", "b-model"]);
1306 }
1307
1308 #[test]
1309 fn build_extra_source_ids_merges_unique_models_into_provider() {
1310 let mut data = minimal_models_dev_json();
1311 zai_extra_models(
1312 &mut data,
1313 json!({
1314 "extra-model": {"id": "extra-model", "name": "Extra Model", "tool_call": true, "limit": {"context": 4000, "output": 0}}
1315 }),
1316 );
1317
1318 let models = build_from_value(&data);
1319 assert!(models["zai"].iter().any(|m| m.model_id == "extra-model"));
1320 }
1321
1322 #[test]
1323 fn build_extra_source_ids_does_not_duplicate_existing_models() {
1324 let mut data = minimal_models_dev_json();
1325 let shared = json!({
1326 "shared-model": {"id": "shared-model", "name": "Shared Model", "tool_call": true, "limit": {"context": 1000, "output": 0}}
1327 });
1328 insert_models(&mut data, "zai", shared.clone());
1329 insert_models(&mut data, "zai-coding-plan", shared);
1330
1331 let models = build_from_value(&data);
1332 let count = models["zai"].iter().filter(|m| m.model_id == "shared-model").count();
1333 assert_eq!(count, 1);
1334 }
1335
1336 #[test]
1337 fn build_derives_prompt_caching_from_cost_fields() {
1338 let mut data = minimal_models_dev_json();
1339 insert_models(
1340 &mut data,
1341 "amazon-bedrock",
1342 json!({
1343 "cached": {
1344 "id": "cached", "name": "Cached", "tool_call": true,
1345 "limit": {"context": 200_000, "output": 0},
1346 "cost": {"input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_write": 3.75}
1347 },
1348 "uncached": {
1349 "id": "uncached", "name": "Uncached", "tool_call": true,
1350 "limit": {"context": 200_000, "output": 0},
1351 "cost": {"input": 3.0, "output": 15.0}
1352 }
1353 }),
1354 );
1355
1356 let models = build_from_value(&data);
1357 let bedrock = &models["amazon-bedrock"];
1358 let cached = bedrock.iter().find(|m| m.model_id == "cached").unwrap();
1359 let uncached = bedrock.iter().find(|m| m.model_id == "uncached").unwrap();
1360 assert!(cached.supports_prompt_caching);
1361 assert!(!uncached.supports_prompt_caching);
1362 }
1363
1364 #[test]
1365 fn build_assigns_codex_four_reasoning_levels() {
1366 let mut data = minimal_models_dev_json();
1367 insert_models(
1368 &mut data,
1369 "openai",
1370 json!({
1371 "gpt-5.4-codex": {
1372 "id": "gpt-5.4-codex", "name": "GPT-5.4 Codex", "tool_call": true, "reasoning": true,
1373 "limit": {"context": 200_000, "output": 0}
1374 }
1375 }),
1376 );
1377
1378 let models = build_from_value(&data);
1379 let codex_model = models["codex"].iter().find(|m| m.model_id == "gpt-5.4-codex").unwrap();
1380 assert_eq!(codex_model.reasoning_levels, vec!["low", "medium", "high", "xhigh"]);
1381 }
1382
1383 #[test]
1384 fn build_applies_codex_subscription_context_window_override() {
1385 let mut data = minimal_models_dev_json();
1386 insert_models(
1387 &mut data,
1388 "openai",
1389 json!({
1390 "gpt-5.5": {
1391 "id": "gpt-5.5", "name": "GPT-5.5", "tool_call": true, "reasoning": true,
1392 "limit": {"context": 1_050_000, "output": 128_000}
1393 }
1394 }),
1395 );
1396
1397 let models = build_from_value(&data);
1398 let codex = models["codex"].iter().find(|m| m.model_id == "gpt-5.5").unwrap();
1399 let openai = models["openai"].iter().find(|m| m.model_id == "gpt-5.5").unwrap();
1400 assert_eq!(codex.context_window, 272_000);
1401 assert_eq!(openai.context_window, 1_050_000);
1402 }
1403
1404 #[test]
1407 fn generate_emits_provider_docs() {
1408 let mut data = minimal_models_dev_json();
1409 anthropic_models(
1410 &mut data,
1411 json!({
1412 "claude-test": {
1413 "id": "claude-test", "name": "Claude Test", "tool_call": true, "reasoning": true,
1414 "limit": {"context": 200_000, "output": 0},
1415 "modalities": {"input": ["text", "image"]}
1416 }
1417 }),
1418 );
1419
1420 let tmp = NamedTempFile::new().unwrap();
1421 std::fs::write(tmp.path(), serde_json::to_string(&data).unwrap()).unwrap();
1422 let output = generate(tmp.path()).unwrap();
1423
1424 let anthropic_doc = &output.provider_docs["anthropic"];
1425 assert!(anthropic_doc.contains("`Anthropic` LLM provider."));
1426 assert!(anthropic_doc.contains("`ANTHROPIC_API_KEY`"));
1427 assert!(anthropic_doc.contains("| `claude-test` | `Claude Test` | `200k` | yes | yes | |"));
1428
1429 let ollama_doc = &output.provider_docs["ollama"];
1430 assert!(ollama_doc.contains("`Ollama` LLM provider."));
1431 assert!(ollama_doc.contains("any model name at runtime"));
1432 }
1433
1434 fn build_from_value(data: &Value) -> ProviderModels {
1435 let parsed: ModelsDevData = serde_json::from_value(data.clone()).expect("parse fixture");
1436 build_provider_models(&parsed).expect("build provider models")
1437 }
1438
1439 fn anthropic_models(data: &mut Value, models: Value) {
1440 insert_models(data, "anthropic", models);
1441 }
1442
1443 fn zai_extra_models(data: &mut Value, models: Value) {
1444 insert_models(data, "zai-coding-plan", models);
1445 }
1446
1447 fn insert_models(data: &mut Value, provider_key: &str, models: Value) {
1448 let provider = data.as_object_mut().unwrap().get_mut(provider_key).unwrap().as_object_mut().unwrap();
1449 provider.insert("models".to_string(), models);
1450 }
1451
1452 fn minimal_models_dev_json() -> Value {
1453 let mut root = serde_json::Map::new();
1454 for cfg in PROVIDERS {
1455 let json_key = cfg.json_key();
1456 root.entry(json_key.to_string())
1457 .or_insert_with(|| json!({"id": json_key, "name": json_key, "env": [], "models": {}}));
1458 for &extra in cfg.extra_source_ids {
1459 root.entry(extra.to_string())
1460 .or_insert_with(|| json!({"id": extra, "name": extra, "env": [], "models": {}}));
1461 }
1462 }
1463 Value::Object(root)
1464 }
1465}