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            vision: entry.vision,
353        })
354        .with_provider_name(entry.effective_name())
355        .with_client(llm_client(config.timeouts.llm_request_timeout_secs))
356        .with_output_schema_forwarding(
357            config.mcp.forward_output_schema,
358            config.mcp.output_schema_hint_bytes,
359            config.mcp.max_description_bytes,
360        ),
361    ))
362}
363
364fn build_gemini_provider(
365    entry: &ProviderEntry,
366    config: &Config,
367) -> Result<AnyProvider, BootstrapError> {
368    let api_key = config
369        .secrets
370        .gemini_api_key
371        .as_ref()
372        .ok_or_else(|| BootstrapError::Provider("ZEPH_GEMINI_API_KEY not found in vault".into()))?
373        .expose()
374        .to_owned();
375    let model = entry
376        .model
377        .clone()
378        .unwrap_or_else(|| "gemini-2.0-flash".to_owned());
379    let max_tokens = entry.max_tokens.unwrap_or(8192);
380    let base_url = entry
381        .base_url
382        .clone()
383        .unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_owned());
384    let mut provider = GeminiProvider::new(api_key, model, max_tokens)
385        .with_provider_name(entry.effective_name())
386        .with_base_url(base_url)
387        .with_client(llm_client(config.timeouts.llm_request_timeout_secs));
388    if let Some(ref em) = entry.embedding_model {
389        provider = provider.with_embedding_model(em.clone());
390    }
391    if let Some(level) = entry.thinking_level {
392        provider = provider.with_thinking_level(level);
393    }
394    if let Some(budget) = entry.thinking_budget {
395        provider = provider
396            .with_thinking_budget(budget)
397            .map_err(|e| BootstrapError::Provider(e.to_string()))?;
398    }
399    if let Some(include) = entry.include_thoughts {
400        provider = provider.with_include_thoughts(include);
401    }
402    if config.mcp.forward_output_schema {
403        tracing::debug!(
404            "mcp.forward_output_schema is enabled but Gemini does not support \
405             output schema forwarding; setting ignored for this provider"
406        );
407    }
408    Ok(AnyProvider::Gemini(provider))
409}
410
411fn build_compatible_provider(
412    entry: &ProviderEntry,
413    config: &Config,
414) -> Result<AnyProvider, BootstrapError> {
415    let name = entry.name.as_deref().ok_or_else(|| {
416        BootstrapError::Provider(
417            "compatible provider requires 'name' field in [[llm.providers]]".into(),
418        )
419    })?;
420    let base_url = entry.base_url.clone().ok_or_else(|| {
421        BootstrapError::Provider(format!("compatible provider '{name}' requires 'base_url'"))
422    })?;
423    let model = entry.model.clone().unwrap_or_default();
424    let api_key = entry.api_key.clone().unwrap_or_else(|| {
425        config
426            .secrets
427            .compatible_api_keys
428            .get(name)
429            .map(|s| s.expose().to_owned())
430            .unwrap_or_default()
431    });
432    let max_tokens = entry.max_tokens.unwrap_or(4096);
433    let provider = CompatibleProvider::new(zeph_llm::CompatibleConfig {
434        provider_name: name.to_owned(),
435        api_key,
436        base_url,
437        model,
438        max_tokens,
439        embedding_model: entry.embedding_model.clone(),
440        completion_tokens_param: None,
441        vision: entry.vision,
442    })
443    .with_output_schema_forwarding(
444        config.mcp.forward_output_schema,
445        config.mcp.output_schema_hint_bytes,
446        config.mcp.max_description_bytes,
447    );
448    tracing::info!(
449        forward = config.mcp.forward_output_schema,
450        provider = name,
451        "mcp.output_schema.forwarding_configured"
452    );
453    Ok(AnyProvider::Compatible(provider))
454}
455
456#[cfg(feature = "gonka")]
457fn build_gonka_provider(
458    entry: &ProviderEntry,
459    config: &Config,
460) -> Result<AnyProvider, BootstrapError> {
461    let _span = tracing::info_span!("core.provider_factory.build_gonka").entered();
462
463    let private_key_hex: Zeroizing<String> = Zeroizing::new(
464        config
465            .secrets
466            .gonka_private_key
467            .as_ref()
468            .ok_or_else(|| {
469                BootstrapError::Provider(
470                    "ZEPH_GONKA_PRIVATE_KEY not found in vault; set it with: zeph vault set ZEPH_GONKA_PRIVATE_KEY <hex>".into(),
471                )
472            })?
473            .expose()
474            .to_owned(),
475    );
476
477    let chain_prefix = entry.effective_gonka_chain_prefix().to_owned();
478    let signer = RequestSigner::from_hex(&private_key_hex, &chain_prefix)
479        .map_err(|e| BootstrapError::Provider(format!("invalid Gonka private key: {e}")))?;
480
481    if let Some(ref configured_address) = config.secrets.gonka_address {
482        let configured = configured_address.expose().to_lowercase();
483        let derived = signer.address().to_lowercase();
484        if configured != derived {
485            return Err(BootstrapError::Provider(format!(
486                "ZEPH_GONKA_ADDRESS does not match address derived from private key \
487                 (configured: {configured}, derived: {derived})"
488            )));
489        }
490    } else {
491        tracing::info!(
492            address = signer.address(),
493            "Gonka: using address derived from private key (ZEPH_GONKA_ADDRESS not set)"
494        );
495    }
496
497    if entry.gonka_nodes.is_empty() {
498        return Err(BootstrapError::Provider(
499            "Gonka provider entry must have at least one node in gonka_nodes".into(),
500        ));
501    }
502
503    let endpoints: Vec<GonkaEndpoint> = entry
504        .gonka_nodes
505        .iter()
506        .map(|n| GonkaEndpoint {
507            base_url: n.url.clone(),
508            address: n.address.clone(),
509        })
510        .collect();
511
512    let pool = EndpointPool::new(endpoints).map_err(|e| {
513        BootstrapError::Provider(format!("failed to build Gonka endpoint pool: {e}"))
514    })?;
515
516    let model = entry.model.clone().unwrap_or_else(|| "gpt-4o".to_owned());
517    let max_tokens = entry.max_tokens.unwrap_or(4096);
518    let timeout = std::time::Duration::from_secs(config.timeouts.llm_request_timeout_secs);
519
520    let provider = GonkaProvider::new(zeph_llm::gonka::GonkaConfig {
521        signer: std::sync::Arc::new(signer),
522        pool: std::sync::Arc::new(pool),
523        model,
524        max_tokens,
525        embedding_model: entry.embedding_model.clone(),
526        timeout,
527    })
528    .with_provider_name(entry.effective_name());
529
530    Ok(AnyProvider::Gonka(provider))
531}
532
533/// Resolved connection parameters for a `CocoonClient`, shared by the provider-build and
534/// health-check call sites so both derive `access_hash`/`base_url`/`timeout` with identical
535/// gating logic.
536#[cfg(feature = "cocoon")]
537struct CocoonClientParams {
538    base_url: String,
539    access_hash: Option<String>,
540    timeout: std::time::Duration,
541}
542
543/// Resolve [`CocoonClientParams`] from a `[[llm.providers]]` entry.
544///
545/// Resolves the access hash from the age vault when `cocoon_access_hash` is `Some(_)` in the
546/// entry. If the vault key is absent an explicit, actionable error is returned. Also validates
547/// `base_url` against a localhost allowlist and warns otherwise, and warns if the config file
548/// appears to contain a raw hash value instead of an opt-in marker.
549///
550/// # Errors
551///
552/// Returns [`BootstrapError::Provider`] when the vault key `ZEPH_COCOON_ACCESS_HASH` is
553/// expected (field is `Some`) but not present in the resolved secrets.
554#[cfg(feature = "cocoon")]
555fn resolve_cocoon_client_params(
556    entry: &ProviderEntry,
557    config: &Config,
558) -> Result<CocoonClientParams, BootstrapError> {
559    let base_url = entry
560        .cocoon_client_url
561        .as_deref()
562        .unwrap_or("http://localhost:10000");
563
564    // Validate URL at construction time (MINOR-3): warn if not localhost.
565    if !base_url.starts_with("http://localhost")
566        && !base_url.starts_with("http://127.0.0.1")
567        && !base_url.starts_with("http://[::1]")
568        && !base_url.starts_with("https://localhost")
569        && !base_url.starts_with("https://127.0.0.1")
570        && !base_url.starts_with("https://[::1]")
571    {
572        tracing::warn!(
573            url = base_url,
574            "cocoon_client_url points to a non-localhost host; \
575             ensure this is intentional (expected sidecar on localhost)"
576        );
577    }
578
579    if entry
580        .cocoon_access_hash
581        .as_deref()
582        .is_some_and(|v| !v.is_empty())
583    {
584        tracing::warn!(
585            "cocoon_access_hash in config file appears to contain a raw value; \
586             this field should be empty — the actual hash must be stored in the vault: \
587             zeph vault set ZEPH_COCOON_ACCESS_HASH <hash>"
588        );
589    }
590
591    let access_hash = if entry.cocoon_access_hash.is_some() {
592        let hash = config
593            .secrets
594            .cocoon_access_hash
595            .as_ref()
596            .ok_or_else(|| {
597                BootstrapError::Provider(
598                    "ZEPH_COCOON_ACCESS_HASH not found in vault; set it with: \
599                     zeph vault set ZEPH_COCOON_ACCESS_HASH <hash>"
600                        .into(),
601                )
602            })?
603            .expose()
604            .to_owned();
605        Some(hash)
606    } else {
607        None
608    };
609
610    let timeout = std::time::Duration::from_secs(config.timeouts.llm_request_timeout_secs);
611
612    Ok(CocoonClientParams {
613        base_url: base_url.to_owned(),
614        access_hash,
615        timeout,
616    })
617}
618
619/// Build a [`CocoonProvider`] from a `[[llm.providers]]` entry.
620///
621/// # Errors
622///
623/// Returns [`BootstrapError::Provider`] when the vault key `ZEPH_COCOON_ACCESS_HASH` is
624/// expected (field is `Some`) but not present in the resolved secrets.
625#[cfg(feature = "cocoon")]
626fn build_cocoon_provider(
627    entry: &ProviderEntry,
628    config: &Config,
629) -> Result<AnyProvider, BootstrapError> {
630    let _span = tracing::info_span!("core.provider_factory.build_cocoon").entered();
631
632    let params = resolve_cocoon_client_params(entry, config)?;
633    let client = std::sync::Arc::new(CocoonClient::new(
634        &params.base_url,
635        params.access_hash,
636        params.timeout,
637    ));
638
639    let model = entry
640        .model
641        .clone()
642        .unwrap_or_else(|| "Qwen/Qwen3-0.6B".to_owned());
643    let max_tokens = entry.max_tokens.unwrap_or(4096);
644    let provider = CocoonProvider::new(model, max_tokens, entry.embedding_model.clone(), client)
645        .with_provider_name(entry.effective_name());
646
647    Ok(AnyProvider::Cocoon(provider))
648}
649
650/// Spawn an advisory health-check for all Cocoon providers that have `cocoon_health_check = true`.
651///
652/// Registers each check as a one-shot supervised task so it is observable via
653/// [`zeph_common::TaskSupervisor::snapshot`] and abortable on shutdown. Failures are logged at
654/// `warn` level and never propagated — the check is purely advisory. Gating logic for
655/// `access_hash`/`base_url` is shared with `build_cocoon_provider` via
656/// `resolve_cocoon_client_params`; unlike the provider-build path, a resolution error here only
657/// skips that entry's health check (logged at `warn`) rather than failing bootstrap, since this
658/// path is advisory and runs after providers have already been built successfully.
659///
660/// Call this once after [`build_provider_from_entry`] has succeeded for all providers, passing
661/// the session-level supervisor. The function is a no-op when `cocoon` feature is not enabled
662/// or no provider has `cocoon_health_check = true`.
663#[cfg(feature = "cocoon")]
664pub fn spawn_cocoon_health_checks(
665    providers: &[&ProviderEntry],
666    config: &Config,
667    supervisor: &std::sync::Arc<zeph_common::TaskSupervisor>,
668) {
669    for entry in providers {
670        if entry.provider_type != ProviderKind::Cocoon || !entry.cocoon_health_check {
671            continue;
672        }
673        let params = match resolve_cocoon_client_params(entry, config) {
674            Ok(params) => params,
675            Err(e) => {
676                tracing::warn!(
677                    name = entry.name.as_deref().unwrap_or("<unnamed>"),
678                    error = %e,
679                    "skipping cocoon health check: failed to resolve client params"
680                );
681                continue;
682            }
683        };
684        let client = std::sync::Arc::new(CocoonClient::new(
685            &params.base_url,
686            params.access_hash,
687            params.timeout,
688        ));
689        supervisor.spawn(zeph_common::task_supervisor::TaskDescriptor {
690            name: "core.provider_factory.cocoon_health_check",
691            restart: zeph_common::task_supervisor::RestartPolicy::RunOnce,
692            factory: move || {
693                let client = client.clone();
694                async move {
695                match client.health_check().await {
696                    Ok(h) => {
697                        tracing::info!(
698                            proxy_connected = h.proxy_connected,
699                            worker_count = h.worker_count,
700                            "cocoon sidecar health check passed"
701                        );
702                    }
703                    Err(e) => {
704                        tracing::warn!(
705                            error = %e,
706                            "cocoon sidecar health check failed; \
707                             inference requests will return LlmError::Unavailable until the sidecar is running"
708                        );
709                    }
710                }
711                }
712            },
713        });
714    }
715}
716
717/// Pure data resolved from a `[[llm.providers]]` Candle entry, prior to the fallible device
718/// selection and model-loading steps in [`build_candle_provider`].
719///
720/// Split out so the config → loader-args mapping (including SHA-256 threading) is unit-testable
721/// without touching the network-calling `CandleProvider::new_with_timeout`.
722#[cfg(feature = "candle")]
723struct CandleLoadParams {
724    source: zeph_llm::candle_provider::loader::ModelSource,
725    template: zeph_llm::candle_provider::template::ChatTemplate,
726    gen_config: zeph_llm::candle_provider::generate::GenerationConfig,
727    embedding_repo: Option<String>,
728    embedding_sha256: Option<String>,
729    hf_token: Option<String>,
730    inference_timeout: std::time::Duration,
731}
732
733#[cfg(feature = "candle")]
734fn resolve_candle_load_params(
735    entry: &ProviderEntry,
736    candle: &CandleInlineConfig,
737    config: &Config,
738) -> CandleLoadParams {
739    let source = match candle.source {
740        CandleSource::Local => zeph_llm::candle_provider::loader::ModelSource::Local {
741            path: std::path::PathBuf::from(&candle.local_path),
742        },
743        CandleSource::Huggingface => zeph_llm::candle_provider::loader::ModelSource::HuggingFace {
744            repo_id: entry
745                .model
746                .clone()
747                .unwrap_or_else(|| config.llm.effective_model().to_owned()),
748            filename: candle.filename.clone(),
749            sha256: candle.chat_model_sha256.clone(),
750        },
751    };
752    let template =
753        zeph_llm::candle_provider::template::ChatTemplate::parse_str(&candle.chat_template);
754    let gen_config = zeph_llm::candle_provider::generate::GenerationConfig {
755        temperature: candle.generation.temperature,
756        top_p: candle.generation.top_p,
757        top_k: candle.generation.top_k,
758        max_tokens: candle.generation.capped_max_tokens(),
759        seed: candle.generation.seed,
760        repeat_penalty: candle.generation.repeat_penalty,
761        repeat_last_n: candle.generation.repeat_last_n,
762    };
763    // Floor at 1s so that inference_timeout_secs = 0 does not cause every request to
764    // immediately time out.
765    let inference_timeout = std::time::Duration::from_secs(candle.inference_timeout_secs.max(1));
766    CandleLoadParams {
767        source,
768        template,
769        gen_config,
770        embedding_repo: candle.embedding_repo.clone(),
771        embedding_sha256: candle.embedding_model_sha256.clone(),
772        hf_token: candle.hf_token.clone(),
773        inference_timeout,
774    }
775}
776
777#[cfg(feature = "candle")]
778fn build_candle_provider(
779    entry: &ProviderEntry,
780    config: &Config,
781) -> Result<AnyProvider, BootstrapError> {
782    let candle = entry.candle.as_ref().ok_or_else(|| {
783        BootstrapError::Provider(
784            "candle provider requires 'candle' section in [[llm.providers]]".into(),
785        )
786    })?;
787    let params = resolve_candle_load_params(entry, candle, config);
788    let device = select_device(candle.device)?;
789    zeph_llm::candle_provider::CandleProvider::new_with_timeout(
790        &params.source,
791        params.template,
792        params.gen_config,
793        params.embedding_repo.as_deref(),
794        params.embedding_sha256.as_deref(),
795        params.hf_token.as_deref(),
796        device,
797        params.inference_timeout,
798    )
799    .map(|provider| AnyProvider::Candle(provider.with_provider_name(entry.effective_name())))
800    .map_err(|e| BootstrapError::Provider(e.to_string()))
801}
802
803/// Select the candle compute device from a [`CandleDevice`] config value.
804///
805/// # Errors
806///
807/// Returns `BootstrapError::Provider` when the requested device is not available (e.g.
808/// `CandleDevice::Metal` requested but compiled without the `metal` feature).
809#[cfg(feature = "candle")]
810pub fn select_device(
811    preference: CandleDevice,
812) -> Result<zeph_llm::candle_provider::Device, BootstrapError> {
813    match preference {
814        CandleDevice::Metal => {
815            #[cfg(feature = "metal")]
816            return zeph_llm::candle_provider::Device::new_metal(0)
817                .map_err(|e| BootstrapError::Provider(e.to_string()));
818            #[cfg(not(feature = "metal"))]
819            return Err(BootstrapError::Provider(
820                "candle compiled without metal feature".into(),
821            ));
822        }
823        CandleDevice::Cuda => {
824            #[cfg(feature = "cuda")]
825            return zeph_llm::candle_provider::Device::new_cuda(0)
826                .map_err(|e| BootstrapError::Provider(e.to_string()));
827            #[cfg(not(feature = "cuda"))]
828            return Err(BootstrapError::Provider(
829                "candle compiled without cuda feature".into(),
830            ));
831        }
832        CandleDevice::Cpu => Ok(zeph_llm::candle_provider::Device::Cpu),
833        CandleDevice::Auto => {
834            #[cfg(feature = "metal")]
835            if let Ok(device) = zeph_llm::candle_provider::Device::new_metal(0) {
836                return Ok(device);
837            }
838            #[cfg(feature = "cuda")]
839            if let Ok(device) = zeph_llm::candle_provider::Device::new_cuda(0) {
840                return Ok(device);
841            }
842            Ok(zeph_llm::candle_provider::Device::Cpu)
843        }
844    }
845}
846
847#[cfg(test)]
848mod tests {
849    #[cfg(feature = "candle")]
850    use super::select_device;
851    #[cfg(feature = "candle")]
852    use crate::config::CandleDevice;
853    #[cfg(feature = "candle")]
854    use std::assert_matches;
855
856    #[cfg(feature = "candle")]
857    #[test]
858    fn select_device_cpu_default() {
859        let device = select_device(CandleDevice::Cpu).unwrap();
860        assert_matches!(device, zeph_llm::candle_provider::Device::Cpu);
861    }
862
863    #[cfg(all(feature = "candle", not(feature = "metal")))]
864    #[test]
865    fn select_device_metal_without_feature_errors() {
866        let result = select_device(CandleDevice::Metal);
867        assert!(result.is_err());
868        assert!(result.unwrap_err().to_string().contains("metal feature"));
869    }
870
871    #[cfg(all(feature = "candle", not(feature = "cuda")))]
872    #[test]
873    fn select_device_cuda_without_feature_errors() {
874        let result = select_device(CandleDevice::Cuda);
875        assert!(result.is_err());
876        assert!(result.unwrap_err().to_string().contains("cuda feature"));
877    }
878
879    // --- sha256 config threading (issues #5692/#5690 follow-up: guards against a future
880    // field-drop regression in `resolve_candle_load_params`) ---
881
882    #[cfg(feature = "candle")]
883    #[test]
884    fn resolve_candle_load_params_threads_chat_and_embedding_sha256() {
885        use super::resolve_candle_load_params;
886        use crate::config::{CandleInlineConfig, Config};
887        use zeph_config::providers::ProviderEntry;
888
889        let candle = CandleInlineConfig {
890            chat_model_sha256: Some("deadbeef".into()),
891            embedding_repo: Some("org/embed-model".into()),
892            embedding_model_sha256: Some("cafef00d".into()),
893            ..CandleInlineConfig::default()
894        };
895        let entry = ProviderEntry {
896            model: Some("org/chat-model".into()),
897            candle: Some(candle.clone()),
898            ..ProviderEntry::default()
899        };
900        let config = Config::default();
901
902        let params = resolve_candle_load_params(&entry, &candle, &config);
903
904        if let zeph_llm::candle_provider::loader::ModelSource::HuggingFace { sha256, .. } =
905            params.source
906        {
907            assert_eq!(sha256.as_deref(), Some("deadbeef"));
908        } else {
909            panic!("expected HuggingFace source for CandleSource::default()")
910        }
911        assert_eq!(params.embedding_sha256.as_deref(), Some("cafef00d"));
912    }
913
914    #[cfg(feature = "candle")]
915    #[test]
916    fn resolve_candle_load_params_sha256_absent_by_default() {
917        use super::resolve_candle_load_params;
918        use crate::config::{CandleInlineConfig, Config};
919        use zeph_config::providers::ProviderEntry;
920
921        let candle = CandleInlineConfig::default();
922        let entry = ProviderEntry {
923            model: Some("org/chat-model".into()),
924            candle: Some(candle.clone()),
925            ..ProviderEntry::default()
926        };
927        let config = Config::default();
928
929        let params = resolve_candle_load_params(&entry, &candle, &config);
930
931        if let zeph_llm::candle_provider::loader::ModelSource::HuggingFace { sha256, .. } =
932            params.source
933        {
934            assert!(sha256.is_none());
935        } else {
936            panic!("expected HuggingFace source for CandleSource::default()")
937        }
938        assert!(params.embedding_sha256.is_none());
939    }
940
941    #[cfg(feature = "cocoon")]
942    use super::spawn_cocoon_health_checks;
943    use super::{build_provider_from_entry, resolve_named_provider};
944    use crate::config::{Config, ProviderKind};
945    use zeph_config::providers::ProviderEntry;
946    use zeph_llm::LlmProvider;
947
948    #[cfg(feature = "gonka")]
949    mod gonka_tests {
950        use super::*;
951        use zeph_common::secret::Secret;
952        use zeph_config::GonkaNode;
953        use zeph_llm::LlmProvider;
954
955        fn gonka_entry_with_nodes(nodes: Vec<GonkaNode>) -> ProviderEntry {
956            ProviderEntry {
957                provider_type: ProviderKind::Gonka,
958                name: Some("gonka".into()),
959                model: Some("gpt-4o".into()),
960                gonka_nodes: nodes,
961                ..ProviderEntry::default()
962            }
963        }
964
965        fn valid_nodes() -> Vec<GonkaNode> {
966            vec![GonkaNode {
967                url: "https://node1.gonka.ai".into(),
968                address: "gonka1w508d6qejxtdg4y5r3zarvary0c5xw7k2gsyg6".into(),
969                name: Some("node1".into()),
970            }]
971        }
972
973        const VALID_PRIV_KEY: &str =
974            "0000000000000000000000000000000000000000000000000000000000000001";
975
976        #[test]
977        fn build_gonka_provider_missing_key_returns_error() {
978            let entry = gonka_entry_with_nodes(valid_nodes());
979            let config = Config::default();
980            let result = build_provider_from_entry(&entry, &config, None);
981            assert!(result.is_err());
982            let msg = result.unwrap_err().to_string();
983            assert!(
984                msg.contains("ZEPH_GONKA_PRIVATE_KEY"),
985                "error must mention missing key: {msg}"
986            );
987        }
988
989        #[test]
990        fn build_gonka_provider_empty_nodes_returns_error() {
991            let entry = gonka_entry_with_nodes(vec![]);
992            let mut config = Config::default();
993            config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
994            let result = build_provider_from_entry(&entry, &config, None);
995            assert!(result.is_err());
996            let msg = result.unwrap_err().to_string();
997            assert!(
998                msg.contains("gonka_nodes") || msg.contains("node"),
999                "error must mention empty nodes: {msg}"
1000            );
1001        }
1002
1003        #[test]
1004        fn build_gonka_provider_address_mismatch_returns_error() {
1005            let entry = gonka_entry_with_nodes(valid_nodes());
1006            let mut config = Config::default();
1007            config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
1008            config.secrets.gonka_address =
1009                Some(Secret::new("gonka1wrongaddress000000000000000000000000000"));
1010            let result = build_provider_from_entry(&entry, &config, None);
1011            assert!(result.is_err());
1012            let msg = result.unwrap_err().to_string();
1013            assert!(
1014                msg.contains("does not match"),
1015                "error must mention address mismatch: {msg}"
1016            );
1017        }
1018
1019        #[test]
1020        fn build_gonka_provider_happy_path() {
1021            let entry = gonka_entry_with_nodes(valid_nodes());
1022            let mut config = Config::default();
1023            config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
1024            let result = build_provider_from_entry(&entry, &config, None);
1025            assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
1026            let provider = result.unwrap();
1027            assert_eq!(provider.name(), "gonka");
1028        }
1029
1030        /// Regression for #5892: `GonkaProvider::name()` was hardcoded to `"gonka"`; two
1031        /// distinct `[[llm.providers]]` entries with distinct configured `name` fields must
1032        /// build `AnyProvider`s with distinct `.name()` values.
1033        #[test]
1034        fn build_gonka_provider_distinct_entries_yield_distinct_provider_names() {
1035            let mut entry_a = gonka_entry_with_nodes(valid_nodes());
1036            entry_a.name = Some("gonka-a".into());
1037            let mut entry_b = gonka_entry_with_nodes(valid_nodes());
1038            entry_b.name = Some("gonka-b".into());
1039            let mut config = Config::default();
1040            config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
1041
1042            let provider_a = build_provider_from_entry(&entry_a, &config, None).unwrap();
1043            let provider_b = build_provider_from_entry(&entry_b, &config, None).unwrap();
1044
1045            assert_eq!(provider_a.name(), "gonka-a");
1046            assert_eq!(provider_b.name(), "gonka-b");
1047            assert_ne!(provider_a.name(), provider_b.name());
1048        }
1049    }
1050
1051    fn make_provider_entry(
1052        embed: bool,
1053        model: Option<&str>,
1054        embedding_model: Option<&str>,
1055    ) -> ProviderEntry {
1056        ProviderEntry {
1057            provider_type: ProviderKind::Ollama,
1058            embed,
1059            model: model.map(str::to_owned),
1060            embedding_model: embedding_model.map(str::to_owned),
1061            ..ProviderEntry::default()
1062        }
1063    }
1064
1065    #[test]
1066    fn stable_skill_embedding_model_prefers_embedding_model_field() {
1067        let mut config = Config::default();
1068        config.llm.providers = vec![make_provider_entry(
1069            true,
1070            Some("chat-model"),
1071            Some("embed-v2"),
1072        )];
1073        assert_eq!(config.llm.stable_skill_embedding_model(), "embed-v2");
1074    }
1075
1076    #[test]
1077    fn stable_skill_embedding_model_falls_back_to_model_field() {
1078        let mut config = Config::default();
1079        config.llm.providers = vec![make_provider_entry(
1080            true,
1081            Some("nomic-embed-text-v2-moe:latest"),
1082            None,
1083        )];
1084        assert_eq!(
1085            config.llm.stable_skill_embedding_model(),
1086            "nomic-embed-text-v2-moe:latest"
1087        );
1088    }
1089
1090    #[test]
1091    fn stable_skill_embedding_model_finds_embed_flag_entry() {
1092        let mut config = Config::default();
1093        config.llm.providers = vec![
1094            make_provider_entry(false, Some("chat-model"), None),
1095            make_provider_entry(true, Some("embed-model"), Some("text-embed-3")),
1096        ];
1097        assert_eq!(config.llm.stable_skill_embedding_model(), "text-embed-3");
1098    }
1099
1100    #[test]
1101    fn stable_skill_embedding_model_falls_back_to_effective_when_no_embed_entry() {
1102        let mut config = Config::default();
1103        config.llm.embedding_model = "global-embed-model".to_owned();
1104        // No embed=true entry, no embedding_model field set — falls back to effective_embedding_model.
1105        config.llm.providers = vec![make_provider_entry(false, Some("chat"), None)];
1106        assert_eq!(
1107            config.llm.stable_skill_embedding_model(),
1108            config.llm.effective_embedding_model()
1109        );
1110    }
1111
1112    #[test]
1113    fn resolve_named_provider_empty_name_falls_back_to_primary_silently() {
1114        let config = Config::default();
1115        let entry = make_provider_entry(false, Some("primary-model"), None);
1116        let primary = build_provider_from_entry(&entry, &config, None).unwrap();
1117        let resolved = resolve_named_provider(&config, &primary, "");
1118        assert_eq!(resolved.name(), primary.name());
1119    }
1120
1121    #[test]
1122    fn resolve_named_provider_unmatched_name_falls_back_to_primary_with_warn() {
1123        let config = Config::default(); // no [[llm.providers]] named "fast"
1124        let entry = make_provider_entry(false, Some("primary-model"), None);
1125        let primary = build_provider_from_entry(&entry, &config, None).unwrap();
1126        let resolved = resolve_named_provider(&config, &primary, "fast");
1127        assert_eq!(resolved.name(), primary.name());
1128    }
1129
1130    /// Regression for #5859: two `[[llm.providers]]` entries of `type = "ollama"` with
1131    /// distinct configured `name` fields must build `AnyProvider`s with distinct
1132    /// `.name()` values. Before the fix, `OllamaProvider::name()` returned the hardcoded
1133    /// literal `"ollama"` for every instance regardless of `entry.effective_name()`,
1134    /// corrupting router reputation tracking and embed-provider selection whenever more
1135    /// than one Ollama entry was configured.
1136    #[test]
1137    fn build_provider_from_entry_ollama_distinct_entries_yield_distinct_provider_names() {
1138        let config = Config::default();
1139        let entry_a = ProviderEntry {
1140            provider_type: ProviderKind::Ollama,
1141            name: Some("ollama-chat".into()),
1142            model: Some("qwen3:8b".into()),
1143            ..ProviderEntry::default()
1144        };
1145        let entry_b = ProviderEntry {
1146            provider_type: ProviderKind::Ollama,
1147            name: Some("ollama-embed".into()),
1148            model: Some("nomic-embed-text".into()),
1149            embed: true,
1150            ..ProviderEntry::default()
1151        };
1152
1153        let provider_a = build_provider_from_entry(&entry_a, &config, None).unwrap();
1154        let provider_b = build_provider_from_entry(&entry_b, &config, None).unwrap();
1155
1156        assert_ne!(
1157            provider_a.name(),
1158            provider_b.name(),
1159            "two distinct Ollama [[llm.providers]] entries must yield distinct \
1160             AnyProvider::name() values"
1161        );
1162        assert_eq!(provider_a.name(), "ollama-chat");
1163        assert_eq!(provider_b.name(), "ollama-embed");
1164    }
1165
1166    /// Regression for #5892: Claude, `OpenAI`, and Gemini backends hardcoded `name()` to
1167    /// their type literal, unlike `OllamaProvider`/`CompatibleProvider` (fixed for #5859).
1168    /// Two distinct `[[llm.providers]]` entries of the same `provider_type` with distinct
1169    /// configured `name` fields must build `AnyProvider`s with distinct `.name()` values.
1170    #[test]
1171    fn build_provider_from_entry_claude_openai_gemini_distinct_entries_yield_distinct_provider_names()
1172     {
1173        use zeph_common::secret::Secret;
1174
1175        let mut config = Config::default();
1176        config.secrets.claude_api_key = Some(Secret::new("test-claude-key"));
1177        config.secrets.openai_api_key = Some(Secret::new("test-openai-key"));
1178        config.secrets.gemini_api_key = Some(Secret::new("test-gemini-key"));
1179
1180        let claude_a = ProviderEntry {
1181            provider_type: ProviderKind::Claude,
1182            name: Some("claude-quality".into()),
1183            ..ProviderEntry::default()
1184        };
1185        let claude_b = ProviderEntry {
1186            provider_type: ProviderKind::Claude,
1187            name: Some("claude-fast".into()),
1188            ..ProviderEntry::default()
1189        };
1190        let provider_claude_a = build_provider_from_entry(&claude_a, &config, None).unwrap();
1191        let provider_claude_b = build_provider_from_entry(&claude_b, &config, None).unwrap();
1192        assert_eq!(provider_claude_a.name(), "claude-quality");
1193        assert_eq!(provider_claude_b.name(), "claude-fast");
1194        assert_ne!(provider_claude_a.name(), provider_claude_b.name());
1195
1196        let openai_a = ProviderEntry {
1197            provider_type: ProviderKind::OpenAi,
1198            name: Some("openai-quality".into()),
1199            ..ProviderEntry::default()
1200        };
1201        let openai_b = ProviderEntry {
1202            provider_type: ProviderKind::OpenAi,
1203            name: Some("openai-fast".into()),
1204            ..ProviderEntry::default()
1205        };
1206        let provider_openai_a = build_provider_from_entry(&openai_a, &config, None).unwrap();
1207        let provider_openai_b = build_provider_from_entry(&openai_b, &config, None).unwrap();
1208        assert_eq!(provider_openai_a.name(), "openai-quality");
1209        assert_eq!(provider_openai_b.name(), "openai-fast");
1210        assert_ne!(provider_openai_a.name(), provider_openai_b.name());
1211
1212        let gemini_a = ProviderEntry {
1213            provider_type: ProviderKind::Gemini,
1214            name: Some("gemini-quality".into()),
1215            ..ProviderEntry::default()
1216        };
1217        let gemini_b = ProviderEntry {
1218            provider_type: ProviderKind::Gemini,
1219            name: Some("gemini-fast".into()),
1220            ..ProviderEntry::default()
1221        };
1222        let provider_gemini_a = build_provider_from_entry(&gemini_a, &config, None).unwrap();
1223        let provider_gemini_b = build_provider_from_entry(&gemini_b, &config, None).unwrap();
1224        assert_eq!(provider_gemini_a.name(), "gemini-quality");
1225        assert_eq!(provider_gemini_b.name(), "gemini-fast");
1226        assert_ne!(provider_gemini_a.name(), provider_gemini_b.name());
1227    }
1228
1229    /// Regression for #5892: an unnamed entry (no `name` field in `[[llm.providers]]`) must
1230    /// fall back to the provider-type literal, matching pre-fix behavior for legacy configs.
1231    #[test]
1232    fn build_provider_from_entry_claude_unnamed_falls_back_to_type_literal() {
1233        use zeph_common::secret::Secret;
1234
1235        let mut config = Config::default();
1236        config.secrets.claude_api_key = Some(Secret::new("test-claude-key"));
1237        let entry = ProviderEntry {
1238            provider_type: ProviderKind::Claude,
1239            ..ProviderEntry::default()
1240        };
1241        let provider = build_provider_from_entry(&entry, &config, None).unwrap();
1242        assert_eq!(provider.name(), "claude");
1243    }
1244
1245    #[cfg(feature = "cocoon")]
1246    mod cocoon_tests {
1247        use super::*;
1248
1249        fn cocoon_entry(access_hash: Option<&str>) -> ProviderEntry {
1250            ProviderEntry {
1251                provider_type: ProviderKind::Cocoon,
1252                name: Some("cocoon".into()),
1253                model: Some("Qwen/Qwen3-0.6B".into()),
1254                cocoon_client_url: Some("http://localhost:10000".into()),
1255                cocoon_access_hash: access_hash.map(str::to_owned),
1256                cocoon_health_check: false,
1257                ..ProviderEntry::default()
1258            }
1259        }
1260
1261        /// `cocoon_access_hash = Some("")` sentinel with no vault key must return an error.
1262        #[test]
1263        fn cocoon_access_hash_gate_vault_miss_errors() {
1264            let entry = cocoon_entry(Some(""));
1265            let config = Config::default(); // secrets.cocoon_access_hash = None
1266            let result = build_provider_from_entry(&entry, &config, None);
1267            assert!(
1268                result.is_err(),
1269                "expected error when vault key is absent but sentinel is set"
1270            );
1271            let err_str = result.unwrap_err().to_string();
1272            assert!(
1273                err_str.contains("ZEPH_COCOON_ACCESS_HASH"),
1274                "error should mention the vault key: {err_str}"
1275            );
1276        }
1277
1278        /// `cocoon_access_hash = None` must succeed without touching the vault (health check off).
1279        #[test]
1280        fn cocoon_no_access_hash_gate_succeeds_without_vault() {
1281            let entry = cocoon_entry(None);
1282            let config = Config::default();
1283            let result = build_provider_from_entry(&entry, &config, None);
1284            assert!(
1285                result.is_ok(),
1286                "expected success when no access hash requested: {:?}",
1287                result.err()
1288            );
1289        }
1290
1291        /// Regression for #5892: `CocoonProvider::name()` was hardcoded to `"cocoon"`; two
1292        /// distinct `[[llm.providers]]` entries with distinct configured `name` fields must
1293        /// build `AnyProvider`s with distinct `.name()` values.
1294        #[test]
1295        fn build_provider_from_entry_cocoon_distinct_entries_yield_distinct_provider_names() {
1296            let mut entry_a = cocoon_entry(None);
1297            entry_a.name = Some("cocoon-a".into());
1298            let mut entry_b = cocoon_entry(None);
1299            entry_b.name = Some("cocoon-b".into());
1300            let config = Config::default();
1301
1302            let provider_a = build_provider_from_entry(&entry_a, &config, None).unwrap();
1303            let provider_b = build_provider_from_entry(&entry_b, &config, None).unwrap();
1304
1305            assert_eq!(provider_a.name(), "cocoon-a");
1306            assert_eq!(provider_b.name(), "cocoon-b");
1307            assert_ne!(provider_a.name(), provider_b.name());
1308        }
1309
1310        /// `spawn_cocoon_health_checks` must share the same vault-miss gating as
1311        /// `build_cocoon_provider`: when the entry opts in but the vault key is absent, the
1312        /// health check is skipped (logged) rather than spawned, and bootstrap is not disturbed.
1313        #[test]
1314        fn spawn_cocoon_health_checks_skips_on_vault_miss() {
1315            let mut entry = cocoon_entry(Some(""));
1316            entry.cocoon_health_check = true;
1317            let config = Config::default(); // secrets.cocoon_access_hash = None
1318            let supervisor = std::sync::Arc::new(zeph_common::TaskSupervisor::new(
1319                tokio_util::sync::CancellationToken::new(),
1320            ));
1321
1322            spawn_cocoon_health_checks(&[&entry], &config, &supervisor);
1323
1324            assert!(
1325                supervisor.snapshot().is_empty(),
1326                "expected no health-check task to be spawned when vault key is absent"
1327            );
1328        }
1329    }
1330}