1use zeph_llm::any::AnyProvider;
12use zeph_llm::claude::ClaudeProvider;
13#[cfg(feature = "cocoon")]
14use zeph_llm::cocoon::{CocoonClient, CocoonProvider};
15use zeph_llm::compatible::CompatibleProvider;
16use zeph_llm::gemini::GeminiProvider;
17#[cfg(feature = "gonka")]
18use zeph_llm::gonka::endpoints::{EndpointPool, GonkaEndpoint};
19#[cfg(feature = "gonka")]
20use zeph_llm::gonka::{GonkaProvider, RequestSigner};
21use zeph_llm::http::llm_client;
22use zeph_llm::ollama::OllamaProvider;
23use zeph_llm::openai::OpenAiProvider;
24#[cfg(feature = "gonka")]
25use zeroize::Zeroizing;
26
27use crate::agent::state::ProviderConfigSnapshot;
28#[cfg(feature = "candle")]
29use crate::config::{CandleDevice, CandleInlineConfig, CandleSource};
30use crate::config::{Config, ProviderEntry, ProviderKind};
31
32#[non_exhaustive]
33#[derive(Debug, thiserror::Error)]
40pub enum BootstrapError {
41 #[error("config error: {0}")]
43 Config(#[from] crate::config::ConfigError),
44 #[error("provider error: {0}")]
46 Provider(String),
47 #[error("memory error: {0}")]
49 Memory(String),
50 #[error("vault init error: {0}")]
52 VaultInit(crate::vault::AgeVaultError),
53 #[error("I/O error: {0}")]
55 Io(#[from] std::io::Error),
56}
57
58pub fn build_provider_for_switch(
75 entry: &ProviderEntry,
76 snapshot: &ProviderConfigSnapshot,
77 secret_registry: Option<&std::sync::Arc<zeph_sanitizer::secret_mask::SecretMaskRegistry>>,
78) -> Result<AnyProvider, BootstrapError> {
79 use zeph_common::secret::Secret;
80 let mut config = Config::default();
84 config.secrets.claude_api_key = snapshot.claude_api_key.as_deref().map(Secret::new);
85 config.secrets.openai_api_key = snapshot.openai_api_key.as_deref().map(Secret::new);
86 config.secrets.gemini_api_key = snapshot.gemini_api_key.as_deref().map(Secret::new);
87 config.secrets.compatible_api_keys = snapshot
88 .compatible_api_keys
89 .iter()
90 .map(|(k, v)| (k.clone(), Secret::new(v.as_str())))
91 .collect();
92 config.secrets.gonka_private_key = snapshot
93 .gonka_private_key
94 .as_ref()
95 .map(|z| Secret::new(z.as_str()));
96 config.secrets.gonka_address = snapshot.gonka_address.as_deref().map(Secret::new);
97 config.secrets.cocoon_access_hash = snapshot.cocoon_access_hash.as_deref().map(Secret::new);
98 config.timeouts.llm_request_timeout_secs = snapshot.llm_request_timeout_secs;
99 config
100 .llm
101 .embedding_model
102 .clone_from(&snapshot.embedding_model);
103 build_provider_from_entry(entry, &config, secret_registry)
104}
105
106pub fn build_provider_from_entry(
125 entry: &ProviderEntry,
126 config: &Config,
127 secret_registry: Option<&std::sync::Arc<zeph_sanitizer::secret_mask::SecretMaskRegistry>>,
128) -> Result<AnyProvider, BootstrapError> {
129 let provider = build_provider_from_entry_inner(entry, config)?;
130 Ok(match secret_registry {
131 Some(registry) => provider.masked(std::sync::Arc::clone(registry)
132 as std::sync::Arc<dyn zeph_llm::masking::OutboundMasker>),
133 None => provider,
134 })
135}
136
137fn build_provider_from_entry_inner(
138 entry: &ProviderEntry,
139 config: &Config,
140) -> Result<AnyProvider, BootstrapError> {
141 match entry.provider_type {
142 ProviderKind::Ollama => Ok(build_ollama_provider(entry, config)),
143 ProviderKind::Claude => build_claude_provider(entry, config),
144 ProviderKind::OpenAi => build_openai_provider(entry, config),
145 ProviderKind::Gemini => build_gemini_provider(entry, config),
146 ProviderKind::Compatible => build_compatible_provider(entry, config),
147 #[cfg(feature = "candle")]
148 ProviderKind::Candle => build_candle_provider(entry, config),
149 #[cfg(not(feature = "candle"))]
150 ProviderKind::Candle => Err(BootstrapError::Provider(
151 "candle feature is not enabled".into(),
152 )),
153 #[cfg(feature = "gonka")]
154 ProviderKind::Gonka => build_gonka_provider(entry, config),
155 #[cfg(not(feature = "gonka"))]
156 ProviderKind::Gonka => Err(BootstrapError::Provider(
157 "gonka feature is not enabled; rebuild with --features gonka".into(),
158 )),
159 #[cfg(feature = "cocoon")]
160 ProviderKind::Cocoon => build_cocoon_provider(entry, config),
161 #[cfg(not(feature = "cocoon"))]
162 ProviderKind::Cocoon => Err(BootstrapError::Provider(
163 "cocoon feature is not enabled; rebuild with --features cocoon".into(),
164 )),
165 _ => Err(BootstrapError::Provider(format!(
166 "unknown provider kind: {:?}",
167 entry.provider_type
168 ))),
169 }
170}
171
172#[must_use]
187pub fn resolve_named_provider(config: &Config, primary: &AnyProvider, name: &str) -> AnyProvider {
188 if name.is_empty() {
189 return primary.clone();
190 }
191 let Some(entry) = config
192 .llm
193 .providers
194 .iter()
195 .find(|e| e.effective_name().eq_ignore_ascii_case(name))
196 else {
197 tracing::warn!(
198 provider = name,
199 "provider not found in [[llm.providers]], falling back to primary"
200 );
201 return primary.clone();
202 };
203 match build_provider_from_entry(entry, config, None) {
204 Ok(provider) => provider,
205 Err(e) => {
206 tracing::warn!(error = %e, provider = name, "failed to build named provider, falling back to primary");
207 primary.clone()
208 }
209 }
210}
211
212#[must_use]
225pub fn build_resume_condenser(
226 config: &Config,
227 primary_provider: &AnyProvider,
228) -> (
229 zeph_session::LlmCondenser,
230 std::sync::Arc<zeph_agent_context::memory_backend::TokenCounterAdapter>,
231) {
232 let condense_config = &config.session.condense;
233 let condense_provider = resolve_named_provider(
234 config,
235 primary_provider,
236 condense_config.condense_provider.as_str(),
237 );
238 let token_counter_adapter = std::sync::Arc::new(
239 zeph_agent_context::memory_backend::TokenCounterAdapter::new(std::sync::Arc::new(
240 zeph_memory::TokenCounter::new(),
241 )),
242 );
243 let condenser = zeph_session::LlmCondenser::new(
244 zeph_context::summarization::SummarizationDeps {
245 provider: condense_provider,
246 llm_timeout: std::time::Duration::from_secs(config.timeouts.llm_seconds),
247 token_counter: std::sync::Arc::new(
248 zeph_agent_context::memory_backend::TokenCounterAdapter::new(std::sync::Arc::new(
249 zeph_memory::TokenCounter::new(),
250 )),
251 ),
252 structured_summaries: config.memory.structured_summaries,
253 on_anchored_summary: None,
254 },
255 condense_config.threshold,
256 condense_config.keep_recent,
257 );
258 (condenser, token_counter_adapter)
259}
260
261fn build_ollama_provider(entry: &ProviderEntry, config: &Config) -> AnyProvider {
262 let base_url = entry
263 .base_url
264 .as_deref()
265 .unwrap_or("http://localhost:11434");
266 let model = entry.model.as_deref().unwrap_or("qwen3:8b").to_owned();
267 let embed = entry
268 .embedding_model
269 .clone()
270 .unwrap_or_else(|| config.llm.embedding_model.clone());
271 let mut provider =
272 OllamaProvider::new(base_url, model, embed).with_provider_name(entry.effective_name());
273 if let Some(ref vm) = entry.vision_model {
274 provider = provider.with_vision_model(vm.clone());
275 }
276 if config.mcp.forward_output_schema {
277 tracing::debug!(
278 "mcp.forward_output_schema is enabled but Ollama does not support \
279 output schema forwarding; setting ignored for this provider"
280 );
281 }
282 AnyProvider::Ollama(provider)
283}
284
285fn build_claude_provider(
286 entry: &ProviderEntry,
287 config: &Config,
288) -> Result<AnyProvider, BootstrapError> {
289 let api_key = config
290 .secrets
291 .claude_api_key
292 .as_ref()
293 .ok_or_else(|| BootstrapError::Provider("ZEPH_CLAUDE_API_KEY not found in vault".into()))?
294 .expose()
295 .to_owned();
296 let model = entry
297 .model
298 .clone()
299 .unwrap_or_else(|| "claude-haiku-4-5-20251001".to_owned());
300 let max_tokens = entry.max_tokens.unwrap_or(4096);
301 let provider = ClaudeProvider::new(api_key, model, max_tokens)
302 .with_provider_name(entry.effective_name())
303 .with_client(llm_client(config.timeouts.llm_request_timeout_secs))
304 .with_extended_context(entry.enable_extended_context)
305 .with_thinking_opt(entry.thinking.clone())
306 .map_err(|e| BootstrapError::Provider(format!("invalid thinking config: {e}")))?
307 .with_server_compaction(entry.server_compaction)
308 .with_prompt_cache_ttl(entry.prompt_cache_ttl)
309 .with_stream_limits(config.llm.stream_limits.clone())
310 .with_output_schema_forwarding(
311 config.mcp.forward_output_schema,
312 config.mcp.output_schema_hint_bytes,
313 config.mcp.max_description_bytes,
314 );
315 tracing::info!(
316 forward = config.mcp.forward_output_schema,
317 "mcp.output_schema.forwarding_configured"
318 );
319 Ok(AnyProvider::Claude(provider))
320}
321
322fn build_openai_provider(
323 entry: &ProviderEntry,
324 config: &Config,
325) -> Result<AnyProvider, BootstrapError> {
326 let api_key = config
327 .secrets
328 .openai_api_key
329 .as_ref()
330 .ok_or_else(|| BootstrapError::Provider("ZEPH_OPENAI_API_KEY not found in vault".into()))?
331 .expose()
332 .to_owned();
333 let base_url = entry
334 .base_url
335 .clone()
336 .unwrap_or_else(|| "https://api.openai.com/v1".to_owned());
337 let model = entry
338 .model
339 .clone()
340 .unwrap_or_else(|| "gpt-4o-mini".to_owned());
341 let max_tokens = entry.max_tokens.unwrap_or(4096);
342 Ok(AnyProvider::OpenAi(
343 OpenAiProvider::new(zeph_llm::OpenAiConfig {
344 api_key,
345 base_url,
346 model,
347 max_tokens,
348 embedding_model: entry.embedding_model.clone(),
349 reasoning_effort: entry.reasoning_effort.clone(),
350 context_window: None,
351 completion_tokens_param: None,
352 })
353 .with_provider_name(entry.effective_name())
354 .with_client(llm_client(config.timeouts.llm_request_timeout_secs))
355 .with_output_schema_forwarding(
356 config.mcp.forward_output_schema,
357 config.mcp.output_schema_hint_bytes,
358 config.mcp.max_description_bytes,
359 ),
360 ))
361}
362
363fn build_gemini_provider(
364 entry: &ProviderEntry,
365 config: &Config,
366) -> Result<AnyProvider, BootstrapError> {
367 let api_key = config
368 .secrets
369 .gemini_api_key
370 .as_ref()
371 .ok_or_else(|| BootstrapError::Provider("ZEPH_GEMINI_API_KEY not found in vault".into()))?
372 .expose()
373 .to_owned();
374 let model = entry
375 .model
376 .clone()
377 .unwrap_or_else(|| "gemini-2.0-flash".to_owned());
378 let max_tokens = entry.max_tokens.unwrap_or(8192);
379 let base_url = entry
380 .base_url
381 .clone()
382 .unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_owned());
383 let mut provider = GeminiProvider::new(api_key, model, max_tokens)
384 .with_provider_name(entry.effective_name())
385 .with_base_url(base_url)
386 .with_client(llm_client(config.timeouts.llm_request_timeout_secs));
387 if let Some(ref em) = entry.embedding_model {
388 provider = provider.with_embedding_model(em.clone());
389 }
390 if let Some(level) = entry.thinking_level {
391 provider = provider.with_thinking_level(level);
392 }
393 if let Some(budget) = entry.thinking_budget {
394 provider = provider
395 .with_thinking_budget(budget)
396 .map_err(|e| BootstrapError::Provider(e.to_string()))?;
397 }
398 if let Some(include) = entry.include_thoughts {
399 provider = provider.with_include_thoughts(include);
400 }
401 if config.mcp.forward_output_schema {
402 tracing::debug!(
403 "mcp.forward_output_schema is enabled but Gemini does not support \
404 output schema forwarding; setting ignored for this provider"
405 );
406 }
407 Ok(AnyProvider::Gemini(provider))
408}
409
410fn build_compatible_provider(
411 entry: &ProviderEntry,
412 config: &Config,
413) -> Result<AnyProvider, BootstrapError> {
414 let name = entry.name.as_deref().ok_or_else(|| {
415 BootstrapError::Provider(
416 "compatible provider requires 'name' field in [[llm.providers]]".into(),
417 )
418 })?;
419 let base_url = entry.base_url.clone().ok_or_else(|| {
420 BootstrapError::Provider(format!("compatible provider '{name}' requires 'base_url'"))
421 })?;
422 let model = entry.model.clone().unwrap_or_default();
423 let api_key = entry.api_key.clone().unwrap_or_else(|| {
424 config
425 .secrets
426 .compatible_api_keys
427 .get(name)
428 .map(|s| s.expose().to_owned())
429 .unwrap_or_default()
430 });
431 let max_tokens = entry.max_tokens.unwrap_or(4096);
432 let provider = CompatibleProvider::new(zeph_llm::CompatibleConfig {
433 provider_name: name.to_owned(),
434 api_key,
435 base_url,
436 model,
437 max_tokens,
438 embedding_model: entry.embedding_model.clone(),
439 completion_tokens_param: None,
440 })
441 .with_output_schema_forwarding(
442 config.mcp.forward_output_schema,
443 config.mcp.output_schema_hint_bytes,
444 config.mcp.max_description_bytes,
445 );
446 tracing::info!(
447 forward = config.mcp.forward_output_schema,
448 provider = name,
449 "mcp.output_schema.forwarding_configured"
450 );
451 Ok(AnyProvider::Compatible(provider))
452}
453
454#[cfg(feature = "gonka")]
455fn build_gonka_provider(
456 entry: &ProviderEntry,
457 config: &Config,
458) -> Result<AnyProvider, BootstrapError> {
459 let _span = tracing::info_span!("core.provider_factory.build_gonka").entered();
460
461 let private_key_hex: Zeroizing<String> = Zeroizing::new(
462 config
463 .secrets
464 .gonka_private_key
465 .as_ref()
466 .ok_or_else(|| {
467 BootstrapError::Provider(
468 "ZEPH_GONKA_PRIVATE_KEY not found in vault; set it with: zeph vault set ZEPH_GONKA_PRIVATE_KEY <hex>".into(),
469 )
470 })?
471 .expose()
472 .to_owned(),
473 );
474
475 let chain_prefix = entry.effective_gonka_chain_prefix().to_owned();
476 let signer = RequestSigner::from_hex(&private_key_hex, &chain_prefix)
477 .map_err(|e| BootstrapError::Provider(format!("invalid Gonka private key: {e}")))?;
478
479 if let Some(ref configured_address) = config.secrets.gonka_address {
480 let configured = configured_address.expose().to_lowercase();
481 let derived = signer.address().to_lowercase();
482 if configured != derived {
483 return Err(BootstrapError::Provider(format!(
484 "ZEPH_GONKA_ADDRESS does not match address derived from private key \
485 (configured: {configured}, derived: {derived})"
486 )));
487 }
488 } else {
489 tracing::info!(
490 address = signer.address(),
491 "Gonka: using address derived from private key (ZEPH_GONKA_ADDRESS not set)"
492 );
493 }
494
495 if entry.gonka_nodes.is_empty() {
496 return Err(BootstrapError::Provider(
497 "Gonka provider entry must have at least one node in gonka_nodes".into(),
498 ));
499 }
500
501 let endpoints: Vec<GonkaEndpoint> = entry
502 .gonka_nodes
503 .iter()
504 .map(|n| GonkaEndpoint {
505 base_url: n.url.clone(),
506 address: n.address.clone(),
507 })
508 .collect();
509
510 let pool = EndpointPool::new(endpoints).map_err(|e| {
511 BootstrapError::Provider(format!("failed to build Gonka endpoint pool: {e}"))
512 })?;
513
514 let model = entry.model.clone().unwrap_or_else(|| "gpt-4o".to_owned());
515 let max_tokens = entry.max_tokens.unwrap_or(4096);
516 let timeout = std::time::Duration::from_secs(config.timeouts.llm_request_timeout_secs);
517
518 let provider = GonkaProvider::new(zeph_llm::gonka::GonkaConfig {
519 signer: std::sync::Arc::new(signer),
520 pool: std::sync::Arc::new(pool),
521 model,
522 max_tokens,
523 embedding_model: entry.embedding_model.clone(),
524 timeout,
525 })
526 .with_provider_name(entry.effective_name());
527
528 Ok(AnyProvider::Gonka(provider))
529}
530
531#[cfg(feature = "cocoon")]
535struct CocoonClientParams {
536 base_url: String,
537 access_hash: Option<String>,
538 timeout: std::time::Duration,
539}
540
541#[cfg(feature = "cocoon")]
553fn resolve_cocoon_client_params(
554 entry: &ProviderEntry,
555 config: &Config,
556) -> Result<CocoonClientParams, BootstrapError> {
557 let base_url = entry
558 .cocoon_client_url
559 .as_deref()
560 .unwrap_or("http://localhost:10000");
561
562 if !base_url.starts_with("http://localhost")
564 && !base_url.starts_with("http://127.0.0.1")
565 && !base_url.starts_with("http://[::1]")
566 && !base_url.starts_with("https://localhost")
567 && !base_url.starts_with("https://127.0.0.1")
568 && !base_url.starts_with("https://[::1]")
569 {
570 tracing::warn!(
571 url = base_url,
572 "cocoon_client_url points to a non-localhost host; \
573 ensure this is intentional (expected sidecar on localhost)"
574 );
575 }
576
577 if entry
578 .cocoon_access_hash
579 .as_deref()
580 .is_some_and(|v| !v.is_empty())
581 {
582 tracing::warn!(
583 "cocoon_access_hash in config file appears to contain a raw value; \
584 this field should be empty — the actual hash must be stored in the vault: \
585 zeph vault set ZEPH_COCOON_ACCESS_HASH <hash>"
586 );
587 }
588
589 let access_hash = if entry.cocoon_access_hash.is_some() {
590 let hash = config
591 .secrets
592 .cocoon_access_hash
593 .as_ref()
594 .ok_or_else(|| {
595 BootstrapError::Provider(
596 "ZEPH_COCOON_ACCESS_HASH not found in vault; set it with: \
597 zeph vault set ZEPH_COCOON_ACCESS_HASH <hash>"
598 .into(),
599 )
600 })?
601 .expose()
602 .to_owned();
603 Some(hash)
604 } else {
605 None
606 };
607
608 let timeout = std::time::Duration::from_secs(config.timeouts.llm_request_timeout_secs);
609
610 Ok(CocoonClientParams {
611 base_url: base_url.to_owned(),
612 access_hash,
613 timeout,
614 })
615}
616
617#[cfg(feature = "cocoon")]
624fn build_cocoon_provider(
625 entry: &ProviderEntry,
626 config: &Config,
627) -> Result<AnyProvider, BootstrapError> {
628 let _span = tracing::info_span!("core.provider_factory.build_cocoon").entered();
629
630 let params = resolve_cocoon_client_params(entry, config)?;
631 let client = std::sync::Arc::new(CocoonClient::new(
632 ¶ms.base_url,
633 params.access_hash,
634 params.timeout,
635 ));
636
637 let model = entry
638 .model
639 .clone()
640 .unwrap_or_else(|| "Qwen/Qwen3-0.6B".to_owned());
641 let max_tokens = entry.max_tokens.unwrap_or(4096);
642 let provider = CocoonProvider::new(model, max_tokens, entry.embedding_model.clone(), client)
643 .with_provider_name(entry.effective_name());
644
645 Ok(AnyProvider::Cocoon(provider))
646}
647
648#[cfg(feature = "cocoon")]
662pub fn spawn_cocoon_health_checks(
663 providers: &[&ProviderEntry],
664 config: &Config,
665 supervisor: &std::sync::Arc<zeph_common::TaskSupervisor>,
666) {
667 for entry in providers {
668 if entry.provider_type != ProviderKind::Cocoon || !entry.cocoon_health_check {
669 continue;
670 }
671 let params = match resolve_cocoon_client_params(entry, config) {
672 Ok(params) => params,
673 Err(e) => {
674 tracing::warn!(
675 name = entry.name.as_deref().unwrap_or("<unnamed>"),
676 error = %e,
677 "skipping cocoon health check: failed to resolve client params"
678 );
679 continue;
680 }
681 };
682 let client = std::sync::Arc::new(CocoonClient::new(
683 ¶ms.base_url,
684 params.access_hash,
685 params.timeout,
686 ));
687 supervisor.spawn(zeph_common::task_supervisor::TaskDescriptor {
688 name: "core.provider_factory.cocoon_health_check",
689 restart: zeph_common::task_supervisor::RestartPolicy::RunOnce,
690 factory: move || {
691 let client = client.clone();
692 async move {
693 match client.health_check().await {
694 Ok(h) => {
695 tracing::info!(
696 proxy_connected = h.proxy_connected,
697 worker_count = h.worker_count,
698 "cocoon sidecar health check passed"
699 );
700 }
701 Err(e) => {
702 tracing::warn!(
703 error = %e,
704 "cocoon sidecar health check failed; \
705 inference requests will return LlmError::Unavailable until the sidecar is running"
706 );
707 }
708 }
709 }
710 },
711 });
712 }
713}
714
715#[cfg(feature = "candle")]
721struct CandleLoadParams {
722 source: zeph_llm::candle_provider::loader::ModelSource,
723 template: zeph_llm::candle_provider::template::ChatTemplate,
724 gen_config: zeph_llm::candle_provider::generate::GenerationConfig,
725 embedding_repo: Option<String>,
726 embedding_sha256: Option<String>,
727 hf_token: Option<String>,
728 inference_timeout: std::time::Duration,
729}
730
731#[cfg(feature = "candle")]
732fn resolve_candle_load_params(
733 entry: &ProviderEntry,
734 candle: &CandleInlineConfig,
735 config: &Config,
736) -> CandleLoadParams {
737 let source = match candle.source {
738 CandleSource::Local => zeph_llm::candle_provider::loader::ModelSource::Local {
739 path: std::path::PathBuf::from(&candle.local_path),
740 },
741 CandleSource::Huggingface => zeph_llm::candle_provider::loader::ModelSource::HuggingFace {
742 repo_id: entry
743 .model
744 .clone()
745 .unwrap_or_else(|| config.llm.effective_model().to_owned()),
746 filename: candle.filename.clone(),
747 sha256: candle.chat_model_sha256.clone(),
748 },
749 };
750 let template =
751 zeph_llm::candle_provider::template::ChatTemplate::parse_str(&candle.chat_template);
752 let gen_config = zeph_llm::candle_provider::generate::GenerationConfig {
753 temperature: candle.generation.temperature,
754 top_p: candle.generation.top_p,
755 top_k: candle.generation.top_k,
756 max_tokens: candle.generation.capped_max_tokens(),
757 seed: candle.generation.seed,
758 repeat_penalty: candle.generation.repeat_penalty,
759 repeat_last_n: candle.generation.repeat_last_n,
760 };
761 let inference_timeout = std::time::Duration::from_secs(candle.inference_timeout_secs.max(1));
764 CandleLoadParams {
765 source,
766 template,
767 gen_config,
768 embedding_repo: candle.embedding_repo.clone(),
769 embedding_sha256: candle.embedding_model_sha256.clone(),
770 hf_token: candle.hf_token.clone(),
771 inference_timeout,
772 }
773}
774
775#[cfg(feature = "candle")]
776fn build_candle_provider(
777 entry: &ProviderEntry,
778 config: &Config,
779) -> Result<AnyProvider, BootstrapError> {
780 let candle = entry.candle.as_ref().ok_or_else(|| {
781 BootstrapError::Provider(
782 "candle provider requires 'candle' section in [[llm.providers]]".into(),
783 )
784 })?;
785 let params = resolve_candle_load_params(entry, candle, config);
786 let device = select_device(candle.device)?;
787 zeph_llm::candle_provider::CandleProvider::new_with_timeout(
788 ¶ms.source,
789 params.template,
790 params.gen_config,
791 params.embedding_repo.as_deref(),
792 params.embedding_sha256.as_deref(),
793 params.hf_token.as_deref(),
794 device,
795 params.inference_timeout,
796 )
797 .map(|provider| AnyProvider::Candle(provider.with_provider_name(entry.effective_name())))
798 .map_err(|e| BootstrapError::Provider(e.to_string()))
799}
800
801#[cfg(feature = "candle")]
808pub fn select_device(
809 preference: CandleDevice,
810) -> Result<zeph_llm::candle_provider::Device, BootstrapError> {
811 match preference {
812 CandleDevice::Metal => {
813 #[cfg(feature = "metal")]
814 return zeph_llm::candle_provider::Device::new_metal(0)
815 .map_err(|e| BootstrapError::Provider(e.to_string()));
816 #[cfg(not(feature = "metal"))]
817 return Err(BootstrapError::Provider(
818 "candle compiled without metal feature".into(),
819 ));
820 }
821 CandleDevice::Cuda => {
822 #[cfg(feature = "cuda")]
823 return zeph_llm::candle_provider::Device::new_cuda(0)
824 .map_err(|e| BootstrapError::Provider(e.to_string()));
825 #[cfg(not(feature = "cuda"))]
826 return Err(BootstrapError::Provider(
827 "candle compiled without cuda feature".into(),
828 ));
829 }
830 CandleDevice::Cpu => Ok(zeph_llm::candle_provider::Device::Cpu),
831 CandleDevice::Auto => {
832 #[cfg(feature = "metal")]
833 if let Ok(device) = zeph_llm::candle_provider::Device::new_metal(0) {
834 return Ok(device);
835 }
836 #[cfg(feature = "cuda")]
837 if let Ok(device) = zeph_llm::candle_provider::Device::new_cuda(0) {
838 return Ok(device);
839 }
840 Ok(zeph_llm::candle_provider::Device::Cpu)
841 }
842 }
843}
844
845#[cfg(test)]
846mod tests {
847 #[cfg(feature = "candle")]
848 use super::select_device;
849 #[cfg(feature = "candle")]
850 use crate::config::CandleDevice;
851 #[cfg(feature = "candle")]
852 use std::assert_matches;
853
854 #[cfg(feature = "candle")]
855 #[test]
856 fn select_device_cpu_default() {
857 let device = select_device(CandleDevice::Cpu).unwrap();
858 assert_matches!(device, zeph_llm::candle_provider::Device::Cpu);
859 }
860
861 #[cfg(all(feature = "candle", not(feature = "metal")))]
862 #[test]
863 fn select_device_metal_without_feature_errors() {
864 let result = select_device(CandleDevice::Metal);
865 assert!(result.is_err());
866 assert!(result.unwrap_err().to_string().contains("metal feature"));
867 }
868
869 #[cfg(all(feature = "candle", not(feature = "cuda")))]
870 #[test]
871 fn select_device_cuda_without_feature_errors() {
872 let result = select_device(CandleDevice::Cuda);
873 assert!(result.is_err());
874 assert!(result.unwrap_err().to_string().contains("cuda feature"));
875 }
876
877 #[cfg(feature = "candle")]
881 #[test]
882 fn resolve_candle_load_params_threads_chat_and_embedding_sha256() {
883 use super::resolve_candle_load_params;
884 use crate::config::{CandleInlineConfig, Config};
885 use zeph_config::providers::ProviderEntry;
886
887 let candle = CandleInlineConfig {
888 chat_model_sha256: Some("deadbeef".into()),
889 embedding_repo: Some("org/embed-model".into()),
890 embedding_model_sha256: Some("cafef00d".into()),
891 ..CandleInlineConfig::default()
892 };
893 let entry = ProviderEntry {
894 model: Some("org/chat-model".into()),
895 candle: Some(candle.clone()),
896 ..ProviderEntry::default()
897 };
898 let config = Config::default();
899
900 let params = resolve_candle_load_params(&entry, &candle, &config);
901
902 if let zeph_llm::candle_provider::loader::ModelSource::HuggingFace { sha256, .. } =
903 params.source
904 {
905 assert_eq!(sha256.as_deref(), Some("deadbeef"));
906 } else {
907 panic!("expected HuggingFace source for CandleSource::default()")
908 }
909 assert_eq!(params.embedding_sha256.as_deref(), Some("cafef00d"));
910 }
911
912 #[cfg(feature = "candle")]
913 #[test]
914 fn resolve_candle_load_params_sha256_absent_by_default() {
915 use super::resolve_candle_load_params;
916 use crate::config::{CandleInlineConfig, Config};
917 use zeph_config::providers::ProviderEntry;
918
919 let candle = CandleInlineConfig::default();
920 let entry = ProviderEntry {
921 model: Some("org/chat-model".into()),
922 candle: Some(candle.clone()),
923 ..ProviderEntry::default()
924 };
925 let config = Config::default();
926
927 let params = resolve_candle_load_params(&entry, &candle, &config);
928
929 if let zeph_llm::candle_provider::loader::ModelSource::HuggingFace { sha256, .. } =
930 params.source
931 {
932 assert!(sha256.is_none());
933 } else {
934 panic!("expected HuggingFace source for CandleSource::default()")
935 }
936 assert!(params.embedding_sha256.is_none());
937 }
938
939 #[cfg(feature = "cocoon")]
940 use super::spawn_cocoon_health_checks;
941 use super::{build_provider_from_entry, resolve_named_provider};
942 use crate::config::{Config, ProviderKind};
943 use zeph_config::providers::ProviderEntry;
944 use zeph_llm::LlmProvider;
945
946 #[cfg(feature = "gonka")]
947 mod gonka_tests {
948 use super::*;
949 use zeph_common::secret::Secret;
950 use zeph_config::GonkaNode;
951 use zeph_llm::LlmProvider;
952
953 fn gonka_entry_with_nodes(nodes: Vec<GonkaNode>) -> ProviderEntry {
954 ProviderEntry {
955 provider_type: ProviderKind::Gonka,
956 name: Some("gonka".into()),
957 model: Some("gpt-4o".into()),
958 gonka_nodes: nodes,
959 ..ProviderEntry::default()
960 }
961 }
962
963 fn valid_nodes() -> Vec<GonkaNode> {
964 vec![GonkaNode {
965 url: "https://node1.gonka.ai".into(),
966 address: "gonka1w508d6qejxtdg4y5r3zarvary0c5xw7k2gsyg6".into(),
967 name: Some("node1".into()),
968 }]
969 }
970
971 const VALID_PRIV_KEY: &str =
972 "0000000000000000000000000000000000000000000000000000000000000001";
973
974 #[test]
975 fn build_gonka_provider_missing_key_returns_error() {
976 let entry = gonka_entry_with_nodes(valid_nodes());
977 let config = Config::default();
978 let result = build_provider_from_entry(&entry, &config, None);
979 assert!(result.is_err());
980 let msg = result.unwrap_err().to_string();
981 assert!(
982 msg.contains("ZEPH_GONKA_PRIVATE_KEY"),
983 "error must mention missing key: {msg}"
984 );
985 }
986
987 #[test]
988 fn build_gonka_provider_empty_nodes_returns_error() {
989 let entry = gonka_entry_with_nodes(vec![]);
990 let mut config = Config::default();
991 config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
992 let result = build_provider_from_entry(&entry, &config, None);
993 assert!(result.is_err());
994 let msg = result.unwrap_err().to_string();
995 assert!(
996 msg.contains("gonka_nodes") || msg.contains("node"),
997 "error must mention empty nodes: {msg}"
998 );
999 }
1000
1001 #[test]
1002 fn build_gonka_provider_address_mismatch_returns_error() {
1003 let entry = gonka_entry_with_nodes(valid_nodes());
1004 let mut config = Config::default();
1005 config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
1006 config.secrets.gonka_address =
1007 Some(Secret::new("gonka1wrongaddress000000000000000000000000000"));
1008 let result = build_provider_from_entry(&entry, &config, None);
1009 assert!(result.is_err());
1010 let msg = result.unwrap_err().to_string();
1011 assert!(
1012 msg.contains("does not match"),
1013 "error must mention address mismatch: {msg}"
1014 );
1015 }
1016
1017 #[test]
1018 fn build_gonka_provider_happy_path() {
1019 let entry = gonka_entry_with_nodes(valid_nodes());
1020 let mut config = Config::default();
1021 config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
1022 let result = build_provider_from_entry(&entry, &config, None);
1023 assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
1024 let provider = result.unwrap();
1025 assert_eq!(provider.name(), "gonka");
1026 }
1027
1028 #[test]
1032 fn build_gonka_provider_distinct_entries_yield_distinct_provider_names() {
1033 let mut entry_a = gonka_entry_with_nodes(valid_nodes());
1034 entry_a.name = Some("gonka-a".into());
1035 let mut entry_b = gonka_entry_with_nodes(valid_nodes());
1036 entry_b.name = Some("gonka-b".into());
1037 let mut config = Config::default();
1038 config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
1039
1040 let provider_a = build_provider_from_entry(&entry_a, &config, None).unwrap();
1041 let provider_b = build_provider_from_entry(&entry_b, &config, None).unwrap();
1042
1043 assert_eq!(provider_a.name(), "gonka-a");
1044 assert_eq!(provider_b.name(), "gonka-b");
1045 assert_ne!(provider_a.name(), provider_b.name());
1046 }
1047 }
1048
1049 fn make_provider_entry(
1050 embed: bool,
1051 model: Option<&str>,
1052 embedding_model: Option<&str>,
1053 ) -> ProviderEntry {
1054 ProviderEntry {
1055 provider_type: ProviderKind::Ollama,
1056 embed,
1057 model: model.map(str::to_owned),
1058 embedding_model: embedding_model.map(str::to_owned),
1059 ..ProviderEntry::default()
1060 }
1061 }
1062
1063 #[test]
1064 fn stable_skill_embedding_model_prefers_embedding_model_field() {
1065 let mut config = Config::default();
1066 config.llm.providers = vec![make_provider_entry(
1067 true,
1068 Some("chat-model"),
1069 Some("embed-v2"),
1070 )];
1071 assert_eq!(config.llm.stable_skill_embedding_model(), "embed-v2");
1072 }
1073
1074 #[test]
1075 fn stable_skill_embedding_model_falls_back_to_model_field() {
1076 let mut config = Config::default();
1077 config.llm.providers = vec![make_provider_entry(
1078 true,
1079 Some("nomic-embed-text-v2-moe:latest"),
1080 None,
1081 )];
1082 assert_eq!(
1083 config.llm.stable_skill_embedding_model(),
1084 "nomic-embed-text-v2-moe:latest"
1085 );
1086 }
1087
1088 #[test]
1089 fn stable_skill_embedding_model_finds_embed_flag_entry() {
1090 let mut config = Config::default();
1091 config.llm.providers = vec![
1092 make_provider_entry(false, Some("chat-model"), None),
1093 make_provider_entry(true, Some("embed-model"), Some("text-embed-3")),
1094 ];
1095 assert_eq!(config.llm.stable_skill_embedding_model(), "text-embed-3");
1096 }
1097
1098 #[test]
1099 fn stable_skill_embedding_model_falls_back_to_effective_when_no_embed_entry() {
1100 let mut config = Config::default();
1101 config.llm.embedding_model = "global-embed-model".to_owned();
1102 config.llm.providers = vec![make_provider_entry(false, Some("chat"), None)];
1104 assert_eq!(
1105 config.llm.stable_skill_embedding_model(),
1106 config.llm.effective_embedding_model()
1107 );
1108 }
1109
1110 #[test]
1111 fn resolve_named_provider_empty_name_falls_back_to_primary_silently() {
1112 let config = Config::default();
1113 let entry = make_provider_entry(false, Some("primary-model"), None);
1114 let primary = build_provider_from_entry(&entry, &config, None).unwrap();
1115 let resolved = resolve_named_provider(&config, &primary, "");
1116 assert_eq!(resolved.name(), primary.name());
1117 }
1118
1119 #[test]
1120 fn resolve_named_provider_unmatched_name_falls_back_to_primary_with_warn() {
1121 let config = Config::default(); let entry = make_provider_entry(false, Some("primary-model"), None);
1123 let primary = build_provider_from_entry(&entry, &config, None).unwrap();
1124 let resolved = resolve_named_provider(&config, &primary, "fast");
1125 assert_eq!(resolved.name(), primary.name());
1126 }
1127
1128 #[test]
1135 fn build_provider_from_entry_ollama_distinct_entries_yield_distinct_provider_names() {
1136 let config = Config::default();
1137 let entry_a = ProviderEntry {
1138 provider_type: ProviderKind::Ollama,
1139 name: Some("ollama-chat".into()),
1140 model: Some("qwen3:8b".into()),
1141 ..ProviderEntry::default()
1142 };
1143 let entry_b = ProviderEntry {
1144 provider_type: ProviderKind::Ollama,
1145 name: Some("ollama-embed".into()),
1146 model: Some("nomic-embed-text".into()),
1147 embed: true,
1148 ..ProviderEntry::default()
1149 };
1150
1151 let provider_a = build_provider_from_entry(&entry_a, &config, None).unwrap();
1152 let provider_b = build_provider_from_entry(&entry_b, &config, None).unwrap();
1153
1154 assert_ne!(
1155 provider_a.name(),
1156 provider_b.name(),
1157 "two distinct Ollama [[llm.providers]] entries must yield distinct \
1158 AnyProvider::name() values"
1159 );
1160 assert_eq!(provider_a.name(), "ollama-chat");
1161 assert_eq!(provider_b.name(), "ollama-embed");
1162 }
1163
1164 #[test]
1169 fn build_provider_from_entry_claude_openai_gemini_distinct_entries_yield_distinct_provider_names()
1170 {
1171 use zeph_common::secret::Secret;
1172
1173 let mut config = Config::default();
1174 config.secrets.claude_api_key = Some(Secret::new("test-claude-key"));
1175 config.secrets.openai_api_key = Some(Secret::new("test-openai-key"));
1176 config.secrets.gemini_api_key = Some(Secret::new("test-gemini-key"));
1177
1178 let claude_a = ProviderEntry {
1179 provider_type: ProviderKind::Claude,
1180 name: Some("claude-quality".into()),
1181 ..ProviderEntry::default()
1182 };
1183 let claude_b = ProviderEntry {
1184 provider_type: ProviderKind::Claude,
1185 name: Some("claude-fast".into()),
1186 ..ProviderEntry::default()
1187 };
1188 let provider_claude_a = build_provider_from_entry(&claude_a, &config, None).unwrap();
1189 let provider_claude_b = build_provider_from_entry(&claude_b, &config, None).unwrap();
1190 assert_eq!(provider_claude_a.name(), "claude-quality");
1191 assert_eq!(provider_claude_b.name(), "claude-fast");
1192 assert_ne!(provider_claude_a.name(), provider_claude_b.name());
1193
1194 let openai_a = ProviderEntry {
1195 provider_type: ProviderKind::OpenAi,
1196 name: Some("openai-quality".into()),
1197 ..ProviderEntry::default()
1198 };
1199 let openai_b = ProviderEntry {
1200 provider_type: ProviderKind::OpenAi,
1201 name: Some("openai-fast".into()),
1202 ..ProviderEntry::default()
1203 };
1204 let provider_openai_a = build_provider_from_entry(&openai_a, &config, None).unwrap();
1205 let provider_openai_b = build_provider_from_entry(&openai_b, &config, None).unwrap();
1206 assert_eq!(provider_openai_a.name(), "openai-quality");
1207 assert_eq!(provider_openai_b.name(), "openai-fast");
1208 assert_ne!(provider_openai_a.name(), provider_openai_b.name());
1209
1210 let gemini_a = ProviderEntry {
1211 provider_type: ProviderKind::Gemini,
1212 name: Some("gemini-quality".into()),
1213 ..ProviderEntry::default()
1214 };
1215 let gemini_b = ProviderEntry {
1216 provider_type: ProviderKind::Gemini,
1217 name: Some("gemini-fast".into()),
1218 ..ProviderEntry::default()
1219 };
1220 let provider_gemini_a = build_provider_from_entry(&gemini_a, &config, None).unwrap();
1221 let provider_gemini_b = build_provider_from_entry(&gemini_b, &config, None).unwrap();
1222 assert_eq!(provider_gemini_a.name(), "gemini-quality");
1223 assert_eq!(provider_gemini_b.name(), "gemini-fast");
1224 assert_ne!(provider_gemini_a.name(), provider_gemini_b.name());
1225 }
1226
1227 #[test]
1230 fn build_provider_from_entry_claude_unnamed_falls_back_to_type_literal() {
1231 use zeph_common::secret::Secret;
1232
1233 let mut config = Config::default();
1234 config.secrets.claude_api_key = Some(Secret::new("test-claude-key"));
1235 let entry = ProviderEntry {
1236 provider_type: ProviderKind::Claude,
1237 ..ProviderEntry::default()
1238 };
1239 let provider = build_provider_from_entry(&entry, &config, None).unwrap();
1240 assert_eq!(provider.name(), "claude");
1241 }
1242
1243 #[cfg(feature = "cocoon")]
1244 mod cocoon_tests {
1245 use super::*;
1246
1247 fn cocoon_entry(access_hash: Option<&str>) -> ProviderEntry {
1248 ProviderEntry {
1249 provider_type: ProviderKind::Cocoon,
1250 name: Some("cocoon".into()),
1251 model: Some("Qwen/Qwen3-0.6B".into()),
1252 cocoon_client_url: Some("http://localhost:10000".into()),
1253 cocoon_access_hash: access_hash.map(str::to_owned),
1254 cocoon_health_check: false,
1255 ..ProviderEntry::default()
1256 }
1257 }
1258
1259 #[test]
1261 fn cocoon_access_hash_gate_vault_miss_errors() {
1262 let entry = cocoon_entry(Some(""));
1263 let config = Config::default(); let result = build_provider_from_entry(&entry, &config, None);
1265 assert!(
1266 result.is_err(),
1267 "expected error when vault key is absent but sentinel is set"
1268 );
1269 let err_str = result.unwrap_err().to_string();
1270 assert!(
1271 err_str.contains("ZEPH_COCOON_ACCESS_HASH"),
1272 "error should mention the vault key: {err_str}"
1273 );
1274 }
1275
1276 #[test]
1278 fn cocoon_no_access_hash_gate_succeeds_without_vault() {
1279 let entry = cocoon_entry(None);
1280 let config = Config::default();
1281 let result = build_provider_from_entry(&entry, &config, None);
1282 assert!(
1283 result.is_ok(),
1284 "expected success when no access hash requested: {:?}",
1285 result.err()
1286 );
1287 }
1288
1289 #[test]
1293 fn build_provider_from_entry_cocoon_distinct_entries_yield_distinct_provider_names() {
1294 let mut entry_a = cocoon_entry(None);
1295 entry_a.name = Some("cocoon-a".into());
1296 let mut entry_b = cocoon_entry(None);
1297 entry_b.name = Some("cocoon-b".into());
1298 let config = Config::default();
1299
1300 let provider_a = build_provider_from_entry(&entry_a, &config, None).unwrap();
1301 let provider_b = build_provider_from_entry(&entry_b, &config, None).unwrap();
1302
1303 assert_eq!(provider_a.name(), "cocoon-a");
1304 assert_eq!(provider_b.name(), "cocoon-b");
1305 assert_ne!(provider_a.name(), provider_b.name());
1306 }
1307
1308 #[test]
1312 fn spawn_cocoon_health_checks_skips_on_vault_miss() {
1313 let mut entry = cocoon_entry(Some(""));
1314 entry.cocoon_health_check = true;
1315 let config = Config::default(); let supervisor = std::sync::Arc::new(zeph_common::TaskSupervisor::new(
1317 tokio_util::sync::CancellationToken::new(),
1318 ));
1319
1320 spawn_cocoon_health_checks(&[&entry], &config, &supervisor);
1321
1322 assert!(
1323 supervisor.snapshot().is_empty(),
1324 "expected no health-check task to be spawned when vault key is absent"
1325 );
1326 }
1327 }
1328}