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:
1 parent
3e3276b8a3
commit
5b8a99a2b2
6 files changed
+719
No files matched your search
@@ -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,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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user