@@ -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())
);
}
}