Skip to main content

llm/catalog/
mod.rs

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
13/// Returns models whose provider env var is set
14pub 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
22/// Returns available catalog models plus any locally discovered models.
23pub 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}