use clap::Subcommand;
use newt_inference::palette::{self, MiniModel};
use std::path::Path;
#[derive(Subcommand, Debug)]
pub enum ModelsCmd {
List,
Pull {
alias: Option<String>,
},
Path {
alias: Option<String>,
},
}
pub async fn run(cmd: ModelsCmd) -> anyhow::Result<()> {
match cmd {
ModelsCmd::List => list(),
ModelsCmd::Pull { alias } => pull(alias.as_deref()).await,
ModelsCmd::Path { alias } => print_path(alias.as_deref()),
}
}
fn resolve(alias: Option<&str>) -> anyhow::Result<&'static MiniModel> {
match alias {
None => Ok(palette::default_model()),
Some(a) => palette::find(a).ok_or_else(|| {
anyhow::anyhow!(
"unknown model alias '{a}'. Available: {}",
palette::palette()
.iter()
.map(|m| m.name)
.collect::<Vec<_>>()
.join(", ")
)
}),
}
}
fn list() -> anyhow::Result<()> {
println!(
"{:<16} {:>6} {:<8} {:<9} note",
"alias", "RAM", "arch", "installed"
);
for m in palette::palette() {
let installed = if palette::resolve_local(m.name).is_some() {
"yes"
} else {
"no"
};
println!(
"{:<16} {:>5.1}G {:<8} {:<9} {}",
m.name,
m.approx_ram_gb,
format!("{:?}", m.arch),
installed,
m.note
);
}
println!(
"\nSummarizer default: {} (fetch with: newt models pull [alias])",
palette::default_model().name
);
Ok(())
}
fn print_path(alias: Option<&str>) -> anyhow::Result<()> {
let m = resolve(alias)?;
let path = palette::local_gguf_path(m)
.ok_or_else(|| anyhow::anyhow!("cannot resolve ~/.newt/models (no home dir)"))?;
println!("{}", path.display());
if !path.is_file() {
eprintln!("(not present — run `newt models pull {}`)", m.name);
}
Ok(())
}
async fn pull(alias: Option<&str>) -> anyhow::Result<()> {
provision(alias).await?;
Ok(())
}
pub async fn provision(alias: Option<&str>) -> anyhow::Result<&'static MiniModel> {
let m = resolve(alias)?;
let gguf = palette::local_gguf_path(m)
.ok_or_else(|| anyhow::anyhow!("cannot resolve ~/.newt/models (no home dir)"))?;
let tok = palette::local_tokenizer_path(m)
.ok_or_else(|| anyhow::anyhow!("cannot resolve ~/.newt/models (no home dir)"))?;
if let Some(parent) = gguf.parent() {
std::fs::create_dir_all(parent)?;
write_models_readme(parent);
}
let gguf_url = format!(
"https://huggingface.co/{}/resolve/main/{}",
m.hf_repo, m.gguf_file
);
let tok_url = format!(
"https://huggingface.co/{}/resolve/main/tokenizer.json",
m.tokenizer_repo
);
println!(
"Provisioning {} -> {}",
m.name,
gguf.parent().unwrap_or(&gguf).display()
);
fetch_if_absent(
&gguf_url,
&gguf,
&format!("weights (~{:.1} GB)", m.approx_ram_gb),
)
.await?;
fetch_if_absent(&tok_url, &tok, "tokenizer.json").await?;
println!("OK {} fully provisioned", m.name);
Ok(m)
}
async fn fetch_if_absent(url: &str, dest: &Path, label: &str) -> anyhow::Result<()> {
if dest.is_file() {
println!(" {label}: present");
return Ok(());
}
println!(" {label}: fetching from {url}");
download_to(url, dest).await?;
Ok(())
}
async fn download_to(url: &str, dest: &Path) -> anyhow::Result<()> {
use std::io::Write;
let mut resp = reqwest::Client::new()
.get(url)
.send()
.await?
.error_for_status()?;
let total = resp.content_length();
let part = dest.with_extension("part");
let mut file = std::fs::File::create(&part)?;
let mut got: u64 = 0;
let mut last_pct = 0u64;
while let Some(chunk) = resp.chunk().await? {
file.write_all(&chunk)?;
got += chunk.len() as u64;
if let Some(t) = total.filter(|&t| t > 0) {
let pct = got * 100 / t;
if pct >= last_pct + 5 {
last_pct = pct;
eprint!(
"\r {pct}% ({} / {} MB) ",
got / 1_048_576,
t / 1_048_576
);
}
}
}
file.flush()?;
eprintln!();
std::fs::rename(&part, dest)?;
Ok(())
}
#[cfg(feature = "embedded")]
pub fn spawn_setup() -> Option<newt_tui::SetupHandle> {
use std::io::IsTerminal;
if !std::io::stdout().is_terminal() || std::env::var_os("NEWT_NO_MODEL_PULL").is_some() {
return None;
}
let m = palette::default_model();
if palette::resolve_local(m.name).is_some() {
return None; }
let (gguf, tok) = (
palette::local_gguf_path(m)?,
palette::local_tokenizer_path(m)?,
);
let gguf_url = format!(
"https://huggingface.co/{}/resolve/main/{}",
m.hf_repo, m.gguf_file
);
let tok_url = format!(
"https://huggingface.co/{}/resolve/main/tokenizer.json",
m.tokenizer_repo
);
let (tx, rx) = std::sync::mpsc::channel();
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let cancel_thread = std::sync::Arc::clone(&cancel);
std::thread::spawn(move || {
run_setup_thread(&gguf_url, &gguf, &tok_url, &tok, &tx, &cancel_thread);
});
Some(newt_tui::SetupHandle {
what: format!("on-host summarizer ({})", m.name),
rx,
cancel,
})
}
#[cfg(not(feature = "embedded"))]
pub fn spawn_setup() -> Option<newt_tui::SetupHandle> {
None
}
#[cfg(feature = "embedded")]
fn run_setup_thread(
gguf_url: &str,
gguf: &Path,
tok_url: &str,
tok: &Path,
tx: &std::sync::mpsc::Sender<newt_tui::SetupEvent>,
cancel: &std::sync::atomic::AtomicBool,
) {
use newt_tui::SetupEvent;
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(SetupEvent::Failed(e.to_string()));
return;
}
};
rt.block_on(async {
if let Some(parent) = gguf.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
let _ = tx.send(SetupEvent::Failed(e.to_string()));
return;
}
write_models_readme(parent);
}
for (label, url, dest) in [("weights", gguf_url, gguf), ("tokenizer", tok_url, tok)] {
let _ = tx.send(SetupEvent::Step(label.into()));
if dest.is_file() {
continue;
}
if let Err(e) = download_stream(url, dest, tx, cancel).await {
let _ = tx.send(SetupEvent::Failed(e.to_string()));
return;
}
}
let _ = tx.send(SetupEvent::Done);
});
}
#[cfg(feature = "embedded")]
async fn download_stream(
url: &str,
dest: &Path,
tx: &std::sync::mpsc::Sender<newt_tui::SetupEvent>,
cancel: &std::sync::atomic::AtomicBool,
) -> anyhow::Result<()> {
use newt_tui::SetupEvent;
use std::io::Write;
use std::sync::atomic::Ordering;
let mut resp = reqwest::Client::new()
.get(url)
.send()
.await?
.error_for_status()?;
let total = resp.content_length();
let part = dest.with_extension("part");
let mut file = std::fs::File::create(&part)?;
let mut got: u64 = 0;
let mut last_mb: u64 = 0;
let _ = tx.send(SetupEvent::Progress { done: 0, total });
while let Some(chunk) = resp.chunk().await? {
if cancel.load(Ordering::SeqCst) {
drop(file);
let _ = std::fs::remove_file(&part);
anyhow::bail!("cancelled");
}
file.write_all(&chunk)?;
got += chunk.len() as u64;
if got / 1_048_576 > last_mb {
last_mb = got / 1_048_576;
let _ = tx.send(SetupEvent::Progress { done: got, total });
}
}
file.flush()?;
std::fs::rename(&part, dest)?;
Ok(())
}
fn write_models_readme(dir: &Path) {
let readme = dir.join("README.md");
if readme.exists() {
return;
}
let _ = std::fs::write(&readme, MODELS_README);
}
const MODELS_README: &str = "\
# newt on-host summarizer models
These GGUF files are the on-host, CPU inference engine newt uses to summarize /
compact its OWN context mid-session, so context management never competes with
your GPU (the primary model) under load.
- Managed by `newt models` (`list` / `pull` / `path`).
- Default summarizer model: qwen2.5-0.5b (Q4_K_M, ~350 MB).
- Fetched from Hugging Face on the first interactive `newt code` run, or with
`newt models pull`. Nothing else auto-downloads.
- Layout: <alias>/<file>.gguf
- Safe to delete: newt re-pulls on the next interactive run, or falls back to the
session model (with a warning) until you re-pull.
- Skip the auto-pull with NEWT_NO_MODEL_PULL=1.
Why the CPU? Context compaction fires exactly when context is large — i.e. when
the GPU is busiest. Running the summarizer on the session GPU model there
overloads it and can stall the turn (#979). A small CPU model decouples them.
See docs/decisions/embedded_inference.md and issues #639 / #661 / #979.
";