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