ai: collapse llamacpp into LLM_BACKEND env switch

Reverts the per-request backend="llamacpp" value. Chat/vision/embedding
backend is now a deploy-time decision (LLM_BACKEND=ollama|llamacpp),
applied globally across chat, vision describe, and embeddings — so
embedding vectors stay in one space across the index.

- Per-request backend whitelist back to "local"|"hybrid". A request
  arriving with backend="llamacpp" is rejected.
- LLM_BACKEND=llamacpp swaps the entire local stack to llama-swap:
  chat hits the chat slot, describe hits the vision slot, embeddings
  hit the embed slot. Hybrid mode still routes chat to OpenRouter
  but uses LLM_BACKEND for the describe pass.
- Drops env vars HYBRID_VISION_BACKEND, LLAMA_SWAP_VISION_MODELS,
  EMBEDDING_BACKEND (the last never shipped). Drops the
  LlamaCppClient.vision_models allowlist — capability inference now
  reports has_vision only for the configured vision_model slot.
- Drops the /insights/llamacpp/models handler. /insights/models is
  the single endpoint; returns Ollama servers under LLM_BACKEND=ollama
  and llama-swap slots (from LLAMA_SWAP_ALLOWED_MODELS) under
  LLM_BACKEND=llamacpp. Same envelope shape either way.
- New ai::embed_one helper routes embeddings through llama-swap when
  LLM_BACKEND=llamacpp (else Ollama). Wires it into the four
  insight_generator embedding sites.
- Cross-replay matrix simplifies to pre-llamacpp shape (local↔local,
  hybrid↔hybrid, hybrid→local allowed; local→hybrid rejected).
This commit is contained in:
Cameron Cordes
2026-05-21 11:36:58 -04:00
parent d14df63f19
commit be51421b38
9 changed files with 338 additions and 301 deletions
+18 -14
View File
@@ -53,26 +53,30 @@ AGENTIC_CHAT_MAX_ITERATIONS=6
# OPENROUTER_HTTP_REFERER=https://your-site.example # OPENROUTER_HTTP_REFERER=https://your-site.example
# OPENROUTER_APP_TITLE=ImageApi # OPENROUTER_APP_TITLE=ImageApi
# ── AI Insights — local backend switch ──────────────────────────────────
# Picks which local LLM stack the server uses for chat, vision describe,
# and embeddings. `ollama` (default) uses the OLLAMA_* settings above;
# `llamacpp` uses the LLAMA_SWAP_* settings below. The switch is global
# and applies to both `backend=local` and `backend=hybrid` (hybrid keeps
# chat on OpenRouter but still uses this stack for the describe pass).
# Don't flip mid-deploy without re-embedding existing index rows —
# mixed vector spaces break similarity search.
# LLM_BACKEND=ollama
# ── AI Insights — llama.cpp / llama-swap (optional) ───────────────────── # ── AI Insights — llama.cpp / llama-swap (optional) ─────────────────────
# Set LLAMA_SWAP_URL to enable the `llamacpp` chat_backend. Talks # Set LLAMA_SWAP_URL plus LLM_BACKEND=llamacpp to swap the local stack
# OpenAI-compatible /v1 to a llama-swap proxy that fronts per-slot # off Ollama. Talks OpenAI-compatible /v1 to a llama-swap proxy fronting
# llama-server instances (chat / vision / embed). Like hybrid, the # per-slot llama-server instances (chat / vision / embed). The chat slot
# agentic loop describes images via the vision slot then inlines the # is treated as text-only — images are pre-described via the vision slot
# text into the chat slot — so the chat slot itself can be text-only. # and inlined into the prompt.
# LLAMA_SWAP_URL=http://localhost:9292/v1 # LLAMA_SWAP_URL=http://localhost:9292/v1
# LLAMA_SWAP_PRIMARY_MODEL=chat # LLAMA_SWAP_PRIMARY_MODEL=chat
# LLAMA_SWAP_VISION_MODEL=vision # LLAMA_SWAP_VISION_MODEL=vision
# LLAMA_SWAP_EMBEDDING_MODEL=embed # LLAMA_SWAP_EMBEDDING_MODEL=embed
# Comma-separated allowlist of model ids the /v1/models endpoint should # Comma-separated allowlist surfaced by /insights/models when
# advertise as vision-capable (llama-swap doesn't report modality). # LLM_BACKEND=llamacpp.
# LLAMA_SWAP_VISION_MODELS=vision
# Comma-separated allowlist surfaced by /insights/llamacpp/models.
# LLAMA_SWAP_ALLOWED_MODELS=chat,vision,embed # LLAMA_SWAP_ALLOWED_MODELS=chat,vision,embed
# LLAMA_SWAP_REQUEST_TIMEOUT_SECONDS=120 # LLAMA_SWAP_REQUEST_TIMEOUT_SECONDS=180
# Routes hybrid mode's vision-describe pass through llama-swap's vision
# slot instead of Ollama (chat still goes to OpenRouter). Values:
# `ollama` (default) | `llamacpp`.
# HYBRID_VISION_BACKEND=ollama
# ── AI Insights — sibling services (optional) ─────────────────────────── # ── AI Insights — sibling services (optional) ───────────────────────────
# Apollo (places, face inference, CLIP encoders). Single-Apollo deploys # Apollo (places, face inference, CLIP encoders). Single-Apollo deploys
+54 -42
View File
@@ -473,9 +473,8 @@ GET /memories?path=...&recursive=true
POST /insights/generate (non-agentic single-shot) POST /insights/generate (non-agentic single-shot)
POST /insights/generate/agentic (tool-calling loop; body: { file_path, backend?, model?, ... }) POST /insights/generate/agentic (tool-calling loop; body: { file_path, backend?, model?, ... })
GET /insights?path=...&library=... GET /insights?path=...&library=...
GET /insights/models (local Ollama models + capabilities) GET /insights/models (local-backend models + capabilities; Ollama OR llama-swap based on LLM_BACKEND)
GET /insights/openrouter/models (curated OpenRouter allowlist) GET /insights/openrouter/models (curated OpenRouter allowlist)
GET /insights/llamacpp/models (curated llama-swap slot allowlist)
POST /insights/rate (thumbs up/down for training data) POST /insights/rate (thumbs up/down for training data)
// Insight Chat Continuation // Insight Chat Continuation
@@ -632,22 +631,27 @@ OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small # Optional, embeddings
OPENROUTER_HTTP_REFERER=https://your-site.example # Optional attribution header OPENROUTER_HTTP_REFERER=https://your-site.example # Optional attribution header
OPENROUTER_APP_TITLE=ImageApi # Optional attribution header OPENROUTER_APP_TITLE=ImageApi # Optional attribution header
# llama.cpp / llama-swap (Llamacpp Backend) - sibling to Ollama; OpenAI-compatible # Local LLM backend switch. `ollama` (default) keeps the OLLAMA_* settings
# above; `llamacpp` swaps the entire local stack (chat + vision describe +
# embeddings) over to llama-swap. The switch is global and applies to
# `backend=local` requests and to `backend=hybrid`'s describe pass (hybrid
# chat still goes to OpenRouter). Don't flip mid-deploy without
# re-embedding — mixed vector spaces break similarity search.
LLM_BACKEND=ollama
# llama.cpp / llama-swap (used when LLM_BACKEND=llamacpp). OpenAI-compatible
# proxy hosting one or more llama-server processes (chat / vision / embed slots). # proxy hosting one or more llama-server processes (chat / vision / embed slots).
LLAMA_SWAP_URL=http://localhost:9292/v1 # Required to enable llamacpp backend LLAMA_SWAP_URL=http://localhost:9292/v1 # Required when LLM_BACKEND=llamacpp
LLAMA_SWAP_PRIMARY_MODEL=chat # Chat slot id (matches config.yaml) LLAMA_SWAP_PRIMARY_MODEL=chat # Chat slot id (matches config.yaml)
LLAMA_SWAP_VISION_MODEL=vision # Vision slot id; describe_image routes here LLAMA_SWAP_VISION_MODEL=vision # Vision slot id; describe_image routes here.
LLAMA_SWAP_EMBEDDING_MODEL=embed # Embedding slot id (when local embeddings via llamacpp) # The only slot reported as has_vision=true in
LLAMA_SWAP_VISION_MODELS=qwen-vl,llava # Comma-separated slot ids known to have vision. # /insights/models — chat slots are treated as
# Drives `has_vision` in /insights/llamacpp/models. # text-only (images pre-described and inlined).
# `LLAMA_SWAP_VISION_MODEL` is auto-included. LLAMA_SWAP_EMBEDDING_MODEL=embed # Embedding slot id
LLAMA_SWAP_ALLOWED_MODELS=chat,coder # Curated allowlist exposed to clients via LLAMA_SWAP_ALLOWED_MODELS=chat,coder # Curated allowlist surfaced by GET /insights/models
# GET /insights/llamacpp/models. Empty = no picker. # when LLM_BACKEND=llamacpp. Empty = picker shows
# only the configured primary model.
LLAMA_SWAP_REQUEST_TIMEOUT_SECONDS=180 # Per-request timeout; bump for slow CPU offload LLAMA_SWAP_REQUEST_TIMEOUT_SECONDS=180 # Per-request timeout; bump for slow CPU offload
HYBRID_VISION_BACKEND=llamacpp # Optional override for hybrid mode's describe_image:
# `ollama` (default) or `llamacpp`. When `llamacpp`,
# hybrid still routes chat to OpenRouter but uses
# llama-swap's vision slot to describe images.
# Insight Chat Continuation # Insight Chat Continuation
AGENTIC_CHAT_MAX_ITERATIONS=6 # Cap on tool-calling iterations per chat turn (default 6) AGENTIC_CHAT_MAX_ITERATIONS=6 # Cap on tool-calling iterations per chat turn (default 6)
@@ -668,13 +672,36 @@ The `OllamaClient` provides methods to query available models:
This allows runtime verification of model availability before generating insights. This allows runtime verification of model availability before generating insights.
**Local backend switch (`LLM_BACKEND`):**
One env var decides which "local" stack the server runs against — `ollama`
(default) or `llamacpp`. It's global on purpose: chat, vision describe, and
embeddings all route through the same backend, so the embedding-vector
column in SQLite stays in one vector space. Don't flip mid-deploy without
re-embedding the affected rows — similarity search will collapse.
- `LLM_BACKEND=ollama`: chat and embeddings use Ollama; vision describe
uses Ollama's multimodal model.
- `LLM_BACKEND=llamacpp`: chat hits llama-swap's `chat` slot (which is
treated as text-only — images are pre-described via the `vision` slot
and inlined), embeddings hit the `embed` slot, vision describe hits the
`vision` slot. Requires `LLAMA_SWAP_URL`.
The per-request `backend=hybrid` override is orthogonal: it always sends
chat to OpenRouter, but the describe pass still routes through whichever
`LLM_BACKEND` is configured.
`GET /insights/models` returns the local-backend models with capabilities
in the same envelope shape regardless of `LLM_BACKEND`: Ollama servers
when `ollama`, llama-swap slots (from `LLAMA_SWAP_ALLOWED_MODELS`) when
`llamacpp`. No `/insights/llamacpp/models` — the picker reads a single
endpoint.
**Hybrid Backend (OpenRouter):** **Hybrid Backend (OpenRouter):**
- Per-request opt-in via `backend=hybrid` on `POST /insights/generate/agentic`. - Per-request opt-in via `backend=hybrid` on `POST /insights/generate/agentic`.
- Vision describe happens before the agentic loop; the description is inlined - Vision describe happens before the agentic loop; the description is inlined
into the chat prompt and the agentic loop runs on OpenRouter. By default into the chat prompt and the agentic loop runs on OpenRouter. Vision
vision uses local Ollama, but `HYBRID_VISION_BACKEND=llamacpp` flips it to routes through whichever `LLM_BACKEND` is configured.
llama-swap's vision slot (useful when you want chat on a frontier model and
vision on a local-but-not-Ollama path).
- `request.model` (if provided) overrides `OPENROUTER_DEFAULT_MODEL` for that - `request.model` (if provided) overrides `OPENROUTER_DEFAULT_MODEL` for that
call. The mobile picker reads from `OPENROUTER_ALLOWED_MODELS`. call. The mobile picker reads from `OPENROUTER_ALLOWED_MODELS`.
- No live capability precheck — the operator-curated allowlist is trusted. - No live capability precheck — the operator-curated allowlist is trusted.
@@ -682,29 +709,14 @@ This allows runtime verification of model availability before generating insight
- `GET /insights/openrouter/models` returns `{ models, default_model, configured }` - `GET /insights/openrouter/models` returns `{ models, default_model, configured }`
for client picker UIs. for client picker UIs.
**Llamacpp Backend (llama-swap):** **Cross-replay matrix (chat continuation):**
- Per-request opt-in via `backend=llamacpp` on `POST /insights/generate/agentic`. - `local → local` allowed (whether served by Ollama or llama-swap; that's
- Sibling to Ollama: a local OpenAI-compatible proxy (mostlygeek/llama-swap) a deploy-time decision, not a request-time one).
fronting one or more `llama-server` processes. The chat slot is text-only - `hybrid → hybrid` allowed.
by default; vision and embeddings have their own slots (`LLAMA_SWAP_VISION_MODEL`, - `hybrid → local` allowed (the inlined description replays as text).
`LLAMA_SWAP_EMBEDDING_MODEL`) that llama-swap routes to by model id. The - `local → hybrid` rejected — the stored transcript has raw images in the
bundled `docker-compose.yml` + `llama-swap/config.yaml` in the opencode root first user message and OpenRouter providers don't accept that shape
is the reference deploy. consistently. Regenerate the insight in hybrid mode instead.
- Operates in the same describe-then-inline shape as hybrid: the chat model
never sees raw images. Vision describe routes through llama-swap's vision
slot (`describe_image` on `LlamaCppClient`).
- `request.model` (if provided) overrides `LLAMA_SWAP_PRIMARY_MODEL` for that
call (must match a slot id in llama-swap's `config.yaml`). The mobile picker
reads from `LLAMA_SWAP_ALLOWED_MODELS`.
- No live capability precheck — slot ids are trusted. Tool calling is assumed
for every slot (llama-swap entries typically launch with `--jinja`).
- `GET /insights/llamacpp/models` returns `{ models, default_model, configured }`.
- Cross-replay matrix (chat continuation): `local ↔ llamacpp` allowed (the
LlamaCppClient passes images through to the chat slot — you're responsible
for a vision-capable slot if the stored transcript carries images);
`hybrid ↔ llamacpp` allowed (both transcripts are text-only); `local →
hybrid` and `llamacpp → hybrid` rejected (mid-conversation description
source change isn't supported).
**Insight Chat Continuation:** **Insight Chat Continuation:**
+33 -31
View File
@@ -470,7 +470,16 @@ pub async fn generate_agentic_insight_handler(
} }
} }
/// GET /insights/models - List available models from both servers with capabilities /// GET /insights/models - Local-backend models with capabilities. Returns
/// Ollama servers when `LLM_BACKEND=ollama` (default), or llama-swap slots
/// when `LLM_BACKEND=llamacpp`. Same envelope shape either way so the
/// client picker doesn't have to branch on backend kind.
///
/// For llama-swap: `models` comes verbatim from `LLAMA_SWAP_ALLOWED_MODELS`
/// (no live `/v1/models` probe), `has_vision` is true only for the
/// configured `LLAMA_SWAP_VISION_MODEL` slot id, and `has_tool_calling` is
/// reported as true for every slot (llama-server is launched with `--jinja`
/// by convention — a misconfigured slot surfaces as a chat-call error).
#[get("/insights/models")] #[get("/insights/models")]
pub async fn get_available_models_handler( pub async fn get_available_models_handler(
_claims: Claims, _claims: Claims,
@@ -478,6 +487,29 @@ pub async fn get_available_models_handler(
) -> impl Responder { ) -> impl Responder {
log::debug!("Fetching available models with capabilities"); log::debug!("Fetching available models with capabilities");
if crate::ai::local_backend_is_llamacpp()
&& let Some(lc) = app_state.llamacpp.as_ref()
{
let models: Vec<ModelCapabilities> = app_state
.llamacpp_allowed_models
.iter()
.map(|name| ModelCapabilities {
name: name.clone(),
has_vision: name == &lc.vision_model,
has_tool_calling: true,
})
.collect();
let primary = ServerModels {
url: lc.base_url.clone(),
models,
default_model: lc.primary_model.clone(),
};
return HttpResponse::Ok().json(AvailableModelsResponse {
primary,
fallback: None,
});
}
let ollama_client = &app_state.ollama; let ollama_client = &app_state.ollama;
// Fetch models with capabilities from primary server // Fetch models with capabilities from primary server
@@ -549,36 +581,6 @@ pub async fn get_openrouter_models_handler(
HttpResponse::Ok().json(response) HttpResponse::Ok().json(response)
} }
#[derive(serde::Serialize)]
pub struct LlamaCppModelsResponse {
pub models: Vec<String>,
pub default_model: Option<String>,
pub configured: bool,
}
/// GET /insights/llamacpp/models - Curated llama-swap model ids exposed
/// to clients for the llamacpp backend. Returned verbatim from
/// `LLAMA_SWAP_ALLOWED_MODELS`; no live call to llama-swap. Use
/// `LLAMA_SWAP_URL` plus `LLAMA_SWAP_PRIMARY_MODEL` on the server side to
/// pick the actual chat slot.
#[get("/insights/llamacpp/models")]
pub async fn get_llamacpp_models_handler(
_claims: Claims,
app_state: web::Data<crate::state::AppState>,
) -> impl Responder {
let configured = app_state.llamacpp.is_some();
let default_model = app_state
.llamacpp
.as_ref()
.map(|c| c.primary_model.clone());
let response = LlamaCppModelsResponse {
models: app_state.llamacpp_allowed_models.clone(),
default_model,
configured,
};
HttpResponse::Ok().json(response)
}
/// POST /insights/rate - Rate an insight (thumbs up/down for training data) /// POST /insights/rate - Rate an insight (thumbs up/down for training data)
#[post("/insights/rate")] #[post("/insights/rate")]
pub async fn rate_insight_handler( pub async fn rate_insight_handler(
+68 -103
View File
@@ -309,14 +309,15 @@ impl InsightChatService {
.unwrap_or_else(|| stored_backend.clone()); .unwrap_or_else(|| stored_backend.clone());
validate_cross_replay(&stored_backend, &effective_backend)?; validate_cross_replay(&stored_backend, &effective_backend)?;
let is_hybrid = effective_backend == "hybrid"; let is_hybrid = effective_backend == "hybrid";
let is_llamacpp = effective_backend == "llamacpp"; let local_via_llamacpp =
let describes_then_inlines = is_hybrid || is_llamacpp; crate::ai::local_backend_is_llamacpp() && self.llamacpp.is_some();
let describes_then_inlines = is_hybrid || local_via_llamacpp;
span.set_attribute(KeyValue::new("backend", effective_backend.clone())); span.set_attribute(KeyValue::new("backend", effective_backend.clone()));
// 4. Build the chat backend client. Ollama in local mode, a freshly // 4. Build the chat backend client. Hybrid → OpenRouter; local with
// cloned OpenRouter client in hybrid mode, a freshly cloned // `LLM_BACKEND=llamacpp` → llama-swap; otherwise Ollama. Clones
// LlamaCppClient in llamacpp mode (clone so per-request // so per-request sampling/model overrides don't leak into shared
// sampling/model overrides don't leak into shared state). // state.
let max_iterations = req let max_iterations = req
.max_iterations .max_iterations
.unwrap_or(DEFAULT_MAX_ITERATIONS) .unwrap_or(DEFAULT_MAX_ITERATIONS)
@@ -353,9 +354,9 @@ impl InsightChatService {
c.set_num_ctx(Some(ctx)); c.set_num_ctx(Some(ctx));
} }
openrouter_client = Some(c); openrouter_client = Some(c);
} else if is_llamacpp { } else if local_via_llamacpp {
let arc = self.llamacpp.as_ref().ok_or_else(|| { let arc = self.llamacpp.as_ref().ok_or_else(|| {
anyhow!("llamacpp backend unavailable: LLAMA_SWAP_URL not configured") anyhow!("LLM_BACKEND=llamacpp but LLAMA_SWAP_URL not configured")
})?; })?;
let mut c: LlamaCppClient = (**arc).clone(); let mut c: LlamaCppClient = (**arc).clone();
if let Some(ref m) = custom_model { if let Some(ref m) = custom_model {
@@ -373,8 +374,8 @@ impl InsightChatService {
} }
llamacpp_client = Some(c); llamacpp_client = Some(c);
} else { } else {
// Local-mode model swap. Build a new client when the chat model // Pure local (Ollama): model swap. Build a new client when the
// differs from the configured one (mirrors the agentic pattern). // chat model differs from the configured one.
if let Some(ref m) = custom_model if let Some(ref m) = custom_model
&& m != &self.ollama.primary_model && m != &self.ollama.primary_model
{ {
@@ -820,8 +821,9 @@ impl InsightChatService {
.unwrap_or_else(|| stored_backend.clone()); .unwrap_or_else(|| stored_backend.clone());
validate_cross_replay(&stored_backend, &effective_backend)?; validate_cross_replay(&stored_backend, &effective_backend)?;
let is_hybrid = effective_backend == "hybrid"; let is_hybrid = effective_backend == "hybrid";
let is_llamacpp = effective_backend == "llamacpp"; let local_via_llamacpp =
let describes_then_inlines = is_hybrid || is_llamacpp; crate::ai::local_backend_is_llamacpp() && self.llamacpp.is_some();
let describes_then_inlines = is_hybrid || local_via_llamacpp;
let max_iterations = req let max_iterations = req
.max_iterations .max_iterations
@@ -841,9 +843,9 @@ impl InsightChatService {
let model_used = chat_backend.primary_model().to_string(); let model_used = chat_backend.primary_model().to_string();
// Tool set — local mode + first user turn carries an image → // Tool set — local mode + first user turn carries an image →
// offer describe_photo. Describe-then-inline modes (hybrid / // offer describe_photo. Describe-then-inline modes (hybrid OR
// llamacpp): visual description was inlined when the insight was // local_via_llamacpp): visual description was inlined when the
// bootstrapped, no describe tool needed. // insight was bootstrapped, no describe tool needed.
let local_first_user_has_image = messages let local_first_user_has_image = messages
.iter() .iter()
.find(|m| m.role == "user") .find(|m| m.role == "user")
@@ -987,8 +989,9 @@ impl InsightChatService {
.unwrap_or_else(|| "default".to_string()); .unwrap_or_else(|| "default".to_string());
let effective_backend = resolve_bootstrap_backend(req.backend.as_deref())?; let effective_backend = resolve_bootstrap_backend(req.backend.as_deref())?;
let is_hybrid = effective_backend == "hybrid"; let is_hybrid = effective_backend == "hybrid";
let is_llamacpp = effective_backend == "llamacpp"; let local_via_llamacpp =
let describes_then_inlines = is_hybrid || is_llamacpp; crate::ai::local_backend_is_llamacpp() && self.llamacpp.is_some();
let describes_then_inlines = is_hybrid || local_via_llamacpp;
let max_iterations = req let max_iterations = req
.max_iterations .max_iterations
@@ -1020,35 +1023,19 @@ impl InsightChatService {
_ => None, _ => None,
}); });
// Describe-then-inline backends (hybrid, llamacpp): pre-describe the // Describe-then-inline (hybrid OR local_via_llamacpp): pre-describe
// image so a text-only chat model gets the visual description inline. // the image so a text-only chat model gets the visual description
// Vision source: llamacpp's vision slot in llamacpp mode; in hybrid // inline. Vision source follows `LLM_BACKEND`: llama-swap when
// mode Ollama by default, llamacpp via `HYBRID_VISION_BACKEND=llamacpp`. // `local_via_llamacpp`, else Ollama.
let visual_block = if describes_then_inlines { let visual_block = if describes_then_inlines {
match image_base64.as_deref() { match image_base64.as_deref() {
Some(b64) => { Some(b64) => {
let use_llamacpp_vision = if is_llamacpp { let described = if local_via_llamacpp {
true self.llamacpp
} else { .as_ref()
matches!( .expect("local_via_llamacpp guarantees Some")
std::env::var("HYBRID_VISION_BACKEND") .describe_image(b64)
.ok() .await
.as_deref()
.map(|s| s.trim().to_lowercase())
.as_deref(),
Some("llamacpp")
)
};
let described = if use_llamacpp_vision {
match self.llamacpp.as_ref() {
Some(c) => c.describe_image(b64).await,
None => {
log::warn!(
"bootstrap: requested llamacpp vision but LLAMA_SWAP_URL unset; falling back to Ollama"
);
self.ollama.describe_image(b64).await
}
}
} else { } else {
self.ollama.describe_image(b64).await self.ollama.describe_image(b64).await
}; };
@@ -1175,8 +1162,11 @@ impl InsightChatService {
/// (boxed because each backend has a different concrete type) and the /// (boxed because each backend has a different concrete type) and the
/// Ollama client used for describe-image / local tool calls. /// Ollama client used for describe-image / local tool calls.
/// ///
/// `effective_backend` must be one of `"local"`, `"hybrid"`, `"llamacpp"` /// `effective_backend` must be one of `"local"` or `"hybrid"` (validated
/// (validated upstream). /// upstream). Hybrid → OpenRouter; local with `LLM_BACKEND=llamacpp` →
/// llama-swap; pure local → Ollama. Returns the dispatched chat client
/// plus the (possibly per-request) Ollama client that the caller uses
/// for non-chat helpers (image describe in non-llamacpp mode, tool ops).
fn build_chat_clients( fn build_chat_clients(
&self, &self,
effective_backend: &str, effective_backend: &str,
@@ -1206,10 +1196,10 @@ impl InsightChatService {
return Ok((Box::new(c), ollama_client)); return Ok((Box::new(c), ollama_client));
} }
if effective_backend == "llamacpp" { // Local mode — env switch decides between Ollama and llama-swap.
let arc = self.llamacpp.as_ref().ok_or_else(|| { if crate::ai::local_backend_is_llamacpp()
anyhow!("llamacpp backend unavailable: LLAMA_SWAP_URL not configured") && let Some(arc) = self.llamacpp.as_ref()
})?; {
let mut c: LlamaCppClient = (**arc).clone(); let mut c: LlamaCppClient = (**arc).clone();
if let Some(m) = custom_model { if let Some(m) = custom_model {
c.primary_model = m.to_string(); c.primary_model = m.to_string();
@@ -1525,41 +1515,26 @@ fn resolve_date_taken_for_context(
/// Validate a stored→effective backend transition for a chat continuation. /// Validate a stored→effective backend transition for a chat continuation.
/// Continuation runs against a transcript that was generated with a specific /// Continuation runs against a transcript that was generated with a specific
/// backend; some transitions break the conversation shape: /// backend; the only blocked transition is `local → hybrid`, because the
/// stored transcript has images embedded in the first user message and the
/// hybrid path (OpenRouter chat with describe-then-inline) can't replay
/// raw image bytes through OpenRouter consistently across providers.
/// `hybrid → local` is allowed (the inlined description replays verbatim
/// as text).
/// ///
/// - `local → hybrid` — the stored transcript has images embedded in the /// Whether "local" routes through Ollama or llama-swap is decided at
/// first user message; the openrouter chat client surfaces them through /// startup by `LLM_BACKEND`; both share the same transcript shape from
/// the wire, but vision-only models routed via the hybrid path may not /// the chat-replay perspective.
/// accept that shape consistently across providers. Reject to keep the
/// `regenerate-in-hybrid-mode` workflow as the supported answer.
/// - `llamacpp → hybrid` — the stored transcript already has an inlined
/// visual description produced by llama-swap's vision slot. Switching
/// to hybrid mid-conversation would mix description sources across
/// subsequent turns (any new image in the chat continuation would be
/// described by ollama-vision while the original was described by
/// llama-vision). Reject for consistency.
///
/// All other transitions are allowed. `local ↔ llamacpp` works because
/// LlamaCppClient passes image content-parts through to the chat slot —
/// the user is responsible for picking a vision-capable chat model in
/// that case. `hybrid ↔ llamacpp` works because both transcripts are
/// text-only (visual description inlined at bootstrap).
fn validate_cross_replay(stored: &str, effective: &str) -> Result<()> { fn validate_cross_replay(stored: &str, effective: &str) -> Result<()> {
if !matches!(effective, "local" | "hybrid" | "llamacpp") { if !matches!(effective, "local" | "hybrid") {
bail!( bail!(
"unknown backend '{}'; expected 'local', 'hybrid', or 'llamacpp'", "unknown backend '{}'; expected 'local' or 'hybrid'",
effective effective
); );
} }
if stored == "local" && effective == "hybrid" { if stored == "local" && effective == "hybrid" {
bail!( bail!(
"switching from local to hybrid mid-chat isn't supported yet; \ "switching from local to hybrid mid-chat isn't supported; \
regenerate the insight in hybrid mode if you want OpenRouter chat"
);
}
if stored == "llamacpp" && effective == "hybrid" {
bail!(
"switching from llamacpp to hybrid mid-chat isn't supported yet; \
regenerate the insight in hybrid mode if you want OpenRouter chat" regenerate the insight in hybrid mode if you want OpenRouter chat"
); );
} }
@@ -1576,9 +1551,9 @@ fn resolve_bootstrap_backend(supplied: Option<&str>) -> Result<String> {
.map(|s| s.trim().to_lowercase()) .map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.unwrap_or_else(|| "local".to_string()); .unwrap_or_else(|| "local".to_string());
if !matches!(lower.as_str(), "local" | "hybrid" | "llamacpp") { if !matches!(lower.as_str(), "local" | "hybrid") {
bail!( bail!(
"unknown backend '{}'; expected 'local', 'hybrid', or 'llamacpp'", "unknown backend '{}'; expected 'local' or 'hybrid'",
lower lower
); );
} }
@@ -2184,10 +2159,6 @@ mod tests {
fn bootstrap_backend_accepts_local_and_hybrid_case_insensitively() { fn bootstrap_backend_accepts_local_and_hybrid_case_insensitively() {
assert_eq!(resolve_bootstrap_backend(Some("LOCAL")).unwrap(), "local"); assert_eq!(resolve_bootstrap_backend(Some("LOCAL")).unwrap(), "local");
assert_eq!(resolve_bootstrap_backend(Some("Hybrid")).unwrap(), "hybrid"); assert_eq!(resolve_bootstrap_backend(Some("Hybrid")).unwrap(), "hybrid");
assert_eq!(
resolve_bootstrap_backend(Some("Llamacpp")).unwrap(),
"llamacpp"
);
assert_eq!( assert_eq!(
resolve_bootstrap_backend(Some(" local ")).unwrap(), resolve_bootstrap_backend(Some(" local ")).unwrap(),
"local" "local"
@@ -2196,10 +2167,13 @@ mod tests {
#[test] #[test]
fn bootstrap_backend_rejects_unknown_label() { fn bootstrap_backend_rejects_unknown_label() {
let err = resolve_bootstrap_backend(Some("openrouter")).unwrap_err(); // `llamacpp` is no longer a per-request backend value — it's chosen
let msg = format!("{}", err); // at deploy time via `LLM_BACKEND`.
assert!(msg.contains("unknown backend")); for label in &["openrouter", "llamacpp", "ollama"] {
assert!(msg.contains("openrouter")); let err = resolve_bootstrap_backend(Some(label)).unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("unknown backend"), "label={}", label);
}
} }
#[test] #[test]
@@ -2209,29 +2183,20 @@ mod tests {
} }
#[test] #[test]
fn cross_replay_rejects_llamacpp_to_hybrid() { fn cross_replay_allows_supported_transitions() {
let err = validate_cross_replay("llamacpp", "hybrid").unwrap_err();
assert!(format!("{}", err).contains("llamacpp to hybrid"));
}
#[test]
fn cross_replay_allows_local_llamacpp_and_hybrid_llamacpp_transitions() {
// Local ↔ llamacpp: user is responsible for picking a vision-capable
// chat slot when the transcript has images.
assert!(validate_cross_replay("local", "llamacpp").is_ok());
assert!(validate_cross_replay("llamacpp", "local").is_ok());
// Hybrid ↔ llamacpp: both transcripts are text-only.
assert!(validate_cross_replay("hybrid", "llamacpp").is_ok());
// Same-backend replays are always fine.
assert!(validate_cross_replay("local", "local").is_ok()); assert!(validate_cross_replay("local", "local").is_ok());
assert!(validate_cross_replay("hybrid", "hybrid").is_ok()); assert!(validate_cross_replay("hybrid", "hybrid").is_ok());
assert!(validate_cross_replay("llamacpp", "llamacpp").is_ok()); // Hybrid → local replays the inlined description as plain text.
assert!(validate_cross_replay("hybrid", "local").is_ok());
} }
#[test] #[test]
fn cross_replay_rejects_unknown_effective() { fn cross_replay_rejects_unknown_effective() {
let err = validate_cross_replay("local", "openrouter").unwrap_err(); // Both "openrouter" and the former "llamacpp" value are unknown now.
assert!(format!("{}", err).contains("unknown backend")); for label in &["openrouter", "llamacpp"] {
let err = validate_cross_replay("local", label).unwrap_err();
assert!(format!("{}", err).contains("unknown backend"), "label={}", label);
}
} }
#[test] #[test]
+63 -64
View File
@@ -471,8 +471,11 @@ impl InsightGenerator {
log::info!("RAG QUERY: {}", query); log::info!("RAG QUERY: {}", query);
log::info!("========================================"); log::info!("========================================");
// Generate embedding for the query // Generate embedding for the query via the configured local backend
let query_embedding = self.ollama.generate_embedding(&query).await?; // (`LLM_BACKEND` switch). Must match the backend that populated the
// daily-summary embeddings or similarity search will be garbage.
let query_embedding =
crate::ai::embed_one(&self.ollama, self.llamacpp.as_deref(), &query).await?;
// Search for similar daily summaries with time-based weighting // Search for similar daily summaries with time-based weighting
// This prioritizes summaries temporally close to the query date // This prioritizes summaries temporally close to the query date
@@ -563,7 +566,7 @@ impl InsightGenerator {
let calendar_cx = parent_cx.with_span(span); let calendar_cx = parent_cx.with_span(span);
let query_embedding = if let Some(loc) = location { let query_embedding = if let Some(loc) = location {
match self.ollama.generate_embedding(loc).await { match crate::ai::embed_one(&self.ollama, self.llamacpp.as_deref(), loc).await {
Ok(emb) => Some(emb), Ok(emb) => Some(emb),
Err(e) => { Err(e) => {
log::warn!("Failed to generate embedding for location '{}': {}", loc, e); log::warn!("Failed to generate embedding for location '{}': {}", loc, e);
@@ -734,16 +737,17 @@ impl InsightGenerator {
) )
}; };
let query_embedding = match self.ollama.generate_embedding(&query_text).await { let query_embedding =
Ok(emb) => emb, match crate::ai::embed_one(&self.ollama, self.llamacpp.as_deref(), &query_text).await {
Err(e) => { Ok(emb) => emb,
log::warn!("Failed to generate search embedding: {}", e); Err(e) => {
search_cx.span().set_status(Status::Error { log::warn!("Failed to generate search embedding: {}", e);
description: e.to_string().into(), search_cx.span().set_status(Status::Error {
}); description: e.to_string().into(),
return Ok(None); });
} return Ok(None);
}; }
};
let searches = { let searches = {
let mut dao = self let mut dao = self
@@ -2608,11 +2612,13 @@ Return ONLY the summary, nothing else."#,
} }
} }
/// Tool: store_entity — upsert an entity into the knowledge memory /// Tool: store_entity — upsert an entity into the knowledge memory.
/// Embeddings go through the configured local backend (`LLM_BACKEND`),
/// independent of the per-request chat backend in the caller.
async fn tool_store_entity( async fn tool_store_entity(
&self, &self,
args: &serde_json::Value, args: &serde_json::Value,
ollama: &OllamaClient, _ollama: &OllamaClient,
cx: &opentelemetry::Context, cx: &opentelemetry::Context,
) -> String { ) -> String {
use crate::database::models::InsertEntity; use crate::database::models::InsertEntity;
@@ -2672,9 +2678,16 @@ Return ONLY the summary, nothing else."#,
.collect() .collect()
}; };
// Generate embedding for name + description (best-effort) // Generate embedding for name + description (best-effort) via the
// configured local backend.
let embed_text = format!("{} {}", name, description); let embed_text = format!("{} {}", name, description);
let embedding: Option<Vec<u8>> = match ollama.generate_embedding(&embed_text).await { let embedding: Option<Vec<u8>> = match crate::ai::embed_one(
&self.ollama,
self.llamacpp.as_deref(),
&embed_text,
)
.await
{
Ok(vec) => { Ok(vec) => {
let bytes: Vec<u8> = vec.iter().flat_map(|f| f.to_le_bytes()).collect(); let bytes: Vec<u8> = vec.iter().flat_map(|f| f.to_le_bytes()).collect();
Some(bytes) Some(bytes)
@@ -3580,20 +3593,24 @@ Return ONLY the summary, nothing else."#,
.map(|s| s.trim().to_lowercase()) .map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.unwrap_or_else(|| "local".to_string()); .unwrap_or_else(|| "local".to_string());
if !matches!(backend_label.as_str(), "local" | "hybrid" | "llamacpp") { if !matches!(backend_label.as_str(), "local" | "hybrid") {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"unknown backend '{}'; expected 'local', 'hybrid', or 'llamacpp'", "unknown backend '{}'; expected 'local' or 'hybrid'",
backend_label backend_label
)); ));
} }
span.set_attribute(KeyValue::new("backend", backend_label.clone())); span.set_attribute(KeyValue::new("backend", backend_label.clone()));
let is_hybrid = backend_label == "hybrid"; let is_hybrid = backend_label == "hybrid";
let is_llamacpp = backend_label == "llamacpp"; // `LLM_BACKEND=llamacpp` swaps Ollama out for llama-swap as the
// In hybrid + llamacpp modes the chat model never sees the image // "local" stack — chat + vision describe + embeddings all route
// directly; we describe-then-inline locally before the agentic loop // through llama-swap. In hybrid mode this still applies to vision
// starts. Tracked as a single flag so vision/tool-gate logic doesn't // describe (chat continues to go to OpenRouter). The chat slot is
// have to branch twice. // text-only in either case, so we describe-then-inline.
let describes_then_inlines = is_hybrid || is_llamacpp; let local_via_llamacpp =
crate::ai::local_backend_is_llamacpp() && self.llamacpp.is_some();
// Describe-then-inline: hybrid (chat is OpenRouter, text-only) or
// any path where chat goes through llama-swap (chat slot is text-only).
let describes_then_inlines = is_hybrid || local_via_llamacpp;
// 1b. Always build an Ollama client. In local mode it owns the chat // 1b. Always build an Ollama client. In local mode it owns the chat
// loop; in hybrid/llamacpp mode it still handles tool-local calls // loop; in hybrid/llamacpp mode it still handles tool-local calls
@@ -3688,13 +3705,14 @@ Return ONLY the summary, nothing else."#,
None None
}; };
// 1d. In llamacpp mode, clone the configured LlamaCpp client and // 1d. When `LLM_BACKEND=llamacpp` and we're in local mode (not
// apply per-request overrides. Same shape as the openrouter // hybrid — hybrid keeps chat on OpenRouter), clone the llamacpp
// branch above; describe_image will route through the vision // client and apply per-request overrides. Same shape as the
// slot configured on the client. // openrouter branch above; describe_image will route through
let llamacpp_client: Option<LlamaCppClient> = if is_llamacpp { // the vision slot configured on the client.
let llamacpp_client: Option<LlamaCppClient> = if local_via_llamacpp && !is_hybrid {
let arc = self.llamacpp.as_ref().ok_or_else(|| { let arc = self.llamacpp.as_ref().ok_or_else(|| {
anyhow::anyhow!("llamacpp backend unavailable: LLAMA_SWAP_URL not configured") anyhow::anyhow!("LLM_BACKEND=llamacpp but LLAMA_SWAP_URL not configured")
})?; })?;
let mut c: LlamaCppClient = (**arc).clone(); let mut c: LlamaCppClient = (**arc).clone();
if let Some(ref m) = custom_model { if let Some(ref m) = custom_model {
@@ -3917,38 +3935,19 @@ Return ONLY the summary, nothing else."#,
None None
}; };
// describe-then-inline path. In hybrid mode the vision backend // describe-then-inline path. Vision describe routes through whichever
// defaults to Ollama but can be flipped to llamacpp via // `LLM_BACKEND` is configured — llama-swap when `local_via_llamacpp`
// `HYBRID_VISION_BACKEND=llamacpp` (so chat goes to OpenRouter while // is set (even in hybrid mode, since chat is OpenRouter but vision
// vision/audio routes through llama-swap). In llamacpp mode we always // stays on the local stack), otherwise Ollama.
// use the llamacpp client's configured vision slot.
let inlined_visual_description: Option<String> = if describes_then_inlines { let inlined_visual_description: Option<String> = if describes_then_inlines {
match image_base64.as_deref() { match image_base64.as_deref() {
Some(b64) => { Some(b64) => {
let use_llamacpp_vision = if is_llamacpp { let described = if local_via_llamacpp {
true self.llamacpp
} else { .as_ref()
// is_hybrid branch — consult env switch .expect("local_via_llamacpp guarantees Some")
matches!( .describe_image(b64)
std::env::var("HYBRID_VISION_BACKEND") .await
.ok()
.as_deref()
.map(|s| s.trim().to_lowercase())
.as_deref(),
Some("llamacpp")
)
};
let described = if use_llamacpp_vision {
match self.llamacpp.as_ref() {
Some(c) => c.describe_image(b64).await,
None => {
log::warn!(
"describe-then-inline: requested llamacpp vision but LLAMA_SWAP_URL is unset, falling back to Ollama"
);
self.ollama.describe_image(b64).await
}
}
} else { } else {
self.ollama.describe_image(b64).await self.ollama.describe_image(b64).await
}; };
@@ -4044,10 +4043,10 @@ Return ONLY the summary, nothing else."#,
); );
// 10. Define tools. Gate flags computed from current data presence; // 10. Define tools. Gate flags computed from current data presence;
// describe-then-inline modes (hybrid, llamacpp) omit describe_photo // describe-then-inline modes (hybrid OR local_via_llamacpp) omit
// since the chat model receives the visual description inline (so // describe_photo since the chat model receives the visual
// we pass `false` for has_vision in those modes regardless of the // description inline (so we pass `false` for has_vision in
// model's actual capability). // those modes regardless of the model's actual capability).
let gate_opts = self.current_gate_opts(has_vision && !describes_then_inlines); let gate_opts = self.current_gate_opts(has_vision && !describes_then_inlines);
let tools = Self::build_tool_definitions(gate_opts); let tools = Self::build_tool_definitions(gate_opts);
+9 -24
View File
@@ -11,10 +11,10 @@
// `model` field, which is how llama-swap selects which backend process to // `model` field, which is how llama-swap selects which backend process to
// run. // run.
// - `/v1/models` returns only the configured slot ids — capabilities aren't // - `/v1/models` returns only the configured slot ids — capabilities aren't
// reported by the API, so `vision_models` is a config-time allowlist (env // reported by the API, so we infer `has_vision` from a single config field
// `LLAMA_SWAP_VISION_MODELS`) used to set `has_vision` on responses. // (`vision_model`, defaulting to `"vision"`) and assume `has_tool_calling`
// `has_tool_calling` is assumed true for every slot, since llama-swap entries // is true for every slot, since llama-swap entries default to launching
// default to launching llama-server with `--jinja`. // llama-server with `--jinja`.
// //
// First consumer lands alongside the three-way backend dispatch in // First consumer lands alongside the three-way backend dispatch in
// insight_generator / insight_chat. // insight_generator / insight_chat.
@@ -50,16 +50,10 @@ pub struct LlamaCppClient {
/// Embedding model slot id (e.g. `"embed"`). Used for /// Embedding model slot id (e.g. `"embed"`). Used for
/// `generate_embeddings`. /// `generate_embeddings`.
pub embedding_model: String, pub embedding_model: String,
/// Vision model slot id (e.g. `"vision"`). Used for `describe_image` and /// Vision model slot id (e.g. `"vision"`). Used for `describe_image`,
/// included in `vision_models` automatically so capability lookups for /// and the only slot that reports `has_vision = true` in capability
/// the default vision slot report `has_vision = true` even when the env /// lookups (llama-swap's `/v1/models` doesn't surface modality).
/// allowlist is empty.
pub vision_model: String, pub vision_model: String,
/// Operator-curated set of slot ids known to be multimodal. Drives the
/// `has_vision` field in `list_models` / `model_capabilities`, since
/// llama-swap's `/v1/models` doesn't report modality. Empty allowlist
/// still marks `vision_model` as vision-capable.
pub vision_models: Vec<String>,
num_ctx: Option<i32>, num_ctx: Option<i32>,
temperature: Option<f32>, temperature: Option<f32>,
top_p: Option<f32>, top_p: Option<f32>,
@@ -83,7 +77,6 @@ impl LlamaCppClient {
primary_model: primary_model.unwrap_or_else(|| DEFAULT_PRIMARY_MODEL.to_string()), primary_model: primary_model.unwrap_or_else(|| DEFAULT_PRIMARY_MODEL.to_string()),
embedding_model: DEFAULT_EMBEDDING_MODEL.to_string(), embedding_model: DEFAULT_EMBEDDING_MODEL.to_string(),
vision_model: DEFAULT_VISION_MODEL.to_string(), vision_model: DEFAULT_VISION_MODEL.to_string(),
vision_models: Vec::new(),
num_ctx: None, num_ctx: None,
temperature: None, temperature: None,
top_p: None, top_p: None,
@@ -100,10 +93,6 @@ impl LlamaCppClient {
self.vision_model = model; self.vision_model = model;
} }
pub fn set_vision_models(&mut self, models: Vec<String>) {
self.vision_models = models;
}
pub fn set_num_ctx(&mut self, num_ctx: Option<i32>) { pub fn set_num_ctx(&mut self, num_ctx: Option<i32>) {
self.num_ctx = num_ctx; self.num_ctx = num_ctx;
} }
@@ -692,7 +681,7 @@ impl LlamaCppClient {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or_default() .unwrap_or_default()
.to_string(); .to_string();
let has_vision = name == self.vision_model || self.vision_models.iter().any(|v| v == &name); let has_vision = name == self.vision_model;
// Tool calling is the default for llama-swap entries we configure // Tool calling is the default for llama-swap entries we configure
// (--jinja flag); no negative-list mechanism yet, so report true. // (--jinja flag); no negative-list mechanism yet, so report true.
ModelCapabilities { ModelCapabilities {
@@ -954,25 +943,21 @@ mod tests {
} }
#[test] #[test]
fn capability_inference_uses_vision_model_and_allowlist() { fn capability_inference_marks_only_vision_slot() {
let mut c = LlamaCppClient::new(None, Some("chat".into())); let mut c = LlamaCppClient::new(None, Some("chat".into()));
c.set_vision_model("vision".into()); c.set_vision_model("vision".into());
c.set_vision_models(vec!["qwen-vl".into()]);
let m_chat = json!({ "id": "chat" }); let m_chat = json!({ "id": "chat" });
let m_vision = json!({ "id": "vision" }); let m_vision = json!({ "id": "vision" });
let m_qwen = json!({ "id": "qwen-vl" });
let m_other = json!({ "id": "embed" }); let m_other = json!({ "id": "embed" });
let chat = c.parse_model_capabilities(&m_chat); let chat = c.parse_model_capabilities(&m_chat);
let vision = c.parse_model_capabilities(&m_vision); let vision = c.parse_model_capabilities(&m_vision);
let qwen = c.parse_model_capabilities(&m_qwen);
let other = c.parse_model_capabilities(&m_other); let other = c.parse_model_capabilities(&m_other);
assert!(!chat.has_vision); assert!(!chat.has_vision);
assert!(chat.has_tool_calling); assert!(chat.has_tool_calling);
assert!(vision.has_vision); assert!(vision.has_vision);
assert!(qwen.has_vision);
assert!(!other.has_vision); assert!(!other.has_vision);
} }
} }
+86 -2
View File
@@ -21,14 +21,14 @@ pub use handlers::{
chat_history_handler, chat_rewind_handler, chat_stream_handler, chat_turn_handler, chat_history_handler, chat_rewind_handler, chat_stream_handler, chat_turn_handler,
delete_insight_handler, export_training_data_handler, generate_agentic_insight_handler, delete_insight_handler, export_training_data_handler, generate_agentic_insight_handler,
generate_insight_handler, get_all_insights_handler, get_available_models_handler, generate_insight_handler, get_all_insights_handler, get_available_models_handler,
get_insight_handler, get_llamacpp_models_handler, get_openrouter_models_handler, get_insight_handler, get_openrouter_models_handler, rate_insight_handler,
rate_insight_handler,
}; };
pub use insight_generator::InsightGenerator; pub use insight_generator::InsightGenerator;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use llm_client::{ pub use llm_client::{
ChatMessage, LlmClient, ModelCapabilities, Tool, ToolCall, ToolCallFunction, ToolFunction, ChatMessage, LlmClient, ModelCapabilities, Tool, ToolCall, ToolCallFunction, ToolFunction,
}; };
pub use llamacpp::LlamaCppClient;
pub use ollama::{EMBEDDING_MODEL, OllamaClient}; pub use ollama::{EMBEDDING_MODEL, OllamaClient};
pub use sms_client::{SmsApiClient, SmsMessage}; pub use sms_client::{SmsApiClient, SmsMessage};
@@ -40,3 +40,87 @@ pub use sms_client::{SmsApiClient, SmsMessage};
pub fn user_display_name() -> String { pub fn user_display_name() -> String {
std::env::var("USER_NAME").unwrap_or_else(|_| "Me".to_string()) std::env::var("USER_NAME").unwrap_or_else(|_| "Me".to_string())
} }
/// One switch for the "local" LLM stack: when `LLM_BACKEND=llamacpp` is
/// set, chat / vision describe / embeddings all route through llama-swap
/// instead of Ollama. Any other value (including unset, the default) is
/// Ollama. This is intentionally global — embeddings must be drawn from
/// a single source or similarity search across the index breaks (mixed
/// vector spaces, possibly mixed dims). The `backend=hybrid` per-request
/// override remains orthogonal: it always sends chat to OpenRouter, and
/// uses `LLM_BACKEND` for the describe-then-inline vision pass.
pub fn local_backend_is_llamacpp() -> bool {
matches!(
std::env::var("LLM_BACKEND")
.ok()
.as_deref()
.map(|s| s.trim().to_lowercase())
.as_deref(),
Some("llamacpp")
)
}
/// Embed one string via the configured local backend. Routes through
/// llama-swap when `LLM_BACKEND=llamacpp` (and a client is configured),
/// else Ollama. Returns the single embedding vector. See
/// [`local_backend_is_llamacpp`] for the rationale on consistency.
pub async fn embed_one(
ollama: &OllamaClient,
llamacpp: Option<&LlamaCppClient>,
text: &str,
) -> anyhow::Result<Vec<f32>> {
if local_backend_is_llamacpp() {
if let Some(lc) = llamacpp {
let mut vecs = <LlamaCppClient as LlmClient>::generate_embeddings(lc, &[text]).await?;
return vecs
.pop()
.ok_or_else(|| anyhow::anyhow!("llama-swap returned no embeddings"));
}
log::warn!(
"LLM_BACKEND=llamacpp but LlamaCppClient is unconfigured; falling back to Ollama embeddings"
);
}
ollama.generate_embedding(text).await
}
#[cfg(test)]
mod env_dispatch_tests {
use super::*;
fn with_env<F: FnOnce()>(key: &str, val: Option<&str>, f: F) {
let prev = std::env::var(key).ok();
match val {
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
f();
match prev {
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
}
#[test]
fn llm_backend_defaults_to_ollama() {
with_env("LLM_BACKEND", None, || {
assert!(!local_backend_is_llamacpp());
});
}
#[test]
fn llm_backend_llamacpp_case_insensitive() {
with_env("LLM_BACKEND", Some("LlamaCpp"), || {
assert!(local_backend_is_llamacpp());
});
with_env("LLM_BACKEND", Some(" llamacpp "), || {
assert!(local_backend_is_llamacpp());
});
}
#[test]
fn llm_backend_unknown_value_is_ollama() {
with_env("LLM_BACKEND", Some("vllm"), || {
assert!(!local_backend_is_llamacpp());
});
}
}
-1
View File
@@ -313,7 +313,6 @@ fn main() -> std::io::Result<()> {
.service(ai::get_all_insights_handler) .service(ai::get_all_insights_handler)
.service(ai::get_available_models_handler) .service(ai::get_available_models_handler)
.service(ai::get_openrouter_models_handler) .service(ai::get_openrouter_models_handler)
.service(ai::get_llamacpp_models_handler)
.service(ai::chat_turn_handler) .service(ai::chat_turn_handler)
.service(ai::chat_stream_handler) .service(ai::chat_stream_handler)
.service(ai::chat_history_handler) .service(ai::chat_history_handler)
+7 -20
View File
@@ -358,10 +358,11 @@ fn parse_openrouter_allowed_models() -> Vec<String> {
} }
/// Build a `LlamaCppClient` from environment variables. Returns `None` when /// Build a `LlamaCppClient` from environment variables. Returns `None` when
/// `LLAMA_SWAP_URL` is unset (the llamacpp backend is then unavailable and /// `LLAMA_SWAP_URL` is unset. The client is constructed unconditionally
/// requests for it return a clear error). The slot ids default to the /// when the URL is set (so it's available even under `LLM_BACKEND=ollama`
/// names the bundled `llama-swap/config.yaml` uses — `chat` / `vision` / /// for ad-hoc tooling), but the agentic / chat paths only route through it
/// `embed` — so a minimal deploy only needs to set `LLAMA_SWAP_URL`. /// when `LLM_BACKEND=llamacpp`. Slot ids default to the names the bundled
/// `llama-swap/config.yaml` uses — `chat` / `vision` / `embed`.
fn build_llamacpp_from_env() -> Option<Arc<LlamaCppClient>> { fn build_llamacpp_from_env() -> Option<Arc<LlamaCppClient>> {
let base_url = env::var("LLAMA_SWAP_URL").ok()?; let base_url = env::var("LLAMA_SWAP_URL").ok()?;
let primary_model = env::var("LLAMA_SWAP_PRIMARY_MODEL").ok(); let primary_model = env::var("LLAMA_SWAP_PRIMARY_MODEL").ok();
@@ -372,12 +373,12 @@ fn build_llamacpp_from_env() -> Option<Arc<LlamaCppClient>> {
if let Ok(model) = env::var("LLAMA_SWAP_VISION_MODEL") { if let Ok(model) = env::var("LLAMA_SWAP_VISION_MODEL") {
client.set_vision_model(model); client.set_vision_model(model);
} }
client.set_vision_models(parse_llamacpp_vision_models());
Some(Arc::new(client)) Some(Arc::new(client))
} }
/// Parse `LLAMA_SWAP_ALLOWED_MODELS` (comma-separated) into a vec. Used to /// Parse `LLAMA_SWAP_ALLOWED_MODELS` (comma-separated) into a vec. Used to
/// drive `/insights/llamacpp/models`; empty when unset. /// populate the model picker when `LLM_BACKEND=llamacpp` — `/insights/models`
/// surfaces these slots with capabilities. Empty when unset.
fn parse_llamacpp_allowed_models() -> Vec<String> { fn parse_llamacpp_allowed_models() -> Vec<String> {
env::var("LLAMA_SWAP_ALLOWED_MODELS") env::var("LLAMA_SWAP_ALLOWED_MODELS")
.unwrap_or_default() .unwrap_or_default()
@@ -387,20 +388,6 @@ fn parse_llamacpp_allowed_models() -> Vec<String> {
.collect() .collect()
} }
/// Parse `LLAMA_SWAP_VISION_MODELS` (comma-separated) — slot ids that report
/// `has_vision = true` in capability lookups. The configured `vision_model`
/// (default `vision`) is always considered vision-capable regardless of this
/// list, so a deploy that only uses the default vision slot can leave it
/// unset.
fn parse_llamacpp_vision_models() -> Vec<String> {
env::var("LLAMA_SWAP_VISION_MODELS")
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
#[cfg(test)] #[cfg(test)]
impl AppState { impl AppState {
/// Creates an AppState instance for testing with temporary directories /// Creates an AppState instance for testing with temporary directories