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::{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/// Look up a provider entry from the pool by name (exact match on `effective_name()`) or type.
111///
112/// Used by quarantine, guardrail, judge, and experiment eval model resolution.
113pub fn create_named_provider(name: &str, config: &Config) -> Result<AnyProvider, BootstrapError> {
114    let entry = config
115        .llm
116        .providers
117        .iter()
118        .find(|e| e.effective_name() == name || e.provider_type.as_str() == name)
119        .ok_or_else(|| {
120            BootstrapError::Provider(format!("provider '{name}' not found in [[llm.providers]]"))
121        })?;
122    build_provider_from_entry(entry, config)
123}
124
125/// Create an `AnyProvider` for use as the summarization provider.
126///
127/// `model_spec` format (set via `[llm] summary_model`):
128/// - `<name>` — looks up a provider by name in `[[llm.providers]]`
129/// - `ollama/<model>` — Ollama shorthand: uses the ollama provider from pool with model override
130/// - `claude[/<model>]`, `openai[/<model>]`, `gemini[/<model>]` — type shorthand with optional model
131pub fn create_summary_provider(
132    model_spec: &str,
133    config: &Config,
134) -> Result<AnyProvider, BootstrapError> {
135    // Try direct name lookup first (e.g. "claude", "my-openai").
136    if let Some(entry) = config
137        .llm
138        .providers
139        .iter()
140        .find(|e| e.effective_name() == model_spec || e.provider_type.as_str() == model_spec)
141    {
142        return build_provider_from_entry(entry, config);
143    }
144
145    // Handle `type/model` shorthand: override the model on a matching provider.
146    if let Some(((_, model), entry)) = model_spec.split_once('/').and_then(|(b, m)| {
147        config
148            .llm
149            .providers
150            .iter()
151            .find(|e| e.provider_type.as_str() == b || e.effective_name() == b)
152            .map(|e| ((b, m), e))
153    }) {
154        let mut cloned = entry.clone();
155        cloned.model = Some(model.to_owned());
156        // Cap summary max_tokens at 4096 — summaries are short.
157        cloned.max_tokens = Some(cloned.max_tokens.unwrap_or(4096).min(4096));
158        return build_provider_from_entry(&cloned, config);
159    }
160
161    Err(BootstrapError::Provider(format!(
162        "summary_model '{model_spec}' not found in [[llm.providers]]. \
163         Use a provider name or 'type/model' shorthand (e.g. 'ollama/qwen3:1.7b')."
164    )))
165}
166
167#[cfg(feature = "candle")]
168pub fn select_device(
169    preference: &str,
170) -> Result<zeph_llm::candle_provider::Device, BootstrapError> {
171    match preference {
172        "metal" => {
173            #[cfg(feature = "metal")]
174            return zeph_llm::candle_provider::Device::new_metal(0)
175                .map_err(|e| BootstrapError::Provider(e.to_string()));
176            #[cfg(not(feature = "metal"))]
177            return Err(BootstrapError::Provider(
178                "candle compiled without metal feature".into(),
179            ));
180        }
181        "cuda" => {
182            #[cfg(feature = "cuda")]
183            return zeph_llm::candle_provider::Device::new_cuda(0)
184                .map_err(|e| BootstrapError::Provider(e.to_string()));
185            #[cfg(not(feature = "cuda"))]
186            return Err(BootstrapError::Provider(
187                "candle compiled without cuda feature".into(),
188            ));
189        }
190        "auto" => {
191            #[cfg(feature = "metal")]
192            if let Ok(device) = zeph_llm::candle_provider::Device::new_metal(0) {
193                return Ok(device);
194            }
195            #[cfg(feature = "cuda")]
196            if let Ok(device) = zeph_llm::candle_provider::Device::new_cuda(0) {
197                return Ok(device);
198            }
199            Ok(zeph_llm::candle_provider::Device::Cpu)
200        }
201        _ => Ok(zeph_llm::candle_provider::Device::Cpu),
202    }
203}
204
205#[cfg(feature = "candle")]
206fn build_candle_provider(
207    source: &zeph_llm::candle_provider::loader::ModelSource,
208    candle_cfg: &crate::config::CandleConfig,
209    device_pref: &str,
210) -> Result<AnyProvider, BootstrapError> {
211    let template =
212        zeph_llm::candle_provider::template::ChatTemplate::parse_str(&candle_cfg.chat_template);
213    let gen_config = zeph_llm::candle_provider::generate::GenerationConfig {
214        temperature: candle_cfg.generation.temperature,
215        top_p: candle_cfg.generation.top_p,
216        top_k: candle_cfg.generation.top_k,
217        max_tokens: candle_cfg.generation.capped_max_tokens(),
218        seed: candle_cfg.generation.seed,
219        repeat_penalty: candle_cfg.generation.repeat_penalty,
220        repeat_last_n: candle_cfg.generation.repeat_last_n,
221    };
222    let device = select_device(device_pref)?;
223    zeph_llm::candle_provider::CandleProvider::new(
224        source,
225        template,
226        gen_config,
227        candle_cfg.embedding_repo.as_deref(),
228        device,
229    )
230    .map(AnyProvider::Candle)
231    .map_err(|e| BootstrapError::Provider(e.to_string()))
232}
233
234/// Build an `AnyProvider` from a `ProviderEntry` using a runtime config snapshot.
235///
236/// Called by the `/provider <name>` slash command to switch providers at runtime without
237/// requiring the full `Config`. Router and Orchestrator provider kinds are not supported
238/// for runtime switching — they require the full provider pool to be re-initialized.
239///
240/// # Errors
241///
242/// Returns `BootstrapError::Provider` when the provider kind is unsupported for runtime
243/// switching, a required secret is missing, or the entry is misconfigured.
244pub fn build_provider_for_switch(
245    entry: &ProviderEntry,
246    snapshot: &ProviderConfigSnapshot,
247) -> Result<AnyProvider, BootstrapError> {
248    use zeph_common::secret::Secret;
249    // Reconstruct a minimal Config from the snapshot so we can reuse build_provider_from_entry.
250    // Only fields read by build_provider_from_entry are populated; everything else uses defaults.
251    // Secrets are stored as plain strings in the snapshot because Secret does not implement Clone.
252    let mut config = Config::default();
253    config.secrets.claude_api_key = snapshot.claude_api_key.as_deref().map(Secret::new);
254    config.secrets.openai_api_key = snapshot.openai_api_key.as_deref().map(Secret::new);
255    config.secrets.gemini_api_key = snapshot.gemini_api_key.as_deref().map(Secret::new);
256    config.secrets.compatible_api_keys = snapshot
257        .compatible_api_keys
258        .iter()
259        .map(|(k, v)| (k.clone(), Secret::new(v.as_str())))
260        .collect();
261    config.timeouts.llm_request_timeout_secs = snapshot.llm_request_timeout_secs;
262    config
263        .llm
264        .embedding_model
265        .clone_from(&snapshot.embedding_model);
266    build_provider_from_entry(entry, &config)
267}
268
269/// Build an `AnyProvider` from a unified `ProviderEntry` (new `[[llm.providers]]` format).
270///
271/// All provider-specific fields come from `entry`; the global `config` is used only for
272/// secrets and timeout settings.
273///
274/// # Errors
275///
276/// Returns `BootstrapError::Provider` when a required secret is missing or an entry is
277/// misconfigured (e.g. compatible provider without a name).
278#[allow(clippy::too_many_lines)]
279pub fn build_provider_from_entry(
280    entry: &ProviderEntry,
281    config: &Config,
282) -> Result<AnyProvider, BootstrapError> {
283    match entry.provider_type {
284        ProviderKind::Ollama => {
285            let base_url = entry
286                .base_url
287                .as_deref()
288                .unwrap_or("http://localhost:11434");
289            let model = entry.model.as_deref().unwrap_or("qwen3:8b").to_owned();
290            let embed = entry
291                .embedding_model
292                .clone()
293                .unwrap_or_else(|| config.llm.embedding_model.clone());
294            let tool_use = entry.tool_use;
295            let mut provider = OllamaProvider::new(base_url, model, embed).with_tool_use(tool_use);
296            if let Some(ref vm) = entry.vision_model {
297                provider = provider.with_vision_model(vm.clone());
298            }
299            Ok(AnyProvider::Ollama(provider))
300        }
301        ProviderKind::Claude => {
302            let api_key = config
303                .secrets
304                .claude_api_key
305                .as_ref()
306                .ok_or_else(|| {
307                    BootstrapError::Provider("ZEPH_CLAUDE_API_KEY not found in vault".into())
308                })?
309                .expose()
310                .to_owned();
311            let model = entry
312                .model
313                .clone()
314                .unwrap_or_else(|| "claude-haiku-4-5-20251001".to_owned());
315            let max_tokens = entry.max_tokens.unwrap_or(4096);
316            let provider = ClaudeProvider::new(api_key, model, max_tokens)
317                .with_client(llm_client(config.timeouts.llm_request_timeout_secs))
318                .with_extended_context(entry.enable_extended_context)
319                .with_thinking_opt(entry.thinking.clone())
320                .map_err(|e| BootstrapError::Provider(format!("invalid thinking config: {e}")))?
321                .with_server_compaction(entry.server_compaction);
322            Ok(AnyProvider::Claude(provider))
323        }
324        ProviderKind::OpenAi => {
325            let api_key = config
326                .secrets
327                .openai_api_key
328                .as_ref()
329                .ok_or_else(|| {
330                    BootstrapError::Provider("ZEPH_OPENAI_API_KEY not found in vault".into())
331                })?
332                .expose()
333                .to_owned();
334            let base_url = entry
335                .base_url
336                .clone()
337                .unwrap_or_else(|| "https://api.openai.com/v1".to_owned());
338            let model = entry
339                .model
340                .clone()
341                .unwrap_or_else(|| "gpt-4o-mini".to_owned());
342            let max_tokens = entry.max_tokens.unwrap_or(4096);
343            Ok(AnyProvider::OpenAi(
344                OpenAiProvider::new(
345                    api_key,
346                    base_url,
347                    model,
348                    max_tokens,
349                    entry.embedding_model.clone(),
350                    entry.reasoning_effort.clone(),
351                )
352                .with_client(llm_client(config.timeouts.llm_request_timeout_secs)),
353            ))
354        }
355        ProviderKind::Gemini => {
356            let api_key = config
357                .secrets
358                .gemini_api_key
359                .as_ref()
360                .ok_or_else(|| {
361                    BootstrapError::Provider("ZEPH_GEMINI_API_KEY not found in vault".into())
362                })?
363                .expose()
364                .to_owned();
365            let model = entry
366                .model
367                .clone()
368                .unwrap_or_else(|| "gemini-2.0-flash".to_owned());
369            let max_tokens = entry.max_tokens.unwrap_or(8192);
370            let base_url = entry
371                .base_url
372                .clone()
373                .unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_owned());
374            let mut provider = GeminiProvider::new(api_key, model, max_tokens)
375                .with_base_url(base_url)
376                .with_client(llm_client(config.timeouts.llm_request_timeout_secs));
377            if let Some(ref em) = entry.embedding_model {
378                provider = provider.with_embedding_model(em.clone());
379            }
380            if let Some(level) = entry.thinking_level {
381                provider = provider.with_thinking_level(level);
382            }
383            if let Some(budget) = entry.thinking_budget {
384                provider = provider
385                    .with_thinking_budget(budget)
386                    .map_err(|e| BootstrapError::Provider(e.to_string()))?;
387            }
388            if let Some(include) = entry.include_thoughts {
389                provider = provider.with_include_thoughts(include);
390            }
391            Ok(AnyProvider::Gemini(provider))
392        }
393        ProviderKind::Compatible => {
394            let name = entry.name.as_deref().ok_or_else(|| {
395                BootstrapError::Provider(
396                    "compatible provider requires 'name' field in [[llm.providers]]".into(),
397                )
398            })?;
399            let base_url = entry.base_url.clone().ok_or_else(|| {
400                BootstrapError::Provider(format!(
401                    "compatible provider '{name}' requires 'base_url'"
402                ))
403            })?;
404            let model = entry.model.clone().unwrap_or_default();
405            let api_key = entry.api_key.clone().unwrap_or_else(|| {
406                config
407                    .secrets
408                    .compatible_api_keys
409                    .get(name)
410                    .map(|s| s.expose().to_owned())
411                    .unwrap_or_default()
412            });
413            let max_tokens = entry.max_tokens.unwrap_or(4096);
414            Ok(AnyProvider::Compatible(CompatibleProvider::new(
415                name.to_owned(),
416                api_key,
417                base_url,
418                model,
419                max_tokens,
420                entry.embedding_model.clone(),
421            )))
422        }
423        #[cfg(feature = "candle")]
424        ProviderKind::Candle => {
425            let candle = entry.candle.as_ref().ok_or_else(|| {
426                BootstrapError::Provider(
427                    "candle provider requires 'candle' section in [[llm.providers]]".into(),
428                )
429            })?;
430            let source = match candle.source.as_str() {
431                "local" => zeph_llm::candle_provider::loader::ModelSource::Local {
432                    path: std::path::PathBuf::from(&candle.local_path),
433                },
434                _ => zeph_llm::candle_provider::loader::ModelSource::HuggingFace {
435                    repo_id: entry
436                        .model
437                        .clone()
438                        .unwrap_or_else(|| config.llm.effective_model().to_owned()),
439                    filename: candle.filename.clone(),
440                },
441            };
442            let candle_cfg_adapter = crate::config::CandleConfig {
443                source: candle.source.clone(),
444                local_path: candle.local_path.clone(),
445                filename: candle.filename.clone(),
446                chat_template: candle.chat_template.clone(),
447                device: candle.device.clone(),
448                embedding_repo: candle.embedding_repo.clone(),
449                generation: candle.generation.clone(),
450            };
451            build_candle_provider(&source, &candle_cfg_adapter, &candle.device)
452        }
453        #[cfg(not(feature = "candle"))]
454        ProviderKind::Candle => Err(BootstrapError::Provider(
455            "candle feature is not enabled".into(),
456        )),
457    }
458}
459
460/// Build the primary `AnyProvider` from the new `[[llm.providers]]` pool.
461///
462/// When `[llm] routing` is set to a non-None strategy, all providers in the pool are
463/// initialized and wrapped in a `RouterProvider` with the appropriate strategy.
464/// When routing is `None`, selects the provider marked `default = true` (or the first
465/// entry) and falls back to subsequent entries on initialization failure.
466#[allow(clippy::too_many_lines)]
467fn create_provider_from_pool(config: &Config) -> Result<AnyProvider, BootstrapError> {
468    let pool = &config.llm.providers;
469
470    // Empty pool → default Ollama on localhost.
471    if pool.is_empty() {
472        let base_url = config.llm.effective_base_url();
473        let model = config.llm.effective_model();
474        let embed = &config.llm.embedding_model;
475        return Ok(AnyProvider::Ollama(OllamaProvider::new(
476            base_url,
477            model.to_owned(),
478            embed.clone(),
479        )));
480    }
481
482    match config.llm.routing {
483        LlmRoutingStrategy::None => build_single_provider_from_pool(pool, config),
484        LlmRoutingStrategy::Ema => {
485            let providers = build_all_pool_providers(pool, config)?;
486            let raw_alpha = config.llm.router_ema_alpha;
487            let alpha = raw_alpha.clamp(f64::MIN_POSITIVE, 1.0);
488            if (alpha - raw_alpha).abs() > f64::EPSILON {
489                tracing::warn!(
490                    raw_alpha,
491                    clamped = alpha,
492                    "router_ema_alpha out of range [MIN_POSITIVE, 1.0], clamped"
493                );
494            }
495            Ok(AnyProvider::Router(Box::new(
496                RouterProvider::new(providers).with_ema(alpha, config.llm.router_reorder_interval),
497            )))
498        }
499        LlmRoutingStrategy::Thompson => {
500            let providers = build_all_pool_providers(pool, config)?;
501            let state_path = config
502                .llm
503                .router
504                .as_ref()
505                .and_then(|r| r.thompson_state_path.as_deref())
506                .map(std::path::Path::new);
507            Ok(AnyProvider::Router(Box::new(
508                RouterProvider::new(providers).with_thompson(state_path),
509            )))
510        }
511        LlmRoutingStrategy::Cascade => {
512            let providers = build_all_pool_providers(pool, config)?;
513            let cascade_cfg = config
514                .llm
515                .router
516                .as_ref()
517                .and_then(|r| r.cascade.clone())
518                .unwrap_or_default();
519            let router_cascade_cfg = build_cascade_router_config(&cascade_cfg, config);
520            Ok(AnyProvider::Router(Box::new(
521                RouterProvider::new(providers).with_cascade(router_cascade_cfg),
522            )))
523        }
524        LlmRoutingStrategy::Task => {
525            // Task-based routing is not yet implemented; fall back to single provider.
526            tracing::warn!(
527                "routing = \"task\" is not yet implemented; \
528                 falling back to single provider from pool"
529            );
530            build_single_provider_from_pool(pool, config)
531        }
532        LlmRoutingStrategy::Triage => build_triage_provider(pool, config),
533    }
534}
535
536/// Initialize all providers in the pool, skipping those that fail with a warning.
537/// Returns an error if no provider could be initialized.
538fn build_all_pool_providers(
539    pool: &[ProviderEntry],
540    config: &Config,
541) -> Result<Vec<AnyProvider>, BootstrapError> {
542    let mut providers = Vec::new();
543    for entry in pool {
544        match build_provider_from_entry(entry, config) {
545            Ok(p) => providers.push(p),
546            Err(e) => {
547                tracing::warn!(
548                    provider = entry.name.as_deref().unwrap_or("?"),
549                    error = %e,
550                    "skipping pool provider during routing initialization"
551                );
552            }
553        }
554    }
555    if providers.is_empty() {
556        return Err(BootstrapError::Provider(
557            "routing enabled but no providers in [[llm.providers]] could be initialized".into(),
558        ));
559    }
560    Ok(providers)
561}
562
563/// Build a `TriageRouter`-backed `AnyProvider` from the pool.
564///
565/// Reads `[llm.complexity_routing]` config and constructs tier providers by name lookup.
566/// If `bypass_single_provider = true` and all configured tiers resolve to the same provider,
567/// returns a single provider instead of wrapping in a `TriageRouter`.
568fn build_triage_provider(
569    pool: &[crate::config::ProviderEntry],
570    config: &crate::config::Config,
571) -> Result<AnyProvider, BootstrapError> {
572    let cr = config.llm.complexity_routing.as_ref().ok_or_else(|| {
573        BootstrapError::Provider(
574            "routing = \"triage\" requires [llm.complexity_routing] section".into(),
575        )
576    })?;
577
578    // Resolve triage classification provider.
579    let default_triage_name = pool
580        .first()
581        .map(crate::config::ProviderEntry::effective_name)
582        .unwrap_or_default();
583    let triage_prov_name = cr
584        .triage_provider
585        .as_deref()
586        .unwrap_or(default_triage_name.as_str());
587    let triage_provider = create_named_provider(triage_prov_name, config).map_err(|e| {
588        BootstrapError::Provider(format!(
589            "triage_provider '{triage_prov_name}' not found in [[llm.providers]]: {e}"
590        ))
591    })?;
592
593    // Build tier provider list. Tiers not configured in the mapping are skipped.
594    let tier_config: [(ComplexityTier, Option<&str>); 4] = [
595        (ComplexityTier::Simple, cr.tiers.simple.as_deref()),
596        (ComplexityTier::Medium, cr.tiers.medium.as_deref()),
597        (ComplexityTier::Complex, cr.tiers.complex.as_deref()),
598        (ComplexityTier::Expert, cr.tiers.expert.as_deref()),
599    ];
600
601    // Collect (tier, config_name, provider) triples.
602    // Bypass detection compares config names (not provider.name()) to correctly distinguish
603    // two pool entries using the same provider type (e.g., two Claude configs for Haiku + Opus).
604    let mut tier_providers: Vec<(ComplexityTier, AnyProvider)> = Vec::new();
605    let mut tier_config_names: Vec<&str> = Vec::new();
606    for (tier, maybe_name) in &tier_config {
607        let Some(name) = maybe_name else { continue };
608        match create_named_provider(name, config) {
609            Ok(p) => {
610                tier_providers.push((*tier, p));
611                tier_config_names.push(name);
612            }
613            Err(e) => {
614                tracing::warn!(
615                    tier = tier.as_str(),
616                    provider = name,
617                    error = %e,
618                    "triage: skipping tier provider (not found in pool)"
619                );
620            }
621        }
622    }
623
624    if tier_providers.is_empty() {
625        // No tiers configured — fall through to single provider.
626        tracing::warn!(
627            "triage routing: no tier providers configured, \
628             falling back to single provider"
629        );
630        return build_single_provider_from_pool(pool, config);
631    }
632
633    // bypass_single_provider: if all tiers reference the same config entry name, skip triage.
634    if cr.bypass_single_provider
635        && let Some(first_name) = tier_config_names
636            .first()
637            .copied()
638            .filter(|&n| tier_config_names.iter().all(|m| *m == n))
639    {
640        tracing::debug!(
641            provider = first_name,
642            "triage routing: all tiers map to same config entry, bypassing triage"
643        );
644        return build_single_provider_from_pool(pool, config);
645    }
646
647    let router = TriageRouter::new(
648        triage_provider,
649        tier_providers,
650        cr.triage_timeout_secs,
651        cr.max_triage_tokens,
652    );
653    Ok(AnyProvider::Triage(Box::new(router)))
654}
655
656/// Pick the default (or first) provider from the pool with fallback on failure.
657fn build_single_provider_from_pool(
658    pool: &[ProviderEntry],
659    config: &Config,
660) -> Result<AnyProvider, BootstrapError> {
661    let primary_idx = pool.iter().position(|e| e.default).unwrap_or(0);
662    let primary = &pool[primary_idx];
663    match build_provider_from_entry(primary, config) {
664        Ok(p) => Ok(p),
665        Err(e) => {
666            let name = primary.name.as_deref().unwrap_or("primary");
667            tracing::warn!(provider = name, error = %e, "primary provider failed, trying next");
668            for (i, entry) in pool.iter().enumerate() {
669                if i == primary_idx {
670                    continue;
671                }
672                match build_provider_from_entry(entry, config) {
673                    Ok(p) => return Ok(p),
674                    Err(e2) => {
675                        tracing::warn!(
676                            provider = entry.name.as_deref().unwrap_or("?"),
677                            error = %e2,
678                            "fallback provider failed"
679                        );
680                    }
681                }
682            }
683            Err(BootstrapError::Provider(format!(
684                "all providers in [[llm.providers]] failed to initialize; first error: {e}"
685            )))
686        }
687    }
688}