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/// Error type for provider construction failures.
31///
32/// String-based variants flatten the error chain intentionally: bootstrap errors are
33/// terminal (the application exits), so downcasting is not needed at this stage.
34/// If a future phase requires programmatic retry on specific failures, expand these
35/// variants into typed sub-errors.
36#[derive(Debug, thiserror::Error)]
37pub enum BootstrapError {
38    /// Configuration validation failed.
39    #[error("config error: {0}")]
40    Config(#[from] crate::config::ConfigError),
41    /// Provider construction failed (missing secrets, unsupported kind, etc.).
42    #[error("provider error: {0}")]
43    Provider(String),
44    /// Memory subsystem initialization failed.
45    #[error("memory error: {0}")]
46    Memory(String),
47    /// Age vault initialization failed.
48    #[error("vault init error: {0}")]
49    VaultInit(crate::vault::AgeVaultError),
50    /// I/O error during bootstrap.
51    #[error("I/O error: {0}")]
52    Io(#[from] std::io::Error),
53}
54
55/// Build an `AnyProvider` from a `ProviderEntry` using a runtime config snapshot.
56///
57/// Called by the `/provider <name>` slash command to switch providers at runtime without
58/// requiring the full `Config`. Router and Orchestrator provider kinds are not supported
59/// for runtime switching — they require the full provider pool to be re-initialized.
60///
61/// # Errors
62///
63/// Returns `BootstrapError::Provider` when the provider kind is unsupported for runtime
64/// switching, a required secret is missing, or the entry is misconfigured.
65pub fn build_provider_for_switch(
66    entry: &ProviderEntry,
67    snapshot: &ProviderConfigSnapshot,
68) -> Result<AnyProvider, BootstrapError> {
69    use zeph_common::secret::Secret;
70    // Reconstruct a minimal Config from the snapshot so we can reuse build_provider_from_entry.
71    // Only fields read by build_provider_from_entry are populated; everything else uses defaults.
72    // Secrets are stored as plain strings in the snapshot because Secret does not implement Clone.
73    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
96/// Build an `AnyProvider` from a unified `ProviderEntry` (new `[[llm.providers]]` format).
97///
98/// All provider-specific fields come from `entry`; the global `config` is used only for
99/// secrets and timeout settings.
100///
101/// # Errors
102///
103/// Returns `BootstrapError::Provider` when a required secret is missing or an entry is
104/// misconfigured (e.g. compatible provider without a name).
105pub 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/// Build a [`CocoonProvider`] from a `[[llm.providers]]` entry.
398///
399/// Resolves the access hash from the age vault when `cocoon_access_hash` is `Some(_)` in the
400/// entry. If the vault key is absent an explicit, actionable error is returned.
401///
402/// # Errors
403///
404/// Returns [`BootstrapError::Provider`] when the vault key `ZEPH_COCOON_ACCESS_HASH` is
405/// expected (field is `Some`) but not present in the resolved secrets.
406#[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    // Validate URL at construction time (MINOR-3): warn if not localhost.
419    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        // Fire-and-forget: intentional. The health check is advisory-only; a failure
470        // does not block provider construction.
471        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    // Floor at 1s so that inference_timeout_secs = 0 does not cause every request to
536    // immediately time out.
537    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/// Select the candle compute device based on a string preference.
552///
553/// Resolution order: `"metal"` → Metal GPU (requires `metal` feature),
554/// `"cuda"` → CUDA GPU (requires `cuda` feature), `"auto"` → best available,
555/// anything else → CPU.
556///
557/// # Errors
558///
559/// Returns `BootstrapError::Provider` when the requested device is not available (e.g.
560/// `"metal"` requested but compiled without the `metal` feature).
561#[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/// Determine the effective embedding model name for the memory subsystem.
600///
601/// Resolution order:
602/// 1. `embedding_model` from the `[[llm.providers]]` entry marked `embed = true`
603/// 2. `embedding_model` from the first entry in `[[llm.providers]]`
604/// 3. `[llm] embedding_model` global fallback
605#[must_use]
606pub fn effective_embedding_model(config: &Config) -> String {
607    // Prefer a dedicated embed provider.
608    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    // Fall back to the first provider's embedding model.
618    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/// Resolve the stable embedding model name for skill-matcher collection versioning.
630///
631/// This uses the same entry resolution as the embedding provider itself: the entry
632/// with `embed = true`, preferring its `embedding_model` field and falling back to
633/// its `model` field. Using the actual provider's model name prevents the
634/// `model_has_changed` check in [`zeph_memory::embedding_registry`] from triggering
635/// false positives that would rebuild the `zeph_skills` collection on every startup.
636///
637/// Falls back to [`effective_embedding_model`] when no dedicated embed entry exists.
638#[must_use]
639pub fn stable_skill_embedding_model(config: &Config) -> String {
640    // Find the dedicated embed entry (same lookup as `create_embedding_provider`).
641    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        // Prefer the explicit `embedding_model` field; fall back to the `model` field.
651        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    // No dedicated embed entry — fall back to the general embedding model resolution.
660    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        // No embed=true entry, no embedding_model field set — falls back to effective_embedding_model.
853        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        /// `cocoon_access_hash = Some("")` sentinel with no vault key must return an error.
877        #[test]
878        fn cocoon_access_hash_gate_vault_miss_errors() {
879            let entry = cocoon_entry(Some(""));
880            let config = Config::default(); // secrets.cocoon_access_hash = None
881            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        /// `cocoon_access_hash = None` must succeed without touching the vault (health check off).
894        #[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}