Skip to main content

zeph_core/bootstrap/
provider.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use zeph_llm::any::AnyProvider;
5use zeph_llm::router::triage::{ComplexityTier, TriageRouter};
6
7/// Error type for bootstrap / provider construction failures.
8///
9/// String-based variants flatten the error chain intentionally: bootstrap errors are
10/// terminal (the application exits), so downcasting is not needed at this stage.
11/// If a future phase requires programmatic retry on specific failures, expand these
12/// variants into typed sub-errors.
13#[derive(Debug, thiserror::Error)]
14pub enum BootstrapError {
15    #[error("config error: {0}")]
16    Config(#[from] crate::config::ConfigError),
17    #[error("provider error: {0}")]
18    Provider(String),
19    #[error("memory error: {0}")]
20    Memory(String),
21    #[error("vault init error: {0}")]
22    VaultInit(crate::vault::AgeVaultError),
23    #[error("I/O error: {0}")]
24    Io(#[from] std::io::Error),
25}
26use zeph_llm::claude::ClaudeProvider;
27use zeph_llm::compatible::CompatibleProvider;
28use zeph_llm::gemini::GeminiProvider;
29use zeph_llm::http::llm_client;
30use zeph_llm::ollama::OllamaProvider;
31use zeph_llm::openai::OpenAiProvider;
32use zeph_llm::router::cascade::ClassifierMode;
33use zeph_llm::router::{AsiRouterConfig, BanditRouterConfig, CascadeRouterConfig, RouterProvider};
34
35use crate::agent::state::ProviderConfigSnapshot;
36use crate::config::{Config, LlmRoutingStrategy, ProviderEntry, ProviderKind};
37
38pub fn create_provider(config: &Config) -> Result<AnyProvider, BootstrapError> {
39    create_provider_from_pool(config)
40}
41
42fn build_cascade_router_config(
43    cascade_cfg: &crate::config::CascadeConfig,
44    config: &Config,
45) -> CascadeRouterConfig {
46    let classifier_mode = match cascade_cfg.classifier_mode {
47        crate::config::CascadeClassifierMode::Heuristic => ClassifierMode::Heuristic,
48        crate::config::CascadeClassifierMode::Judge => ClassifierMode::Judge,
49    };
50    // SEC-CASCADE-01: clamp quality_threshold to [0.0, 1.0]; reject NaN/Inf.
51    let raw_threshold = cascade_cfg.quality_threshold;
52    let quality_threshold = if raw_threshold.is_finite() {
53        raw_threshold.clamp(0.0, 1.0)
54    } else {
55        tracing::warn!(
56            raw_threshold,
57            "cascade quality_threshold is non-finite, defaulting to 0.5"
58        );
59        0.5
60    };
61    if (quality_threshold - raw_threshold).abs() > f64::EPSILON {
62        tracing::warn!(
63            raw_threshold,
64            clamped = quality_threshold,
65            "cascade quality_threshold out of range [0.0, 1.0], clamped"
66        );
67    }
68    // SEC-CASCADE-02: clamp window_size to minimum 1 to prevent silent no-op tracking.
69    let window_size = cascade_cfg.window_size.max(1);
70    if window_size != cascade_cfg.window_size {
71        tracing::warn!(
72            raw = cascade_cfg.window_size,
73            "cascade window_size=0 is invalid, clamped to 1"
74        );
75    }
76    // Build summary provider for judge mode.
77    let summary_provider = if classifier_mode == ClassifierMode::Judge {
78        if let Some(model_spec) = config.llm.summary_model.as_deref() {
79            match create_summary_provider(model_spec, config) {
80                Ok(p) => Some(p),
81                Err(e) => {
82                    tracing::warn!(
83                        error = %e,
84                        "cascade: failed to build judge provider, falling back to heuristic"
85                    );
86                    None
87                }
88            }
89        } else {
90            tracing::warn!(
91                "cascade: classifier_mode=judge requires [llm] summary_model to \
92                 be configured; falling back to heuristic"
93            );
94            None
95        }
96    } else {
97        None
98    };
99    CascadeRouterConfig {
100        quality_threshold,
101        max_escalations: cascade_cfg.max_escalations,
102        classifier_mode,
103        window_size,
104        max_cascade_tokens: cascade_cfg.max_cascade_tokens,
105        summary_provider,
106        cost_tiers: cascade_cfg.cost_tiers.clone(),
107    }
108}
109
110/// Apply ASI and `quality_gate` configuration to a `RouterProvider` from `[llm.routing]` config.
111fn apply_routing_signals(router: RouterProvider, config: &Config) -> RouterProvider {
112    let router_cfg = config.llm.router.as_ref();
113    let mut router = router;
114
115    // ASI coherence tracking.
116    if let Some(asi_cfg) = router_cfg.and_then(|r| r.asi.as_ref())
117        && asi_cfg.enabled
118    {
119        let threshold = asi_cfg.coherence_threshold.clamp(0.0, 1.0);
120        let penalty = asi_cfg.penalty_weight.clamp(0.0, 1.0);
121        if (threshold - asi_cfg.coherence_threshold).abs() > f32::EPSILON
122            || (penalty - asi_cfg.penalty_weight).abs() > f32::EPSILON
123        {
124            tracing::warn!("asi: coherence_threshold/penalty_weight clamped to [0.0, 1.0]");
125        }
126        router = router.with_asi(AsiRouterConfig {
127            window: asi_cfg.window,
128            coherence_threshold: threshold,
129            penalty_weight: penalty,
130        });
131    }
132
133    // Quality gate.
134    if let Some(threshold) = router_cfg.and_then(|r| r.quality_gate) {
135        if threshold.is_finite() && threshold > 0.0 && threshold <= 1.0 {
136            router = router.with_quality_gate(threshold);
137        } else {
138            tracing::warn!(
139                quality_gate = threshold,
140                "quality_gate must be in (0.0, 1.0], ignoring"
141            );
142        }
143    }
144
145    // Embed concurrency semaphore.
146    let embed_concurrency = router_cfg.map_or(4, |r| r.embed_concurrency);
147    router = router.with_embed_concurrency(embed_concurrency);
148
149    router
150}
151
152/// Look up a provider entry from the pool by name (exact match on `effective_name()`) or type.
153///
154/// Used by quarantine, guardrail, judge, and experiment eval model resolution.
155pub fn create_named_provider(name: &str, config: &Config) -> Result<AnyProvider, BootstrapError> {
156    let entry = config
157        .llm
158        .providers
159        .iter()
160        .find(|e| e.effective_name() == name || e.provider_type.as_str() == name)
161        .ok_or_else(|| {
162            BootstrapError::Provider(format!("provider '{name}' not found in [[llm.providers]]"))
163        })?;
164    build_provider_from_entry(entry, config)
165}
166
167/// Create an `AnyProvider` for use as the summarization provider.
168///
169/// `model_spec` format (set via `[llm] summary_model`):
170/// - `<name>` — looks up a provider by name in `[[llm.providers]]`
171/// - `ollama/<model>` — Ollama shorthand: uses the ollama provider from pool with model override
172/// - `claude[/<model>]`, `openai[/<model>]`, `gemini[/<model>]` — type shorthand with optional model
173pub fn create_summary_provider(
174    model_spec: &str,
175    config: &Config,
176) -> Result<AnyProvider, BootstrapError> {
177    // Try direct name lookup first (e.g. "claude", "my-openai").
178    if let Some(entry) = config
179        .llm
180        .providers
181        .iter()
182        .find(|e| e.effective_name() == model_spec || e.provider_type.as_str() == model_spec)
183    {
184        return build_provider_from_entry(entry, config);
185    }
186
187    // Handle `type/model` shorthand: override the model on a matching provider.
188    if let Some(((_, model), entry)) = model_spec.split_once('/').and_then(|(b, m)| {
189        config
190            .llm
191            .providers
192            .iter()
193            .find(|e| e.provider_type.as_str() == b || e.effective_name() == b)
194            .map(|e| ((b, m), e))
195    }) {
196        let mut cloned = entry.clone();
197        cloned.model = Some(model.to_owned());
198        // Cap summary max_tokens at 4096 — summaries are short.
199        cloned.max_tokens = Some(cloned.max_tokens.unwrap_or(4096).min(4096));
200        return build_provider_from_entry(&cloned, config);
201    }
202
203    Err(BootstrapError::Provider(format!(
204        "summary_model '{model_spec}' not found in [[llm.providers]]. \
205         Use a provider name or 'type/model' shorthand (e.g. 'ollama/qwen3:1.7b')."
206    )))
207}
208
209#[cfg(feature = "candle")]
210pub fn select_device(
211    preference: &str,
212) -> Result<zeph_llm::candle_provider::Device, BootstrapError> {
213    match preference {
214        "metal" => {
215            #[cfg(feature = "metal")]
216            return zeph_llm::candle_provider::Device::new_metal(0)
217                .map_err(|e| BootstrapError::Provider(e.to_string()));
218            #[cfg(not(feature = "metal"))]
219            return Err(BootstrapError::Provider(
220                "candle compiled without metal feature".into(),
221            ));
222        }
223        "cuda" => {
224            #[cfg(feature = "cuda")]
225            return zeph_llm::candle_provider::Device::new_cuda(0)
226                .map_err(|e| BootstrapError::Provider(e.to_string()));
227            #[cfg(not(feature = "cuda"))]
228            return Err(BootstrapError::Provider(
229                "candle compiled without cuda feature".into(),
230            ));
231        }
232        "auto" => {
233            #[cfg(feature = "metal")]
234            if let Ok(device) = zeph_llm::candle_provider::Device::new_metal(0) {
235                return Ok(device);
236            }
237            #[cfg(feature = "cuda")]
238            if let Ok(device) = zeph_llm::candle_provider::Device::new_cuda(0) {
239                return Ok(device);
240            }
241            Ok(zeph_llm::candle_provider::Device::Cpu)
242        }
243        _ => Ok(zeph_llm::candle_provider::Device::Cpu),
244    }
245}
246
247#[cfg(feature = "candle")]
248fn build_candle_provider(
249    source: &zeph_llm::candle_provider::loader::ModelSource,
250    candle_cfg: &crate::config::CandleConfig,
251    device_pref: &str,
252) -> Result<AnyProvider, BootstrapError> {
253    let template =
254        zeph_llm::candle_provider::template::ChatTemplate::parse_str(&candle_cfg.chat_template);
255    let gen_config = zeph_llm::candle_provider::generate::GenerationConfig {
256        temperature: candle_cfg.generation.temperature,
257        top_p: candle_cfg.generation.top_p,
258        top_k: candle_cfg.generation.top_k,
259        max_tokens: candle_cfg.generation.capped_max_tokens(),
260        seed: candle_cfg.generation.seed,
261        repeat_penalty: candle_cfg.generation.repeat_penalty,
262        repeat_last_n: candle_cfg.generation.repeat_last_n,
263    };
264    let device = select_device(device_pref)?;
265    zeph_llm::candle_provider::CandleProvider::new(
266        source,
267        template,
268        gen_config,
269        candle_cfg.embedding_repo.as_deref(),
270        candle_cfg.hf_token.as_deref(),
271        device,
272    )
273    .map(AnyProvider::Candle)
274    .map_err(|e| BootstrapError::Provider(e.to_string()))
275}
276
277/// Build an `AnyProvider` from a `ProviderEntry` using a runtime config snapshot.
278///
279/// Called by the `/provider <name>` slash command to switch providers at runtime without
280/// requiring the full `Config`. Router and Orchestrator provider kinds are not supported
281/// for runtime switching — they require the full provider pool to be re-initialized.
282///
283/// # Errors
284///
285/// Returns `BootstrapError::Provider` when the provider kind is unsupported for runtime
286/// switching, a required secret is missing, or the entry is misconfigured.
287pub fn build_provider_for_switch(
288    entry: &ProviderEntry,
289    snapshot: &ProviderConfigSnapshot,
290) -> Result<AnyProvider, BootstrapError> {
291    use zeph_common::secret::Secret;
292    // Reconstruct a minimal Config from the snapshot so we can reuse build_provider_from_entry.
293    // Only fields read by build_provider_from_entry are populated; everything else uses defaults.
294    // Secrets are stored as plain strings in the snapshot because Secret does not implement Clone.
295    let mut config = Config::default();
296    config.secrets.claude_api_key = snapshot.claude_api_key.as_deref().map(Secret::new);
297    config.secrets.openai_api_key = snapshot.openai_api_key.as_deref().map(Secret::new);
298    config.secrets.gemini_api_key = snapshot.gemini_api_key.as_deref().map(Secret::new);
299    config.secrets.compatible_api_keys = snapshot
300        .compatible_api_keys
301        .iter()
302        .map(|(k, v)| (k.clone(), Secret::new(v.as_str())))
303        .collect();
304    config.timeouts.llm_request_timeout_secs = snapshot.llm_request_timeout_secs;
305    config
306        .llm
307        .embedding_model
308        .clone_from(&snapshot.embedding_model);
309    build_provider_from_entry(entry, &config)
310}
311
312/// Build an `AnyProvider` from a unified `ProviderEntry` (new `[[llm.providers]]` format).
313///
314/// All provider-specific fields come from `entry`; the global `config` is used only for
315/// secrets and timeout settings.
316///
317/// # Errors
318///
319/// Returns `BootstrapError::Provider` when a required secret is missing or an entry is
320/// misconfigured (e.g. compatible provider without a name).
321#[allow(clippy::too_many_lines)]
322pub fn build_provider_from_entry(
323    entry: &ProviderEntry,
324    config: &Config,
325) -> Result<AnyProvider, BootstrapError> {
326    match entry.provider_type {
327        ProviderKind::Ollama => {
328            let base_url = entry
329                .base_url
330                .as_deref()
331                .unwrap_or("http://localhost:11434");
332            let model = entry.model.as_deref().unwrap_or("qwen3:8b").to_owned();
333            let embed = entry
334                .embedding_model
335                .clone()
336                .unwrap_or_else(|| config.llm.embedding_model.clone());
337            let mut provider = OllamaProvider::new(base_url, model, embed);
338            if let Some(ref vm) = entry.vision_model {
339                provider = provider.with_vision_model(vm.clone());
340            }
341            Ok(AnyProvider::Ollama(provider))
342        }
343        ProviderKind::Claude => {
344            let api_key = config
345                .secrets
346                .claude_api_key
347                .as_ref()
348                .ok_or_else(|| {
349                    BootstrapError::Provider("ZEPH_CLAUDE_API_KEY not found in vault".into())
350                })?
351                .expose()
352                .to_owned();
353            let model = entry
354                .model
355                .clone()
356                .unwrap_or_else(|| "claude-haiku-4-5-20251001".to_owned());
357            let max_tokens = entry.max_tokens.unwrap_or(4096);
358            let provider = ClaudeProvider::new(api_key, model, max_tokens)
359                .with_client(llm_client(config.timeouts.llm_request_timeout_secs))
360                .with_extended_context(entry.enable_extended_context)
361                .with_thinking_opt(entry.thinking.clone())
362                .map_err(|e| BootstrapError::Provider(format!("invalid thinking config: {e}")))?
363                .with_server_compaction(entry.server_compaction);
364            Ok(AnyProvider::Claude(provider))
365        }
366        ProviderKind::OpenAi => {
367            let api_key = config
368                .secrets
369                .openai_api_key
370                .as_ref()
371                .ok_or_else(|| {
372                    BootstrapError::Provider("ZEPH_OPENAI_API_KEY not found in vault".into())
373                })?
374                .expose()
375                .to_owned();
376            let base_url = entry
377                .base_url
378                .clone()
379                .unwrap_or_else(|| "https://api.openai.com/v1".to_owned());
380            let model = entry
381                .model
382                .clone()
383                .unwrap_or_else(|| "gpt-4o-mini".to_owned());
384            let max_tokens = entry.max_tokens.unwrap_or(4096);
385            Ok(AnyProvider::OpenAi(
386                OpenAiProvider::new(
387                    api_key,
388                    base_url,
389                    model,
390                    max_tokens,
391                    entry.embedding_model.clone(),
392                    entry.reasoning_effort.clone(),
393                )
394                .with_client(llm_client(config.timeouts.llm_request_timeout_secs)),
395            ))
396        }
397        ProviderKind::Gemini => {
398            let api_key = config
399                .secrets
400                .gemini_api_key
401                .as_ref()
402                .ok_or_else(|| {
403                    BootstrapError::Provider("ZEPH_GEMINI_API_KEY not found in vault".into())
404                })?
405                .expose()
406                .to_owned();
407            let model = entry
408                .model
409                .clone()
410                .unwrap_or_else(|| "gemini-2.0-flash".to_owned());
411            let max_tokens = entry.max_tokens.unwrap_or(8192);
412            let base_url = entry
413                .base_url
414                .clone()
415                .unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_owned());
416            let mut provider = GeminiProvider::new(api_key, model, max_tokens)
417                .with_base_url(base_url)
418                .with_client(llm_client(config.timeouts.llm_request_timeout_secs));
419            if let Some(ref em) = entry.embedding_model {
420                provider = provider.with_embedding_model(em.clone());
421            }
422            if let Some(level) = entry.thinking_level {
423                provider = provider.with_thinking_level(level);
424            }
425            if let Some(budget) = entry.thinking_budget {
426                provider = provider
427                    .with_thinking_budget(budget)
428                    .map_err(|e| BootstrapError::Provider(e.to_string()))?;
429            }
430            if let Some(include) = entry.include_thoughts {
431                provider = provider.with_include_thoughts(include);
432            }
433            Ok(AnyProvider::Gemini(provider))
434        }
435        ProviderKind::Compatible => {
436            let name = entry.name.as_deref().ok_or_else(|| {
437                BootstrapError::Provider(
438                    "compatible provider requires 'name' field in [[llm.providers]]".into(),
439                )
440            })?;
441            let base_url = entry.base_url.clone().ok_or_else(|| {
442                BootstrapError::Provider(format!(
443                    "compatible provider '{name}' requires 'base_url'"
444                ))
445            })?;
446            let model = entry.model.clone().unwrap_or_default();
447            let api_key = entry.api_key.clone().unwrap_or_else(|| {
448                config
449                    .secrets
450                    .compatible_api_keys
451                    .get(name)
452                    .map(|s| s.expose().to_owned())
453                    .unwrap_or_default()
454            });
455            let max_tokens = entry.max_tokens.unwrap_or(4096);
456            Ok(AnyProvider::Compatible(CompatibleProvider::new(
457                name.to_owned(),
458                api_key,
459                base_url,
460                model,
461                max_tokens,
462                entry.embedding_model.clone(),
463            )))
464        }
465        #[cfg(feature = "candle")]
466        ProviderKind::Candle => {
467            let candle = entry.candle.as_ref().ok_or_else(|| {
468                BootstrapError::Provider(
469                    "candle provider requires 'candle' section in [[llm.providers]]".into(),
470                )
471            })?;
472            let source = match candle.source.as_str() {
473                "local" => zeph_llm::candle_provider::loader::ModelSource::Local {
474                    path: std::path::PathBuf::from(&candle.local_path),
475                },
476                _ => zeph_llm::candle_provider::loader::ModelSource::HuggingFace {
477                    repo_id: entry
478                        .model
479                        .clone()
480                        .unwrap_or_else(|| config.llm.effective_model().to_owned()),
481                    filename: candle.filename.clone(),
482                },
483            };
484            let candle_cfg_adapter = crate::config::CandleConfig {
485                source: candle.source.clone(),
486                local_path: candle.local_path.clone(),
487                filename: candle.filename.clone(),
488                chat_template: candle.chat_template.clone(),
489                device: candle.device.clone(),
490                embedding_repo: candle.embedding_repo.clone(),
491                hf_token: candle.hf_token.clone(),
492                generation: candle.generation.clone(),
493            };
494            build_candle_provider(&source, &candle_cfg_adapter, &candle.device)
495        }
496        #[cfg(not(feature = "candle"))]
497        ProviderKind::Candle => Err(BootstrapError::Provider(
498            "candle feature is not enabled".into(),
499        )),
500    }
501}
502
503/// Build the primary `AnyProvider` from the new `[[llm.providers]]` pool.
504///
505/// When `[llm] routing` is set to a non-None strategy, all providers in the pool are
506/// initialized and wrapped in a `RouterProvider` with the appropriate strategy.
507/// When routing is `None`, selects the provider marked `default = true` (or the first
508/// entry) and falls back to subsequent entries on initialization failure.
509#[allow(clippy::too_many_lines)]
510fn create_provider_from_pool(config: &Config) -> Result<AnyProvider, BootstrapError> {
511    let pool = &config.llm.providers;
512
513    // Empty pool → default Ollama on localhost.
514    if pool.is_empty() {
515        let base_url = config.llm.effective_base_url();
516        let model = config.llm.effective_model();
517        let embed = &config.llm.embedding_model;
518        return Ok(AnyProvider::Ollama(OllamaProvider::new(
519            base_url,
520            model.to_owned(),
521            embed.clone(),
522        )));
523    }
524
525    match config.llm.routing {
526        LlmRoutingStrategy::None => build_single_provider_from_pool(pool, config),
527        LlmRoutingStrategy::Ema => {
528            let providers = build_all_pool_providers(pool, config)?;
529            let raw_alpha = config.llm.router_ema_alpha;
530            let alpha = raw_alpha.clamp(f64::MIN_POSITIVE, 1.0);
531            if (alpha - raw_alpha).abs() > f64::EPSILON {
532                tracing::warn!(
533                    raw_alpha,
534                    clamped = alpha,
535                    "router_ema_alpha out of range [MIN_POSITIVE, 1.0], clamped"
536                );
537            }
538            let router =
539                RouterProvider::new(providers).with_ema(alpha, config.llm.router_reorder_interval);
540            Ok(AnyProvider::Router(Box::new(apply_routing_signals(
541                router, config,
542            ))))
543        }
544        LlmRoutingStrategy::Thompson => {
545            let providers = build_all_pool_providers(pool, config)?;
546            let state_path = config
547                .llm
548                .router
549                .as_ref()
550                .and_then(|r| r.thompson_state_path.as_deref())
551                .map(std::path::Path::new);
552            let router = RouterProvider::new(providers).with_thompson(state_path);
553            Ok(AnyProvider::Router(Box::new(apply_routing_signals(
554                router, config,
555            ))))
556        }
557        LlmRoutingStrategy::Cascade => {
558            let providers = build_all_pool_providers(pool, config)?;
559            let cascade_cfg = config
560                .llm
561                .router
562                .as_ref()
563                .and_then(|r| r.cascade.clone())
564                .unwrap_or_default();
565            let router_cascade_cfg = build_cascade_router_config(&cascade_cfg, config);
566            let embed_concurrency = config
567                .llm
568                .router
569                .as_ref()
570                .map_or(4, |r| r.embed_concurrency);
571            Ok(AnyProvider::Router(Box::new(
572                RouterProvider::new(providers)
573                    .with_cascade(router_cascade_cfg)
574                    .with_embed_concurrency(embed_concurrency),
575            )))
576        }
577        LlmRoutingStrategy::Bandit => {
578            let providers = build_all_pool_providers(pool, config)?;
579            let bandit_cfg = config
580                .llm
581                .router
582                .as_ref()
583                .and_then(|r| r.bandit.clone())
584                .unwrap_or_default();
585            let state_path = bandit_cfg.state_path.as_deref().map(std::path::Path::new);
586            let router_bandit_cfg = BanditRouterConfig {
587                alpha: bandit_cfg.alpha,
588                dim: bandit_cfg.dim,
589                cost_weight: bandit_cfg.cost_weight.clamp(0.0, 1.0),
590                decay_factor: bandit_cfg.decay_factor,
591                warmup_queries: bandit_cfg.warmup_queries.unwrap_or(0),
592                embedding_timeout_ms: bandit_cfg.embedding_timeout_ms,
593                cache_size: bandit_cfg.cache_size,
594                memory_confidence_threshold: bandit_cfg.memory_confidence_threshold.clamp(0.0, 1.0),
595            };
596            // Resolve embedding provider for feature vectors.
597            let embed_provider = if bandit_cfg.embedding_provider.is_empty() {
598                None
599            } else if let Some(entry) = pool
600                .iter()
601                .find(|e| e.effective_name() == bandit_cfg.embedding_provider.as_str())
602            {
603                match build_provider_from_entry(entry, config) {
604                    Ok(p) => Some(p),
605                    Err(e) => {
606                        tracing::warn!(
607                            provider = %bandit_cfg.embedding_provider,
608                            error = %e,
609                            "bandit: embedding provider failed to init, bandit will use Thompson fallback"
610                        );
611                        None
612                    }
613                }
614            } else {
615                tracing::warn!(
616                    provider = %bandit_cfg.embedding_provider,
617                    "bandit: embedding_provider not found in [[llm.providers]], \
618                     bandit will use Thompson fallback"
619                );
620                None
621            };
622            let embed_concurrency = config
623                .llm
624                .router
625                .as_ref()
626                .map_or(4, |r| r.embed_concurrency);
627            Ok(AnyProvider::Router(Box::new(
628                RouterProvider::new(providers)
629                    .with_bandit(router_bandit_cfg, state_path, embed_provider)
630                    .with_embed_concurrency(embed_concurrency),
631            )))
632        }
633        LlmRoutingStrategy::Task => {
634            // Task-based routing is not yet implemented; fall back to single provider.
635            tracing::warn!(
636                "routing = \"task\" is not yet implemented; \
637                 falling back to single provider from pool"
638            );
639            build_single_provider_from_pool(pool, config)
640        }
641        LlmRoutingStrategy::Triage => build_triage_provider(pool, config),
642    }
643}
644
645/// Initialize all providers in the pool, skipping those that fail with a warning.
646/// Returns an error if no provider could be initialized.
647fn build_all_pool_providers(
648    pool: &[ProviderEntry],
649    config: &Config,
650) -> Result<Vec<AnyProvider>, BootstrapError> {
651    let mut providers = Vec::new();
652    for entry in pool {
653        if entry.embed {
654            continue;
655        }
656        match build_provider_from_entry(entry, config) {
657            Ok(p) => providers.push(p),
658            Err(e) => {
659                tracing::warn!(
660                    provider = entry.name.as_deref().unwrap_or("?"),
661                    error = %e,
662                    "skipping pool provider during routing initialization"
663                );
664            }
665        }
666    }
667    if providers.is_empty() {
668        return Err(BootstrapError::Provider(
669            "routing enabled but no providers in [[llm.providers]] could be initialized".into(),
670        ));
671    }
672    Ok(providers)
673}
674
675/// Build a `TriageRouter`-backed `AnyProvider` from the pool.
676///
677/// Reads `[llm.complexity_routing]` config and constructs tier providers by name lookup.
678/// If `bypass_single_provider = true` and all configured tiers resolve to the same provider,
679/// returns a single provider instead of wrapping in a `TriageRouter`.
680fn build_triage_provider(
681    pool: &[crate::config::ProviderEntry],
682    config: &crate::config::Config,
683) -> Result<AnyProvider, BootstrapError> {
684    let cr = config.llm.complexity_routing.as_ref().ok_or_else(|| {
685        BootstrapError::Provider(
686            "routing = \"triage\" requires [llm.complexity_routing] section".into(),
687        )
688    })?;
689
690    // Resolve triage classification provider.
691    let default_triage_name = pool
692        .first()
693        .map(crate::config::ProviderEntry::effective_name)
694        .unwrap_or_default();
695    let triage_prov_name = cr
696        .triage_provider
697        .as_deref()
698        .unwrap_or(default_triage_name.as_str());
699    let triage_provider = create_named_provider(triage_prov_name, config).map_err(|e| {
700        BootstrapError::Provider(format!(
701            "triage_provider '{triage_prov_name}' not found in [[llm.providers]]: {e}"
702        ))
703    })?;
704
705    // Build tier provider list. Tiers not configured in the mapping are skipped.
706    let tier_config: [(ComplexityTier, Option<&str>); 4] = [
707        (ComplexityTier::Simple, cr.tiers.simple.as_deref()),
708        (ComplexityTier::Medium, cr.tiers.medium.as_deref()),
709        (ComplexityTier::Complex, cr.tiers.complex.as_deref()),
710        (ComplexityTier::Expert, cr.tiers.expert.as_deref()),
711    ];
712
713    // Collect (tier, config_name, provider) triples.
714    // Bypass detection compares config names (not provider.name()) to correctly distinguish
715    // two pool entries using the same provider type (e.g., two Claude configs for Haiku + Opus).
716    let mut tier_providers: Vec<(ComplexityTier, AnyProvider)> = Vec::new();
717    let mut tier_config_names: Vec<&str> = Vec::new();
718    for (tier, maybe_name) in &tier_config {
719        let Some(name) = maybe_name else { continue };
720        match create_named_provider(name, config) {
721            Ok(p) => {
722                tier_providers.push((*tier, p));
723                tier_config_names.push(name);
724            }
725            Err(e) => {
726                tracing::warn!(
727                    tier = tier.as_str(),
728                    provider = name,
729                    error = %e,
730                    "triage: skipping tier provider (not found in pool)"
731                );
732            }
733        }
734    }
735
736    if tier_providers.is_empty() {
737        // No tiers configured — fall through to single provider.
738        tracing::warn!(
739            "triage routing: no tier providers configured, \
740             falling back to single provider"
741        );
742        return build_single_provider_from_pool(pool, config);
743    }
744
745    // bypass_single_provider: if all tiers reference the same config entry name, skip triage.
746    if cr.bypass_single_provider
747        && let Some(first_name) = tier_config_names
748            .first()
749            .copied()
750            .filter(|&n| tier_config_names.iter().all(|m| *m == n))
751    {
752        tracing::debug!(
753            provider = first_name,
754            "triage routing: all tiers map to same config entry, bypassing triage"
755        );
756        return build_single_provider_from_pool(pool, config);
757    }
758
759    let router = TriageRouter::new(
760        triage_provider,
761        tier_providers,
762        cr.triage_timeout_secs,
763        cr.max_triage_tokens,
764    );
765    Ok(AnyProvider::Triage(Box::new(router)))
766}
767
768/// Pick the default (or first) provider from the pool with fallback on failure.
769fn build_single_provider_from_pool(
770    pool: &[ProviderEntry],
771    config: &Config,
772) -> Result<AnyProvider, BootstrapError> {
773    let primary_idx = pool.iter().position(|e| e.default).unwrap_or(0);
774    let primary = &pool[primary_idx];
775    match build_provider_from_entry(primary, config) {
776        Ok(p) => Ok(p),
777        Err(e) => {
778            let name = primary.name.as_deref().unwrap_or("primary");
779            tracing::warn!(provider = name, error = %e, "primary provider failed, trying next");
780            for (i, entry) in pool.iter().enumerate() {
781                if i == primary_idx {
782                    continue;
783                }
784                match build_provider_from_entry(entry, config) {
785                    Ok(p) => return Ok(p),
786                    Err(e2) => {
787                        tracing::warn!(
788                            provider = entry.name.as_deref().unwrap_or("?"),
789                            error = %e2,
790                            "fallback provider failed"
791                        );
792                    }
793                }
794            }
795            Err(BootstrapError::Provider(format!(
796                "all providers in [[llm.providers]] failed to initialize; first error: {e}"
797            )))
798        }
799    }
800}
801
802#[cfg(test)]
803mod tests {
804    use std::path::Path;
805
806    use crate::config::{Config, ProviderEntry, ProviderKind};
807
808    use super::build_all_pool_providers;
809
810    #[test]
811    fn excludes_embed_only_entry() {
812        let mut config = Config::load(Path::new("/nonexistent")).unwrap();
813        config.llm.providers = vec![
814            ProviderEntry {
815                provider_type: ProviderKind::Ollama,
816                name: Some("chat".into()),
817                model: Some("qwen3:8b".into()),
818                embed: false,
819                ..ProviderEntry::default()
820            },
821            ProviderEntry {
822                provider_type: ProviderKind::Ollama,
823                name: Some("embedder".into()),
824                model: Some("nomic-embed-text".into()),
825                embed: true,
826                ..ProviderEntry::default()
827            },
828        ];
829        let providers = build_all_pool_providers(&config.llm.providers, &config).unwrap();
830        assert_eq!(providers.len(), 1);
831    }
832
833    #[test]
834    fn includes_all_non_embed_entries() {
835        let mut config = Config::load(Path::new("/nonexistent")).unwrap();
836        config.llm.providers = vec![
837            ProviderEntry {
838                provider_type: ProviderKind::Ollama,
839                name: Some("chat1".into()),
840                model: Some("qwen3:8b".into()),
841                embed: false,
842                ..ProviderEntry::default()
843            },
844            ProviderEntry {
845                provider_type: ProviderKind::Ollama,
846                name: Some("chat2".into()),
847                model: Some("qwen3:1.7b".into()),
848                embed: false,
849                ..ProviderEntry::default()
850            },
851        ];
852        let providers = build_all_pool_providers(&config.llm.providers, &config).unwrap();
853        assert_eq!(providers.len(), 2);
854    }
855
856    #[test]
857    fn errors_when_all_providers_are_embed_only() {
858        let mut config = Config::load(Path::new("/nonexistent")).unwrap();
859        config.llm.providers = vec![ProviderEntry {
860            provider_type: ProviderKind::Ollama,
861            name: Some("embedder".into()),
862            model: Some("nomic-embed-text".into()),
863            embed: true,
864            ..ProviderEntry::default()
865        }];
866        let result = build_all_pool_providers(&config.llm.providers, &config);
867        assert!(result.is_err());
868    }
869}