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:
1 parent
be51421b38
commit
0631820fbf
6 files changed
+395
-70
No files matched your search
+37
-12
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user