//! One `llama-server` per machine, in **router mode**: the front door to every //! model that machine serves, shared by every session on it. //! //! A router holds no weights itself. It reads a preset file naming models and //! their flags, and starts a child `llama-server` per model that is asked for //! -- so "one server per model, with that model's own settings" is what a //! machine ends up running, and one process, one port and one record is what //! this backend has to keep track of. That is the whole reason it is here: //! before 2026-09-19 each session started its own `llama-server`, so two //! sessions on one model held two copies of it in memory and a model change //! cost a load that only that session benefited from. //! //! **A router outlives this backend, and nothing here stops it on its own.** //! It is recorded the way a session's process is ([`process`]), adopted again //! on the way back up, and ended only when somebody asks for that in the //! machine's provider settings. A loaded model is minutes of disk and //! gigabytes of memory; letting the last session to be closed throw that away //! would make the shared server pointless. //! //! **The preset file is the configuration, and it lives on the serving //! machine.** Flags that decide how a model is loaded -- context size, layers //! on the GPU, slots, the draft head -- are per model rather than per session, //! because one loaded model is what several sessions are now talking to. They //! are written into a section named by the model's key, which is also the name //! a request routes by, so nothing has to translate between the two. //! //! What is deliberately *not* here: which tools a session offers, how hard it //! thinks, and the sampling settings. Those ride on each request, so they stay //! the session's own and need no reload -- see `super`'s module comment. use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use anyhow::{Context, Result, bail}; use serde::Serialize; use serde_json::{Value, json}; use super::Model; use crate::config::{MachineConfig, ProviderConfig}; use crate::session::process; use crate::session::transport::{Launch, Streams, Transport}; /// Where a router's own output goes, both streams into one file. For a remote /// machine this is the local end of the ssh connection, so it carries what the /// far `llama-server` said -- including what a child model instance said, /// which is the only account of a model that would not load. const LOG: &str = "llama-router.log"; /// The preset file's name in the router's own directory, for a router on this /// machine. One on another machine keeps it over there instead, at /// [`REMOTE_PRESET`], since that is the only side that can read it. const PRESET: &str = "models.ini"; /// The preset file's first line, which `llama-server` refuses a file without. const VERSION: &str = "version = 1\n"; /// How long to wait for a model to load before giving up. Loading is mostly /// disk, and a large quantised model on a cold cache is genuinely slow, so /// this is generous -- the failure it exists for is a model that will never /// answer rather than one that is slow. const LOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); /// How long to wait for the router process itself. Short: it loads nothing, /// so anything beyond a second or two is a port it cannot bind or a program /// too old for one of these flags. const START_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); /// How often either of those is checked. const POLL: std::time::Duration = std::time::Duration::from_millis(250); /// How much of the router's log to carry into a message somebody reads on a /// phone. const LOG_TAIL_LINES: usize = 6; /// How many models a router keeps loaded at once before evicting the least /// recently used. One by default because the machine serving models usually /// has one GPU: a second model loaded beside the first is the case where /// neither fits. const DEFAULT_MAX_LOADED: u32 = 1; /// Every machine's router, so that two sessions on one machine reach one /// process rather than starting two. /// /// A registry rather than a field on each session: the sharing *is* the /// point, and a router that two drivers could each own is one that both would /// start. pub struct Routers { dir: PathBuf, /// The runtime a router is started on and reaped into. /// /// Captured here because everything that starts one runs on a *blocking* /// thread -- loading a model is minutes of disk, so it cannot be on the /// runtime -- and tokio's `Command::spawn` registers the child with the /// reactor, so calling it outside a runtime context panics. That panic is /// silent: it kills the loading thread and leaves the session saying /// "loading" for ever, with nothing in the log, which is exactly how it /// was found. runtime: Option, inner: Mutex>>, } impl Routers { /// `dir` is where each router's record, log and (for this machine) preset /// file live -- beside the session directories, since a router is shared /// by sessions and belongs to none of them. /// /// Made on the runtime that will outlive it; `None` is a test with no /// runtime at all, where there is nothing to reap into either. pub fn new(dir: PathBuf) -> Self { Self { dir, runtime: tokio::runtime::Handle::try_current().ok(), inner: Mutex::new(HashMap::new()), } } /// This machine-and-provider's router, made if this is the first ask. /// /// The machine and provider are re-read every time rather than captured, /// because both are editable while sessions are running: a renamed /// machine, a re-probed program, a changed `maxLoaded`. What a *running* /// router was started with is whatever it was started with; the new value /// reaches the next start, which is the same rule every other launch flag /// follows. pub fn of(&self, machine: &MachineConfig, provider: &ProviderConfig) -> Arc { let key = format!("{}/{}", machine.id, provider.name); let spec = Spec { transport: Transport::for_machine(machine), program: provider.program().to_string(), max_loaded: provider.max_loaded.unwrap_or(DEFAULT_MAX_LOADED), }; let mut routers = self.inner.lock().unwrap_or_else(|e| e.into_inner()); let router = routers.entry(key.clone()).or_insert_with(|| { Arc::new(Router { dir: self.dir.join(key.replace('/', "-")), spec: Mutex::new(spec.clone()), runtime: self.runtime.clone(), gate: Mutex::new(()), }) }); *router.spec.lock().unwrap_or_else(|e| e.into_inner()) = spec; Arc::clone(router) } } /// What it takes to start a router, as its machine currently describes it. #[derive(Clone)] struct Spec { transport: Transport, program: String, max_loaded: u32, } pub struct Router { /// Its record and log, on this machine whichever machine it serves from. dir: PathBuf, spec: Mutex, /// See [`Routers::runtime`]. runtime: Option, /// Held while a router is started and while the preset file is edited -- /// the two things that go wrong when two sessions do them at once. Never /// held across a model load, which takes minutes. gate: Mutex<()>, } impl Router { /// Where its record and log are, for a session that wants to watch the /// process it is talking through or read what it last said. pub fn dir(&self) -> &Path { &self.dir } /// Its process record, if one is running -- which is also how a session /// records the process it reaches its model through. pub fn record(&self) -> Option { process::live(&self.dir) } /// The same process, recorded as one a session reaches but does not own. /// /// The one place that conversion happens, so that a session directory can /// never come to hold a record saying it owns the machine's router -- see /// [`process::Detail::Shared`]. pub fn shared_record(&self) -> Option { let record = self.record()?; match record.detail { process::Detail::Http { port } => Some(process::Record { detail: process::Detail::Shared { port }, ..record }), process::Detail::Stdio { .. } | process::Detail::Shared { .. } => None, } } /// Where to reach it, or `None` when nothing is running. pub fn endpoint(&self) -> Option { match self.record()?.detail { process::Detail::Http { port } => Some(format!("http://127.0.0.1:{port}")), process::Detail::Stdio { .. } | process::Detail::Shared { .. } => None, } } /// Puts `model` in memory and says where to talk to it, starting the /// router first if it is not up. /// /// Blocking, and slow on purpose: a model that has to come off disk takes /// as long as it takes. The caller is the driver's loading thread, which /// is what [`super::Serving::Loading`] exists to describe. /// /// Nothing is held across that wait. Two sessions load through one router, /// and the second one wanting a model already in memory must not queue /// behind the first one's cold load of a different model -- which is most /// of what sharing a server was for. pub fn load( &self, key: &str, found: &Model, settings: &BTreeMap, ) -> Result { let endpoint = self.start_if_down()?; self.describe_model(key, found, settings)?; // Nothing to ask for where it is already in memory, which is the case // this whole module exists to produce: a second session naming a model // somebody else loaded is a round trip rather than a load. Asking // anyway is not harmless -- `POST /models/load` answers **400** for a // model that is already loaded, which arrived as a session that // refused to start next to one happily using that same model. if !self.is_ready(key) { let asked = self .post("/models/load", json!({ "model": key })) .with_context(|| format!("asking llama-server to load {key}")); match (asked, self.wait_loaded(key)) { // Loaded, whatever the request said: something else may have // asked for it in the meantime, and what is in memory is the // answer rather than what one request made of being told to // put it there. (_, Ok(())) => {} // It did not load, and a refusal of the request itself says // more about why than "it never appeared" does. (Err(refused), Err(_)) => return Err(refused), (Ok(_), Err(never)) => return Err(never), } } Ok(endpoint) } /// Writes what this model is and how to load it into the preset file, and /// has the router re-read it. /// /// Also the path a settings change takes, which is why it is separate from /// [`load`](Self::load): a model whose entry has changed is **unloaded** /// by the re-read, and that is the change taking effect rather than a /// side effect -- the sessions using it load it again, with the new /// settings, on their next message. What must not happen is the same /// thing to an unrelated model, which is why the file is written and the /// re-read asked for only when the text actually differs. pub fn describe_model( &self, key: &str, found: &Model, settings: &BTreeMap, ) -> Result<()> { // Read, edit, write: under the gate because two of those at once lose // one of the two sections. let _one_at_a_time = self.gate.lock().unwrap_or_else(|e| e.into_inner()); let existing = self.preset()?; let updated = upsert(&existing, key, §ion(found, settings)); if updated == existing { return Ok(()); } self.write_preset(&updated)?; // Only meaningful against a running router; one that is down reads the // file when it starts. if self.endpoint().is_some() { self.get("/models?reload=1") .context("asking llama-server to re-read its models")?; } Ok(()) } /// Whether this model is in memory now. fn is_ready(&self, key: &str) -> bool { self.loaded() .into_iter() .any(|model| model.model == key && model.ready) } /// Every model this router knows about and what each is doing, or an empty /// list when it is not running. pub fn loaded(&self) -> Vec { let Ok(answer) = self.get("/models") else { return Vec::new(); }; answer .get("data") .and_then(Value::as_array) .map(|models| models.iter().filter_map(RouterModel::read).collect()) .unwrap_or_default() } /// Takes one model out of memory, leaving the router and every other model /// alone. pub fn unload(&self, key: &str) -> Result<()> { self.post("/models/unload", json!({ "model": key })) .with_context(|| format!("asking llama-server to unload {key}"))?; Ok(()) } /// Ends the router and every model it is holding. /// /// The only thing that does: a session closing, being deleted, or this /// backend shutting down all leave it running. Sessions using it will see /// their process go and report `exited`, which is true -- the model they /// were talking to is no longer in memory. /// /// Neither the record nor the mark is cleared here, and that is what tells /// those sessions this was asked for rather than a crash. A session's /// watcher looks a couple of seconds later, so anything removed now is /// removed before the only reader of it has looked -- which is how the /// first version of this put "llama-server exited" in three transcripts /// belonging to somebody who had just pressed Stop. The record describes a /// dead process, which every reader already handles, and starting a new /// router is what clears both. pub fn stop(&self) -> Result<()> { let Some(record) = self.record() else { return Ok(()); }; process::mark_stopping(&self.dir)?; process::stop(&record, process::STOP_GRACE); Ok(()) } /// The end of the router's log, for a failure message. /// /// Its account of what went wrong is the useful half -- "failed to load /// model", "bind: Address already in use" -- and on a remote machine it is /// the only half, since nobody reading the phone can open a file over /// there. Bounded, because this ends up in an event a phone draws. pub fn log_tail(&self) -> String { let Ok(text) = std::fs::read_to_string(self.dir.join(LOG)) else { return String::new(); }; let tail: Vec<&str> = text.lines().rev().take(LOG_TAIL_LINES).collect(); if tail.is_empty() { return String::new(); } format!( " It last said: {}", tail.into_iter().rev().collect::>().join(" / ") ) } /// Adopts the running router or starts one, and waits for it to answer. /// /// Under the gate, and asked again inside it: two sessions starting at once /// would otherwise both find no record, both start a router, and bind two /// ports to the same models. fn start_if_down(&self) -> Result { if let Some(endpoint) = self.endpoint() { return Ok(endpoint); } let _one_at_a_time = self.gate.lock().unwrap_or_else(|e| e.into_inner()); if let Some(endpoint) = self.endpoint() { return Ok(endpoint); } let spec = self.spec.lock().unwrap_or_else(|e| e.into_inner()).clone(); wg_app_link::private::create_dir(&self.dir)?; // Whatever the last router left behind, including the mark saying its // end was asked for -- see [`stop`](Self::stop). From here on, a // process that goes away is news. process::clear(&self.dir); // Written before the router starts, because it is read at startup and // a router with no preset file at all lists nothing. let preset = self.write_preset(&self.preset()?)?; let forward = spec .transport .reserve_port() .context("finding a port for llama-server")?; let args = vec![ // Loopback there, whichever machine there is: what reaches it from // outside that machine is the ssh tunnel and nothing else. "--host".to_string(), "127.0.0.1".to_string(), "--port".to_string(), forward.there.to_string(), // No `-m`: a `llama-server` given no model is a router. "--models-preset".to_string(), preset, "--models-max".to_string(), spec.max_loaded.to_string(), // The built-in agent tools -- read, search, edit, shell. Hosted by // the router itself, which is what makes them one set for the // machine rather than one per model. Which of them a *session* // offers its model is decided here in the backend, per request, so // there is nothing per-session to pass through: see `super::tools`. "--tools".to_string(), "all".to_string(), ]; let launch = Launch::new(&spec.program, args, None).reaching(forward); // Starting and reaping both happen inside the runtime, though this is // a blocking thread: tokio's `Command::spawn` registers the child with // the reactor, so calling it outside a runtime context panics -- and // that panic kills only this thread, leaving a session that says // "loading" for ever with nothing in the log. See [`Routers::runtime`]. let _inside = self.runtime.as_ref().map(tokio::runtime::Handle::enter); // Its output goes to a file, not a pipe. Not only so the process can // outlive this server: nothing ever read those pipes, so a chatty // llama-server filled the 64 KB buffer and blocked with no sign of why. let child = spec.transport.spawn( &launch, Streams::Detached { stdin: std::process::Stdio::null(), stdout: log_file(&self.dir.join(LOG))?.into(), stderr: log_file(&self.dir.join(LOG))?.into(), }, )?; let pid = child .id() .context("llama-server exited before it could be recorded")?; tracing::info!( "running {} in router mode {} on 127.0.0.1:{} there, reached at 127.0.0.1:{} here, \ as pid {pid}", spec.program, spec.transport.describe(), forward.there, forward.here, ); // Reaped so it does not become a zombie while this server is still // its parent; the record and the health poll are what say whether it // is alive, because after a restart there is no `Child` to ask. if let Some(runtime) = &self.runtime { runtime.spawn(async move { let mut child = child; let _ = child.wait().await; }); } // The *near* port, because that is the one anything reaching this // router has to dial -- including a later run of this backend, which // adopts the record without knowing which machine it is on. For a // remote machine the recorded pid is the ssh client's, which is the // process this machine owns and which holds the tunnel open for // exactly as long as the far router lives. let record = process::Record::of(pid, process::Detail::Http { port: forward.here }) .context("llama-server was gone before its start time could be read")?; process::write(&self.dir, &record); let endpoint = format!("http://127.0.0.1:{}", forward.here); self.wait_answering(&endpoint)?; Ok(endpoint) } /// Polls until the router answers, watching the process as well as the /// port: a port already taken, or a `llama-server` too old for one of /// these flags, exits within a second and would otherwise be waited out. fn wait_answering(&self, endpoint: &str) -> Result<()> { let deadline = std::time::Instant::now() + START_TIMEOUT; let url = format!("{endpoint}/health"); loop { if let Ok(response) = ureq::get(&url).call() && response.status() == 200 { return Ok(()); } match process::recorded(&self.dir) { Some((_, process::Liveness::Alive | process::Liveness::Unknown)) => {} Some((_, process::Liveness::Dead)) | None => { process::clear(&self.dir); bail!("llama-server exited before it answered.{}", self.log_tail()); } } if std::time::Instant::now() > deadline { process::clear(&self.dir); bail!( "llama-server did not answer within {}s.{}", START_TIMEOUT.as_secs(), self.log_tail() ); } std::thread::sleep(POLL); } } /// Polls until the named model is in memory, or says why it will never be. /// /// A model that will not load is the common failure and it is fast: the /// child exits, the router reports it unloaded with an exit code, and this /// says so rather than waiting out the timeout -- which is what turned "it /// said the file was corrupt" into "gave up after 300s". fn wait_loaded(&self, key: &str) -> Result<()> { let deadline = std::time::Instant::now() + LOAD_TIMEOUT; loop { match self.loaded().into_iter().find(|model| model.model == key) { Some(model) if model.ready => return Ok(()), Some(model) if model.failed => { bail!("{key} would not load.{}", self.log_tail()) } // Still loading, or not listed yet after a reload. Some(_) | None => {} } if process::live(&self.dir).is_none() { bail!( "llama-server went away while loading {key}.{}", self.log_tail() ); } if std::time::Instant::now() > deadline { bail!( "{key} was still loading after {}s.{}", LOAD_TIMEOUT.as_secs(), self.log_tail() ); } std::thread::sleep(POLL); } } /// One request to the router's management endpoints. The generation ones /// are the driver's and stream, so they are not these. fn get(&self, path: &str) -> Result { let url = format!("{}{path}", self.answering()?); Self::read( ureq::get(&url) .call() .with_context(|| format!("GET {path}"))?, path, ) } fn post(&self, path: &str, body: Value) -> Result { let url = format!("{}{path}", self.answering()?); Self::read( ureq::post(&url) .send_json(body) .with_context(|| format!("POST {path}"))?, path, ) } /// Where to reach a router that is running, or the failure saying it is /// not -- which is what every one of these requests needs first. fn answering(&self) -> Result { self.endpoint().context("no llama-server is running") } fn read(mut response: ureq::http::Response, path: &str) -> Result { response .body_mut() .read_json() .with_context(|| format!("reading what {path} answered")) } /// The preset file as it stands on the machine that serves the models, or /// empty where there is none yet. /// /// Read back rather than remembered, for one reason that matters after a /// restart: a router adopted from a previous run is already serving models /// whose sections this process has never seen, and rewriting the file /// without them would unload them at the next reload. fn preset(&self) -> Result { let spec = self.spec.lock().unwrap_or_else(|e| e.into_inner()).clone(); let text = match &spec.transport { Transport::Here => { Ok(std::fs::read_to_string(self.dir.join(PRESET)).unwrap_or_default()) } Transport::Ssh { name, .. } => { let script = format!("p={REMOTE_PRESET}; cat \"$p\" 2>/dev/null || true"); let launch = Launch::new("sh", vec!["-c".to_string(), script], None); spec.transport .capture_blocking(&launch) .with_context(|| format!("reading the model settings on {name}")) } }?; // A file that is not there yet reads as a new one rather than as // nothing: `llama-server` refuses a preset with no version line, so // "empty" is not a state this can hand back or write. Ok(if text.trim().is_empty() { VERSION.to_string() } else { text }) } /// Writes the preset file where the router will read it, and says where /// that is -- which is the path the router is given, so the two cannot /// disagree. fn write_preset(&self, text: &str) -> Result { let spec = self.spec.lock().unwrap_or_else(|e| e.into_inner()).clone(); match &spec.transport { Transport::Here => { let path = self.dir.join(PRESET); wg_app_link::private::write_file(&path, text.as_bytes())?; Ok(path.to_string_lossy().into_owned()) } Transport::Ssh { name, .. } => { use base64::Engine as _; // Base64 rather than a heredoc: the text goes through a shell // on the far side, and an INI value is not something to trust // to quoting rules twice over. let encoded = base64::engine::general_purpose::STANDARD.encode(text); let script = format!( "p={REMOTE_PRESET}; mkdir -p \"$(dirname \"$p\")\" && \ printf %s \"$1\" | base64 -d > \"$p\" && printf '%s\\n' \"$p\"" ); let launch = Launch::new( "sh", vec!["-c".to_string(), script, "sh".to_string(), encoded], None, ); let answer = spec .transport .capture_blocking(&launch) .with_context(|| format!("writing the model settings on {name}"))?; match answer.trim() { "" => bail!("{name} did not say where it wrote the model settings"), path => Ok(path.to_string()), } } } } } /// Where the preset file goes on a machine that is not this one, as a shell /// word the far side expands: under that machine's state directory, beside /// whatever else belongs to this app there. /// /// `$HOME` is resolved over there because only that machine knows what it is. const REMOTE_PRESET: &str = "\"${XDG_STATE_HOME:-$HOME/.local/state}/ai-app/llama-models.ini\""; /// One model the router knows about, as the provider view and the load poll /// both read it. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct RouterModel { pub model: String, /// The router's own word: `unloaded`, `loading`, `loaded`, `sleeping`. /// Carried through rather than reduced to a boolean, because the phone /// draws it and llama.cpp is the authority on what states there are. pub status: String, pub ready: bool, /// Unloaded *and* something went wrong, which is not the same as unloaded. #[serde(skip)] pub failed: bool, } impl RouterModel { fn read(entry: &Value) -> Option { let model = entry.get("id")?.as_str()?.to_string(); let status = entry .pointer("/status/value") .and_then(Value::as_str) .unwrap_or("unknown") .to_string(); let exited = entry .get("exit_code") .and_then(Value::as_i64) .unwrap_or_default(); Some(Self { ready: status == "loaded" || status == "sleeping", failed: status == "unloaded" && exited != 0, model, status, }) } } /// What a model's section says, from the file it is and the settings it has. /// /// The keys are `llama-server`'s own argument names without their dashes, /// which is what a preset section is: `--n-gpu-layers 20` is /// `n-gpu-layers = 20`. So adding a setting is a row here and a row in /// [`crate::config::LLAMA_MODEL_PARAMS`], and nothing in between. fn section(found: &Model, settings: &BTreeMap) -> String { let mut lines = vec![format!("model = {}", found.path)]; for (key, flag) in [ ("contextSize", "ctx-size"), ("gpuLayers", "n-gpu-layers"), ("threads", "threads"), // How far ahead the draft head guesses. Not defaulted: 2 measured 7% // faster than llama.cpp's 3 on this machine's GPU, once, which is a // reason to make the knob reachable and not a reason to move it for // everybody. ("specDraftNMax", "spec-draft-n-max"), ] { if let Some(value) = settings.get(key).map(|value| value.trim()) && !value.is_empty() { lines.push(format!("{flag} = {value}")); } } // One slot unless this model is told otherwise. A session is one // conversation making one request at a time, and a second session's turn // waits rather than splitting the cache: measured 2026-09-19 on the 27B // here, 41.5 tok/s plain at any slot count, **61.4** with the draft head // at one slot, and **28** with the head at four. Speculating against a // split KV cache is slower than not speculating at all. let slots = settings .get("slots") .map(|value| value.trim()) .filter(|value| !value.is_empty()) .unwrap_or("1"); lines.push(format!("parallel = {slots}")); // A model carrying a multi-token-prediction head drafts with it, which is // most of a 50% speed-up for free -- the tensors are in the file whether // or not they are used. Conditional because it cannot be otherwise: asked // for on a model without one, `llama-server` **exits** ("context type MTP // requested but model doesn't contain MTP layers"). See `Model::mtp`. if found.mtp && settings.get("speculative").map(String::as_str) != Some("off") { lines.push("spec-type = draft-mtp".to_string()); } // What makes a model able to read pictures, and the one flag here that is // found rather than defaulted: the projector is a second file published // beside the weights, so a model that has one is loaded with it unless // this model's settings name another or turn it off. if let Some(projector) = projector(found, settings) { lines.push(format!("mmproj = {projector}")); } lines.join("\n") } /// Which projector this model is loaded with: what its settings say, else /// whatever was found beside it, and nothing for `"off"`. /// /// A setting naming a bare file name means one in the model's own directory, /// since that is where the alternatives to the file found there are; anything /// with a `/` in it is taken as the path it is, absolute or not -- the serving /// machine resolves it, and this side does not know its working directory. fn projector(found: &Model, settings: &BTreeMap) -> Option { let chosen = settings.get("mmproj").map(|value| value.trim()); match chosen { Some("off") => None, Some("") => found.mmproj.clone(), Some(name) if name.contains('/') => Some(name.to_string()), Some(name) => { let dir = found.path.rsplit_once('/').map_or("", |(dir, _)| dir); Some(format!("{dir}/{name}")) } None => found.mmproj.clone(), } } /// The preset file with `name`'s section replaced by `body`, added at the end /// if it was not there. /// /// Text in and text out, rather than a parsed model, because the file belongs /// to `llama-server` rather than to this: anything in it that this does not /// understand -- a `[*]` section, a key added by a later version, a comment /// somebody wrote -- has to survive being edited. fn upsert(existing: &str, name: &str, body: &str) -> String { let header = format!("[{name}]"); let mut out = String::new(); let mut skipping = false; let mut replaced = false; for line in existing.lines() { let trimmed = line.trim(); if trimmed.starts_with('[') && trimmed.ends_with(']') { skipping = trimmed == header; if skipping { replaced = true; push_section(&mut out, &header, body); continue; } } if !skipping { out.push_str(line); out.push('\n'); } } if out.is_empty() { out.push_str(VERSION); } if !replaced { push_section(&mut out, &header, body); } out } fn push_section(out: &mut String, header: &str, body: &str) { if !out.ends_with("\n\n") && !out.is_empty() { out.push('\n'); } out.push_str(header); out.push('\n'); out.push_str(body.trim_end()); out.push_str("\n\n"); } /// An owner-only log opened for appending, so the two streams pointed at it do /// not overwrite each other and an adopted router keeps what came before. fn log_file(path: &Path) -> Result { use std::os::unix::fs::OpenOptionsExt; std::fs::OpenOptions::new() .create(true) .append(true) .mode(0o600) .open(path) .with_context(|| format!("opening {}", path.display())) } #[cfg(test)] mod tests { use super::*; fn settings(pairs: &[(&str, &str)]) -> BTreeMap { pairs .iter() .map(|(key, value)| ((*key).to_string(), (*value).to_string())) .collect() } #[test] fn a_section_names_the_file_and_the_flags_that_were_set() { let found = Model { mmproj: None, path: "/models/a.gguf".to_string(), mtp: true, }; let text = section( &found, &settings(&[("contextSize", "8192"), ("threads", " 6 ")]), ); assert_eq!( text, "model = /models/a.gguf\nctx-size = 8192\nthreads = 6\nparallel = 1\n\ spec-type = draft-mtp" ); // A blank is not a value: it is the setting being unset, and passing // it on is a child that exits on an empty argument. let text = section(&found, &settings(&[("contextSize", " ")])); assert!(!text.contains("ctx-size"), "{text}"); // The draft head is asked for only where the file has one, and can be // turned off for a machine where it does not pay. let plain = Model { mtp: false, ..found.clone() }; assert!(!section(&plain, &settings(&[])).contains("spec-type")); assert!(!section(&found, &settings(&[("speculative", "off")])).contains("spec-type")); } /// The projector found beside a model is loaded with it; the setting names /// another where a repository published several, or turns it off. #[test] fn a_vision_model_is_loaded_with_its_projector() { let found = Model { path: "/models/repo/a.gguf".to_string(), mtp: false, mmproj: Some("/models/repo/mmproj-F16.gguf".to_string()), }; let line = |settings: &[(&str, &str)]| { section(&found, &self::settings(settings)) .lines() .find_map(|line| line.strip_prefix("mmproj = ")) .map(str::to_string) }; assert_eq!(line(&[]), Some("/models/repo/mmproj-F16.gguf".to_string())); assert_eq!( line(&[("mmproj", " ")]), Some("/models/repo/mmproj-F16.gguf".to_string()) ); assert_eq!(line(&[("mmproj", "off")]), None); // A bare name is one of the model's own neighbours; anything with a // separator in it is the path it says it is. assert_eq!( line(&[("mmproj", "mmproj-F32.gguf")]), Some("/models/repo/mmproj-F32.gguf".to_string()), ); assert_eq!( line(&[("mmproj", "/elsewhere/p.gguf")]), Some("/elsewhere/p.gguf".to_string()), ); // A model with none, and nothing asked for, loads without one. assert!( !section( &Model { mmproj: None, ..found.clone() }, &settings(&[]) ) .contains("mmproj") ); } #[test] fn a_section_replaces_its_own_and_leaves_every_other_line_alone() { let first = upsert("", "repo/a.gguf", "model = /models/a.gguf"); assert!(first.starts_with("version = 1\n"), "{first}"); assert!( first.contains("[repo/a.gguf]\nmodel = /models/a.gguf\n"), "{first}" ); // A second model is added rather than replacing the first, because a // reload of a file that lost a section unloads that model -- which // would be one session taking another's model out of memory. let both = upsert(&first, "repo/b.gguf", "model = /models/b.gguf"); assert!(both.contains("[repo/a.gguf]"), "{both}"); assert!(both.contains("[repo/b.gguf]"), "{both}"); // Editing one rewrites only its own keys, and keeps what llama.cpp's // own file has that this does not know about. let with_global = format!( "version = 1\n\n[*]\njinja = true\n\n{}", both.trim_start_matches("version = 1\n") ); let edited = upsert( &with_global, "repo/a.gguf", "model = /models/a.gguf\nctx-size = 4096", ); assert!(edited.contains("[*]\njinja = true"), "{edited}"); assert!(edited.contains("ctx-size = 4096"), "{edited}"); assert_eq!(edited.matches("[repo/a.gguf]").count(), 1, "{edited}"); assert!( edited.contains("[repo/b.gguf]\nmodel = /models/b.gguf"), "{edited}" ); } #[test] fn writing_the_same_settings_twice_changes_nothing() { // What keeps an unrelated session's model in memory: the file is only // written, and the router only told to re-read it, when the text // actually differs. let once = upsert("", "repo/a.gguf", "model = /models/a.gguf"); assert_eq!(upsert(&once, "repo/a.gguf", "model = /models/a.gguf"), once); } }