mod commit;
pub(crate) mod credentials;
mod operator;
mod tool_trust;
use commit::*;
#[cfg(feature = "rich-tui")]
pub(crate) use commit::{
inline_backend_names, panel_backend_file_names, persist_panel_backend, remove_panel_backend,
};
use newt_core::agent_identity::Secret;
use newt_core::backend_probe::{EndpointProbeResult, GenerationCheck};
use newt_core::config::Discovery;
use newt_core::interaction_form::{self, NO, YES};
use newt_core::provider_preset::{
self, list_models_for_preset, preset_support,
validate_authenticated_url as validate_authenticated_target, PresetSupport, ProviderPreset,
};
use newt_core::{BackendConfig, BackendKind, Config, EndpointKind, OpenAiApi, Tier};
use newt_interaction::InteractionDefinition;
use operator::Operator;
use std::io::{self, IsTerminal};
use std::path::{Path, PathBuf};
const SETUP_HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
const GENERATION_CHECK_ATTEMPTS: usize = 3;
fn setup_http_client() -> anyhow::Result<reqwest::Client> {
Ok(reqwest::Client::builder()
.timeout(SETUP_HTTP_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.build()?)
}
pub fn run(_color: bool) -> anyhow::Result<()> {
wizard_entry(&Operator::terminal(), Flow::Setup)
}
pub(crate) fn run_first_run(_color: bool) -> anyhow::Result<()> {
wizard_entry(&Operator::terminal(), Flow::FirstRun)
}
fn wizard_entry(op: &Operator<'_>, flow: Flow) -> anyhow::Result<()> {
tool_trust::check(op)?;
let config_path =
Config::user_config_path().unwrap_or_else(|| std::path::PathBuf::from("newt.toml"));
let client = setup_http_client()?;
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(run_with_flow(op, &client, &config_path, flow))
})
}
pub async fn run_target(
target: &str,
token_env: Option<&str>,
token_file: Option<&Path>,
model: Option<&str>,
yes: bool,
explicit_config_path: Option<&Path>,
) -> anyhow::Result<()> {
let has_env_config = std::env::var_os("NEWT_CONFIG").is_some_and(|value| !value.is_empty());
if explicit_config_path.is_some() || has_env_config {
anyhow::bail!(
"targeted setup does not support --config or NEWT_CONFIG because backend drop-ins \
need a config root; use --config-dir instead"
);
}
if !yes && !io::stdin().is_terminal() {
anyhow::bail!("setup needs confirmation on a terminal; pass --yes for non-interactive use");
}
let config_path = explicit_config_path
.map(Path::to_path_buf)
.unwrap_or_else(|| {
Config::user_config_path().unwrap_or_else(|| PathBuf::from("newt.toml"))
});
let discovery = if config_path.is_file() {
crate::migration_notices::read(|report| Config::load(&config_path, report))?.discovery
} else {
Discovery::default()
};
let client = setup_http_client()?;
let op = Operator::terminal();
tool_trust::check(&op)?;
run_target_with(
&op,
&client,
&config_path,
TargetSetupRequest {
target,
token_env,
token_file,
model,
yes,
},
&discovery,
)
.await
}
#[derive(Clone, Copy)]
struct TargetSetupRequest<'a> {
target: &'a str,
token_env: Option<&'a str>,
token_file: Option<&'a Path>,
model: Option<&'a str>,
yes: bool,
}
async fn run_target_with(
op: &Operator<'_>,
client: &reqwest::Client,
config_path: &Path,
request: TargetSetupRequest<'_>,
discovery: &Discovery,
) -> anyhow::Result<()> {
run_target_with_persist(
op,
client,
config_path,
request,
discovery,
|config_path, hits, token_env, token_file| {
persist_verified_setup(config_path, hits, token_env, token_file)
},
)
.await
}
async fn run_target_with_persist(
op: &Operator<'_>,
client: &reqwest::Client,
config_path: &Path,
request: TargetSetupRequest<'_>,
discovery: &Discovery,
persist: impl FnOnce(
&Path,
&[VerifiedTargetHit],
Option<&str>,
Option<&Path>,
) -> anyhow::Result<Vec<PathBuf>>,
) -> anyhow::Result<()> {
let TargetSetupRequest {
target,
token_env,
token_file,
model,
yes,
} = request;
if token_env.is_some() || token_file.is_some() {
validate_authenticated_target(target)?;
}
let candidates = candidate_endpoints(target, discovery)?;
let token_file = token_file.map(std::fs::canonicalize).transpose()?;
let api_key = resolve_setup_token(token_env, token_file.as_deref())?;
op.say(&format!(
"Probing {} candidate endpoint{} for Ollama or OpenAI-compatible APIs...",
candidates.len(),
if candidates.len() == 1 { "" } else { "s" }
));
let (hits, mut failures) =
probe_candidates_concurrently(client, &candidates, api_key.as_deref()).await?;
if hits.is_empty() {
for failure in failures {
op.say(&format!(" {failure}"));
}
anyhow::bail!(
"no supported inference API found for `{target}`; tried {}",
candidates.join(", ")
);
}
let (hits, generation_failures) =
verify_target_hits(op, client, hits, model, api_key.as_deref()).await;
failures.extend(generation_failures);
if hits.is_empty() {
for failure in failures {
op.say(&format!(" {failure}"));
}
anyhow::bail!("no inference backend passed a minimal generation check for `{target}`");
}
if !failures.is_empty() {
op.say(&format!(
"Skipped {} candidate endpoint{}:",
failures.len(),
if failures.len() == 1 { "" } else { "s" }
));
for failure in failures {
op.say(&format!(" {failure}"));
}
}
op.say(&format!(
"Detected {} inference backend{}:",
hits.len(),
if hits.len() == 1 { "" } else { "s" }
));
for hit in &hits {
let backend = backend_from_verified_probe(hit, token_env, token_file.as_deref())?;
op.say(&format!(
" {} ({:?}, {}, {} model{})",
backend.name,
backend.kind,
backend.endpoint,
hit.probe.models.len(),
if hit.probe.models.len() == 1 { "" } else { "s" }
));
}
if !yes
&& !decide(
op,
&interaction_form::confirm(
format!("Write backend files and update {}?", config_path.display()),
"",
"yes, write them",
"no, stop here",
),
None,
)?
{
op.say("Aborted. Nothing written.");
return Ok(());
}
let written = persist(config_path, &hits, token_env, token_file.as_deref())?;
for path in &written {
op.say(&format!("Wrote {}.", path.display()));
}
op.say(&format!(
"Configuration ready at {}.",
config_path.display()
));
Ok(())
}
#[derive(Debug, Clone)]
struct VerifiedTargetHit {
probe: EndpointProbeResult,
api: Option<OpenAiApi>,
}
async fn verify_target_hits(
op: &Operator<'_>,
client: &reqwest::Client,
hits: Vec<EndpointProbeResult>,
requested_model: Option<&str>,
api_key: Option<&str>,
) -> (Vec<VerifiedTargetHit>, Vec<String>) {
let supplied_key = api_key.is_some_and(|key| !key.trim().is_empty());
let mut verified = Vec::with_capacity(hits.len());
let mut failures = Vec::new();
for mut hit in hits {
let Some(model) = requested_model
.map(str::to_string)
.or_else(|| hit.warm.first().or(hit.models.first()).cloned())
else {
failures.push(format!(
"{} listed no model to generation-test",
hit.endpoint
));
continue;
};
op.say(&format!(
"Testing a minimal generation at {} with {model}…",
hit.endpoint
));
match newt_core::backend_probe::verify_generation(
client,
hit.kind,
None,
&hit.endpoint,
&model,
api_key,
)
.await
{
GenerationCheck::Accepted(api) => {
op.say(" ✓ generation accepted");
hit.models = vec![model.clone()];
hit.warm = vec![model];
verified.push(VerifiedTargetHit { probe: hit, api });
}
GenerationCheck::Rejected(code) if supplied_key => failures.push(format!(
"{} rejected the token or model authorization for {model} (HTTP {code}); check --token-file/--token-env and model access",
hit.endpoint
)),
GenerationCheck::Rejected(code) => failures.push(format!(
"{} requires authentication or model authorization for {model} (HTTP {code}); supply --token-file or --token-env",
hit.endpoint
)),
GenerationCheck::Unverified(reason) => failures.push(format!(
"{} could not generate with {model}: {reason}",
hit.endpoint
)),
}
}
(verified, failures)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Flow {
Setup,
FirstRun,
}
struct PendingWizardToken {
token: Secret,
passphrase: Option<newt_core::secrets::SecretString>,
path: PathBuf,
reference: credentials::SealedSecret,
}
fn persist_interactive_backend(
op: &Operator<'_>,
config_path: &Path,
cfg: &Config,
backend: &BackendConfig,
pending_token: Option<&PendingWizardToken>,
) -> anyhow::Result<PathBuf> {
persist_interactive_backend_with(
op,
config_path,
cfg,
backend,
pending_token,
|staged, destination| destination.durable_replace(staged),
|staged, destination| {
destination
.durable_replace(staged)
.map_err(anyhow::Error::from)
},
)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SetupCommitStep {
PersistVersionedToken,
PublishBackendTuple,
SelectBackend,
}
const SETUP_COMMIT_STEPS: [SetupCommitStep; 3] = [
SetupCommitStep::PersistVersionedToken,
SetupCommitStep::PublishBackendTuple,
SetupCommitStep::SelectBackend,
];
fn run_setup_commit(
mut apply: impl FnMut(SetupCommitStep) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
for step in SETUP_COMMIT_STEPS {
apply(step)?;
}
Ok(())
}
fn persist_interactive_backend_with(
op: &Operator<'_>,
config_path: &Path,
cfg: &Config,
backend: &BackendConfig,
pending_token: Option<&PendingWizardToken>,
publish_backend: impl FnOnce(
&Path,
&newt_core::atomic_fs::ResolvedPath,
) -> Result<(), newt_core::atomic_fs::DurableReplaceError>,
commit_config: impl FnOnce(&Path, &newt_core::atomic_fs::ResolvedPath) -> anyhow::Result<()>,
) -> anyhow::Result<PathBuf> {
if let Some(parent) = config_path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
std::fs::create_dir_all(parent)?;
}
let setup_lock = acquire_setup_lock(config_path)?;
let logical_backend_path = config_path
.with_file_name("backends")
.join(format!("{}.toml", backend.name));
let backend_destination = setup_config_destination(&logical_backend_path)?;
let config_destination = &setup_lock.destination;
let token_destination = pending_token
.map(|pending| setup_config_destination(&pending.path))
.transpose()?;
let token_reference = pending_token.map(|pending| pending.reference.handle().to_string());
if let Some(reference) = token_reference.as_deref() {
if backend.api_key_file.as_deref() != Some(reference) {
anyhow::bail!("internal token-reference mismatch for {}", backend.name);
}
}
let backend_body = toml::to_string(backend)?;
let config_body = toml::to_string_pretty(cfg)?;
let mut backend_published = false;
let result = (|| {
let mut guard = SetupCommitGuard::default();
let backend_permissions = setup_file_permissions(backend_destination.as_path())?;
let backend_stage = guard.stage(
&backend_destination,
backend_body.as_bytes(),
backend_permissions.as_ref(),
)?;
let config_permissions = setup_file_permissions(config_destination.as_path())?;
let config_stage = guard.stage(
config_destination,
config_body.as_bytes(),
config_permissions.as_ref(),
)?;
let mut publish_backend = Some(publish_backend);
let mut commit_config = Some(commit_config);
run_setup_commit(|step| match step {
SetupCommitStep::PersistVersionedToken => {
if let (Some(pending), Some(destination)) =
(pending_token, token_destination.as_ref())
{
match std::fs::symlink_metadata(destination.as_path()) {
Ok(_) => anyhow::bail!(
"allocated credential path {} already exists; retry setup",
destination.as_path().display()
),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
newt_core::secrets::store_token_at_resolved(
destination,
pending.token.expose(),
pending.passphrase.as_ref(),
)
.map_err(|error| anyhow::anyhow!(error))?;
guard.created.push(destination.as_path().to_path_buf());
let resolved = newt_core::secrets::resolve_token_file(destination.as_path())
.map_err(|error| anyhow::anyhow!(error))?;
if resolved.as_deref() != pending_token.map(|pending| pending.token.expose()) {
anyhow::bail!("stored token verification failed for {}", backend.name);
}
}
Ok(())
}
SetupCommitStep::PublishBackendTuple => {
let publication = publish_backend
.take()
.expect("setup backend publication is called exactly once")(
&backend_stage,
&backend_destination,
);
if let Err(error) = publication {
if error.committed() {
guard
.created
.push(backend_destination.as_path().to_path_buf());
guard.retain_created();
backend_published = true;
}
return Err(error.into());
}
guard
.created
.push(backend_destination.as_path().to_path_buf());
guard.retain_created();
backend_published = true;
Ok(())
}
SetupCommitStep::SelectBackend => {
commit_config
.take()
.expect("setup config commit is called exactly once")(
&config_stage,
config_destination,
)?;
guard
.created
.push(config_destination.as_path().to_path_buf());
Ok(())
}
})?;
let _ = guard.finish();
Ok(())
})();
if let Err(error) = result {
if backend_published {
anyhow::bail!(
"backend {} and its credential were published coherently, but setup could not \
finish updating {} ({error:#}); re-run setup to finish",
backend.name,
config_path.display()
);
}
return Err(error);
}
if let Some(reference) = token_reference {
op.say(&format!(" → stored encrypted at {reference}"));
}
Ok(logical_backend_path)
}
type ConfiguredBackend = (Config, BackendConfig, Option<PendingWizardToken>);
#[cfg(test)]
async fn run_with(
op: &Operator<'_>,
client: &reqwest::Client,
config_path: &Path,
) -> anyhow::Result<()> {
run_with_flow(op, client, config_path, Flow::Setup).await
}
async fn run_with_flow(
op: &Operator<'_>,
client: &reqwest::Client,
config_path: &Path,
flow: Flow,
) -> anyhow::Result<()> {
if flow == Flow::Setup {
op.say(&format!("newt v{} — interactive setup", crate::VERSION));
}
if flow == Flow::Setup
&& config_path.exists()
&& !decide(
op,
&interaction_form::confirm(
format!(
"A config already exists at {}. Overwrite?",
config_path.display()
),
"",
"yes, overwrite it",
"no, keep it",
),
Some(false),
)?
{
op.say("Keeping the existing config. Nothing written.");
return Ok(());
}
let mut written: Vec<String> = Vec::new();
let mut last_endpoint: Option<String> = None;
loop {
let (cfg, backend, pending_token) = match choose_backend(op)? {
BackendChoice::LocalOllama => configure_ollama(op, client).await?,
BackendChoice::CustomHost => configure_custom_host(op, client, config_path).await?,
BackendChoice::HostedProvider => configure_hosted(op, client, config_path).await?,
};
let preview = toml::to_string_pretty(&backend)
.unwrap_or_else(|e| format!("# (could not render preview: {e})"));
op.say(&format!("\nbackends/{}.toml:\n", backend.name));
op.say(&preview);
if !decide(
op,
&interaction_form::confirm(
format!("Write to {}?", config_path.display()),
"",
"yes, write it",
"no, skip this one",
),
None,
)? {
if written.is_empty() {
op.say("Aborted. Nothing written.");
return Ok(());
}
op.say("Skipped this one.");
} else {
let dropin = persist_interactive_backend(
op,
config_path,
&cfg,
&backend,
pending_token.as_ref(),
)?;
op.say(&format!(
"Wrote {} and {}.",
config_path.display(),
dropin.display()
));
written.push(backend.name.clone());
last_endpoint = Some(backend.endpoint.clone());
}
if !decide(
op,
&interaction_form::confirm(
"Add another backend?",
"",
"yes, add one",
"no, that is all",
),
Some(false),
)? {
break;
}
}
if written.len() > 1 {
op.say("\nWhich backend should sessions start on?");
let idx = select_row(op, &written, "backends")?;
let chosen = &written[idx];
persist_default_backend(config_path, chosen)?;
op.say(&format!(
"Default backend: {chosen} (/backends switches per session)."
));
}
op.say("Edit those files (or re-run `newt setup`) to change anything.");
offer_identity(op);
offer_sandbox_git(op);
let backend_host = last_endpoint
.as_deref()
.and_then(|e| reqwest::Url::parse(e).ok())
.and_then(|u| u.host_str().map(str::to_string));
offer_caveat_pack(op, config_path, backend_host.as_deref());
Ok(())
}
fn offer_sandbox_git(op: &Operator<'_>) {
op.say(
"Sandbox git: git the model runs never reads your ~/.gitconfig. \
Choose what it uses instead, and how newt signs its own commits \
(/settings changes these later).",
);
for field in [
crate::settings_form::Field::GitAuthor,
crate::settings_form::Field::GitConfig,
crate::settings_form::Field::GitSigning,
] {
for line in crate::settings_form::ask_and_apply(op.ask_seam(), field, "/setup") {
op.say(&format!(" {line}"));
}
}
}
fn offer_caveat_pack(op: &Operator<'_>, config_path: &Path, backend_host: Option<&str>) {
if let Err(e) = try_offer_caveat_pack(op, config_path, backend_host) {
op.say(&format!("\nStandard caveat pack: {e:#} — skipped."));
}
}
fn try_offer_caveat_pack(
op: &Operator<'_>,
config_path: &Path,
backend_host: Option<&str>,
) -> anyhow::Result<()> {
use newt_core::caveat_pack::{
drop_never_candidates, merge_candidates, narrow_candidates, propose_standard_pack,
CaveatPackDropIn, STANDARD_TEXT_TOOLS,
};
use newt_core::ocap_propose::in_policy_pairs;
use newt_core::ocap_store::{build_store, lock_approve_file, PolicyFile, Verdict, VERDICTS};
let store_dir = config_path.with_file_name("ocap");
let store_files: Vec<(Verdict, Option<String>)> = VERDICTS
.iter()
.map(|&v| {
(
v,
std::fs::read_to_string(store_dir.join(v.filename())).ok(),
)
})
.collect();
let (set, _warnings) = build_store(&store_files);
let in_policy = in_policy_pairs(&set);
let drop_in_path = config_path.with_file_name("caveat-pack.toml");
let drop_in = match std::fs::read_to_string(&drop_in_path) {
Ok(text) => match CaveatPackDropIn::parse(&text) {
Ok(d) => d,
Err(e) => {
op.say(&format!(
"\nStandard caveat pack: {e} — ignoring the drop-in."
));
CaveatPackDropIn::default()
}
},
Err(_) => CaveatPackDropIn::default(),
};
let (exec_candidates, ignored_additions) =
narrow_candidates(STANDARD_TEXT_TOOLS, &drop_in.exec);
if !ignored_additions.is_empty() {
op.say(&format!(
"\nStandard caveat pack: drop-in requested {} — a drop-in may only narrow this \
built-in offer, not add a program to it; ignored.",
ignored_additions.join(", ")
));
}
let exec_candidates = drop_never_candidates(&exec_candidates);
let net_builtin: Vec<&str> = backend_host.into_iter().collect();
let net_candidates = merge_candidates(&net_builtin, &drop_in.net);
let today = crate::probe::today_local_date();
let proposal = propose_standard_pack(
&exec_candidates,
&net_candidates,
&in_policy,
crate::ocap_high_danger_predicate(),
&today,
);
if proposal.is_empty() {
op.say("\nStandard caveat pack: the current policy already covers it.");
return Ok(());
}
op.say(
"\nStandard caveat pack: common text tools that can't run other programs, plus the \
endpoint you just configured (writes are still gated by fs_write). Signing these now \
stops the routine permission prompt for each of them (`/permissions` edits this \
later; the model never can):",
);
for e in &proposal.additions.exec {
op.say(&format!(" exec {}", e.target));
}
for e in &proposal.additions.net {
op.say(&format!(" net {}", e.host));
}
if !proposal.deferred.is_empty() {
op.say(" Not included (durable approval is refused by policy — named here so they don't silently keep prompting):");
for d in &proposal.deferred {
op.say(&format!(" {} — {}", d.target, d.reason));
}
}
if proposal.additions.exec.is_empty() && proposal.additions.net.is_empty() {
return Ok(());
}
let exclude_raw = op
.ask(&interaction_form::text_field(
"Exclude any by name?",
"comma-separated, Enter to keep all",
))
.unwrap_or_default();
let excluded: std::collections::HashSet<&str> = exclude_raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
let mut additions = proposal.additions;
additions
.exec
.retain(|e| !excluded.contains(e.target.as_str()));
additions
.net
.retain(|e| !excluded.contains(e.host.as_str()));
let kept = additions.exec.len() + additions.net.len();
if kept == 0 {
op.say(" All candidates excluded; nothing written.");
return Ok(());
}
if !decide(
op,
&interaction_form::confirm(
format!(
"Sign and write {kept} entr{}?",
if kept == 1 { "y" } else { "ies" }
),
"",
"yes, sign it",
"no, skip",
),
None,
)
.unwrap_or(false)
{
op.say(" Skipped. Nothing written.");
return Ok(());
}
let (destination, _lock) = lock_approve_file(config_path)?;
let approve_path = destination.as_path().to_path_buf();
let mut merged = match std::fs::read_to_string(&approve_path) {
Ok(text) => PolicyFile::parse(&text).map_err(|e| anyhow::anyhow!("approve.toml: {e}"))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => PolicyFile::default(),
Err(e) => {
return Err(anyhow::anyhow!(
"cannot read {}: {e}",
approve_path.display()
))
}
};
let key_path = newt_identity::default_key_path()?;
let user = newt_identity::load_or_generate(&key_path)?;
let mut fresh = additions;
let (signed, refused) = newt_core::ocap_store::sign_approves(
&mut fresh,
crate::ocap_high_danger_predicate(),
|payload| user.sign(payload).to_bytes(),
);
merged.exec.extend(fresh.exec);
merged.fs.extend(fresh.fs);
merged.net.extend(fresh.net);
destination
.atomic_write(merged.to_toml().map_err(|e| anyhow::anyhow!(e))?.as_bytes())
.map_err(|e| anyhow::anyhow!("cannot write {}: {e}", approve_path.display()))?;
op.say(&format!(
" Signed {signed} entr{} into {} (root key {}).",
if signed == 1 { "y" } else { "ies" },
approve_path.display(),
key_path.display()
));
for r in &refused {
op.say(&format!(" refused: {r}"));
}
Ok(())
}
fn persist_default_backend(config_path: &Path, chosen: &str) -> anyhow::Result<()> {
let setup_lock = acquire_setup_lock(config_path)?;
let destination = &setup_lock.destination;
let old_text = read_setup_config(destination.as_path())?;
let new_text = Config::with_default_backend(&old_text, chosen)?;
if new_text == old_text {
return Ok(());
}
let permissions = setup_file_permissions(destination.as_path())?;
let staged = stage_setup_file(destination, new_text.as_bytes(), permissions.as_ref())?;
if let Err(error) = destination.durable_replace(&staged) {
let _ = std::fs::remove_file(staged);
return Err(error.into());
}
Ok(())
}
#[derive(Debug, Clone, Copy)]
enum BackendChoice {
LocalOllama,
CustomHost,
HostedProvider,
}
const DECISION_ATTEMPTS: usize = 3;
fn choose(
op: &Operator<'_>,
definition: &InteractionDefinition,
blank: Option<&str>,
) -> anyhow::Result<String> {
for _ in 0..DECISION_ATTEMPTS {
let answer = op.ask(definition)?;
let answer = answer.trim();
if answer.is_empty() {
if let Some(default) = blank {
return Ok(default.to_string());
}
}
if let Some(picked) = interaction_form::resolve(definition, answer) {
return Ok(picked.as_str().to_string());
}
op.say(&format!(
" not one of the choices — enter {}",
keys(definition)
));
}
anyhow::bail!("no usable answer after {DECISION_ATTEMPTS} attempts")
}
fn keys(definition: &InteractionDefinition) -> String {
definition
.controls
.iter()
.filter_map(|control| match &control.kind {
newt_interaction::ControlKind::Choice { options } => Some(options),
_ => None,
})
.flatten()
.map(|option| option.key.as_str())
.collect::<Vec<_>>()
.join(", ")
}
fn decide(
op: &Operator<'_>,
definition: &InteractionDefinition,
blank: Option<bool>,
) -> anyhow::Result<bool> {
let blank = blank.map(|yes| if yes { YES } else { NO });
Ok(choose(op, definition, blank)? == YES)
}
fn choose_backend(op: &Operator<'_>) -> anyhow::Result<BackendChoice> {
let picked = choose(
op,
&interaction_form::menu(
"\nWhere does your model run?",
"",
&[
("1", "Ollama on this machine (http://127.0.0.1:11434)"),
(
"2",
"Another machine (hostname or URL — newt probes Ollama / llama.cpp / vLLM)",
),
(
"3",
"A hosted provider (OpenAI, Anthropic, OpenRouter, NVIDIA, … — API key)",
),
],
),
Some("1"),
)?;
Ok(match picked.as_str() {
"2" => BackendChoice::CustomHost,
"3" => BackendChoice::HostedProvider,
_ => BackendChoice::LocalOllama,
})
}
async fn configure_ollama(
op: &Operator<'_>,
client: &reqwest::Client,
) -> anyhow::Result<ConfiguredBackend> {
let default_url = "http://127.0.0.1:11434";
let raw = op.ask(&interaction_form::text_field(
"Ollama host",
format!("[{default_url}] — Enter keeps it"),
))?;
let url = normalize_url(
if raw.is_empty() { default_url } else { &raw },
"http",
11434,
);
let model = pick_model(op, client, &url).await?;
let (config, backend) = build_ollama_config(
Config::default(),
"default",
EndpointKind::Ollama,
&url,
&model,
);
Ok((config, backend, None))
}
fn format_endpoint_row(hit: &EndpointProbeResult) -> String {
let engine = hit
.engine
.map(|e| e.label().to_string())
.unwrap_or_else(|| hit.kind.label().to_string());
let mut row = format!(
"{} {} {} model{}",
hit.endpoint,
engine,
hit.models.len(),
if hit.models.len() == 1 { "" } else { "s" }
);
if let Some(warm) = hit.warm.first() {
row.push_str(&format!(" warm: {warm}"));
}
row
}
fn order_models_warm_first(models: &[String], warm: &[String]) -> Vec<String> {
let mut out: Vec<String> = warm
.iter()
.filter(|w| models.contains(w))
.cloned()
.collect();
for m in models {
if !out.contains(m) {
out.push(m.clone());
}
}
out
}
async fn configure_custom_host(
op: &Operator<'_>,
client: &reqwest::Client,
config_path: &Path,
) -> anyhow::Result<ConfiguredBackend> {
loop {
let raw = op.ask(&interaction_form::text_field(
"Host name or URL",
"e.g. gpu-box, 10.0.0.5:8000, https://llm.example.net",
))?;
let target = raw.trim().to_string();
if target.is_empty() {
op.say(" A host is required.");
continue;
}
let discovery = if config_path.is_file() {
crate::migration_notices::read(|report| Config::load(config_path, report))
.map(|c| c.discovery)
.unwrap_or_default()
} else {
Discovery::default()
};
let candidates = match candidate_endpoints(&target, &discovery) {
Ok(c) => c,
Err(e) => {
op.say(&format!(" {e}"));
continue;
}
};
op.say(&format!("Probing {}…", candidates.join(", ")));
let (mut hits, mut failures) =
probe_candidates_concurrently(client, &candidates, None).await?;
let mut api_key: Option<Secret> = None;
if hits.is_empty()
&& failures
.iter()
.any(|f| f.contains("authentication required"))
{
if let Some(key) =
credentials::ask_secret(op, "API key (echoes as *, Enter to skip): ")?
{
validate_authenticated_target(&target)?;
let (h, f) =
probe_candidates_concurrently(client, &candidates, Some(key.expose())).await?;
hits = h;
failures = f;
api_key = Some(key);
}
}
if hits.is_empty() {
for failure in &failures {
op.say(&format!(" {failure}"));
}
op.say(&format!(
"\nNo inference API answered at {target} (tried {}).",
candidates.join(", ")
));
let picked = choose(
op,
&interaction_form::menu(
"",
"",
&[
("1", "Try a different host"),
("2", "Enter endpoint and model by hand (generation-tested)"),
("3", "Cancel setup"),
],
),
Some("1"),
)?;
match picked.as_str() {
"2" => return manual_backend_entry(op, client, config_path).await,
"3" => {
return Err(anyhow::Error::from(io::Error::new(
io::ErrorKind::Interrupted,
"setup cancelled",
)));
}
_ => continue,
}
}
op.say(&format!(
"\nFound {} endpoint{}:",
hits.len(),
if hits.len() == 1 { "" } else { "s" }
));
let rows: Vec<(String, String)> = hits
.iter()
.enumerate()
.map(|(i, hit)| ((i + 1).to_string(), format_endpoint_row(hit)))
.collect();
let entries: Vec<(&str, &str)> =
rows.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let picked = choose(op, &interaction_form::menu("", "", &entries), Some("1"))?;
let idx = picked.parse::<usize>().unwrap_or(1) - 1;
let hit = &hits[idx];
let ordered = order_models_warm_first(&hit.models, &hit.warm);
let model = if ordered.is_empty() {
ask_model_name(op)?
} else {
select_model(op, &ordered)?
};
let (api_key, api) =
verify_custom_chat_with_retries(op, client, hit, &model, api_key).await?;
let mut backend = backend_from_probe(hit, None, None)?;
backend.model = Some(model);
if backend.serving == Some(newt_core::Serving::Instance) {
backend.api = api;
}
let pending_token = if let Some(key) = api_key {
let pending = collect_wizard_token(op, &key, config_path, &backend.name)?;
backend.api_key_file = Some(pending.reference.handle().to_string());
Some(pending)
} else {
None
};
let config = Config {
backends: vec![], default_backend: Some(backend.name.clone()),
..Default::default()
};
return Ok((config, backend, pending_token));
}
}
async fn verify_custom_chat_with_retries(
op: &Operator<'_>,
client: &reqwest::Client,
hit: &EndpointProbeResult,
model: &str,
mut api_key: Option<Secret>,
) -> anyhow::Result<(Option<Secret>, Option<newt_core::config::OpenAiApi>)> {
for attempt in 0..GENERATION_CHECK_ATTEMPTS {
if api_key
.as_ref()
.is_some_and(|key| !key.expose().trim().is_empty())
{
validate_authenticated_target(&hit.endpoint)?;
}
op.say(&format!(
"Testing a minimal generation against {}…",
hit.endpoint
));
match newt_core::backend_probe::verify_generation(
client,
hit.kind,
None,
&hit.endpoint,
model,
api_key.as_ref().map(Secret::expose),
)
.await
{
GenerationCheck::Accepted(api) => {
op.say(" ✓ generation accepted");
let api = api.filter(|surface| *surface == newt_core::config::OpenAiApi::Responses);
return Ok((api_key, api));
}
GenerationCheck::Rejected(code) => {
op.say(&format!(" ✗ authentication rejected (HTTP {code})"));
if attempt + 1 == GENERATION_CHECK_ATTEMPTS {
break;
}
validate_authenticated_target(&hit.endpoint)?;
let Some(key) =
credentials::ask_secret(op, "API key (echoes as *, Enter to cancel): ")?
else {
anyhow::bail!("setup cancelled after authentication rejection");
};
api_key = Some(key);
}
GenerationCheck::Unverified(reason) => {
op.say(&format!(" Could not verify generation ({reason})."));
anyhow::bail!("setup cancelled because generation verification did not pass");
}
}
}
anyhow::bail!("authentication was rejected {GENERATION_CHECK_ATTEMPTS} times")
}
async fn manual_backend_entry(
op: &Operator<'_>,
client: &reqwest::Client,
config_path: &Path,
) -> anyhow::Result<ConfiguredBackend> {
let url = loop {
let raw = op.ask(&interaction_form::text_field(
"Endpoint URL",
"e.g. http://host:8080",
))?;
if !raw.trim().is_empty() {
let normalized = normalize_url(raw.trim(), "http", 11434);
break candidate_endpoints(&normalized, &Discovery::default())?
.into_iter()
.next()
.expect("an explicit URL produces one candidate");
}
op.say(" An endpoint URL is required (e.g. http://host:8080).");
};
let picked = choose(
op,
&interaction_form::menu(
"\nWire protocol:",
"",
&[
("1", "ollama (POST /api/chat)"),
(
"2",
"openai-compatible (POST /v1/chat/completions — llama.cpp, vLLM, gateways)",
),
],
),
Some("1"),
)?;
let kind = match picked.as_str() {
"2" => BackendKind::Openai,
_ => BackendKind::Ollama,
};
let model = ask_model_name(op)?;
let name = backend_name(&url)?;
let serving = match kind {
BackendKind::Openai => newt_core::Serving::Instance,
_ => newt_core::Serving::Multiplexer,
};
let hit = EndpointProbeResult {
endpoint: url.clone(),
kind,
models: vec![model.clone()],
serving,
engine: None,
warm: vec![],
};
let (api_key, api) = verify_custom_chat_with_retries(op, client, &hit, &model, None).await?;
let (config, mut backend) =
build_backend_pair(&name, &url, &model, kind, serving, None, "manual");
let pending_token = if let Some(key) = api_key {
let pending = collect_wizard_token(op, &key, config_path, &backend.name)?;
backend.api_key_file = Some(pending.reference.handle().to_string());
Some(pending)
} else {
None
};
if backend.serving == Some(newt_core::Serving::Instance) {
backend.api = api;
}
Ok((config, backend, pending_token))
}
async fn configure_hosted(
op: &Operator<'_>,
client: &reqwest::Client,
config_path: &Path,
) -> anyhow::Result<ConfiguredBackend> {
let presets = provider_preset::resolve_presets(None);
match select_hosted_provider(op, &presets)? {
HostedProviderChoice::Preset(preset) => {
configure_preset(op, client, &preset, config_path).await
}
HostedProviderChoice::CustomEndpoint => {
configure_custom_host(op, client, config_path).await
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum HostedProviderChoice {
CustomEndpoint,
Preset(Box<ProviderPreset>),
}
fn select_hosted_provider(
op: &Operator<'_>,
presets: &[ProviderPreset],
) -> anyhow::Result<HostedProviderChoice> {
let mut available: Vec<(&ProviderPreset, String)> = Vec::new();
let mut unavailable: Vec<(String, String)> = Vec::new();
for p in presets {
match preset_support(p) {
PresetSupport::Supported { endpoint, .. } => available.push((p, endpoint)),
PresetSupport::Unsupported { reason } => {
unavailable.push((p.label().to_string(), reason));
}
}
}
for (label, reason) in &unavailable {
op.say(&format!(" (unavailable: {label} — {reason})"));
}
if available.is_empty() {
let _ = choose(
op,
&interaction_form::menu(
"\nAvailable providers:",
"",
&[("0", "I have a URL (custom endpoint)")],
),
Some("0"),
)?;
return Ok(HostedProviderChoice::CustomEndpoint);
}
let rows: Vec<String> = available
.iter()
.map(|(p, endpoint)| format!("{:<24}{}", p.label(), endpoint))
.collect();
match select_row_with_zero(op, &rows, "providers", "I have a URL (custom endpoint)")? {
Some(idx) => Ok(HostedProviderChoice::Preset(Box::new(
available[idx].0.clone(),
))),
None => Ok(HostedProviderChoice::CustomEndpoint),
}
}
async fn configure_preset(
op: &Operator<'_>,
client: &reqwest::Client,
preset: &ProviderPreset,
config_path: &Path,
) -> anyhow::Result<ConfiguredBackend> {
op.say(&format!("\n{} — {}", preset.label(), preset.base_url));
if let Some(description) = &preset.description {
op.say(description);
}
if let Some(signup) = &preset.signup_url {
op.say(&format!("Create an API key at {signup}"));
}
if !preset.default_headers.is_empty() {
op.say(&format!(
" Note: {} suggests extra request headers; newt does not send custom headers yet.",
preset.label()
));
}
let cred = if preset.env_vars.is_empty() {
WizardCred {
api_key_env: None,
api_key_file: None,
probe_key: None,
pending_token: None,
}
} else {
let exported = preset.env_vars.iter().find_map(|var| {
std::env::var(var)
.ok()
.filter(|v| !v.trim().is_empty())
.map(|v| (var.clone(), v))
});
if let Some((var, value)) = exported {
let use_it = decide(
op,
&interaction_form::confirm(
format!("${var} is set in this shell. Use it?"),
"",
"yes, use it",
"no, paste a key instead",
),
None,
)?;
if use_it {
WizardCred {
api_key_env: Some(var),
api_key_file: None,
probe_key: Some(Secret::new(value)),
pending_token: None,
}
} else {
preset_pasted_key(op, preset, config_path)?
}
} else {
preset_pasted_key(op, preset, config_path)?
}
};
if cred.probe_key.is_some() {
let PresetSupport::Supported { endpoint, .. } = preset_support(preset) else {
anyhow::bail!("preset {} is not usable on this build", preset.name);
};
validate_authenticated_target(&endpoint)?;
}
op.say(&format!(
"Probing {} for available models…",
preset.base_url
));
let models =
list_models_for_preset(client, preset, cred.probe_key.as_ref().map(Secret::expose)).await;
let model = match models {
Ok(m) if !m.is_empty() => select_model(op, &m)?,
Ok(_) | Err(_) => {
op.say(" Could not list models (endpoint unreachable or key not yet usable).");
match preset.fallback_models.len() {
0 => ask_model_name(op)?,
1 => {
let default = &preset.fallback_models[0];
let raw = op.ask(&interaction_form::text_field(
"Model name",
format!("[{default}] — Enter keeps it"),
))?;
if raw.trim().is_empty() {
default.clone()
} else {
raw.trim().to_string()
}
}
_ => select_model(op, &preset.fallback_models)?,
}
}
};
let (cred, verified_api) =
verify_key_with_retries(op, client, preset, config_path, &model, cred).await?;
let mut backend = provider_preset::backend_from_preset(
preset,
&model,
cred.api_key_env,
cred.api_key_file,
crate::VERSION,
)
.map_err(|reason| anyhow::anyhow!("preset {} is not usable: {reason}", preset.name))?;
if verified_api.is_some() && backend.serving == Some(newt_core::Serving::Instance) {
backend.api = verified_api;
}
let config = Config {
backends: vec![], default_backend: Some(backend.name.clone()),
..Default::default()
};
Ok((config, backend, cred.pending_token))
}
struct WizardCred {
api_key_env: Option<String>,
api_key_file: Option<String>,
probe_key: Option<Secret>,
pending_token: Option<PendingWizardToken>,
}
async fn verify_key_with_retries(
op: &Operator<'_>,
client: &reqwest::Client,
preset: &ProviderPreset,
config_path: &Path,
model: &str,
mut cred: WizardCred,
) -> anyhow::Result<(WizardCred, Option<newt_core::config::OpenAiApi>)> {
let provider_preset::PresetSupport::Supported {
kind,
api,
endpoint,
} = provider_preset::preset_support(preset)
else {
anyhow::bail!("preset {} is not usable on this build", preset.name);
};
for attempt in 0..GENERATION_CHECK_ATTEMPTS {
if cred
.probe_key
.as_ref()
.is_some_and(|key| !key.expose().trim().is_empty())
{
validate_authenticated_target(&endpoint)?;
}
op.say(&format!(
"Testing a minimal generation against {}…",
preset.base_url
));
match newt_core::backend_probe::verify_generation(
client,
kind,
api,
&endpoint,
model,
cred.probe_key.as_ref().map(Secret::expose),
)
.await
{
GenerationCheck::Accepted(api) => {
op.say(" ✓ generation accepted");
let api = api.filter(|surface| *surface == newt_core::config::OpenAiApi::Responses);
return Ok((cred, api));
}
GenerationCheck::Rejected(code) => {
op.say(&format!(" ✗ authentication rejected (HTTP {code})"));
if attempt + 1 == GENERATION_CHECK_ATTEMPTS {
break;
}
if !decide(
op,
&interaction_form::confirm(
"Re-enter the key?",
"",
"yes, re-enter it",
"no, cancel setup",
),
None,
)? {
anyhow::bail!("setup cancelled after authentication rejection");
}
cred = preset_pasted_key(op, preset, config_path)?;
}
GenerationCheck::Unverified(reason) => {
anyhow::bail!("minimal generation verification failed: {reason}");
}
}
}
anyhow::bail!("authentication was rejected {GENERATION_CHECK_ATTEMPTS} times")
}
#[allow(clippy::type_complexity)]
fn preset_pasted_key(
op: &Operator<'_>,
preset: &ProviderPreset,
config_path: &Path,
) -> anyhow::Result<WizardCred> {
let key = credentials::ask_secret(op, "API key (echoes as *, Enter to skip): ")?;
let Some(key) = key else {
let var = preset
.env_vars
.first()
.cloned()
.unwrap_or_else(|| provider_preset::synthesized_env_var(&preset.name));
op.say(&format!(
" No key — writing the backend anyway; export ${var} before use."
));
return Ok(WizardCred {
api_key_env: Some(var),
api_key_file: None,
probe_key: None,
pending_token: None,
});
};
let pending = collect_wizard_token(op, &key, config_path, &preset.name)?;
let reference = pending.reference.handle().to_string();
Ok(WizardCred {
api_key_env: None,
api_key_file: Some(reference),
probe_key: Some(key),
pending_token: Some(pending),
})
}
fn collect_wizard_token(
op: &Operator<'_>,
token: &Secret,
config_path: &Path,
name: &str,
) -> anyhow::Result<PendingWizardToken> {
op.say("Protect the stored key with a passphrase? Enter uses a machine-local key.");
let passphrase = credentials::ask_secret(op, "Passphrase (echoes as *): ")?
.map(|pass| newt_core::secrets::SecretString::from(pass.expose().to_string()));
let path = versioned_wizard_token_path(config_path, name)?;
let reference = credentials::SealedSecret::new(&collapse_home(&path))?;
Ok(PendingWizardToken {
token: token.clone(),
passphrase,
path,
reference,
})
}
fn versioned_wizard_token_path(config_path: &Path, name: &str) -> anyhow::Result<PathBuf> {
Ok(config_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.join("backends"))
.ok_or_else(|| anyhow::anyhow!("config path has no parent directory"))?
.join(format!(
"{name}.token.{}.age",
newt_core::atomic_fs::unique_suffix()
)))
}
#[cfg(test)]
fn persist_wizard_token(
op: &Operator<'_>,
_config_path: &Path,
_name: &str,
pending: &PendingWizardToken,
) -> anyhow::Result<String> {
newt_core::secrets::store_token_at(
&pending.path,
pending.token.expose(),
pending.passphrase.as_ref(),
)
.map_err(|e| anyhow::anyhow!(e))?;
let reference = pending.reference.handle().to_string();
op.say(&format!(" → stored encrypted at {reference}"));
Ok(reference)
}
fn offer_identity(op: &Operator<'_>) {
let Some(path) = newt_core::AgentIdentity::user_identity_path() else {
return;
};
if path.exists() {
return; }
let raw = match op.ask(&interaction_form::text_field(
"Attribution for agent commits",
"\"Name <email>\" — Enter keeps the default",
)) {
Ok(raw) => raw,
Err(_) => return, };
let raw = raw.trim();
if raw.is_empty() {
return;
}
let Some((name, email)) = parse_identity_line(raw) else {
op.say(" Expected \"Name <email>\" — skipped (set later with `newt identity set`).");
return;
};
let identity = newt_core::AgentIdentity {
name,
email,
..Default::default()
};
match identity.save(&path) {
Ok(()) => op.say(&format!(" Wrote {}.", path.display())),
Err(e) => op.say(&format!(" Could not write identity ({e}) — skipped.")),
}
}
fn parse_identity_line(raw: &str) -> Option<(String, String)> {
let (name, rest) = raw.split_once('<')?;
let email = rest.strip_suffix('>')?.trim();
let name = name.trim();
if name.is_empty() || email.is_empty() || !email.contains('@') {
return None;
}
Some((name.to_string(), email.to_string()))
}
fn collapse_home(path: &Path) -> String {
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(std::path::PathBuf::from);
if let Some(home) = home {
if let Ok(rel) = path.strip_prefix(&home) {
return format!("~/{}", rel.display());
}
}
path.display().to_string()
}
async fn pick_model(
op: &Operator<'_>,
client: &reqwest::Client,
url: &str,
) -> anyhow::Result<String> {
op.say(&format!("Probing {url} for installed models…"));
let models = fetch_ollama_models(client, url).await;
let models = match models {
Ok(m) if !m.is_empty() => m,
Ok(_) => {
op.say(" Endpoint answered but listed no models.");
return ask_model_name(op);
}
Err(e) => {
op.say(&format!(" Could not reach the endpoint ({e})."));
return ask_model_name(op);
}
};
select_model(op, &models)
}
const FILTER_THRESHOLD: usize = 9;
fn select_model(op: &Operator<'_>, models: &[String]) -> anyhow::Result<String> {
let idx = select_row(op, models, "models")?;
Ok(models[idx].clone())
}
fn select_row(op: &Operator<'_>, rows: &[String], noun: &str) -> anyhow::Result<usize> {
Ok(select_row_with_zero(op, rows, noun, "")?.expect("no zero choice was offered"))
}
fn select_row_with_zero(
op: &Operator<'_>,
rows: &[String],
noun: &str,
zero: &str,
) -> anyhow::Result<Option<usize>> {
let mut pool: Vec<(usize, &String)> = rows.iter().enumerate().collect();
if pool.len() > FILTER_THRESHOLD {
op.say(&format!("\n{} {noun} available.", pool.len()));
let hint = if zero.is_empty() {
"blank = show all".to_string()
} else {
format!("blank = show all, 0 = {zero}")
};
let needle = op.ask(&interaction_form::text_field("Filter", hint))?;
if !zero.is_empty() && needle.trim() == "0" {
return Ok(None);
}
let needle = needle.trim().to_ascii_lowercase();
if !needle.is_empty() {
let matched: Vec<(usize, &String)> = pool
.iter()
.filter(|(_, row)| row.to_ascii_lowercase().contains(&needle))
.copied()
.collect();
if matched.is_empty() {
op.say(&format!(" No match for {needle:?}; showing all."));
} else {
pool = matched;
}
}
}
let mut rows: Vec<(String, String)> = Vec::with_capacity(pool.len() + 1);
if !zero.is_empty() {
rows.push(("0".to_string(), zero.to_string()));
}
rows.extend(
pool.iter()
.enumerate()
.map(|(i, (_, row))| ((i + 1).to_string(), (*row).clone())),
);
let entries: Vec<(&str, &str)> = rows.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let picked = choose(
op,
&interaction_form::menu(format!("\nAvailable {noun}:"), "", &entries),
Some("1"),
)?;
if !zero.is_empty() && picked == "0" {
return Ok(None);
}
let index = picked.parse::<usize>().unwrap_or(1) - 1;
Ok(Some(pool[index].0))
}
fn ask_model_name(op: &Operator<'_>) -> anyhow::Result<String> {
let default = "llama3.1:8b";
let raw = op.ask(&interaction_form::text_field(
"Model name",
format!("[{default}] — Enter keeps it"),
))?;
Ok(if raw.is_empty() {
default.to_string()
} else {
raw
})
}
use newt_core::backend_probe::fetch_ollama_models;
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 candidate_endpoints(target: &str, discovery: &Discovery) -> anyhow::Result<Vec<String>> {
let target = target.trim();
if target.is_empty() {
anyhow::bail!("setup target cannot be empty");
}
let has_scheme = target.contains("://");
let url_input = if has_scheme {
target.to_string()
} else {
format!("http://{target}")
};
let mut url = reqwest::Url::parse(&url_input)
.map_err(|e| anyhow::anyhow!("invalid setup target `{target}`: {e}"))?;
if !matches!(url.scheme(), "http" | "https") {
anyhow::bail!(
"unsupported setup URL scheme `{}`; use http or https",
url.scheme()
);
}
if !url.username().is_empty() || url.password().is_some() {
anyhow::bail!("do not put credentials in the setup URL; use --token-env or --token-file");
}
if url.host_str().is_none() {
anyhow::bail!("setup target `{target}` has no hostname");
}
if url.query().is_some() || url.fragment().is_some() {
anyhow::bail!("setup target must not contain a query string or fragment");
}
let explicit_port =
url.port().is_some() || (!has_scheme && !target.starts_with('[') && target.contains(':'));
if has_scheme || explicit_port {
strip_probe_suffix(&mut url);
return Ok(vec![url.as_str().trim_end_matches('/').to_string()]);
}
if url.path() != "/" {
anyhow::bail!("a bare setup host cannot contain a path; supply a full URL instead");
}
let mut ports = Vec::new();
for port in discovery
.ollama_ports
.iter()
.chain(discovery.vllm_ports.iter())
.copied()
{
if !ports.contains(&port) {
ports.push(port);
}
}
if ports.is_empty() {
anyhow::bail!("[discovery] contains no ports to probe");
}
let mut endpoints = Vec::with_capacity(ports.len());
for port in ports {
let mut candidate = url.clone();
candidate
.set_port(Some(port))
.map_err(|()| anyhow::anyhow!("cannot apply port {port} to `{target}`"))?;
endpoints.push(candidate.as_str().trim_end_matches('/').to_string());
}
Ok(endpoints)
}
async fn probe_candidates_concurrently(
client: &reqwest::Client,
candidates: &[String],
api_key: Option<&str>,
) -> anyhow::Result<(Vec<EndpointProbeResult>, Vec<String>)> {
let mut tasks = tokio::task::JoinSet::new();
for (index, endpoint) in candidates.iter().cloned().enumerate() {
let client = client.clone();
let api_key = api_key.map(str::to_string);
tasks.spawn(async move {
let result =
newt_core::backend_probe::detect_endpoint(&client, &endpoint, api_key.as_deref())
.await;
(index, endpoint, result)
});
}
let mut ordered: Vec<Option<EndpointProbeResult>> = vec![None; candidates.len()];
let mut failures: Vec<Option<String>> = vec![None; candidates.len()];
while let Some(joined) = tasks.join_next().await {
let (index, endpoint, result) = joined.map_err(|e| anyhow::anyhow!(e))?;
match result {
Ok(hit) if hit.models.is_empty() => {
failures[index] = Some(format!(
"{endpoint}: endpoint answered but listed no models"
));
}
Ok(hit) => ordered[index] = Some(hit),
Err(error) => failures[index] = Some(format!("{endpoint}: {error}")),
}
}
Ok((
ordered.into_iter().flatten().collect(),
failures.into_iter().flatten().collect(),
))
}
fn strip_probe_suffix(url: &mut reqwest::Url) {
let path = url.path().trim_end_matches('/');
let base = ["/v1/models", "/api/tags", "/v1"]
.iter()
.find_map(|suffix| path.strip_suffix(suffix))
.unwrap_or(path)
.to_string();
url.set_path(if base.is_empty() { "/" } else { &base });
}
fn backend_name(endpoint: &str) -> anyhow::Result<String> {
let url = reqwest::Url::parse(endpoint)
.map_err(|e| anyhow::anyhow!("invalid detected endpoint `{endpoint}`: {e}"))?;
let host = url
.host_str()
.ok_or_else(|| anyhow::anyhow!("detected endpoint `{endpoint}` has no hostname"))?;
let port = url
.port_or_known_default()
.ok_or_else(|| anyhow::anyhow!("detected endpoint `{endpoint}` has no port"))?;
let mut slug = String::new();
let mut last_was_separator = false;
for ch in host.chars().flat_map(char::to_lowercase) {
if ch.is_ascii_alphanumeric() {
slug.push(ch);
last_was_separator = false;
} else if !last_was_separator && !slug.is_empty() {
slug.push('-');
last_was_separator = true;
}
}
while slug.ends_with('-') {
slug.pop();
}
if slug.is_empty() {
anyhow::bail!("cannot derive a backend name from `{endpoint}`");
}
Ok(format!("{slug}-{port}"))
}
fn resolve_setup_token(
token_env: Option<&str>,
token_file: Option<&Path>,
) -> anyhow::Result<Option<String>> {
if token_env.is_some() && token_file.is_some() {
anyhow::bail!("use only one of --token-env or --token-file");
}
if let Some(name) = token_env {
if name.trim().is_empty() {
anyhow::bail!("--token-env needs a non-empty environment variable name");
}
return std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(Some)
.ok_or_else(|| {
anyhow::anyhow!("token environment variable `{name}` is unset or empty")
});
}
if let Some(path) = token_file {
let reference = path
.to_str()
.ok_or_else(|| anyhow::anyhow!("token file path is not valid UTF-8"))?;
let backend = BackendConfig {
api_key_file: Some(reference.to_string()),
..Default::default()
};
return backend.resolve_api_key().map(Some).ok_or_else(|| {
anyhow::anyhow!(
"token file `{}` is missing or contains no token",
path.display()
)
});
}
Ok(None)
}
fn backend_from_probe(
probe: &EndpointProbeResult,
token_env: Option<&str>,
token_file: Option<&Path>,
) -> anyhow::Result<BackendConfig> {
let url = reqwest::Url::parse(&probe.endpoint)
.map_err(|e| anyhow::anyhow!("invalid detected endpoint `{}`: {e}", probe.endpoint))?;
let token_file = token_file
.map(|path| {
path.to_str()
.map(str::to_string)
.ok_or_else(|| anyhow::anyhow!("token file path is not valid UTF-8"))
})
.transpose()?;
Ok(BackendConfig {
name: backend_name(&probe.endpoint)?,
endpoint: probe.endpoint.clone(),
model: probe.warm.first().or(probe.models.first()).cloned(),
tiers: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
kind: Some(probe.kind),
api_key_file: token_file,
api_key_env: token_env.map(str::to_string),
serving: Some(probe.serving),
engine: probe.engine,
host: url.host_str().map(str::to_string),
provenance: Some(newt_core::config::BackendProvenance {
source: Some(format!(
"newt setup v{} (auto-detected {:?})",
crate::VERSION,
probe.kind
)),
probed: Some(chrono::Local::now().format("%Y-%m-%d").to_string()),
derived_serving: Some(true),
}),
..Default::default()
})
}
fn backend_from_verified_probe(
verified: &VerifiedTargetHit,
token_env: Option<&str>,
token_file: Option<&Path>,
) -> anyhow::Result<BackendConfig> {
let mut backend = backend_from_probe(&verified.probe, token_env, token_file)?;
backend.api = verified
.api
.filter(|surface| *surface == OpenAiApi::Responses);
Ok(backend)
}
fn build_backend_pair(
name: &str,
endpoint: &str,
model: &str,
kind: BackendKind,
serving: newt_core::Serving,
api_key_env: Option<String>,
source_note: &str,
) -> (Config, BackendConfig) {
let backend = BackendConfig {
name: name.to_string(),
endpoint: endpoint.to_string(),
model: Some(model.to_string()),
tiers: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
kind: Some(kind),
api_key_env,
serving: Some(serving),
provenance: Some(newt_core::config::BackendProvenance {
source: Some(format!("newt setup v{} ({source_note})", crate::VERSION)),
probed: Some(chrono::Local::now().format("%Y-%m-%d").to_string()),
derived_serving: Some(true),
}),
..Default::default()
};
let config = Config {
backends: vec![], default_backend: Some(backend.name.clone()),
..Default::default()
};
(config, backend)
}
fn build_ollama_config(
_base: Config,
node_name: &str,
kind: EndpointKind,
url: &str,
model: &str,
) -> (Config, BackendConfig) {
build_backend_pair(
node_name,
url,
model,
BackendKind::Ollama,
newt_core::Serving::Multiplexer,
None,
kind.as_str(),
)
}
#[cfg(test)]
mod tests;