Merge pull request 'Move LLM config to llm.toml; discover llama-swap models from metadata' (#190) from feat/llm-config-file into master

Reviewed-on: #190
This commit was merged in pull request #190.
This commit is contained in:
cameron committed 2026-10-30 13:13:03 +00:00
commit e7aaa961ad
30 files changed
+1453 -623

No files matched your search

+9 -71
View File
@@ -40,78 +40,16 @@ PLAYLIST_CLEANUP_INTERVAL_SECONDS=86400
# RUST_LOG also gates export: keep it at info or debug. # RUST_LOG also gates export: keep it at info or debug.
# OTLP_OTLS_ENDPOINT=http://localhost:4317 # OTLP_OTLS_ENDPOINT=http://localhost:4317
# ── AI Insights — Ollama (local LLM) ──────────────────────────────────── # ── AI Insights — LLMs ──────────────────────────────────────────────────
OLLAMA_PRIMARY_URL=http://localhost:11434 # Models, backends, the embedding contract, TTS and agentic caps live in
OLLAMA_PRIMARY_MODEL=nemotron-3-nano:30b # llm.toml — copy llm.example.toml. Only the secret stays here.
# Optional fallback server tried on connection failure. # LLM_CONFIG_PATH=./llm.toml
# OLLAMA_FALLBACK_URL=http://server:11434 # Enables the hybrid backend (chat via OpenRouter; photos stay local).
# OLLAMA_FALLBACK_MODEL=llama3.2:3b
OLLAMA_REQUEST_TIMEOUT_SECONDS=120
# Cap on tool-calling iterations per chat turn / agentic insight.
AGENTIC_MAX_ITERATIONS=6
AGENTIC_CHAT_MAX_ITERATIONS=6
# ── AI Insights — OpenRouter (hybrid backend, optional) ─────────────────
# Set OPENROUTER_API_KEY to enable the hybrid backend (vision stays
# local on Ollama, chat routes to OpenRouter).
# OPENROUTER_API_KEY=sk-or-... # OPENROUTER_API_KEY=sk-or-...
# OPENROUTER_DEFAULT_MODEL=anthropic/claude-sonnet-4 #
# OPENROUTER_ALLOWED_MODELS=openai/gpt-4o-mini,anthropic/claude-haiku-4-5,google/gemini-2.5-flash # The old OLLAMA_* / LLAMA_SWAP_* / OPENROUTER_* / LLM_BACKEND / EMBED* /
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # AGENTIC_* / UNIFIED_SEARCH_MODEL vars still override llm.toml for now, with a
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small # startup deprecation warning. Move them into llm.toml.
# OPENROUTER_HTTP_REFERER=https://your-site.example
# OPENROUTER_APP_TITLE=ImageApi
# Cap on generated tokens per call (sent as max_tokens). OpenRouter reserves
# credit against the requested max, so leaving it uncapped makes pricey
# models fail with credit-limit errors. Default 16384; 0 = no cap.
# OPENROUTER_MAX_TOKENS=16384
# ── 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) ─────────────────────
# Set LLAMA_SWAP_URL plus LLM_BACKEND=llamacpp to swap the local stack
# off Ollama. Talks OpenAI-compatible /v1 to a llama-swap proxy fronting
# per-slot llama-server instances. Chat models receive images directly
# via content-parts (vision-capable models assumed); a separate vision
# slot is used only by the describe_photo tool and describe-image utility.
# LLAMA_SWAP_URL=http://localhost:9292/v1
# LLAMA_SWAP_PRIMARY_MODEL=chat
# Optional dedicated vision slot for describe_image. Defaults to
# PRIMARY_MODEL so describe_photo works without extra config.
# LLAMA_SWAP_VISION_MODEL=vision
# LLAMA_SWAP_EMBEDDING_MODEL=embed
# Comma-separated allowlist surfaced by /insights/models when
# LLM_BACKEND=llamacpp. All report has_vision=true.
# LLAMA_SWAP_ALLOWED_MODELS=chat,vision,embed
# LLAMA_SWAP_REQUEST_TIMEOUT_SECONDS=180
# ── Unified search translation model (optional) ─────────────────────────
# /photos/search/unified runs one small LLM call to translate a natural-
# language query into structured filters + a semantic term, then CLIP-ranks.
# That step needs an LLM AND CLIP available at once. On a tight VRAM budget a
# large chat model can't co-reside with CLIP, so pin a small, fast model here
# (it can stay loaded alongside CLIP and the chat model). Precedence:
# UNIFIED_SEARCH_MODEL > the client's selected model > the configured default.
# Use the configured backend (LLM_BACKEND); local only — no hybrid.
# UNIFIED_SEARCH_MODEL=qwen3-0.6b
# ── Text-to-speech (optional, requires LLAMA_SWAP_URL) ───────────────────
# TTS routes through the same llama-swap proxy (a Chatterbox model id), so it
# only needs LLAMA_SWAP_URL — it does NOT require LLM_BACKEND=llamacpp.
# Powers POST /tts/speech and the /tts/voices* endpoints (read-aloud insights
# + voice cloning in the mobile app).
# LLAMA_SWAP_TTS_MODEL=chatterbox # TTS model id in config.yaml
# LLAMA_SWAP_TTS_VOICE=m # default voice when a request omits one
# LLAMA_SWAP_TTS_REF_SECONDS=30 # max voice-clone reference clip length (s)
# LLAMA_SWAP_TTS_REQUEST_TIMEOUT_SECONDS=600 # synth timeout (long chunked text)
# ── 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
+2
View File
@@ -5,6 +5,8 @@ database/target
*.db-shm *.db-shm
*.db-wal *.db-wal
.env .env
# Server-local LLM config (llm.example.toml is the template)
/llm.toml
# Server-local TTS pronunciation overrides (tts_pronunciations.example.json is the template) # Server-local TTS pronunciation overrides (tts_pronunciations.example.json is the template)
/tts_pronunciations.json /tts_pronunciations.json
/tmp /tmp
+64 -92
View File
@@ -561,11 +561,11 @@ 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-backend models + capabilities; Ollama OR llama-swap based on LLM_BACKEND) GET /insights/models (local-backend models + capabilities; Ollama OR llama-swap per llm.toml `backend`)
GET /insights/openrouter/models (curated OpenRouter allowlist) GET /insights/openrouter/models (curated OpenRouter allowlist)
POST /insights/rate (thumbs up/down for training data) POST /insights/rate (thumbs up/down for training data)
// Text-to-Speech (Chatterbox via llama-swap; needs LLAMA_SWAP_URL) // Text-to-Speech (Chatterbox via llama-swap; needs llamacpp.url)
POST /tts/speech (read-aloud: { text, voice?, ... } -> { audio_base64, format }) POST /tts/speech (read-aloud: { text, voice?, ... } -> { audio_base64, format })
GET /tts/voices (Chatterbox voice library) GET /tts/voices (Chatterbox voice library)
POST /tts/voices/upload (clone a voice from an uploaded clip; multipart) POST /tts/voices/upload (clone a voice from an uploaded clip; multipart)
@@ -745,12 +745,9 @@ DATE_BACKFILL_MAX_PER_TICK=500 # Cap on canonical-date drain per watcher
HLS_COPY_MAX_BITRATE=20000000 # Max source bits/s to stream-copy into HLS; above it (or >1080p) transcodes HLS_COPY_MAX_BITRATE=20000000 # Max source bits/s to stream-copy into HLS; above it (or >1080p) transcodes
OTLP_OTLS_ENDPOINT=http://... # OTLP collector for span export (any build; unset = no export) OTLP_OTLS_ENDPOINT=http://... # OTLP collector for span export (any build; unset = no export)
# AI Insights Configuration # AI Insights: LLM settings live in llm.toml (see "LLM configuration" below)
OLLAMA_PRIMARY_URL=http://desktop:11434 # Primary Ollama server (e.g., desktop) LLM_CONFIG_PATH=./llm.toml # Default ./llm.toml; missing default file = env + defaults
OLLAMA_FALLBACK_URL=http://server:11434 # Fallback Ollama server (optional, always-on) OPENROUTER_API_KEY=sk-or-... # Secret, env-only. Enables the hybrid backend
OLLAMA_PRIMARY_MODEL=nemotron-3-nano:30b # Model for primary server (default: nemotron-3-nano:30b)
OLLAMA_FALLBACK_MODEL=llama3.2:3b # Model for fallback server (optional, uses primary if not set)
OLLAMA_REQUEST_TIMEOUT_SECONDS=120 # Per-request generation timeout (default 120). Increase for slow CPU-offloaded models.
SMS_API_URL=http://localhost:8000 # SMS message API endpoint (default: localhost:8000) SMS_API_URL=http://localhost:8000 # SMS message API endpoint (default: localhost:8000)
SMS_API_TOKEN=your-api-token # SMS API authentication token (optional) SMS_API_TOKEN=your-api-token # SMS API authentication token (optional)
@@ -769,79 +766,49 @@ FACE_AUTOBIND_MIN_COS=0.4 # Phase 3: cosine-sim floor for
FACE_DETECT_CONCURRENCY=8 # Phase 3: per-scan-tick parallel detect calls FACE_DETECT_CONCURRENCY=8 # Phase 3: per-scan-tick parallel detect calls
FACE_DETECT_TIMEOUT_SEC=60 # reqwest client timeout (CPU inference can be slow) FACE_DETECT_TIMEOUT_SEC=60 # reqwest client timeout (CPU inference can be slow)
# OpenRouter (Hybrid Backend) - keeps embeddings + vision local, routes chat to OpenRouter
OPENROUTER_API_KEY=sk-or-... # Required to enable hybrid backend
OPENROUTER_DEFAULT_MODEL=anthropic/claude-sonnet-4 # Used when client doesn't pick a model
OPENROUTER_ALLOWED_MODELS=openai/gpt-4o-mini,anthropic/claude-haiku-4-5,google/gemini-2.5-flash
# Curated allowlist exposed to clients via
# GET /insights/openrouter/models. Empty = no picker.
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Override base URL (optional)
OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small # Optional, embeddings stay local today
OPENROUTER_HTTP_REFERER=https://your-site.example # Optional attribution header
OPENROUTER_APP_TITLE=ImageApi # Optional attribution header
OPENROUTER_MAX_TOKENS=16384 # Per-call max_tokens cap (default 16384; 0 = uncapped).
# OpenRouter reserves credit against this, so an uncapped
# request to a pricey model can fail on credit limits.
# 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
# Embedding model contract. Corpus and queries must be embedded by the same
# model with matching prefixes — after changing the embed model or any of
# these, run `cargo run --bin reembed_embeddings` (all tables) or search is
# garbage. Prefix values may contain a literal \n (expanded to a newline).
EMBEDDING_DIM=768 # 768 = nomic-embed-text v1.5; 1024 = Qwen3-Embedding-0.6B
EMBED_QUERY_PREFIX= # nomic: "search_query: " | Qwen3: "Instruct: <task>\nQuery: "
EMBED_DOCUMENT_PREFIX= # nomic: "search_document: " | Qwen3: leave empty
# llama.cpp / llama-swap (used when LLM_BACKEND=llamacpp). OpenAI-compatible
# proxy hosting one or more llama-server processes. Chat models receive
# images directly via content-parts (all models assumed vision-capable).
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_VISION_MODEL= # Dedicated vision slot for describe_image / describe_photo
# tool. Defaults to PRIMARY_MODEL when unset.
LLAMA_SWAP_EMBEDDING_MODEL=embed # Embedding slot id
LLAMA_SWAP_ALLOWED_MODELS=chat,coder # Curated allowlist surfaced by GET /insights/models
# when LLM_BACKEND=llamacpp. All report has_vision=true.
# Empty = picker shows only the configured primary model.
LLAMA_SWAP_REQUEST_TIMEOUT_SECONDS=180 # Per-request timeout; bump for slow CPU offload
# Text-to-speech (Chatterbox served behind llama-swap). Only needs
# LLAMA_SWAP_URL — independent of LLM_BACKEND. Powers /tts/speech (read-aloud)
# and /tts/voices* (voice cloning). Reference audio is ffmpeg-normalized to WAV
# server-side, so any source format works.
LLAMA_SWAP_TTS_MODEL=chatterbox # TTS model id in config.yaml (default: chatterbox)
LLAMA_SWAP_TTS_VOICE=m # Default voice when /tts/speech omits one (optional)
LLAMA_SWAP_TTS_REF_SECONDS=30 # Max voice-clone reference clip length, seconds
# (Chatterbox is zero-shot; ~10-20s clean ref is ideal)
LLAMA_SWAP_TTS_REQUEST_TIMEOUT_SECONDS=600 # Per-request synth timeout (long chunked insights take
# minutes); overrides the shared client timeout for /tts/speech
TTS_PRONUNCIATIONS_PATH=tts_pronunciations.json # JSON map of pronunciation overrides applied before synth TTS_PRONUNCIATIONS_PATH=tts_pronunciations.json # JSON map of pronunciation overrides applied before synth
# (see tts_pronunciations.example.json); hot-reloaded on change # (see tts_pronunciations.example.json); hot-reloaded on change
# Insight Chat Continuation
AGENTIC_CHAT_MAX_ITERATIONS=6 # Cap on tool-calling iterations per chat turn (default 6)
AGENTIC_CHAT_DEFAULT_NUM_CTX=32768 # Assumed context window for the history-truncation budget
# when a chat request omits num_ctx AND the backend can't
# report its served window (llama-swap reports it via /props,
# so this mostly matters for Ollama/OpenRouter). Too small
# silently guts replayed history every turn (and destroys
# llama.cpp KV-cache prefix reuse).
``` ```
**LLM configuration (`llm.toml`, `src/ai/config.rs`):**
Every LLM setting except the OpenRouter key lives in `llm.toml`; `llm.example.toml`
is the annotated schema (a test parses it). `ai::config::llm_config()` is a
process-wide `OnceLock` resolved as **defaults → `llm.toml` → legacy env vars**.
The legacy vars (`OLLAMA_*`, `LLAMA_SWAP_*`, `OPENROUTER_*` except the key,
`LLM_BACKEND`, `EMBEDDING_DIM`, `EMBED_*_PREFIX`, `AGENTIC_*`,
`UNIFIED_SEARCH_MODEL`, `INSIGHT_CHAT_TURN_TIMEOUT_SECS`) override the file for one
release, with a single startup deprecation warning naming the ones in use;
`LLAMA_SWAP_ALLOWED_MODELS` maps to `llamacpp.picker.include`. `main` loads it right
after tracing init, so a bad file (unknown key, zero timeout, `backend =
"llamacpp"` without `llamacpp.url`) fails startup. Readers call `llm_config()`
instead of `std::env::var`; the merge itself is a pure `resolve(file, env_fn)`, so
tests never touch the process environment. ADR-0003 records the split.
Sections: `backend`, `unified_search_model`, `[llamacpp]` (`url`, `chat`, `vision`,
`timeout_secs`, `[llamacpp.picker]` `include`/`exclude`), `[ollama]`,
`[openrouter]`, `[embedding]` (`model`, `dim`, `query_prefix`, `document_prefix` —
the corpus contract: change any and run `reembed_embeddings`), `[tts]`
(`model`, `voice`, `ref_seconds`, `timeout_secs`), `[agentic]` (`max_iterations`,
`chat_max_iterations`, `chat_default_num_ctx`, `chat_turn_timeout_secs`).
**llama-swap capabilities come from its `config.yaml`, not from here.** Each model
there carries `metadata: { kind: chat|embed|tts, vision, ctx, dim }`, which
`/v1/models` passes through (the deployed build merges it into
`meta.llamaswap`; `DiscoveredModel::from_json` also reads top-level `meta`). A model
without metadata is a text-only chat model. At startup a background task logs
`catalog_warnings`: role models that aren't served or lack the needed capability,
an embed `dim` that differs from `embedding.dim`, picker ids that aren't served, and
models with no metadata.
**AI Insights Fallback Behavior:** **AI Insights Fallback Behavior:**
- Primary server is tried first with its configured model (5-second connection timeout) - Primary server is tried first with its configured model (5-second connection timeout)
- On connection failure, automatically falls back to secondary server with its model (if configured) - On connection failure, automatically falls back to secondary server with its model (if configured)
- If `OLLAMA_FALLBACK_MODEL` not set, uses same model as primary server on fallback - If `ollama.fallback_model` is unset, uses same model as primary server on fallback
- Total request timeout is 120 seconds to accommodate slow LLM inference - Total request timeout is `ollama.timeout_secs` (default 120) to accommodate slow LLM inference
- Logs indicate which server and model was used (info level) and failover attempts (warn level) - Logs indicate which server and model was used (info level) and failover attempts (warn level)
- Backwards compatible: `OLLAMA_URL` and `OLLAMA_MODEL` still supported as fallbacks - Backwards compatible: legacy `OLLAMA_URL` / `OLLAMA_MODEL` env vars still map to the primary URL / model
**Model Discovery:** **Model Discovery:**
The `OllamaClient` provides methods to query available models: The `OllamaClient` provides methods to query available models:
@@ -850,25 +817,27 @@ 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`):** **Local backend switch (`backend` in llm.toml):**
One env var decides which "local" stack the server runs against — `ollama` One setting decides which "local" stack the server runs against — `ollama`
(default) or `llamacpp`. It's global on purpose: chat, vision, and (default) or `llamacpp`. It's global on purpose: chat, vision, and
embeddings all route through the same backend, so the embedding-vector 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 column in SQLite stays in one vector space. Don't flip mid-deploy without
re-embedding the affected rows — similarity search will collapse. re-embedding the affected rows — similarity search will collapse.
- `LLM_BACKEND=ollama`: chat, vision, and embeddings use Ollama. Vision - `backend = "ollama"`: chat, vision, and embeddings use Ollama. Vision
capability is probed per-model via `/api/show`. capability is probed per-model via `/api/show`.
- `LLM_BACKEND=llamacpp`: chat models receive images directly via OpenAI - `backend = "llamacpp"`: chat models with `vision: true` metadata receive
content-parts (all models assumed vision-capable). Embeddings hit the images directly via OpenAI content-parts; text-only ones get the local
`embed` slot. A dedicated `LLAMA_SWAP_VISION_MODEL` slot (defaults to vision model's description inlined (the hybrid path), so a `--no-mmproj`
the chat model) handles `describe_image` for the `describe_photo` tool. model never gets an image it can't read. If llama-swap can't be asked,
Requires `LLAMA_SWAP_URL`. images are sent. Embeddings hit `embedding.model`. `llamacpp.vision`
(defaults to the chat model) handles `describe_image` for the
`describe_photo` tool. Requires `llamacpp.url`.
The per-request `backend=hybrid` override is orthogonal: it always sends The per-request `backend=hybrid` override is orthogonal: it always sends
chat to OpenRouter (text-only, images are pre-described and inlined), but chat to OpenRouter (text-only, images are pre-described and inlined), but
the describe + embed passes still route through whichever `LLM_BACKEND` the describe + embed passes still route through whichever `backend`
is configured. is configured.
**Backend dispatch (`ResolvedBackend`):** **Backend dispatch (`ResolvedBackend`):**
@@ -884,18 +853,20 @@ min_p per-request overrides. All downstream code (`execute_tool`,
individual client references. individual client references.
`GET /insights/models` returns the local-backend models with capabilities `GET /insights/models` returns the local-backend models with capabilities
in the same envelope shape regardless of `LLM_BACKEND`: Ollama servers in the same envelope shape regardless of `backend`: Ollama servers
when `ollama`, llama-swap slots (from `LLAMA_SWAP_ALLOWED_MODELS`) when when `ollama`; when `llamacpp`, llama-swap's models discovered live from
`llamacpp`. No `/insights/llamacpp/models` — the picker reads a single `/v1/models` (`picker_models`: every `kind: chat` model, `:sub`/`:fast`
aliases hidden, filtered by `[llamacpp.picker]`), with vision / context
window from metadata. No `/insights/llamacpp/models` — the picker reads a single
endpoint. 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. Vision into the chat prompt and the agentic loop runs on OpenRouter. Vision
routes through whichever `LLM_BACKEND` is configured. routes through whichever `backend` is configured.
- `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`.
- No live capability precheck — the operator-curated allowlist is trusted. - No live capability precheck — the operator-curated allowlist is trusted.
A bad model id surfaces as a chat-call error. A bad model id surfaces as a chat-call error.
- `GET /insights/openrouter/models` returns `{ models, default_model, configured }` - `GET /insights/openrouter/models` returns `{ models, default_model, configured }`
@@ -970,7 +941,7 @@ heuristic). The served window comes from `LlmClient::context_window()`:
llama-swap models fix it at launch (`-c`) and ignore the request's llama-swap models fix it at launch (`-c`) and ignore the request's
`num_ctx`, so `LlamaCppClient` reads it from `/upstream/<model>/props` `num_ctx`, so `LlamaCppClient` reads it from `/upstream/<model>/props`
(cached 10 min). When neither is known the budget uses (cached 10 min). When neither is known the budget uses
`AGENTIC_CHAT_DEFAULT_NUM_CTX` (32768). Over budget, `apply_context_budget` `agentic.chat_default_num_ctx` (32768). Over budget, `apply_context_budget`
drops, oldest first: tool-call scaffolding in completed exchanges, then drops, oldest first: tool-call scaffolding in completed exchanges, then
whole earlier exchanges (keeps user/assistant alternation), then the whole earlier exchanges (keeps user/assistant alternation), then the
current turn's own tool results. The system prompt, the first user message current turn's own tool results. The system prompt, the first user message
@@ -981,12 +952,13 @@ server still rejects the prompt as too long, the budget is shrunk from its
reported token counts and the call retried once. reported token counts and the call retried once.
The `truncated` event / flag is surfaced to the client when a drop occurred. The `truncated` event / flag is surfaced to the client when a drop occurred.
Configurable env: Configurable in `llm.toml` `[agentic]`:
- `AGENTIC_CHAT_MAX_ITERATIONS` — cap on tool-calling iterations per turn - `chat_max_iterations` — cap on tool-calling iterations per turn
(default 6). Per-request `max_iterations` is clamped to this cap. (default 6). Per-request `max_iterations` is clamped to this cap.
- `AGENTIC_CHAT_DEFAULT_NUM_CTX` — assumed context window for the truncation - `chat_default_num_ctx` — assumed context window for the truncation
budget when the request omits `num_ctx` and the backend can't report its budget when the request omits `num_ctx` and the backend can't report its
served window (default 32768). served window (default 32768). Too small silently guts replayed history
every turn (and destroys llama.cpp KV-cache prefix reuse).
**Apollo Places integration (optional):** **Apollo Places integration (optional):**
+5 -4
View File
@@ -74,13 +74,14 @@ _Avoid_: user prefs, user settings, preferences (ambiguous with device-local cli
**Deployment config**: **Deployment config**:
Values the server needs before it can start serving — bind address, database path, media Values the server needs before it can start serving — bind address, database path, media
roots, the URLs used to build clients — plus every secret. Lives in `.env` only; changing roots, the URLs used to build clients — plus every secret. Lives in `.env`, except LLM
it is a redeploy, by design. settings, which live in `llm.toml` (ADR-0003); secrets stay in `.env` only. Changing it
is a restart, by design.
_Avoid_: environment config, system config _Avoid_: environment config, system config
**Hard default**: **Hard default**:
The env/code fallback a request lands on when neither an explicit param nor a stored The config/code fallback (`.env`, `llm.toml` or a code constant) a request lands on when neither an explicit param nor a stored
owner default supplies the field (e.g. backend `local`, persona `default`, reel span owner default supplies the field (e.g. backend `local`, persona `default`, reel span
day). It is the floor under the owner-default layer: env changes keep working whenever a day). It is the floor under the owner-default layer: config changes keep working whenever a
default column is NULL. default column is NULL.
_Avoid_: fallback value, env default _Avoid_: fallback value, env default
Generated
+9
View File
@@ -2122,6 +2122,7 @@ dependencies = [
"tempfile", "tempfile",
"tokio", "tokio",
"tokio-util", "tokio-util",
"toml",
"tracing", "tracing",
"tracing-actix-web", "tracing-actix-web",
"tracing-log", "tracing-log",
@@ -4236,10 +4237,12 @@ version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [ dependencies = [
"indexmap",
"serde_core", "serde_core",
"serde_spanned", "serde_spanned",
"toml_datetime", "toml_datetime",
"toml_parser", "toml_parser",
"toml_writer",
"winnow 0.7.15", "winnow 0.7.15",
] ]
@@ -4261,6 +4264,12 @@ dependencies = [
"winnow 1.0.2", "winnow 1.0.2",
] ]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]] [[package]]
name = "tonic" name = "tonic"
version = "0.14.5" version = "0.14.5"
+1
View File
@@ -25,6 +25,7 @@ futures = "0.3.5"
jsonwebtoken = "9.3.0" jsonwebtoken = "9.3.0"
serde = "1" serde = "1"
serde_json = "1" serde_json = "1"
toml = "0.9"
diesel = { version = "2.2.10", features = ["sqlite", "64-column-tables"] } diesel = { version = "2.2.10", features = ["sqlite", "64-column-tables"] }
libsqlite3-sys = "0.35" libsqlite3-sys = "0.35"
diesel_migrations = "2.2.0" diesel_migrations = "2.2.0"
+43 -45
View File
@@ -83,44 +83,41 @@ They should be defined where the binary is located or above it in an `.env` file
### AI Insights Configuration (Optional) ### AI Insights Configuration (Optional)
The following environment variables configure AI-powered photo insights and daily conversation summaries: AI-powered photo insights, chat, read-aloud and daily conversation summaries are
configured in **`llm.toml`**, not `.env`.
#### Ollama Configuration #### LLM configuration (`llm.toml`)
- `OLLAMA_PRIMARY_URL` - Primary Ollama server URL [default: `http://localhost:11434`] Copy `llm.example.toml` to `llm.toml` next to `.env` (or point `LLM_CONFIG_PATH`
- Example: `http://desktop:11434` (your main/powerful server) at it). Every key is optional and the example documents each one:
- `OLLAMA_FALLBACK_URL` - Fallback Ollama server URL (optional)
- Example: `http://server:11434` (always-on backup server)
- `OLLAMA_PRIMARY_MODEL` - Model to use on primary server [default: `nemotron-3-nano:30b`]
- Example: `nemotron-3-nano:30b`, `llama3.2:3b`, etc.
- `OLLAMA_FALLBACK_MODEL` - Model to use on fallback server (optional)
- If not set, uses `OLLAMA_PRIMARY_MODEL` on fallback server
**Legacy Variables** (still supported): - `backend` — the local stack for chat, vision describe and embeddings:
- `OLLAMA_URL` - Used if `OLLAMA_PRIMARY_URL` not set `ollama` (default) or `llamacpp` (llama-swap). Global: don't flip it without
- `OLLAMA_MODEL` - Used if `OLLAMA_PRIMARY_MODEL` not set re-embedding.
- `[llamacpp]` — the llama-swap URL, the default `chat` model, the `vision` model
for `describe_photo`, and `[llamacpp.picker]` `exclude` / `include` filters.
- `[ollama]` — primary and fallback server URLs and models, request timeout.
- `[openrouter]` — hybrid backend: `default_model`, the curated `allowed` picker
list, `max_tokens`, attribution. The API key stays in env:
`OPENROUTER_API_KEY` enables the hybrid backend.
- `[embedding]` — embed model, `dim` and query/document prefixes. All of them
must match what the stored corpus was embedded with.
- `[tts]`, `[agentic]`, `unified_search_model` — see below and the example.
#### OpenRouter Configuration (Hybrid Backend) **The llama-swap model list is discovered, not configured.** `GET /insights/models`
The hybrid agentic backend keeps embeddings + vision local (Ollama) while routing lists every model llama-swap serves whose `metadata:` in llama-swap's
chat + tool-calling to OpenRouter. Enabled per-request when the client sends `config.yaml` says `kind: chat` (param aliases like `:sub` are hidden), with
`backend=hybrid`. `vision` and `ctx` from the same metadata. At startup the server logs a warning
for every role model, picker id or embed `dim` that doesn't match what
llama-swap serves.
- `OPENROUTER_API_KEY` - OpenRouter API key. Required to enable the hybrid backend. **Legacy env vars still work for now.** The old `OLLAMA_*`, `LLAMA_SWAP_*`,
- `OPENROUTER_DEFAULT_MODEL` - Model id used when the client doesn't specify one `OPENROUTER_*` (except the key), `LLM_BACKEND`, `EMBEDDING_DIM`, `EMBED_*_PREFIX`,
[default: `anthropic/claude-sonnet-4`] `AGENTIC_*`, `UNIFIED_SEARCH_MODEL` and `INSIGHT_CHAT_TURN_TIMEOUT_SECS` override
- Example: `openai/gpt-4o-mini`, `google/gemini-2.5-flash` `llm.toml`, with a startup deprecation warning listing the ones in use.
- `OPENROUTER_ALLOWED_MODELS` - Comma-separated curated allowlist exposed to `LLAMA_SWAP_ALLOWED_MODELS` maps to `picker.include`.
clients via `GET /insights/openrouter/models`. The mobile picker shows only
these. Empty/unset = no picker, server default is used.
- Example: `openai/gpt-4o-mini,anthropic/claude-haiku-4-5,google/gemini-2.5-flash`
- `OPENROUTER_BASE_URL` - Override base URL [default: `https://openrouter.ai/api/v1`]
- `OPENROUTER_EMBEDDING_MODEL` - Embedding model for OpenRouter
[default: `openai/text-embedding-3-small`]. Only used if/when embeddings are
routed through OpenRouter (currently embeddings stay local).
- `OPENROUTER_HTTP_REFERER` - Optional `HTTP-Referer` for OpenRouter attribution
- `OPENROUTER_APP_TITLE` - Optional `X-Title` for OpenRouter attribution
Capability checks are skipped for the curated allowlist — bad model ids surface Capability checks are skipped for the curated OpenRouter list — bad model ids
as a 4xx from the chat call. Pick tool-capable models. surface as a 4xx from the chat call. Pick tool-capable models.
#### SMS API Configuration #### SMS API Configuration
- `SMS_API_URL` - URL to SMS message API [default: `http://localhost:8000`] - `SMS_API_URL` - URL to SMS message API [default: `http://localhost:8000`]
@@ -128,7 +125,7 @@ as a 4xx from the chat call. Pick tool-capable models.
- `SMS_API_TOKEN` - Authentication token for SMS API (optional) - `SMS_API_TOKEN` - Authentication token for SMS API (optional)
#### Agentic Insight Generation #### Agentic Insight Generation
- `AGENTIC_MAX_ITERATIONS` - Maximum tool-call iterations per agentic insight request [default: `10`] - `agentic.max_iterations` - Maximum tool-call iterations per agentic insight request [default: `12`]
- Controls how many times the model can invoke tools before being forced to produce a final answer - Controls how many times the model can invoke tools before being forced to produce a final answer
- Increase for more thorough context gathering; decrease to limit response time - Increase for more thorough context gathering; decrease to limit response time
@@ -147,13 +144,13 @@ Amend mode (`amend: true` in the chat request body) regenerates the insight's
title and inserts a new row instead of appending to the existing transcript, title and inserts a new row instead of appending to the existing transcript,
so you can rewrite the saved summary from within chat. so you can rewrite the saved summary from within chat.
- `AGENTIC_CHAT_MAX_ITERATIONS` - Cap on tool-calling iterations per chat turn [default: `6`] - `agentic.chat_max_iterations` - Cap on tool-calling iterations per chat turn [default: `6`]
- Per-request `max_iterations` (when sent by the client) is clamped to this cap - Per-request `max_iterations` (when sent by the client) is clamped to this cap
#### Text-to-Speech (Optional) #### Text-to-Speech (Optional)
Reads insights aloud and manages cloned voices via a Chatterbox model served Reads insights aloud and manages cloned voices via a Chatterbox model served
behind the same llama-swap proxy. Only requires `LLAMA_SWAP_URL` (the TTS client behind the same llama-swap proxy. Only requires `llamacpp.url` (the TTS client
is built whenever that's set — independent of `LLM_BACKEND`). Endpoints: is built whenever that's set — independent of `backend`). Endpoints:
- `POST /tts/speech` — body `{ text, voice?, format?, exaggeration?, cfg_weight?, - `POST /tts/speech` — body `{ text, voice?, format?, exaggeration?, cfg_weight?,
temperature? }`; returns `{ audio_base64, format }`. Input is cleaned temperature? }`; returns `{ audio_base64, format }`. Input is cleaned
server-side (markdown + emoji stripped, then pronunciation overrides applied — server-side (markdown + emoji stripped, then pronunciation overrides applied —
@@ -189,21 +186,22 @@ matching rules are documented in `src/ai/pronunciation.rs`.
Env: Env:
- `TTS_PRONUNCIATIONS_PATH` - pronunciation-override JSON file - `TTS_PRONUNCIATIONS_PATH` - pronunciation-override JSON file
[default: `tts_pronunciations.json` in the working directory] [default: `tts_pronunciations.json` in the working directory]
- `LLAMA_SWAP_TTS_MODEL` - TTS model id in llama-swap's `config.yaml` [default: `chatterbox`]
- `LLAMA_SWAP_TTS_VOICE` - default voice used when a `/tts/speech` request omits `voice` (optional) `llm.toml` `[tts]`:
- `LLAMA_SWAP_TTS_REF_SECONDS` - max voice-clone reference clip length in seconds - `model` - TTS model id in llama-swap's `config.yaml` [default: `chatterbox`]
- `voice` - default voice used when a `/tts/speech` request omits `voice` (optional)
- `ref_seconds` - max voice-clone reference clip length in seconds
[default: `30`]. Reference audio is ffmpeg-normalized to mono 24 kHz WAV (so any [default: `30`]. Reference audio is ffmpeg-normalized to mono 24 kHz WAV (so any
source format works); Chatterbox is zero-shot, so a clean ~10–20s sample is the source format works); Chatterbox is zero-shot, so a clean ~10–20s sample is the
sweet spot — more rarely helps. sweet spot — more rarely helps.
- `LLAMA_SWAP_TTS_REQUEST_TIMEOUT_SECONDS` - per-request synthesis timeout in - `timeout_secs` - per-request synthesis timeout in seconds [default: `600`]. Long
seconds [default: `600`]. Long insights are chunked + synthesized server-side insights are chunked + synthesized server-side and can take minutes; this is
and can take minutes; this is separate from (and overrides, for `/tts/speech`) separate from (and overrides, for `/tts/speech`) `llamacpp.timeout_secs`.
the shared `LLAMA_SWAP_REQUEST_TIMEOUT_SECONDS`.
#### Fallback Behavior #### Fallback Behavior
- Primary server is tried first with 5-second connection timeout - Primary server is tried first with 5-second connection timeout
- On failure, automatically falls back to secondary server (if configured) - On failure, automatically falls back to secondary server (if configured)
- Total request timeout is 120 seconds to accommodate LLM inference - Total request timeout is `ollama.timeout_secs` (default 120) to accommodate LLM inference
- Logs indicate which server/model was used and any failover attempts - Logs indicate which server/model was used and any failover attempts
#### Daily Summary Generation #### Daily Summary Generation
+56
View File
@@ -0,0 +1,56 @@
# ADR-0003: LLM deployment config moves to llm.toml; llama-swap owns model capabilities
**Status**: accepted (2026-10-26). Amends ADR-0002's *deployment config* home for LLM settings.
## Context
ADR-0002 put LLM deployment config in `.env`: ~40 vars across `OLLAMA_*`, `LLAMA_SWAP_*`,
`OPENROUTER_*`, `LLM_BACKEND`, `EMBEDDING_DIM`, `EMBED_*_PREFIX`, `AGENTIC_*`. Flat env
vars can't express nested or list-shaped settings, and they duplicated facts that
llama-swap's `config.yaml` already owns. The deployed `LLAMA_SWAP_ALLOWED_MODELS` had
drifted from llama-swap: it listed three model ids that no longer existed (one a typo),
was missing three new ones, and every entry reported `has_vision: true`, including a
`--no-mmproj` text-only model.
## Decision
- **`llm.toml`** (TOML, `LLM_CONFIG_PATH`, default `./llm.toml`) holds every LLM
setting that ImageApi decides: the local `backend`, which model fills each role
(chat, vision, embedding, TTS, unified search), the embedding contract, the Ollama /
OpenRouter / TTS settings, agentic caps and the picker filters. Unknown keys fail startup.
- **Secrets stay in `.env`** (`OPENROUTER_API_KEY`), per ADR-0002.
- **llama-swap's `config.yaml` owns model capabilities.** Each model carries
`metadata: { kind, vision, ctx, dim }`, which `/v1/models` passes through. ImageApi
discovers the picker from it (every `kind: chat` model, param aliases hidden, minus
`picker.exclude`, optionally restricted to `picker.include`) instead of keeping a
hand-written allowlist. Text-only models get the vision model's description instead
of the image.
- **Startup cross-checks, warn-only.** Role models that aren't served or lack the needed
capability, and an embed `dim` that differs from `embedding.dim`, are logged. They
don't fail startup, because llama-swap may still be starting.
- **One release of env compatibility.** The legacy vars override the file, with one
deprecation warning listing the ones in use. Then they're removed.
The resolution chain from ADR-0002 is unchanged: request param → owner default
(`user_ai_prefs`) → hard default. `llm.toml` replaces `.env` as the hard-default layer
for LLM fields.
## Considered options
- **A standalone ImageApi model list** (roles plus per-model vision/ctx in `llm.toml`):
easier to read than env, but still a second copy of llama-swap's facts, so it drifts
the same way. Rejected.
- **Reading llama-swap's `config.yaml` directly**: couples ImageApi to a file on another
host and to llama-swap's schema. `/v1/models` is the published interface. Rejected.
- **Owner defaults in the DB** (`user_ai_prefs`): right for per-request tuning, which
stays there, but URLs and the embedding contract are needed before serving.
Rejected for these fields, as in ADR-0002.
## Non-obvious rules worth remembering
- The deployed llama-swap merges `metadata` into `meta.llamaswap`, while upstream
documents it at `meta`. `DiscoveredModel::from_json` reads both.
- A model with no metadata is treated as a text-only chat model, and the startup check
lists it.
- `embedding.query_prefix` / `document_prefix` default to empty because the existing
corpus was embedded without them. Set them only together with `reembed_embeddings`.
+76
View File
@@ -0,0 +1,76 @@
# ImageApi LLM configuration. Copy to `llm.toml` next to `.env` (or point
# LLM_CONFIG_PATH at it). Every key is optional: delete any line to get its
# default. The values below are an example llama-swap setup, not the
# defaults; each key's default is in its trailing `default:` comment.
#
# Secrets stay in env: OPENROUTER_API_KEY enables the hybrid backend.
#
# The local model catalog (which models exist, vision, context size) is NOT
# listed here. It is discovered from llama-swap's /v1/models, using each
# model's `metadata:` block in llama-swap/config.yaml (kind/vision/ctx/dim).
#
# The old env vars (LLAMA_SWAP_*, OLLAMA_*, OPENROUTER_* except the key,
# EMBED_*, EMBEDDING_DIM, AGENTIC_*, LLM_BACKEND, UNIFIED_SEARCH_MODEL) still
# work for now and OVERRIDE this file, with a deprecation warning at startup.
# Local stack for chat, vision describe and embeddings: "ollama" | "llamacpp".
# Global on purpose: don't flip it without re-embedding (mixed vector spaces
# break similarity search).
backend = "llamacpp" # default: "ollama"
# Small model pinned for /photos/search/unified query translation, so it can
# stay loaded alongside CLIP.
unified_search_model = "gemma4-e4b" # default: unset (the client's selected model)
[llamacpp]
url = "http://localhost:9292/v1" # default: unset (llama.cpp client + TTS disabled)
chat = "qwen3.8-27b-abliterated" # default: "chat"
vision = "qwen3.8-27b-code-vision" # default: unset (uses `chat`); describe_photo model
timeout_secs = 180 # default: 180
# The picker shows every llama-swap model with `kind: chat` metadata (param
# aliases like `:sub` / `:fast` are always hidden), minus `exclude`.
# `include`, if set, is a strict allowlist instead.
[llamacpp.picker]
exclude = ["nemotron3.5-lightning-30b-A3B"] # default: []
# include = ["qwen3.8-27b-code", "gemma4-31"] # default: unset (show everything)
[embedding]
# Must all agree with what the stored corpus was embedded with. Changing any
# of them means `cargo run --bin reembed_embeddings`.
model = "embed" # default: "embed" (llama-swap id; Ollama always uses nomic-embed-text:v1.5)
dim = 768 # default: 768 (nomic v1.5) | 1024 for Qwen3-Embedding-0.6B
# Set prefixes only together with a re-embed; a corpus embedded without them
# won't match queries embedded with them.
query_prefix = "" # default: "" | nomic v1.5: "search_query: " | Qwen3: "Instruct: <task>\nQuery: "
document_prefix = "" # default: "" | nomic v1.5: "search_document: " | Qwen3: ""
[tts]
# Chatterbox behind llama-swap; needs llamacpp.url but not backend = "llamacpp".
model = "chatterbox" # default: "chatterbox"
voice = "m" # default: unset (server default); used when a request omits one
ref_seconds = 30 # default: 30; max voice-clone reference clip length
timeout_secs = 600 # default: 600; long chunked text takes minutes
[ollama]
primary_url = "http://localhost:11434" # default: "http://localhost:11434"
primary_model = "nemotron-3-nano:30b" # default: "nemotron-3-nano:30b"
# Tried on connection failure.
# fallback_url = "http://server:11434" # default: unset (no fallback)
# fallback_model = "llama3.2:3b" # default: unset (reuse primary_model)
timeout_secs = 120 # default: 120
[openrouter]
default_model = "anthropic/claude-sonnet-4" # default: "anthropic/claude-sonnet-4"
allowed = ["openai/gpt-4o-mini", "anthropic/claude-haiku-4-5", "google/gemini-2.5-flash"] # default: []
max_tokens = 16384 # default: 16384; 0 = uncapped (OpenRouter reserves credit against it)
# base_url = "https://openrouter.ai/api/v1" # default: unset (OpenRouter's public API)
# embedding_model = "openai/text-embedding-3-small" # default: unset
# http_referer = "https://your-site.example" # default: unset
# app_title = "ImageApi" # default: unset
[agentic]
max_iterations = 12 # default: 12; tool-loop cap for agentic insight generation
chat_max_iterations = 6 # default: 6; per chat turn, per-request values are clamped to it
chat_default_num_ctx = 32768 # default: 32768; assumed window when neither request nor backend reports one
chat_turn_timeout_secs = 300 # default: 300; reconnectable chat turns are dropped after this
+647
View File
@@ -0,0 +1,647 @@
//! LLM configuration: one `llm.toml` instead of ~40 scattered env vars.
//!
//! Resolution order, lowest to highest precedence:
//! 1. built-in defaults (`Default` impls below — the same values the env
//! readers used to fall back to),
//! 2. `llm.toml` (path from `LLM_CONFIG_PATH`, else `./llm.toml`; a missing
//! file at the default path is fine, a missing explicit path is not),
//! 3. the legacy env vars, for one release, each logging a deprecation
//! warning so the deploy `.env` can be migrated at leisure.
//!
//! Secrets stay in env only (`OPENROUTER_API_KEY`) — they never belong in a
//! file that's likely to be copied around.
//!
//! The local model *catalog* (which llama-swap models exist, vision, ctx) is
//! not configured here: it's discovered from llama-swap's `/v1/models`
//! per-model `metadata`. This file only names which model fills which role
//! and filters the picker.
//!
//! See `llm.example.toml` for the annotated schema.
use anyhow::{Context as _, Result, bail};
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::OnceLock;
const DEFAULT_PATH: &str = "llm.toml";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum LocalBackend {
#[default]
Ollama,
Llamacpp,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct LlmConfig {
/// Which local stack serves chat, vision describe and embeddings. Global
/// on purpose: embeddings must come from one vector space.
pub backend: LocalBackend,
/// Small model pinned for `/photos/search/unified` query translation, so
/// it can stay co-resident with CLIP. `None` = the client's selection.
pub unified_search_model: Option<String>,
pub llamacpp: LlamaCppConfig,
pub ollama: OllamaConfig,
pub openrouter: OpenRouterConfig,
pub embedding: EmbeddingConfig,
pub tts: TtsConfig,
pub agentic: AgenticConfig,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LlamaCppConfig {
/// llama-swap's OpenAI-compatible root, e.g. `http://host:9292/v1`.
/// `None` disables the llama.cpp client (and TTS).
pub url: Option<String>,
/// Default chat model id.
pub chat: String,
/// Model for `describe_image` / the `describe_photo` tool. `None` = `chat`.
pub vision: Option<String>,
pub timeout_secs: u64,
pub picker: PickerConfig,
}
impl Default for LlamaCppConfig {
fn default() -> Self {
Self {
url: None,
chat: "chat".into(),
vision: None,
timeout_secs: 180,
picker: PickerConfig::default(),
}
}
}
impl LlamaCppConfig {
pub fn vision_model(&self) -> &str {
self.vision.as_deref().unwrap_or(&self.chat)
}
}
/// Filters over the models discovered from llama-swap. The picker shows every
/// `kind: chat` model (param aliases like `:sub` never appear) minus
/// `exclude`; `include`, when set, is a hard allowlist on top.
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct PickerConfig {
pub include: Option<Vec<String>>,
pub exclude: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct OllamaConfig {
pub primary_url: String,
pub primary_model: String,
pub fallback_url: Option<String>,
/// `None` = reuse `primary_model` on the fallback server.
pub fallback_model: Option<String>,
pub timeout_secs: u64,
}
impl Default for OllamaConfig {
fn default() -> Self {
Self {
primary_url: "http://localhost:11434".into(),
primary_model: "nemotron-3-nano:30b".into(),
fallback_url: None,
fallback_model: None,
timeout_secs: 120,
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct OpenRouterConfig {
/// `None` = the client's built-in OpenRouter URL.
pub base_url: Option<String>,
pub default_model: String,
/// Curated picker list for `/insights/openrouter/models`.
pub allowed: Vec<String>,
pub embedding_model: Option<String>,
pub http_referer: Option<String>,
pub app_title: Option<String>,
/// Per-call `max_tokens`; 0 = no cap. OpenRouter reserves credit against
/// the requested max, so uncapped pricey models fail on credit limits.
pub max_tokens: u32,
}
impl Default for OpenRouterConfig {
fn default() -> Self {
Self {
base_url: None,
default_model: "anthropic/claude-sonnet-4".into(),
allowed: Vec::new(),
embedding_model: None,
http_referer: None,
app_title: None,
max_tokens: crate::ai::openrouter::DEFAULT_MAX_TOKENS,
}
}
}
impl OpenRouterConfig {
pub fn max_tokens_cap(&self) -> Option<u32> {
(self.max_tokens > 0).then_some(self.max_tokens)
}
}
/// Everything that must agree for stored vectors to stay searchable. Change
/// any of it and the corpus needs `reembed_embeddings`.
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct EmbeddingConfig {
/// llama-swap embed model id (Ollama always uses `nomic-embed-text:v1.5`).
pub model: String,
pub dim: usize,
/// nomic v1.5: `"search_query: "`; Qwen3: `"Instruct: <task>\nQuery: "`.
pub query_prefix: String,
/// nomic v1.5: `"search_document: "`; Qwen3: empty.
pub document_prefix: String,
}
impl Default for EmbeddingConfig {
fn default() -> Self {
Self {
model: "embed".into(),
dim: 768,
query_prefix: String::new(),
document_prefix: String::new(),
}
}
}
/// Text-to-speech, served behind llama-swap (needs `llamacpp.url`, but not
/// `backend = "llamacpp"`).
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TtsConfig {
pub model: String,
/// Default voice when a request omits one.
pub voice: Option<String>,
/// Max voice-clone reference clip length.
pub ref_seconds: u32,
/// Synthesis timeout; long chunked text takes minutes.
pub timeout_secs: u64,
}
impl Default for TtsConfig {
fn default() -> Self {
Self {
model: "chatterbox".into(),
voice: None,
ref_seconds: 30,
timeout_secs: 600,
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AgenticConfig {
/// Tool-loop cap for agentic insight generation.
pub max_iterations: usize,
/// Tool-loop cap per chat turn; per-request values are clamped to it.
pub chat_max_iterations: usize,
/// Assumed context window when neither the request nor the backend
/// reports one.
pub chat_default_num_ctx: i32,
/// Stale reconnectable chat turns are dropped after this long.
pub chat_turn_timeout_secs: u64,
}
impl Default for AgenticConfig {
fn default() -> Self {
Self {
max_iterations: 12,
chat_max_iterations: crate::ai::insight_chat::DEFAULT_MAX_ITERATIONS,
chat_default_num_ctx: crate::ai::insight_chat::DEFAULT_NUM_CTX,
chat_turn_timeout_secs: 300,
}
}
}
/// The process-wide config. Loaded on first use; `main` touches it right
/// after logging is up so a bad file fails startup instead of the first
/// insight request.
pub fn llm_config() -> &'static LlmConfig {
static CONFIG: OnceLock<LlmConfig> = OnceLock::new();
CONFIG.get_or_init(|| load().unwrap_or_else(|e| panic!("invalid LLM config: {e:#}")))
}
/// Read the file and env, log what came from where.
fn load() -> Result<LlmConfig> {
let (path, explicit) = match std::env::var("LLM_CONFIG_PATH") {
Ok(p) if !p.trim().is_empty() => (PathBuf::from(p.trim()), true),
_ => (PathBuf::from(DEFAULT_PATH), false),
};
let text = read_config_file(&path, explicit)?;
let resolved = resolve(text.as_deref(), |k| std::env::var(k).ok())
.with_context(|| format!("in {}", path.display()))?;
match text {
Some(_) => log::info!("LLM config loaded from {}", path.display()),
None => log::info!(
"No {} found; LLM config from env + defaults",
path.display()
),
}
if !resolved.env_overrides.is_empty() {
log::warn!(
"Deprecated LLM env vars override {}: {}. Move them into the file.",
path.display(),
resolved.env_overrides.join(", ")
);
}
for key in &resolved.env_invalid {
log::warn!("Ignoring {key}: value doesn't parse");
}
Ok(resolved.config)
}
fn read_config_file(path: &Path, explicit: bool) -> Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !explicit => Ok(None),
Err(e) => Err(e).with_context(|| format!("reading LLM config {}", path.display())),
}
}
#[derive(Debug)]
struct Resolved {
config: LlmConfig,
/// Env keys that overrode file/default values.
env_overrides: Vec<&'static str>,
/// Env keys that were set but didn't parse (and were ignored).
env_invalid: Vec<&'static str>,
}
/// Pure merge of file text + an env lookup. Separate from `load` so tests
/// can drive it without touching the process environment.
fn resolve(file: Option<&str>, env: impl Fn(&str) -> Option<String>) -> Result<Resolved> {
let mut config: LlmConfig = match file {
Some(text) => toml::from_str(text)?,
None => LlmConfig::default(),
};
let mut env = EnvLayer {
get: env,
overrides: Vec::new(),
invalid: Vec::new(),
};
apply_env(&mut config, &mut env);
config.validate()?;
Ok(Resolved {
config,
env_overrides: env.overrides,
env_invalid: env.invalid,
})
}
/// The legacy env surface. Kept as one flat list so it's easy to delete when
/// the deprecation window closes.
fn apply_env<F: Fn(&str) -> Option<String>>(c: &mut LlmConfig, env: &mut EnvLayer<F>) {
if let Some(v) = env.string("LLM_BACKEND") {
// Historical behavior: anything but "llamacpp" means Ollama.
c.backend = if v.eq_ignore_ascii_case("llamacpp") {
LocalBackend::Llamacpp
} else {
LocalBackend::Ollama
};
}
env.set_opt(&mut c.unified_search_model, "UNIFIED_SEARCH_MODEL");
let lc = &mut c.llamacpp;
env.set_opt(&mut lc.url, "LLAMA_SWAP_URL");
env.set(&mut lc.chat, "LLAMA_SWAP_PRIMARY_MODEL");
env.set_opt(&mut lc.vision, "LLAMA_SWAP_VISION_MODEL");
env.set_parsed(&mut lc.timeout_secs, "LLAMA_SWAP_REQUEST_TIMEOUT_SECONDS");
if let Some(list) = env.list("LLAMA_SWAP_ALLOWED_MODELS") {
lc.picker.include = Some(list);
}
let o = &mut c.ollama;
// OLLAMA_URL / OLLAMA_MODEL are the pre-fallback-era names.
env.set(&mut o.primary_url, "OLLAMA_URL");
env.set(&mut o.primary_url, "OLLAMA_PRIMARY_URL");
env.set(&mut o.primary_model, "OLLAMA_MODEL");
env.set(&mut o.primary_model, "OLLAMA_PRIMARY_MODEL");
env.set_opt(&mut o.fallback_url, "OLLAMA_FALLBACK_URL");
env.set_opt(&mut o.fallback_model, "OLLAMA_FALLBACK_MODEL");
env.set_parsed(&mut o.timeout_secs, "OLLAMA_REQUEST_TIMEOUT_SECONDS");
let or = &mut c.openrouter;
env.set_opt(&mut or.base_url, "OPENROUTER_BASE_URL");
env.set(&mut or.default_model, "OPENROUTER_DEFAULT_MODEL");
if let Some(list) = env.list("OPENROUTER_ALLOWED_MODELS") {
or.allowed = list;
}
env.set_opt(&mut or.embedding_model, "OPENROUTER_EMBEDDING_MODEL");
env.set_opt(&mut or.http_referer, "OPENROUTER_HTTP_REFERER");
env.set_opt(&mut or.app_title, "OPENROUTER_APP_TITLE");
env.set_parsed(&mut or.max_tokens, "OPENROUTER_MAX_TOKENS");
let e = &mut c.embedding;
env.set(&mut e.model, "LLAMA_SWAP_EMBEDDING_MODEL");
env.set_parsed(&mut e.dim, "EMBEDDING_DIM");
// Prefixes keep their whitespace (trailing space is significant), and
// `.env` can't hold real newlines, so a literal `\n` is expanded.
if let Some(v) = env.raw("EMBED_QUERY_PREFIX") {
e.query_prefix = v.replace("\\n", "\n");
}
if let Some(v) = env.raw("EMBED_DOCUMENT_PREFIX") {
e.document_prefix = v.replace("\\n", "\n");
}
let t = &mut c.tts;
env.set(&mut t.model, "LLAMA_SWAP_TTS_MODEL");
env.set_opt(&mut t.voice, "LLAMA_SWAP_TTS_VOICE");
env.set_parsed(&mut t.ref_seconds, "LLAMA_SWAP_TTS_REF_SECONDS");
env.set_parsed(
&mut t.timeout_secs,
"LLAMA_SWAP_TTS_REQUEST_TIMEOUT_SECONDS",
);
let a = &mut c.agentic;
env.set_parsed(&mut a.max_iterations, "AGENTIC_MAX_ITERATIONS");
env.set_parsed(&mut a.chat_max_iterations, "AGENTIC_CHAT_MAX_ITERATIONS");
env.set_parsed(&mut a.chat_default_num_ctx, "AGENTIC_CHAT_DEFAULT_NUM_CTX");
env.set_parsed(
&mut a.chat_turn_timeout_secs,
"INSIGHT_CHAT_TURN_TIMEOUT_SECS",
);
}
struct EnvLayer<F> {
get: F,
overrides: Vec<&'static str>,
invalid: Vec<&'static str>,
}
impl<F: Fn(&str) -> Option<String>> EnvLayer<F> {
/// Untrimmed value; set-but-empty counts as set (an empty prefix is a
/// meaningful override).
fn raw(&mut self, key: &'static str) -> Option<String> {
let v = (self.get)(key)?;
self.overrides.push(key);
Some(v)
}
/// Trimmed, non-empty value. Blank lines like `KEY=` in `.env` are
/// treated as unset, matching how the old readers behaved.
fn string(&mut self, key: &'static str) -> Option<String> {
let v = (self.get)(key)?.trim().to_string();
if v.is_empty() {
return None;
}
self.overrides.push(key);
Some(v)
}
fn set(&mut self, slot: &mut String, key: &'static str) {
if let Some(v) = self.string(key) {
*slot = v;
}
}
fn set_opt(&mut self, slot: &mut Option<String>, key: &'static str) {
if let Some(v) = self.string(key) {
*slot = Some(v);
}
}
fn set_parsed<T: FromStr>(&mut self, slot: &mut T, key: &'static str) {
let Some(v) = (self.get)(key) else { return };
let v = v.trim();
if v.is_empty() {
return;
}
match v.parse() {
Ok(parsed) => {
*slot = parsed;
self.overrides.push(key);
}
Err(_) => self.invalid.push(key),
}
}
/// Comma-separated list, entries trimmed, blanks dropped.
fn list(&mut self, key: &'static str) -> Option<Vec<String>> {
let v = self.string(key)?;
Some(
v.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
)
}
}
impl LlmConfig {
fn validate(&self) -> Result<()> {
let nonzero: [(&str, u64); 6] = [
("llamacpp.timeout_secs", self.llamacpp.timeout_secs),
("ollama.timeout_secs", self.ollama.timeout_secs),
("tts.timeout_secs", self.tts.timeout_secs),
("tts.ref_seconds", self.tts.ref_seconds as u64),
("embedding.dim", self.embedding.dim as u64),
(
"agentic.chat_max_iterations",
self.agentic.chat_max_iterations as u64,
),
];
for (name, value) in nonzero {
if value == 0 {
bail!("{name} must be greater than 0");
}
}
if self.backend == LocalBackend::Llamacpp && self.llamacpp.url.is_none() {
bail!("backend = \"llamacpp\" needs llamacpp.url (or LLAMA_SWAP_URL)");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |k| map.get(k).cloned()
}
fn no_env() -> impl Fn(&str) -> Option<String> {
env_of(&[])
}
#[test]
fn no_file_no_env_gives_defaults() {
let r = resolve(None, no_env()).unwrap();
assert_eq!(r.config, LlmConfig::default());
assert!(r.env_overrides.is_empty());
assert_eq!(r.config.backend, LocalBackend::Ollama);
assert_eq!(r.config.llamacpp.vision_model(), "chat");
assert_eq!(r.config.embedding.dim, 768);
}
#[test]
fn example_file_parses_and_validates() {
let r = resolve(Some(include_str!("../../llm.example.toml")), no_env()).unwrap();
let c = r.config;
assert_eq!(c.backend, LocalBackend::Llamacpp);
assert!(c.llamacpp.url.is_some());
assert_eq!(c.embedding.dim, 768);
}
#[test]
fn partial_file_keeps_defaults_for_the_rest() {
let r = resolve(Some("[ollama]\nprimary_model = \"qwen3:8b\"\n"), no_env()).unwrap();
assert_eq!(r.config.ollama.primary_model, "qwen3:8b");
assert_eq!(r.config.ollama.primary_url, "http://localhost:11434");
assert_eq!(r.config.agentic, AgenticConfig::default());
}
#[test]
fn unknown_keys_are_rejected() {
// A typo'd key silently doing nothing is exactly the drift this file
// exists to prevent.
let err = resolve(Some("[llamacpp]\nvison = \"x\"\n"), no_env()).unwrap_err();
assert!(format!("{err:#}").contains("vison"), "{err:#}");
}
#[test]
fn env_overrides_file_and_is_reported() {
let file = "[llamacpp]\nurl = \"http://file/v1\"\nchat = \"from-file\"\n";
let r = resolve(
Some(file),
env_of(&[
("LLAMA_SWAP_PRIMARY_MODEL", "from-env"),
("LLM_BACKEND", "llamacpp"),
]),
)
.unwrap();
assert_eq!(r.config.llamacpp.chat, "from-env");
assert_eq!(r.config.llamacpp.url.as_deref(), Some("http://file/v1"));
assert_eq!(r.config.backend, LocalBackend::Llamacpp);
assert_eq!(
r.env_overrides,
vec!["LLM_BACKEND", "LLAMA_SWAP_PRIMARY_MODEL"]
);
}
#[test]
fn llm_backend_env_is_case_insensitive_and_unknown_means_ollama() {
let backend = |v: &str| {
resolve(
None,
env_of(&[("LLM_BACKEND", v), ("LLAMA_SWAP_URL", "http://x/v1")]),
)
.unwrap()
.config
.backend
};
assert_eq!(backend("LlamaCpp"), LocalBackend::Llamacpp);
assert_eq!(backend(" llamacpp "), LocalBackend::Llamacpp);
assert_eq!(backend("vllm"), LocalBackend::Ollama);
}
#[test]
fn blank_env_values_count_as_unset() {
let r = resolve(
Some("[tts]\nvoice = \"m\"\n"),
env_of(&[("LLAMA_SWAP_TTS_VOICE", " "), ("EMBEDDING_DIM", "")]),
)
.unwrap();
assert_eq!(r.config.tts.voice.as_deref(), Some("m"));
assert!(r.env_overrides.is_empty());
}
#[test]
fn unparseable_env_number_is_ignored_and_reported() {
let r = resolve(
Some("[agentic]\nmax_iterations = 9\n"),
env_of(&[("AGENTIC_MAX_ITERATIONS", "lots")]),
)
.unwrap();
assert_eq!(r.config.agentic.max_iterations, 9);
assert_eq!(r.env_invalid, vec!["AGENTIC_MAX_ITERATIONS"]);
}
#[test]
fn legacy_ollama_names_lose_to_current_names() {
let r = resolve(
None,
env_of(&[
("OLLAMA_URL", "http://old"),
("OLLAMA_MODEL", "old-model"),
("OLLAMA_PRIMARY_MODEL", "new-model"),
]),
)
.unwrap();
assert_eq!(r.config.ollama.primary_url, "http://old");
assert_eq!(r.config.ollama.primary_model, "new-model");
}
#[test]
fn allowed_models_env_becomes_picker_include() {
let r = resolve(None, env_of(&[("LLAMA_SWAP_ALLOWED_MODELS", "a, b,, c ")])).unwrap();
assert_eq!(
r.config.llamacpp.picker.include,
Some(vec!["a".to_string(), "b".into(), "c".into()])
);
}
#[test]
fn embed_prefix_env_keeps_whitespace_and_expands_newlines() {
let r = resolve(
None,
env_of(&[
("EMBED_QUERY_PREFIX", "Instruct: find\\nQuery: "),
("EMBED_DOCUMENT_PREFIX", "search_document: "),
]),
)
.unwrap();
assert_eq!(r.config.embedding.query_prefix, "Instruct: find\nQuery: ");
assert_eq!(r.config.embedding.document_prefix, "search_document: ");
}
#[test]
fn openrouter_max_tokens_zero_means_uncapped() {
let r = resolve(None, env_of(&[("OPENROUTER_MAX_TOKENS", "0")])).unwrap();
assert_eq!(r.config.openrouter.max_tokens_cap(), None);
assert_eq!(
LlmConfig::default().openrouter.max_tokens_cap(),
Some(crate::ai::openrouter::DEFAULT_MAX_TOKENS)
);
}
#[test]
fn llamacpp_backend_without_url_is_rejected() {
let err = resolve(Some("backend = \"llamacpp\"\n"), no_env()).unwrap_err();
assert!(err.to_string().contains("llamacpp.url"), "{err}");
}
#[test]
fn zero_timeouts_are_rejected() {
let err = resolve(Some("[tts]\ntimeout_secs = 0\n"), no_env()).unwrap_err();
assert!(err.to_string().contains("tts.timeout_secs"), "{err}");
}
#[test]
fn missing_default_file_is_fine_but_missing_explicit_file_errors() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nope.toml");
assert!(read_config_file(&missing, false).unwrap().is_none());
assert!(read_config_file(&missing, true).is_err());
}
}
+32 -27
View File
@@ -867,10 +867,7 @@ pub async fn generate_agentic_insight_handler(
.persona_system_prompt(user_id, &persona_id) .persona_system_prompt(user_id, &persona_id)
}); });
let max_iterations: usize = std::env::var("AGENTIC_MAX_ITERATIONS") let max_iterations = crate::ai::config::llm_config().agentic.max_iterations;
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(12);
// Spawn background task with timeout // Spawn background task with timeout
let generator = app_state.insight_generator.clone(); let generator = app_state.insight_generator.clone();
@@ -986,17 +983,15 @@ pub async fn generate_agentic_insight_handler(
} }
/// GET /insights/models - Local-backend models with capabilities. Returns /// GET /insights/models - Local-backend models with capabilities. Returns
/// Ollama servers when `LLM_BACKEND=ollama` (default), or llama-swap slots /// Ollama servers when `backend = "ollama"` (default), or llama-swap models
/// when `LLM_BACKEND=llamacpp`. Same envelope shape either way so the /// when `backend = "llamacpp"`. Same envelope shape either way so the
/// client picker doesn't have to branch on backend kind. /// client picker doesn't have to branch on backend kind.
/// ///
/// For llama-swap: `models` comes verbatim from `LLAMA_SWAP_ALLOWED_MODELS` /// For llama-swap: `models` is discovered live from `/v1/models` — every
/// (no live `/v1/models` probe), `has_vision` is true only for the /// `kind: chat` model (aliases hidden) filtered by `[llamacpp.picker]` —
/// configured `LLAMA_SWAP_VISION_MODEL` slot id, and `has_tool_calling` is /// with `has_vision` / `context_window` from each model's config.yaml
/// reported as true for every slot (llama-server is launched with `--jinja` /// `metadata`. A served window known without loading the model (cached from
/// by convention — a misconfigured slot surfaces as a chat-call error). /// a recent chat, or the model is running) takes precedence over `ctx`.
/// `context_window` is each slot's served `-c` window when known without
/// loading the model (cached from a recent chat, or the model is running).
#[get("/insights/models")] #[get("/insights/models")]
pub async fn get_available_models_handler( pub async fn get_available_models_handler(
_claims: Claims, _claims: Claims,
@@ -1007,20 +1002,30 @@ pub async fn get_available_models_handler(
if crate::ai::local_backend_is_llamacpp() if crate::ai::local_backend_is_llamacpp()
&& let Some(lc) = app_state.llamacpp.as_ref() && let Some(lc) = app_state.llamacpp.as_ref()
{ {
let picker = &crate::ai::config::llm_config().llamacpp.picker;
let mut models = match lc.discover_models().await {
Ok(found) => crate::ai::llamacpp::picker_models(found, picker),
Err(e) => {
// llama-swap is down, so chat would fail anyway; still list
// the configured model so the picker isn't empty when it
// comes back.
log::warn!("llama-swap model discovery failed: {e:#}");
vec![ModelCapabilities {
name: lc.primary_model.clone(),
has_vision: true,
has_tool_calling: true,
context_window: None,
}]
}
};
// Served windows for the context picker; never loads a model. // Served windows for the context picker; never loads a model.
let windows = lc let names: Vec<String> = models.iter().map(|m| m.name.clone()).collect();
.known_context_windows(&app_state.llamacpp_allowed_models) let windows = lc.known_context_windows(&names).await;
.await; for m in &mut models {
let models: Vec<ModelCapabilities> = app_state if let Some(&n) = windows.get(&m.name) {
.llamacpp_allowed_models m.context_window = Some(n);
.iter() }
.map(|name| ModelCapabilities { }
name: name.clone(),
has_vision: true,
has_tool_calling: true,
context_window: windows.get(name).copied(),
})
.collect();
let primary = ServerModels { let primary = ServerModels {
url: lc.base_url.clone(), url: lc.base_url.clone(),
models, models,
@@ -1084,7 +1089,7 @@ pub struct OpenRouterModelsResponse {
/// GET /insights/openrouter/models - Curated OpenRouter model ids exposed /// GET /insights/openrouter/models - Curated OpenRouter model ids exposed
/// to clients for the hybrid backend. Returned verbatim from /// to clients for the hybrid backend. Returned verbatim from
/// `OPENROUTER_ALLOWED_MODELS`; no live call to OpenRouter. /// `openrouter.allowed`; no live call to OpenRouter.
#[get("/insights/openrouter/models")] #[get("/insights/openrouter/models")]
pub async fn get_openrouter_models_handler( pub async fn get_openrouter_models_handler(
_claims: Claims, _claims: Claims,
+21 -27
View File
@@ -26,8 +26,8 @@ pub const DEFAULT_MAX_ITERATIONS: usize = 6;
/// `effective_num_ctx`). Real conversations rarely pass ~16k tokens, so 32k /// `effective_num_ctx`). Real conversations rarely pass ~16k tokens, so 32k
/// keeps the truncation pass from gutting history the server could hold /// keeps the truncation pass from gutting history the server could hold
/// (which also destroys the server's KV-cache prefix reuse). Override /// (which also destroys the server's KV-cache prefix reuse). Override
/// per-deploy with AGENTIC_CHAT_DEFAULT_NUM_CTX. /// per-deploy with `agentic.chat_default_num_ctx` in llm.toml.
const DEFAULT_NUM_CTX: i32 = 32768; pub(crate) const DEFAULT_NUM_CTX: i32 = 32768;
/// Headroom reserved for the model's response, deducted from the context /// Headroom reserved for the model's response, deducted from the context
/// budget when deciding whether to truncate the replayed history. /// budget when deciding whether to truncate the replayed history.
pub(crate) const RESPONSE_HEADROOM_TOKENS: usize = 2048; pub(crate) const RESPONSE_HEADROOM_TOKENS: usize = 2048;
@@ -341,7 +341,7 @@ impl InsightChatService {
let max_iterations = req let max_iterations = req
.max_iterations .max_iterations
.unwrap_or(DEFAULT_MAX_ITERATIONS) .unwrap_or(DEFAULT_MAX_ITERATIONS)
.clamp(1, env_max_iterations()); .clamp(1, max_chat_iterations());
span.set_attribute("max_iterations", max_iterations as i64); span.set_attribute("max_iterations", max_iterations as i64);
let stored_model = insight.model_version.clone(); let stored_model = insight.model_version.clone();
@@ -1043,7 +1043,7 @@ impl InsightChatService {
let max_iterations = req let max_iterations = req
.max_iterations .max_iterations
.unwrap_or(DEFAULT_MAX_ITERATIONS) .unwrap_or(DEFAULT_MAX_ITERATIONS)
.clamp(1, env_max_iterations()); .clamp(1, max_chat_iterations());
let stored_model = insight.model_version.clone(); let stored_model = insight.model_version.clone();
let overrides = SamplingOverrides { let overrides = SamplingOverrides {
@@ -1246,7 +1246,7 @@ impl InsightChatService {
let max_iterations = req let max_iterations = req
.max_iterations .max_iterations
.unwrap_or(DEFAULT_MAX_ITERATIONS) .unwrap_or(DEFAULT_MAX_ITERATIONS)
.clamp(1, env_max_iterations()); .clamp(1, max_chat_iterations());
let overrides = SamplingOverrides { let overrides = SamplingOverrides {
model: req.model.clone().filter(|m| !m.is_empty()), model: req.model.clone().filter(|m| !m.is_empty()),
@@ -1526,7 +1526,7 @@ impl InsightChatService {
let max_iterations = req let max_iterations = req
.max_iterations .max_iterations
.unwrap_or(DEFAULT_MAX_ITERATIONS) .unwrap_or(DEFAULT_MAX_ITERATIONS)
.clamp(1, env_max_iterations()); .clamp(1, max_chat_iterations());
let stored_model = insight.model_version.clone(); let stored_model = insight.model_version.clone();
let overrides = SamplingOverrides { let overrides = SamplingOverrides {
@@ -1721,7 +1721,7 @@ impl InsightChatService {
let max_iterations = req let max_iterations = req
.max_iterations .max_iterations
.unwrap_or(DEFAULT_MAX_ITERATIONS) .unwrap_or(DEFAULT_MAX_ITERATIONS)
.clamp(1, env_max_iterations()); .clamp(1, max_chat_iterations());
let overrides = SamplingOverrides { let overrides = SamplingOverrides {
model: req.model.clone().filter(|m| !m.is_empty()), model: req.model.clone().filter(|m| !m.is_empty()),
@@ -2164,7 +2164,7 @@ fn resolve_date_taken_for_context(
/// symmetry and future rules. /// symmetry and future rules.
/// ///
/// Whether "local" routes through Ollama or llama-swap is decided at /// Whether "local" routes through Ollama or llama-swap is decided at
/// startup by `LLM_BACKEND`; both share the same transcript shape from /// startup by llm.toml's `backend`; both share the same transcript shape from
/// the chat-replay perspective. /// the chat-replay perspective.
fn validate_cross_replay(_stored: &str, effective: &str) -> Result<()> { fn validate_cross_replay(_stored: &str, effective: &str) -> Result<()> {
if !matches!(effective, "local" | "hybrid") { if !matches!(effective, "local" | "hybrid") {
@@ -2415,25 +2415,19 @@ pub(crate) fn find_raw_cut(
None None
} }
/// Read AGENTIC_CHAT_MAX_ITERATIONS once per call. Cheap; keeps the code /// Cap on tool-calling iterations per chat turn (`agentic.chat_max_iterations`,
/// free of static globals and lets the operator change the cap by env without /// validated >= 1). Per-request values are clamped to it.
/// a restart in test harnesses (the running server still caches via Default). pub fn max_chat_iterations() -> usize {
pub fn env_max_iterations() -> usize { crate::ai::config::llm_config().agentic.chat_max_iterations
std::env::var("AGENTIC_CHAT_MAX_ITERATIONS")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(DEFAULT_MAX_ITERATIONS)
.max(1)
} }
/// Read AGENTIC_CHAT_DEFAULT_NUM_CTX once per call — the assumed context /// Assumed context window for the truncation budget when the request omits
/// window for the truncation budget when the request omits `num_ctx`. Same /// `num_ctx` and the backend can't report one (`agentic.chat_default_num_ctx`).
/// no-static-global rationale as `env_max_iterations` above. /// Floored so the budget always leaves room for the response headroom.
pub(crate) fn env_default_num_ctx() -> i32 { pub(crate) fn default_num_ctx() -> i32 {
std::env::var("AGENTIC_CHAT_DEFAULT_NUM_CTX") crate::ai::config::llm_config()
.ok() .agentic
.and_then(|s| s.parse::<i32>().ok()) .chat_default_num_ctx
.unwrap_or(DEFAULT_NUM_CTX)
.max(RESPONSE_HEADROOM_TOKENS as i32 + 1024) .max(RESPONSE_HEADROOM_TOKENS as i32 + 1024)
} }
@@ -2451,7 +2445,7 @@ pub(crate) async fn effective_num_ctx(
(Some(r), Some(served)) => r.min(served), (Some(r), Some(served)) => r.min(served),
(None, Some(served)) => served, (None, Some(served)) => served,
(Some(r), None) => r, (Some(r), None) => r,
(None, None) => env_default_num_ctx() as usize, (None, None) => default_num_ctx() as usize,
} }
} }
@@ -3835,7 +3829,7 @@ mod tests {
#[test] #[test]
fn bootstrap_backend_rejects_unknown_label() { fn bootstrap_backend_rejects_unknown_label() {
// `llamacpp` is no longer a per-request backend value — it's chosen // `llamacpp` is no longer a per-request backend value — it's chosen
// at deploy time via `LLM_BACKEND`. // at deploy time via llm.toml's `backend`.
for label in &["openrouter", "llamacpp", "ollama"] { for label in &["openrouter", "llamacpp", "ollama"] {
let err = resolve_bootstrap_backend(Some(label)).unwrap_err(); let err = resolve_bootstrap_backend(Some(label)).unwrap_err();
let msg = format!("{}", err); let msg = format!("{}", err);
+20 -9
View File
@@ -234,7 +234,7 @@ pub struct InsightGenerator {
/// `None` when `OPENROUTER_API_KEY` is not configured. /// `None` when `OPENROUTER_API_KEY` is not configured.
openrouter: Option<Arc<OpenRouterClient>>, openrouter: Option<Arc<OpenRouterClient>>,
/// Optional llama-swap client, used when `backend=llamacpp` is requested. /// Optional llama-swap client, used when `backend=llamacpp` is requested.
/// `None` when `LLAMA_SWAP_URL` is not configured. /// `None` when `llamacpp.url` is not configured.
llamacpp: Option<Arc<LlamaCppClient>>, llamacpp: Option<Arc<LlamaCppClient>>,
sms_client: SmsApiClient, sms_client: SmsApiClient,
/// Optional integration with Apollo's user-defined Places. When the /// Optional integration with Apollo's user-defined Places. When the
@@ -735,7 +735,7 @@ impl InsightGenerator {
log::info!("========================================"); log::info!("========================================");
// Generate embedding for the query via the configured local backend // Generate embedding for the query via the configured local backend
// (`LLM_BACKEND` switch). Must match the backend that populated the // (llm.toml `backend` switch). Must match the backend that populated the
// daily-summary embeddings or similarity search will be garbage. // daily-summary embeddings or similarity search will be garbage.
let query_embedding = let query_embedding =
crate::ai::embed_query(&self.ollama, self.llamacpp.as_deref(), &query).await?; crate::ai::embed_query(&self.ollama, self.llamacpp.as_deref(), &query).await?;
@@ -3331,7 +3331,7 @@ 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`), /// Embeddings go through the configured local backend (llm.toml `backend`),
/// independent of the per-request chat backend in the caller. /// independent of the per-request chat backend in the caller.
async fn tool_store_entity(&self, args: &serde_json::Value) -> String { async fn tool_store_entity(&self, args: &serde_json::Value) -> String {
use crate::database::models::InsertEntity; use crate::database::models::InsertEntity;
@@ -4447,7 +4447,7 @@ Return ONLY the summary, nothing else."#,
} else if local_via_llamacpp { } else if local_via_llamacpp {
// Local via llama-swap. // Local via llama-swap.
let arc = self.llamacpp.as_ref().ok_or_else(|| { let arc = self.llamacpp.as_ref().ok_or_else(|| {
anyhow::anyhow!("LLM_BACKEND=llamacpp but LLAMA_SWAP_URL not configured") anyhow::anyhow!("backend = \"llamacpp\" but llamacpp.url not configured")
})?; })?;
let mut c: LlamaCppClient = (**arc).clone(); let mut c: LlamaCppClient = (**arc).clone();
if let Some(ref m) = overrides.model { if let Some(ref m) = overrides.model {
@@ -4515,11 +4515,22 @@ Return ONLY the summary, nothing else."#,
// Hybrid: chat model never sees images — describe-then-inject. // Hybrid: chat model never sees images — describe-then-inject.
false false
} else if local_via_llamacpp { } else if local_via_llamacpp {
// llama-swap models receive images directly via OpenAI content // Vision models get images directly via OpenAI content parts;
// parts. Capability probing isn't available (no `/api/show`), // text-only ones (metadata `vision: false`) get the local vision
// so assume vision support; a misconfigured model surfaces as // model's description inlined instead, like hybrid. If llama-swap
// a chat-call error. // can't be asked, assume vision — the chat call will say why.
true let lc = self
.llamacpp
.as_deref()
.expect("checked by local_via_llamacpp");
let model = overrides.model.as_deref().unwrap_or(&lc.primary_model);
match lc.model_capabilities(model).await {
Ok(caps) => caps.has_vision,
Err(e) => {
log::warn!("vision capability unknown for '{model}', sending images: {e:#}");
true
}
}
} else { } else {
// Pure Ollama: probe model capabilities. // Pure Ollama: probe model capabilities.
let ollama_for_caps = if let Some(ref model) = overrides.model { let ollama_for_caps = if let Some(ref model) = overrides.model {
+336 -68
View File
@@ -10,11 +10,11 @@
// `generate_embeddings` issue requests with the appropriate slot id in the // `generate_embeddings` issue requests with the appropriate slot id in the
// `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 // - Capabilities come from each model's `metadata:` block in llama-swap's
// reported by the API, so we infer `has_vision` from a single config field // config.yaml, which `/v1/models` passes through (`kind`, `vision`, `ctx`;
// (`vision_model`, defaulting to `"vision"`) and assume `has_tool_calling` // see `DiscoveredModel`). A model without metadata is treated as a
// is true for every slot, since llama-swap entries default to launching // text-only chat model. `has_tool_calling` is assumed true for every model,
// llama-server with `--jinja`. // since llama-swap entries launch 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.
@@ -31,6 +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::{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,
}; };
@@ -41,7 +42,6 @@ const DEFAULT_PRIMARY_MODEL: &str = "chat";
const DEFAULT_VISION_MODEL: &str = "vision"; const DEFAULT_VISION_MODEL: &str = "vision";
const DEFAULT_EMBEDDING_MODEL: &str = "embed"; const DEFAULT_EMBEDDING_MODEL: &str = "embed";
const DEFAULT_TTS_MODEL: &str = "chatterbox"; const DEFAULT_TTS_MODEL: &str = "chatterbox";
const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 180;
/// How long a served-context-window lookup stays cached per model. /// How long a served-context-window lookup stays cached per model.
const CONTEXT_WINDOW_CACHE_TTL: Duration = Duration::from_secs(600); const CONTEXT_WINDOW_CACHE_TTL: Duration = Duration::from_secs(600);
@@ -69,11 +69,11 @@ pub struct LlamaCppClient {
pub embedding_model: String, pub embedding_model: String,
/// Vision model slot id. Used for `describe_image` routing. Defaults /// Vision model slot id. Used for `describe_image` routing. Defaults
/// to `primary_model` so describe_image works out of the box; override /// to `primary_model` so describe_image works out of the box; override
/// via `LLAMA_SWAP_VISION_MODEL` for a dedicated vision slot. /// via `llamacpp.vision` for a dedicated vision slot.
pub vision_model: String, pub vision_model: String,
/// TTS model slot id (e.g. `"chatterbox"`). Routes `text_to_speech` and /// TTS model slot id (e.g. `"chatterbox"`). Routes `text_to_speech` and
/// is the `/upstream/<id>/voices` path segment for the voice library. /// is the `/upstream/<id>/voices` path segment for the voice library.
/// Override via `LLAMA_SWAP_TTS_MODEL`. /// Set via `tts.model`.
pub tts_model: String, pub tts_model: String,
num_ctx: Option<i32>, num_ctx: Option<i32>,
temperature: Option<f32>, temperature: Option<f32>,
@@ -90,10 +90,7 @@ pub struct LlamaCppClient {
impl LlamaCppClient { impl LlamaCppClient {
pub fn new(base_url: Option<String>, primary_model: Option<String>) -> Self { pub fn new(base_url: Option<String>, primary_model: Option<String>) -> Self {
let timeout_secs = std::env::var("LLAMA_SWAP_REQUEST_TIMEOUT_SECONDS") let timeout_secs = crate::ai::config::llm_config().llamacpp.timeout_secs;
.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()); let pm = primary_model.unwrap_or_else(|| DEFAULT_PRIMARY_MODEL.to_string());
Self { Self {
client: Client::builder() client: Client::builder()
@@ -287,11 +284,7 @@ impl LlamaCppClient {
// TTS gets its own (longer) timeout: synthesizing a long, internally // TTS gets its own (longer) timeout: synthesizing a long, internally
// chunked insight can take minutes, well past the shared chat/embedding // chunked insight can take minutes, well past the shared chat/embedding
// client timeout. Per-request `.timeout()` overrides the client default. // client timeout. Per-request `.timeout()` overrides the client default.
let tts_timeout = std::env::var("LLAMA_SWAP_TTS_REQUEST_TIMEOUT_SECONDS") let tts_timeout = crate::ai::config::llm_config().tts.timeout_secs;
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|n| *n > 0)
.unwrap_or(600);
let resp = self let resp = self
.client .client
@@ -995,32 +988,12 @@ impl LlmClient for LlamaCppClient {
} }
async fn list_models(&self) -> Result<Vec<ModelCapabilities>> { async fn list_models(&self) -> Result<Vec<ModelCapabilities>> {
let url = format!("{}/models", self.base_url); Ok(self
let resp = self .discover_models()
.client .await?
.get(&url) .into_iter()
.send() .map(|m| m.caps)
.await .collect())
.with_context(|| format!("GET {} failed", url))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("llama-swap list_models failed: {} — {}", status, body);
}
let parsed: Value = resp.json().await.context("parsing models response")?;
let data = parsed
.get("data")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow!("models response missing data[]"))?;
let caps: Vec<ModelCapabilities> = data
.iter()
.map(|m| self.parse_model_capabilities(m))
.collect();
Ok(caps)
} }
async fn model_capabilities(&self, model: &str) -> Result<ModelCapabilities> { async fn model_capabilities(&self, model: &str) -> Result<ModelCapabilities> {
@@ -1042,25 +1015,183 @@ impl LlmClient for LlamaCppClient {
} }
} }
impl LlamaCppClient { /// One entry of llama-swap's `/v1/models`, with the per-model `metadata`
fn parse_model_capabilities(&self, m: &Value) -> ModelCapabilities { /// from config.yaml decoded.
#[derive(Debug, Clone)]
pub struct DiscoveredModel {
pub caps: ModelCapabilities,
/// `metadata.kind`: `chat` | `embed` | `tts`. `None` when the model has
/// no metadata (treated as chat).
pub kind: Option<String>,
/// A `setParamsByID` alias like `qwen3.8-27b-code:sub` — a parameter
/// profile on another model, not a model of its own.
pub is_alias: bool,
/// `metadata.dim` on embed models: the vector width they produce.
pub dim: Option<usize>,
}
impl DiscoveredModel {
pub fn is_chat(&self) -> bool {
matches!(self.kind.as_deref(), None | Some("chat"))
}
/// Parse one `/v1/models` row. The deployed llama-swap merges config
/// `metadata` into `meta.llamaswap` (next to its own `type` / `aliases`);
/// upstream docs describe it at `meta` itself, so both are read.
fn from_json(m: &Value) -> Self {
let field = |key: &str| {
m.pointer(&format!("/meta/llamaswap/{key}"))
.or_else(|| m.pointer(&format!("/meta/{key}")))
};
let name = m let name = m
.get("id") .get("id")
.and_then(|v| v.as_str()) .and_then(Value::as_str)
.unwrap_or_default() .unwrap_or_default()
.to_string(); .to_string();
// llama-swap doesn't expose per-model modality flags. Assume all Self {
// configured models support vision and tool calling; a model caps: ModelCapabilities {
// without multimodal support surfaces as a chat-call error. name,
ModelCapabilities { has_vision: field("vision").and_then(Value::as_bool).unwrap_or(false),
name, has_tool_calling: true,
has_vision: true, context_window: field("ctx").and_then(Value::as_u64).map(|n| n as usize),
has_tool_calling: true, },
context_window: None, kind: field("kind").and_then(Value::as_str).map(str::to_string),
is_alias: field("type").and_then(Value::as_str) == Some("alias"),
dim: field("dim").and_then(Value::as_u64).map(|n| n as usize),
} }
} }
} }
/// The `/insights/models` picker: every non-alias chat model, minus
/// `picker.exclude`, restricted to `picker.include` when that's set.
/// llama-swap's order is kept.
pub fn picker_models(
models: Vec<DiscoveredModel>,
picker: &PickerConfig,
) -> Vec<ModelCapabilities> {
models
.into_iter()
.filter(|m| !m.is_alias && m.is_chat())
.filter(|m| !picker.exclude.contains(&m.caps.name))
.filter(|m| {
picker
.include
.as_ref()
.is_none_or(|inc| inc.contains(&m.caps.name))
})
.map(|m| m.caps)
.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 {
/// 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.
pub async fn discover_models(&self) -> Result<Vec<DiscoveredModel>> {
let url = format!("{}/models", self.base_url);
let resp = self
.client
.get(&url)
.send()
.await
.with_context(|| format!("GET {} failed", url))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("llama-swap list_models failed: {} — {}", status, body);
}
let parsed: Value = resp.json().await.context("parsing models response")?;
let data = parsed
.get("data")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow!("models response missing data[]"))?;
Ok(data.iter().map(DiscoveredModel::from_json).collect())
}
}
/// Extract a diagnostic fragment from a llama-swap / llama-server response /// Extract a diagnostic fragment from a llama-swap / llama-server response
/// that doesn't match the expected `{choices: [...]}` shape. llama-server /// that doesn't match the expected `{choices: [...]}` shape. llama-server
/// returns errors as `{"error": {"message": "...", "code": N, "type": "..."}}`; /// returns errors as `{"error": {"message": "...", "code": N, "type": "..."}}`;
@@ -1448,22 +1579,159 @@ mod tests {
} }
#[test] #[test]
fn all_models_report_vision_capable() { fn metadata_drives_capabilities() {
let mut c = LlamaCppClient::new(None, Some("chat".into())); // Shape of the deployed llama-swap: config metadata merged into
c.set_vision_model("vision".into()); // meta.llamaswap.
let m = DiscoveredModel::from_json(&json!({
"id": "qwen3.8-27b-code-vision",
"meta": { "llamaswap": {
"type": "model", "kind": "chat", "vision": true, "ctx": 90000
}}
}));
assert_eq!(m.caps.name, "qwen3.8-27b-code-vision");
assert!(m.caps.has_vision);
assert!(m.caps.has_tool_calling);
assert_eq!(m.caps.context_window, Some(90000));
assert!(m.is_chat() && !m.is_alias);
}
let m_chat = json!({ "id": "chat" }); #[test]
let m_vision = json!({ "id": "vision" }); fn top_level_meta_is_read_too() {
let m_other = json!({ "id": "embed" }); // Upstream docs put metadata at `meta` itself.
let m = DiscoveredModel::from_json(&json!({
"id": "embed", "meta": { "kind": "embed", "dim": 768 }
}));
assert_eq!(m.kind.as_deref(), Some("embed"));
assert!(!m.is_chat());
}
let chat = c.parse_model_capabilities(&m_chat); #[test]
let vision = c.parse_model_capabilities(&m_vision); fn model_without_metadata_is_text_only_chat() {
let other = c.parse_model_capabilities(&m_other); let m = DiscoveredModel::from_json(&json!({ "id": "mystery" }));
assert!(m.is_chat());
assert!(!m.caps.has_vision);
assert_eq!(m.caps.context_window, None);
}
assert!(chat.has_vision); fn row(id: &str, kind: &str, kind_type: &str) -> DiscoveredModel {
assert!(chat.has_tool_calling); DiscoveredModel::from_json(&json!({
assert!(vision.has_vision); "id": id, "meta": { "llamaswap": { "type": kind_type, "kind": kind } }
assert!(other.has_vision); }))
}
fn names(models: Vec<ModelCapabilities>) -> Vec<String> {
models.into_iter().map(|m| m.name).collect()
}
#[test]
fn picker_shows_chat_models_only_and_hides_aliases() {
let models = vec![
row("chatterbox", "tts", "model"),
row("embed", "embed", "model"),
row("q-code", "chat", "model"),
row("q-code:sub", "chat", "alias"),
row("gemma", "chat", "model"),
];
assert_eq!(
names(picker_models(models, &PickerConfig::default())),
["q-code", "gemma"]
);
}
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]
fn picker_applies_exclude_and_include() {
let models = || {
vec![
row("a", "chat", "model"),
row("b", "chat", "model"),
row("c", "chat", "model"),
]
};
let exclude = PickerConfig {
include: None,
exclude: vec!["b".into()],
};
assert_eq!(names(picker_models(models(), &exclude)), ["a", "c"]);
// include keeps llama-swap's order and silently drops ids it doesn't
// serve (the stale-allowlist case).
let include = PickerConfig {
include: Some(vec!["c".into(), "a".into(), "gone".into()]),
exclude: vec!["a".into()],
};
assert_eq!(names(picker_models(models(), &include)), ["c"]);
} }
#[test] #[test]
@@ -1510,7 +1778,7 @@ mod tests {
// In hybrid mode, overrides.model is an OpenRouter model id // In hybrid mode, overrides.model is an OpenRouter model id
// (e.g. "google/gemini-3-flash-preview"). The local llamacpp // (e.g. "google/gemini-3-flash-preview"). The local llamacpp
// client must NOT adopt that as its vision_model — it should // client must NOT adopt that as its vision_model — it should
// keep the configured LLAMA_SWAP_VISION_MODEL so describe_image // keep the configured `llamacpp.vision` so describe_image
// hits the correct local slot instead of sending an unknown // hits the correct local slot instead of sending an unknown
// model name to llama-swap. // model name to llama-swap.
let mut base = LlamaCppClient::new(None, Some("chat".into())); let mut base = LlamaCppClient::new(None, Some("chat".into()));
+12 -11
View File
@@ -1,10 +1,10 @@
//! Bundle of the local LLM pair (Ollama + optional llama-swap) with the //! Bundle of the local LLM pair (Ollama + optional llama-swap) with the
//! `LLM_BACKEND` dispatch baked in. //! `backend` (llm.toml) dispatch baked in.
//! //!
//! Exists because passing the pair around as loose values invited the same //! Exists because passing the pair around as loose values invited the same
//! bug three times: import/backfill tooling embedded corpora via //! bug three times: import/backfill tooling embedded corpora via
//! `OllamaClient` directly while the query side dispatched through //! `OllamaClient` directly while the query side dispatched through
//! `embed_one`, so flipping `LLM_BACKEND=llamacpp` silently split queries //! `embed_one`, so flipping `backend = "llamacpp"` silently split queries
//! and corpus into different vector spaces. Anything that writes or reads //! and corpus into different vector spaces. Anything that writes or reads
//! embeddings should go through this type (or `embed_one`/`embed_many`), //! embeddings should go through this type (or `embed_one`/`embed_many`),
//! never a concrete client. //! never a concrete client.
@@ -32,27 +32,28 @@ impl LocalLlm {
Self { ollama, llamacpp } Self { ollama, llamacpp }
} }
/// Construct from the canonical env wiring shared with `AppState`. /// Construct from `llm.toml`, the same wiring `AppState` uses.
pub fn from_env() -> Self { pub fn from_config() -> Self {
let cfg = crate::ai::config::llm_config();
Self::new( Self::new(
crate::state::build_ollama_from_env(), crate::state::build_ollama(&cfg.ollama),
crate::state::build_llamacpp_from_env(), crate::state::build_llamacpp(cfg),
) )
} }
/// Embed a search query (applies `EMBED_QUERY_PREFIX`). Callers must /// Embed a search query (applies `embedding.query_prefix`). Callers must
/// pick query vs document — retrieval models treat the two sides /// pick query vs document — retrieval models treat the two sides
/// differently and an unmarked embed invites prefix-mismatch bugs. /// differently and an unmarked embed invites prefix-mismatch bugs.
pub async fn embed_query(&self, text: &str) -> Result<Vec<f32>> { pub async fn embed_query(&self, text: &str) -> Result<Vec<f32>> {
super::embed_query(&self.ollama, self.llamacpp.as_deref(), text).await super::embed_query(&self.ollama, self.llamacpp.as_deref(), text).await
} }
/// Embed corpus text (applies `EMBED_DOCUMENT_PREFIX`). /// Embed corpus text (applies `embedding.document_prefix`).
pub async fn embed_document(&self, text: &str) -> Result<Vec<f32>> { pub async fn embed_document(&self, text: &str) -> Result<Vec<f32>> {
super::embed_document(&self.ollama, self.llamacpp.as_deref(), text).await super::embed_document(&self.ollama, self.llamacpp.as_deref(), text).await
} }
/// Single-shot local text generation via the `LLM_BACKEND`-selected /// Single-shot local text generation via the `backend`-selected
/// client (offline tooling; chat turns belong to `ResolvedBackend`). /// client (offline tooling; chat turns belong to `ResolvedBackend`).
pub async fn generate(&self, prompt: &str, system: Option<&str>) -> Result<String> { pub async fn generate(&self, prompt: &str, system: Option<&str>) -> Result<String> {
if super::local_backend_is_llamacpp() { if super::local_backend_is_llamacpp() {
@@ -60,8 +61,8 @@ impl LocalLlm {
return <LlamaCppClient as LlmClient>::generate(lc, prompt, system, None).await; return <LlamaCppClient as LlmClient>::generate(lc, prompt, system, None).await;
} }
anyhow::bail!( anyhow::bail!(
"LLM_BACKEND=llamacpp but LlamaCppClient is unconfigured — \ "backend = \"llamacpp\" but LlamaCppClient is unconfigured — \
set LLAMA_SWAP_URL or switch to LLM_BACKEND=ollama" set llamacpp.url in llm.toml or switch to backend = \"ollama\""
); );
} }
self.ollama.generate(prompt, system).await self.ollama.generate(prompt, system).await
+21 -92
View File
@@ -1,6 +1,7 @@
pub mod apollo_client; pub mod apollo_client;
pub mod backend; pub mod backend;
pub mod clip_client; pub mod clip_client;
pub mod config;
pub mod daily_summary_job; pub mod daily_summary_job;
pub mod date_info; pub mod date_info;
pub mod face_client; pub mod face_client;
@@ -67,51 +68,26 @@ 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 /// One switch for the "local" LLM stack: with `backend = "llamacpp"` in
/// set, chat / vision describe / embeddings all route through llama-swap /// `llm.toml`, chat / vision describe / embeddings all route through
/// instead of Ollama. Any other value (including unset, the default) is /// llama-swap instead of Ollama. Intentionally global — embeddings must be
/// Ollama. This is intentionally global — embeddings must be drawn from /// drawn from a single source or similarity search across the index breaks
/// a single source or similarity search across the index breaks (mixed /// (mixed vector spaces, possibly mixed dims). The `backend=hybrid`
/// vector spaces, possibly mixed dims). The `backend=hybrid` per-request /// per-request override remains orthogonal: it always sends chat to
/// override remains orthogonal: it always sends chat to OpenRouter, and /// OpenRouter, and uses this switch for the describe-then-inline vision pass.
/// uses `LLM_BACKEND` for the describe-then-inline vision pass.
pub fn local_backend_is_llamacpp() -> bool { pub fn local_backend_is_llamacpp() -> bool {
matches!( config::llm_config().backend == config::LocalBackend::Llamacpp
std::env::var("LLM_BACKEND")
.ok()
.as_deref()
.map(|s| s.trim().to_lowercase())
.as_deref(),
Some("llamacpp")
)
} }
/// Expected embedding dimensionality, env-overridable via `EMBEDDING_DIM` /// Expected embedding dimensionality (`embedding.dim`, default 768 for
/// (default 768, nomic-embed-text). Every store/query dim check reads this — /// nomic-embed-text). Every store/query dim check reads this — swapping to a
/// swapping to a different-dim model (e.g. Qwen3-Embedding-0.6B at 1024) is /// different-dim model (e.g. Qwen3-Embedding-0.6B at 1024) is a config flip
/// then a config flip plus a `reembed_embeddings` run, not a code change. /// plus a `reembed_embeddings` run, not a code change.
/// Cached for the process lifetime; a flip requires a restart anyway since
/// the corpus must be re-embedded with it.
pub fn embedding_dim() -> usize { pub fn embedding_dim() -> usize {
static DIM: std::sync::OnceLock<usize> = std::sync::OnceLock::new(); config::llm_config().embedding.dim
*DIM.get_or_init(|| {
std::env::var("EMBEDDING_DIM")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(768)
})
} }
/// Read an embedding prefix from the environment. `.env` values can't hold /// Embed a search query. Applies `embedding.query_prefix` (default empty) —
/// real newlines, so a literal `\n` in the value is expanded — Qwen3-style
/// query instructions need one ("Instruct: ...\nQuery: ").
fn embed_prefix(key: &str) -> String {
std::env::var(key)
.map(|v| v.replace("\\n", "\n"))
.unwrap_or_default()
}
/// Embed a search query. Applies `EMBED_QUERY_PREFIX` (default empty) —
/// retrieval models distinguish query-side from document-side text: /// retrieval models distinguish query-side from document-side text:
/// nomic v1.5 wants `search_query: `, Qwen3-Embedding wants /// nomic v1.5 wants `search_query: `, Qwen3-Embedding wants
/// `Instruct: <task>\nQuery: `. Must pair with the document prefix the /// `Instruct: <task>\nQuery: `. Must pair with the document prefix the
@@ -121,24 +97,24 @@ pub async fn embed_query(
llamacpp: Option<&LlamaCppClient>, llamacpp: Option<&LlamaCppClient>,
text: &str, text: &str,
) -> anyhow::Result<Vec<f32>> { ) -> anyhow::Result<Vec<f32>> {
let prefixed = format!("{}{}", embed_prefix("EMBED_QUERY_PREFIX"), text); let prefixed = format!("{}{}", config::llm_config().embedding.query_prefix, text);
embed_one(ollama, llamacpp, &prefixed).await embed_one(ollama, llamacpp, &prefixed).await
} }
/// Embed corpus text (the stored side of retrieval). Applies /// Embed corpus text (the stored side of retrieval). Applies
/// `EMBED_DOCUMENT_PREFIX` (default empty; nomic v1.5 wants /// `embedding.document_prefix` (default empty; nomic v1.5 wants
/// `search_document: `, Qwen3-Embedding wants none). /// `search_document: `, Qwen3-Embedding wants none).
pub async fn embed_document( pub async fn embed_document(
ollama: &OllamaClient, ollama: &OllamaClient,
llamacpp: Option<&LlamaCppClient>, llamacpp: Option<&LlamaCppClient>,
text: &str, text: &str,
) -> anyhow::Result<Vec<f32>> { ) -> anyhow::Result<Vec<f32>> {
let prefixed = format!("{}{}", embed_prefix("EMBED_DOCUMENT_PREFIX"), text); let prefixed = format!("{}{}", config::llm_config().embedding.document_prefix, text);
embed_one(ollama, llamacpp, &prefixed).await embed_one(ollama, llamacpp, &prefixed).await
} }
/// Embed a batch of strings via the configured local backend. Routes /// Embed a batch of strings via the configured local backend. Routes
/// through llama-swap when `LLM_BACKEND=llamacpp` (and a client is /// through llama-swap when `backend = "llamacpp"` (and a client is
/// configured), else Ollama. See [`local_backend_is_llamacpp`] for the /// configured), else Ollama. See [`local_backend_is_llamacpp`] for the
/// rationale on consistency. /// rationale on consistency.
pub async fn embed_many( pub async fn embed_many(
@@ -151,8 +127,8 @@ pub async fn embed_many(
return <LlamaCppClient as LlmClient>::generate_embeddings(lc, texts).await; return <LlamaCppClient as LlmClient>::generate_embeddings(lc, texts).await;
} }
anyhow::bail!( anyhow::bail!(
"LLM_BACKEND=llamacpp but LlamaCppClient is unconfigured — \ "backend = \"llamacpp\" but LlamaCppClient is unconfigured — \
set LLAMA_SWAP_URL or switch to LLM_BACKEND=ollama" set llamacpp.url in llm.toml or switch to backend = \"ollama\""
); );
} }
ollama.generate_embeddings(texts).await ollama.generate_embeddings(texts).await
@@ -169,50 +145,3 @@ pub async fn embed_one(
vecs.pop() vecs.pop()
.ok_or_else(|| anyhow::anyhow!("embedding backend returned no embeddings")) .ok_or_else(|| anyhow::anyhow!("embedding backend returned no embeddings"))
} }
#[cfg(test)]
mod env_dispatch_tests {
use super::*;
/// Env vars are process-global, and the test harness runs in parallel —
/// without this lock the `LLM_BACKEND` tests race each other and flake.
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_env<F: FnOnce()>(key: &str, val: Option<&str>, f: F) {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
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());
});
}
}
+5 -11
View File
@@ -23,17 +23,11 @@ pub use crate::ai::llm_client::{ToolCall, ToolCallFunction, ToolFunction};
// Cache duration: 15 minutes // Cache duration: 15 minutes
const CACHE_DURATION_SECS: u64 = 15 * 60; const CACHE_DURATION_SECS: u64 = 15 * 60;
/// Default total request timeout for generation calls, in seconds. /// Total request timeout for generation calls (`ollama.timeout_secs`,
/// Overridable via `OLLAMA_REQUEST_TIMEOUT_SECONDS` env var for slow /// default 120). Raise it for slow CPU-offloaded models where inference can
/// CPU-offloaded models where inference can take several minutes. /// take several minutes.
const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 120;
fn configured_request_timeout_secs() -> u64 { fn configured_request_timeout_secs() -> u64 {
std::env::var("OLLAMA_REQUEST_TIMEOUT_SECONDS") crate::ai::config::llm_config().ollama.timeout_secs
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|&s| s > 0)
.unwrap_or(DEFAULT_REQUEST_TIMEOUT_SECS)
} }
/// Embedding model used across the app. Callers that persist a /// Embedding model used across the app. Callers that persist a
@@ -1169,7 +1163,7 @@ Analyze the image and use specific details from both the visual content and the
} }
}; };
// Validate embedding dimensions (EMBEDDING_DIM; 768 for nomic-embed-text:v1.5) // Validate embedding dimensions (`embedding.dim`; 768 for nomic-embed-text:v1.5)
for (i, embedding) in embeddings.iter().enumerate() { for (i, embedding) in embeddings.iter().enumerate() {
if embedding.len() != crate::ai::embedding_dim() { if embedding.len() != crate::ai::embedding_dim() {
log::warn!( log::warn!(
+1 -1
View File
@@ -26,7 +26,7 @@ const CACHE_DURATION_SECS: u64 = 15 * 60;
/// Default completion cap sent as `max_tokens`. OpenRouter pre-authorizes /// Default completion cap sent as `max_tokens`. OpenRouter pre-authorizes
/// credit against the requested max (defaulting to the model's full output /// credit against the requested max (defaulting to the model's full output
/// window when omitted), so expensive models fail with credit-limit errors /// window when omitted), so expensive models fail with credit-limit errors
/// even when the actual reply is short. Override via `OPENROUTER_MAX_TOKENS`. /// even when the actual reply is short. Override via `openrouter.max_tokens`.
pub const DEFAULT_MAX_TOKENS: u32 = 16384; pub const DEFAULT_MAX_TOKENS: u32 = 16384;
#[derive(Clone)] #[derive(Clone)]
+2 -2
View File
@@ -37,7 +37,7 @@ use uuid::Uuid;
use crate::ai::backend::{BackendKind, SamplingOverrides}; use crate::ai::backend::{BackendKind, SamplingOverrides};
use crate::ai::handlers::ReplayQuery; use crate::ai::handlers::ReplayQuery;
use crate::ai::insight_chat::{ use crate::ai::insight_chat::{
ChatStreamEvent, ContextFitter, DEFAULT_MAX_ITERATIONS, ForkInfo, env_max_iterations, ChatStreamEvent, ContextFitter, DEFAULT_MAX_ITERATIONS, ForkInfo, max_chat_iterations,
render_tree_path, render_tree_path,
}; };
use crate::ai::insight_generator::InsightGenerator; use crate::ai::insight_generator::InsightGenerator;
@@ -954,7 +954,7 @@ impl PersonaChatSession {
Some("hybrid") => BackendKind::Hybrid, Some("hybrid") => BackendKind::Hybrid,
_ => BackendKind::Local, _ => BackendKind::Local,
}; };
let max_iterations = DEFAULT_MAX_ITERATIONS.clamp(1, env_max_iterations()); let max_iterations = DEFAULT_MAX_ITERATIONS.clamp(1, max_chat_iterations());
let overrides = SamplingOverrides { let overrides = SamplingOverrides {
model: req.model.clone().filter(|m| !m.is_empty()), model: req.model.clone().filter(|m| !m.is_empty()),
num_ctx: req.num_ctx, num_ctx: req.num_ctx,
+9 -16
View File
@@ -169,13 +169,9 @@ fn sanitize_voice_name(raw: &str) -> Option<String> {
/// Reference-clip cap in seconds for voice cloning. Chatterbox is zero-shot — /// Reference-clip cap in seconds for voice cloning. Chatterbox is zero-shot —
/// a clean ~10–20s sample is the sweet spot and more rarely helps. Tune via /// a clean ~10–20s sample is the sweet spot and more rarely helps. Tune via
/// `LLAMA_SWAP_TTS_REF_SECONDS` (default 30). /// `tts.ref_seconds` (default 30).
fn tts_ref_seconds() -> u32 { fn tts_ref_seconds() -> u32 {
std::env::var("LLAMA_SWAP_TTS_REF_SECONDS") crate::ai::config::llm_config().tts.ref_seconds
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|n| *n > 0)
.unwrap_or(30)
} }
/// Tag a (sanitized) voice name with the reference window used to create it: /// Tag a (sanitized) voice name with the reference window used to create it:
@@ -228,12 +224,9 @@ fn resolve_ref_window(
} }
/// Optional default voice for synthesis when the request doesn't name one. /// Optional default voice for synthesis when the request doesn't name one.
/// Set `LLAMA_SWAP_TTS_VOICE=m` to read insights in a cloned voice by default. /// Set `tts.voice = "m"` to read insights in a cloned voice by default.
fn default_voice() -> Option<String> { fn default_voice() -> Option<String> {
std::env::var("LLAMA_SWAP_TTS_VOICE") crate::ai::config::llm_config().tts.voice.clone()
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
} }
// Markdown / formatting strippers, compiled once. Insight text is markdown, // Markdown / formatting strippers, compiled once. Insight text is markdown,
@@ -398,7 +391,7 @@ pub async fn tts_speech_handler(
let Some(client) = app_state.llamacpp.as_ref() else { let Some(client) = app_state.llamacpp.as_ref() else {
span.set_status(Status::error("tts backend not configured")); span.set_status(Status::error("tts backend not configured"));
return HttpResponse::ServiceUnavailable() return HttpResponse::ServiceUnavailable()
.json(json!({ "error": "TTS backend not configured (set LLAMA_SWAP_URL)" })); .json(json!({ "error": "TTS backend not configured (set llamacpp.url in llm.toml)" }));
}; };
let format = req let format = req
@@ -528,7 +521,7 @@ pub async fn create_speech_job_handler(
if app_state.llamacpp.is_none() { if app_state.llamacpp.is_none() {
span.set_status(Status::error("tts backend not configured")); span.set_status(Status::error("tts backend not configured"));
return HttpResponse::ServiceUnavailable() return HttpResponse::ServiceUnavailable()
.json(json!({ "error": "TTS backend not configured (set LLAMA_SWAP_URL)" })); .json(json!({ "error": "TTS backend not configured (set llamacpp.url in llm.toml)" }));
} }
let format = req let format = req
@@ -965,7 +958,7 @@ pub struct CreateVoiceFromLibraryRequest {
/// lets the client pick the clean-speech section of a long recording. /// lets the client pick the clean-speech section of a long recording.
#[serde(default)] #[serde(default)]
pub start_seconds: Option<f64>, pub start_seconds: Option<f64>,
/// Reference window length; clamped to LLAMA_SWAP_TTS_REF_SECONDS. /// Reference window length; clamped to `tts.ref_seconds`.
#[serde(default)] #[serde(default)]
pub duration_seconds: Option<f64>, pub duration_seconds: Option<f64>,
} }
@@ -973,7 +966,7 @@ pub struct CreateVoiceFromLibraryRequest {
/// POST /tts/voices/from-library — register a cloned voice from a file already /// POST /tts/voices/from-library — register a cloned voice from a file already
/// in a library. Audio and video alike are ffmpeg-normalized to a mono 24 kHz /// in a library. Audio and video alike are ffmpeg-normalized to a mono 24 kHz
/// WAV reference clip (window selected by start/duration_seconds, length /// WAV reference clip (window selected by start/duration_seconds, length
/// capped by LLAMA_SWAP_TTS_REF_SECONDS). /// capped by `tts.ref_seconds`).
#[post("/tts/voices/from-library")] #[post("/tts/voices/from-library")]
#[tracing::instrument(name = "http.tts.voices.from_library", skip_all)] #[tracing::instrument(name = "http.tts.voices.from_library", skip_all)]
pub async fn create_voice_from_library_handler( pub async fn create_voice_from_library_handler(
@@ -1160,7 +1153,7 @@ mod tests {
#[test] #[test]
fn resolve_ref_window_defaults_to_start_of_clip_at_cap_length() { fn resolve_ref_window_defaults_to_start_of_clip_at_cap_length() {
// Reads the live cap rather than mutating LLAMA_SWAP_TTS_REF_SECONDS: // Reads the live cap rather than mutating `tts.ref_seconds`:
// env mutation flakes under the parallel suite (see env_dispatch). // env mutation flakes under the parallel suite (see env_dispatch).
let cap = f64::from(tts_ref_seconds()); let cap = f64::from(tts_ref_seconds());
assert_eq!(resolve_ref_window(None, None), Ok((0.0, cap))); assert_eq!(resolve_ref_window(None, None), Ok((0.0, cap)));
+2 -2
View File
@@ -42,10 +42,10 @@ async fn main() -> Result<()> {
info!("Found {} calendar events", events.len()); info!("Found {} calendar events", events.len());
// LocalLlm dispatches per LLM_BACKEND, so embeddings written here land // LocalLlm dispatches per llm.toml's backend, so embeddings written here land
// in the same vector space the query side searches. // in the same vector space the query side searches.
let llm = if args.generate_embeddings { let llm = if args.generate_embeddings {
Some(LocalLlm::from_env()) Some(LocalLlm::from_config())
} else { } else {
None None
}; };
+2 -2
View File
@@ -38,9 +38,9 @@ async fn main() -> Result<()> {
info!("Found {} search records", searches.len()); info!("Found {} search records", searches.len());
// LocalLlm dispatches per LLM_BACKEND, so embeddings written here land // LocalLlm dispatches per llm.toml's backend, so embeddings written here land
// in the same vector space the query side searches. // in the same vector space the query side searches.
let llm = LocalLlm::from_env(); let llm = LocalLlm::from_config();
let mut inserted_count = 0usize; let mut inserted_count = 0usize;
let mut skipped_count = 0usize; let mut skipped_count = 0usize;
+7 -12
View File
@@ -35,7 +35,7 @@ struct Args {
#[arg(long)] #[arg(long)]
path: Option<String>, path: Option<String>,
/// Ollama model override. Defaults to OLLAMA_PRIMARY_MODEL from .env /// Ollama model override. Defaults to ollama.primary_model from llm.toml
#[arg(long)] #[arg(long)]
model: Option<String>, model: Option<String>,
@@ -129,24 +129,19 @@ async fn main() -> anyhow::Result<()> {
} }
}; };
// Ollama config from env with CLI overrides. // Ollama config from llm.toml with CLI overrides.
let primary_url = std::env::var("OLLAMA_PRIMARY_URL") let ollama_cfg = &image_api::ai::config::llm_config().ollama;
.or_else(|_| std::env::var("OLLAMA_URL")) let primary_url = ollama_cfg.primary_url.clone();
.unwrap_or_else(|_| "http://localhost:11434".to_string());
let fallback_url = std::env::var("OLLAMA_FALLBACK_URL").ok();
let primary_model = args let primary_model = args
.model .model
.clone() .clone()
.or_else(|| std::env::var("OLLAMA_PRIMARY_MODEL").ok()) .unwrap_or_else(|| ollama_cfg.primary_model.clone());
.or_else(|| std::env::var("OLLAMA_MODEL").ok())
.unwrap_or_else(|| "nemotron-3-nano:30b".to_string());
let fallback_model = std::env::var("OLLAMA_FALLBACK_MODEL").ok();
let mut ollama = OllamaClient::new( let mut ollama = OllamaClient::new(
primary_url.clone(), primary_url.clone(),
fallback_url, ollama_cfg.fallback_url.clone(),
primary_model.clone(), primary_model.clone(),
fallback_model, ollama_cfg.fallback_model.clone(),
) )
.with_request_timeout(args.timeout_secs); .with_request_timeout(args.timeout_secs);
+6 -6
View File
@@ -1,7 +1,7 @@
//! Re-embed stored corpora through `LocalLlm`, i.e. the same //! Re-embed stored corpora through `LocalLlm`, i.e. the same
//! `LLM_BACKEND` dispatch the query side uses. The original import / //! `backend` (llm.toml) dispatch the query side uses. The original import /
//! backfill tools always embedded via Ollama, so a deploy running //! backfill tools always embedded via Ollama, so a deploy running
//! `LLM_BACKEND=llamacpp` queries vector spaces the corpora may not live //! `backend = "llamacpp"` queries vector spaces the corpora may not live
//! in. Three tables share the problem and are all covered here: //! in. Three tables share the problem and are all covered here:
//! //!
//! - `daily_conversation_summaries` — re-embeds //! - `daily_conversation_summaries` — re-embeds
@@ -30,7 +30,7 @@ use image_api::bin_progress;
use std::env; use std::env;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version, about = "Re-embed stored corpora via the configured LLM_BACKEND", long_about = None)] #[command(author, version, about = "Re-embed stored corpora via the configured local backend", long_about = None)]
struct Args { struct Args {
/// Comma-separated tables to process: summaries, calendar, search, entities /// Comma-separated tables to process: summaries, calendar, search, entities
#[arg(long, default_value = "summaries,calendar,search,entities")] #[arg(long, default_value = "summaries,calendar,search,entities")]
@@ -195,12 +195,12 @@ async fn reembed_table(
}; };
// The whole pipeline (DAO checks, stored corpora) assumes // The whole pipeline (DAO checks, stored corpora) assumes
// EMBEDDING_DIM dims. A mismatch means the active embed slot is not // embedding.dim dims. A mismatch means the active embed slot is not
// serving the configured model — stop rather than corrupt the table. // serving the configured model — stop rather than corrupt the table.
anyhow::ensure!( anyhow::ensure!(
new_emb.len() == image_api::ai::embedding_dim(), new_emb.len() == image_api::ai::embedding_dim(),
"backend returned {}-dim embedding (expected {}) — '{}' does not \ "backend returned {}-dim embedding (expected {}) — '{}' does not \
match the configured EMBEDDING_DIM", match the configured embedding.dim",
new_emb.len(), new_emb.len(),
image_api::ai::embedding_dim(), image_api::ai::embedding_dim(),
llm.embedding_model_version() llm.embedding_model_version()
@@ -284,7 +284,7 @@ async fn main() -> Result<()> {
let mut conn = SqliteConnection::establish(&database_url) let mut conn = SqliteConnection::establish(&database_url)
.with_context(|| format!("connecting to {}", database_url))?; .with_context(|| format!("connecting to {}", database_url))?;
let llm = LocalLlm::from_env(); let llm = LocalLlm::from_config();
let model_version = llm.embedding_model_version(); let model_version = llm.embedding_model_version();
println!("Embedding via '{}'", model_version); println!("Embedding via '{}'", model_version);
if args.dry_run { if args.dry_run {
+9 -20
View File
@@ -66,6 +66,7 @@ async fn main() -> Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let args = Args::parse(); let args = Args::parse();
let ollama_cfg = &image_api::ai::config::llm_config().ollama;
// Parse dates // Parse dates
let start_date = NaiveDate::parse_from_str(&args.start, "%Y-%m-%d") let start_date = NaiveDate::parse_from_str(&args.start, "%Y-%m-%d")
@@ -82,12 +83,7 @@ async fn main() -> Result<()> {
if let Some(ref model) = args.model { if let Some(ref model) = args.model {
println!("Model: {}", model); println!("Model: {}", model);
} else { } else {
println!( println!("Model: {} (from llm config)", ollama_cfg.primary_model);
"Model: {} (from env)",
env::var("OLLAMA_PRIMARY_MODEL")
.or_else(|_| env::var("OLLAMA_MODEL"))
.unwrap_or_else(|_| "nemotron-3-nano:30b".to_string())
);
} }
if args.test_mode { if args.test_mode {
println!("⚠ TEST MODE: Results will NOT be saved to database"); println!("⚠ TEST MODE: Results will NOT be saved to database");
@@ -96,22 +92,15 @@ async fn main() -> Result<()> {
println!(); println!();
// Initialize AI clients // Initialize AI clients
let ollama_primary_url = env::var("OLLAMA_PRIMARY_URL") // Use provided model or fall back to the configured one
.or_else(|_| env::var("OLLAMA_URL")) let model_to_use = args
.unwrap_or_else(|_| "http://localhost:11434".to_string()); .model
.clone()
let ollama_fallback_url = env::var("OLLAMA_FALLBACK_URL").ok(); .unwrap_or_else(|| ollama_cfg.primary_model.clone());
// Use provided model or fallback to env
let model_to_use = args.model.clone().unwrap_or_else(|| {
env::var("OLLAMA_PRIMARY_MODEL")
.or_else(|_| env::var("OLLAMA_MODEL"))
.unwrap_or_else(|_| "nemotron-3-nano:30b".to_string())
});
let mut ollama = OllamaClient::new( let mut ollama = OllamaClient::new(
ollama_primary_url, ollama_cfg.primary_url.clone(),
ollama_fallback_url.clone(), ollama_cfg.fallback_url.clone(),
model_to_use.clone(), model_to_use.clone(),
Some(model_to_use), // Use same model for fallback Some(model_to_use), // Use same model for fallback
); );
+9
View File
@@ -67,10 +67,19 @@ fn main() -> std::io::Result<()> {
// `log::` records are bridged into it. // `log::` records are bridged into it.
let _logger_provider = otel::init_tracing(); let _logger_provider = otel::init_tracing();
// Load llm.toml (+ legacy env overrides) now, after logging is up, so
// a bad file fails startup and its deprecation warnings are visible.
ai::config::llm_config();
// AppState construction loads (and seeds if needed) the libraries // AppState construction loads (and seeds if needed) the libraries
// 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),
+2 -2
View File
@@ -157,7 +157,7 @@ pub enum ScripterMode {
/// Fast path: single LLM call via the direct client. /// Fast path: single LLM call via the direct client.
Fast, Fast,
/// Agentic path: resolves the backend through the InsightGenerator /// Agentic path: resolves the backend through the InsightGenerator
/// (honouring LLM_BACKEND, model overrides, etc.). Falls back to /// (honouring llm.toml's backend, model overrides, etc.). Falls back to
/// Fast on error so a scripting failure never sinks a reel. /// Fast on error so a scripting failure never sinks a reel.
Agentic, Agentic,
} }
@@ -450,7 +450,7 @@ pub async fn create_reel_handler(
) -> impl Responder { ) -> impl Responder {
if app_state.llamacpp.is_none() { if app_state.llamacpp.is_none() {
return HttpResponse::ServiceUnavailable().json(json!({ return HttpResponse::ServiceUnavailable().json(json!({
"error": "Reel narration needs the LLM/TTS backend (set LLAMA_SWAP_URL)" "error": "Reel narration needs the LLM/TTS backend (set llamacpp.url in llm.toml)"
})); }));
} }
+1 -1
View File
@@ -289,7 +289,7 @@ pub async fn generate_script(
} }
/// Agentic version of script generation: resolves the backend via the /// Agentic version of script generation: resolves the backend via the
/// InsightGenerator (honouring LLM_BACKEND, model overrides, etc.), builds /// InsightGenerator (honouring llm.toml's backend, model overrides, etc.), builds
/// a read-only tool set, runs the tool loop, then parses the JSON response. /// a read-only tool set, runs the tool loop, then parses the JSON response.
/// Returns the same ReelScript shape. On failure the caller may fall back to /// Returns the same ReelScript shape. On failure the caller may fall back to
/// `generate_script`. /// `generate_script`.
+39 -97
View File
@@ -1,5 +1,6 @@
use crate::ai::apollo_client::ApolloClient; use crate::ai::apollo_client::ApolloClient;
use crate::ai::clip_client::ClipClient; use crate::ai::clip_client::ClipClient;
use crate::ai::config::{LlmConfig, OllamaConfig, OpenRouterConfig, llm_config};
use crate::ai::face_client::FaceClient; use crate::ai::face_client::FaceClient;
use crate::ai::insight_chat::{ChatLockMap, InsightChatService}; use crate::ai::insight_chat::{ChatLockMap, InsightChatService};
use crate::ai::llamacpp::LlamaCppClient; use crate::ai::llamacpp::LlamaCppClient;
@@ -69,18 +70,14 @@ pub struct AppState {
#[allow(dead_code)] #[allow(dead_code)]
pub openrouter: Option<Arc<OpenRouterClient>>, pub openrouter: Option<Arc<OpenRouterClient>>,
/// Curated list of OpenRouter model ids exposed to clients. Sourced from /// Curated list of OpenRouter model ids exposed to clients. Sourced from
/// `OPENROUTER_ALLOWED_MODELS` (comma-separated). Empty when unset. /// `openrouter.allowed`. Empty when unset.
pub openrouter_allowed_models: Vec<String>, pub openrouter_allowed_models: Vec<String>,
/// `None` when `LLAMA_SWAP_URL` is not configured. Consulted only when a /// `None` when `llamacpp.url` is not configured. Consulted only when a
/// request explicitly opts into `backend=llamacpp`. Same shape as the /// request explicitly opts into `backend=llamacpp`. Same shape as the
/// `openrouter` slot — present here so handlers can route to it without /// `openrouter` slot — present here so handlers can route to it without
/// threading through the generator. /// threading through the generator.
#[allow(dead_code)] #[allow(dead_code)]
pub llamacpp: Option<Arc<LlamaCppClient>>, pub llamacpp: Option<Arc<LlamaCppClient>>,
/// Curated list of llama-swap model ids exposed to clients. Sourced from
/// `LLAMA_SWAP_ALLOWED_MODELS` (comma-separated). Empty when unset; the
/// server then falls back to `LLAMA_SWAP_PRIMARY_MODEL`.
pub llamacpp_allowed_models: Vec<String>,
pub sms_client: SmsApiClient, pub sms_client: SmsApiClient,
pub insight_generator: InsightGenerator, pub insight_generator: InsightGenerator,
/// Chat continuation service. Hold an Arc so handlers can clone cheaply. /// Chat continuation service. Hold an Arc so handlers can clone cheaply.
@@ -133,7 +130,6 @@ impl AppState {
openrouter: Option<Arc<OpenRouterClient>>, openrouter: Option<Arc<OpenRouterClient>>,
openrouter_allowed_models: Vec<String>, openrouter_allowed_models: Vec<String>,
llamacpp: Option<Arc<LlamaCppClient>>, llamacpp: Option<Arc<LlamaCppClient>>,
llamacpp_allowed_models: Vec<String>,
sms_client: SmsApiClient, sms_client: SmsApiClient,
insight_generator: InsightGenerator, insight_generator: InsightGenerator,
insight_chat: Arc<InsightChatService>, insight_chat: Arc<InsightChatService>,
@@ -195,7 +191,6 @@ impl AppState {
openrouter, openrouter,
openrouter_allowed_models, openrouter_allowed_models,
llamacpp, llamacpp,
llamacpp_allowed_models,
sms_client, sms_client,
insight_generator, insight_generator,
insight_chat, insight_chat,
@@ -224,13 +219,13 @@ impl AppState {
impl Default for AppState { impl Default for AppState {
fn default() -> Self { fn default() -> Self {
// Initialize AI clients // Initialize AI clients
let ollama = build_ollama_from_env(); let llm = llm_config();
let ollama = build_ollama(&llm.ollama);
let openrouter = build_openrouter_from_env(); let openrouter = build_openrouter(&llm.openrouter);
let openrouter_allowed_models = parse_openrouter_allowed_models(); let openrouter_allowed_models = llm.openrouter.allowed.clone();
let llamacpp = build_llamacpp_from_env(); let llamacpp = build_llamacpp(llm);
let llamacpp_allowed_models = parse_llamacpp_allowed_models();
let sms_api_url = let sms_api_url =
env::var("SMS_API_URL").unwrap_or_else(|_| "http://localhost:8000".to_string()); env::var("SMS_API_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
@@ -346,10 +341,7 @@ impl Default for AppState {
// Turn registry for reconnectable chat turns. 5-minute timeout for // Turn registry for reconnectable chat turns. 5-minute timeout for
// stale turns (background cleaner drops entries older than this). // stale turns (background cleaner drops entries older than this).
let timeout_secs: u64 = env::var("INSIGHT_CHAT_TURN_TIMEOUT_SECS") let timeout_secs = llm_config().agentic.chat_turn_timeout_secs;
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(300);
let turn_registry = Arc::new(TurnRegistry::new(timeout_secs)); let turn_registry = Arc::new(TurnRegistry::new(timeout_secs));
// Ensure preview clips directory exists // Ensure preview clips directory exists
@@ -370,7 +362,6 @@ impl Default for AppState {
openrouter, openrouter,
openrouter_allowed_models, openrouter_allowed_models,
llamacpp, llamacpp,
llamacpp_allowed_models,
sms_client, sms_client,
insight_generator, insight_generator,
insight_chat, insight_chat,
@@ -387,94 +378,46 @@ impl Default for AppState {
} }
} }
/// Build an `OpenRouterClient` from environment variables. Returns `None` /// Build an `OpenRouterClient` from `[openrouter]`. Returns `None` when
/// when `OPENROUTER_API_KEY` is unset (the hybrid backend is then /// `OPENROUTER_API_KEY` (env-only, it's a secret) is unset — the hybrid
/// unavailable and requests for it return a clear error). /// backend is then unavailable and requests for it return a clear error.
fn build_openrouter_from_env() -> Option<Arc<OpenRouterClient>> { fn build_openrouter(cfg: &OpenRouterConfig) -> Option<Arc<OpenRouterClient>> {
let api_key = env::var("OPENROUTER_API_KEY").ok()?; let api_key = env::var("OPENROUTER_API_KEY").ok()?;
let base_url = env::var("OPENROUTER_BASE_URL").ok(); let mut client =
let default_model = env::var("OPENROUTER_DEFAULT_MODEL") OpenRouterClient::new(api_key, cfg.base_url.clone(), cfg.default_model.clone());
.unwrap_or_else(|_| "anthropic/claude-sonnet-4".to_string()); client.set_attribution(cfg.http_referer.clone(), cfg.app_title.clone());
let mut client = OpenRouterClient::new(api_key, base_url, default_model); if let Some(model) = &cfg.embedding_model {
client.set_attribution( client.set_embedding_model(model.clone());
env::var("OPENROUTER_HTTP_REFERER").ok(),
env::var("OPENROUTER_APP_TITLE").ok(),
);
if let Ok(model) = env::var("OPENROUTER_EMBEDDING_MODEL") {
client.set_embedding_model(model);
}
// OPENROUTER_MAX_TOKENS=0 omits the cap (provider default).
if let Some(n) = env::var("OPENROUTER_MAX_TOKENS")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok())
{
client.set_max_tokens((n > 0).then_some(n));
} }
client.set_max_tokens(cfg.max_tokens_cap());
Some(Arc::new(client)) Some(Arc::new(client))
} }
/// Parse `OPENROUTER_ALLOWED_MODELS` (comma-separated) into a vec. Returns /// Build the `OllamaClient` from `[ollama]` — shared by the server
/// empty when unset, in which case `/insights/openrouter/models` reports no /// (`AppState::default`) and the standalone binaries.
/// curated picks and the server falls back to `OPENROUTER_DEFAULT_MODEL`. pub fn build_ollama(cfg: &OllamaConfig) -> OllamaClient {
fn parse_openrouter_allowed_models() -> Vec<String> { OllamaClient::new(
env::var("OPENROUTER_ALLOWED_MODELS") cfg.primary_url.clone(),
.unwrap_or_default() cfg.fallback_url.clone(),
.split(',') cfg.primary_model.clone(),
.map(|s| s.trim().to_string()) cfg.fallback_model.clone(),
.filter(|s| !s.is_empty()) )
.collect()
} }
/// Build the `OllamaClient` from environment variables — the canonical /// Build a `LlamaCppClient` from `[llamacpp]` (+ the embed and TTS model
/// `OLLAMA_*` wiring shared by the server (`AppState::default`) and the /// ids). Returns `None` when `llamacpp.url` is unset. The client is built
/// standalone binaries (which predate this helper and used to copy it). /// whenever the URL is set (TTS and ad-hoc tooling use it even under
pub fn build_ollama_from_env() -> OllamaClient { /// `backend = "ollama"`), but chat / embeddings only route through it when
let primary_url = env::var("OLLAMA_PRIMARY_URL").unwrap_or_else(|_| { /// `backend = "llamacpp"`.
env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()) pub fn build_llamacpp(cfg: &LlmConfig) -> Option<Arc<LlamaCppClient>> {
}); let base_url = cfg.llamacpp.url.clone()?;
let fallback_url = env::var("OLLAMA_FALLBACK_URL").ok(); let mut client = LlamaCppClient::new(Some(base_url), Some(cfg.llamacpp.chat.clone()));
let primary_model = env::var("OLLAMA_PRIMARY_MODEL") client.set_embedding_model(cfg.embedding.model.clone());
.or_else(|_| env::var("OLLAMA_MODEL")) client.set_vision_model(cfg.llamacpp.vision_model().to_string());
.unwrap_or_else(|_| "nemotron-3-nano:30b".to_string()); client.set_tts_model(cfg.tts.model.clone());
let fallback_model = env::var("OLLAMA_FALLBACK_MODEL").ok();
OllamaClient::new(primary_url, fallback_url, primary_model, fallback_model)
}
/// Build a `LlamaCppClient` from environment variables. Returns `None` when
/// `LLAMA_SWAP_URL` is unset. The client is constructed unconditionally
/// when the URL is set (so it's available even under `LLM_BACKEND=ollama`
/// for ad-hoc tooling), but the agentic / chat paths only route through it
/// when `LLM_BACKEND=llamacpp`. Slot ids default to the names the bundled
/// `llama-swap/config.yaml` uses — `chat` / `vision` / `embed`.
pub fn build_llamacpp_from_env() -> Option<Arc<LlamaCppClient>> {
let base_url = env::var("LLAMA_SWAP_URL").ok()?;
let primary_model = env::var("LLAMA_SWAP_PRIMARY_MODEL").ok();
let mut client = LlamaCppClient::new(Some(base_url), primary_model);
if let Ok(model) = env::var("LLAMA_SWAP_EMBEDDING_MODEL") {
client.set_embedding_model(model);
}
if let Ok(model) = env::var("LLAMA_SWAP_VISION_MODEL") {
client.set_vision_model(model);
}
if let Ok(model) = env::var("LLAMA_SWAP_TTS_MODEL") {
client.set_tts_model(model);
}
Some(Arc::new(client)) Some(Arc::new(client))
} }
/// Parse `LLAMA_SWAP_ALLOWED_MODELS` (comma-separated) into a vec. Used to
/// 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> {
env::var("LLAMA_SWAP_ALLOWED_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
@@ -597,7 +540,6 @@ impl AppState {
None, None,
Vec::new(), Vec::new(),
None, None,
Vec::new(),
sms_client, sms_client,
insight_generator, insight_generator,
insight_chat, insight_chat,
+5 -5
View File
@@ -635,19 +635,19 @@ async fn translate<TagD: TagDao>(
} }
}; };
// Respect env/config for the LLM backend (LLM_BACKEND → ollama or // Respect env/config for the LLM backend (llm.toml `backend` → ollama or
// llama-swap); local only, no hybrid, per the feature's design. // llama-swap); local only, no hybrid, per the feature's design.
// //
// Translation-model precedence: // Translation-model precedence:
// 1. UNIFIED_SEARCH_MODEL env — pin a small, fast model that can stay // 1. `unified_search_model` (llm.toml) — pin a small, fast model that can stay
// co-resident with CLIP (and the chat model) so translation never // co-resident with CLIP (and the chat model) so translation never
// evicts them. This is the recommended setup on a tight VRAM budget. // evicts them. This is the recommended setup on a tight VRAM budget.
// 2. the client-selected model — routes translation to whatever the user // 2. the client-selected model — routes translation to whatever the user
// already has loaded (no swap) when no dedicated model is pinned. // already has loaded (no swap) when no dedicated model is pinned.
// 3. None → resolve_backend uses the configured default local model. // 3. None → resolve_backend uses the configured default local model.
let translation_model = std::env::var("UNIFIED_SEARCH_MODEL") let translation_model = crate::ai::config::llm_config()
.ok() .unified_search_model
.filter(|m| !m.trim().is_empty()) .clone()
.or_else(|| query.model.clone()) .or_else(|| query.model.clone())
.filter(|m| !m.trim().is_empty()); .filter(|m| !m.trim().is_empty());
let overrides = SamplingOverrides { let overrides = SamplingOverrides {