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 vision: entry.vision,
353 })
354 .with_provider_name(entry.effective_name())
355 .with_client(llm_client(config.timeouts.llm_request_timeout_secs))
356 .with_output_schema_forwarding(
357 config.mcp.forward_output_schema,
358 config.mcp.output_schema_hint_bytes,
359 config.mcp.max_description_bytes,
360 ),
361 ))
362}
363
364fn build_gemini_provider(
365 entry: &ProviderEntry,
366 config: &Config,
367) -> Result<AnyProvider, BootstrapError> {
368 let api_key = config
369 .secrets
370 .gemini_api_key
371 .as_ref()
372 .ok_or_else(|| BootstrapError::Provider("ZEPH_GEMINI_API_KEY not found in vault".into()))?
373 .expose()
374 .to_owned();
375 let model = entry
376 .model
377 .clone()
378 .unwrap_or_else(|| "gemini-2.0-flash".to_owned());
379 let max_tokens = entry.max_tokens.unwrap_or(8192);
380 let base_url = entry
381 .base_url
382 .clone()
383 .unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_owned());
384 let mut provider = GeminiProvider::new(api_key, model, max_tokens)
385 .with_provider_name(entry.effective_name())
386 .with_base_url(base_url)
387 .with_client(llm_client(config.timeouts.llm_request_timeout_secs));
388 if let Some(ref em) = entry.embedding_model {
389 provider = provider.with_embedding_model(em.clone());
390 }
391 if let Some(level) = entry.thinking_level {
392 provider = provider.with_thinking_level(level);
393 }
394 if let Some(budget) = entry.thinking_budget {
395 provider = provider
396 .with_thinking_budget(budget)
397 .map_err(|e| BootstrapError::Provider(e.to_string()))?;
398 }
399 if let Some(include) = entry.include_thoughts {
400 provider = provider.with_include_thoughts(include);
401 }
402 if config.mcp.forward_output_schema {
403 tracing::debug!(
404 "mcp.forward_output_schema is enabled but Gemini does not support \
405 output schema forwarding; setting ignored for this provider"
406 );
407 }
408 Ok(AnyProvider::Gemini(provider))
409}
410
411fn build_compatible_provider(
412 entry: &ProviderEntry,
413 config: &Config,
414) -> Result<AnyProvider, BootstrapError> {
415 let name = entry.name.as_deref().ok_or_else(|| {
416 BootstrapError::Provider(
417 "compatible provider requires 'name' field in [[llm.providers]]".into(),
418 )
419 })?;
420 let base_url = entry.base_url.clone().ok_or_else(|| {
421 BootstrapError::Provider(format!("compatible provider '{name}' requires 'base_url'"))
422 })?;
423 let model = entry.model.clone().unwrap_or_default();
424 let api_key = entry.api_key.clone().unwrap_or_else(|| {
425 config
426 .secrets
427 .compatible_api_keys
428 .get(name)
429 .map(|s| s.expose().to_owned())
430 .unwrap_or_default()
431 });
432 let max_tokens = entry.max_tokens.unwrap_or(4096);
433 let provider = CompatibleProvider::new(zeph_llm::CompatibleConfig {
434 provider_name: name.to_owned(),
435 api_key,
436 base_url,
437 model,
438 max_tokens,
439 embedding_model: entry.embedding_model.clone(),
440 completion_tokens_param: None,
441 vision: entry.vision,
442 })
443 .with_output_schema_forwarding(
444 config.mcp.forward_output_schema,
445 config.mcp.output_schema_hint_bytes,
446 config.mcp.max_description_bytes,
447 );
448 tracing::info!(
449 forward = config.mcp.forward_output_schema,
450 provider = name,
451 "mcp.output_schema.forwarding_configured"
452 );
453 Ok(AnyProvider::Compatible(provider))
454}
455
456#[cfg(feature = "gonka")]
457fn build_gonka_provider(
458 entry: &ProviderEntry,
459 config: &Config,
460) -> Result<AnyProvider, BootstrapError> {
461 let _span = tracing::info_span!("core.provider_factory.build_gonka").entered();
462
463 let private_key_hex: Zeroizing<String> = Zeroizing::new(
464 config
465 .secrets
466 .gonka_private_key
467 .as_ref()
468 .ok_or_else(|| {
469 BootstrapError::Provider(
470 "ZEPH_GONKA_PRIVATE_KEY not found in vault; set it with: zeph vault set ZEPH_GONKA_PRIVATE_KEY <hex>".into(),
471 )
472 })?
473 .expose()
474 .to_owned(),
475 );
476
477 let chain_prefix = entry.effective_gonka_chain_prefix().to_owned();
478 let signer = RequestSigner::from_hex(&private_key_hex, &chain_prefix)
479 .map_err(|e| BootstrapError::Provider(format!("invalid Gonka private key: {e}")))?;
480
481 if let Some(ref configured_address) = config.secrets.gonka_address {
482 let configured = configured_address.expose().to_lowercase();
483 let derived = signer.address().to_lowercase();
484 if configured != derived {
485 return Err(BootstrapError::Provider(format!(
486 "ZEPH_GONKA_ADDRESS does not match address derived from private key \
487 (configured: {configured}, derived: {derived})"
488 )));
489 }
490 } else {
491 tracing::info!(
492 address = signer.address(),
493 "Gonka: using address derived from private key (ZEPH_GONKA_ADDRESS not set)"
494 );
495 }
496
497 if entry.gonka_nodes.is_empty() {
498 return Err(BootstrapError::Provider(
499 "Gonka provider entry must have at least one node in gonka_nodes".into(),
500 ));
501 }
502
503 let endpoints: Vec<GonkaEndpoint> = entry
504 .gonka_nodes
505 .iter()
506 .map(|n| GonkaEndpoint {
507 base_url: n.url.clone(),
508 address: n.address.clone(),
509 })
510 .collect();
511
512 let pool = EndpointPool::new(endpoints).map_err(|e| {
513 BootstrapError::Provider(format!("failed to build Gonka endpoint pool: {e}"))
514 })?;
515
516 let model = entry.model.clone().unwrap_or_else(|| "gpt-4o".to_owned());
517 let max_tokens = entry.max_tokens.unwrap_or(4096);
518 let timeout = std::time::Duration::from_secs(config.timeouts.llm_request_timeout_secs);
519
520 let provider = GonkaProvider::new(zeph_llm::gonka::GonkaConfig {
521 signer: std::sync::Arc::new(signer),
522 pool: std::sync::Arc::new(pool),
523 model,
524 max_tokens,
525 embedding_model: entry.embedding_model.clone(),
526 timeout,
527 })
528 .with_provider_name(entry.effective_name());
529
530 Ok(AnyProvider::Gonka(provider))
531}
532
533#[cfg(feature = "cocoon")]
537struct CocoonClientParams {
538 base_url: String,
539 access_hash: Option<String>,
540 timeout: std::time::Duration,
541}
542
543#[cfg(feature = "cocoon")]
555fn resolve_cocoon_client_params(
556 entry: &ProviderEntry,
557 config: &Config,
558) -> Result<CocoonClientParams, BootstrapError> {
559 let base_url = entry
560 .cocoon_client_url
561 .as_deref()
562 .unwrap_or("http://localhost:10000");
563
564 if !base_url.starts_with("http://localhost")
566 && !base_url.starts_with("http://127.0.0.1")
567 && !base_url.starts_with("http://[::1]")
568 && !base_url.starts_with("https://localhost")
569 && !base_url.starts_with("https://127.0.0.1")
570 && !base_url.starts_with("https://[::1]")
571 {
572 tracing::warn!(
573 url = base_url,
574 "cocoon_client_url points to a non-localhost host; \
575 ensure this is intentional (expected sidecar on localhost)"
576 );
577 }
578
579 if entry
580 .cocoon_access_hash
581 .as_deref()
582 .is_some_and(|v| !v.is_empty())
583 {
584 tracing::warn!(
585 "cocoon_access_hash in config file appears to contain a raw value; \
586 this field should be empty — the actual hash must be stored in the vault: \
587 zeph vault set ZEPH_COCOON_ACCESS_HASH <hash>"
588 );
589 }
590
591 let access_hash = if entry.cocoon_access_hash.is_some() {
592 let hash = config
593 .secrets
594 .cocoon_access_hash
595 .as_ref()
596 .ok_or_else(|| {
597 BootstrapError::Provider(
598 "ZEPH_COCOON_ACCESS_HASH not found in vault; set it with: \
599 zeph vault set ZEPH_COCOON_ACCESS_HASH <hash>"
600 .into(),
601 )
602 })?
603 .expose()
604 .to_owned();
605 Some(hash)
606 } else {
607 None
608 };
609
610 let timeout = std::time::Duration::from_secs(config.timeouts.llm_request_timeout_secs);
611
612 Ok(CocoonClientParams {
613 base_url: base_url.to_owned(),
614 access_hash,
615 timeout,
616 })
617}
618
619#[cfg(feature = "cocoon")]
626fn build_cocoon_provider(
627 entry: &ProviderEntry,
628 config: &Config,
629) -> Result<AnyProvider, BootstrapError> {
630 let _span = tracing::info_span!("core.provider_factory.build_cocoon").entered();
631
632 let params = resolve_cocoon_client_params(entry, config)?;
633 let client = std::sync::Arc::new(CocoonClient::new(
634 ¶ms.base_url,
635 params.access_hash,
636 params.timeout,
637 ));
638
639 let model = entry
640 .model
641 .clone()
642 .unwrap_or_else(|| "Qwen/Qwen3-0.6B".to_owned());
643 let max_tokens = entry.max_tokens.unwrap_or(4096);
644 let provider = CocoonProvider::new(model, max_tokens, entry.embedding_model.clone(), client)
645 .with_provider_name(entry.effective_name());
646
647 Ok(AnyProvider::Cocoon(provider))
648}
649
650#[cfg(feature = "cocoon")]
664pub fn spawn_cocoon_health_checks(
665 providers: &[&ProviderEntry],
666 config: &Config,
667 supervisor: &std::sync::Arc<zeph_common::TaskSupervisor>,
668) {
669 for entry in providers {
670 if entry.provider_type != ProviderKind::Cocoon || !entry.cocoon_health_check {
671 continue;
672 }
673 let params = match resolve_cocoon_client_params(entry, config) {
674 Ok(params) => params,
675 Err(e) => {
676 tracing::warn!(
677 name = entry.name.as_deref().unwrap_or("<unnamed>"),
678 error = %e,
679 "skipping cocoon health check: failed to resolve client params"
680 );
681 continue;
682 }
683 };
684 let client = std::sync::Arc::new(CocoonClient::new(
685 ¶ms.base_url,
686 params.access_hash,
687 params.timeout,
688 ));
689 supervisor.spawn(zeph_common::task_supervisor::TaskDescriptor {
690 name: "core.provider_factory.cocoon_health_check",
691 restart: zeph_common::task_supervisor::RestartPolicy::RunOnce,
692 factory: move || {
693 let client = client.clone();
694 async move {
695 match client.health_check().await {
696 Ok(h) => {
697 tracing::info!(
698 proxy_connected = h.proxy_connected,
699 worker_count = h.worker_count,
700 "cocoon sidecar health check passed"
701 );
702 }
703 Err(e) => {
704 tracing::warn!(
705 error = %e,
706 "cocoon sidecar health check failed; \
707 inference requests will return LlmError::Unavailable until the sidecar is running"
708 );
709 }
710 }
711 }
712 },
713 });
714 }
715}
716
717#[cfg(feature = "candle")]
723struct CandleLoadParams {
724 source: zeph_llm::candle_provider::loader::ModelSource,
725 template: zeph_llm::candle_provider::template::ChatTemplate,
726 gen_config: zeph_llm::candle_provider::generate::GenerationConfig,
727 embedding_repo: Option<String>,
728 embedding_sha256: Option<String>,
729 hf_token: Option<String>,
730 inference_timeout: std::time::Duration,
731}
732
733#[cfg(feature = "candle")]
734fn resolve_candle_load_params(
735 entry: &ProviderEntry,
736 candle: &CandleInlineConfig,
737 config: &Config,
738) -> CandleLoadParams {
739 let source = match candle.source {
740 CandleSource::Local => zeph_llm::candle_provider::loader::ModelSource::Local {
741 path: std::path::PathBuf::from(&candle.local_path),
742 },
743 CandleSource::Huggingface => zeph_llm::candle_provider::loader::ModelSource::HuggingFace {
744 repo_id: entry
745 .model
746 .clone()
747 .unwrap_or_else(|| config.llm.effective_model().to_owned()),
748 filename: candle.filename.clone(),
749 sha256: candle.chat_model_sha256.clone(),
750 },
751 };
752 let template =
753 zeph_llm::candle_provider::template::ChatTemplate::parse_str(&candle.chat_template);
754 let gen_config = zeph_llm::candle_provider::generate::GenerationConfig {
755 temperature: candle.generation.temperature,
756 top_p: candle.generation.top_p,
757 top_k: candle.generation.top_k,
758 max_tokens: candle.generation.capped_max_tokens(),
759 seed: candle.generation.seed,
760 repeat_penalty: candle.generation.repeat_penalty,
761 repeat_last_n: candle.generation.repeat_last_n,
762 };
763 let inference_timeout = std::time::Duration::from_secs(candle.inference_timeout_secs.max(1));
766 CandleLoadParams {
767 source,
768 template,
769 gen_config,
770 embedding_repo: candle.embedding_repo.clone(),
771 embedding_sha256: candle.embedding_model_sha256.clone(),
772 hf_token: candle.hf_token.clone(),
773 inference_timeout,
774 }
775}
776
777#[cfg(feature = "candle")]
778fn build_candle_provider(
779 entry: &ProviderEntry,
780 config: &Config,
781) -> Result<AnyProvider, BootstrapError> {
782 let candle = entry.candle.as_ref().ok_or_else(|| {
783 BootstrapError::Provider(
784 "candle provider requires 'candle' section in [[llm.providers]]".into(),
785 )
786 })?;
787 let params = resolve_candle_load_params(entry, candle, config);
788 let device = select_device(candle.device)?;
789 zeph_llm::candle_provider::CandleProvider::new_with_timeout(
790 ¶ms.source,
791 params.template,
792 params.gen_config,
793 params.embedding_repo.as_deref(),
794 params.embedding_sha256.as_deref(),
795 params.hf_token.as_deref(),
796 device,
797 params.inference_timeout,
798 )
799 .map(|provider| AnyProvider::Candle(provider.with_provider_name(entry.effective_name())))
800 .map_err(|e| BootstrapError::Provider(e.to_string()))
801}
802
803#[cfg(feature = "candle")]
810pub fn select_device(
811 preference: CandleDevice,
812) -> Result<zeph_llm::candle_provider::Device, BootstrapError> {
813 match preference {
814 CandleDevice::Metal => {
815 #[cfg(feature = "metal")]
816 return zeph_llm::candle_provider::Device::new_metal(0)
817 .map_err(|e| BootstrapError::Provider(e.to_string()));
818 #[cfg(not(feature = "metal"))]
819 return Err(BootstrapError::Provider(
820 "candle compiled without metal feature".into(),
821 ));
822 }
823 CandleDevice::Cuda => {
824 #[cfg(feature = "cuda")]
825 return zeph_llm::candle_provider::Device::new_cuda(0)
826 .map_err(|e| BootstrapError::Provider(e.to_string()));
827 #[cfg(not(feature = "cuda"))]
828 return Err(BootstrapError::Provider(
829 "candle compiled without cuda feature".into(),
830 ));
831 }
832 CandleDevice::Cpu => Ok(zeph_llm::candle_provider::Device::Cpu),
833 CandleDevice::Auto => {
834 #[cfg(feature = "metal")]
835 if let Ok(device) = zeph_llm::candle_provider::Device::new_metal(0) {
836 return Ok(device);
837 }
838 #[cfg(feature = "cuda")]
839 if let Ok(device) = zeph_llm::candle_provider::Device::new_cuda(0) {
840 return Ok(device);
841 }
842 Ok(zeph_llm::candle_provider::Device::Cpu)
843 }
844 }
845}
846
847#[cfg(test)]
848mod tests {
849 #[cfg(feature = "candle")]
850 use super::select_device;
851 #[cfg(feature = "candle")]
852 use crate::config::CandleDevice;
853 #[cfg(feature = "candle")]
854 use std::assert_matches;
855
856 #[cfg(feature = "candle")]
857 #[test]
858 fn select_device_cpu_default() {
859 let device = select_device(CandleDevice::Cpu).unwrap();
860 assert_matches!(device, zeph_llm::candle_provider::Device::Cpu);
861 }
862
863 #[cfg(all(feature = "candle", not(feature = "metal")))]
864 #[test]
865 fn select_device_metal_without_feature_errors() {
866 let result = select_device(CandleDevice::Metal);
867 assert!(result.is_err());
868 assert!(result.unwrap_err().to_string().contains("metal feature"));
869 }
870
871 #[cfg(all(feature = "candle", not(feature = "cuda")))]
872 #[test]
873 fn select_device_cuda_without_feature_errors() {
874 let result = select_device(CandleDevice::Cuda);
875 assert!(result.is_err());
876 assert!(result.unwrap_err().to_string().contains("cuda feature"));
877 }
878
879 #[cfg(feature = "candle")]
883 #[test]
884 fn resolve_candle_load_params_threads_chat_and_embedding_sha256() {
885 use super::resolve_candle_load_params;
886 use crate::config::{CandleInlineConfig, Config};
887 use zeph_config::providers::ProviderEntry;
888
889 let candle = CandleInlineConfig {
890 chat_model_sha256: Some("deadbeef".into()),
891 embedding_repo: Some("org/embed-model".into()),
892 embedding_model_sha256: Some("cafef00d".into()),
893 ..CandleInlineConfig::default()
894 };
895 let entry = ProviderEntry {
896 model: Some("org/chat-model".into()),
897 candle: Some(candle.clone()),
898 ..ProviderEntry::default()
899 };
900 let config = Config::default();
901
902 let params = resolve_candle_load_params(&entry, &candle, &config);
903
904 if let zeph_llm::candle_provider::loader::ModelSource::HuggingFace { sha256, .. } =
905 params.source
906 {
907 assert_eq!(sha256.as_deref(), Some("deadbeef"));
908 } else {
909 panic!("expected HuggingFace source for CandleSource::default()")
910 }
911 assert_eq!(params.embedding_sha256.as_deref(), Some("cafef00d"));
912 }
913
914 #[cfg(feature = "candle")]
915 #[test]
916 fn resolve_candle_load_params_sha256_absent_by_default() {
917 use super::resolve_candle_load_params;
918 use crate::config::{CandleInlineConfig, Config};
919 use zeph_config::providers::ProviderEntry;
920
921 let candle = CandleInlineConfig::default();
922 let entry = ProviderEntry {
923 model: Some("org/chat-model".into()),
924 candle: Some(candle.clone()),
925 ..ProviderEntry::default()
926 };
927 let config = Config::default();
928
929 let params = resolve_candle_load_params(&entry, &candle, &config);
930
931 if let zeph_llm::candle_provider::loader::ModelSource::HuggingFace { sha256, .. } =
932 params.source
933 {
934 assert!(sha256.is_none());
935 } else {
936 panic!("expected HuggingFace source for CandleSource::default()")
937 }
938 assert!(params.embedding_sha256.is_none());
939 }
940
941 #[cfg(feature = "cocoon")]
942 use super::spawn_cocoon_health_checks;
943 use super::{build_provider_from_entry, resolve_named_provider};
944 use crate::config::{Config, ProviderKind};
945 use zeph_config::providers::ProviderEntry;
946 use zeph_llm::LlmProvider;
947
948 #[cfg(feature = "gonka")]
949 mod gonka_tests {
950 use super::*;
951 use zeph_common::secret::Secret;
952 use zeph_config::GonkaNode;
953 use zeph_llm::LlmProvider;
954
955 fn gonka_entry_with_nodes(nodes: Vec<GonkaNode>) -> ProviderEntry {
956 ProviderEntry {
957 provider_type: ProviderKind::Gonka,
958 name: Some("gonka".into()),
959 model: Some("gpt-4o".into()),
960 gonka_nodes: nodes,
961 ..ProviderEntry::default()
962 }
963 }
964
965 fn valid_nodes() -> Vec<GonkaNode> {
966 vec![GonkaNode {
967 url: "https://node1.gonka.ai".into(),
968 address: "gonka1w508d6qejxtdg4y5r3zarvary0c5xw7k2gsyg6".into(),
969 name: Some("node1".into()),
970 }]
971 }
972
973 const VALID_PRIV_KEY: &str =
974 "0000000000000000000000000000000000000000000000000000000000000001";
975
976 #[test]
977 fn build_gonka_provider_missing_key_returns_error() {
978 let entry = gonka_entry_with_nodes(valid_nodes());
979 let config = Config::default();
980 let result = build_provider_from_entry(&entry, &config, None);
981 assert!(result.is_err());
982 let msg = result.unwrap_err().to_string();
983 assert!(
984 msg.contains("ZEPH_GONKA_PRIVATE_KEY"),
985 "error must mention missing key: {msg}"
986 );
987 }
988
989 #[test]
990 fn build_gonka_provider_empty_nodes_returns_error() {
991 let entry = gonka_entry_with_nodes(vec![]);
992 let mut config = Config::default();
993 config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
994 let result = build_provider_from_entry(&entry, &config, None);
995 assert!(result.is_err());
996 let msg = result.unwrap_err().to_string();
997 assert!(
998 msg.contains("gonka_nodes") || msg.contains("node"),
999 "error must mention empty nodes: {msg}"
1000 );
1001 }
1002
1003 #[test]
1004 fn build_gonka_provider_address_mismatch_returns_error() {
1005 let entry = gonka_entry_with_nodes(valid_nodes());
1006 let mut config = Config::default();
1007 config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
1008 config.secrets.gonka_address =
1009 Some(Secret::new("gonka1wrongaddress000000000000000000000000000"));
1010 let result = build_provider_from_entry(&entry, &config, None);
1011 assert!(result.is_err());
1012 let msg = result.unwrap_err().to_string();
1013 assert!(
1014 msg.contains("does not match"),
1015 "error must mention address mismatch: {msg}"
1016 );
1017 }
1018
1019 #[test]
1020 fn build_gonka_provider_happy_path() {
1021 let entry = gonka_entry_with_nodes(valid_nodes());
1022 let mut config = Config::default();
1023 config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
1024 let result = build_provider_from_entry(&entry, &config, None);
1025 assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
1026 let provider = result.unwrap();
1027 assert_eq!(provider.name(), "gonka");
1028 }
1029
1030 #[test]
1034 fn build_gonka_provider_distinct_entries_yield_distinct_provider_names() {
1035 let mut entry_a = gonka_entry_with_nodes(valid_nodes());
1036 entry_a.name = Some("gonka-a".into());
1037 let mut entry_b = gonka_entry_with_nodes(valid_nodes());
1038 entry_b.name = Some("gonka-b".into());
1039 let mut config = Config::default();
1040 config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
1041
1042 let provider_a = build_provider_from_entry(&entry_a, &config, None).unwrap();
1043 let provider_b = build_provider_from_entry(&entry_b, &config, None).unwrap();
1044
1045 assert_eq!(provider_a.name(), "gonka-a");
1046 assert_eq!(provider_b.name(), "gonka-b");
1047 assert_ne!(provider_a.name(), provider_b.name());
1048 }
1049 }
1050
1051 fn make_provider_entry(
1052 embed: bool,
1053 model: Option<&str>,
1054 embedding_model: Option<&str>,
1055 ) -> ProviderEntry {
1056 ProviderEntry {
1057 provider_type: ProviderKind::Ollama,
1058 embed,
1059 model: model.map(str::to_owned),
1060 embedding_model: embedding_model.map(str::to_owned),
1061 ..ProviderEntry::default()
1062 }
1063 }
1064
1065 #[test]
1066 fn stable_skill_embedding_model_prefers_embedding_model_field() {
1067 let mut config = Config::default();
1068 config.llm.providers = vec![make_provider_entry(
1069 true,
1070 Some("chat-model"),
1071 Some("embed-v2"),
1072 )];
1073 assert_eq!(config.llm.stable_skill_embedding_model(), "embed-v2");
1074 }
1075
1076 #[test]
1077 fn stable_skill_embedding_model_falls_back_to_model_field() {
1078 let mut config = Config::default();
1079 config.llm.providers = vec![make_provider_entry(
1080 true,
1081 Some("nomic-embed-text-v2-moe:latest"),
1082 None,
1083 )];
1084 assert_eq!(
1085 config.llm.stable_skill_embedding_model(),
1086 "nomic-embed-text-v2-moe:latest"
1087 );
1088 }
1089
1090 #[test]
1091 fn stable_skill_embedding_model_finds_embed_flag_entry() {
1092 let mut config = Config::default();
1093 config.llm.providers = vec![
1094 make_provider_entry(false, Some("chat-model"), None),
1095 make_provider_entry(true, Some("embed-model"), Some("text-embed-3")),
1096 ];
1097 assert_eq!(config.llm.stable_skill_embedding_model(), "text-embed-3");
1098 }
1099
1100 #[test]
1101 fn stable_skill_embedding_model_falls_back_to_effective_when_no_embed_entry() {
1102 let mut config = Config::default();
1103 config.llm.embedding_model = "global-embed-model".to_owned();
1104 config.llm.providers = vec![make_provider_entry(false, Some("chat"), None)];
1106 assert_eq!(
1107 config.llm.stable_skill_embedding_model(),
1108 config.llm.effective_embedding_model()
1109 );
1110 }
1111
1112 #[test]
1113 fn resolve_named_provider_empty_name_falls_back_to_primary_silently() {
1114 let config = Config::default();
1115 let entry = make_provider_entry(false, Some("primary-model"), None);
1116 let primary = build_provider_from_entry(&entry, &config, None).unwrap();
1117 let resolved = resolve_named_provider(&config, &primary, "");
1118 assert_eq!(resolved.name(), primary.name());
1119 }
1120
1121 #[test]
1122 fn resolve_named_provider_unmatched_name_falls_back_to_primary_with_warn() {
1123 let config = Config::default(); let entry = make_provider_entry(false, Some("primary-model"), None);
1125 let primary = build_provider_from_entry(&entry, &config, None).unwrap();
1126 let resolved = resolve_named_provider(&config, &primary, "fast");
1127 assert_eq!(resolved.name(), primary.name());
1128 }
1129
1130 #[test]
1137 fn build_provider_from_entry_ollama_distinct_entries_yield_distinct_provider_names() {
1138 let config = Config::default();
1139 let entry_a = ProviderEntry {
1140 provider_type: ProviderKind::Ollama,
1141 name: Some("ollama-chat".into()),
1142 model: Some("qwen3:8b".into()),
1143 ..ProviderEntry::default()
1144 };
1145 let entry_b = ProviderEntry {
1146 provider_type: ProviderKind::Ollama,
1147 name: Some("ollama-embed".into()),
1148 model: Some("nomic-embed-text".into()),
1149 embed: true,
1150 ..ProviderEntry::default()
1151 };
1152
1153 let provider_a = build_provider_from_entry(&entry_a, &config, None).unwrap();
1154 let provider_b = build_provider_from_entry(&entry_b, &config, None).unwrap();
1155
1156 assert_ne!(
1157 provider_a.name(),
1158 provider_b.name(),
1159 "two distinct Ollama [[llm.providers]] entries must yield distinct \
1160 AnyProvider::name() values"
1161 );
1162 assert_eq!(provider_a.name(), "ollama-chat");
1163 assert_eq!(provider_b.name(), "ollama-embed");
1164 }
1165
1166 #[test]
1171 fn build_provider_from_entry_claude_openai_gemini_distinct_entries_yield_distinct_provider_names()
1172 {
1173 use zeph_common::secret::Secret;
1174
1175 let mut config = Config::default();
1176 config.secrets.claude_api_key = Some(Secret::new("test-claude-key"));
1177 config.secrets.openai_api_key = Some(Secret::new("test-openai-key"));
1178 config.secrets.gemini_api_key = Some(Secret::new("test-gemini-key"));
1179
1180 let claude_a = ProviderEntry {
1181 provider_type: ProviderKind::Claude,
1182 name: Some("claude-quality".into()),
1183 ..ProviderEntry::default()
1184 };
1185 let claude_b = ProviderEntry {
1186 provider_type: ProviderKind::Claude,
1187 name: Some("claude-fast".into()),
1188 ..ProviderEntry::default()
1189 };
1190 let provider_claude_a = build_provider_from_entry(&claude_a, &config, None).unwrap();
1191 let provider_claude_b = build_provider_from_entry(&claude_b, &config, None).unwrap();
1192 assert_eq!(provider_claude_a.name(), "claude-quality");
1193 assert_eq!(provider_claude_b.name(), "claude-fast");
1194 assert_ne!(provider_claude_a.name(), provider_claude_b.name());
1195
1196 let openai_a = ProviderEntry {
1197 provider_type: ProviderKind::OpenAi,
1198 name: Some("openai-quality".into()),
1199 ..ProviderEntry::default()
1200 };
1201 let openai_b = ProviderEntry {
1202 provider_type: ProviderKind::OpenAi,
1203 name: Some("openai-fast".into()),
1204 ..ProviderEntry::default()
1205 };
1206 let provider_openai_a = build_provider_from_entry(&openai_a, &config, None).unwrap();
1207 let provider_openai_b = build_provider_from_entry(&openai_b, &config, None).unwrap();
1208 assert_eq!(provider_openai_a.name(), "openai-quality");
1209 assert_eq!(provider_openai_b.name(), "openai-fast");
1210 assert_ne!(provider_openai_a.name(), provider_openai_b.name());
1211
1212 let gemini_a = ProviderEntry {
1213 provider_type: ProviderKind::Gemini,
1214 name: Some("gemini-quality".into()),
1215 ..ProviderEntry::default()
1216 };
1217 let gemini_b = ProviderEntry {
1218 provider_type: ProviderKind::Gemini,
1219 name: Some("gemini-fast".into()),
1220 ..ProviderEntry::default()
1221 };
1222 let provider_gemini_a = build_provider_from_entry(&gemini_a, &config, None).unwrap();
1223 let provider_gemini_b = build_provider_from_entry(&gemini_b, &config, None).unwrap();
1224 assert_eq!(provider_gemini_a.name(), "gemini-quality");
1225 assert_eq!(provider_gemini_b.name(), "gemini-fast");
1226 assert_ne!(provider_gemini_a.name(), provider_gemini_b.name());
1227 }
1228
1229 #[test]
1232 fn build_provider_from_entry_claude_unnamed_falls_back_to_type_literal() {
1233 use zeph_common::secret::Secret;
1234
1235 let mut config = Config::default();
1236 config.secrets.claude_api_key = Some(Secret::new("test-claude-key"));
1237 let entry = ProviderEntry {
1238 provider_type: ProviderKind::Claude,
1239 ..ProviderEntry::default()
1240 };
1241 let provider = build_provider_from_entry(&entry, &config, None).unwrap();
1242 assert_eq!(provider.name(), "claude");
1243 }
1244
1245 #[cfg(feature = "cocoon")]
1246 mod cocoon_tests {
1247 use super::*;
1248
1249 fn cocoon_entry(access_hash: Option<&str>) -> ProviderEntry {
1250 ProviderEntry {
1251 provider_type: ProviderKind::Cocoon,
1252 name: Some("cocoon".into()),
1253 model: Some("Qwen/Qwen3-0.6B".into()),
1254 cocoon_client_url: Some("http://localhost:10000".into()),
1255 cocoon_access_hash: access_hash.map(str::to_owned),
1256 cocoon_health_check: false,
1257 ..ProviderEntry::default()
1258 }
1259 }
1260
1261 #[test]
1263 fn cocoon_access_hash_gate_vault_miss_errors() {
1264 let entry = cocoon_entry(Some(""));
1265 let config = Config::default(); let result = build_provider_from_entry(&entry, &config, None);
1267 assert!(
1268 result.is_err(),
1269 "expected error when vault key is absent but sentinel is set"
1270 );
1271 let err_str = result.unwrap_err().to_string();
1272 assert!(
1273 err_str.contains("ZEPH_COCOON_ACCESS_HASH"),
1274 "error should mention the vault key: {err_str}"
1275 );
1276 }
1277
1278 #[test]
1280 fn cocoon_no_access_hash_gate_succeeds_without_vault() {
1281 let entry = cocoon_entry(None);
1282 let config = Config::default();
1283 let result = build_provider_from_entry(&entry, &config, None);
1284 assert!(
1285 result.is_ok(),
1286 "expected success when no access hash requested: {:?}",
1287 result.err()
1288 );
1289 }
1290
1291 #[test]
1295 fn build_provider_from_entry_cocoon_distinct_entries_yield_distinct_provider_names() {
1296 let mut entry_a = cocoon_entry(None);
1297 entry_a.name = Some("cocoon-a".into());
1298 let mut entry_b = cocoon_entry(None);
1299 entry_b.name = Some("cocoon-b".into());
1300 let config = Config::default();
1301
1302 let provider_a = build_provider_from_entry(&entry_a, &config, None).unwrap();
1303 let provider_b = build_provider_from_entry(&entry_b, &config, None).unwrap();
1304
1305 assert_eq!(provider_a.name(), "cocoon-a");
1306 assert_eq!(provider_b.name(), "cocoon-b");
1307 assert_ne!(provider_a.name(), provider_b.name());
1308 }
1309
1310 #[test]
1314 fn spawn_cocoon_health_checks_skips_on_vault_miss() {
1315 let mut entry = cocoon_entry(Some(""));
1316 entry.cocoon_health_check = true;
1317 let config = Config::default(); let supervisor = std::sync::Arc::new(zeph_common::TaskSupervisor::new(
1319 tokio_util::sync::CancellationToken::new(),
1320 ));
1321
1322 spawn_cocoon_health_checks(&[&entry], &config, &supervisor);
1323
1324 assert!(
1325 supervisor.snapshot().is_empty(),
1326 "expected no health-check task to be spawned when vault key is absent"
1327 );
1328 }
1329 }
1330}