ref:4719e1397129e617ec786e155be98c0b7d17a355

feat(runner): report each model's context length, and honour the num_ctx asked for

Ollama defaults `num_ctx` to 4096 whatever the model can actually hold, and an oversized prompt is not an error there — it silently drops the head of the context, taking the system prompt and the tool definitions with it. The model then answers something it was never asked, intermittently, depending on how long the conversation has got. Anvil can only size a request correctly if it knows the model's real window, and only this host can answer that: the same tag pulled at a different quantization on a different machine is a different window. Two halves: * **Report it.** The heartbeat's `inference_models` entries gain `context_length`, read from `/api/show`'s architecture-prefixed `<arch>.context_length` (`llama.context_length`, `qwen2.context_length`, …). `/api/tags` does not carry it, so this is one extra request per model — cached by digest across heartbeats and bounded to four new lookups per beat, so a host that just pulled twenty models spreads the cost over several beats instead of stalling one. A model whose length cannot be determined is reported without the key: the server treats absent as *unknown* and sizes conservatively, which is the right direction to be wrong in. `/api/show` also carries capabilities, so a build whose `/api/tags` omits them now reports them too. * **Apply it.** `num_ctx` in an inference request's `options` is passed through to Ollama. A request without it (an older server) keeps Ollama's default, exactly as before. Six unit tests over the `/api/show` parsing: the architecture-prefixed key, the fallback scan when `general.architecture` is absent, the `details.family` fallback, unknown staying unknown rather than being guessed, an empty capability list meaning unknown rather than empty, and the digest keying the cache so a re-pull relearns. Companion to fangorn/anvil#252, which consumes this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SHA: 4719e1397129e617ec786e155be98c0b7d17a355
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-08-05 22:14
Parents: 526a448
2 files changed +262 -17
Type
src/runner/heartbeat.rs +247 −17
@@ -1,7 +1,22 @@
use crate::runner::slot_counter::SlotCounter;
use crate::runner::RunnerConfig;
use reqwest::header::{HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use std::collections::HashMap;
/// Per-model facts that `/api/tags` does not carry, cached across heartbeats.
///
/// `/api/tags` is one cheap request for the whole inventory; the context
/// length lives in `/api/show`, which is one request *per model*. Doing that
/// every heartbeat would put N requests on the Ollama host every few seconds
/// to re-learn a constant. The digest keys the cache because it is what
/// actually changes when a tag is re-pulled — a model re-pulled at a different
/// quantization keeps its name and can change its context length.
#[derive(Clone, Default)]
struct ModelDetail {
context_length: Option<u64>,
capabilities: Option<Vec<String>>,
}
/// Start a heartbeat loop that sends periodic pings to the server with
/// capacity telemetry (`parallel` total slots, `slots_busy` currently
/// executing). Older servers that don't parse those fields are a no-op.
@@ -18,6 +33,7 @@
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_millis(interval_ms));
let mut details: HashMap<String, ModelDetail> = HashMap::new();
loop {
interval.tick().await;
@@ -32,6 +48,6 @@
"slots_busy": slots.busy(),
});
if let Some(models) = local_models(&client, &ollama_url, &mut details).await {
if let Some(models) = local_models(&client, &ollama_url).await {
body["metadata"] = serde_json::json!({ "inference_models": models });
}
@@ -80,16 +96,26 @@
/// What the local Ollama can serve, for the heartbeat's `metadata`.
///
/// Each entry carries the model name and the capabilities Ollama reports for
/// it (`"completion"`, `"tools"`, `"thinking"`, …). The server filters on
/// these: an agent chat always sends a tool set, so a model without `tools`
/// fails on its first call, and offering it in the picker is offering a
/// guaranteed error.
/// Each entry carries the model name, the capabilities Ollama reports for it
/// (`"completion"`, `"tools"`, `"thinking"`, …), and its **context length**.
///
/// The server filters on capabilities: an agent chat always sends a tool set,
/// so a model without `tools` fails on its first call, and offering it in the
/// picker is offering a guaranteed error.
///
/// The context length is what lets the server size the request it sends. Ollama
/// defaults `num_ctx` to 4096 regardless of what the model can actually hold,
/// and it does not error on an oversized prompt — it silently drops the head of
/// the context, which is where the system prompt and tool definitions live. The
/// server can only ask for the model's real window if it knows what that is,
/// and only this host can answer: the same tag can be pulled at different
/// quantizations on different machines.
///
/// A model whose capabilities Ollama does not report is sent with the key
/// omitted rather than an empty list — the server treats that as *unknown*
/// and keeps the model, which is what a caller filtering on a capability
/// should do when nothing is known. `context_length` is omitted on the same
/// should do when nothing is known.
/// principle: absent means "ask conservatively", not "this model has none".
///
/// Best-effort: Ollama being down or slow must never cost us a heartbeat, so a
/// failure returns `None` and the heartbeat goes out without the key rather
@@ -98,11 +124,12 @@
async fn local_models(
client: &reqwest::Client,
ollama_url: &str,
details: &mut HashMap<String, ModelDetail>,
) -> Option<Vec<serde_json::Value>> {
let base = ollama_url.trim_end_matches('/');
let url = format!("{}/api/tags", ollama_url.trim_end_matches('/'));
let resp = client
.get(&url)
.get(format!("{base}/api/tags"))
.timeout(std::time::Duration::from_secs(3))
.send()
.await
@@ -113,22 +140,72 @@
}
let body: serde_json::Value = resp.json().await.ok()?;
let tags = body.get("models")?.as_array()?.clone();
// Learn the details of anything new before building the report. Bounded per
// heartbeat so a host that just pulled twenty models spreads the `/api/show`
// calls over several beats instead of stalling one for a minute; the rest
// are picked up on the next tick and reported without context in the
let models: Vec<serde_json::Value> = body
.get("models")?
.as_array()?
// meantime, which is the conservative direction to be wrong in.
let mut budget = SHOW_CALLS_PER_HEARTBEAT;
for tag in &tags {
if budget == 0 {
break;
}
let Some(key) = detail_key(tag) else { continue };
if details.contains_key(&key) {
continue;
}
let Some(name) = tag.get("name").and_then(|v| v.as_str()) else {
continue;
};
budget -= 1;
details.insert(
key,
show_model(client, base, name).await.unwrap_or_default(),
);
}
// Anything no longer in the inventory was deleted or re-pulled; drop it so
// the cache tracks the host rather than growing for the process's lifetime.
let live: std::collections::HashSet<String> = tags.iter().filter_map(detail_key).collect();
details.retain(|k, _| live.contains(k));
let models: Vec<serde_json::Value> = tags
.iter()
.filter_map(|m| {
let name = m.get("name")?.as_str()?;
let detail = detail_key(m)
.and_then(|k| details.get(&k))
.cloned()
.unwrap_or_default();
let mut entry = serde_json::json!({ "name": name });
if let Some(caps) = m.get("capabilities").and_then(|c| c.as_array()) {
let caps: Vec<&str> = caps.iter().filter_map(|c| c.as_str()).collect();
if !caps.is_empty() {
entry["capabilities"] = serde_json::json!(caps);
}
// `/api/tags` reports capabilities on newer Ollama builds and
// `/api/show` on all of them, so prefer the tag (already in hand)
// and fall back to what `show` told us.
let caps = m
.get("capabilities")
.and_then(|c| c.as_array())
.map(|caps| {
caps.iter()
.filter_map(|c| c.as_str().map(str::to_string))
.collect::<Vec<String>>()
})
.filter(|caps| !caps.is_empty())
.or(detail.capabilities);
if let Some(caps) = caps {
entry["capabilities"] = serde_json::json!(caps);
}
if let Some(ctx) = detail.context_length {
entry["context_length"] = serde_json::json!(ctx);
}
Some(entry)
})
.collect();
@@ -137,5 +214,158 @@
None
} else {
Some(models)
}
}
/// How many `/api/show` calls one heartbeat may spend learning new models.
const SHOW_CALLS_PER_HEARTBEAT: usize = 4;
/// Cache key for a tag: its digest, which changes when the tag is re-pulled.
/// Falls back to the name on a build that doesn't report one — worse (a
/// re-pull at a new quantization keeps a stale context length until restart)
/// but never wrong enough to skip caching entirely.
fn detail_key(tag: &serde_json::Value) -> Option<String> {
let digest = tag.get("digest").and_then(|v| v.as_str());
let name = tag.get("name").and_then(|v| v.as_str())?;
Some(digest.unwrap_or(name).to_string())
}
/// `POST /api/show` for one model's architecture facts.
///
/// The context length is reported under an architecture-prefixed key —
/// `llama.context_length`, `qwen2.context_length`, `gemma3.context_length` —
/// so there is no single field to read. `general.architecture` names the
/// prefix; when it is missing or the key is absent we scan for any
/// `*.context_length`, which is unambiguous in practice (`model_info` carries
/// exactly one) and degrades to `None` rather than to a guess.
async fn show_model(client: &reqwest::Client, base: &str, name: &str) -> Option<ModelDetail> {
let resp = client
.post(format!("{base}/api/show"))
.json(&serde_json::json!({ "model": name }))
.timeout(std::time::Duration::from_secs(5))
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
Some(parse_show_body(&body))
}
/// The pure half of `show_model` — see its doc for why the key is
/// architecture-prefixed.
fn parse_show_body(body: &serde_json::Value) -> ModelDetail {
let info = body.get("model_info").and_then(|v| v.as_object());
let context_length = info.and_then(|info| {
let arch = body
.get("details")
.and_then(|d| d.get("family"))
.and_then(|v| v.as_str());
let by_arch = info
.get("general.architecture")
.and_then(|v| v.as_str())
.or(arch)
.and_then(|arch| info.get(&format!("{arch}.context_length")))
.and_then(|v| v.as_u64());
by_arch.or_else(|| {
info.iter()
.find(|(k, _)| k.ends_with(".context_length"))
.and_then(|(_, v)| v.as_u64())
})
});
let capabilities = body
.get("capabilities")
.and_then(|c| c.as_array())
.map(|caps| {
caps.iter()
.filter_map(|c| c.as_str().map(str::to_string))
.collect::<Vec<String>>()
})
.filter(|caps| !caps.is_empty());
ModelDetail {
context_length,
capabilities,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn reads_the_architecture_prefixed_context_length() {
let detail = parse_show_body(&json!({
"model_info": {
"general.architecture": "qwen2",
"qwen2.context_length": 32768,
"qwen2.embedding_length": 3584
},
"capabilities": ["completion", "tools"]
}));
assert_eq!(detail.context_length, Some(32768));
assert_eq!(
detail.capabilities,
Some(vec!["completion".to_string(), "tools".to_string()])
);
}
#[test]
fn falls_back_to_any_context_length_key_when_architecture_is_absent() {
// Some builds omit `general.architecture`; `model_info` still carries
// exactly one `*.context_length`, so scanning is unambiguous.
let detail = parse_show_body(&json!({
"model_info": { "gemma3.context_length": 8192 }
}));
assert_eq!(detail.context_length, Some(8192));
}
#[test]
fn falls_back_to_the_details_family_when_the_info_key_is_missing() {
let detail = parse_show_body(&json!({
"details": { "family": "llama" },
"model_info": { "llama.context_length": 131072 }
}));
assert_eq!(detail.context_length, Some(131072));
}
#[test]
fn reports_unknown_rather_than_guessing() {
// Absent must stay absent: the server treats it as "ask
// conservatively", and a fabricated number would be asked for.
let detail = parse_show_body(&json!({ "model_info": { "general.architecture": "llama" } }));
assert_eq!(detail.context_length, None);
assert_eq!(detail.capabilities, None);
}
#[test]
fn an_empty_capability_list_is_unknown_not_empty() {
let detail = parse_show_body(&json!({ "capabilities": [] }));
assert_eq!(detail.capabilities, None);
}
#[test]
fn digest_keys_the_cache_so_a_repull_relearns() {
let a = detail_key(&json!({ "name": "llama3.2:latest", "digest": "sha256:aaa" }));
let b = detail_key(&json!({ "name": "llama3.2:latest", "digest": "sha256:bbb" }));
assert_ne!(a, b);
// No digest reported: the name still keys it, so caching happens at all.
assert_eq!(
detail_key(&json!({ "name": "llama3.2:latest" })),
Some("llama3.2:latest".to_string())
);
}
}
src/runner/inference.rs +15 −0
@@ -258,6 +258,16 @@
.unwrap_or_default()
}
/// Anvil's request options → Ollama's `options` object.
///
/// `num_ctx` is the important one. Ollama defaults it to 4096 whatever the
/// model can hold, and an oversized prompt is not an error there — it silently
/// drops the head of the context, taking the system prompt and the tool
/// definitions with it, and the model answers something it was never asked.
/// The server sizes the request against the context length this runner
/// advertised on heartbeat and sends the result here; passing it through is
/// what makes that budget real. A request without it (an older server) keeps
/// Ollama's default, exactly as before.
fn ollama_options(options: &Value) -> Option<Value> {
let mut opts = Map::new();
if let Some(t) = options.get("temperature") {
@@ -268,6 +278,11 @@
if let Some(mt) = options.get("max_tokens") {
if !mt.is_null() {
opts.insert("num_predict".into(), mt.clone());
}
}
if let Some(ctx) = options.get("num_ctx") {
if ctx.as_u64().is_some_and(|n| n > 0) {
opts.insert("num_ctx".into(), ctx.clone());
}
}
if opts.is_empty() {