use dialoguer::{Confirm, Input, Password, Select};
use zeph_config::{GeminiThinkingLevel, GonkaNode, ThinkingConfig, ThinkingEffort};
use zeph_core::config::ProviderKind;
use zeroize::Zeroizing;
use super::WizardState;
pub(super) fn supports_embeddings(provider_type: &str) -> bool {
!matches!(provider_type, "claude" | "candle")
}
#[allow(clippy::too_many_lines)]
pub(super) fn step_llm(state: &mut WizardState) -> anyhow::Result<()> {
println!("== Step 2/10: LLM Provider ==\n");
let use_age = state.vault_backend == "age";
loop {
step_llm_provider(state, use_age)?;
let provider_type = state.provider.map_or("ollama", ProviderKind::as_str);
if supports_embeddings(provider_type) {
state.embedding_model = Some(
Input::new()
.with_prompt("Embedding model")
.default("qwen3-embedding".into())
.interact_text()?,
);
}
if state.provider == Some(ProviderKind::Ollama) {
let use_vision = Confirm::new()
.with_prompt("Use a separate model for vision (image input)?")
.default(false)
.interact()?;
if use_vision {
state.vision_model = Some(
Input::new()
.with_prompt("Vision model name (e.g. llava:13b)")
.interact_text()?,
);
}
}
let run_test = Confirm::new()
.with_prompt("Test connectivity to the configured provider now?")
.default(true)
.interact()?;
if run_test {
let base_url = resolve_probe_url(state);
if let Some(url) = base_url {
print!(" Probing {url} … ");
if probe_url_reachable(&url) {
println!("PASS");
break;
}
println!("FAIL");
let retry = Confirm::new()
.with_prompt("Re-enter provider settings?")
.default(true)
.interact()?;
if retry {
continue;
}
println!(" Continuing with current settings. Run 'zeph doctor' to verify later.");
} else {
println!(" Skipping connectivity test (no HTTP endpoint for this provider).");
}
} else {
println!(" Skipping connectivity test. Run 'zeph doctor' to verify later.");
}
break;
}
println!();
Ok(())
}
fn openai_models_url(base: &str) -> String {
let trimmed = base.trim_end_matches('/');
if trimmed.ends_with("/v1") {
format!("{trimmed}/models")
} else {
format!("{trimmed}/v1/models")
}
}
pub(crate) fn resolve_probe_url(state: &WizardState) -> Option<String> {
match state.provider? {
ProviderKind::Ollama => {
let base = state
.base_url
.as_deref()
.unwrap_or("http://localhost:11434");
Some(format!("{}/api/tags", base.trim_end_matches('/')))
}
ProviderKind::Claude => Some(format!(
"{}/v1/models",
state
.base_url
.as_deref()
.unwrap_or("https://api.anthropic.com")
.trim_end_matches('/')
)),
ProviderKind::OpenAi | ProviderKind::Compatible => {
let base = state
.base_url
.as_deref()
.unwrap_or("https://api.openai.com/v1");
Some(openai_models_url(base))
}
ProviderKind::Gemini => {
Some("https://generativelanguage.googleapis.com/v1/models".to_owned())
}
ProviderKind::Cocoon => state
.cocoon_client_url
.as_deref()
.map(|u| u.trim_end_matches('/').to_owned()),
_ => None,
}
}
fn probe_url_reachable(url: &str) -> bool {
let Ok(parsed) = url::Url::parse(url) else {
return false;
};
let host = parsed.host_str().unwrap_or("localhost");
let port = parsed
.port()
.unwrap_or(if parsed.scheme() == "https" { 443 } else { 80 });
let addr_str = format!("{host}:{port}");
let Ok(mut addrs) = std::net::ToSocketAddrs::to_socket_addrs(addr_str.as_str()) else {
return false;
};
let Some(addr) = addrs.next() else {
return false;
};
std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_secs(5)).is_ok()
}
#[allow(clippy::too_many_lines)]
pub(super) fn step_llm_provider(state: &mut WizardState, use_age: bool) -> anyhow::Result<()> {
let providers = [
"Ollama (local)",
"Claude (API)",
"OpenAI (API)",
"Gemini (API)",
"Compatible (custom)",
"Gonka (decentralized \u{2014} via GonkaGate)",
"Gonka (native \u{2014} requires GNK staking)",
"Cocoon (decentralized TEE inference via TON \u{2014} requires local sidecar)",
];
let selection = Select::new()
.with_prompt("Select LLM provider")
.items(providers)
.default(0)
.interact()?;
match selection {
0 => {
state.provider = Some(ProviderKind::Ollama);
state.base_url = Some(
Input::new()
.with_prompt("Ollama base URL")
.default("http://localhost:11434".into())
.interact_text()?,
);
state.model = Some(
Input::new()
.with_prompt("Model name")
.default("qwen3:8b".into())
.interact_text()?,
);
}
1 => {
state.provider = Some(ProviderKind::Claude);
if !use_age {
let raw = Password::new().with_prompt("Claude API key").interact()?;
state.api_key = if raw.is_empty() { None } else { Some(raw) };
}
state.model = Some(
Input::new()
.with_prompt("Model name")
.default("claude-sonnet-4-5-20250929".into())
.interact_text()?,
);
let thinking_mode = Select::new()
.with_prompt("Enable thinking?")
.items(["No", "Extended", "Adaptive"])
.default(0)
.interact()?;
state.thinking = match thinking_mode {
1 => {
let budget: u32 = Input::new()
.with_prompt("Budget tokens (1024-128000)")
.default(10_000)
.interact_text()?;
Some(ThinkingConfig::Extended {
budget_tokens: budget,
})
}
2 => {
let effort_idx = Select::new()
.with_prompt("Effort level")
.items(["Low", "Medium", "High"])
.default(1)
.interact()?;
let effort = match effort_idx {
0 => ThinkingEffort::Low,
2 => ThinkingEffort::High,
_ => ThinkingEffort::Medium,
};
Some(ThinkingConfig::Adaptive {
effort: Some(effort),
})
}
_ => None,
};
state.enable_extended_context = Confirm::new()
.with_prompt("Enable 1M extended context? (long-context pricing above 200K tokens)")
.default(false)
.interact()?;
}
2 => {
state.provider = Some(ProviderKind::OpenAi);
if !use_age {
let raw = Password::new().with_prompt("OpenAI API key").interact()?;
state.api_key = if raw.is_empty() { None } else { Some(raw) };
}
state.base_url = Some(
Input::new()
.with_prompt("Base URL")
.default("https://api.openai.com/v1".into())
.interact_text()?,
);
state.model = Some(
Input::new()
.with_prompt("Model name")
.default("gpt-4o".into())
.interact_text()?,
);
let effort_opts = ["skip (no reasoning_effort)", "low", "medium", "high"];
let effort_sel = Select::new()
.with_prompt("Default reasoning effort (for o-series/reasoning models)")
.items(effort_opts)
.default(0)
.interact()?;
state.reasoning_effort = match effort_sel {
1 => Some("low".to_owned()),
2 => Some("medium".to_owned()),
3 => Some("high".to_owned()),
_ => None,
};
}
3 => {
state.provider = Some(ProviderKind::Gemini);
if !use_age {
let raw = Password::new().with_prompt("Gemini API key").interact()?;
state.api_key = if raw.is_empty() { None } else { Some(raw) };
}
state.model = Some(
Input::new()
.with_prompt("Model name")
.default("gemini-2.0-flash".into())
.interact_text()?,
);
let thinking_opts = [
"skip (no thinking_level)",
"minimal",
"low",
"medium",
"high",
];
let thinking_sel = Select::new()
.with_prompt("Thinking level (for Gemini 3+ thinking models; skip for 2.x)")
.items(thinking_opts)
.default(0)
.interact()?;
state.gemini_thinking_level = match thinking_sel {
1 => Some(GeminiThinkingLevel::Minimal),
2 => Some(GeminiThinkingLevel::Low),
3 => Some(GeminiThinkingLevel::Medium),
4 => Some(GeminiThinkingLevel::High),
_ => None,
};
}
4 => {
state.provider = Some(ProviderKind::Compatible);
state.compatible_name =
Some(Input::new().with_prompt("Provider name").interact_text()?);
state.base_url = Some(Input::new().with_prompt("Base URL").interact_text()?);
state.model = Some(Input::new().with_prompt("Model name").interact_text()?);
if !use_age {
state.api_key = Some(
Password::new()
.with_prompt("API key (leave empty if none)")
.allow_empty_password(true)
.interact()?,
);
}
}
5 => {
state.provider = Some(ProviderKind::Compatible);
state.compatible_name = Some("gonkagate".into());
state.base_url = Some("https://api.gonkagate.com/v1".into());
let models = ["Qwen/Qwen3-235B-A22B-Instruct-2507-FP8", "Custom..."];
let model_sel = Select::new()
.with_prompt("Select model")
.items(models)
.default(0)
.interact()?;
state.model = Some(match model_sel {
0 => models[0].to_owned(),
_ => Input::new().with_prompt("Model name").interact_text()?,
});
if !use_age {
let raw = Password::new()
.with_prompt("GonkaGate API key (starts with gp-...)")
.interact()?;
state.api_key = Some(raw);
}
}
6 => {
step_gonka_native(state, use_age)?;
}
7 => {
step_cocoon(state, use_age)?;
}
_ => unreachable!(),
}
Ok(())
}
fn step_gonka_native(state: &mut WizardState, use_age: bool) -> anyhow::Result<()> {
if !use_age {
anyhow::bail!(
"Gonka native provider requires the age vault backend for secure key storage.\n\
Please re-run the wizard and select the age vault backend first."
);
}
state.provider = Some(ProviderKind::Gonka);
let hex_key = pick_hex_key()?;
if hex_key.len() != 64 || !hex_key.chars().all(|c| c.is_ascii_hexdigit()) {
anyhow::bail!("Private key must be exactly 64 lowercase hex characters");
}
#[cfg(feature = "gonka")]
let derived_address = {
use zeph_llm::gonka::RequestSigner;
let signer = RequestSigner::from_hex(&hex_key, "gonka")
.map_err(|e| anyhow::anyhow!("invalid private key: {e}"))?;
signer.address().to_owned()
};
#[cfg(not(feature = "gonka"))]
let derived_address = String::from("<gonka feature not compiled in>");
println!("\n Derived address: {derived_address}");
let nodes = configure_gonka_nodes()?;
state.model = Some(
Input::new()
.with_prompt("Model name")
.default("gpt-4o".into())
.interact_text()?,
);
state.gonka_private_key = Some(hex_key);
state.gonka_address = Some(derived_address);
state.gonka_nodes = nodes;
Ok(())
}
fn probe_cocoon_reachable(url: &str) -> bool {
let Ok(parsed) = url::Url::parse(url) else {
return false;
};
let host = parsed.host_str().unwrap_or("localhost");
if host != "localhost" && host != "127.0.0.1" && host != "::1" {
return false;
}
let port = parsed.port().unwrap_or(10000);
let addr_str = format!("{host}:{port}");
let socket_addr = match std::net::ToSocketAddrs::to_socket_addrs(addr_str.as_str()) {
Ok(mut addrs) => match addrs.next() {
Some(a) => a,
None => return false,
},
Err(_) => return false,
};
std::net::TcpStream::connect_timeout(&socket_addr, std::time::Duration::from_secs(3)).is_ok()
}
fn step_cocoon(state: &mut WizardState, use_age: bool) -> anyhow::Result<()> {
state.provider = Some(ProviderKind::Cocoon);
let sidecar_url: String = Input::new()
.with_prompt("Cocoon sidecar URL")
.default("http://localhost:10000".into())
.validate_with(|input: &String| -> Result<(), String> {
match url::Url::parse(input) {
Ok(u) if u.scheme() == "http" || u.scheme() == "https" => Ok(()),
Ok(u) => Err(format!(
"scheme must be http or https, got '{}'",
u.scheme()
)),
Err(e) => Err(format!("invalid URL: {e}")),
}
})
.interact_text()?;
if url::Url::parse(&sidecar_url).is_ok_and(|u| u.port().is_none()) {
println!(" Note: no port specified; Cocoon sidecar typically runs on port 10000");
}
if probe_cocoon_reachable(&sidecar_url) {
println!(" Sidecar at {sidecar_url} is reachable.");
} else {
println!(
" Warning: sidecar at {sidecar_url} is not reachable. \
You can still configure the provider and start the sidecar later."
);
}
state.cocoon_client_url = Some(sidecar_url);
let model: String = Input::new()
.with_prompt("Default model")
.default("Qwen/Qwen3-0.6B".into())
.validate_with(|input: &String| -> Result<(), String> {
if input.trim().is_empty() {
Err("model name must not be empty".into())
} else {
Ok(())
}
})
.interact_text()?;
state.model = Some(model);
let wants_access_hash = use_age
&& Confirm::new()
.with_prompt("Do you have an access hash for this Cocoon network?")
.default(false)
.interact()?;
if wants_access_hash {
println!(
" Access hash will not be stored in config. After setup, run:\n \
zeph vault set ZEPH_COCOON_ACCESS_HASH <your-hash>\n \
Then set cocoon_access_hash = \"\" in config to enable vault lookup."
);
}
state.cocoon_wants_access_hash = wants_access_hash;
state.cocoon_show_balance = Confirm::new()
.with_prompt("Show Cocoon TON balance in the TUI status bar?")
.default(true)
.interact()?;
Ok(())
}
fn pick_hex_key() -> anyhow::Result<Zeroizing<String>> {
let inferenced_available = std::process::Command::new("which")
.arg("inferenced")
.output()
.is_ok_and(|o| o.status.success());
if !inferenced_available {
println!(
"\n inferenced CLI not found. Download from:\n \
https://github.com/gonka-ai/gonka/releases\n"
);
println!("Alternatively, you can paste a raw hex private key (64 hex characters).");
}
if inferenced_available {
let key_names = get_inferenced_keys()?;
let key_name = if key_names.is_empty() {
let name: String = Input::new()
.with_prompt("New key name")
.default("zeph".into())
.interact_text()?;
create_inferenced_key(&name)?;
name
} else {
let create_new = Confirm::new()
.with_prompt("Create a new key?")
.default(false)
.interact()?;
if create_new {
let name: String = Input::new()
.with_prompt("New key name")
.default("zeph".into())
.interact_text()?;
create_inferenced_key(&name)?;
name
} else {
let items: Vec<&str> = key_names.iter().map(String::as_str).collect();
let idx = Select::new()
.with_prompt("Select existing key")
.items(&items)
.default(0)
.interact()?;
key_names[idx].clone()
}
};
export_inferenced_key_hex(&key_name).map(Zeroizing::new)
} else {
let raw = Password::new()
.with_prompt("Private key hex (64 hex chars, input hidden)")
.interact()?;
Ok(Zeroizing::new(raw.trim().to_owned()))
}
}
fn configure_gonka_nodes() -> anyhow::Result<Vec<GonkaNode>> {
println!("\nConfigure Gonka nodes (press Enter to use default seed nodes):");
let default_seeds = vec![
(
"https://node1.gonka.ai".to_owned(),
"gonka1node1placeholder000000000000000000000000".to_owned(),
),
(
"https://node2.gonka.ai".to_owned(),
"gonka1node2placeholder000000000000000000000000".to_owned(),
),
(
"https://node3.gonka.ai".to_owned(),
"gonka1node3placeholder000000000000000000000000".to_owned(),
),
];
let use_defaults = Confirm::new()
.with_prompt("Use default seed nodes?")
.default(true)
.interact()?;
if use_defaults {
return Ok(default_seeds
.into_iter()
.map(|(url, address)| GonkaNode {
url,
address,
name: None,
})
.collect());
}
let mut nodes = Vec::new();
loop {
let url: String = Input::new()
.with_prompt("Node URL (leave empty to finish)")
.allow_empty(true)
.interact_text()?;
if url.is_empty() {
break;
}
let address: String = Input::new()
.with_prompt("Node on-chain address (bech32)")
.interact_text()?;
nodes.push(GonkaNode {
url,
address,
name: None,
});
}
if nodes.is_empty() {
anyhow::bail!("At least one Gonka node is required");
}
Ok(nodes)
}
fn get_inferenced_keys() -> anyhow::Result<Vec<String>> {
let output = std::process::Command::new("inferenced")
.args(["keys", "list"])
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let names: Vec<String> = stdout
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| l.trim().to_owned())
.collect();
Ok(names)
}
fn create_inferenced_key(name: &str) -> anyhow::Result<()> {
let status = std::process::Command::new("inferenced")
.args(["keys", "add", name])
.status()?;
if !status.success() {
anyhow::bail!("inferenced keys add failed");
}
Ok(())
}
fn export_inferenced_key_hex(name: &str) -> anyhow::Result<String> {
let output = std::process::Command::new("inferenced")
.args(["keys", "export", name, "--unarmored-hex", "--unsafe"])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("inferenced keys export failed: {stderr}");
}
let hex = String::from_utf8_lossy(&output.stdout)
.trim()
.to_lowercase();
Ok(hex)
}
#[cfg(test)]
mod tests {
use super::*;
use zeph_core::config::ProviderKind;
fn state_with(provider: ProviderKind, base_url: Option<&str>) -> WizardState {
WizardState {
provider: Some(provider),
base_url: base_url.map(ToOwned::to_owned),
..WizardState::default()
}
}
#[test]
fn resolve_probe_url_ollama_default() {
let s = state_with(ProviderKind::Ollama, None);
assert_eq!(
resolve_probe_url(&s).unwrap(),
"http://localhost:11434/api/tags"
);
}
#[test]
fn resolve_probe_url_ollama_custom_base_url() {
let s = state_with(ProviderKind::Ollama, Some("http://192.168.1.10:11434"));
assert_eq!(
resolve_probe_url(&s).unwrap(),
"http://192.168.1.10:11434/api/tags"
);
}
#[test]
fn resolve_probe_url_openai_default_no_double_v1() {
let s = state_with(ProviderKind::OpenAi, None);
let url = resolve_probe_url(&s).unwrap();
assert_eq!(url, "https://api.openai.com/v1/models");
assert!(!url.contains("v1/v1"), "double /v1 detected: {url}");
}
#[test]
fn resolve_probe_url_openai_base_with_v1_no_double() {
let s = state_with(ProviderKind::OpenAi, Some("https://api.openai.com/v1"));
let url = resolve_probe_url(&s).unwrap();
assert_eq!(url, "https://api.openai.com/v1/models");
assert!(!url.contains("v1/v1"), "double /v1 detected: {url}");
}
#[test]
fn resolve_probe_url_openai_base_without_v1() {
let s = state_with(ProviderKind::OpenAi, Some("https://api.openai.com"));
let url = resolve_probe_url(&s).unwrap();
assert_eq!(url, "https://api.openai.com/v1/models");
}
#[test]
fn resolve_probe_url_claude_default() {
let s = state_with(ProviderKind::Claude, None);
assert_eq!(
resolve_probe_url(&s).unwrap(),
"https://api.anthropic.com/v1/models"
);
}
#[test]
fn resolve_probe_url_none_for_candle() {
let s = state_with(ProviderKind::Candle, None);
assert!(resolve_probe_url(&s).is_none());
}
#[test]
fn resolve_probe_url_none_when_provider_is_none() {
let s = WizardState::default();
assert!(resolve_probe_url(&s).is_none());
}
#[test]
fn probe_url_reachable_returns_false_for_invalid_url() {
assert!(!probe_url_reachable("not-a-url"));
}
#[test]
fn probe_url_reachable_returns_false_for_closed_port() {
assert!(!probe_url_reachable("http://127.0.0.1:19999"));
}
}