ai: send images directly to llamacpp chat models + add ResolvedBackend

llamacpp models now receive images via OpenAI content-parts instead of
the describe-then-inline strategy (hybrid mode unchanged). Fixes
assistant messages with tool_calls emitting content: null instead of ""
to satisfy strict Jinja template role-alternation checks. Adds debug
logging of message role sequences on llamacpp requests.

Introduces BackendKind enum, SamplingOverrides, and ResolvedBackend in
a new backend.rs module. InsightGenerator::resolve_backend centralises
client construction + vision capability detection — next step wires the
existing inline dispatch through it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 4.7 committed 2026-05-24 14:00:37 -04:00
1 parent be51421b38
commit 0631820fbf
6 files changed
+395 -70

No files matched your search

+37 -12
View File
@@ -50,9 +50,9 @@ pub struct LlamaCppClient {
/// Embedding model slot id (e.g. `"embed"`). Used for
/// `generate_embeddings`.
pub embedding_model: String,
/// Vision model slot id (e.g. `"vision"`). Used for `describe_image`,
/// and the only slot that reports `has_vision = true` in capability
/// lookups (llama-swap's `/v1/models` doesn't surface modality).
/// Vision model slot id. Used for `describe_image` routing. Defaults
/// to `primary_model` so describe_image works out of the box; override
/// via `LLAMA_SWAP_VISION_MODEL` for a dedicated vision slot.
pub vision_model: String,
num_ctx: Option<i32>,
temperature: Option<f32>,
@@ -67,6 +67,7 @@ impl LlamaCppClient {
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_REQUEST_TIMEOUT_SECS);
let pm = primary_model.unwrap_or_else(|| DEFAULT_PRIMARY_MODEL.to_string());
Self {
client: Client::builder()
.connect_timeout(Duration::from_secs(10))
@@ -74,9 +75,9 @@ impl LlamaCppClient {
.build()
.unwrap_or_else(|_| Client::new()),
base_url: base_url.unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
primary_model: primary_model.unwrap_or_else(|| DEFAULT_PRIMARY_MODEL.to_string()),
primary_model: pm.clone(),
embedding_model: DEFAULT_EMBEDDING_MODEL.to_string(),
vision_model: DEFAULT_VISION_MODEL.to_string(),
vision_model: pm,
num_ctx: None,
temperature: None,
top_p: None,
@@ -124,6 +125,13 @@ impl LlamaCppClient {
let mut obj = serde_json::Map::new();
obj.insert("role".into(), Value::String(msg.role.clone()));
// Assistant messages with tool_calls must emit `content: null`
// (not `""`) — some Jinja templates (Mistral-family) treat
// empty-string content as a regular message rather than a
// tool-calling turn, breaking role-alternation validation.
let has_tool_calls = msg.role == "assistant"
&& msg.tool_calls.as_ref().is_some_and(|tcs| !tcs.is_empty());
match &msg.images {
Some(images) if !images.is_empty() => {
let mut parts: Vec<Value> = Vec::new();
@@ -139,6 +147,9 @@ impl LlamaCppClient {
}
obj.insert("content".into(), Value::Array(parts));
}
_ if has_tool_calls && msg.content.is_empty() => {
obj.insert("content".into(), Value::Null);
}
_ => {
obj.insert("content".into(), Value::String(msg.content.clone()));
}
@@ -276,6 +287,13 @@ impl LlamaCppClient {
tools: Vec<Tool>,
) -> Result<(ChatMessage, Option<i32>, Option<i32>)> {
let url = format!("{}/chat/completions", self.base_url);
let roles: Vec<&str> = messages.iter().map(|m| m.role.as_str()).collect();
log::debug!(
"llama-swap chat_completion: model={} roles={:?} tools={}",
model,
roles,
tools.len()
);
let mut body = serde_json::Map::new();
body.insert("model".into(), Value::String(model.to_string()));
body.insert(
@@ -381,6 +399,13 @@ impl LlmClient for LlamaCppClient {
tools: Vec<Tool>,
) -> Result<BoxStream<'static, Result<LlmStreamEvent>>> {
let url = format!("{}/chat/completions", self.base_url);
let roles: Vec<&str> = messages.iter().map(|m| m.role.as_str()).collect();
log::debug!(
"llama-swap stream: model={} roles={:?} tools={}",
self.primary_model,
roles,
tools.len()
);
let mut body = serde_json::Map::new();
body.insert(
"model".into(),
@@ -681,12 +706,12 @@ impl LlamaCppClient {
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let has_vision = name == self.vision_model;
// Tool calling is the default for llama-swap entries we configure
// (--jinja flag); no negative-list mechanism yet, so report true.
// llama-swap doesn't expose per-model modality flags. Assume all
// configured models support vision and tool calling; a model
// without multimodal support surfaces as a chat-call error.
ModelCapabilities {
name,
has_vision,
has_vision: true,
has_tool_calling: true,
}
}
@@ -943,7 +968,7 @@ mod tests {
}
#[test]
fn capability_inference_marks_only_vision_slot() {
fn all_models_report_vision_capable() {
let mut c = LlamaCppClient::new(None, Some("chat".into()));
c.set_vision_model("vision".into());
@@ -955,9 +980,9 @@ mod tests {
let vision = c.parse_model_capabilities(&m_vision);
let other = c.parse_model_capabilities(&m_other);
assert!(!chat.has_vision);
assert!(chat.has_vision);
assert!(chat.has_tool_calling);
assert!(vision.has_vision);
assert!(!other.has_vision);
assert!(other.has_vision);
}
}