Skip to main content

defect_cli/
providers.rs

1//! Assembles a [`ProviderRegistry`] and individual provider instances.
2//!
3//! - [`build_registry`]: entry point for assembly; given a [`LoadedConfig`], returns
4//!   `(ProviderRegistry, TurnConfig)` for direct attachment to
5//!   `DefaultAgentCore::builder().registry(...)`.
6//! - [`build_single_llm_provider`]: constructs a provider instance by [`ProviderKind`];
7//!   callers that want to "swap out a provider" can call this function independently
8//!   and assemble their own `ProviderEntry`.
9//! - [`build_provider_entries`]: the list of entries for `ProviderRegistry::new` —
10//!   the default entry plus any additional entries the user configured under
11//!   `[providers.*]`.
12//!
13//! [`ProviderKind`]: defect_config::ProviderKind
14
15// BTreeMap/HashMap and http header types are only used by provider_headers (openai).
16#[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// Only used for reasoning-effort mapping, included alongside openai/deepseek.
30#[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// LiteLLM uses the OpenAI provider; related constants are compiled in under
49// `provider-openai`.
50#[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
61/// Assembles the provider registry and default turn config.
62///
63/// Entry point for the main binary:
64/// ```ignore
65/// let (registry, turn_config) = defect_cli::providers::build_registry(&config).await?;
66/// DefaultAgentCore::builder().registry(registry).config(turn_config)...
67/// ```
68pub 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
79/// For each valid `ProviderKind` in the `[providers]` section, assemble a
80/// [`ProviderEntry`] — the default provider is always included; other entries are only
81/// included if they declare `default_model` or `models`.
82pub 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/// Instantiate a provider based on [`ProviderKind`](defect_config::ProviderKind).
122///
123/// When downstream developers want to swap in their own OpenAI implementation, call this
124/// function independently to construct the default provider, then push a custom entry
125/// into [`ProviderRegistry::new`].
126// `http_config` is only used by the anthropic, openai, and deepseek providers (which use
127// hyper); bedrock uses the AWS SDK's own transport, and echo has no transport. For these
128// combinations the parameter is unused and is allowed accordingly.
129#[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        // LiteLLM reuses the OpenAI provider implementation, so it follows
178        // `provider-openai`.
179        #[cfg(feature = "provider-openai")]
180        ConfigProviderKind::Litellm => {
181            build_litellm_provider(config.effective.providers.litellm.clone(), http_config)
182        }
183        // Providers selected by config but not compiled into this build: hard fail with
184        // actionable hint.
185        // Echo is always available and never reaches this branch; custom is handled
186        // separately below.
187        #[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            // Protocol default: if the provider is `bedrock` or has an `aws` section, use
204            // `AnthropicMessages`; otherwise fall back to `OpenaiChat`. Previously there
205            // was no fallback before dispatch — users writing `[providers.bedrock] aws =
206            // { ... }` without an explicit `protocol` would be routed to the OpenAI
207            // builder, producing a misleading "missing OPENAI_API_KEY" error unrelated to
208            // their actual configuration.
209            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/// A provider that was selected by configuration but not compiled into this build via a
252/// `provider-*` feature — hard fail with a message indicating which feature to enable
253/// (following the fail-loud principle: no silent fallback to echo).
254///
255/// This function has no call sites when all providers are enabled, so it is only compiled
256/// when at least one provider is excluded.
257#[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
271/// Merge the global [`capabilities`] with `providers.<p>.capabilities` and project the
272/// result into the agent-side [`SessionCapabilitiesConfig`]. Each entry carries its own
273/// copy so that the session can obtain the correct capability configuration when
274/// switching models across providers.
275///
276/// [`capabilities`]: defect_config::CapabilitiesConfig
277fn 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        // `default_model` is just an ID (a bare string) with no display name.
354        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
371/// Append a [`ModelInfo`] deduplicated by `id`. If an entry with the same `id` already
372/// exists and the new one has a display name while the existing one does not, update the
373/// existing entry with the new name (so that a `name` from `[[models]]` overrides a bare
374/// id from `default_model`); otherwise leave it unchanged.
375fn 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        // For the Bedrock provider's model list, only the id is needed; display names are
421        // fetched separately in the `entry_models` pipeline.
422        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/// Fill default `base_url` / `api_key_env` for OpenAI-compatible providers.
465///
466/// `pub(crate)` is exposed for unit tests — LiteLLM assembly uses this path.
467#[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}