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