feat(ai): LlmConfig loaded from llm.toml with legacy env overrides

Adds ai::config: one serde struct for every LLM setting (backend, llama.cpp,
Ollama, OpenRouter, embedding, TTS, agentic caps), resolved as defaults ->
llm.toml (LLM_CONFIG_PATH, default ./llm.toml) -> the old env vars. Env
overrides are kept for one release and listed in a single startup
deprecation warning; LLAMA_SWAP_ALLOWED_MODELS maps to picker.include so
current deploys keep their picker. Unknown keys, zero timeouts and
backend=llamacpp without a URL fail startup. OPENROUTER_API_KEY stays
env-only.

llm.example.toml documents the schema and is parsed by a test. main loads
the config right after tracing init; readers switch over next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-26 17:26:32 -04:00
1 parent 3e3276b8a3
commit 5b8a99a2b2
6 files changed
+719

No files matched your search

Generated
+9
View File
@@ -2122,6 +2122,7 @@ dependencies = [
"tempfile",
"tokio",
"tokio-util",
"toml",
"tracing",
"tracing-actix-web",
"tracing-log",
@@ -4236,10 +4237,12 @@ version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
]
@@ -4261,6 +4264,12 @@ dependencies = [
"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]]
name = "tonic"
version = "0.14.5"
+1
View File
@@ -25,6 +25,7 @@ futures = "0.3.5"
jsonwebtoken = "9.3.0"
serde = "1"
serde_json = "1"
toml = "0.9"
diesel = { version = "2.2.10", features = ["sqlite", "64-column-tables"] }
libsqlite3-sys = "0.35"
diesel_migrations = "2.2.0"
+73
View File
@@ -0,0 +1,73 @@
# ImageApi LLM configuration. Copy to `llm.toml` next to `.env` (or point
# LLM_CONFIG_PATH at it). Every key is optional; omitted keys use the
# defaults shown in the comments.
#
# 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"
# Small model pinned for /photos/search/unified query translation, so it can
# stay loaded alongside CLIP. Unset = the client's selected model.
unified_search_model = "gemma4-e4b"
[llamacpp]
url = "http://localhost:9292/v1" # unset = llama.cpp client + TTS disabled
chat = "qwen3.8-27b-abliterated" # default chat model (default "chat")
vision = "qwen3.8-27b-code-vision" # describe_photo model (default: chat)
timeout_secs = 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"]
# include = ["qwen3.8-27b-code", "gemma4-31"]
[embedding]
# Must all agree with what the stored corpus was embedded with. Changing any
# of them means `cargo run --bin reembed_embeddings`.
model = "embed" # llama-swap id (Ollama always uses nomic-embed-text:v1.5)
dim = 768 # 768 nomic v1.5 | 1024 Qwen3-Embedding-0.6B
query_prefix = "search_query: " # Qwen3: "Instruct: <task>\nQuery: "
document_prefix = "search_document: " # Qwen3: ""
[tts]
# Chatterbox behind llama-swap; needs llamacpp.url but not backend = "llamacpp".
model = "chatterbox"
voice = "m" # default voice when a request omits one (unset = server default)
ref_seconds = 30 # max voice-clone reference clip length
timeout_secs = 600 # long chunked text takes minutes
[ollama]
primary_url = "http://localhost:11434"
primary_model = "nemotron-3-nano:30b"
# Tried on connection failure. fallback_model unset = reuse primary_model.
# fallback_url = "http://server:11434"
# fallback_model = "llama3.2:3b"
timeout_secs = 120
[openrouter]
default_model = "anthropic/claude-sonnet-4"
allowed = ["openai/gpt-4o-mini", "anthropic/claude-haiku-4-5", "google/gemini-2.5-flash"]
max_tokens = 16384 # 0 = uncapped (OpenRouter reserves credit against it)
# base_url = "https://openrouter.ai/api/v1"
# embedding_model = "openai/text-embedding-3-small"
# http_referer = "https://your-site.example"
# app_title = "ImageApi"
[agentic]
max_iterations = 12 # tool-loop cap for agentic insight generation
chat_max_iterations = 6 # per chat turn; per-request values are clamped to it
chat_default_num_ctx = 32768 # assumed window when neither request nor backend reports one
chat_turn_timeout_secs = 300 # reconnectable chat turns are dropped after this
+631
View File
@@ -0,0 +1,631 @@
//! 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: 32768,
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!(c.embedding.query_prefix.ends_with(' '));
}
#[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 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());
}
}
+1
View File
@@ -1,6 +1,7 @@
pub mod apollo_client;
pub mod backend;
pub mod clip_client;
pub mod config;
pub mod daily_summary_job;
pub mod date_info;
pub mod face_client;
+4
View File
@@ -67,6 +67,10 @@ fn main() -> std::io::Result<()> {
// `log::` records are bridged into it.
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
// table; we use that list to drive the initial thumbnail sweep.
let app_data = Data::new(AppState::default());