feat(ai): warn at startup when llm.toml disagrees with llama-swap

After AppState is built, main checks the config against llama-swap's
/v1/models in the background and logs one warning per problem: a role
model (chat, vision, embedding, unified_search, tts) that isn't served or
lacks what the role needs (vision, kind: embed / tts), an embed model
whose metadata dim differs from embedding.dim, picker include/exclude ids
llama-swap doesn't serve, and models with no metadata. Warn-only: llama-swap
may still be starting. Chat-side roles are only checked under
backend = "llamacpp"; TTS always is.

Against the deployed .env this reports exactly the three stale
LLAMA_SWAP_ALLOWED_MODELS ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-26 17:37:05 -04:00
1 parent e10fc26ca7
commit d68d9b09f3
2 files changed
+165 -1

No files matched your search

+160 -1
View File
@@ -31,7 +31,7 @@ use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
use tracing_opentelemetry::OpenTelemetrySpanExt as _; use tracing_opentelemetry::OpenTelemetrySpanExt as _;
use crate::ai::config::PickerConfig; use crate::ai::config::{LlmConfig, LocalBackend, PickerConfig};
use crate::ai::llm_client::{ use crate::ai::llm_client::{
ChatMessage, LlmClient, LlmStreamEvent, ModelCapabilities, Tool, ToolCall, ToolCallFunction, ChatMessage, LlmClient, LlmStreamEvent, ModelCapabilities, Tool, ToolCall, ToolCallFunction,
}; };
@@ -1026,6 +1026,8 @@ pub struct DiscoveredModel {
/// A `setParamsByID` alias like `qwen3.8-27b-code:sub` — a parameter /// A `setParamsByID` alias like `qwen3.8-27b-code:sub` — a parameter
/// profile on another model, not a model of its own. /// profile on another model, not a model of its own.
pub is_alias: bool, pub is_alias: bool,
/// `metadata.dim` on embed models: the vector width they produce.
pub dim: Option<usize>,
} }
impl DiscoveredModel { impl DiscoveredModel {
@@ -1055,6 +1057,7 @@ impl DiscoveredModel {
}, },
kind: field("kind").and_then(Value::as_str).map(str::to_string), kind: field("kind").and_then(Value::as_str).map(str::to_string),
is_alias: field("type").and_then(Value::as_str) == Some("alias"), is_alias: field("type").and_then(Value::as_str) == Some("alias"),
dim: field("dim").and_then(Value::as_u64).map(|n| n as usize),
} }
} }
} }
@@ -1080,7 +1083,90 @@ pub fn picker_models(
.collect() .collect()
} }
/// Ways `cfg` disagrees with what llama-swap serves: role models that don't
/// exist or lack the capability the role needs, picker ids that don't exist,
/// models without metadata, and an embed `dim` that differs from
/// `embedding.dim` (stored vectors would stop matching).
pub fn catalog_warnings(cfg: &LlmConfig, models: &[DiscoveredModel]) -> Vec<String> {
let find = |id: &str| models.iter().find(|m| m.caps.name == id);
let mut out = Vec::new();
let mut require =
|role: &str, id: &str, check: &dyn Fn(&DiscoveredModel) -> Option<String>| match find(id) {
None => out.push(format!("{role} model '{id}' is not served by llama-swap")),
Some(m) => {
if let Some(problem) = check(m) {
out.push(format!("{role} model '{id}' {problem}"));
}
}
};
let chat_check = |m: &DiscoveredModel| {
(!m.is_chat()).then(|| format!("has kind '{}', not chat", m.kind.as_deref().unwrap_or("")))
};
let lc = &cfg.llamacpp;
if cfg.backend == LocalBackend::Llamacpp {
require("chat", &lc.chat, &chat_check);
require("vision", lc.vision_model(), &|m| {
(!m.caps.has_vision).then(|| "has no vision (metadata vision: true missing)".into())
});
let want_dim = cfg.embedding.dim;
require("embedding", &cfg.embedding.model, &|m| {
if m.kind.as_deref() != Some("embed") {
Some("isn't kind: embed".into())
} else {
match m.dim {
Some(d) if d != want_dim => Some(format!(
"produces {d}-dim vectors but embedding.dim is {want_dim}; \
stored vectors won't match"
)),
_ => None,
}
}
});
if let Some(id) = &cfg.unified_search_model {
require("unified_search", id, &chat_check);
}
}
require("tts", &cfg.tts.model, &|m| {
(m.kind.as_deref() != Some("tts")).then(|| "isn't kind: tts".into())
});
let picker_ids = lc.picker.include.iter().flatten().chain(&lc.picker.exclude);
for id in picker_ids {
if find(id).is_none() {
out.push(format!(
"picker lists '{id}', which llama-swap doesn't serve"
));
}
}
let unlabeled: Vec<&str> = models
.iter()
.filter(|m| !m.is_alias && m.kind.is_none())
.map(|m| m.caps.name.as_str())
.collect();
if !unlabeled.is_empty() {
out.push(format!(
"no metadata in llama-swap config.yaml for: {} (treated as text-only chat)",
unlabeled.join(", ")
));
}
out
}
impl LlamaCppClient { impl LlamaCppClient {
/// Log [`catalog_warnings`] once at startup. Never fails: llama-swap may
/// simply not be up yet.
pub async fn log_catalog_warnings(&self, cfg: &LlmConfig) {
match self.discover_models().await {
Ok(models) => {
for w in catalog_warnings(cfg, &models) {
log::warn!("LLM config: {w}");
}
}
Err(e) => log::warn!("LLM config not checked, llama-swap unreachable: {e:#}"),
}
}
/// Every model llama-swap serves, aliases included, with metadata. /// Every model llama-swap serves, aliases included, with metadata.
pub async fn discover_models(&self) -> Result<Vec<DiscoveredModel>> { pub async fn discover_models(&self) -> Result<Vec<DiscoveredModel>> {
let url = format!("{}/models", self.base_url); let url = format!("{}/models", self.base_url);
@@ -1552,6 +1638,79 @@ mod tests {
); );
} }
fn served() -> Vec<DiscoveredModel> {
let m = |v: Value| DiscoveredModel::from_json(&v);
vec![
m(
json!({"id": "chat", "meta": {"llamaswap": {"type": "model", "kind": "chat", "vision": false}}}),
),
m(
json!({"id": "eyes", "meta": {"llamaswap": {"type": "model", "kind": "chat", "vision": true}}}),
),
m(
json!({"id": "embed", "meta": {"llamaswap": {"type": "model", "kind": "embed", "dim": 768}}}),
),
m(json!({"id": "chatterbox", "meta": {"llamaswap": {"type": "model", "kind": "tts"}}})),
m(json!({"id": "chat:sub", "meta": {"llamaswap": {"type": "alias", "kind": "chat"}}})),
]
}
fn llamacpp_cfg() -> LlmConfig {
let mut cfg = LlmConfig {
backend: LocalBackend::Llamacpp,
..Default::default()
};
cfg.llamacpp.url = Some("http://x/v1".into());
cfg.llamacpp.vision = Some("eyes".into());
cfg
}
#[test]
fn matching_catalog_has_no_warnings() {
assert!(catalog_warnings(&llamacpp_cfg(), &served()).is_empty());
}
#[test]
fn catalog_mismatches_are_reported() {
let mut cfg = llamacpp_cfg();
cfg.llamacpp.chat = "gone".into();
cfg.llamacpp.vision = None; // falls back to "gone" too
cfg.embedding.dim = 1024;
cfg.tts.model = "embed".into();
cfg.llamacpp.picker.exclude = vec!["stale".into()];
let w = catalog_warnings(&cfg, &served()).join("\n");
assert!(w.contains("chat model 'gone' is not served"), "{w}");
assert!(w.contains("vision model 'gone' is not served"), "{w}");
assert!(
w.contains("768-dim vectors but embedding.dim is 1024"),
"{w}"
);
assert!(w.contains("tts model 'embed' isn't kind: tts"), "{w}");
assert!(w.contains("picker lists 'stale'"), "{w}");
}
#[test]
fn text_only_vision_model_and_unlabeled_models_are_reported() {
let mut cfg = llamacpp_cfg();
cfg.llamacpp.vision = Some("chat".into());
let mut models = served();
models.push(DiscoveredModel::from_json(&json!({"id": "bare"})));
let w = catalog_warnings(&cfg, &models).join("\n");
assert!(w.contains("vision model 'chat' has no vision"), "{w}");
assert!(
w.contains("no metadata in llama-swap config.yaml for: bare"),
"{w}"
);
assert!(!w.contains("chat:sub"), "aliases never need metadata: {w}");
}
#[test]
fn ollama_backend_only_checks_tts() {
let mut cfg = LlmConfig::default();
cfg.llamacpp.chat = "gone".into();
assert!(catalog_warnings(&cfg, &served()).is_empty());
}
#[test] #[test]
fn picker_applies_exclude_and_include() { fn picker_applies_exclude_and_include() {
let models = || { let models = || {
+5
View File
@@ -75,6 +75,11 @@ fn main() -> std::io::Result<()> {
// table; we use that list to drive the initial thumbnail sweep. // table; we use that list to drive the initial thumbnail sweep.
let app_data = Data::new(AppState::default()); let app_data = Data::new(AppState::default());
// Cross-check llm.toml against what llama-swap serves (warn-only).
if let Some(lc) = app_data.llamacpp.clone() {
tokio::spawn(async move { lc.log_catalog_warnings(ai::config::llm_config()).await });
}
// Kick thumbnail generation onto a background thread so the HTTP // Kick thumbnail generation onto a background thread so the HTTP
// server can accept traffic while large libraries are backfilling. // server can accept traffic while large libraries are backfilling.
// Existing thumbs are re-used (exists() check inside the walk), // Existing thumbs are re-used (exists() check inside the walk),