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;
28#[cfg(feature = "candle")]
29use crate::config::{CandleDevice, CandleInlineConfig, CandleSource};
30use crate::config::{Config, ProviderEntry, ProviderKind};
31
32#[non_exhaustive]
33/// Error type for provider construction failures.
34///
35/// String-based variants flatten the error chain intentionally: bootstrap errors are
36/// terminal (the application exits), so downcasting is not needed at this stage.
37/// If a future phase requires programmatic retry on specific failures, expand these
38/// variants into typed sub-errors.
39#[derive(Debug, thiserror::Error)]
40pub enum BootstrapError {
41    /// Configuration validation failed.
42    #[error("config error: {0}")]
43    Config(#[from] crate::config::ConfigError),
44    /// Provider construction failed (missing secrets, unsupported kind, etc.).
45    #[error("provider error: {0}")]
46    Provider(String),
47    /// Memory subsystem initialization failed.
48    #[error("memory error: {0}")]
49    Memory(String),
50    /// Age vault initialization failed.
51    #[error("vault init error: {0}")]
52    VaultInit(crate::vault::AgeVaultError),
53    /// I/O error during bootstrap.
54    #[error("I/O error: {0}")]
55    Io(#[from] std::io::Error),
56}
57
58/// Build an `AnyProvider` from a `ProviderEntry` using a runtime config snapshot.
59///
60/// Called by the `/provider <name>` slash command, and by other runtime provider-resolution
61/// paths (`Agent::resolve_background_provider`, `Agent::build_supervisor`, autodream, magic
62/// docs) to switch providers at runtime without requiring the full `Config`. Router and
63/// Orchestrator provider kinds are not supported for runtime switching — they require the
64/// full provider pool to be re-initialized.
65///
66/// `secret_registry`, when `Some`, wraps the built provider so every outbound `chat*` call
67/// masks registered secrets from message text (#5437) — pass the live agent's
68/// `self.services.security.secret_registry.as_ref()` from any runtime call site.
69///
70/// # Errors
71///
72/// Returns `BootstrapError::Provider` when the provider kind is unsupported for runtime
73/// switching, a required secret is missing, or the entry is misconfigured.
74pub fn build_provider_for_switch(
75    entry: &ProviderEntry,
76    snapshot: &ProviderConfigSnapshot,
77    secret_registry: Option<&std::sync::Arc<zeph_sanitizer::secret_mask::SecretMaskRegistry>>,
78) -> Result<AnyProvider, BootstrapError> {
79    use zeph_common::secret::Secret;
80    // Reconstruct a minimal Config from the snapshot so we can reuse build_provider_from_entry.
81    // Only fields read by build_provider_from_entry are populated; everything else uses defaults.
82    // Secrets are stored as plain strings in the snapshot because Secret does not implement Clone.
83    let mut config = Config::default();
84    config.secrets.claude_api_key = snapshot.claude_api_key.as_deref().map(Secret::new);
85    config.secrets.openai_api_key = snapshot.openai_api_key.as_deref().map(Secret::new);
86    config.secrets.gemini_api_key = snapshot.gemini_api_key.as_deref().map(Secret::new);
87    config.secrets.compatible_api_keys = snapshot
88        .compatible_api_keys
89        .iter()
90        .map(|(k, v)| (k.clone(), Secret::new(v.as_str())))
91        .collect();
92    config.secrets.gonka_private_key = snapshot
93        .gonka_private_key
94        .as_ref()
95        .map(|z| Secret::new(z.as_str()));
96    config.secrets.gonka_address = snapshot.gonka_address.as_deref().map(Secret::new);
97    config.secrets.cocoon_access_hash = snapshot.cocoon_access_hash.as_deref().map(Secret::new);
98    config.timeouts.llm_request_timeout_secs = snapshot.llm_request_timeout_secs;
99    config
100        .llm
101        .embedding_model
102        .clone_from(&snapshot.embedding_model);
103    build_provider_from_entry(entry, &config, secret_registry)
104}
105
106/// Build an `AnyProvider` from a unified `ProviderEntry` (new `[[llm.providers]]` format).
107///
108/// All provider-specific fields come from `entry`; the global `config` is used only for
109/// secrets and timeout settings.
110///
111/// `secret_registry`, when `Some`, wraps the built provider via [`AnyProvider::masked`] so
112/// every outbound `chat*`/`chat_with_tools*` call masks registered secrets from message text
113/// before the request leaves the process (#5437) — this is the single construction-time choke
114/// point for every `AnyProvider` the bootstrap/runtime layer builds. Bootstrap-time callers
115/// (the `AppBuilder::build_*_provider` family) pass `None` here and rely on
116/// `Agent::with_secret_registry` to retroactively wrap every already-set provider field once
117/// the registry is known; runtime callers that resolve/switch providers on a live `Agent`
118/// (`build_provider_for_switch`) pass the live registry directly since it is already known.
119///
120/// # Errors
121///
122/// Returns `BootstrapError::Provider` when a required secret is missing or an entry is
123/// misconfigured (e.g. compatible provider without a name).
124pub fn build_provider_from_entry(
125    entry: &ProviderEntry,
126    config: &Config,
127    secret_registry: Option<&std::sync::Arc<zeph_sanitizer::secret_mask::SecretMaskRegistry>>,
128) -> Result<AnyProvider, BootstrapError> {
129    let provider = build_provider_from_entry_inner(entry, config)?;
130    Ok(match secret_registry {
131        Some(registry) => provider.masked(std::sync::Arc::clone(registry)
132            as std::sync::Arc<dyn zeph_llm::masking::OutboundMasker>),
133        None => provider,
134    })
135}
136
137fn build_provider_from_entry_inner(
138    entry: &ProviderEntry,
139    config: &Config,
140) -> Result<AnyProvider, BootstrapError> {
141    match entry.provider_type {
142        ProviderKind::Ollama => Ok(build_ollama_provider(entry, config)),
143        ProviderKind::Claude => build_claude_provider(entry, config),
144        ProviderKind::OpenAi => build_openai_provider(entry, config),
145        ProviderKind::Gemini => build_gemini_provider(entry, config),
146        ProviderKind::Compatible => build_compatible_provider(entry, config),
147        #[cfg(feature = "candle")]
148        ProviderKind::Candle => build_candle_provider(entry, config),
149        #[cfg(not(feature = "candle"))]
150        ProviderKind::Candle => Err(BootstrapError::Provider(
151            "candle feature is not enabled".into(),
152        )),
153        #[cfg(feature = "gonka")]
154        ProviderKind::Gonka => build_gonka_provider(entry, config),
155        #[cfg(not(feature = "gonka"))]
156        ProviderKind::Gonka => Err(BootstrapError::Provider(
157            "gonka feature is not enabled; rebuild with --features gonka".into(),
158        )),
159        #[cfg(feature = "cocoon")]
160        ProviderKind::Cocoon => build_cocoon_provider(entry, config),
161        #[cfg(not(feature = "cocoon"))]
162        ProviderKind::Cocoon => Err(BootstrapError::Provider(
163            "cocoon feature is not enabled; rebuild with --features cocoon".into(),
164        )),
165        _ => Err(BootstrapError::Provider(format!(
166            "unknown provider kind: {:?}",
167            entry.provider_type
168        ))),
169    }
170}
171
172/// Resolve a provider by name from `config.llm.providers`, falling back to `primary` when
173/// `name` is empty, not found, or fails to build.
174///
175/// The Agent-free counterpart to `Agent::resolve_background_provider` (`crates/zeph-core/src/
176/// agent/learning/arise.rs`) — that method looks up an *already-built* provider from the live
177/// `Agent`'s `provider_pool` cache; this one builds fresh from `config.llm.providers` via
178/// [`build_provider_from_entry`] for callers that run *before* an `Agent` (and its pool) exists
179/// — e.g. resume-time condensation at CLI/ACP/`zeph serve` construction sites (spec-068
180/// architect ruling D-13, spec §8.1). Fresh-build cost is a non-issue here: this only runs on
181/// session resume, not the hot per-turn path.
182///
183/// Runs before any `Agent`/`SecretMaskRegistry` exists, so the built provider is never
184/// masked (#5437 residual gap — session-resume condensation is not on the hot per-turn path;
185/// tracked as a known limitation rather than blocking this construction-time choke point).
186#[must_use]
187pub fn resolve_named_provider(config: &Config, primary: &AnyProvider, name: &str) -> AnyProvider {
188    if name.is_empty() {
189        return primary.clone();
190    }
191    let Some(entry) = config
192        .llm
193        .providers
194        .iter()
195        .find(|e| e.effective_name().eq_ignore_ascii_case(name))
196    else {
197        tracing::warn!(
198            provider = name,
199            "provider not found in [[llm.providers]], falling back to primary"
200        );
201        return primary.clone();
202    };
203    match build_provider_from_entry(entry, config, None) {
204        Ok(provider) => provider,
205        Err(e) => {
206            tracing::warn!(error = %e, provider = name, "failed to build named provider, falling back to primary");
207            primary.clone()
208        }
209    }
210}
211
212/// Build the [`zeph_session::LlmCondenser`] + token counter D-13's Agent-free resume-time
213/// condensation needs, shared by every construction-time session-open path (CLI `sessions
214/// resume`, ACP `spawn_acp_agent`, `zeph serve`'s `hydrate_session_sink`) so they cannot drift
215/// from each other on `[session.condense]` field mapping — the same divergence risk D-10 named
216/// for the hydration pipeline itself, applied here to condenser construction.
217///
218/// Returns the condenser plus a standalone `Arc<TokenCounterAdapter>` for
219/// [`zeph_agent_persistence::resume_budget_fraction`]'s own token-counting need (distinct from
220/// the counter embedded in the condenser's `SummarizationDeps`, which the LLM-summarization path
221/// uses) — cheap to construct twice: [`zeph_memory::TokenCounter::new`] is backed by a
222/// process-scoped `OnceLock`, so only the first call anywhere in the process pays for loading
223/// the BPE tokenizer.
224#[must_use]
225pub fn build_resume_condenser(
226    config: &Config,
227    primary_provider: &AnyProvider,
228) -> (
229    zeph_session::LlmCondenser,
230    std::sync::Arc<zeph_agent_context::memory_backend::TokenCounterAdapter>,
231) {
232    let condense_config = &config.session.condense;
233    let condense_provider = resolve_named_provider(
234        config,
235        primary_provider,
236        condense_config.condense_provider.as_str(),
237    );
238    let token_counter_adapter = std::sync::Arc::new(
239        zeph_agent_context::memory_backend::TokenCounterAdapter::new(std::sync::Arc::new(
240            zeph_memory::TokenCounter::new(),
241        )),
242    );
243    let condenser = zeph_session::LlmCondenser::new(
244        zeph_context::summarization::SummarizationDeps {
245            provider: condense_provider,
246            llm_timeout: std::time::Duration::from_secs(config.timeouts.llm_seconds),
247            token_counter: std::sync::Arc::new(
248                zeph_agent_context::memory_backend::TokenCounterAdapter::new(std::sync::Arc::new(
249                    zeph_memory::TokenCounter::new(),
250                )),
251            ),
252            structured_summaries: config.memory.structured_summaries,
253            on_anchored_summary: None,
254        },
255        condense_config.threshold,
256        condense_config.keep_recent,
257    );
258    (condenser, token_counter_adapter)
259}
260
261fn build_ollama_provider(entry: &ProviderEntry, config: &Config) -> AnyProvider {
262    let base_url = entry
263        .base_url
264        .as_deref()
265        .unwrap_or("http://localhost:11434");
266    let model = entry.model.as_deref().unwrap_or("qwen3:8b").to_owned();
267    let embed = entry
268        .embedding_model
269        .clone()
270        .unwrap_or_else(|| config.llm.embedding_model.clone());
271    let mut provider =
272        OllamaProvider::new(base_url, model, embed).with_provider_name(entry.effective_name());
273    if let Some(ref vm) = entry.vision_model {
274        provider = provider.with_vision_model(vm.clone());
275    }
276    if config.mcp.forward_output_schema {
277        tracing::debug!(
278            "mcp.forward_output_schema is enabled but Ollama does not support \
279             output schema forwarding; setting ignored for this provider"
280        );
281    }
282    AnyProvider::Ollama(provider)
283}
284
285fn build_claude_provider(
286    entry: &ProviderEntry,
287    config: &Config,
288) -> Result<AnyProvider, BootstrapError> {
289    let api_key = config
290        .secrets
291        .claude_api_key
292        .as_ref()
293        .ok_or_else(|| BootstrapError::Provider("ZEPH_CLAUDE_API_KEY not found in vault".into()))?
294        .expose()
295        .to_owned();
296    let model = entry
297        .model
298        .clone()
299        .unwrap_or_else(|| "claude-haiku-4-5-20251001".to_owned());
300    let max_tokens = entry.max_tokens.unwrap_or(4096);
301    let provider = ClaudeProvider::new(api_key, model, max_tokens)
302        .with_provider_name(entry.effective_name())
303        .with_client(llm_client(config.timeouts.llm_request_timeout_secs))
304        .with_extended_context(entry.enable_extended_context)
305        .with_thinking_opt(entry.thinking.clone())
306        .map_err(|e| BootstrapError::Provider(format!("invalid thinking config: {e}")))?
307        .with_server_compaction(entry.server_compaction)
308        .with_prompt_cache_ttl(entry.prompt_cache_ttl)
309        .with_stream_limits(config.llm.stream_limits.clone())
310        .with_output_schema_forwarding(
311            config.mcp.forward_output_schema,
312            config.mcp.output_schema_hint_bytes,
313            config.mcp.max_description_bytes,
314        );
315    tracing::info!(
316        forward = config.mcp.forward_output_schema,
317        "mcp.output_schema.forwarding_configured"
318    );
319    Ok(AnyProvider::Claude(provider))
320}
321
322fn build_openai_provider(
323    entry: &ProviderEntry,
324    config: &Config,
325) -> Result<AnyProvider, BootstrapError> {
326    let api_key = config
327        .secrets
328        .openai_api_key
329        .as_ref()
330        .ok_or_else(|| BootstrapError::Provider("ZEPH_OPENAI_API_KEY not found in vault".into()))?
331        .expose()
332        .to_owned();
333    let base_url = entry
334        .base_url
335        .clone()
336        .unwrap_or_else(|| "https://api.openai.com/v1".to_owned());
337    let model = entry
338        .model
339        .clone()
340        .unwrap_or_else(|| "gpt-4o-mini".to_owned());
341    let max_tokens = entry.max_tokens.unwrap_or(4096);
342    Ok(AnyProvider::OpenAi(
343        OpenAiProvider::new(zeph_llm::OpenAiConfig {
344            api_key,
345            base_url,
346            model,
347            max_tokens,
348            embedding_model: entry.embedding_model.clone(),
349            reasoning_effort: entry.reasoning_effort.clone(),
350            context_window: None,
351            completion_tokens_param: None,
352        })
353        .with_provider_name(entry.effective_name())
354        .with_client(llm_client(config.timeouts.llm_request_timeout_secs))
355        .with_output_schema_forwarding(
356            config.mcp.forward_output_schema,
357            config.mcp.output_schema_hint_bytes,
358            config.mcp.max_description_bytes,
359        ),
360    ))
361}
362
363fn build_gemini_provider(
364    entry: &ProviderEntry,
365    config: &Config,
366) -> Result<AnyProvider, BootstrapError> {
367    let api_key = config
368        .secrets
369        .gemini_api_key
370        .as_ref()
371        .ok_or_else(|| BootstrapError::Provider("ZEPH_GEMINI_API_KEY not found in vault".into()))?
372        .expose()
373        .to_owned();
374    let model = entry
375        .model
376        .clone()
377        .unwrap_or_else(|| "gemini-2.0-flash".to_owned());
378    let max_tokens = entry.max_tokens.unwrap_or(8192);
379    let base_url = entry
380        .base_url
381        .clone()
382        .unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_owned());
383    let mut provider = GeminiProvider::new(api_key, model, max_tokens)
384        .with_provider_name(entry.effective_name())
385        .with_base_url(base_url)
386        .with_client(llm_client(config.timeouts.llm_request_timeout_secs));
387    if let Some(ref em) = entry.embedding_model {
388        provider = provider.with_embedding_model(em.clone());
389    }
390    if let Some(level) = entry.thinking_level {
391        provider = provider.with_thinking_level(level);
392    }
393    if let Some(budget) = entry.thinking_budget {
394        provider = provider
395            .with_thinking_budget(budget)
396            .map_err(|e| BootstrapError::Provider(e.to_string()))?;
397    }
398    if let Some(include) = entry.include_thoughts {
399        provider = provider.with_include_thoughts(include);
400    }
401    if config.mcp.forward_output_schema {
402        tracing::debug!(
403            "mcp.forward_output_schema is enabled but Gemini does not support \
404             output schema forwarding; setting ignored for this provider"
405        );
406    }
407    Ok(AnyProvider::Gemini(provider))
408}
409
410fn build_compatible_provider(
411    entry: &ProviderEntry,
412    config: &Config,
413) -> Result<AnyProvider, BootstrapError> {
414    let name = entry.name.as_deref().ok_or_else(|| {
415        BootstrapError::Provider(
416            "compatible provider requires 'name' field in [[llm.providers]]".into(),
417        )
418    })?;
419    let base_url = entry.base_url.clone().ok_or_else(|| {
420        BootstrapError::Provider(format!("compatible provider '{name}' requires 'base_url'"))
421    })?;
422    let model = entry.model.clone().unwrap_or_default();
423    let api_key = entry.api_key.clone().unwrap_or_else(|| {
424        config
425            .secrets
426            .compatible_api_keys
427            .get(name)
428            .map(|s| s.expose().to_owned())
429            .unwrap_or_default()
430    });
431    let max_tokens = entry.max_tokens.unwrap_or(4096);
432    let provider = CompatibleProvider::new(zeph_llm::CompatibleConfig {
433        provider_name: name.to_owned(),
434        api_key,
435        base_url,
436        model,
437        max_tokens,
438        embedding_model: entry.embedding_model.clone(),
439        completion_tokens_param: None,
440    })
441    .with_output_schema_forwarding(
442        config.mcp.forward_output_schema,
443        config.mcp.output_schema_hint_bytes,
444        config.mcp.max_description_bytes,
445    );
446    tracing::info!(
447        forward = config.mcp.forward_output_schema,
448        provider = name,
449        "mcp.output_schema.forwarding_configured"
450    );
451    Ok(AnyProvider::Compatible(provider))
452}
453
454#[cfg(feature = "gonka")]
455fn build_gonka_provider(
456    entry: &ProviderEntry,
457    config: &Config,
458) -> Result<AnyProvider, BootstrapError> {
459    let _span = tracing::info_span!("core.provider_factory.build_gonka").entered();
460
461    let private_key_hex: Zeroizing<String> = Zeroizing::new(
462        config
463            .secrets
464            .gonka_private_key
465            .as_ref()
466            .ok_or_else(|| {
467                BootstrapError::Provider(
468                    "ZEPH_GONKA_PRIVATE_KEY not found in vault; set it with: zeph vault set ZEPH_GONKA_PRIVATE_KEY <hex>".into(),
469                )
470            })?
471            .expose()
472            .to_owned(),
473    );
474
475    let chain_prefix = entry.effective_gonka_chain_prefix().to_owned();
476    let signer = RequestSigner::from_hex(&private_key_hex, &chain_prefix)
477        .map_err(|e| BootstrapError::Provider(format!("invalid Gonka private key: {e}")))?;
478
479    if let Some(ref configured_address) = config.secrets.gonka_address {
480        let configured = configured_address.expose().to_lowercase();
481        let derived = signer.address().to_lowercase();
482        if configured != derived {
483            return Err(BootstrapError::Provider(format!(
484                "ZEPH_GONKA_ADDRESS does not match address derived from private key \
485                 (configured: {configured}, derived: {derived})"
486            )));
487        }
488    } else {
489        tracing::info!(
490            address = signer.address(),
491            "Gonka: using address derived from private key (ZEPH_GONKA_ADDRESS not set)"
492        );
493    }
494
495    if entry.gonka_nodes.is_empty() {
496        return Err(BootstrapError::Provider(
497            "Gonka provider entry must have at least one node in gonka_nodes".into(),
498        ));
499    }
500
501    let endpoints: Vec<GonkaEndpoint> = entry
502        .gonka_nodes
503        .iter()
504        .map(|n| GonkaEndpoint {
505            base_url: n.url.clone(),
506            address: n.address.clone(),
507        })
508        .collect();
509
510    let pool = EndpointPool::new(endpoints).map_err(|e| {
511        BootstrapError::Provider(format!("failed to build Gonka endpoint pool: {e}"))
512    })?;
513
514    let model = entry.model.clone().unwrap_or_else(|| "gpt-4o".to_owned());
515    let max_tokens = entry.max_tokens.unwrap_or(4096);
516    let timeout = std::time::Duration::from_secs(config.timeouts.llm_request_timeout_secs);
517
518    let provider = GonkaProvider::new(zeph_llm::gonka::GonkaConfig {
519        signer: std::sync::Arc::new(signer),
520        pool: std::sync::Arc::new(pool),
521        model,
522        max_tokens,
523        embedding_model: entry.embedding_model.clone(),
524        timeout,
525    })
526    .with_provider_name(entry.effective_name());
527
528    Ok(AnyProvider::Gonka(provider))
529}
530
531/// Resolved connection parameters for a `CocoonClient`, shared by the provider-build and
532/// health-check call sites so both derive `access_hash`/`base_url`/`timeout` with identical
533/// gating logic.
534#[cfg(feature = "cocoon")]
535struct CocoonClientParams {
536    base_url: String,
537    access_hash: Option<String>,
538    timeout: std::time::Duration,
539}
540
541/// Resolve [`CocoonClientParams`] from a `[[llm.providers]]` entry.
542///
543/// Resolves the access hash from the age vault when `cocoon_access_hash` is `Some(_)` in the
544/// entry. If the vault key is absent an explicit, actionable error is returned. Also validates
545/// `base_url` against a localhost allowlist and warns otherwise, and warns if the config file
546/// appears to contain a raw hash value instead of an opt-in marker.
547///
548/// # Errors
549///
550/// Returns [`BootstrapError::Provider`] when the vault key `ZEPH_COCOON_ACCESS_HASH` is
551/// expected (field is `Some`) but not present in the resolved secrets.
552#[cfg(feature = "cocoon")]
553fn resolve_cocoon_client_params(
554    entry: &ProviderEntry,
555    config: &Config,
556) -> Result<CocoonClientParams, BootstrapError> {
557    let base_url = entry
558        .cocoon_client_url
559        .as_deref()
560        .unwrap_or("http://localhost:10000");
561
562    // Validate URL at construction time (MINOR-3): warn if not localhost.
563    if !base_url.starts_with("http://localhost")
564        && !base_url.starts_with("http://127.0.0.1")
565        && !base_url.starts_with("http://[::1]")
566        && !base_url.starts_with("https://localhost")
567        && !base_url.starts_with("https://127.0.0.1")
568        && !base_url.starts_with("https://[::1]")
569    {
570        tracing::warn!(
571            url = base_url,
572            "cocoon_client_url points to a non-localhost host; \
573             ensure this is intentional (expected sidecar on localhost)"
574        );
575    }
576
577    if entry
578        .cocoon_access_hash
579        .as_deref()
580        .is_some_and(|v| !v.is_empty())
581    {
582        tracing::warn!(
583            "cocoon_access_hash in config file appears to contain a raw value; \
584             this field should be empty — the actual hash must be stored in the vault: \
585             zeph vault set ZEPH_COCOON_ACCESS_HASH <hash>"
586        );
587    }
588
589    let access_hash = if entry.cocoon_access_hash.is_some() {
590        let hash = config
591            .secrets
592            .cocoon_access_hash
593            .as_ref()
594            .ok_or_else(|| {
595                BootstrapError::Provider(
596                    "ZEPH_COCOON_ACCESS_HASH not found in vault; set it with: \
597                     zeph vault set ZEPH_COCOON_ACCESS_HASH <hash>"
598                        .into(),
599                )
600            })?
601            .expose()
602            .to_owned();
603        Some(hash)
604    } else {
605        None
606    };
607
608    let timeout = std::time::Duration::from_secs(config.timeouts.llm_request_timeout_secs);
609
610    Ok(CocoonClientParams {
611        base_url: base_url.to_owned(),
612        access_hash,
613        timeout,
614    })
615}
616
617/// Build a [`CocoonProvider`] from a `[[llm.providers]]` entry.
618///
619/// # Errors
620///
621/// Returns [`BootstrapError::Provider`] when the vault key `ZEPH_COCOON_ACCESS_HASH` is
622/// expected (field is `Some`) but not present in the resolved secrets.
623#[cfg(feature = "cocoon")]
624fn build_cocoon_provider(
625    entry: &ProviderEntry,
626    config: &Config,
627) -> Result<AnyProvider, BootstrapError> {
628    let _span = tracing::info_span!("core.provider_factory.build_cocoon").entered();
629
630    let params = resolve_cocoon_client_params(entry, config)?;
631    let client = std::sync::Arc::new(CocoonClient::new(
632        &params.base_url,
633        params.access_hash,
634        params.timeout,
635    ));
636
637    let model = entry
638        .model
639        .clone()
640        .unwrap_or_else(|| "Qwen/Qwen3-0.6B".to_owned());
641    let max_tokens = entry.max_tokens.unwrap_or(4096);
642    let provider = CocoonProvider::new(model, max_tokens, entry.embedding_model.clone(), client)
643        .with_provider_name(entry.effective_name());
644
645    Ok(AnyProvider::Cocoon(provider))
646}
647
648/// Spawn an advisory health-check for all Cocoon providers that have `cocoon_health_check = true`.
649///
650/// Registers each check as a one-shot supervised task so it is observable via
651/// [`TaskSupervisor::snapshot`] and abortable on shutdown. Failures are logged at `warn` level
652/// and never propagated — the check is purely advisory. Gating logic for `access_hash`/`base_url`
653/// is shared with [`build_cocoon_provider`] via [`resolve_cocoon_client_params`]; unlike the
654/// provider-build path, a resolution error here only skips that entry's health check (logged at
655/// `warn`) rather than failing bootstrap, since this path is advisory and runs after providers
656/// have already been built successfully.
657///
658/// Call this once after [`build_provider_from_entry`] has succeeded for all providers, passing
659/// the session-level supervisor. The function is a no-op when `cocoon` feature is not enabled
660/// or no provider has `cocoon_health_check = true`.
661#[cfg(feature = "cocoon")]
662pub fn spawn_cocoon_health_checks(
663    providers: &[&ProviderEntry],
664    config: &Config,
665    supervisor: &std::sync::Arc<zeph_common::TaskSupervisor>,
666) {
667    for entry in providers {
668        if entry.provider_type != ProviderKind::Cocoon || !entry.cocoon_health_check {
669            continue;
670        }
671        let params = match resolve_cocoon_client_params(entry, config) {
672            Ok(params) => params,
673            Err(e) => {
674                tracing::warn!(
675                    name = entry.name.as_deref().unwrap_or("<unnamed>"),
676                    error = %e,
677                    "skipping cocoon health check: failed to resolve client params"
678                );
679                continue;
680            }
681        };
682        let client = std::sync::Arc::new(CocoonClient::new(
683            &params.base_url,
684            params.access_hash,
685            params.timeout,
686        ));
687        supervisor.spawn(zeph_common::task_supervisor::TaskDescriptor {
688            name: "core.provider_factory.cocoon_health_check",
689            restart: zeph_common::task_supervisor::RestartPolicy::RunOnce,
690            factory: move || {
691                let client = client.clone();
692                async move {
693                match client.health_check().await {
694                    Ok(h) => {
695                        tracing::info!(
696                            proxy_connected = h.proxy_connected,
697                            worker_count = h.worker_count,
698                            "cocoon sidecar health check passed"
699                        );
700                    }
701                    Err(e) => {
702                        tracing::warn!(
703                            error = %e,
704                            "cocoon sidecar health check failed; \
705                             inference requests will return LlmError::Unavailable until the sidecar is running"
706                        );
707                    }
708                }
709                }
710            },
711        });
712    }
713}
714
715/// Pure data resolved from a `[[llm.providers]]` Candle entry, prior to the fallible device
716/// selection and model-loading steps in [`build_candle_provider`].
717///
718/// Split out so the config → loader-args mapping (including SHA-256 threading) is unit-testable
719/// without touching the network-calling `CandleProvider::new_with_timeout`.
720#[cfg(feature = "candle")]
721struct CandleLoadParams {
722    source: zeph_llm::candle_provider::loader::ModelSource,
723    template: zeph_llm::candle_provider::template::ChatTemplate,
724    gen_config: zeph_llm::candle_provider::generate::GenerationConfig,
725    embedding_repo: Option<String>,
726    embedding_sha256: Option<String>,
727    hf_token: Option<String>,
728    inference_timeout: std::time::Duration,
729}
730
731#[cfg(feature = "candle")]
732fn resolve_candle_load_params(
733    entry: &ProviderEntry,
734    candle: &CandleInlineConfig,
735    config: &Config,
736) -> CandleLoadParams {
737    let source = match candle.source {
738        CandleSource::Local => zeph_llm::candle_provider::loader::ModelSource::Local {
739            path: std::path::PathBuf::from(&candle.local_path),
740        },
741        CandleSource::Huggingface => zeph_llm::candle_provider::loader::ModelSource::HuggingFace {
742            repo_id: entry
743                .model
744                .clone()
745                .unwrap_or_else(|| config.llm.effective_model().to_owned()),
746            filename: candle.filename.clone(),
747            sha256: candle.chat_model_sha256.clone(),
748        },
749    };
750    let template =
751        zeph_llm::candle_provider::template::ChatTemplate::parse_str(&candle.chat_template);
752    let gen_config = zeph_llm::candle_provider::generate::GenerationConfig {
753        temperature: candle.generation.temperature,
754        top_p: candle.generation.top_p,
755        top_k: candle.generation.top_k,
756        max_tokens: candle.generation.capped_max_tokens(),
757        seed: candle.generation.seed,
758        repeat_penalty: candle.generation.repeat_penalty,
759        repeat_last_n: candle.generation.repeat_last_n,
760    };
761    // Floor at 1s so that inference_timeout_secs = 0 does not cause every request to
762    // immediately time out.
763    let inference_timeout = std::time::Duration::from_secs(candle.inference_timeout_secs.max(1));
764    CandleLoadParams {
765        source,
766        template,
767        gen_config,
768        embedding_repo: candle.embedding_repo.clone(),
769        embedding_sha256: candle.embedding_model_sha256.clone(),
770        hf_token: candle.hf_token.clone(),
771        inference_timeout,
772    }
773}
774
775#[cfg(feature = "candle")]
776fn build_candle_provider(
777    entry: &ProviderEntry,
778    config: &Config,
779) -> Result<AnyProvider, BootstrapError> {
780    let candle = entry.candle.as_ref().ok_or_else(|| {
781        BootstrapError::Provider(
782            "candle provider requires 'candle' section in [[llm.providers]]".into(),
783        )
784    })?;
785    let params = resolve_candle_load_params(entry, candle, config);
786    let device = select_device(candle.device)?;
787    zeph_llm::candle_provider::CandleProvider::new_with_timeout(
788        &params.source,
789        params.template,
790        params.gen_config,
791        params.embedding_repo.as_deref(),
792        params.embedding_sha256.as_deref(),
793        params.hf_token.as_deref(),
794        device,
795        params.inference_timeout,
796    )
797    .map(|provider| AnyProvider::Candle(provider.with_provider_name(entry.effective_name())))
798    .map_err(|e| BootstrapError::Provider(e.to_string()))
799}
800
801/// Select the candle compute device from a [`CandleDevice`] config value.
802///
803/// # Errors
804///
805/// Returns `BootstrapError::Provider` when the requested device is not available (e.g.
806/// `CandleDevice::Metal` requested but compiled without the `metal` feature).
807#[cfg(feature = "candle")]
808pub fn select_device(
809    preference: CandleDevice,
810) -> Result<zeph_llm::candle_provider::Device, BootstrapError> {
811    match preference {
812        CandleDevice::Metal => {
813            #[cfg(feature = "metal")]
814            return zeph_llm::candle_provider::Device::new_metal(0)
815                .map_err(|e| BootstrapError::Provider(e.to_string()));
816            #[cfg(not(feature = "metal"))]
817            return Err(BootstrapError::Provider(
818                "candle compiled without metal feature".into(),
819            ));
820        }
821        CandleDevice::Cuda => {
822            #[cfg(feature = "cuda")]
823            return zeph_llm::candle_provider::Device::new_cuda(0)
824                .map_err(|e| BootstrapError::Provider(e.to_string()));
825            #[cfg(not(feature = "cuda"))]
826            return Err(BootstrapError::Provider(
827                "candle compiled without cuda feature".into(),
828            ));
829        }
830        CandleDevice::Cpu => Ok(zeph_llm::candle_provider::Device::Cpu),
831        CandleDevice::Auto => {
832            #[cfg(feature = "metal")]
833            if let Ok(device) = zeph_llm::candle_provider::Device::new_metal(0) {
834                return Ok(device);
835            }
836            #[cfg(feature = "cuda")]
837            if let Ok(device) = zeph_llm::candle_provider::Device::new_cuda(0) {
838                return Ok(device);
839            }
840            Ok(zeph_llm::candle_provider::Device::Cpu)
841        }
842    }
843}
844
845#[cfg(test)]
846mod tests {
847    #[cfg(feature = "candle")]
848    use super::select_device;
849    #[cfg(feature = "candle")]
850    use crate::config::CandleDevice;
851    #[cfg(feature = "candle")]
852    use std::assert_matches;
853
854    #[cfg(feature = "candle")]
855    #[test]
856    fn select_device_cpu_default() {
857        let device = select_device(CandleDevice::Cpu).unwrap();
858        assert_matches!(device, zeph_llm::candle_provider::Device::Cpu);
859    }
860
861    #[cfg(all(feature = "candle", not(feature = "metal")))]
862    #[test]
863    fn select_device_metal_without_feature_errors() {
864        let result = select_device(CandleDevice::Metal);
865        assert!(result.is_err());
866        assert!(result.unwrap_err().to_string().contains("metal feature"));
867    }
868
869    #[cfg(all(feature = "candle", not(feature = "cuda")))]
870    #[test]
871    fn select_device_cuda_without_feature_errors() {
872        let result = select_device(CandleDevice::Cuda);
873        assert!(result.is_err());
874        assert!(result.unwrap_err().to_string().contains("cuda feature"));
875    }
876
877    // --- sha256 config threading (issues #5692/#5690 follow-up: guards against a future
878    // field-drop regression in `resolve_candle_load_params`) ---
879
880    #[cfg(feature = "candle")]
881    #[test]
882    fn resolve_candle_load_params_threads_chat_and_embedding_sha256() {
883        use super::resolve_candle_load_params;
884        use crate::config::{CandleInlineConfig, Config};
885        use zeph_config::providers::ProviderEntry;
886
887        let candle = CandleInlineConfig {
888            chat_model_sha256: Some("deadbeef".into()),
889            embedding_repo: Some("org/embed-model".into()),
890            embedding_model_sha256: Some("cafef00d".into()),
891            ..CandleInlineConfig::default()
892        };
893        let entry = ProviderEntry {
894            model: Some("org/chat-model".into()),
895            candle: Some(candle.clone()),
896            ..ProviderEntry::default()
897        };
898        let config = Config::default();
899
900        let params = resolve_candle_load_params(&entry, &candle, &config);
901
902        if let zeph_llm::candle_provider::loader::ModelSource::HuggingFace { sha256, .. } =
903            params.source
904        {
905            assert_eq!(sha256.as_deref(), Some("deadbeef"));
906        } else {
907            panic!("expected HuggingFace source for CandleSource::default()")
908        }
909        assert_eq!(params.embedding_sha256.as_deref(), Some("cafef00d"));
910    }
911
912    #[cfg(feature = "candle")]
913    #[test]
914    fn resolve_candle_load_params_sha256_absent_by_default() {
915        use super::resolve_candle_load_params;
916        use crate::config::{CandleInlineConfig, Config};
917        use zeph_config::providers::ProviderEntry;
918
919        let candle = CandleInlineConfig::default();
920        let entry = ProviderEntry {
921            model: Some("org/chat-model".into()),
922            candle: Some(candle.clone()),
923            ..ProviderEntry::default()
924        };
925        let config = Config::default();
926
927        let params = resolve_candle_load_params(&entry, &candle, &config);
928
929        if let zeph_llm::candle_provider::loader::ModelSource::HuggingFace { sha256, .. } =
930            params.source
931        {
932            assert!(sha256.is_none());
933        } else {
934            panic!("expected HuggingFace source for CandleSource::default()")
935        }
936        assert!(params.embedding_sha256.is_none());
937    }
938
939    #[cfg(feature = "cocoon")]
940    use super::spawn_cocoon_health_checks;
941    use super::{build_provider_from_entry, resolve_named_provider};
942    use crate::config::{Config, ProviderKind};
943    use zeph_config::providers::ProviderEntry;
944    use zeph_llm::LlmProvider;
945
946    #[cfg(feature = "gonka")]
947    mod gonka_tests {
948        use super::*;
949        use zeph_common::secret::Secret;
950        use zeph_config::GonkaNode;
951        use zeph_llm::LlmProvider;
952
953        fn gonka_entry_with_nodes(nodes: Vec<GonkaNode>) -> ProviderEntry {
954            ProviderEntry {
955                provider_type: ProviderKind::Gonka,
956                name: Some("gonka".into()),
957                model: Some("gpt-4o".into()),
958                gonka_nodes: nodes,
959                ..ProviderEntry::default()
960            }
961        }
962
963        fn valid_nodes() -> Vec<GonkaNode> {
964            vec![GonkaNode {
965                url: "https://node1.gonka.ai".into(),
966                address: "gonka1w508d6qejxtdg4y5r3zarvary0c5xw7k2gsyg6".into(),
967                name: Some("node1".into()),
968            }]
969        }
970
971        const VALID_PRIV_KEY: &str =
972            "0000000000000000000000000000000000000000000000000000000000000001";
973
974        #[test]
975        fn build_gonka_provider_missing_key_returns_error() {
976            let entry = gonka_entry_with_nodes(valid_nodes());
977            let config = Config::default();
978            let result = build_provider_from_entry(&entry, &config, None);
979            assert!(result.is_err());
980            let msg = result.unwrap_err().to_string();
981            assert!(
982                msg.contains("ZEPH_GONKA_PRIVATE_KEY"),
983                "error must mention missing key: {msg}"
984            );
985        }
986
987        #[test]
988        fn build_gonka_provider_empty_nodes_returns_error() {
989            let entry = gonka_entry_with_nodes(vec![]);
990            let mut config = Config::default();
991            config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
992            let result = build_provider_from_entry(&entry, &config, None);
993            assert!(result.is_err());
994            let msg = result.unwrap_err().to_string();
995            assert!(
996                msg.contains("gonka_nodes") || msg.contains("node"),
997                "error must mention empty nodes: {msg}"
998            );
999        }
1000
1001        #[test]
1002        fn build_gonka_provider_address_mismatch_returns_error() {
1003            let entry = gonka_entry_with_nodes(valid_nodes());
1004            let mut config = Config::default();
1005            config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
1006            config.secrets.gonka_address =
1007                Some(Secret::new("gonka1wrongaddress000000000000000000000000000"));
1008            let result = build_provider_from_entry(&entry, &config, None);
1009            assert!(result.is_err());
1010            let msg = result.unwrap_err().to_string();
1011            assert!(
1012                msg.contains("does not match"),
1013                "error must mention address mismatch: {msg}"
1014            );
1015        }
1016
1017        #[test]
1018        fn build_gonka_provider_happy_path() {
1019            let entry = gonka_entry_with_nodes(valid_nodes());
1020            let mut config = Config::default();
1021            config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
1022            let result = build_provider_from_entry(&entry, &config, None);
1023            assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
1024            let provider = result.unwrap();
1025            assert_eq!(provider.name(), "gonka");
1026        }
1027
1028        /// Regression for #5892: `GonkaProvider::name()` was hardcoded to `"gonka"`; two
1029        /// distinct `[[llm.providers]]` entries with distinct configured `name` fields must
1030        /// build `AnyProvider`s with distinct `.name()` values.
1031        #[test]
1032        fn build_gonka_provider_distinct_entries_yield_distinct_provider_names() {
1033            let mut entry_a = gonka_entry_with_nodes(valid_nodes());
1034            entry_a.name = Some("gonka-a".into());
1035            let mut entry_b = gonka_entry_with_nodes(valid_nodes());
1036            entry_b.name = Some("gonka-b".into());
1037            let mut config = Config::default();
1038            config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
1039
1040            let provider_a = build_provider_from_entry(&entry_a, &config, None).unwrap();
1041            let provider_b = build_provider_from_entry(&entry_b, &config, None).unwrap();
1042
1043            assert_eq!(provider_a.name(), "gonka-a");
1044            assert_eq!(provider_b.name(), "gonka-b");
1045            assert_ne!(provider_a.name(), provider_b.name());
1046        }
1047    }
1048
1049    fn make_provider_entry(
1050        embed: bool,
1051        model: Option<&str>,
1052        embedding_model: Option<&str>,
1053    ) -> ProviderEntry {
1054        ProviderEntry {
1055            provider_type: ProviderKind::Ollama,
1056            embed,
1057            model: model.map(str::to_owned),
1058            embedding_model: embedding_model.map(str::to_owned),
1059            ..ProviderEntry::default()
1060        }
1061    }
1062
1063    #[test]
1064    fn stable_skill_embedding_model_prefers_embedding_model_field() {
1065        let mut config = Config::default();
1066        config.llm.providers = vec![make_provider_entry(
1067            true,
1068            Some("chat-model"),
1069            Some("embed-v2"),
1070        )];
1071        assert_eq!(config.llm.stable_skill_embedding_model(), "embed-v2");
1072    }
1073
1074    #[test]
1075    fn stable_skill_embedding_model_falls_back_to_model_field() {
1076        let mut config = Config::default();
1077        config.llm.providers = vec![make_provider_entry(
1078            true,
1079            Some("nomic-embed-text-v2-moe:latest"),
1080            None,
1081        )];
1082        assert_eq!(
1083            config.llm.stable_skill_embedding_model(),
1084            "nomic-embed-text-v2-moe:latest"
1085        );
1086    }
1087
1088    #[test]
1089    fn stable_skill_embedding_model_finds_embed_flag_entry() {
1090        let mut config = Config::default();
1091        config.llm.providers = vec![
1092            make_provider_entry(false, Some("chat-model"), None),
1093            make_provider_entry(true, Some("embed-model"), Some("text-embed-3")),
1094        ];
1095        assert_eq!(config.llm.stable_skill_embedding_model(), "text-embed-3");
1096    }
1097
1098    #[test]
1099    fn stable_skill_embedding_model_falls_back_to_effective_when_no_embed_entry() {
1100        let mut config = Config::default();
1101        config.llm.embedding_model = "global-embed-model".to_owned();
1102        // No embed=true entry, no embedding_model field set — falls back to effective_embedding_model.
1103        config.llm.providers = vec![make_provider_entry(false, Some("chat"), None)];
1104        assert_eq!(
1105            config.llm.stable_skill_embedding_model(),
1106            config.llm.effective_embedding_model()
1107        );
1108    }
1109
1110    #[test]
1111    fn resolve_named_provider_empty_name_falls_back_to_primary_silently() {
1112        let config = Config::default();
1113        let entry = make_provider_entry(false, Some("primary-model"), None);
1114        let primary = build_provider_from_entry(&entry, &config, None).unwrap();
1115        let resolved = resolve_named_provider(&config, &primary, "");
1116        assert_eq!(resolved.name(), primary.name());
1117    }
1118
1119    #[test]
1120    fn resolve_named_provider_unmatched_name_falls_back_to_primary_with_warn() {
1121        let config = Config::default(); // no [[llm.providers]] named "fast"
1122        let entry = make_provider_entry(false, Some("primary-model"), None);
1123        let primary = build_provider_from_entry(&entry, &config, None).unwrap();
1124        let resolved = resolve_named_provider(&config, &primary, "fast");
1125        assert_eq!(resolved.name(), primary.name());
1126    }
1127
1128    /// Regression for #5859: two `[[llm.providers]]` entries of `type = "ollama"` with
1129    /// distinct configured `name` fields must build `AnyProvider`s with distinct
1130    /// `.name()` values. Before the fix, `OllamaProvider::name()` returned the hardcoded
1131    /// literal `"ollama"` for every instance regardless of `entry.effective_name()`,
1132    /// corrupting router reputation tracking and embed-provider selection whenever more
1133    /// than one Ollama entry was configured.
1134    #[test]
1135    fn build_provider_from_entry_ollama_distinct_entries_yield_distinct_provider_names() {
1136        let config = Config::default();
1137        let entry_a = ProviderEntry {
1138            provider_type: ProviderKind::Ollama,
1139            name: Some("ollama-chat".into()),
1140            model: Some("qwen3:8b".into()),
1141            ..ProviderEntry::default()
1142        };
1143        let entry_b = ProviderEntry {
1144            provider_type: ProviderKind::Ollama,
1145            name: Some("ollama-embed".into()),
1146            model: Some("nomic-embed-text".into()),
1147            embed: true,
1148            ..ProviderEntry::default()
1149        };
1150
1151        let provider_a = build_provider_from_entry(&entry_a, &config, None).unwrap();
1152        let provider_b = build_provider_from_entry(&entry_b, &config, None).unwrap();
1153
1154        assert_ne!(
1155            provider_a.name(),
1156            provider_b.name(),
1157            "two distinct Ollama [[llm.providers]] entries must yield distinct \
1158             AnyProvider::name() values"
1159        );
1160        assert_eq!(provider_a.name(), "ollama-chat");
1161        assert_eq!(provider_b.name(), "ollama-embed");
1162    }
1163
1164    /// Regression for #5892: Claude, `OpenAI`, and Gemini backends hardcoded `name()` to
1165    /// their type literal, unlike `OllamaProvider`/`CompatibleProvider` (fixed for #5859).
1166    /// Two distinct `[[llm.providers]]` entries of the same `provider_type` with distinct
1167    /// configured `name` fields must build `AnyProvider`s with distinct `.name()` values.
1168    #[test]
1169    fn build_provider_from_entry_claude_openai_gemini_distinct_entries_yield_distinct_provider_names()
1170     {
1171        use zeph_common::secret::Secret;
1172
1173        let mut config = Config::default();
1174        config.secrets.claude_api_key = Some(Secret::new("test-claude-key"));
1175        config.secrets.openai_api_key = Some(Secret::new("test-openai-key"));
1176        config.secrets.gemini_api_key = Some(Secret::new("test-gemini-key"));
1177
1178        let claude_a = ProviderEntry {
1179            provider_type: ProviderKind::Claude,
1180            name: Some("claude-quality".into()),
1181            ..ProviderEntry::default()
1182        };
1183        let claude_b = ProviderEntry {
1184            provider_type: ProviderKind::Claude,
1185            name: Some("claude-fast".into()),
1186            ..ProviderEntry::default()
1187        };
1188        let provider_claude_a = build_provider_from_entry(&claude_a, &config, None).unwrap();
1189        let provider_claude_b = build_provider_from_entry(&claude_b, &config, None).unwrap();
1190        assert_eq!(provider_claude_a.name(), "claude-quality");
1191        assert_eq!(provider_claude_b.name(), "claude-fast");
1192        assert_ne!(provider_claude_a.name(), provider_claude_b.name());
1193
1194        let openai_a = ProviderEntry {
1195            provider_type: ProviderKind::OpenAi,
1196            name: Some("openai-quality".into()),
1197            ..ProviderEntry::default()
1198        };
1199        let openai_b = ProviderEntry {
1200            provider_type: ProviderKind::OpenAi,
1201            name: Some("openai-fast".into()),
1202            ..ProviderEntry::default()
1203        };
1204        let provider_openai_a = build_provider_from_entry(&openai_a, &config, None).unwrap();
1205        let provider_openai_b = build_provider_from_entry(&openai_b, &config, None).unwrap();
1206        assert_eq!(provider_openai_a.name(), "openai-quality");
1207        assert_eq!(provider_openai_b.name(), "openai-fast");
1208        assert_ne!(provider_openai_a.name(), provider_openai_b.name());
1209
1210        let gemini_a = ProviderEntry {
1211            provider_type: ProviderKind::Gemini,
1212            name: Some("gemini-quality".into()),
1213            ..ProviderEntry::default()
1214        };
1215        let gemini_b = ProviderEntry {
1216            provider_type: ProviderKind::Gemini,
1217            name: Some("gemini-fast".into()),
1218            ..ProviderEntry::default()
1219        };
1220        let provider_gemini_a = build_provider_from_entry(&gemini_a, &config, None).unwrap();
1221        let provider_gemini_b = build_provider_from_entry(&gemini_b, &config, None).unwrap();
1222        assert_eq!(provider_gemini_a.name(), "gemini-quality");
1223        assert_eq!(provider_gemini_b.name(), "gemini-fast");
1224        assert_ne!(provider_gemini_a.name(), provider_gemini_b.name());
1225    }
1226
1227    /// Regression for #5892: an unnamed entry (no `name` field in `[[llm.providers]]`) must
1228    /// fall back to the provider-type literal, matching pre-fix behavior for legacy configs.
1229    #[test]
1230    fn build_provider_from_entry_claude_unnamed_falls_back_to_type_literal() {
1231        use zeph_common::secret::Secret;
1232
1233        let mut config = Config::default();
1234        config.secrets.claude_api_key = Some(Secret::new("test-claude-key"));
1235        let entry = ProviderEntry {
1236            provider_type: ProviderKind::Claude,
1237            ..ProviderEntry::default()
1238        };
1239        let provider = build_provider_from_entry(&entry, &config, None).unwrap();
1240        assert_eq!(provider.name(), "claude");
1241    }
1242
1243    #[cfg(feature = "cocoon")]
1244    mod cocoon_tests {
1245        use super::*;
1246
1247        fn cocoon_entry(access_hash: Option<&str>) -> ProviderEntry {
1248            ProviderEntry {
1249                provider_type: ProviderKind::Cocoon,
1250                name: Some("cocoon".into()),
1251                model: Some("Qwen/Qwen3-0.6B".into()),
1252                cocoon_client_url: Some("http://localhost:10000".into()),
1253                cocoon_access_hash: access_hash.map(str::to_owned),
1254                cocoon_health_check: false,
1255                ..ProviderEntry::default()
1256            }
1257        }
1258
1259        /// `cocoon_access_hash = Some("")` sentinel with no vault key must return an error.
1260        #[test]
1261        fn cocoon_access_hash_gate_vault_miss_errors() {
1262            let entry = cocoon_entry(Some(""));
1263            let config = Config::default(); // secrets.cocoon_access_hash = None
1264            let result = build_provider_from_entry(&entry, &config, None);
1265            assert!(
1266                result.is_err(),
1267                "expected error when vault key is absent but sentinel is set"
1268            );
1269            let err_str = result.unwrap_err().to_string();
1270            assert!(
1271                err_str.contains("ZEPH_COCOON_ACCESS_HASH"),
1272                "error should mention the vault key: {err_str}"
1273            );
1274        }
1275
1276        /// `cocoon_access_hash = None` must succeed without touching the vault (health check off).
1277        #[test]
1278        fn cocoon_no_access_hash_gate_succeeds_without_vault() {
1279            let entry = cocoon_entry(None);
1280            let config = Config::default();
1281            let result = build_provider_from_entry(&entry, &config, None);
1282            assert!(
1283                result.is_ok(),
1284                "expected success when no access hash requested: {:?}",
1285                result.err()
1286            );
1287        }
1288
1289        /// Regression for #5892: `CocoonProvider::name()` was hardcoded to `"cocoon"`; two
1290        /// distinct `[[llm.providers]]` entries with distinct configured `name` fields must
1291        /// build `AnyProvider`s with distinct `.name()` values.
1292        #[test]
1293        fn build_provider_from_entry_cocoon_distinct_entries_yield_distinct_provider_names() {
1294            let mut entry_a = cocoon_entry(None);
1295            entry_a.name = Some("cocoon-a".into());
1296            let mut entry_b = cocoon_entry(None);
1297            entry_b.name = Some("cocoon-b".into());
1298            let config = Config::default();
1299
1300            let provider_a = build_provider_from_entry(&entry_a, &config, None).unwrap();
1301            let provider_b = build_provider_from_entry(&entry_b, &config, None).unwrap();
1302
1303            assert_eq!(provider_a.name(), "cocoon-a");
1304            assert_eq!(provider_b.name(), "cocoon-b");
1305            assert_ne!(provider_a.name(), provider_b.name());
1306        }
1307
1308        /// `spawn_cocoon_health_checks` must share the same vault-miss gating as
1309        /// `build_cocoon_provider`: when the entry opts in but the vault key is absent, the
1310        /// health check is skipped (logged) rather than spawned, and bootstrap is not disturbed.
1311        #[test]
1312        fn spawn_cocoon_health_checks_skips_on_vault_miss() {
1313            let mut entry = cocoon_entry(Some(""));
1314            entry.cocoon_health_check = true;
1315            let config = Config::default(); // secrets.cocoon_access_hash = None
1316            let supervisor = std::sync::Arc::new(zeph_common::TaskSupervisor::new(
1317                tokio_util::sync::CancellationToken::new(),
1318            ));
1319
1320            spawn_cocoon_health_checks(&[&entry], &config, &supervisor);
1321
1322            assert!(
1323                supervisor.snapshot().is_empty(),
1324                "expected no health-check task to be spawned when vault key is absent"
1325            );
1326        }
1327    }
1328}