use newt_core::{BackendConfig, BackendKind, Config, DgxConfig, DgxNode, EndpointKind, Tier};
use std::io::{self, Write};
use std::path::Path;
pub trait Console {
fn ask(&mut self, prompt: &str) -> io::Result<String>;
fn say(&mut self, line: &str);
}
struct StdinConsole;
impl Console for StdinConsole {
fn ask(&mut self, prompt: &str) -> io::Result<String> {
print!("{prompt}");
io::stdout().flush()?;
let mut buf = String::new();
let n = io::stdin().read_line(&mut buf)?;
if n == 0 {
return Ok(String::new());
}
Ok(buf.trim().to_string())
}
fn say(&mut self, line: &str) {
println!("{line}");
}
}
pub fn run(_color: bool) -> anyhow::Result<()> {
let config_path =
Config::user_config_path().unwrap_or_else(|| std::path::PathBuf::from("newt.toml"));
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(4))
.build()
.unwrap_or_default();
let mut console = StdinConsole;
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(run_with(&mut console, &client, &config_path))
})
}
async fn run_with(
console: &mut dyn Console,
client: &reqwest::Client,
config_path: &Path,
) -> anyhow::Result<()> {
console.say(&format!(
"newt v{} — interactive setup",
env!("CARGO_PKG_VERSION")
));
if config_path.exists() {
let ans = console.ask(&format!(
"A config already exists at {}. Overwrite? [y/N] ",
config_path.display()
))?;
if !is_yes(&ans, false) {
console.say("Keeping the existing config. Nothing written.");
return Ok(());
}
}
let cfg = match choose_backend(console)? {
BackendChoice::Ollama => configure_ollama(console, client).await?,
BackendChoice::Dgx => configure_dgx(console, client).await?,
};
let preview = toml::to_string_pretty(&cfg)
.unwrap_or_else(|e| format!("# (could not render preview: {e})"));
console.say("\nResulting config:\n");
console.say(&preview);
let ans = console.ask(&format!("Write to {}? [Y/n] ", config_path.display()))?;
if !is_yes(&ans, true) {
console.say("Aborted. Nothing written.");
return Ok(());
}
cfg.save(config_path)?;
console.say(&format!("Wrote {}.", config_path.display()));
console.say("Edit that file (or re-run `newt setup`) to change anything.");
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BackendChoice {
Ollama,
Dgx,
}
fn choose_backend(console: &mut dyn Console) -> anyhow::Result<BackendChoice> {
console.say("\nWhere does your model run?");
console.say(" 1) Ollama (local, or a plain self-hosted Ollama host)");
console.say(" 2) DGX (remote NVIDIA endpoint: Ollama or vLLM)");
let ans = console.ask("Choose [1]: ")?;
match parse_choice(&ans, 2).unwrap_or(1) {
2 => Ok(BackendChoice::Dgx),
_ => Ok(BackendChoice::Ollama),
}
}
async fn configure_ollama(
console: &mut dyn Console,
client: &reqwest::Client,
) -> anyhow::Result<Config> {
let default_url = "http://127.0.0.1:11434";
let raw = console.ask(&format!("Ollama host [{default_url}]: "))?;
let url = normalize_url(
if raw.is_empty() { default_url } else { &raw },
"http",
11434,
);
let model = pick_model(console, client, &url, Protocol::Ollama).await?;
Ok(build_ollama_config(
Config::default(),
"default",
EndpointKind::Ollama,
&url,
&model,
))
}
async fn configure_dgx(
console: &mut dyn Console,
client: &reqwest::Client,
) -> anyhow::Result<Config> {
let host = loop {
let raw = console.ask("DGX host (e.g. REDACTED-HOST or http://REDACTED-IP:8000): ")?;
if !raw.is_empty() {
break raw;
}
console.say(" A host is required for a DGX endpoint.");
};
console.say("\nEndpoint flavour:");
console.say(" 1) ollama (direct DGX Ollama, /api/chat)");
console.say(" 2) ollama_lb (round-robin Ollama LB, /api/chat)");
console.say(" 3) in_cluster (in-cluster Ollama proxy, /api/chat)");
console.say(" 4) vllm (vLLM OpenAI-compatible, /v1/chat/completions)");
let ans = console.ask("Choose [1]: ")?;
let kind = match parse_choice(&ans, 4).unwrap_or(1) {
2 => EndpointKind::OllamaLb,
3 => EndpointKind::InCluster,
4 => EndpointKind::Vllm,
_ => EndpointKind::Ollama,
};
let (default_port, protocol) = match kind {
EndpointKind::Vllm => (8000, Protocol::OpenAi),
_ => (11434, Protocol::Ollama),
};
let url = normalize_url(&host, "http", default_port);
let model = pick_model(console, client, &url, protocol).await?;
let cfg = match kind {
EndpointKind::Vllm => {
let key_env = console.ask("API-key env var (optional, e.g. DGX_API_KEY) [none]: ")?;
let key_env = if key_env.is_empty() {
None
} else {
Some(key_env)
};
build_openai_config(Config::default(), "dgx-vllm", &url, &model, key_env)
}
_ => build_ollama_config(Config::default(), "dgx", kind, &url, &model),
};
Ok(cfg)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Protocol {
Ollama,
OpenAi,
}
async fn pick_model(
console: &mut dyn Console,
client: &reqwest::Client,
url: &str,
protocol: Protocol,
) -> anyhow::Result<String> {
console.say(&format!("Probing {url} for installed models…"));
let models = match protocol {
Protocol::Ollama => fetch_ollama_models(client, url).await,
Protocol::OpenAi => fetch_openai_models(client, url).await,
};
let models = match models {
Ok(m) if !m.is_empty() => m,
Ok(_) => {
console.say(" Endpoint answered but listed no models.");
return ask_model_name(console);
}
Err(e) => {
console.say(&format!(" Could not reach the endpoint ({e})."));
return ask_model_name(console);
}
};
console.say("\nAvailable models:");
for (i, m) in models.iter().enumerate() {
console.say(&format!(" {}) {m}", i + 1));
}
let ans = console.ask("Choose [1]: ")?;
let idx = parse_choice(&ans, models.len()).map(|n| n - 1).unwrap_or(0);
Ok(models[idx].clone())
}
fn ask_model_name(console: &mut dyn Console) -> anyhow::Result<String> {
let default = "llama3.1:8b";
let raw = console.ask(&format!("Model name [{default}]: "))?;
Ok(if raw.is_empty() {
default.to_string()
} else {
raw
})
}
async fn fetch_ollama_models(client: &reqwest::Client, url: &str) -> anyhow::Result<Vec<String>> {
let tags_url = format!("{}/api/tags", url.trim_end_matches('/'));
let resp = client.get(&tags_url).send().await?;
if !resp.status().is_success() {
anyhow::bail!("HTTP {}", resp.status());
}
let json: serde_json::Value = resp.json().await?;
Ok(json["models"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|m| m["name"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default())
}
async fn fetch_openai_models(client: &reqwest::Client, url: &str) -> anyhow::Result<Vec<String>> {
let models_url = format!("{}/v1/models", url.trim_end_matches('/'));
let resp = client.get(&models_url).send().await?;
if !resp.status().is_success() {
anyhow::bail!("HTTP {}", resp.status());
}
let json: serde_json::Value = resp.json().await?;
Ok(json["data"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|m| m["id"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default())
}
fn parse_choice(input: &str, max: usize) -> Option<usize> {
let n: usize = input.trim().parse().ok()?;
if (1..=max).contains(&n) {
Some(n)
} else {
None
}
}
fn is_yes(input: &str, default: bool) -> bool {
match input.trim().to_ascii_lowercase().as_str() {
"" => default,
"y" | "yes" => true,
_ => false,
}
}
fn normalize_url(raw: &str, default_scheme: &str, default_port: u16) -> String {
let raw = raw.trim().trim_end_matches('/');
if raw.contains("://") {
return raw.to_string();
}
if raw.contains(':') {
return format!("{default_scheme}://{raw}");
}
format!("{default_scheme}://{raw}:{default_port}")
}
fn build_ollama_config(
mut base: Config,
node_name: &str,
kind: EndpointKind,
url: &str,
model: &str,
) -> Config {
let mut node = DgxNode {
name: node_name.to_string(),
ollama: Some(url.to_string()),
..Default::default()
};
match kind {
EndpointKind::OllamaLb => node.ollama_lb = Some(url.to_string()),
EndpointKind::InCluster => node.in_cluster = Some(url.to_string()),
_ => {}
}
base.dgx = Some(DgxConfig {
active_node: Some(node_name.to_string()),
active_endpoint: kind,
active_model: Some(model.to_string()),
nodes: vec![node],
..Default::default()
});
base
}
fn build_openai_config(
mut base: Config,
name: &str,
endpoint: &str,
model: &str,
api_key_env: Option<String>,
) -> Config {
base.backends = vec![BackendConfig {
name: name.to_string(),
endpoint: endpoint.to_string(),
model: model.to_string(),
model_path: None,
tiers: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
kind: BackendKind::Openai,
api: Default::default(),
api_key_file: None,
api_key_env,
}];
base
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
struct ScriptedConsole {
answers: VecDeque<String>,
output: Vec<String>,
}
impl ScriptedConsole {
fn new(answers: &[&str]) -> Self {
Self {
answers: answers.iter().map(|s| s.to_string()).collect(),
output: Vec::new(),
}
}
fn transcript(&self) -> String {
self.output.join("\n")
}
}
impl Console for ScriptedConsole {
fn ask(&mut self, _prompt: &str) -> io::Result<String> {
Ok(self.answers.pop_front().unwrap_or_default())
}
fn say(&mut self, line: &str) {
self.output.push(line.to_string());
}
}
#[test]
fn parse_choice_valid_and_out_of_range() {
assert_eq!(parse_choice("1", 2), Some(1));
assert_eq!(parse_choice("2", 2), Some(2));
assert_eq!(parse_choice("3", 2), None); assert_eq!(parse_choice("", 2), None); assert_eq!(parse_choice("abc", 2), None); assert_eq!(parse_choice("0", 2), None); }
#[test]
fn is_yes_respects_default() {
assert!(is_yes("", true));
assert!(!is_yes("", false));
assert!(is_yes("y", false));
assert!(is_yes("YES", false));
assert!(!is_yes("n", true));
assert!(!is_yes("nope", true));
}
#[test]
fn normalize_url_bare_host_gets_scheme_and_port() {
assert_eq!(
normalize_url("REDACTED-HOST", "http", 11434),
"http://REDACTED-HOST:11434"
);
}
#[test]
fn normalize_url_keeps_explicit_port_and_full_url() {
assert_eq!(
normalize_url("REDACTED-HOST:8000", "http", 11434),
"http://REDACTED-HOST:8000"
);
assert_eq!(
normalize_url("https://REDACTED-HOST/", "http", 11434),
"https://REDACTED-HOST"
);
}
#[test]
fn build_ollama_config_writes_resolvable_node() {
let cfg = build_ollama_config(
Config::default(),
"default",
EndpointKind::Ollama,
"http://127.0.0.1:11434",
"qwen2.5-coder:7b",
);
let dgx = cfg.dgx.unwrap();
assert_eq!(dgx.active_model.as_deref(), Some("qwen2.5-coder:7b"));
assert_eq!(
dgx.nodes[0].ollama.as_deref(),
Some("http://127.0.0.1:11434")
);
}
#[test]
fn build_ollama_config_mirrors_lb_url_into_ollama_field() {
let cfg = build_ollama_config(
Config::default(),
"dgx",
EndpointKind::OllamaLb,
"https://REDACTED-HOST",
"llama3.1:70b",
);
let node = &cfg.dgx.unwrap().nodes[0];
assert_eq!(node.ollama_lb.as_deref(), Some("https://REDACTED-HOST"));
assert_eq!(node.ollama.as_deref(), Some("https://REDACTED-HOST"));
}
#[test]
fn build_openai_config_sets_openai_kind() {
let cfg = build_openai_config(
Config::default(),
"dgx-vllm",
"http://REDACTED-HOST:8000",
"llama3.1:8b",
Some("DGX_API_KEY".to_string()),
);
let b = &cfg.backends[0];
assert_eq!(b.kind, BackendKind::Openai);
assert_eq!(b.endpoint, "http://REDACTED-HOST:8000");
assert_eq!(b.api_key_env.as_deref(), Some("DGX_API_KEY"));
}
#[tokio::test]
async fn fetch_ollama_models_parses_tags() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"models": [{"name": "llama3.1:8b"}, {"name": "qwen2.5-coder:7b"}]
})))
.mount(&server)
.await;
let client = reqwest::Client::new();
let models = fetch_ollama_models(&client, &server.uri()).await.unwrap();
assert_eq!(models, vec!["llama3.1:8b", "qwen2.5-coder:7b"]);
}
#[tokio::test]
async fn fetch_openai_models_parses_data() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id": "meta/llama-3.1-8b-instruct"}]
})))
.mount(&server)
.await;
let client = reqwest::Client::new();
let models = fetch_openai_models(&client, &server.uri()).await.unwrap();
assert_eq!(models, vec!["meta/llama-3.1-8b-instruct"]);
}
#[tokio::test]
async fn fetch_ollama_models_errors_on_500() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let client = reqwest::Client::new();
assert!(fetch_ollama_models(&client, &server.uri()).await.is_err());
}
#[serial_test::serial(real_fs)]
#[tokio::test]
async fn ollama_flow_writes_config() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"models": [{"name": "llama3.1:8b"}, {"name": "qwen2.5-coder:7b"}]
})))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let client = reqwest::Client::new();
let mut console = ScriptedConsole::new(&["1", &server.uri(), "2", "y"]);
run_with(&mut console, &client, &path).await.unwrap();
let cfg = Config::load(&path).unwrap();
let dgx = cfg.dgx.unwrap();
assert_eq!(dgx.active_model.as_deref(), Some("qwen2.5-coder:7b"));
assert_eq!(dgx.nodes[0].ollama.as_deref(), Some(server.uri().as_str()));
}
#[serial_test::serial(real_fs)]
#[tokio::test]
async fn dgx_vllm_flow_writes_openai_backend() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id": "meta/llama-3.1-8b-instruct"}]
})))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let client = reqwest::Client::new();
let mut console = ScriptedConsole::new(&["2", &server.uri(), "4", "1", "", "y"]);
run_with(&mut console, &client, &path).await.unwrap();
let cfg = Config::load(&path).unwrap();
let b = &cfg.backends[0];
assert_eq!(b.kind, BackendKind::Openai);
assert_eq!(b.model, "meta/llama-3.1-8b-instruct");
assert_eq!(b.endpoint, server.uri());
}
#[serial_test::serial(real_fs)]
#[tokio::test]
async fn manual_model_when_endpoint_unreachable() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(200))
.build()
.unwrap();
let mut console = ScriptedConsole::new(&[
"1",
"http://127.0.0.1:1", "phi3:mini",
"y",
]);
run_with(&mut console, &client, &path).await.unwrap();
let cfg = Config::load(&path).unwrap();
assert_eq!(cfg.dgx.unwrap().active_model.as_deref(), Some("phi3:mini"));
}
#[serial_test::serial(real_fs)]
#[tokio::test]
async fn decline_overwrite_keeps_existing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "# sentinel\n").unwrap();
let client = reqwest::Client::new();
let mut console = ScriptedConsole::new(&["n"]);
run_with(&mut console, &client, &path).await.unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "# sentinel\n");
}
#[serial_test::serial(real_fs)]
#[tokio::test]
async fn decline_final_write_leaves_no_file() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"models": [{"name": "llama3.1:8b"}]
})))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let client = reqwest::Client::new();
let mut console = ScriptedConsole::new(&["1", &server.uri(), "1", "n"]);
run_with(&mut console, &client, &path).await.unwrap();
assert!(!path.exists());
}
#[serial_test::serial(real_fs)]
#[tokio::test]
async fn dgx_requires_a_host() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"models": [{"name": "qwen2.5-coder:32b"}]
})))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let client = reqwest::Client::new();
let mut console = ScriptedConsole::new(&["2", "", &server.uri(), "1", "1", "y"]);
run_with(&mut console, &client, &path).await.unwrap();
let cfg = Config::load(&path).unwrap();
assert_eq!(
cfg.dgx.unwrap().active_model.as_deref(),
Some("qwen2.5-coder:32b")
);
assert!(console.transcript().contains("host is required"));
}
}