Skip to main content

zeph_core/
provider_factory.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Pure provider factory helpers: build `AnyProvider` instances from config entries.
5//!
6//! This module contains configuration-to-provider transformation functions that are
7//! used by internal `zeph-core` subsystems (skills, tools, autodream, session config).
8//! They are intentionally separated from bootstrap orchestration logic so that provider
9//! construction can be reasoned about and tested independently of startup sequencing.
10
11use 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/// Error type for provider construction failures.
32///
33/// String-based variants flatten the error chain intentionally: bootstrap errors are
34/// terminal (the application exits), so downcasting is not needed at this stage.
35/// If a future phase requires programmatic retry on specific failures, expand these
36/// variants into typed sub-errors.
37#[derive(Debug, thiserror::Error)]
38pub enum BootstrapError {
39    /// Configuration validation failed.
40    #[error("config error: {0}")]
41    Config(#[from] crate::config::ConfigError),
42    /// Provider construction failed (missing secrets, unsupported kind, etc.).
43    #[error("provider error: {0}")]
44    Provider(String),
45    /// Memory subsystem initialization failed.
46    #[error("memory error: {0}")]
47    Memory(String),
48    /// Age vault initialization failed.
49    #[error("vault init error: {0}")]
50    VaultInit(crate::vault::AgeVaultError),
51    /// I/O error during bootstrap.
52    #[error("I/O error: {0}")]
53    Io(#[from] std::io::Error),
54}
55
56/// Build an `AnyProvider` from a `ProviderEntry` using a runtime config snapshot.
57///
58/// Called by the `/provider <name>` slash command to switch providers at runtime without
59/// requiring the full `Config`. Router and Orchestrator provider kinds are not supported
60/// for runtime switching — they require the full provider pool to be re-initialized.
61///
62/// # Errors
63///
64/// Returns `BootstrapError::Provider` when the provider kind is unsupported for runtime
65/// switching, a required secret is missing, or the entry is misconfigured.
66pub fn build_provider_for_switch(
67    entry: &ProviderEntry,
68    snapshot: &ProviderConfigSnapshot,
69) -> Result<AnyProvider, BootstrapError> {
70    use zeph_common::secret::Secret;
71    // Reconstruct a minimal Config from the snapshot so we can reuse build_provider_from_entry.
72    // Only fields read by build_provider_from_entry are populated; everything else uses defaults.
73    // Secrets are stored as plain strings in the snapshot because Secret does not implement Clone.
74    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
97/// Build an `AnyProvider` from a unified `ProviderEntry` (new `[[llm.providers]]` format).
98///
99/// All provider-specific fields come from `entry`; the global `config` is used only for
100/// secrets and timeout settings.
101///
102/// # Errors
103///
104/// Returns `BootstrapError::Provider` when a required secret is missing or an entry is
105/// misconfigured (e.g. compatible provider without a name).
106pub 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/// Build a [`CocoonProvider`] from a `[[llm.providers]]` entry.
404///
405/// Resolves the access hash from the age vault when `cocoon_access_hash` is `Some(_)` in the
406/// entry. If the vault key is absent an explicit, actionable error is returned.
407///
408/// # Errors
409///
410/// Returns [`BootstrapError::Provider`] when the vault key `ZEPH_COCOON_ACCESS_HASH` is
411/// expected (field is `Some`) but not present in the resolved secrets.
412#[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    // Validate URL at construction time (MINOR-3): warn if not localhost.
425    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        // Fire-and-forget: intentional. The health check is advisory-only; a failure
476        // does not block provider construction.
477        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    // Floor at 1s so that inference_timeout_secs = 0 does not cause every request to
542    // immediately time out.
543    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/// Select the candle compute device based on a string preference.
558///
559/// Resolution order: `"metal"` → Metal GPU (requires `metal` feature),
560/// `"cuda"` → CUDA GPU (requires `cuda` feature), `"auto"` → best available,
561/// anything else → CPU.
562///
563/// # Errors
564///
565/// Returns `BootstrapError::Provider` when the requested device is not available (e.g.
566/// `"metal"` requested but compiled without the `metal` feature).
567#[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        // No embed=true entry, no embedding_model field set — falls back to effective_embedding_model.
794        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        /// `cocoon_access_hash = Some("")` sentinel with no vault key must return an error.
818        #[test]
819        fn cocoon_access_hash_gate_vault_miss_errors() {
820            let entry = cocoon_entry(Some(""));
821            let config = Config::default(); // secrets.cocoon_access_hash = None
822            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        /// `cocoon_access_hash = None` must succeed without touching the vault (health check off).
835        #[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}