1use zeph_llm::any::AnyProvider;
5use zeph_llm::router::triage::{ComplexityTier, TriageRouter};
6
7#[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 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 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 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
110fn apply_routing_signals(router: RouterProvider, config: &Config) -> RouterProvider {
112 let router_cfg = config.llm.router.as_ref();
113 let mut router = router;
114
115 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 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 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
152pub 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
167pub fn create_summary_provider(
174 model_spec: &str,
175 config: &Config,
176) -> Result<AnyProvider, BootstrapError> {
177 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 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 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
277pub fn build_provider_for_switch(
288 entry: &ProviderEntry,
289 snapshot: &ProviderConfigSnapshot,
290) -> Result<AnyProvider, BootstrapError> {
291 use zeph_common::secret::Secret;
292 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#[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#[allow(clippy::too_many_lines)]
510fn create_provider_from_pool(config: &Config) -> Result<AnyProvider, BootstrapError> {
511 let pool = &config.llm.providers;
512
513 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 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 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
645fn 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
675fn 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 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 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 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 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 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
768fn 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}