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:
1 parent
e10fc26ca7
commit
d68d9b09f3
2 files changed
+165
-1
No files matched your search
+160
-1
@@ -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 = || {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in new issue
Block a user