1#![doc = include_str!("../docs/catalog.md")]
2
3use crate::providers::local::discovery::discover_local_models;
4
5mod bedrock;
6mod model_spec;
7
8pub use bedrock::BedrockModel;
9pub use model_spec::{ModelSpec, ModelSpecError, ReasoningEffortError, validate_reasoning_effort};
10
11include!(concat!(env!("OUT_DIR"), "/generated.rs"));
12
13pub fn available_models() -> Vec<LlmModel> {
15 LlmModel::all()
16 .iter()
17 .filter(|m| m.required_env_var().is_none_or(|var| std::env::var(var).is_ok()))
18 .cloned()
19 .collect()
20}
21
22pub async fn get_local_models() -> Vec<LlmModel> {
24 let mut models = available_models();
25 let local = discover_local_models().await;
26 models.extend(local);
27 models
28}
29
30#[cfg(test)]
31mod tests {
32 use super::*;
33
34 #[test]
35 fn display_fromstr_roundtrip_all_catalog_models() {
36 for model in LlmModel::all() {
37 let s = model.to_string();
38 let parsed: LlmModel = s.parse().unwrap_or_else(|e| panic!("Failed to parse '{s}' back to LlmModel: {e}"));
39 assert_eq!(&parsed, model, "roundtrip failed for '{s}'");
40 }
41 }
42
43 #[test]
44 fn display_fromstr_roundtrip_dynamic_providers() {
45 let cases = [LlmModel::Ollama("llama3.2".to_string()), LlmModel::LlamaCpp("my-model".to_string())];
46 for model in &cases {
47 let s = model.to_string();
48 let parsed: LlmModel = s.parse().unwrap();
49 assert_eq!(&parsed, model);
50 }
51 }
52
53 #[test]
54 fn codex_gpt56_subscription_models_have_expected_metadata() {
55 let expected_levels = &[
56 ReasoningEffort::Low,
57 ReasoningEffort::Medium,
58 ReasoningEffort::High,
59 ReasoningEffort::Xhigh,
60 ReasoningEffort::Max,
61 ];
62 for id in ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] {
63 let model: LlmModel = format!("codex:{id}").parse().unwrap();
64 assert_eq!(model.model_id(), id);
65 assert_eq!(model.context_window(), Some(372_000));
66 assert_eq!(model.reasoning_levels(), expected_levels);
67 }
68
69 assert!("codex:gpt-5.6".parse::<LlmModel>().is_err());
70 assert!("codex:gpt-5.1-codex".parse::<LlmModel>().is_err());
71 assert!("openai:gpt-5.6".parse::<LlmModel>().is_ok());
72 }
73
74 #[test]
75 fn codex_gpt55_uses_subscription_context_window() {
76 let model: LlmModel = "codex:gpt-5.5".parse().unwrap();
77 assert_eq!(model.context_window(), Some(272_000));
78 }
79
80 #[test]
81 fn openai_gpt55_keeps_api_context_window() {
82 let model: LlmModel = "openai:gpt-5.5".parse().unwrap();
83 assert_eq!(model.context_window(), Some(1_050_000));
84 }
85
86 #[test]
87 fn bedrock_foundation_model_parses() {
88 let model: LlmModel = "bedrock:anthropic.claude-sonnet-4-5-20250929-v1:0".parse().unwrap();
89
90 assert_eq!(model.to_string(), "bedrock:anthropic.claude-sonnet-4-5-20250929-v1:0");
91 assert_eq!(model.context_window(), Some(200_000));
92 }
93
94 #[test]
95 fn bedrock_prompt_caching_support_comes_from_catalog() {
96 let claude: LlmModel = "bedrock:anthropic.claude-sonnet-4-5-20250929-v1:0".parse().unwrap();
97 let nova: LlmModel = "bedrock:amazon.nova-lite-v1:0".parse().unwrap();
98 let profile: LlmModel = "bedrock:us.anthropic.claude-future-model-v99:0".parse().unwrap();
99
100 assert!(claude.supports_prompt_caching());
101 assert!(nova.supports_prompt_caching());
102 assert!(!profile.supports_prompt_caching());
103 }
104
105 #[test]
106 fn provider_display_name_returns_human_readable() {
107 let anthropic: LlmModel = "anthropic:claude-opus-4-6".parse().unwrap();
108 assert_eq!(anthropic.provider_display_name(), "Anthropic");
109
110 let bedrock: LlmModel = "bedrock:anthropic.claude-haiku-4-5-20251001-v1:0".parse().unwrap();
111 assert_eq!(bedrock.provider_display_name(), "AWS Bedrock");
112
113 let zai: LlmModel = "zai:glm-4.5".parse().unwrap();
114 assert_eq!(zai.provider_display_name(), "ZAI");
115
116 let ollama = LlmModel::Ollama("llama3.2".to_string());
117 assert_eq!(ollama.provider_display_name(), "Ollama");
118 }
119
120 #[test]
121 fn oauth_provider_id_is_codex_only_for_codex_provider() {
122 let codex: LlmModel = "codex:gpt-5.5".parse().unwrap();
123 assert_eq!(codex.oauth_provider_id(), Some("codex"));
124
125 for non_oauth in
126 ["anthropic:claude-opus-4-6", "openai:gpt-5.5", "bedrock:anthropic.claude-sonnet-4-5-20250929-v1:0"]
127 {
128 let model: LlmModel = non_oauth.parse().unwrap();
129 assert_eq!(model.oauth_provider_id(), None, "{non_oauth} should not have OAuth");
130 }
131 assert_eq!(LlmModel::Ollama("foo".into()).oauth_provider_id(), None);
132 }
133
134 #[test]
135 fn required_env_var_matches_provider() {
136 let cases = [
137 ("anthropic:claude-opus-4-6", Some("ANTHROPIC_API_KEY")),
138 ("openai:gpt-5.5", Some("OPENAI_API_KEY")),
139 ("deepseek:deepseek-chat", Some("DEEPSEEK_API_KEY")),
140 ("gemini:gemini-2.5-pro", Some("GEMINI_API_KEY")),
141 ("openrouter:anthropic/claude-opus-4.6", Some("OPENROUTER_API_KEY")),
142 ("zai:glm-4.5", Some("ZAI_API_KEY")),
143 ("codex:gpt-5.5", None),
144 ("bedrock:anthropic.claude-sonnet-4-5-20250929-v1:0", None),
145 ];
146 for (input, expected) in cases {
147 let model: LlmModel = input.parse().unwrap();
148 assert_eq!(model.required_env_var(), expected, "{input}");
149 }
150 assert_eq!(LlmModel::Ollama("foo".into()).required_env_var(), None);
151 assert_eq!(LlmModel::LlamaCpp("foo".into()).required_env_var(), None);
152 }
153
154 #[test]
155 fn codex_reasoning_models_include_xhigh_level() {
156 let codex: LlmModel = "codex:gpt-5.5".parse().unwrap();
157 assert_eq!(
158 codex.reasoning_levels(),
159 &[ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, ReasoningEffort::Xhigh]
160 );
161 assert!(codex.supports_reasoning());
162 }
163
164 #[test]
165 fn anthropic_reasoning_models_use_catalog_levels() {
166 let claude: LlmModel = "anthropic:claude-opus-4-6".parse().unwrap();
167 assert_eq!(
168 claude.reasoning_levels(),
169 &[ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, ReasoningEffort::Max,]
170 );
171 }
172
173 #[test]
174 fn dynamic_provider_models_have_no_reasoning_levels() {
175 assert!(LlmModel::Ollama("llama3.2".into()).reasoning_levels().is_empty());
176 assert!(!LlmModel::Ollama("llama3.2".into()).supports_reasoning());
177 }
178
179 #[test]
180 fn supports_reasoning_matches_reasoning_levels_emptiness() {
181 for model in LlmModel::all() {
182 assert_eq!(model.supports_reasoning(), !model.reasoning_levels().is_empty(), "{model}");
183 }
184 }
185
186 #[test]
187 fn dynamic_providers_have_no_context_window() {
188 assert_eq!(LlmModel::Ollama("foo".into()).context_window(), None);
189 assert_eq!(LlmModel::LlamaCpp("foo".into()).context_window(), None);
190 }
191
192 #[test]
193 fn bedrock_profile_fallback_parses_arbitrary_id() {
194 let profile: LlmModel = "bedrock:us.anthropic.future-model-v99:0".parse().unwrap();
195 assert_eq!(profile.context_window(), None);
196 assert!(profile.reasoning_levels().is_empty());
197 assert!(!profile.supports_prompt_caching());
198 }
199}