Let a machine have more than one llama.cpp
A model whose kernels are not upstream needs the fork that has them, and the ordinary models still want the ordinary build. Anything under `~/.local/share/ai-app/llama/<name>/` -- `llama-server`, or the `bin/llama-server` a `cmake --install --prefix` leaves -- is now discovered beside the one on PATH and becomes a provider called `llama-cpp-<name>`, with its own router, preset and model settings. That keeps the module's security property rather than bending it: the phone still names no command, because what runs is still decided by what somebody put on the machine. Each probe answer is tagged with what was asked for, since two of these are now the same program under different paths. Flash attention joins the model settings (`flash-attn` in the preset). llama.cpp's `auto` stays the default; the control is for a model whose publisher asks for `on` outright, which Prism ML's ternary Bonsai does. Verified against the fork built into that directory: discovery answers `llama-cpp-prism`, the child server is started with `--flash-attn on`, and Ternary-Bonsai-2-27B PTQ1_0 loads and answers through a session.
This commit is contained in:
1 parent
df48a334f7
commit
7e7910083c
6 files changed
+220
-16
No files matched your search
@@ -550,6 +550,19 @@ pub const LLAMA_MODEL_PARAMS: &[ParamSpec] = &[
|
||||
kind: ParamKind::Integer,
|
||||
restart: true,
|
||||
},
|
||||
ParamSpec {
|
||||
key: "flashAttention",
|
||||
label: "Flash attention",
|
||||
// llama.cpp's `auto` is the right default and stays it. The control
|
||||
// is here because a model's publisher can ask for `on` outright --
|
||||
// Prism ML's ternary Bonsai does -- which is a statement about the
|
||||
// file that `auto` would be taking from the backend instead.
|
||||
unset: "llama.cpp's own choice",
|
||||
kind: ParamKind::Choice {
|
||||
options: &["auto", "on", "off"],
|
||||
},
|
||||
restart: true,
|
||||
},
|
||||
ParamSpec {
|
||||
key: "speculative",
|
||||
label: "Speculative decoding",
|
||||
|
||||
+135
-16
@@ -12,7 +12,9 @@
|
||||
//!
|
||||
//! The cost is that a program somewhere unusual is invisible. The escape hatch
|
||||
//! is editing `config.ron` on the backend, which is exactly the authority the
|
||||
//! phone is not being given.
|
||||
//! phone is not being given -- and for the case that keeps arising, a second
|
||||
//! llama.cpp built to serve a model the ordinary one cannot, there is a
|
||||
//! directory a build is put in to be found: [`LLAMA_BUILDS`].
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
@@ -38,17 +40,49 @@ const PROBES: &[(&str, &str, DriverKind)] = &[
|
||||
/// screen, not a restriction -- the field stays free text.
|
||||
const CLAUDE_MODELS: &[&str] = &["fable", "opus", "sonnet", "haiku"];
|
||||
|
||||
/// Asks `transport`'s machine which of [`PROBES`] it has.
|
||||
/// Where a machine keeps the llama.cpp builds it has besides the one on its
|
||||
/// PATH: one directory per build, holding either `llama-server` itself or the
|
||||
/// `bin/llama-server` that `cmake --install` puts there.
|
||||
///
|
||||
/// A model whose kernels are not upstream -- Prism ML's ternary Bonsai
|
||||
/// packings are the case this was built for -- needs the fork that has them,
|
||||
/// while the ordinary models still want the ordinary build. So each directory
|
||||
/// found here is a provider of its own, with its own router process, its own
|
||||
/// model settings and its own sessions.
|
||||
///
|
||||
/// **A directory rather than a path the phone could type**, which is the whole
|
||||
/// design of this module: no route accepts a command to run, so putting a
|
||||
/// build here is a decision somebody makes on the machine itself. The name of
|
||||
/// the directory is what the provider is called, so it is worth choosing.
|
||||
const LLAMA_BUILDS: &str = "$HOME/.local/share/ai-app/llama";
|
||||
|
||||
/// The word a [`LLAMA_BUILDS`] line is tagged with, which is not a program
|
||||
/// name and so cannot collide with one.
|
||||
const BUILD: &str = "build";
|
||||
|
||||
/// Asks `transport`'s machine which of [`PROBES`] it has, and which llama.cpp
|
||||
/// builds are in [`LLAMA_BUILDS`].
|
||||
///
|
||||
/// One round trip rather than one per program: over ssh each would be a separate
|
||||
/// connection and handshake. `command -v` is POSIX and a shell builtin, so it
|
||||
/// works whatever is installed -- and `|| true` keeps a missing program from
|
||||
/// ending the loop, since the caller wants the whole answer.
|
||||
/// works whatever is installed -- and each answer is tagged with what was asked
|
||||
/// for, since two of these are the same program under different paths.
|
||||
///
|
||||
/// Each test is an `if`'s condition rather than the loop body's last command,
|
||||
/// so that finding nothing is not the script's exit status -- the caller reads
|
||||
/// a non-zero exit as a machine it could not reach. And a build has to be a
|
||||
/// *file*, since a directory carries the executable bit too.
|
||||
pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
|
||||
let wanted: Vec<&str> = PROBES.iter().map(|(_, binary, _)| *binary).collect();
|
||||
let script = format!(
|
||||
"for p in {}; do command -v \"$p\" || true; done",
|
||||
wanted.join(" ")
|
||||
"for p in {wanted}; do \
|
||||
if q=$(command -v \"$p\"); then printf '%s\\t%s\\n' \"$p\" \"$q\"; fi; \
|
||||
done; \
|
||||
d={LLAMA_BUILDS}; \
|
||||
for p in \"$d\"/*/llama-server \"$d\"/*/bin/llama-server; do \
|
||||
if [ -f \"$p\" ] && [ -x \"$p\" ]; then printf '{BUILD}\\t%s\\n' \"$p\"; fi; \
|
||||
done",
|
||||
wanted = wanted.join(" "),
|
||||
);
|
||||
let launch = Launch::new("sh", vec!["-c".to_string(), script], None);
|
||||
let found = transport.capture(&launch).await.map_err(explain)?;
|
||||
@@ -60,17 +94,38 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
|
||||
if matches!(transport, Transport::Here) {
|
||||
providers.push(crate::config::Config::echo_provider());
|
||||
}
|
||||
for (name, binary, kind) in PROBES {
|
||||
let path = found
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| line.rsplit('/').next() == Some(*binary));
|
||||
let Some(path) = path else {
|
||||
providers.extend(probed(&found));
|
||||
Ok(providers)
|
||||
}
|
||||
|
||||
/// What the probe script's output says is installed.
|
||||
///
|
||||
/// Separated from the round trip so it can be exercised without a machine.
|
||||
fn probed(found: &str) -> Vec<ProviderConfig> {
|
||||
let mut providers: Vec<ProviderConfig> = Vec::new();
|
||||
for line in found.lines() {
|
||||
let Some((key, path)) = line.trim().split_once('\t') else {
|
||||
continue;
|
||||
};
|
||||
let (name, kind) = if key == BUILD {
|
||||
let Some(name) = build_name(path) else {
|
||||
continue;
|
||||
};
|
||||
(name, DriverKind::LlamaCpp)
|
||||
} else {
|
||||
let Some((name, _, kind)) = PROBES.iter().find(|(_, binary, _)| *binary == key) else {
|
||||
continue;
|
||||
};
|
||||
((*name).to_string(), *kind)
|
||||
};
|
||||
// A directory holding both shapes is matched by both globs. Two
|
||||
// providers of one name is a config that silently loses one of them.
|
||||
if providers.iter().any(|already| already.name == name) {
|
||||
continue;
|
||||
}
|
||||
providers.push(ProviderConfig {
|
||||
name: (*name).to_string(),
|
||||
kind: *kind,
|
||||
name,
|
||||
kind,
|
||||
// The resolved path rather than the bare name: PATH under a
|
||||
// non-interactive ssh session is not the one a person sees when they
|
||||
// log in, so "it is on my PATH" is not enough.
|
||||
@@ -79,7 +134,7 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
|
||||
DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
mcp_servers: mcp_defaults(*kind),
|
||||
mcp_servers: mcp_defaults(kind),
|
||||
// What a probe cannot know: how this machine's models are loaded
|
||||
// is configured after the fact, and a re-probe keeps it -- see
|
||||
// `SessionManager::update_machine`.
|
||||
@@ -87,7 +142,21 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
|
||||
max_loaded: None,
|
||||
});
|
||||
}
|
||||
Ok(providers)
|
||||
providers
|
||||
}
|
||||
|
||||
/// What to call the provider for a build found at `path`: the name of its own
|
||||
/// directory, under the `llama-cpp` the plain one already has.
|
||||
///
|
||||
/// The `bin/` a `cmake --install` produces is not part of the name -- a build
|
||||
/// installed as a prefix and one that is a single binary in a directory are
|
||||
/// the same build, and naming them differently would make moving between them
|
||||
/// lose the model settings kept against the name.
|
||||
fn build_name(path: &str) -> Option<String> {
|
||||
let dir = path.rsplit_once('/')?.0;
|
||||
let dir = dir.strip_suffix("/bin").unwrap_or(dir);
|
||||
let name = dir.rsplit('/').next()?;
|
||||
(!name.is_empty()).then(|| format!("llama-cpp-{name}"))
|
||||
}
|
||||
|
||||
/// One model a picker can offer, and what to call it there.
|
||||
@@ -316,6 +385,56 @@ mod tests {
|
||||
assert_eq!(tidy(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_llama_build_is_a_provider_named_for_its_directory() {
|
||||
let found = "llama-server\t/usr/bin/llama-server\n\
|
||||
build\t/home/me/.local/share/ai-app/llama/prism/bin/llama-server\n\
|
||||
build\t/home/me/.local/share/ai-app/llama/nightly/llama-server\n";
|
||||
let providers = probed(found);
|
||||
let named: Vec<(&str, &str)> = providers
|
||||
.iter()
|
||||
.map(|p| (p.name.as_str(), p.program()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
named,
|
||||
vec![
|
||||
("llama-cpp", "/usr/bin/llama-server"),
|
||||
(
|
||||
"llama-cpp-prism",
|
||||
"/home/me/.local/share/ai-app/llama/prism/bin/llama-server"
|
||||
),
|
||||
(
|
||||
"llama-cpp-nightly",
|
||||
"/home/me/.local/share/ai-app/llama/nightly/llama-server"
|
||||
),
|
||||
]
|
||||
);
|
||||
assert!(providers.iter().all(|p| p.kind == DriverKind::LlamaCpp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_build_matched_twice_is_one_provider() {
|
||||
let found = "build\t/home/me/.local/share/ai-app/llama/prism/llama-server\n\
|
||||
build\t/home/me/.local/share/ai-app/llama/prism/bin/llama-server\n";
|
||||
let providers = probed(found);
|
||||
assert_eq!(providers.len(), 1);
|
||||
assert_eq!(
|
||||
providers[0].program(),
|
||||
"/home/me/.local/share/ai-app/llama/prism/llama-server"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_this_does_not_understand_is_not_a_provider() {
|
||||
let found = "warning: something on stderr\n\
|
||||
\n\
|
||||
ruby\t/usr/bin/ruby\n\
|
||||
codex\t/usr/bin/codex\n";
|
||||
let providers = probed(found);
|
||||
assert_eq!(providers.len(), 1);
|
||||
assert_eq!(providers[0].name, "codex-cli");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_models_are_the_selectable_non_hidden_catalog_entries() {
|
||||
let answer = json!({"result": {"data": [
|
||||
|
||||
@@ -675,6 +675,7 @@ fn section(found: &Model, settings: &BTreeMap<String, String>) -> String {
|
||||
("contextSize", "ctx-size"),
|
||||
("gpuLayers", "n-gpu-layers"),
|
||||
("threads", "threads"),
|
||||
("flashAttention", "flash-attn"),
|
||||
// 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
|
||||
|
||||
Reference in new issue
Block a user