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, which both
16// the openai and anthropic providers feed their custom-header maps through.
17#[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// Only used for reasoning-effort mapping, included alongside openai/deepseek.
31#[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// Per-model thinking wire format mapping, shared by the anthropic and bedrock builders
36// (both speak the Anthropic Messages protocol).
37#[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// LiteLLM uses the OpenAI provider; related constants are compiled in under
56// `provider-openai`.
57#[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
70/// Assembles the provider registry and default turn config.
71///
72/// Entry point for the main binary:
73/// ```ignore
74/// let (registry, turn_config) = defect_cli::providers::build_registry(&config).await?;
75/// DefaultAgentCore::builder().registry(registry).config(turn_config)...
76/// ```
77pub 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
88/// For each valid `ProviderKind` in the `[providers]` section, assemble a
89/// [`ProviderEntry`] — the default provider is always included; other entries are only
90/// included if they declare `default_model` or `models`.
91pub 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/// Instantiate a provider based on [`ProviderKind`](defect_config::ProviderKind).
131///
132/// When downstream developers want to swap in their own OpenAI implementation, call this
133/// function independently to construct the default provider, then push a custom entry
134/// into [`ProviderRegistry::new`].
135// `http_config` is only used by the anthropic, openai, and deepseek providers (which use
136// hyper); bedrock uses the AWS SDK's own transport, and echo has no transport. For these
137// combinations the parameter is unused and is allowed accordingly.
138#[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        // LiteLLM reuses the OpenAI provider implementation, so it follows
184        // `provider-openai`.
185        #[cfg(feature = "provider-openai")]
186        ConfigProviderKind::Litellm => {
187            build_litellm_provider(config.effective.providers.litellm.clone(), http_config)
188        }
189        // Providers selected by config but not compiled into this build: hard fail with
190        // actionable hint.
191        // Echo is always available and never reaches this branch; custom is handled
192        // separately below.
193        #[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            // Protocol default: if the provider is `bedrock` or has an `aws` section, use
210            // `AnthropicMessages`; otherwise fall back to `OpenaiChat`. Previously there
211            // was no fallback before dispatch — users writing `[providers.bedrock] aws =
212            // { ... }` without an explicit `protocol` would be routed to the OpenAI
213            // builder, producing a misleading "missing OPENAI_API_KEY" error unrelated to
214            // their actual configuration.
215            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                        // Custom HTTP endpoint speaking the Anthropic Messages protocol.
247                        // Reuses `AnthropicProvider`; `auth_header` lets the gateway's
248                        // credential header differ from the official `x-api-key`.
249                        #[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/// A provider that was selected by configuration but not compiled into this build via a
274/// `provider-*` feature — hard fail with a message indicating which feature to enable
275/// (following the fail-loud principle: no silent fallback to echo).
276///
277/// This function has no call sites when all providers are enabled, so it is only compiled
278/// when at least one provider is excluded.
279#[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
293/// Merge the global [`capabilities`] with `providers.<p>.capabilities` and project the
294/// result into the agent-side [`SessionCapabilitiesConfig`]. Each entry carries its own
295/// copy so that the session can obtain the correct capability configuration when
296/// switching models across providers.
297///
298/// [`capabilities`]: defect_config::CapabilitiesConfig
299fn 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        // `default_model` is just an ID (a bare string) with no display name or limits.
376        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
399/// Append a [`ModelInfo`] deduplicated by `id`. If an entry with the same `id` already
400/// exists, fill in any field the existing entry is missing (so a `[[models]]` table form
401/// can enrich a bare id contributed by `default_model`); otherwise leave it unchanged.
402fn 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        // Display names are fetched separately in the `entry_models` pipeline, but the
456        // limits (`context_window` / `max_output_tokens`) must be carried into the provider
457        // here — the Bedrock SDK cannot discover them, and compaction reads them back via
458        // `provider.model_info()`.
459        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/// Build an `AnthropicProvider` for either the built-in `anthropic` kind or a custom
480/// `anthropic-messages` HTTP endpoint (a gateway fronting the protocol). `vendor` is the
481/// registry key; `display_name`, when `None`, falls back to the provider's own default.
482#[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/// Fill default `base_url` / `api_key_env` for OpenAI-compatible providers.
546///
547/// `pub(crate)` is exposed for unit tests — LiteLLM assembly uses this path.
548#[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}