Declare provider settings, and give the context figure a denominator
Two things a session could not say, and one it was saying wrongly.
**Every provider setting is reachable.** `-np 1`, the MTP draft depth, the
tool set, the sampling parameters -- most were hardcoded to what measured
best on this machine, which is right as a default and wrong as a constant:
the next machine has a different GPU and a different core count, and
nobody running this app can edit the source. `DriverKind::params` now
declares what a provider takes -- key, label, shape, what blank means, and
whether a change waits for a restart -- and the phone renders whatever
arrives, on the spawn form and in the session settings dialog. Adding a
setting to a driver is one entry in that table and no app change.
`POST /sessions/{id}/params` takes the whole map, so an absent key is the
instruction to unset; the sampling half applies at once and the session is
told in words which of the rest are waiting for a restart.
`tools` is one of them, because it is the biggest lever on a tight
context: the seven built-in definitions are ~1,300 tokens of every prompt
(2,191 against 887 with none). `"none"` omits the flag rather than passing
it on, since `--tools none` is `unknown tool "none"` and a server that
exits.
**The context figure has a denominator.** `Event::ContextWindow` carries
it, read from `llama-server`'s `/props` once the model is up -- the
measurement rather than the request, since a session that named no context
size gets the model's own. Neither coding CLI states its window, so those
keep the bare figure: "2,042" and "2,042 / 8,192" are deliberately
different-looking, and a missing ceiling is never drawn as a proportion of
an assumed one.
**And the numerator was wrong**, by the length of the last reply: it was
the prompt alone, so a five-word answer reported 2,042 against a slot
holding 2,355. It is the turn's total now, which matches `llama-server`'s
own `n_tokens` to within a token.
Two defects the review found, both of which would have shipped: changing
settings on a *stopped* session reported "no process running, so it can't
take new settings", when a stopped session is exactly when you would set
them for the next start; and `GET /tools` answers **403** rather than an
empty list on a server started without `--tools`, so reading it as a
failure made the no-tools session one that never started.
Verified against real models: settings spawned and changed live, the
restart note, a session with two tools and one with none, and the counter
checked against the server's own slot occupancy each time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ac476ab0c9
commit
81ab564a09
18 files changed
+831
-115
No files matched your search
+159
-35
@@ -65,6 +65,32 @@ pub use tools::{DEFAULT_MODE as DEFAULT_PERMISSION_MODE, MODES as PERMISSION_MOD
|
||||
use mcp::McpServer;
|
||||
use tools::Tools;
|
||||
|
||||
/// The sampling half of a session's settings, in the wire's own names.
|
||||
///
|
||||
/// One function because two callers need the identical mapping: the spawn, and
|
||||
/// a later change through [`Driver::set_params`]. A value that will not parse
|
||||
/// as a number is left out rather than passed through -- `llama-server` would
|
||||
/// refuse the whole request for it, which would look like the session breaking
|
||||
/// rather than like one field being wrong.
|
||||
fn sampling_from(
|
||||
params: &std::collections::BTreeMap<String, String>,
|
||||
) -> serde_json::Map<String, Value> {
|
||||
let mut sampling = serde_json::Map::new();
|
||||
for (key, field) in [
|
||||
("temperature", "temperature"),
|
||||
("topP", "top_p"),
|
||||
("topK", "top_k"),
|
||||
("maxTokens", "max_tokens"),
|
||||
] {
|
||||
if let Some(raw) = params.get(key)
|
||||
&& let Ok(number) = raw.parse::<f64>()
|
||||
{
|
||||
sampling.insert(field.to_string(), json!(number));
|
||||
}
|
||||
}
|
||||
sampling
|
||||
}
|
||||
|
||||
/// Exa's own hosted MCP server, which is what a llama session searches the web
|
||||
/// with. The address llama.cpp's web UI offers under "Exa" in its recommended
|
||||
/// servers, so a session here reaches the same thing that UI does.
|
||||
@@ -76,6 +102,11 @@ pub const EXA_MCP_URL: &str = "https://mcp.exa.ai/mcp";
|
||||
/// is the escape for a machine where drafting turns out not to pay.
|
||||
const SPECULATIVE: &str = "speculative";
|
||||
|
||||
/// The spawn parameter naming which built-in tools a session gets, as
|
||||
/// `llama-server`'s own comma-separated list. Absent is all of them, and
|
||||
/// `"none"` is the way to ask for a session that only talks.
|
||||
const TOOLS: &str = "tools";
|
||||
|
||||
/// How many times one message may go round the call-a-tool loop.
|
||||
///
|
||||
/// A bound rather than a budget: a small model that has decided to read the
|
||||
@@ -221,8 +252,10 @@ struct Shared {
|
||||
/// Where this session's process record lives, so [`Driver::stop`] can find
|
||||
/// the server it has to end.
|
||||
session_dir: PathBuf,
|
||||
/// Sampling settings chosen at spawn, sent with every request.
|
||||
sampling: serde_json::Map<String, Value>,
|
||||
/// Sampling settings, sent with every request. Behind a lock because they
|
||||
/// are changeable while the session runs: they ride on the next request,
|
||||
/// so unlike the server's own flags there is nothing to reload.
|
||||
sampling: Mutex<serde_json::Map<String, Value>>,
|
||||
/// The session's working directory, which is where its tools act. `None`
|
||||
/// leaves that to `llama-server`, which is the honest answer rather than a
|
||||
/// guess at one.
|
||||
@@ -289,26 +322,14 @@ impl LlamaDriver {
|
||||
"a llama.cpp session needs a model -- one of the downloaded ones, by its key",
|
||||
)?;
|
||||
|
||||
let mut sampling = serde_json::Map::new();
|
||||
for (key, field) in [
|
||||
("temperature", "temperature"),
|
||||
("topP", "top_p"),
|
||||
("topK", "top_k"),
|
||||
("maxTokens", "max_tokens"),
|
||||
] {
|
||||
if let Some(raw) = meta.params.get(key)
|
||||
&& let Ok(number) = raw.parse::<f64>()
|
||||
{
|
||||
sampling.insert(field.to_string(), json!(number));
|
||||
}
|
||||
}
|
||||
let sampling = sampling_from(&meta.params);
|
||||
|
||||
let driver = Self {
|
||||
shared: Arc::new(Shared {
|
||||
sink,
|
||||
transcript: transcript.to_path_buf(),
|
||||
session_dir: session_dir.to_path_buf(),
|
||||
sampling,
|
||||
sampling: Mutex::new(sampling),
|
||||
cwd: meta
|
||||
.cwd
|
||||
.as_ref()
|
||||
@@ -403,6 +424,15 @@ impl LlamaDriver {
|
||||
"{model} loaded and answering at {endpoint} with {} tools",
|
||||
tools.offered().map_or(0, |offered| offered.len()),
|
||||
);
|
||||
// Asked now rather than carried from the spawn flags: a
|
||||
// session that named no context size gets the model's
|
||||
// own, which only the loaded server knows, and one that
|
||||
// named an impossible one gets whatever it settled for.
|
||||
// Either way this is the measurement rather than the
|
||||
// request.
|
||||
if let Some(window) = context_window(&endpoint) {
|
||||
shared.emit(Event::ContextWindow { tokens: window });
|
||||
}
|
||||
Serving::Ready {
|
||||
endpoint: endpoint.clone(),
|
||||
tools: Arc::new(tools),
|
||||
@@ -531,14 +561,12 @@ fn spawn_server(
|
||||
"127.0.0.1".into(),
|
||||
"--port".into(),
|
||||
forward.there.to_string(),
|
||||
// The built-in agent tools -- read, search, edit, shell. Every one of
|
||||
// them, because a session offered a subset is a session that says "I
|
||||
// can't do that" about something it was installed to do, and the
|
||||
// question of whether a particular call should happen is the
|
||||
// permission gate's rather than a flag's. They run on the machine
|
||||
// serving the model, which is the machine the files are on.
|
||||
"--tools".into(),
|
||||
"all".into(),
|
||||
// The built-in agent tools -- read, search, edit, shell. All of them
|
||||
// unless the session says otherwise, because whether a particular call
|
||||
// should happen is the permission gate's question rather than a flag's.
|
||||
// They run on the machine serving the model, which is the machine the
|
||||
// files are on.
|
||||
//
|
||||
// One slot, not the four `llama-server` picks on its own. A session is
|
||||
// one conversation making one request at a time -- the driver holds a
|
||||
// second message until the turn ends -- so the other three are context
|
||||
@@ -553,6 +581,27 @@ fn spawn_server(
|
||||
"-np".into(),
|
||||
"1".into(),
|
||||
];
|
||||
// Settable because it is not free: the definitions of all seven are around
|
||||
// 2,000 tokens of every prompt -- measured at 2,191 against 1,322 for two
|
||||
// of them -- which on a small context window is a quarter of it spent
|
||||
// before anything is said.
|
||||
//
|
||||
// "none" omits the flag rather than passing it on: `--tools none` is
|
||||
// `tools setup failed: unknown tool "none"` and a server that exits, since
|
||||
// the argument is a list of tool names and no-tools is what having no flag
|
||||
// means.
|
||||
match meta.params.get(TOOLS).map(|chosen| chosen.trim()) {
|
||||
Some("none") => {}
|
||||
chosen => {
|
||||
args.push("--tools".into());
|
||||
args.push(
|
||||
chosen
|
||||
.filter(|c| !c.is_empty())
|
||||
.unwrap_or("all")
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
// A model that carries 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, and without the flag `llama-server` says
|
||||
@@ -573,6 +622,11 @@ fn spawn_server(
|
||||
("contextSize", "-c"),
|
||||
("gpuLayers", "-ngl"),
|
||||
("threads", "-t"),
|
||||
// How far ahead the draft head guesses. Not defaulted here: 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) = meta.params.get(key) {
|
||||
args.push(flag.to_string());
|
||||
@@ -854,7 +908,10 @@ fn converse(
|
||||
if shared.cancel.load(Ordering::SeqCst) {
|
||||
return Ok(());
|
||||
}
|
||||
let reply = generate(endpoint, &messages, tools, &shared.sampling, shared)?;
|
||||
// Read per call rather than per turn, so a sampling change made while
|
||||
// a long turn is running reaches the rest of it.
|
||||
let sampling = shared.sampling.lock().unwrap().clone();
|
||||
let reply = generate(endpoint, &messages, tools, &sampling, shared)?;
|
||||
let calls = reply.calls;
|
||||
messages.push(Message {
|
||||
tool_calls: calls.iter().map(Call::wire).collect(),
|
||||
@@ -1046,6 +1103,49 @@ impl Driver for LlamaDriver {
|
||||
// is called, and the rename has already happened where the name lives.
|
||||
fn set_title(&self, _title: &str) {}
|
||||
|
||||
/// Takes new settings: the sampling half now, and says so about the rest.
|
||||
///
|
||||
/// The split is what [`crate::config::ParamSpec::restart`] describes, and
|
||||
/// it is said out loud rather than left to the screen, because the screen
|
||||
/// can only say what a setting *usually* does -- this is the one place
|
||||
/// that knows whether this session's server was started with the old
|
||||
/// value. A session already stopped needs no such note: its next start
|
||||
/// will read all of them.
|
||||
fn set_params(&self, params: &std::collections::BTreeMap<String, String>) {
|
||||
*self.shared.sampling.lock().unwrap() = sampling_from(params);
|
||||
// Only the settings that actually differ from what this session's
|
||||
// server was started with. Listing every restart-only one on every
|
||||
// save would be a wall of text about nothing having changed.
|
||||
let waiting: Vec<&str> = crate::config::DriverKind::LlamaCpp
|
||||
.params()
|
||||
.iter()
|
||||
.filter(|spec| {
|
||||
spec.restart && params.get(spec.key) != self.respawn.meta.params.get(spec.key)
|
||||
})
|
||||
.map(|spec| spec.label)
|
||||
.collect();
|
||||
// Nothing to say to a session with no server: its next start reads all
|
||||
// of them, which is what the note would have been asking for.
|
||||
let running = matches!(&*self.shared.serving.lock().unwrap(), Serving::Ready { .. });
|
||||
if !waiting.is_empty() && running {
|
||||
let one = waiting.len() == 1;
|
||||
self.shared.emit(Event::Error {
|
||||
message: format!(
|
||||
"{} {} saved. {} when this session's server next starts -- stop and start \
|
||||
the session, or change its model, to load {} now.",
|
||||
waiting.join(", "),
|
||||
if one { "is" } else { "are" },
|
||||
if one {
|
||||
"It takes effect"
|
||||
} else {
|
||||
"They take effect"
|
||||
},
|
||||
if one { "it" } else { "them" },
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn set_permission_mode(&self, mode: &str) {
|
||||
if !tools::MODES.contains(&mode) {
|
||||
self.shared.emit(Event::Error {
|
||||
@@ -1436,6 +1536,23 @@ fn log_tail(session_dir: &Path) -> String {
|
||||
/// How much of that log to carry into a message somebody reads on a phone.
|
||||
const LOG_TAIL_LINES: usize = 6;
|
||||
|
||||
/// How many tokens this server can hold, from the server itself.
|
||||
///
|
||||
/// `/props` reports the per-slot context, which is the whole of it because
|
||||
/// this driver always starts one slot -- see the `-np` argument. `None` for a
|
||||
/// server that would not answer, which draws as no ceiling rather than as a
|
||||
/// guessed one.
|
||||
fn context_window(endpoint: &str) -> Option<u64> {
|
||||
ureq::get(format!("{endpoint}/props"))
|
||||
.call()
|
||||
.ok()?
|
||||
.body_mut()
|
||||
.read_json::<Value>()
|
||||
.ok()?
|
||||
.pointer("/default_generation_settings/n_ctx")
|
||||
.and_then(Value::as_u64)
|
||||
}
|
||||
|
||||
/// One streamed completion: posts the conversation, emits each text delta as
|
||||
/// it arrives, and assembles whatever tool calls came with it.
|
||||
///
|
||||
@@ -1491,9 +1608,16 @@ fn generate(
|
||||
// at once interleaves their fragments, each tagged with its index.
|
||||
let mut calls: Vec<Call> = Vec::new();
|
||||
let mut tokens = 0u64;
|
||||
// The prompt side only, which is what the model is holding -- the same
|
||||
// definition the other dialects report, so one word on the phone means one
|
||||
// thing whichever kind of session it is.
|
||||
// What the model is holding when this call ends: the prompt it was given
|
||||
// plus the reply it produced, which is exactly what the next call's prompt
|
||||
// begins with.
|
||||
//
|
||||
// The prompt alone was wrong, and measurably: a five-word reply reported
|
||||
// 2,042 against a slot holding 2,355 (2026-09-19, checked against
|
||||
// `stop processing: n_tokens` in the server's own log). The gap is the
|
||||
// reply, so it grows with how much the model just said -- which is the
|
||||
// worst direction for a figure somebody is watching to see how much room
|
||||
// is left.
|
||||
let mut context = None;
|
||||
for line in std::io::BufRead::lines(reader) {
|
||||
if shared.cancel.load(Ordering::SeqCst) {
|
||||
@@ -1510,13 +1634,13 @@ fn generate(
|
||||
let Ok(chunk) = serde_json::from_str::<Value>(payload) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(usage) = chunk.get("usage") {
|
||||
if let Some(total) = usage.get("total_tokens").and_then(Value::as_u64) {
|
||||
tokens = total;
|
||||
}
|
||||
if let Some(prompt) = usage.get("prompt_tokens").and_then(Value::as_u64) {
|
||||
context = Some(prompt);
|
||||
}
|
||||
// One figure answering both questions, which for this dialect it
|
||||
// genuinely does: what the call was charged for and what the model is
|
||||
// left holding are the same tokens, because nothing here is billed and
|
||||
// the whole conversation is resent every time.
|
||||
if let Some(total) = chunk.pointer("/usage/total_tokens").and_then(Value::as_u64) {
|
||||
tokens = total;
|
||||
context = Some(total);
|
||||
}
|
||||
let Some(delta) = chunk.pointer("/choices/0/delta") else {
|
||||
continue;
|
||||
|
||||
Reference in new issue
Block a user