1use std::collections::BTreeMap;
15
16use anyhow::{bail, Context, Result};
17
18use crate::config::{Config, ProviderEntry};
19use crate::llm_provider::GenAIProvider;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct ProviderCaps {
24 pub tool_calling: bool,
25 pub strict_schema: bool,
26 pub parallel_tool_calls: bool,
27 pub streaming_tool_calls: bool,
28 pub prompt_caching: bool,
29 pub structured_output: bool,
30 pub max_context_tokens: u32,
31 pub probed: bool,
35}
36
37pub struct ProviderHandle {
40 pub id: String,
42 pub adapter: String,
44 pub provider: GenAIProvider,
47 pub caps: ProviderCaps,
49}
50
51impl std::fmt::Debug for ProviderHandle {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 f.debug_struct("ProviderHandle")
54 .field("id", &self.id)
55 .field("adapter", &self.adapter)
56 .field("caps", &self.caps)
57 .finish()
58 }
59}
60
61#[derive(Debug, Default)]
63pub struct ModelPortfolio {
64 handles: BTreeMap<String, ProviderHandle>,
65}
66
67impl ModelPortfolio {
68 pub fn from_config(config: &Config) -> Result<Self> {
77 let mut handles = BTreeMap::new();
78 if config.providers.is_empty() {
79 let (provider, resolved) = GenAIProvider::from_config(config, None)?;
80 let adapter = resolved.provider.clone();
81 handles.insert(
82 adapter.clone(),
83 ProviderHandle {
84 id: adapter.clone(),
85 caps: declared_caps(&adapter),
86 adapter,
87 provider,
88 },
89 );
90 return Ok(Self { handles });
91 }
92
93 for (id, entry) in &config.providers {
94 let handle = Self::build_handle(id, entry, config)
95 .with_context(|| format!("building provider handle {id:?}"))?;
96 handles.insert(id.clone(), handle);
97 }
98 Ok(Self { handles })
99 }
100
101 fn build_handle(id: &str, entry: &ProviderEntry, config: &Config) -> Result<ProviderHandle> {
103 let adapter = entry.adapter_or(id).to_string();
104
105 let api_key = match &entry.api_key_env {
109 Some(var) => std::env::var(var).ok(),
110 None => entry.api_key.clone(),
111 };
112
113 if let Some(base_url) = entry.base_url.as_deref() {
114 if let Some(env_var) = crate::llm_provider::provider_base_url_env_var(&adapter) {
115 if std::env::var(env_var).is_err() {
116 std::env::set_var(env_var, base_url);
117 }
118 }
119 }
120 if adapter.eq_ignore_ascii_case("vertex") {
121 let vertex_config = Config {
123 vertex_project_id: entry
124 .project_id
125 .clone()
126 .or(config.vertex_project_id.clone()),
127 vertex_location: entry.location.clone().or(config.vertex_location.clone()),
128 ..Config::default()
129 };
130 crate::llm_provider::configure_vertex_environment(&vertex_config);
131 }
132
133 let provider = GenAIProvider::new_with_config(Some(&adapter), api_key.as_deref())?;
134 Ok(ProviderHandle {
135 id: id.to_string(),
136 caps: declared_caps(&adapter),
137 adapter,
138 provider,
139 })
140 }
141
142 pub fn resolve(&self, provider: &str) -> Result<&ProviderHandle> {
144 match self.handles.get(provider) {
145 Some(handle) => Ok(handle),
146 None => bail!(
147 "unknown provider {provider:?}; configured providers: [{}]",
148 self.provider_ids().join(", ")
149 ),
150 }
151 }
152
153 pub fn provider_ids(&self) -> Vec<String> {
155 self.handles.keys().cloned().collect()
156 }
157
158 pub fn handles(&self) -> impl Iterator<Item = &ProviderHandle> {
160 self.handles.values()
161 }
162
163 pub fn len(&self) -> usize {
165 self.handles.len()
166 }
167
168 pub fn is_empty(&self) -> bool {
170 self.handles.is_empty()
171 }
172}
173
174pub fn declared_caps(adapter: &str) -> ProviderCaps {
180 let full = ProviderCaps {
181 tool_calling: true,
182 strict_schema: false,
183 parallel_tool_calls: true,
184 streaming_tool_calls: true,
185 prompt_caching: false,
186 structured_output: true,
187 max_context_tokens: 128_000,
188 probed: false,
189 };
190 match adapter.to_ascii_lowercase().as_str() {
191 "openai" => ProviderCaps {
192 strict_schema: true,
193 prompt_caching: true,
194 ..full
195 },
196 "anthropic" => ProviderCaps {
197 prompt_caching: true,
198 max_context_tokens: 200_000,
199 ..full
200 },
201 "gemini" | "vertex" => ProviderCaps {
202 prompt_caching: true,
203 max_context_tokens: 1_000_000,
204 ..full
205 },
206 "groq" | "xai" | "deepseek" | "cohere" => full,
207 "ollama" => ProviderCaps {
208 parallel_tool_calls: false,
210 streaming_tool_calls: false,
211 max_context_tokens: 32_000,
212 ..full
213 },
214 _ => ProviderCaps {
215 tool_calling: false,
218 parallel_tool_calls: false,
219 streaming_tool_calls: false,
220 structured_output: false,
221 max_context_tokens: 32_000,
222 ..full
223 },
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 fn table_config(toml: &str) -> Config {
232 Config::from_toml_str(toml).unwrap()
233 }
234
235 #[test]
236 fn builds_one_handle_per_provider_entry() {
237 let config = table_config(
238 r#"
239 [providers.local]
240 adapter = "ollama"
241 base_url = "http://localhost:11434"
242
243 [providers.openai]
244 api_key = "sk-test"
245
246 [providers.groq]
247 api_key = "gsk-test"
248 "#,
249 );
250 let portfolio = ModelPortfolio::from_config(&config).unwrap();
251 assert_eq!(portfolio.len(), 3);
252 assert_eq!(portfolio.provider_ids(), ["groq", "local", "openai"]);
253 assert_eq!(portfolio.resolve("local").unwrap().adapter, "ollama");
254 assert!(portfolio.resolve("nope").is_err());
255 }
256
257 #[test]
258 fn one_entry_portfolio_from_flat_config_still_works() {
259 let config = table_config("provider = \"ollama\"\nmodel = \"qwen2.5-coder\"\n");
260 let portfolio = ModelPortfolio::from_config(&config).unwrap();
261 assert_eq!(portfolio.len(), 1);
262 assert_eq!(portfolio.provider_ids(), ["ollama"]);
263 }
264
265 #[test]
266 fn unknown_adapter_declares_text_only_not_a_guess() {
267 let caps = declared_caps("mystery-gateway");
268 assert!(!caps.tool_calling);
269 assert!(!caps.probed);
270 }
271
272 #[test]
273 fn declared_caps_are_marked_unprobed() {
274 for adapter in ["openai", "anthropic", "gemini", "ollama", "groq"] {
275 assert!(!declared_caps(adapter).probed, "{adapter}");
276 assert!(declared_caps(adapter).tool_calling, "{adapter}");
277 }
278 }
279}