README, CLAUDE.md and .env.example now point at llm.toml for LLM settings (OPENROUTER_API_KEY stays in env) and describe the legacy env override window, the discovered picker and the startup cross-check. ADR-0003 records the move and amends ADR-0002's deployment-config home; CONTEXT.md's Deployment config / Hard default entries follow. Source comments and the "set LLAMA_SWAP_URL" error strings name the llm.toml keys. Also fixes README's AGENTIC_MAX_ITERATIONS default (10 -> 12, what the code uses). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3.2 KiB
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 localbackend, 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.yamlowns model capabilities. Each model carriesmetadata: { kind, vision, ctx, dim }, which/v1/modelspasses through. ImageApi discovers the picker from it (everykind: chatmodel, param aliases hidden, minuspicker.exclude, optionally restricted topicker.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
dimthat differs fromembedding.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.yamldirectly: couples ImageApi to a file on another host and to llama-swap's schema./v1/modelsis 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
metadataintometa.llamaswap, while upstream documents it atmeta.DiscoveredModel::from_jsonreads both. - A model with no metadata is treated as a text-only chat model, and the startup check lists it.
embedding.query_prefix/document_prefixdefault to empty because the existing corpus was embedded without them. Set them only together withreembed_embeddings.