1#[cfg(feature = "provider-openai")]
17use std::collections::{BTreeMap, HashMap};
18use std::sync::Arc;
19
20use defect_acp::EchoProvider;
21use defect_agent::llm::{
22 LlmProvider, ModelCapabilityOverrides, ModelInfo, ProviderEntry, ProviderRegistry,
23};
24use defect_agent::session::{SessionCapabilitiesConfig, TurnConfig};
25use defect_config::{
26 LoadedConfig, ProviderConfigFile, ProviderConfigs, ProviderKind as ConfigProviderKind,
27 ProviderProtocol,
28};
29#[cfg(any(feature = "provider-openai", feature = "provider-deepseek"))]
31use defect_agent::llm::ReasoningEffort as LlmReasoningEffort;
32#[cfg(any(feature = "provider-openai", feature = "provider-deepseek"))]
33use defect_config::ReasoningEffort as ConfigReasoningEffort;
34#[cfg(feature = "provider-anthropic")]
35use defect_llm::provider::anthropic::{AnthropicConfig, AnthropicProvider};
36#[cfg(feature = "provider-bedrock")]
37use defect_llm::provider::bedrock::{BedrockConfig, BedrockProvider};
38#[cfg(feature = "provider-deepseek")]
39use defect_llm::provider::deepseek::{DeepSeekConfig, DeepSeekProvider};
40#[cfg(feature = "provider-openai")]
41use defect_llm::provider::openai::{OpenAiConfig, OpenAiProvider};
42#[cfg(feature = "provider-openai")]
43use http::{HeaderName, HeaderValue};
44
45use crate::http_stack::build_http_stack_config;
46
47pub(crate) const BEDROCK_PROVIDER: &str = "bedrock";
48#[cfg(feature = "provider-openai")]
51pub(crate) const LITELLM_API_KEY_ENV: &str = "LITELLM_API_KEY";
52#[cfg(feature = "provider-openai")]
53pub(crate) const LITELLM_DEFAULT_BASE_URL: &str = "http://localhost:4000/v1";
54#[cfg(feature = "provider-openai")]
55const CUSTOM_OPENAI_DISPLAY_NAME: &str = "Custom OpenAI-compatible";
56#[cfg(feature = "provider-bedrock")]
57const CUSTOM_BEDROCK_DISPLAY_NAME: &str = "Amazon Bedrock";
58#[cfg(feature = "provider-openai")]
59const LITELLM_DISPLAY_NAME: &str = "LiteLLM Gateway";
60
61pub async fn build_registry(
69 config: &LoadedConfig,
70) -> anyhow::Result<(Arc<ProviderRegistry>, TurnConfig)> {
71 let http_config = build_http_stack_config(&config.effective.http)?;
72 let entries = build_provider_entries(config, http_config).await?;
73 let turn_config = config.effective.turn.clone();
74 let registry = ProviderRegistry::new(entries, &turn_config.provider, &turn_config.model)
75 .map_err(|e| anyhow::anyhow!("provider registry init failed: {e}"))?;
76 Ok((Arc::new(registry), turn_config))
77}
78
79pub async fn build_provider_entries(
83 config: &LoadedConfig,
84 http_config: defect_http::HttpStackConfig,
85) -> anyhow::Result<Vec<ProviderEntry>> {
86 let default_kind = config.effective.cli.provider.clone();
87 let default_provider =
88 build_single_llm_provider(&default_kind, config, http_config.clone()).await?;
89 let mut entries = vec![ProviderEntry::new(
90 default_provider,
91 entry_models(
92 provider_config_for_kind(&config.effective.providers, &default_kind),
93 Some(config.effective.turn.model.as_str()),
94 ),
95 provider_session_capabilities(config, &default_kind),
96 )];
97
98 for provider_kind in configured_entry_kinds(config) {
99 if provider_kind == default_kind {
100 continue;
101 }
102 let models = entry_models(
103 provider_config_for_kind(&config.effective.providers, &provider_kind),
104 None,
105 );
106 if models.is_empty() {
107 continue;
108 }
109 let provider =
110 build_single_llm_provider(&provider_kind, config, http_config.clone()).await?;
111 entries.push(ProviderEntry::new(
112 provider,
113 models,
114 provider_session_capabilities(config, &provider_kind),
115 ));
116 }
117
118 Ok(entries)
119}
120
121#[cfg_attr(
130 not(any(
131 feature = "provider-anthropic",
132 feature = "provider-openai",
133 feature = "provider-deepseek"
134 )),
135 allow(unused_variables)
136)]
137pub async fn build_single_llm_provider(
138 provider_kind: &ConfigProviderKind,
139 config: &LoadedConfig,
140 http_config: defect_http::HttpStackConfig,
141) -> anyhow::Result<Arc<dyn LlmProvider>> {
142 match provider_kind {
143 ConfigProviderKind::Defect => Ok(Arc::new(EchoProvider::new()) as Arc<dyn LlmProvider>),
144 #[cfg(feature = "provider-anthropic")]
145 ConfigProviderKind::Anthropic => Ok(Arc::new(
146 AnthropicProvider::new(AnthropicConfig {
147 api_key: None,
148 api_key_env: config.effective.providers.anthropic.api_key_env.clone(),
149 base_url: config.effective.providers.anthropic.base_url.clone(),
150 http: http_config,
151 })
152 .map_err(|e| anyhow::anyhow!("anthropic provider init failed: {e}"))?,
153 ) as Arc<dyn LlmProvider>),
154 #[cfg(feature = "provider-openai")]
155 ConfigProviderKind::Openai => build_openai_provider(
156 "openai",
157 "OpenAI Chat Completions",
158 config.effective.providers.openai.clone(),
159 http_config,
160 ),
161 #[cfg(feature = "provider-deepseek")]
162 ConfigProviderKind::Deepseek => Ok(Arc::new(
163 DeepSeekProvider::new(DeepSeekConfig {
164 api_key: None,
165 api_key_env: config.effective.providers.deepseek.api_key_env.clone(),
166 base_url: config.effective.providers.deepseek.base_url.clone(),
167 reasoning_effort: config
168 .effective
169 .providers
170 .deepseek
171 .reasoning_effort
172 .map(map_reasoning_effort),
173 http: http_config,
174 })
175 .map_err(|e| anyhow::anyhow!("deepseek provider init failed: {e}"))?,
176 ) as Arc<dyn LlmProvider>),
177 #[cfg(feature = "provider-openai")]
180 ConfigProviderKind::Litellm => {
181 build_litellm_provider(config.effective.providers.litellm.clone(), http_config)
182 }
183 #[cfg(not(feature = "provider-anthropic"))]
188 ConfigProviderKind::Anthropic => Err(provider_not_compiled("anthropic")),
189 #[cfg(not(feature = "provider-openai"))]
190 ConfigProviderKind::Openai => Err(provider_not_compiled("openai")),
191 #[cfg(not(feature = "provider-deepseek"))]
192 ConfigProviderKind::Deepseek => Err(provider_not_compiled("deepseek")),
193 #[cfg(not(feature = "provider-openai"))]
194 ConfigProviderKind::Litellm => Err(provider_not_compiled("openai")),
195 ConfigProviderKind::Custom(name) => {
196 let Some(provider) = config
197 .effective
198 .providers
199 .get(&ConfigProviderKind::Custom(name.clone()))
200 else {
201 return Err(anyhow::anyhow!("missing [providers.{name}] configuration"));
202 };
203 let protocol = provider.protocol.unwrap_or_else(|| {
210 if name == BEDROCK_PROVIDER || provider.aws.is_some() {
211 ProviderProtocol::AnthropicMessages
212 } else {
213 ProviderProtocol::OpenaiChat
214 }
215 });
216 match protocol {
217 #[cfg(feature = "provider-openai")]
218 ProviderProtocol::OpenaiChat => build_openai_provider(
219 name,
220 provider
221 .display_name
222 .as_deref()
223 .unwrap_or(CUSTOM_OPENAI_DISPLAY_NAME),
224 provider.clone(),
225 http_config,
226 ),
227 #[cfg(not(feature = "provider-openai"))]
228 ProviderProtocol::OpenaiChat => Err(provider_not_compiled("openai")),
229 ProviderProtocol::AnthropicMessages => {
230 if name == BEDROCK_PROVIDER || provider.aws.is_some() {
231 #[cfg(feature = "provider-bedrock")]
232 {
233 build_bedrock_provider(name, provider.clone()).await
234 }
235 #[cfg(not(feature = "provider-bedrock"))]
236 {
237 Err(provider_not_compiled("bedrock"))
238 }
239 } else {
240 Err(anyhow::anyhow!(
241 "custom provider `{name}` uses protocol `anthropic-messages`, \
242 but only AWS Bedrock transport is implemented for custom providers"
243 ))
244 }
245 }
246 }
247 }
248 }
249}
250
251#[cfg(not(all(
258 feature = "provider-anthropic",
259 feature = "provider-bedrock",
260 feature = "provider-openai",
261 feature = "provider-deepseek"
262)))]
263fn provider_not_compiled(feature_suffix: &str) -> anyhow::Error {
264 anyhow::anyhow!(
265 "provider was selected but not compiled into this build; \
266 rebuild with `--features provider-{feature_suffix}` \
267 (or use the default feature set)"
268 )
269}
270
271fn provider_session_capabilities(
278 config: &LoadedConfig,
279 provider: &ConfigProviderKind,
280) -> SessionCapabilitiesConfig {
281 match provider {
282 ConfigProviderKind::Anthropic => config
283 .effective
284 .providers
285 .anthropic
286 .capabilities
287 .merge_into(config.effective.capabilities),
288 ConfigProviderKind::Openai => config
289 .effective
290 .providers
291 .openai
292 .capabilities
293 .merge_into(config.effective.capabilities),
294 ConfigProviderKind::Deepseek => config
295 .effective
296 .providers
297 .deepseek
298 .capabilities
299 .merge_into(config.effective.capabilities),
300 ConfigProviderKind::Litellm => config
301 .effective
302 .providers
303 .litellm
304 .capabilities
305 .merge_into(config.effective.capabilities),
306 ConfigProviderKind::Defect => config.effective.capabilities,
307 ConfigProviderKind::Custom(name) => config
308 .effective
309 .providers
310 .get(&ConfigProviderKind::Custom(name.clone()))
311 .map(|provider| {
312 provider
313 .capabilities
314 .merge_into(config.effective.capabilities)
315 })
316 .unwrap_or(config.effective.capabilities),
317 }
318 .to_session_capabilities()
319}
320
321fn configured_entry_kinds(config: &LoadedConfig) -> Vec<ConfigProviderKind> {
322 let mut kinds = vec![
323 ConfigProviderKind::Anthropic,
324 ConfigProviderKind::Openai,
325 ConfigProviderKind::Deepseek,
326 ConfigProviderKind::Litellm,
327 ];
328 kinds.extend(
329 config
330 .effective
331 .providers
332 .custom
333 .keys()
334 .cloned()
335 .map(ConfigProviderKind::Custom),
336 );
337 kinds
338}
339
340fn provider_config_for_kind<'a>(
341 providers: &'a ProviderConfigs,
342 kind: &ConfigProviderKind,
343) -> Option<&'a ProviderConfigFile> {
344 providers.get(kind)
345}
346
347fn entry_models(
348 provider: Option<&ProviderConfigFile>,
349 fallback_model: Option<&str>,
350) -> Vec<ModelInfo> {
351 let mut models: Vec<ModelInfo> = Vec::new();
352 if let Some(provider) = provider {
353 if let Some(default_model) = &provider.default_model {
355 push_unique_model(&mut models, default_model, None);
356 }
357 if let Some(entries) = &provider.models {
358 for entry in entries {
359 push_unique_model(&mut models, entry.id(), entry.name());
360 }
361 }
362 }
363 if models.is_empty()
364 && let Some(fallback_model) = fallback_model
365 {
366 push_unique_model(&mut models, fallback_model, None);
367 }
368 models
369}
370
371fn push_unique_model(models: &mut Vec<ModelInfo>, id: &str, name: Option<&str>) {
376 if let Some(existing) = models.iter_mut().find(|m| m.id == id) {
377 if existing.display_name.is_none() {
378 existing.display_name = name.map(str::to_string);
379 }
380 return;
381 }
382 models.push(ModelInfo {
383 id: id.to_string(),
384 display_name: name.map(str::to_string),
385 context_window: None,
386 max_output_tokens: None,
387 deprecated: false,
388 capabilities_overrides: ModelCapabilityOverrides::default(),
389 });
390}
391
392#[cfg(feature = "provider-openai")]
393fn build_litellm_provider(
394 provider: ProviderConfigFile,
395 http_config: defect_http::HttpStackConfig,
396) -> anyhow::Result<Arc<dyn LlmProvider>> {
397 let provider = ProviderDefaults {
398 base_url: LITELLM_DEFAULT_BASE_URL,
399 api_key_env: LITELLM_API_KEY_ENV,
400 }
401 .apply(provider);
402 build_openai_provider("litellm", LITELLM_DISPLAY_NAME, provider, http_config)
403}
404
405#[cfg(feature = "provider-bedrock")]
406async fn build_bedrock_provider(
407 vendor: &str,
408 provider: ProviderConfigFile,
409) -> anyhow::Result<Arc<dyn LlmProvider>> {
410 let aws = provider.aws.unwrap_or_default();
411 let provider = BedrockProvider::new(BedrockConfig {
412 vendor: Some(vendor.to_string()),
413 display_name: Some(
414 provider
415 .display_name
416 .unwrap_or_else(|| CUSTOM_BEDROCK_DISPLAY_NAME.to_string()),
417 ),
418 base_url: provider.base_url,
419 default_model: provider.default_model,
420 models: provider
423 .models
424 .unwrap_or_default()
425 .into_iter()
426 .map(|m| m.id().to_string())
427 .collect(),
428 aws_profile: aws.profile,
429 aws_region: aws.region,
430 })
431 .await
432 .map_err(|e| anyhow::anyhow!("{vendor} provider init failed: {e}"))?;
433 Ok(Arc::new(provider) as Arc<dyn LlmProvider>)
434}
435
436#[cfg(feature = "provider-openai")]
437fn build_openai_provider(
438 vendor: &str,
439 display_name: &str,
440 provider: ProviderConfigFile,
441 http_config: defect_http::HttpStackConfig,
442) -> anyhow::Result<Arc<dyn LlmProvider>> {
443 let provider = OpenAiProvider::new(OpenAiConfig {
444 api_key: provider
445 .api_key_env
446 .as_deref()
447 .and_then(|env| std::env::var(env).ok()),
448 base_url: provider.base_url,
449 organization: provider.organization,
450 project: provider.project,
451 vendor: vendor.to_string(),
452 display_name: display_name.to_string(),
453 api_key_env: provider.api_key_env,
454 headers: provider_headers(provider.headers)?,
455 capabilities_override: None,
456 reasoning_effort: provider.reasoning_effort.map(map_reasoning_effort),
457 chat_dialect: defect_llm::protocol::openai_chat::ChatDialect::OpenAi,
458 http: http_config,
459 })
460 .map_err(|e| anyhow::anyhow!("{vendor} provider init failed: {e}"))?;
461 Ok(Arc::new(provider) as Arc<dyn LlmProvider>)
462}
463
464#[cfg(feature = "provider-openai")]
468pub(crate) struct ProviderDefaults {
469 pub(crate) base_url: &'static str,
470 pub(crate) api_key_env: &'static str,
471}
472
473#[cfg(feature = "provider-openai")]
474impl ProviderDefaults {
475 pub(crate) fn apply(self, mut provider: ProviderConfigFile) -> ProviderConfigFile {
476 provider
477 .base_url
478 .get_or_insert_with(|| self.base_url.to_string());
479 provider
480 .api_key_env
481 .get_or_insert_with(|| self.api_key_env.to_string());
482 provider
483 }
484}
485
486#[cfg(feature = "provider-openai")]
487fn provider_headers(
488 headers: BTreeMap<String, String>,
489) -> anyhow::Result<HashMap<HeaderName, HeaderValue>> {
490 let mut parsed = HashMap::with_capacity(headers.len());
491 for (name, value) in headers {
492 let header_name = HeaderName::from_bytes(name.as_bytes())
493 .map_err(|e| anyhow::anyhow!("invalid provider header name `{name}`: {e}"))?;
494 let header_value = HeaderValue::from_str(&value)
495 .map_err(|e| anyhow::anyhow!("invalid provider header value for `{name}`: {e}"))?;
496 parsed.insert(header_name, header_value);
497 }
498 Ok(parsed)
499}
500
501#[cfg(any(feature = "provider-openai", feature = "provider-deepseek"))]
502pub(crate) fn map_reasoning_effort(value: ConfigReasoningEffort) -> LlmReasoningEffort {
503 match value {
504 ConfigReasoningEffort::None => LlmReasoningEffort::None,
505 ConfigReasoningEffort::Minimal => LlmReasoningEffort::Minimal,
506 ConfigReasoningEffort::Low => LlmReasoningEffort::Low,
507 ConfigReasoningEffort::Medium => LlmReasoningEffort::Medium,
508 ConfigReasoningEffort::High => LlmReasoningEffort::High,
509 ConfigReasoningEffort::Xhigh => LlmReasoningEffort::Xhigh,
510 }
511}