use zeph_llm::any::AnyProvider;
use zeph_llm::claude::ClaudeProvider;
#[cfg(feature = "cocoon")]
use zeph_llm::cocoon::{CocoonClient, CocoonProvider};
use zeph_llm::compatible::CompatibleProvider;
use zeph_llm::gemini::GeminiProvider;
#[cfg(feature = "gonka")]
use zeph_llm::gonka::endpoints::{EndpointPool, GonkaEndpoint};
#[cfg(feature = "gonka")]
use zeph_llm::gonka::{GonkaProvider, RequestSigner};
use zeph_llm::http::llm_client;
use zeph_llm::ollama::OllamaProvider;
use zeph_llm::openai::OpenAiProvider;
#[cfg(feature = "gonka")]
use zeroize::Zeroizing;
use crate::agent::state::ProviderConfigSnapshot;
use crate::config::{Config, ProviderEntry, ProviderKind};
#[derive(Debug, thiserror::Error)]
pub enum BootstrapError {
#[error("config error: {0}")]
Config(#[from] crate::config::ConfigError),
#[error("provider error: {0}")]
Provider(String),
#[error("memory error: {0}")]
Memory(String),
#[error("vault init error: {0}")]
VaultInit(crate::vault::AgeVaultError),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
pub fn build_provider_for_switch(
entry: &ProviderEntry,
snapshot: &ProviderConfigSnapshot,
) -> Result<AnyProvider, BootstrapError> {
use zeph_common::secret::Secret;
let mut config = Config::default();
config.secrets.claude_api_key = snapshot.claude_api_key.as_deref().map(Secret::new);
config.secrets.openai_api_key = snapshot.openai_api_key.as_deref().map(Secret::new);
config.secrets.gemini_api_key = snapshot.gemini_api_key.as_deref().map(Secret::new);
config.secrets.compatible_api_keys = snapshot
.compatible_api_keys
.iter()
.map(|(k, v)| (k.clone(), Secret::new(v.as_str())))
.collect();
config.secrets.gonka_private_key = snapshot
.gonka_private_key
.as_ref()
.map(|z| Secret::new(z.as_str()));
config.secrets.gonka_address = snapshot.gonka_address.as_deref().map(Secret::new);
config.secrets.cocoon_access_hash = snapshot.cocoon_access_hash.as_deref().map(Secret::new);
config.timeouts.llm_request_timeout_secs = snapshot.llm_request_timeout_secs;
config
.llm
.embedding_model
.clone_from(&snapshot.embedding_model);
build_provider_from_entry(entry, &config)
}
pub fn build_provider_from_entry(
entry: &ProviderEntry,
config: &Config,
) -> Result<AnyProvider, BootstrapError> {
match entry.provider_type {
ProviderKind::Ollama => Ok(build_ollama_provider(entry, config)),
ProviderKind::Claude => build_claude_provider(entry, config),
ProviderKind::OpenAi => build_openai_provider(entry, config),
ProviderKind::Gemini => build_gemini_provider(entry, config),
ProviderKind::Compatible => build_compatible_provider(entry, config),
#[cfg(feature = "candle")]
ProviderKind::Candle => build_candle_provider(entry, config),
#[cfg(not(feature = "candle"))]
ProviderKind::Candle => Err(BootstrapError::Provider(
"candle feature is not enabled".into(),
)),
#[cfg(feature = "gonka")]
ProviderKind::Gonka => build_gonka_provider(entry, config),
#[cfg(not(feature = "gonka"))]
ProviderKind::Gonka => Err(BootstrapError::Provider(
"gonka feature is not enabled; rebuild with --features gonka".into(),
)),
#[cfg(feature = "cocoon")]
ProviderKind::Cocoon => build_cocoon_provider(entry, config),
#[cfg(not(feature = "cocoon"))]
ProviderKind::Cocoon => Err(BootstrapError::Provider(
"cocoon feature is not enabled; rebuild with --features cocoon".into(),
)),
}
}
fn build_ollama_provider(entry: &ProviderEntry, config: &Config) -> AnyProvider {
let base_url = entry
.base_url
.as_deref()
.unwrap_or("http://localhost:11434");
let model = entry.model.as_deref().unwrap_or("qwen3:8b").to_owned();
let embed = entry
.embedding_model
.clone()
.unwrap_or_else(|| config.llm.embedding_model.clone());
let mut provider = OllamaProvider::new(base_url, model, embed);
if let Some(ref vm) = entry.vision_model {
provider = provider.with_vision_model(vm.clone());
}
if config.mcp.forward_output_schema {
tracing::debug!(
"mcp.forward_output_schema is enabled but Ollama does not support \
output schema forwarding; setting ignored for this provider"
);
}
AnyProvider::Ollama(provider)
}
fn build_claude_provider(
entry: &ProviderEntry,
config: &Config,
) -> Result<AnyProvider, BootstrapError> {
let api_key = config
.secrets
.claude_api_key
.as_ref()
.ok_or_else(|| BootstrapError::Provider("ZEPH_CLAUDE_API_KEY not found in vault".into()))?
.expose()
.to_owned();
let model = entry
.model
.clone()
.unwrap_or_else(|| "claude-haiku-4-5-20251001".to_owned());
let max_tokens = entry.max_tokens.unwrap_or(4096);
let provider = ClaudeProvider::new(api_key, model, max_tokens)
.with_client(llm_client(config.timeouts.llm_request_timeout_secs))
.with_extended_context(entry.enable_extended_context)
.with_thinking_opt(entry.thinking.clone())
.map_err(|e| BootstrapError::Provider(format!("invalid thinking config: {e}")))?
.with_server_compaction(entry.server_compaction)
.with_prompt_cache_ttl(entry.prompt_cache_ttl)
.with_output_schema_forwarding(
config.mcp.forward_output_schema,
config.mcp.output_schema_hint_bytes,
config.mcp.max_description_bytes,
);
tracing::info!(
forward = config.mcp.forward_output_schema,
"mcp.output_schema.forwarding_configured"
);
Ok(AnyProvider::Claude(provider))
}
fn build_openai_provider(
entry: &ProviderEntry,
config: &Config,
) -> Result<AnyProvider, BootstrapError> {
let api_key = config
.secrets
.openai_api_key
.as_ref()
.ok_or_else(|| BootstrapError::Provider("ZEPH_OPENAI_API_KEY not found in vault".into()))?
.expose()
.to_owned();
let base_url = entry
.base_url
.clone()
.unwrap_or_else(|| "https://api.openai.com/v1".to_owned());
let model = entry
.model
.clone()
.unwrap_or_else(|| "gpt-4o-mini".to_owned());
let max_tokens = entry.max_tokens.unwrap_or(4096);
Ok(AnyProvider::OpenAi(
OpenAiProvider::new(
api_key,
base_url,
model,
max_tokens,
entry.embedding_model.clone(),
entry.reasoning_effort.clone(),
)
.with_client(llm_client(config.timeouts.llm_request_timeout_secs))
.with_output_schema_forwarding(
config.mcp.forward_output_schema,
config.mcp.output_schema_hint_bytes,
config.mcp.max_description_bytes,
),
))
}
fn build_gemini_provider(
entry: &ProviderEntry,
config: &Config,
) -> Result<AnyProvider, BootstrapError> {
let api_key = config
.secrets
.gemini_api_key
.as_ref()
.ok_or_else(|| BootstrapError::Provider("ZEPH_GEMINI_API_KEY not found in vault".into()))?
.expose()
.to_owned();
let model = entry
.model
.clone()
.unwrap_or_else(|| "gemini-2.0-flash".to_owned());
let max_tokens = entry.max_tokens.unwrap_or(8192);
let base_url = entry
.base_url
.clone()
.unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_owned());
let mut provider = GeminiProvider::new(api_key, model, max_tokens)
.with_base_url(base_url)
.with_client(llm_client(config.timeouts.llm_request_timeout_secs));
if let Some(ref em) = entry.embedding_model {
provider = provider.with_embedding_model(em.clone());
}
if let Some(level) = entry.thinking_level {
provider = provider.with_thinking_level(level);
}
if let Some(budget) = entry.thinking_budget {
provider = provider
.with_thinking_budget(budget)
.map_err(|e| BootstrapError::Provider(e.to_string()))?;
}
if let Some(include) = entry.include_thoughts {
provider = provider.with_include_thoughts(include);
}
if config.mcp.forward_output_schema {
tracing::debug!(
"mcp.forward_output_schema is enabled but Gemini does not support \
output schema forwarding; setting ignored for this provider"
);
}
Ok(AnyProvider::Gemini(provider))
}
fn build_compatible_provider(
entry: &ProviderEntry,
config: &Config,
) -> Result<AnyProvider, BootstrapError> {
let name = entry.name.as_deref().ok_or_else(|| {
BootstrapError::Provider(
"compatible provider requires 'name' field in [[llm.providers]]".into(),
)
})?;
let base_url = entry.base_url.clone().ok_or_else(|| {
BootstrapError::Provider(format!("compatible provider '{name}' requires 'base_url'"))
})?;
let model = entry.model.clone().unwrap_or_default();
let api_key = entry.api_key.clone().unwrap_or_else(|| {
config
.secrets
.compatible_api_keys
.get(name)
.map(|s| s.expose().to_owned())
.unwrap_or_default()
});
let max_tokens = entry.max_tokens.unwrap_or(4096);
let provider = CompatibleProvider::new(
name.to_owned(),
api_key,
base_url,
model,
max_tokens,
entry.embedding_model.clone(),
)
.with_output_schema_forwarding(
config.mcp.forward_output_schema,
config.mcp.output_schema_hint_bytes,
config.mcp.max_description_bytes,
);
tracing::info!(
forward = config.mcp.forward_output_schema,
provider = name,
"mcp.output_schema.forwarding_configured"
);
Ok(AnyProvider::Compatible(provider))
}
#[cfg(feature = "gonka")]
fn build_gonka_provider(
entry: &ProviderEntry,
config: &Config,
) -> Result<AnyProvider, BootstrapError> {
let _span = tracing::info_span!("core.provider_factory.build_gonka").entered();
let private_key_hex: Zeroizing<String> = Zeroizing::new(
config
.secrets
.gonka_private_key
.as_ref()
.ok_or_else(|| {
BootstrapError::Provider(
"ZEPH_GONKA_PRIVATE_KEY not found in vault; set it with: zeph vault set ZEPH_GONKA_PRIVATE_KEY <hex>".into(),
)
})?
.expose()
.to_owned(),
);
let chain_prefix = entry.effective_gonka_chain_prefix().to_owned();
let signer = RequestSigner::from_hex(&private_key_hex, &chain_prefix)
.map_err(|e| BootstrapError::Provider(format!("invalid Gonka private key: {e}")))?;
if let Some(ref configured_address) = config.secrets.gonka_address {
let configured = configured_address.expose().to_lowercase();
let derived = signer.address().to_lowercase();
if configured != derived {
return Err(BootstrapError::Provider(format!(
"ZEPH_GONKA_ADDRESS does not match address derived from private key \
(configured: {configured}, derived: {derived})"
)));
}
} else {
tracing::info!(
address = signer.address(),
"Gonka: using address derived from private key (ZEPH_GONKA_ADDRESS not set)"
);
}
if entry.gonka_nodes.is_empty() {
return Err(BootstrapError::Provider(
"Gonka provider entry must have at least one node in gonka_nodes".into(),
));
}
let endpoints: Vec<GonkaEndpoint> = entry
.gonka_nodes
.iter()
.map(|n| GonkaEndpoint {
base_url: n.url.clone(),
address: n.address.clone(),
})
.collect();
let pool = EndpointPool::new(endpoints).map_err(|e| {
BootstrapError::Provider(format!("failed to build Gonka endpoint pool: {e}"))
})?;
let model = entry.model.clone().unwrap_or_else(|| "gpt-4o".to_owned());
let max_tokens = entry.max_tokens.unwrap_or(4096);
let timeout = std::time::Duration::from_secs(config.timeouts.llm_request_timeout_secs);
let provider = GonkaProvider::new(
std::sync::Arc::new(signer),
std::sync::Arc::new(pool),
model,
max_tokens,
entry.embedding_model.clone(),
timeout,
);
Ok(AnyProvider::Gonka(provider))
}
#[cfg(feature = "cocoon")]
fn build_cocoon_provider(
entry: &ProviderEntry,
config: &Config,
) -> Result<AnyProvider, BootstrapError> {
let _span = tracing::info_span!("core.provider_factory.build_cocoon").entered();
let base_url = entry
.cocoon_client_url
.as_deref()
.unwrap_or("http://localhost:10000");
if !base_url.starts_with("http://localhost")
&& !base_url.starts_with("http://127.0.0.1")
&& !base_url.starts_with("http://[::1]")
&& !base_url.starts_with("https://localhost")
&& !base_url.starts_with("https://127.0.0.1")
&& !base_url.starts_with("https://[::1]")
{
tracing::warn!(
url = base_url,
"cocoon_client_url points to a non-localhost host; \
ensure this is intentional (expected sidecar on localhost)"
);
}
if entry
.cocoon_access_hash
.as_deref()
.is_some_and(|v| !v.is_empty())
{
tracing::warn!(
"cocoon_access_hash in config file appears to contain a raw value; \
this field should be empty — the actual hash must be stored in the vault: \
zeph vault set ZEPH_COCOON_ACCESS_HASH <hash>"
);
}
let access_hash = if entry.cocoon_access_hash.is_some() {
let hash = config
.secrets
.cocoon_access_hash
.as_ref()
.ok_or_else(|| {
BootstrapError::Provider(
"ZEPH_COCOON_ACCESS_HASH not found in vault; set it with: \
zeph vault set ZEPH_COCOON_ACCESS_HASH <hash>"
.into(),
)
})?
.expose()
.to_owned();
Some(hash)
} else {
None
};
let timeout = std::time::Duration::from_secs(config.timeouts.llm_request_timeout_secs);
let client = std::sync::Arc::new(CocoonClient::new(base_url, access_hash, timeout));
if entry.cocoon_health_check {
let client_clone = std::sync::Arc::clone(&client);
drop(tokio::spawn(async move {
match client_clone.health_check().await {
Ok(h) => {
tracing::info!(
proxy_connected = h.proxy_connected,
worker_count = h.worker_count,
"cocoon sidecar health check passed"
);
}
Err(e) => {
tracing::warn!(
error = %e,
"cocoon sidecar health check failed; \
inference requests will return LlmError::Unavailable until the sidecar is running"
);
}
}
}));
}
let model = entry
.model
.clone()
.unwrap_or_else(|| "Qwen/Qwen3-0.6B".to_owned());
let max_tokens = entry.max_tokens.unwrap_or(4096);
let provider = CocoonProvider::new(model, max_tokens, entry.embedding_model.clone(), client);
Ok(AnyProvider::Cocoon(provider))
}
#[cfg(feature = "candle")]
fn build_candle_provider(
entry: &ProviderEntry,
config: &Config,
) -> Result<AnyProvider, BootstrapError> {
let candle = entry.candle.as_ref().ok_or_else(|| {
BootstrapError::Provider(
"candle provider requires 'candle' section in [[llm.providers]]".into(),
)
})?;
let source = match candle.source.as_str() {
"local" => zeph_llm::candle_provider::loader::ModelSource::Local {
path: std::path::PathBuf::from(&candle.local_path),
},
_ => zeph_llm::candle_provider::loader::ModelSource::HuggingFace {
repo_id: entry
.model
.clone()
.unwrap_or_else(|| config.llm.effective_model().to_owned()),
filename: candle.filename.clone(),
},
};
let template =
zeph_llm::candle_provider::template::ChatTemplate::parse_str(&candle.chat_template);
let gen_config = zeph_llm::candle_provider::generate::GenerationConfig {
temperature: candle.generation.temperature,
top_p: candle.generation.top_p,
top_k: candle.generation.top_k,
max_tokens: candle.generation.capped_max_tokens(),
seed: candle.generation.seed,
repeat_penalty: candle.generation.repeat_penalty,
repeat_last_n: candle.generation.repeat_last_n,
};
let device = select_device(&candle.device)?;
let inference_timeout = std::time::Duration::from_secs(candle.inference_timeout_secs.max(1));
zeph_llm::candle_provider::CandleProvider::new_with_timeout(
&source,
template,
gen_config,
candle.embedding_repo.as_deref(),
candle.hf_token.as_deref(),
device,
inference_timeout,
)
.map(AnyProvider::Candle)
.map_err(|e| BootstrapError::Provider(e.to_string()))
}
#[cfg(feature = "candle")]
pub fn select_device(
preference: &str,
) -> Result<zeph_llm::candle_provider::Device, BootstrapError> {
match preference {
"metal" => {
#[cfg(feature = "metal")]
return zeph_llm::candle_provider::Device::new_metal(0)
.map_err(|e| BootstrapError::Provider(e.to_string()));
#[cfg(not(feature = "metal"))]
return Err(BootstrapError::Provider(
"candle compiled without metal feature".into(),
));
}
"cuda" => {
#[cfg(feature = "cuda")]
return zeph_llm::candle_provider::Device::new_cuda(0)
.map_err(|e| BootstrapError::Provider(e.to_string()));
#[cfg(not(feature = "cuda"))]
return Err(BootstrapError::Provider(
"candle compiled without cuda feature".into(),
));
}
"auto" => {
#[cfg(feature = "metal")]
if let Ok(device) = zeph_llm::candle_provider::Device::new_metal(0) {
return Ok(device);
}
#[cfg(feature = "cuda")]
if let Ok(device) = zeph_llm::candle_provider::Device::new_cuda(0) {
return Ok(device);
}
Ok(zeph_llm::candle_provider::Device::Cpu)
}
_ => Ok(zeph_llm::candle_provider::Device::Cpu),
}
}
#[must_use]
pub fn effective_embedding_model(config: &Config) -> String {
if let Some(m) = config
.llm
.providers
.iter()
.find(|e| e.embed)
.and_then(|e| e.embedding_model.as_ref())
{
return m.clone();
}
if let Some(m) = config
.llm
.providers
.first()
.and_then(|e| e.embedding_model.as_ref())
{
return m.clone();
}
config.llm.embedding_model.clone()
}
#[must_use]
pub fn stable_skill_embedding_model(config: &Config) -> String {
let embed_entry = config.llm.providers.iter().find(|e| e.embed).or_else(|| {
config
.llm
.providers
.iter()
.find(|e| e.embedding_model.is_some())
});
if let Some(entry) = embed_entry {
if let Some(em) = entry.embedding_model.as_ref().filter(|s| !s.is_empty()) {
return em.clone();
}
if let Some(m) = entry.model.as_ref().filter(|s| !s.is_empty()) {
return m.clone();
}
}
effective_embedding_model(config)
}
#[cfg(test)]
mod tests {
#[cfg(feature = "candle")]
use super::select_device;
#[cfg(feature = "candle")]
#[test]
fn select_device_cpu_default() {
let device = select_device("cpu").unwrap();
assert!(matches!(device, zeph_llm::candle_provider::Device::Cpu));
}
#[cfg(feature = "candle")]
#[test]
fn select_device_unknown_defaults_to_cpu() {
let device = select_device("unknown").unwrap();
assert!(matches!(device, zeph_llm::candle_provider::Device::Cpu));
}
#[cfg(all(feature = "candle", not(feature = "metal")))]
#[test]
fn select_device_metal_without_feature_errors() {
let result = select_device("metal");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("metal feature"));
}
#[cfg(all(feature = "candle", not(feature = "cuda")))]
#[test]
fn select_device_cuda_without_feature_errors() {
let result = select_device("cuda");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cuda feature"));
}
#[cfg(feature = "candle")]
#[test]
fn select_device_auto_fallback() {
let device = select_device("auto").unwrap();
assert!(matches!(
device,
zeph_llm::candle_provider::Device::Cpu
| zeph_llm::candle_provider::Device::Cuda(_)
| zeph_llm::candle_provider::Device::Metal(_)
));
}
#[cfg(any(feature = "gonka", feature = "cocoon"))]
use super::build_provider_from_entry;
use super::{effective_embedding_model, stable_skill_embedding_model};
use crate::config::{Config, ProviderKind};
use zeph_config::providers::ProviderEntry;
#[cfg(feature = "gonka")]
mod gonka_tests {
use super::*;
use zeph_common::secret::Secret;
use zeph_config::GonkaNode;
use zeph_llm::LlmProvider;
fn gonka_entry_with_nodes(nodes: Vec<GonkaNode>) -> ProviderEntry {
ProviderEntry {
provider_type: ProviderKind::Gonka,
name: Some("gonka".into()),
model: Some("gpt-4o".into()),
gonka_nodes: nodes,
..ProviderEntry::default()
}
}
fn valid_nodes() -> Vec<GonkaNode> {
vec![GonkaNode {
url: "https://node1.gonka.ai".into(),
address: "gonka1w508d6qejxtdg4y5r3zarvary0c5xw7k2gsyg6".into(),
name: Some("node1".into()),
}]
}
const VALID_PRIV_KEY: &str =
"0000000000000000000000000000000000000000000000000000000000000001";
#[test]
fn build_gonka_provider_missing_key_returns_error() {
let entry = gonka_entry_with_nodes(valid_nodes());
let config = Config::default();
let result = build_provider_from_entry(&entry, &config);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("ZEPH_GONKA_PRIVATE_KEY"),
"error must mention missing key: {msg}"
);
}
#[test]
fn build_gonka_provider_empty_nodes_returns_error() {
let entry = gonka_entry_with_nodes(vec![]);
let mut config = Config::default();
config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
let result = build_provider_from_entry(&entry, &config);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("gonka_nodes") || msg.contains("node"),
"error must mention empty nodes: {msg}"
);
}
#[test]
fn build_gonka_provider_address_mismatch_returns_error() {
let entry = gonka_entry_with_nodes(valid_nodes());
let mut config = Config::default();
config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
config.secrets.gonka_address =
Some(Secret::new("gonka1wrongaddress000000000000000000000000000"));
let result = build_provider_from_entry(&entry, &config);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("does not match"),
"error must mention address mismatch: {msg}"
);
}
#[test]
fn build_gonka_provider_happy_path() {
let entry = gonka_entry_with_nodes(valid_nodes());
let mut config = Config::default();
config.secrets.gonka_private_key = Some(Secret::new(VALID_PRIV_KEY));
let result = build_provider_from_entry(&entry, &config);
assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
let provider = result.unwrap();
assert_eq!(provider.name(), "gonka");
}
}
fn make_provider_entry(
embed: bool,
model: Option<&str>,
embedding_model: Option<&str>,
) -> ProviderEntry {
ProviderEntry {
provider_type: ProviderKind::Ollama,
embed,
model: model.map(str::to_owned),
embedding_model: embedding_model.map(str::to_owned),
..ProviderEntry::default()
}
}
#[test]
fn stable_skill_embedding_model_prefers_embedding_model_field() {
let mut config = Config::default();
config.llm.providers = vec![make_provider_entry(
true,
Some("chat-model"),
Some("embed-v2"),
)];
assert_eq!(stable_skill_embedding_model(&config), "embed-v2");
}
#[test]
fn stable_skill_embedding_model_falls_back_to_model_field() {
let mut config = Config::default();
config.llm.providers = vec![make_provider_entry(
true,
Some("nomic-embed-text-v2-moe:latest"),
None,
)];
assert_eq!(
stable_skill_embedding_model(&config),
"nomic-embed-text-v2-moe:latest"
);
}
#[test]
fn stable_skill_embedding_model_finds_embed_flag_entry() {
let mut config = Config::default();
config.llm.providers = vec![
make_provider_entry(false, Some("chat-model"), None),
make_provider_entry(true, Some("embed-model"), Some("text-embed-3")),
];
assert_eq!(stable_skill_embedding_model(&config), "text-embed-3");
}
#[test]
fn stable_skill_embedding_model_falls_back_to_effective_when_no_embed_entry() {
let mut config = Config::default();
config.llm.embedding_model = "global-embed-model".to_owned();
config.llm.providers = vec![make_provider_entry(false, Some("chat"), None)];
assert_eq!(
stable_skill_embedding_model(&config),
effective_embedding_model(&config)
);
}
#[cfg(feature = "cocoon")]
mod cocoon_tests {
use super::*;
fn cocoon_entry(access_hash: Option<&str>) -> ProviderEntry {
ProviderEntry {
provider_type: ProviderKind::Cocoon,
name: Some("cocoon".into()),
model: Some("Qwen/Qwen3-0.6B".into()),
cocoon_client_url: Some("http://localhost:10000".into()),
cocoon_access_hash: access_hash.map(str::to_owned),
cocoon_health_check: false,
..ProviderEntry::default()
}
}
#[test]
fn cocoon_access_hash_gate_vault_miss_errors() {
let entry = cocoon_entry(Some(""));
let config = Config::default(); let result = build_provider_from_entry(&entry, &config);
assert!(
result.is_err(),
"expected error when vault key is absent but sentinel is set"
);
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("ZEPH_COCOON_ACCESS_HASH"),
"error should mention the vault key: {err_str}"
);
}
#[test]
fn cocoon_no_access_hash_gate_succeeds_without_vault() {
let entry = cocoon_entry(None);
let config = Config::default();
let result = build_provider_from_entry(&entry, &config);
assert!(
result.is_ok(),
"expected success when no access hash requested: {:?}",
result.err()
);
}
}
}