1#[cfg(any(feature = "provider-openai", feature = "provider-anthropic"))]
18use std::collections::{BTreeMap, HashMap};
19use std::sync::Arc;
20
21use defect_acp::EchoProvider;
22use defect_agent::llm::{
23 LlmProvider, ModelCapabilityOverrides, ModelInfo, ProviderEntry, ProviderRegistry,
24};
25use defect_agent::session::{SessionCapabilitiesConfig, TurnConfig};
26use defect_config::{
27 LoadedConfig, ProviderConfigFile, ProviderConfigs, ProviderKind as ConfigProviderKind,
28 ProviderProtocol,
29};
30#[cfg(any(feature = "provider-openai", feature = "provider-deepseek"))]
32use defect_agent::llm::ReasoningEffort as LlmReasoningEffort;
33#[cfg(any(feature = "provider-openai", feature = "provider-deepseek"))]
34use defect_config::ReasoningEffort as ConfigReasoningEffort;
35#[cfg(any(feature = "provider-anthropic", feature = "provider-bedrock"))]
38use defect_config::ThinkingFormat as ConfigThinkingFormat;
39#[cfg(any(feature = "provider-anthropic", feature = "provider-bedrock"))]
40use defect_llm::protocol::anthropic_messages::ThinkingWireFormat;
41#[cfg(feature = "provider-anthropic")]
42use defect_llm::provider::anthropic::{AnthropicConfig, AnthropicProvider};
43#[cfg(feature = "provider-bedrock")]
44use defect_llm::provider::bedrock::{BedrockConfig, BedrockProvider};
45#[cfg(feature = "provider-deepseek")]
46use defect_llm::provider::deepseek::{DeepSeekConfig, DeepSeekProvider};
47#[cfg(feature = "provider-openai")]
48use defect_llm::provider::openai::{OpenAiConfig, OpenAiProvider};
49#[cfg(any(feature = "provider-openai", feature = "provider-anthropic"))]
50use http::{HeaderName, HeaderValue};
51
52use crate::http_stack::build_http_stack_config;
53
54pub(crate) const BEDROCK_PROVIDER: &str = "bedrock";
55#[cfg(feature = "provider-openai")]
58pub(crate) const LITELLM_API_KEY_ENV: &str = "LITELLM_API_KEY";
59#[cfg(feature = "provider-openai")]
60pub(crate) const LITELLM_DEFAULT_BASE_URL: &str = "http://localhost:4000/v1";
61#[cfg(feature = "provider-openai")]
62const CUSTOM_OPENAI_DISPLAY_NAME: &str = "Custom OpenAI-compatible";
63#[cfg(feature = "provider-anthropic")]
64const CUSTOM_ANTHROPIC_DISPLAY_NAME: &str = "Custom Anthropic-compatible";
65#[cfg(feature = "provider-bedrock")]
66const CUSTOM_BEDROCK_DISPLAY_NAME: &str = "Amazon Bedrock";
67#[cfg(feature = "provider-openai")]
68const LITELLM_DISPLAY_NAME: &str = "LiteLLM Gateway";
69
70pub async fn build_registry(
78 config: &LoadedConfig,
79) -> anyhow::Result<(Arc<ProviderRegistry>, TurnConfig)> {
80 let http_config = build_http_stack_config(&config.effective.http)?;
81 let entries = build_provider_entries(config, http_config).await?;
82 let turn_config = config.effective.turn.clone();
83 let registry = ProviderRegistry::new(entries, &turn_config.provider, &turn_config.model)
84 .map_err(|e| anyhow::anyhow!("provider registry init failed: {e}"))?;
85 Ok((Arc::new(registry), turn_config))
86}
87
88pub async fn build_provider_entries(
92 config: &LoadedConfig,
93 http_config: defect_http::HttpStackConfig,
94) -> anyhow::Result<Vec<ProviderEntry>> {
95 let default_kind = config.effective.cli.provider.clone();
96 let default_provider =
97 build_single_llm_provider(&default_kind, config, http_config.clone()).await?;
98 let mut entries = vec![ProviderEntry::new(
99 default_provider,
100 entry_models(
101 provider_config_for_kind(&config.effective.providers, &default_kind),
102 Some(config.effective.turn.model.as_str()),
103 ),
104 provider_session_capabilities(config, &default_kind),
105 )];
106
107 for provider_kind in configured_entry_kinds(config) {
108 if provider_kind == default_kind {
109 continue;
110 }
111 let models = entry_models(
112 provider_config_for_kind(&config.effective.providers, &provider_kind),
113 None,
114 );
115 if models.is_empty() {
116 continue;
117 }
118 let provider =
119 build_single_llm_provider(&provider_kind, config, http_config.clone()).await?;
120 entries.push(ProviderEntry::new(
121 provider,
122 models,
123 provider_session_capabilities(config, &provider_kind),
124 ));
125 }
126
127 Ok(entries)
128}
129
130#[cfg_attr(
139 not(any(
140 feature = "provider-anthropic",
141 feature = "provider-openai",
142 feature = "provider-deepseek"
143 )),
144 allow(unused_variables)
145)]
146pub async fn build_single_llm_provider(
147 provider_kind: &ConfigProviderKind,
148 config: &LoadedConfig,
149 http_config: defect_http::HttpStackConfig,
150) -> anyhow::Result<Arc<dyn LlmProvider>> {
151 match provider_kind {
152 ConfigProviderKind::Defect => Ok(Arc::new(EchoProvider::new()) as Arc<dyn LlmProvider>),
153 #[cfg(feature = "provider-anthropic")]
154 ConfigProviderKind::Anthropic => build_anthropic_provider(
155 "anthropic",
156 None,
157 config.effective.providers.anthropic.clone(),
158 http_config,
159 ),
160 #[cfg(feature = "provider-openai")]
161 ConfigProviderKind::Openai => build_openai_provider(
162 "openai",
163 "OpenAI Chat Completions",
164 config.effective.providers.openai.clone(),
165 http_config,
166 ),
167 #[cfg(feature = "provider-deepseek")]
168 ConfigProviderKind::Deepseek => Ok(Arc::new(
169 DeepSeekProvider::new(DeepSeekConfig {
170 api_key: None,
171 api_key_env: config.effective.providers.deepseek.api_key_env.clone(),
172 base_url: config.effective.providers.deepseek.base_url.clone(),
173 reasoning_effort: config
174 .effective
175 .providers
176 .deepseek
177 .reasoning_effort
178 .map(map_reasoning_effort),
179 http: http_config,
180 })
181 .map_err(|e| anyhow::anyhow!("deepseek provider init failed: {e}"))?,
182 ) as Arc<dyn LlmProvider>),
183 #[cfg(feature = "provider-openai")]
186 ConfigProviderKind::Litellm => {
187 build_litellm_provider(config.effective.providers.litellm.clone(), http_config)
188 }
189 #[cfg(not(feature = "provider-anthropic"))]
194 ConfigProviderKind::Anthropic => Err(provider_not_compiled("anthropic")),
195 #[cfg(not(feature = "provider-openai"))]
196 ConfigProviderKind::Openai => Err(provider_not_compiled("openai")),
197 #[cfg(not(feature = "provider-deepseek"))]
198 ConfigProviderKind::Deepseek => Err(provider_not_compiled("deepseek")),
199 #[cfg(not(feature = "provider-openai"))]
200 ConfigProviderKind::Litellm => Err(provider_not_compiled("openai")),
201 ConfigProviderKind::Custom(name) => {
202 let Some(provider) = config
203 .effective
204 .providers
205 .get(&ConfigProviderKind::Custom(name.clone()))
206 else {
207 return Err(anyhow::anyhow!("missing [providers.{name}] configuration"));
208 };
209 let protocol = provider.protocol.unwrap_or_else(|| {
216 if name == BEDROCK_PROVIDER || provider.aws.is_some() {
217 ProviderProtocol::AnthropicMessages
218 } else {
219 ProviderProtocol::OpenaiChat
220 }
221 });
222 match protocol {
223 #[cfg(feature = "provider-openai")]
224 ProviderProtocol::OpenaiChat => build_openai_provider(
225 name,
226 provider
227 .display_name
228 .as_deref()
229 .unwrap_or(CUSTOM_OPENAI_DISPLAY_NAME),
230 provider.clone(),
231 http_config,
232 ),
233 #[cfg(not(feature = "provider-openai"))]
234 ProviderProtocol::OpenaiChat => Err(provider_not_compiled("openai")),
235 ProviderProtocol::AnthropicMessages => {
236 if name == BEDROCK_PROVIDER || provider.aws.is_some() {
237 #[cfg(feature = "provider-bedrock")]
238 {
239 build_bedrock_provider(name, provider.clone()).await
240 }
241 #[cfg(not(feature = "provider-bedrock"))]
242 {
243 Err(provider_not_compiled("bedrock"))
244 }
245 } else {
246 #[cfg(feature = "provider-anthropic")]
250 {
251 let display_name = provider
252 .display_name
253 .clone()
254 .unwrap_or_else(|| CUSTOM_ANTHROPIC_DISPLAY_NAME.to_string());
255 build_anthropic_provider(
256 name,
257 Some(display_name),
258 provider.clone(),
259 http_config,
260 )
261 }
262 #[cfg(not(feature = "provider-anthropic"))]
263 {
264 Err(provider_not_compiled("anthropic"))
265 }
266 }
267 }
268 }
269 }
270 }
271}
272
273#[cfg(not(all(
280 feature = "provider-anthropic",
281 feature = "provider-bedrock",
282 feature = "provider-openai",
283 feature = "provider-deepseek"
284)))]
285fn provider_not_compiled(feature_suffix: &str) -> anyhow::Error {
286 anyhow::anyhow!(
287 "provider was selected but not compiled into this build; \
288 rebuild with `--features provider-{feature_suffix}` \
289 (or use the default feature set)"
290 )
291}
292
293fn provider_session_capabilities(
300 config: &LoadedConfig,
301 provider: &ConfigProviderKind,
302) -> SessionCapabilitiesConfig {
303 match provider {
304 ConfigProviderKind::Anthropic => config
305 .effective
306 .providers
307 .anthropic
308 .capabilities
309 .merge_into(config.effective.capabilities),
310 ConfigProviderKind::Openai => config
311 .effective
312 .providers
313 .openai
314 .capabilities
315 .merge_into(config.effective.capabilities),
316 ConfigProviderKind::Deepseek => config
317 .effective
318 .providers
319 .deepseek
320 .capabilities
321 .merge_into(config.effective.capabilities),
322 ConfigProviderKind::Litellm => config
323 .effective
324 .providers
325 .litellm
326 .capabilities
327 .merge_into(config.effective.capabilities),
328 ConfigProviderKind::Defect => config.effective.capabilities,
329 ConfigProviderKind::Custom(name) => config
330 .effective
331 .providers
332 .get(&ConfigProviderKind::Custom(name.clone()))
333 .map(|provider| {
334 provider
335 .capabilities
336 .merge_into(config.effective.capabilities)
337 })
338 .unwrap_or(config.effective.capabilities),
339 }
340 .to_session_capabilities()
341}
342
343fn configured_entry_kinds(config: &LoadedConfig) -> Vec<ConfigProviderKind> {
344 let mut kinds = vec![
345 ConfigProviderKind::Anthropic,
346 ConfigProviderKind::Openai,
347 ConfigProviderKind::Deepseek,
348 ConfigProviderKind::Litellm,
349 ];
350 kinds.extend(
351 config
352 .effective
353 .providers
354 .custom
355 .keys()
356 .cloned()
357 .map(ConfigProviderKind::Custom),
358 );
359 kinds
360}
361
362fn provider_config_for_kind<'a>(
363 providers: &'a ProviderConfigs,
364 kind: &ConfigProviderKind,
365) -> Option<&'a ProviderConfigFile> {
366 providers.get(kind)
367}
368
369fn entry_models(
370 provider: Option<&ProviderConfigFile>,
371 fallback_model: Option<&str>,
372) -> Vec<ModelInfo> {
373 let mut models: Vec<ModelInfo> = Vec::new();
374 if let Some(provider) = provider {
375 if let Some(default_model) = &provider.default_model {
377 push_unique_model(&mut models, default_model, None, None, None);
378 }
379 if let Some(entries) = &provider.models {
380 for entry in entries {
381 push_unique_model(
382 &mut models,
383 entry.id(),
384 entry.name(),
385 entry.context_window(),
386 entry.max_output_tokens(),
387 );
388 }
389 }
390 }
391 if models.is_empty()
392 && let Some(fallback_model) = fallback_model
393 {
394 push_unique_model(&mut models, fallback_model, None, None, None);
395 }
396 models
397}
398
399fn push_unique_model(
403 models: &mut Vec<ModelInfo>,
404 id: &str,
405 name: Option<&str>,
406 context_window: Option<u64>,
407 max_output_tokens: Option<u64>,
408) {
409 if let Some(existing) = models.iter_mut().find(|m| m.id == id) {
410 if existing.display_name.is_none() {
411 existing.display_name = name.map(str::to_string);
412 }
413 existing.context_window = existing.context_window.or(context_window);
414 existing.max_output_tokens = existing.max_output_tokens.or(max_output_tokens);
415 return;
416 }
417 models.push(ModelInfo {
418 id: id.to_string(),
419 display_name: name.map(str::to_string),
420 context_window,
421 max_output_tokens,
422 deprecated: false,
423 capabilities_overrides: ModelCapabilityOverrides::default(),
424 });
425}
426
427#[cfg(feature = "provider-openai")]
428fn build_litellm_provider(
429 provider: ProviderConfigFile,
430 http_config: defect_http::HttpStackConfig,
431) -> anyhow::Result<Arc<dyn LlmProvider>> {
432 let provider = ProviderDefaults {
433 base_url: LITELLM_DEFAULT_BASE_URL,
434 api_key_env: LITELLM_API_KEY_ENV,
435 }
436 .apply(provider);
437 build_openai_provider("litellm", LITELLM_DISPLAY_NAME, provider, http_config)
438}
439
440#[cfg(feature = "provider-bedrock")]
441async fn build_bedrock_provider(
442 vendor: &str,
443 provider: ProviderConfigFile,
444) -> anyhow::Result<Arc<dyn LlmProvider>> {
445 let aws = provider.aws.unwrap_or_default();
446 let provider = BedrockProvider::new(BedrockConfig {
447 vendor: Some(vendor.to_string()),
448 display_name: Some(
449 provider
450 .display_name
451 .unwrap_or_else(|| CUSTOM_BEDROCK_DISPLAY_NAME.to_string()),
452 ),
453 base_url: provider.base_url,
454 default_model: provider.default_model,
455 models: provider
460 .models
461 .unwrap_or_default()
462 .into_iter()
463 .map(|m| defect_llm::provider::bedrock::BedrockModel {
464 id: m.id().to_string(),
465 context_window: m.context_window(),
466 max_output_tokens: m.max_output_tokens(),
467 thinking_format: m.thinking_format().map(map_thinking_format),
468 })
469 .collect(),
470 aws_profile: aws.profile,
471 aws_region: aws.region,
472 anthropic_beta: aws.anthropic_beta,
473 })
474 .await
475 .map_err(|e| anyhow::anyhow!("{vendor} provider init failed: {e}"))?;
476 Ok(Arc::new(provider) as Arc<dyn LlmProvider>)
477}
478
479#[cfg(feature = "provider-anthropic")]
483fn build_anthropic_provider(
484 vendor: &str,
485 display_name: Option<String>,
486 provider: ProviderConfigFile,
487 http_config: defect_http::HttpStackConfig,
488) -> anyhow::Result<Arc<dyn LlmProvider>> {
489 let thinking_formats = provider
490 .models
491 .as_deref()
492 .unwrap_or_default()
493 .iter()
494 .filter_map(|m| {
495 m.thinking_format()
496 .map(|f| (m.id().to_string(), map_thinking_format(f)))
497 })
498 .collect();
499 let provider = AnthropicProvider::new(AnthropicConfig {
500 api_key: provider
501 .api_key_env
502 .as_deref()
503 .and_then(|env| std::env::var(env).ok()),
504 api_key_env: provider.api_key_env,
505 base_url: provider.base_url,
506 vendor: Some(vendor.to_string()),
507 display_name,
508 auth_header: provider.auth_header,
509 headers: provider_headers(provider.headers)?,
510 thinking_formats,
511 http: http_config,
512 })
513 .map_err(|e| anyhow::anyhow!("{vendor} provider init failed: {e}"))?;
514 Ok(Arc::new(provider) as Arc<dyn LlmProvider>)
515}
516
517#[cfg(feature = "provider-openai")]
518fn build_openai_provider(
519 vendor: &str,
520 display_name: &str,
521 provider: ProviderConfigFile,
522 http_config: defect_http::HttpStackConfig,
523) -> anyhow::Result<Arc<dyn LlmProvider>> {
524 let provider = OpenAiProvider::new(OpenAiConfig {
525 api_key: provider
526 .api_key_env
527 .as_deref()
528 .and_then(|env| std::env::var(env).ok()),
529 base_url: provider.base_url,
530 organization: provider.organization,
531 project: provider.project,
532 vendor: vendor.to_string(),
533 display_name: display_name.to_string(),
534 api_key_env: provider.api_key_env,
535 headers: provider_headers(provider.headers)?,
536 capabilities_override: None,
537 reasoning_effort: provider.reasoning_effort.map(map_reasoning_effort),
538 chat_dialect: defect_llm::protocol::openai_chat::ChatDialect::OpenAi,
539 http: http_config,
540 })
541 .map_err(|e| anyhow::anyhow!("{vendor} provider init failed: {e}"))?;
542 Ok(Arc::new(provider) as Arc<dyn LlmProvider>)
543}
544
545#[cfg(feature = "provider-openai")]
549pub(crate) struct ProviderDefaults {
550 pub(crate) base_url: &'static str,
551 pub(crate) api_key_env: &'static str,
552}
553
554#[cfg(feature = "provider-openai")]
555impl ProviderDefaults {
556 pub(crate) fn apply(self, mut provider: ProviderConfigFile) -> ProviderConfigFile {
557 provider
558 .base_url
559 .get_or_insert_with(|| self.base_url.to_string());
560 provider
561 .api_key_env
562 .get_or_insert_with(|| self.api_key_env.to_string());
563 provider
564 }
565}
566
567#[cfg(any(feature = "provider-openai", feature = "provider-anthropic"))]
568fn provider_headers(
569 headers: BTreeMap<String, String>,
570) -> anyhow::Result<HashMap<HeaderName, HeaderValue>> {
571 let mut parsed = HashMap::with_capacity(headers.len());
572 for (name, value) in headers {
573 let header_name = HeaderName::from_bytes(name.as_bytes())
574 .map_err(|e| anyhow::anyhow!("invalid provider header name `{name}`: {e}"))?;
575 let header_value = HeaderValue::from_str(&value)
576 .map_err(|e| anyhow::anyhow!("invalid provider header value for `{name}`: {e}"))?;
577 parsed.insert(header_name, header_value);
578 }
579 Ok(parsed)
580}
581
582#[cfg(any(feature = "provider-openai", feature = "provider-deepseek"))]
583pub(crate) fn map_reasoning_effort(value: ConfigReasoningEffort) -> LlmReasoningEffort {
584 match value {
585 ConfigReasoningEffort::None => LlmReasoningEffort::None,
586 ConfigReasoningEffort::Minimal => LlmReasoningEffort::Minimal,
587 ConfigReasoningEffort::Low => LlmReasoningEffort::Low,
588 ConfigReasoningEffort::Medium => LlmReasoningEffort::Medium,
589 ConfigReasoningEffort::High => LlmReasoningEffort::High,
590 ConfigReasoningEffort::Xhigh => LlmReasoningEffort::Xhigh,
591 }
592}
593
594#[cfg(any(feature = "provider-anthropic", feature = "provider-bedrock"))]
595fn map_thinking_format(value: ConfigThinkingFormat) -> ThinkingWireFormat {
596 match value {
597 ConfigThinkingFormat::Adaptive => ThinkingWireFormat::Adaptive,
598 ConfigThinkingFormat::Legacy => ThinkingWireFormat::Legacy,
599 }
600}