1use crate::ProviderRegistry;
9use std::sync::Arc;
10
11#[derive(Clone)]
18pub struct ProviderCreds {
19 pub name: String,
22 pub api_key: Option<String>,
24 pub base_url: Option<String>,
26 pub model_capabilities: std::collections::HashMap<String, leviath_providers::ModelCapabilities>,
28 pub request_timeout_secs: Option<u64>,
30 pub rate_limit: Option<leviath_providers::RateLimitConfig>,
34 pub options: std::collections::HashMap<String, String>,
41}
42
43impl std::fmt::Debug for ProviderCreds {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 f.debug_struct("ProviderCreds")
51 .field("name", &self.name)
52 .field(
53 "api_key",
54 match self.api_key {
55 Some(_) => &"<set>",
56 None => &"<unset>",
57 },
58 )
59 .field("base_url", &self.base_url)
60 .field("model_capabilities", &self.model_capabilities)
61 .field("request_timeout_secs", &self.request_timeout_secs)
62 .field("rate_limit", &self.rate_limit)
63 .field("options", &self.options)
64 .finish()
65 }
66}
67
68impl ProviderCreds {
69 pub fn simple(name: impl Into<String>) -> Self {
71 Self {
72 name: name.into(),
73 api_key: None,
74 base_url: None,
75 model_capabilities: std::collections::HashMap::new(),
76 request_timeout_secs: None,
77 rate_limit: None,
78 options: std::collections::HashMap::new(),
79 }
80 }
81}
82
83pub fn build_provider_registry(creds: &[ProviderCreds]) -> ProviderRegistry {
85 let mut registry = ProviderRegistry::new();
86
87 for c in creds {
88 let caps = c.model_capabilities.clone();
89 let timeout = c.request_timeout_secs;
90 match c.name.as_str() {
91 "anthropic" => {
92 if let Some(ref key) = c.api_key {
93 registry.register(
94 "anthropic".to_string(),
95 Arc::new(leviath_providers::AnthropicProvider::with_overrides(
96 key.clone(),
97 caps,
98 timeout,
99 c.rate_limit.as_ref(),
100 )),
101 );
102 }
103 }
104 "openai" => {
105 if let Some(ref key) = c.api_key {
106 registry.register(
107 "openai".to_string(),
108 Arc::new(leviath_providers::OpenAIProvider::with_overrides(
109 key.clone(),
110 caps,
111 timeout,
112 c.rate_limit.as_ref(),
113 )),
114 );
115 }
116 }
117 "google" => {
118 if let Some(ref key) = c.api_key {
119 registry.register(
120 "google".to_string(),
121 Arc::new(leviath_providers::GeminiProvider::with_overrides(
122 key.clone(),
123 caps,
124 timeout,
125 c.rate_limit.as_ref(),
126 )),
127 );
128 }
129 }
130 "openrouter" => {
131 if let Some(ref key) = c.api_key {
132 registry.register(
133 "openrouter".to_string(),
134 Arc::new(leviath_providers::OpenRouterProvider::with_overrides(
135 key.clone(),
136 caps,
137 timeout,
138 c.rate_limit.as_ref(),
139 )),
140 );
141 }
142 }
143 "ollama" => {
144 let url = c
145 .base_url
146 .clone()
147 .unwrap_or_else(|| "http://localhost:11434".to_string());
148 registry.register(
149 "ollama".to_string(),
150 Arc::new(leviath_providers::OllamaProvider::with_overrides(
151 url, caps, timeout,
152 )),
153 );
154 }
155 "claude-code" => {
156 let binary = c
160 .options
161 .get("binary")
162 .cloned()
163 .unwrap_or_else(|| "claude".to_string());
164 registry.register(
165 "claude-code".to_string(),
166 Arc::new(leviath_providers::ClaudeCodeProvider::with_overrides(
167 binary,
168 c.options.get("effort").cloned(),
169 Some(caps),
170 )),
171 );
172 }
173 _ => {}
174 }
175 }
176
177 registry
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
187 fn debug_output_never_contains_the_api_key() {
188 let mut creds = ProviderCreds::simple("anthropic");
189 creds.api_key = Some("sk-ant-SECRET-VALUE".to_string());
190 creds.base_url = Some("https://api.example.com".to_string());
191
192 let rendered = format!("{creds:?}");
193 assert!(!rendered.contains("SECRET-VALUE"), "key leaked: {rendered}");
194 assert!(rendered.contains("<set>"), "{rendered}");
195 assert!(rendered.contains("anthropic"), "{rendered}");
197 assert!(rendered.contains("api.example.com"), "{rendered}");
198
199 let keyless = format!("{:?}", ProviderCreds::simple("ollama"));
201 assert!(keyless.contains("<unset>"), "{keyless}");
202 }
203
204 #[test]
205 fn build_provider_registry_from_creds_slice() {
206 let caps = std::collections::HashMap::new();
210 let creds = vec![
211 ProviderCreds {
212 name: "anthropic".to_string(),
213 api_key: Some("sk-ant".to_string()),
214 base_url: None,
215 model_capabilities: caps.clone(),
216 request_timeout_secs: Some(30),
217 rate_limit: None,
218 options: Default::default(),
219 },
220 ProviderCreds {
221 name: "openai".to_string(),
222 api_key: Some("sk-oa".to_string()),
223 base_url: None,
224 model_capabilities: caps.clone(),
225 request_timeout_secs: None,
226 rate_limit: None,
227 options: Default::default(),
228 },
229 ProviderCreds {
230 name: "google".to_string(),
231 api_key: Some("AIza".to_string()),
232 base_url: None,
233 model_capabilities: caps.clone(),
234 request_timeout_secs: None,
235 rate_limit: None,
236 options: Default::default(),
237 },
238 ProviderCreds {
239 name: "openrouter".to_string(),
240 api_key: Some("sk-or".to_string()),
241 base_url: None,
242 model_capabilities: caps.clone(),
243 request_timeout_secs: None,
244 rate_limit: None,
245 options: Default::default(),
246 },
247 ProviderCreds {
248 name: "ollama".to_string(),
249 api_key: None,
250 base_url: None, model_capabilities: caps.clone(),
252 request_timeout_secs: None,
253 rate_limit: None,
254 options: Default::default(),
255 },
256 ProviderCreds {
257 name: "claude-code".to_string(),
258 api_key: None,
259 base_url: None,
260 model_capabilities: caps.clone(),
261 request_timeout_secs: None,
262 rate_limit: None,
263 options: Default::default(),
264 },
265 ProviderCreds {
266 name: "totally-unknown".to_string(),
267 api_key: Some("x".to_string()),
268 base_url: None,
269 model_capabilities: caps,
270 request_timeout_secs: None,
271 rate_limit: None,
272 options: Default::default(),
273 },
274 ];
275 let registry = build_provider_registry(&creds);
276 assert!(registry.has("anthropic"));
277 assert!(registry.has("openai"));
278 assert!(registry.has("google"));
279 assert!(registry.has("openrouter"));
280 assert!(registry.has("ollama"));
281 assert!(registry.has("claude-code"));
282 assert!(!registry.has("totally-unknown"));
283 }
284
285 #[test]
286 fn build_provider_registry_skips_keyed_providers_without_api_key() {
287 let caps = std::collections::HashMap::new();
291 let creds: Vec<ProviderCreds> = ["anthropic", "openai", "google", "openrouter"]
292 .into_iter()
293 .map(|name| ProviderCreds {
294 name: name.to_string(),
295 api_key: None,
296 base_url: None,
297 model_capabilities: caps.clone(),
298 request_timeout_secs: None,
299 rate_limit: None,
300 options: Default::default(),
301 })
302 .collect();
303 let registry = build_provider_registry(&creds);
304 assert!(!registry.has("anthropic"));
305 assert!(!registry.has("openai"));
306 assert!(!registry.has("google"));
307 assert!(!registry.has("openrouter"));
308 }
309
310 #[test]
311 fn claude_code_reads_its_binary_and_effort_options() {
312 let mut creds = ProviderCreds::simple("claude-code");
316 creds
317 .options
318 .insert("binary".to_string(), "/opt/bin/claude".to_string());
319 creds
320 .options
321 .insert("effort".to_string(), "low".to_string());
322 let registry = build_provider_registry(std::slice::from_ref(&creds));
323 assert!(registry.has("claude-code"));
324
325 creds
328 .options
329 .insert("effort".to_string(), "warp-speed".to_string());
330 assert!(build_provider_registry(&[creds]).has("claude-code"));
331 }
332
333 #[test]
334 fn provider_creds_simple_has_no_key_or_options() {
335 let creds = ProviderCreds::simple("ollama");
336 assert_eq!(creds.name, "ollama");
337 assert!(creds.api_key.is_none());
338 assert!(creds.base_url.is_none());
339 assert!(creds.options.is_empty());
340 assert!(creds.model_capabilities.is_empty());
341 assert!(creds.request_timeout_secs.is_none());
342 }
343}