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;
28use crate::config::{Config, ProviderEntry, ProviderKind};
29
30#[derive(Debug, thiserror::Error)]
37pub enum BootstrapError {
38 #[error("config error: {0}")]
40 Config(#[from] crate::config::ConfigError),
41 #[error("provider error: {0}")]
43 Provider(String),
44 #[error("memory error: {0}")]
46 Memory(String),
47 #[error("vault init error: {0}")]
49 VaultInit(crate::vault::AgeVaultError),
50 #[error("I/O error: {0}")]
52 Io(#[from] std::io::Error),
53}
54
55pub fn build_provider_for_switch(
66 entry: &ProviderEntry,
67 snapshot: &ProviderConfigSnapshot,
68) -> Result<AnyProvider, BootstrapError> {
69 use zeph_common::secret::Secret;
70 let mut config = Config::default();
74 config.secrets.claude_api_key = snapshot.claude_api_key.as_deref().map(Secret::new);
75 config.secrets.openai_api_key = snapshot.openai_api_key.as_deref().map(Secret::new);
76 config.secrets.gemini_api_key = snapshot.gemini_api_key.as_deref().map(Secret::new);
77 config.secrets.compatible_api_keys = snapshot
78 .compatible_api_keys
79 .iter()
80 .map(|(k, v)| (k.clone(), Secret::new(v.as_str())))
81 .collect();
82 config.secrets.gonka_private_key = snapshot
83 .gonka_private_key
84 .as_ref()
85 .map(|z| Secret::new(z.as_str()));
86 config.secrets.gonka_address = snapshot.gonka_address.as_deref().map(Secret::new);
87 config.secrets.cocoon_access_hash = snapshot.cocoon_access_hash.as_deref().map(Secret::new);
88 config.timeouts.llm_request_timeout_secs = snapshot.llm_request_timeout_secs;
89 config
90 .llm
91 .embedding_model
92 .clone_from(&snapshot.embedding_model);
93 build_provider_from_entry(entry, &config)
94}
95
96pub fn build_provider_from_entry(
106 entry: &ProviderEntry,
107 config: &Config,
108) -> Result<AnyProvider, BootstrapError> {
109 match entry.provider_type {
110 ProviderKind::Ollama => Ok(build_ollama_provider(entry, config)),
111 ProviderKind::Claude => build_claude_provider(entry, config),
112 ProviderKind::OpenAi => build_openai_provider(entry, config),
113 ProviderKind::Gemini => build_gemini_provider(entry, config),
114 ProviderKind::Compatible => build_compatible_provider(entry, config),
115 #[cfg(feature = "candle")]
116 ProviderKind::Candle => build_candle_provider(entry, config),
117 #[cfg(not(feature = "candle"))]
118 ProviderKind::Candle => Err(BootstrapError::Provider(
119 "candle feature is not enabled".into(),
120 )),
121 #[cfg(feature = "gonka")]
122 ProviderKind::Gonka => build_gonka_provider(entry, config),
123 #[cfg(not(feature = "gonka"))]
124 ProviderKind::Gonka => Err(BootstrapError::Provider(
125 "gonka feature is not enabled; rebuild with --features gonka".into(),
126 )),
127 #[cfg(feature = "cocoon")]
128 ProviderKind::Cocoon => build_cocoon_provider(entry, config),
129 #[cfg(not(feature = "cocoon"))]
130 ProviderKind::Cocoon => Err(BootstrapError::Provider(
131 "cocoon feature is not enabled; rebuild with --features cocoon".into(),
132 )),
133 }
134}
135
136fn build_ollama_provider(entry: &ProviderEntry, config: &Config) -> AnyProvider {
137 let base_url = entry
138 .base_url
139 .as_deref()
140 .unwrap_or("http://localhost:11434");
141 let model = entry.model.as_deref().unwrap_or("qwen3:8b").to_owned();
142 let embed = entry
143 .embedding_model
144 .clone()
145 .unwrap_or_else(|| config.llm.embedding_model.clone());
146 let mut provider = OllamaProvider::new(base_url, model, embed);
147 if let Some(ref vm) = entry.vision_model {
148 provider = provider.with_vision_model(vm.clone());
149 }
150 if config.mcp.forward_output_schema {
151 tracing::debug!(
152 "mcp.forward_output_schema is enabled but Ollama does not support \
153 output schema forwarding; setting ignored for this provider"
154 );
155 }
156 AnyProvider::Ollama(provider)
157}
158
159fn build_claude_provider(
160 entry: &ProviderEntry,
161 config: &Config,
162) -> Result<AnyProvider, BootstrapError> {
163 let api_key = config
164 .secrets
165 .claude_api_key
166 .as_ref()
167 .ok_or_else(|| BootstrapError::Provider("ZEPH_CLAUDE_API_KEY not found in vault".into()))?
168 .expose()
169 .to_owned();
170 let model = entry
171 .model
172 .clone()
173 .unwrap_or_else(|| "claude-haiku-4-5-20251001".to_owned());
174 let max_tokens = entry.max_tokens.unwrap_or(4096);
175 let provider = ClaudeProvider::new(api_key, model, max_tokens)
176 .with_client(llm_client(config.timeouts.llm_request_timeout_secs))
177 .with_extended_context(entry.enable_extended_context)
178 .with_thinking_opt(entry.thinking.clone())
179 .map_err(|e| BootstrapError::Provider(format!("invalid thinking config: {e}")))?
180 .with_server_compaction(entry.server_compaction)
181 .with_prompt_cache_ttl(entry.prompt_cache_ttl)
182 .with_output_schema_forwarding(
183 config.mcp.forward_output_schema,
184 config.mcp.output_schema_hint_bytes,
185 config.mcp.max_description_bytes,
186 );
187 tracing::info!(
188 forward = config.mcp.forward_output_schema,
189 "mcp.output_schema.forwarding_configured"
190 );
191 Ok(AnyProvider::Claude(provider))
192}
193
194fn build_openai_provider(
195 entry: &ProviderEntry,
196 config: &Config,
197) -> Result<AnyProvider, BootstrapError> {
198 let api_key = config
199 .secrets
200 .openai_api_key
201 .as_ref()
202 .ok_or_else(|| BootstrapError::Provider("ZEPH_OPENAI_API_KEY not found in vault".into()))?
203 .expose()
204 .to_owned();
205 let base_url = entry
206 .base_url
207 .clone()
208 .unwrap_or_else(|| "https://api.openai.com/v1".to_owned());
209 let model = entry
210 .model
211 .clone()
212 .unwrap_or_else(|| "gpt-4o-mini".to_owned());
213 let max_tokens = entry.max_tokens.unwrap_or(4096);
214 Ok(AnyProvider::OpenAi(
215 OpenAiProvider::new(
216 api_key,
217 base_url,
218 model,
219 max_tokens,
220 entry.embedding_model.clone(),
221 entry.reasoning_effort.clone(),
222 )
223 .with_client(llm_client(config.timeouts.llm_request_timeout_secs))
224 .with_output_schema_forwarding(
225 config.mcp.forward_output_schema,
226 config.mcp.output_schema_hint_bytes,
227 config.mcp.max_description_bytes,
228 ),
229 ))
230}
231
232fn build_gemini_provider(
233 entry: &ProviderEntry,
234 config: &Config,
235) -> Result<AnyProvider, BootstrapError> {
236 let api_key = config
237 .secrets
238 .gemini_api_key
239 .as_ref()
240 .ok_or_else(|| BootstrapError::Provider("ZEPH_GEMINI_API_KEY not found in vault".into()))?
241 .expose()
242 .to_owned();
243 let model = entry
244 .model
245 .clone()
246 .unwrap_or_else(|| "gemini-2.0-flash".to_owned());
247 let max_tokens = entry.max_tokens.unwrap_or(8192);
248 let base_url = entry
249 .base_url
250 .clone()
251 .unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_owned());
252 let mut provider = GeminiProvider::new(api_key, model, max_tokens)
253 .with_base_url(base_url)
254 .with_client(llm_client(config.timeouts.llm_request_timeout_secs));
255 if let Some(ref em) = entry.embedding_model {
256 provider = provider.with_embedding_model(em.clone());
257 }
258 if let Some(level) = entry.thinking_level {
259 provider = provider.with_thinking_level(level);
260 }
261 if let Some(budget) = entry.thinking_budget {
262 provider = provider
263 .with_thinking_budget(budget)
264 .map_err(|e| BootstrapError::Provider(e.to_string()))?;
265 }
266 if let Some(include) = entry.include_thoughts {
267 provider = provider.with_include_thoughts(include);
268 }
269 if config.mcp.forward_output_schema {
270 tracing::debug!(
271 "mcp.forward_output_schema is enabled but Gemini does not support \
272 output schema forwarding; setting ignored for this provider"
273 );
274 }
275 Ok(AnyProvider::Gemini(provider))
276}
277
278fn build_compatible_provider(
279 entry: &ProviderEntry,
280 config: &Config,
281) -> Result<AnyProvider, BootstrapError> {
282 let name = entry.name.as_deref().ok_or_else(|| {
283 BootstrapError::Provider(
284 "compatible provider requires 'name' field in [[llm.providers]]".into(),
285 )
286 })?;
287 let base_url = entry.base_url.clone().ok_or_else(|| {
288 BootstrapError::Provider(format!("compatible provider '{name}' requires 'base_url'"))
289 })?;
290 let model = entry.model.clone().unwrap_or_default();
291 let api_key = entry.api_key.clone().unwrap_or_else(|| {
292 config
293 .secrets
294 .compatible_api_keys
295 .get(name)
296 .map(|s| s.expose().to_owned())
297 .unwrap_or_default()
298 });
299 let max_tokens = entry.max_tokens.unwrap_or(4096);
300 let provider = CompatibleProvider::new(
301 name.to_owned(),
302 api_key,
303 base_url,
304 model,
305 max_tokens,
306 entry.embedding_model.clone(),
307 )
308 .with_output_schema_forwarding(
309 config.mcp.forward_output_schema,
310 config.mcp.output_schema_hint_bytes,
311 config.mcp.max_description_bytes,
312 );
313 tracing::info!(
314 forward = config.mcp.forward_output_schema,
315 provider = name,
316 "mcp.output_schema.forwarding_configured"
317 );
318 Ok(AnyProvider::Compatible(provider))
319}
320
321#[cfg(feature = "gonka")]
322fn build_gonka_provider(
323 entry: &ProviderEntry,
324 config: &Config,
325) -> Result<AnyProvider, BootstrapError> {
326 let _span = tracing::info_span!("core.provider_factory.build_gonka").entered();
327
328 let private_key_hex: Zeroizing<String> = Zeroizing::new(
329 config
330 .secrets
331 .gonka_private_key
332 .as_ref()
333 .ok_or_else(|| {
334 BootstrapError::Provider(
335 "ZEPH_GONKA_PRIVATE_KEY not found in vault; set it with: zeph vault set ZEPH_GONKA_PRIVATE_KEY <hex>".into(),
336 )
337 })?
338 .expose()
339 .to_owned(),
340 );
341
342 let chain_prefix = entry.effective_gonka_chain_prefix().to_owned();
343 let signer = RequestSigner::from_hex(&private_key_hex, &chain_prefix)
344 .map_err(|e| BootstrapError::Provider(format!("invalid Gonka private key: {e}")))?;
345
346 if let Some(ref configured_address) = config.secrets.gonka_address {
347 let configured = configured_address.expose().to_lowercase();
348 let derived = signer.address().to_lowercase();
349 if configured != derived {
350 return Err(BootstrapError::Provider(format!(
351 "ZEPH_GONKA_ADDRESS does not match address derived from private key \
352 (configured: {configured}, derived: {derived})"
353 )));
354 }
355 } else {
356 tracing::info!(
357 address = signer.address(),
358 "Gonka: using address derived from private key (ZEPH_GONKA_ADDRESS not set)"
359 );
360 }
361
362 if entry.gonka_nodes.is_empty() {
363 return Err(BootstrapError::Provider(
364 "Gonka provider entry must have at least one node in gonka_nodes".into(),
365 ));
366 }
367
368 let endpoints: Vec<GonkaEndpoint> = entry
369 .gonka_nodes
370 .iter()
371 .map(|n| GonkaEndpoint {
372 base_url: n.url.clone(),
373 address: n.address.clone(),
374 })
375 .collect();
376
377 let pool = EndpointPool::new(endpoints).map_err(|e| {
378 BootstrapError::Provider(format!("failed to build Gonka endpoint pool: {e}"))
379 })?;
380
381 let model = entry.model.clone().unwrap_or_else(|| "gpt-4o".to_owned());
382 let max_tokens = entry.max_tokens.unwrap_or(4096);
383 let timeout = std::time::Duration::from_secs(config.timeouts.llm_request_timeout_secs);
384
385 let provider = GonkaProvider::new(
386 std::sync::Arc::new(signer),
387 std::sync::Arc::new(pool),
388 model,
389 max_tokens,
390 entry.embedding_model.clone(),
391 timeout,
392 );
393
394 Ok(AnyProvider::Gonka(provider))
395}
396
397#[cfg(feature = "cocoon")]
407fn build_cocoon_provider(
408 entry: &ProviderEntry,
409 config: &Config,
410) -> Result<AnyProvider, BootstrapError> {
411 let _span = tracing::info_span!("core.provider_factory.build_cocoon").entered();
412
413 let base_url = entry
414 .cocoon_client_url
415 .as_deref()
416 .unwrap_or("http://localhost:10000");
417
418 if !base_url.starts_with("http://localhost")
420 && !base_url.starts_with("http://127.0.0.1")
421 && !base_url.starts_with("http://[::1]")
422 && !base_url.starts_with("https://localhost")
423 && !base_url.starts_with("https://127.0.0.1")
424 && !base_url.starts_with("https://[::1]")
425 {
426 tracing::warn!(
427 url = base_url,
428 "cocoon_client_url points to a non-localhost host; \
429 ensure this is intentional (expected sidecar on localhost)"
430 );
431 }
432
433 if entry
434 .cocoon_access_hash
435 .as_deref()
436 .is_some_and(|v| !v.is_empty())
437 {
438 tracing::warn!(
439 "cocoon_access_hash in config file appears to contain a raw value; \
440 this field should be empty — the actual hash must be stored in the vault: \
441 zeph vault set ZEPH_COCOON_ACCESS_HASH <hash>"
442 );
443 }
444
445 let access_hash = if entry.cocoon_access_hash.is_some() {
446 let hash = config
447 .secrets
448 .cocoon_access_hash
449 .as_ref()
450 .ok_or_else(|| {
451 BootstrapError::Provider(
452 "ZEPH_COCOON_ACCESS_HASH not found in vault; set it with: \
453 zeph vault set ZEPH_COCOON_ACCESS_HASH <hash>"
454 .into(),
455 )
456 })?
457 .expose()
458 .to_owned();
459 Some(hash)
460 } else {
461 None
462 };
463
464 let timeout = std::time::Duration::from_secs(config.timeouts.llm_request_timeout_secs);
465 let client = std::sync::Arc::new(CocoonClient::new(base_url, access_hash, timeout));
466
467 if entry.cocoon_health_check {
468 let client_clone = std::sync::Arc::clone(&client);
469 drop(tokio::spawn(async move {
472 match client_clone.health_check().await {
473 Ok(h) => {
474 tracing::info!(
475 proxy_connected = h.proxy_connected,
476 worker_count = h.worker_count,
477 "cocoon sidecar health check passed"
478 );
479 }
480 Err(e) => {
481 tracing::warn!(
482 error = %e,
483 "cocoon sidecar health check failed; \
484 inference requests will return LlmError::Unavailable until the sidecar is running"
485 );
486 }
487 }
488 }));
489 }
490
491 let model = entry
492 .model
493 .clone()
494 .unwrap_or_else(|| "Qwen/Qwen3-0.6B".to_owned());
495 let max_tokens = entry.max_tokens.unwrap_or(4096);
496 let provider = CocoonProvider::new(model, max_tokens, entry.embedding_model.clone(), client);
497
498 Ok(AnyProvider::Cocoon(provider))
499}
500
501#[cfg(feature = "candle")]
502fn build_candle_provider(
503 entry: &ProviderEntry,
504 config: &Config,
505) -> Result<AnyProvider, BootstrapError> {
506 let candle = entry.candle.as_ref().ok_or_else(|| {
507 BootstrapError::Provider(
508 "candle provider requires 'candle' section in [[llm.providers]]".into(),
509 )
510 })?;
511 let source = match candle.source.as_str() {
512 "local" => zeph_llm::candle_provider::loader::ModelSource::Local {
513 path: std::path::PathBuf::from(&candle.local_path),
514 },
515 _ => zeph_llm::candle_provider::loader::ModelSource::HuggingFace {
516 repo_id: entry
517 .model
518 .clone()
519 .unwrap_or_else(|| config.llm.effective_model().to_owned()),
520 filename: candle.filename.clone(),
521 },
522 };
523 let template =
524 zeph_llm::candle_provider::template::ChatTemplate::parse_str(&candle.chat_template);
525 let gen_config = zeph_llm::candle_provider::generate::GenerationConfig {
526 temperature: candle.generation.temperature,
527 top_p: candle.generation.top_p,
528 top_k: candle.generation.top_k,
529 max_tokens: candle.generation.capped_max_tokens(),
530 seed: candle.generation.seed,
531 repeat_penalty: candle.generation.repeat_penalty,
532 repeat_last_n: candle.generation.repeat_last_n,
533 };
534 let device = select_device(&candle.device)?;
535 let inference_timeout = std::time::Duration::from_secs(candle.inference_timeout_secs.max(1));
538 zeph_llm::candle_provider::CandleProvider::new_with_timeout(
539 &source,
540 template,
541 gen_config,
542 candle.embedding_repo.as_deref(),
543 candle.hf_token.as_deref(),
544 device,
545 inference_timeout,
546 )
547 .map(AnyProvider::Candle)
548 .map_err(|e| BootstrapError::Provider(e.to_string()))
549}
550
551#[cfg(feature = "candle")]
562pub fn select_device(
563 preference: &str,
564) -> Result<zeph_llm::candle_provider::Device, BootstrapError> {
565 match preference {
566 "metal" => {
567 #[cfg(feature = "metal")]
568 return zeph_llm::candle_provider::Device::new_metal(0)
569 .map_err(|e| BootstrapError::Provider(e.to_string()));
570 #[cfg(not(feature = "metal"))]
571 return Err(BootstrapError::Provider(
572 "candle compiled without metal feature".into(),
573 ));
574 }
575 "cuda" => {
576 #[cfg(feature = "cuda")]
577 return zeph_llm::candle_provider::Device::new_cuda(0)
578 .map_err(|e| BootstrapError::Provider(e.to_string()));
579 #[cfg(not(feature = "cuda"))]
580 return Err(BootstrapError::Provider(
581 "candle compiled without cuda feature".into(),
582 ));
583 }
584 "auto" => {
585 #[cfg(feature = "metal")]
586 if let Ok(device) = zeph_llm::candle_provider::Device::new_metal(0) {
587 return Ok(device);
588 }
589 #[cfg(feature = "cuda")]
590 if let Ok(device) = zeph_llm::candle_provider::Device::new_cuda(0) {
591 return Ok(device);
592 }
593 Ok(zeph_llm::candle_provider::Device::Cpu)
594 }
595 _ => Ok(zeph_llm::candle_provider::Device::Cpu),
596 }
597}
598
599#[must_use]
606pub fn effective_embedding_model(config: &Config) -> String {
607 if let Some(m) = config
609 .llm
610 .providers
611 .iter()
612 .find(|e| e.embed)
613 .and_then(|e| e.embedding_model.as_ref())
614 {
615 return m.clone();
616 }
617 if let Some(m) = config
619 .llm
620 .providers
621 .first()
622 .and_then(|e| e.embedding_model.as_ref())
623 {
624 return m.clone();
625 }
626 config.llm.embedding_model.clone()
627}
628
629#[must_use]
639pub fn stable_skill_embedding_model(config: &Config) -> String {
640 let embed_entry = config.llm.providers.iter().find(|e| e.embed).or_else(|| {
642 config
643 .llm
644 .providers
645 .iter()
646 .find(|e| e.embedding_model.is_some())
647 });
648
649 if let Some(entry) = embed_entry {
650 if let Some(em) = entry.embedding_model.as_ref().filter(|s| !s.is_empty()) {
652 return em.clone();
653 }
654 if let Some(m) = entry.model.as_ref().filter(|s| !s.is_empty()) {
655 return m.clone();
656 }
657 }
658
659 effective_embedding_model(config)
661}
662
663#[cfg(test)]
664mod tests {
665 #[cfg(feature = "candle")]
666 use super::select_device;
667
668 #[cfg(feature = "candle")]
669 #[test]
670 fn select_device_cpu_default() {
671 let device = select_device("cpu").unwrap();
672 assert!(matches!(device, zeph_llm::candle_provider::Device::Cpu));
673 }
674
675 #[cfg(feature = "candle")]
676 #[test]
677 fn select_device_unknown_defaults_to_cpu() {
678 let device = select_device("unknown").unwrap();
679 assert!(matches!(device, zeph_llm::candle_provider::Device::Cpu));
680 }
681
682 #[cfg(all(feature = "candle", not(feature = "metal")))]
683 #[test]
684 fn select_device_metal_without_feature_errors() {
685 let result = select_device("metal");
686 assert!(result.is_err());
687 assert!(result.unwrap_err().to_string().contains("metal feature"));
688 }
689
690 #[cfg(all(feature = "candle", not(feature = "cuda")))]
691 #[test]
692 fn select_device_cuda_without_feature_errors() {
693 let result = select_device("cuda");
694 assert!(result.is_err());
695 assert!(result.unwrap_err().to_string().contains("cuda feature"));
696 }
697
698 #[cfg(feature = "candle")]
699 #[test]
700 fn select_device_auto_fallback() {
701 let device = select_device("auto").unwrap();
702 assert!(matches!(
703 device,
704 zeph_llm::candle_provider::Device::Cpu
705 | zeph_llm::candle_provider::Device::Cuda(_)
706 | zeph_llm::candle_provider::Device::Metal(_)
707 ));
708 }
709
710 #[cfg(any(feature = "gonka", feature = "cocoon"))]
711 use super::build_provider_from_entry;
712 use super::{effective_embedding_model, stable_skill_embedding_model};
713 use crate::config::{Config, ProviderKind};
714 use zeph_config::providers::ProviderEntry;
715
716 #[cfg(feature = "gonka")]
717 mod gonka_tests {
718 use super::*;
719 use zeph_common::secret::Secret;
720 use zeph_config::GonkaNode;
721 use zeph_llm::LlmProvider;
722
723 fn gonka_entry_with_nodes(nodes: Vec<GonkaNode>) -> ProviderEntry {
724 ProviderEntry {
725 provider_type: ProviderKind::Gonka,
726 name: Some("gonka".into()),
727 model: Some("gpt-4o".into()),
728 gonka_nodes: nodes,
729 ..ProviderEntry::default()
730 }
731 }
732
733 fn valid_nodes() -> Vec<GonkaNode> {
734 vec![GonkaNode {
735 url: "https://node1.gonka.ai".into(),
736 address: "gonka1w508d6qejxtdg4y5r3zarvary0c5xw7k2gsyg6".into(),
737 name: Some("node1".into()),
738 }]
739 }
740
741 const VALID_PRIV_KEY: &str =
742 "0000000000000000000000000000000000000000000000000000000000000001";
743
744 #[test]
745 fn build_gonka_provider_missing_key_returns_error() {
746 let entry = gonka_entry_with_nodes(valid_nodes());
747 let config = Config::default();
748 let result = build_provider_from_entry(&entry, &config);
749 assert!(result.is_err());
750 let msg = result.unwrap_err().to_string();
751 assert!(
752 msg.contains("ZEPH_GONKA_PRIVATE_KEY"),
753 "error must mention missing key: {msg}"
754 );
755 }
756
757 #[test]
758 fn build_gonka_provider_empty_nodes_returns_error() {
759 let entry = gonka_entry_with_nodes(vec![]);
760 let mut config = Config::default();
761 config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
762 let result = build_provider_from_entry(&entry, &config);
763 assert!(result.is_err());
764 let msg = result.unwrap_err().to_string();
765 assert!(
766 msg.contains("gonka_nodes") || msg.contains("node"),
767 "error must mention empty nodes: {msg}"
768 );
769 }
770
771 #[test]
772 fn build_gonka_provider_address_mismatch_returns_error() {
773 let entry = gonka_entry_with_nodes(valid_nodes());
774 let mut config = Config::default();
775 config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
776 config.secrets.gonka_address =
777 Some(Secret::new("gonka1wrongaddress000000000000000000000000000"));
778 let result = build_provider_from_entry(&entry, &config);
779 assert!(result.is_err());
780 let msg = result.unwrap_err().to_string();
781 assert!(
782 msg.contains("does not match"),
783 "error must mention address mismatch: {msg}"
784 );
785 }
786
787 #[test]
788 fn build_gonka_provider_happy_path() {
789 let entry = gonka_entry_with_nodes(valid_nodes());
790 let mut config = Config::default();
791 config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
792 let result = build_provider_from_entry(&entry, &config);
793 assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
794 let provider = result.unwrap();
795 assert_eq!(provider.name(), "gonka");
796 }
797 }
798
799 fn make_provider_entry(
800 embed: bool,
801 model: Option<&str>,
802 embedding_model: Option<&str>,
803 ) -> ProviderEntry {
804 ProviderEntry {
805 provider_type: ProviderKind::Ollama,
806 embed,
807 model: model.map(str::to_owned),
808 embedding_model: embedding_model.map(str::to_owned),
809 ..ProviderEntry::default()
810 }
811 }
812
813 #[test]
814 fn stable_skill_embedding_model_prefers_embedding_model_field() {
815 let mut config = Config::default();
816 config.llm.providers = vec![make_provider_entry(
817 true,
818 Some("chat-model"),
819 Some("embed-v2"),
820 )];
821 assert_eq!(stable_skill_embedding_model(&config), "embed-v2");
822 }
823
824 #[test]
825 fn stable_skill_embedding_model_falls_back_to_model_field() {
826 let mut config = Config::default();
827 config.llm.providers = vec![make_provider_entry(
828 true,
829 Some("nomic-embed-text-v2-moe:latest"),
830 None,
831 )];
832 assert_eq!(
833 stable_skill_embedding_model(&config),
834 "nomic-embed-text-v2-moe:latest"
835 );
836 }
837
838 #[test]
839 fn stable_skill_embedding_model_finds_embed_flag_entry() {
840 let mut config = Config::default();
841 config.llm.providers = vec![
842 make_provider_entry(false, Some("chat-model"), None),
843 make_provider_entry(true, Some("embed-model"), Some("text-embed-3")),
844 ];
845 assert_eq!(stable_skill_embedding_model(&config), "text-embed-3");
846 }
847
848 #[test]
849 fn stable_skill_embedding_model_falls_back_to_effective_when_no_embed_entry() {
850 let mut config = Config::default();
851 config.llm.embedding_model = "global-embed-model".to_owned();
852 config.llm.providers = vec![make_provider_entry(false, Some("chat"), None)];
854 assert_eq!(
855 stable_skill_embedding_model(&config),
856 effective_embedding_model(&config)
857 );
858 }
859
860 #[cfg(feature = "cocoon")]
861 mod cocoon_tests {
862 use super::*;
863
864 fn cocoon_entry(access_hash: Option<&str>) -> ProviderEntry {
865 ProviderEntry {
866 provider_type: ProviderKind::Cocoon,
867 name: Some("cocoon".into()),
868 model: Some("Qwen/Qwen3-0.6B".into()),
869 cocoon_client_url: Some("http://localhost:10000".into()),
870 cocoon_access_hash: access_hash.map(str::to_owned),
871 cocoon_health_check: false,
872 ..ProviderEntry::default()
873 }
874 }
875
876 #[test]
878 fn cocoon_access_hash_gate_vault_miss_errors() {
879 let entry = cocoon_entry(Some(""));
880 let config = Config::default(); let result = build_provider_from_entry(&entry, &config);
882 assert!(
883 result.is_err(),
884 "expected error when vault key is absent but sentinel is set"
885 );
886 let err_str = result.unwrap_err().to_string();
887 assert!(
888 err_str.contains("ZEPH_COCOON_ACCESS_HASH"),
889 "error should mention the vault key: {err_str}"
890 );
891 }
892
893 #[test]
895 fn cocoon_no_access_hash_gate_succeeds_without_vault() {
896 let entry = cocoon_entry(None);
897 let config = Config::default();
898 let result = build_provider_from_entry(&entry, &config);
899 assert!(
900 result.is_ok(),
901 "expected success when no access hash requested: {:?}",
902 result.err()
903 );
904 }
905 }
906}