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, None, None);
356 }
357 if let Some(entries) = &provider.models {
358 for entry in entries {
359 push_unique_model(
360 &mut models,
361 entry.id(),
362 entry.name(),
363 entry.context_window(),
364 entry.max_output_tokens(),
365 );
366 }
367 }
368 }
369 if models.is_empty()
370 && let Some(fallback_model) = fallback_model
371 {
372 push_unique_model(&mut models, fallback_model, None, None, None);
373 }
374 models
375}
376
377fn push_unique_model(
381 models: &mut Vec<ModelInfo>,
382 id: &str,
383 name: Option<&str>,
384 context_window: Option<u64>,
385 max_output_tokens: Option<u64>,
386) {
387 if let Some(existing) = models.iter_mut().find(|m| m.id == id) {
388 if existing.display_name.is_none() {
389 existing.display_name = name.map(str::to_string);
390 }
391 existing.context_window = existing.context_window.or(context_window);
392 existing.max_output_tokens = existing.max_output_tokens.or(max_output_tokens);
393 return;
394 }
395 models.push(ModelInfo {
396 id: id.to_string(),
397 display_name: name.map(str::to_string),
398 context_window,
399 max_output_tokens,
400 deprecated: false,
401 capabilities_overrides: ModelCapabilityOverrides::default(),
402 });
403}
404
405#[cfg(feature = "provider-openai")]
406fn build_litellm_provider(
407 provider: ProviderConfigFile,
408 http_config: defect_http::HttpStackConfig,
409) -> anyhow::Result<Arc<dyn LlmProvider>> {
410 let provider = ProviderDefaults {
411 base_url: LITELLM_DEFAULT_BASE_URL,
412 api_key_env: LITELLM_API_KEY_ENV,
413 }
414 .apply(provider);
415 build_openai_provider("litellm", LITELLM_DISPLAY_NAME, provider, http_config)
416}
417
418#[cfg(feature = "provider-bedrock")]
419async fn build_bedrock_provider(
420 vendor: &str,
421 provider: ProviderConfigFile,
422) -> anyhow::Result<Arc<dyn LlmProvider>> {
423 let aws = provider.aws.unwrap_or_default();
424 let provider = BedrockProvider::new(BedrockConfig {
425 vendor: Some(vendor.to_string()),
426 display_name: Some(
427 provider
428 .display_name
429 .unwrap_or_else(|| CUSTOM_BEDROCK_DISPLAY_NAME.to_string()),
430 ),
431 base_url: provider.base_url,
432 default_model: provider.default_model,
433 models: provider
438 .models
439 .unwrap_or_default()
440 .into_iter()
441 .map(|m| defect_llm::provider::bedrock::BedrockModel {
442 id: m.id().to_string(),
443 context_window: m.context_window(),
444 max_output_tokens: m.max_output_tokens(),
445 })
446 .collect(),
447 aws_profile: aws.profile,
448 aws_region: aws.region,
449 anthropic_beta: aws.anthropic_beta,
450 })
451 .await
452 .map_err(|e| anyhow::anyhow!("{vendor} provider init failed: {e}"))?;
453 Ok(Arc::new(provider) as Arc<dyn LlmProvider>)
454}
455
456#[cfg(feature = "provider-openai")]
457fn build_openai_provider(
458 vendor: &str,
459 display_name: &str,
460 provider: ProviderConfigFile,
461 http_config: defect_http::HttpStackConfig,
462) -> anyhow::Result<Arc<dyn LlmProvider>> {
463 let provider = OpenAiProvider::new(OpenAiConfig {
464 api_key: provider
465 .api_key_env
466 .as_deref()
467 .and_then(|env| std::env::var(env).ok()),
468 base_url: provider.base_url,
469 organization: provider.organization,
470 project: provider.project,
471 vendor: vendor.to_string(),
472 display_name: display_name.to_string(),
473 api_key_env: provider.api_key_env,
474 headers: provider_headers(provider.headers)?,
475 capabilities_override: None,
476 reasoning_effort: provider.reasoning_effort.map(map_reasoning_effort),
477 chat_dialect: defect_llm::protocol::openai_chat::ChatDialect::OpenAi,
478 http: http_config,
479 })
480 .map_err(|e| anyhow::anyhow!("{vendor} provider init failed: {e}"))?;
481 Ok(Arc::new(provider) as Arc<dyn LlmProvider>)
482}
483
484#[cfg(feature = "provider-openai")]
488pub(crate) struct ProviderDefaults {
489 pub(crate) base_url: &'static str,
490 pub(crate) api_key_env: &'static str,
491}
492
493#[cfg(feature = "provider-openai")]
494impl ProviderDefaults {
495 pub(crate) fn apply(self, mut provider: ProviderConfigFile) -> ProviderConfigFile {
496 provider
497 .base_url
498 .get_or_insert_with(|| self.base_url.to_string());
499 provider
500 .api_key_env
501 .get_or_insert_with(|| self.api_key_env.to_string());
502 provider
503 }
504}
505
506#[cfg(feature = "provider-openai")]
507fn provider_headers(
508 headers: BTreeMap<String, String>,
509) -> anyhow::Result<HashMap<HeaderName, HeaderValue>> {
510 let mut parsed = HashMap::with_capacity(headers.len());
511 for (name, value) in headers {
512 let header_name = HeaderName::from_bytes(name.as_bytes())
513 .map_err(|e| anyhow::anyhow!("invalid provider header name `{name}`: {e}"))?;
514 let header_value = HeaderValue::from_str(&value)
515 .map_err(|e| anyhow::anyhow!("invalid provider header value for `{name}`: {e}"))?;
516 parsed.insert(header_name, header_value);
517 }
518 Ok(parsed)
519}
520
521#[cfg(any(feature = "provider-openai", feature = "provider-deepseek"))]
522pub(crate) fn map_reasoning_effort(value: ConfigReasoningEffort) -> LlmReasoningEffort {
523 match value {
524 ConfigReasoningEffort::None => LlmReasoningEffort::None,
525 ConfigReasoningEffort::Minimal => LlmReasoningEffort::Minimal,
526 ConfigReasoningEffort::Low => LlmReasoningEffort::Low,
527 ConfigReasoningEffort::Medium => LlmReasoningEffort::Medium,
528 ConfigReasoningEffort::High => LlmReasoningEffort::High,
529 ConfigReasoningEffort::Xhigh => LlmReasoningEffort::Xhigh,
530 }
531}