diff --git a/Cargo.lock b/Cargo.lock index 5637e7c..48bd3fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 01d56b7..f8dda39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/llm.example.toml b/llm.example.toml new file mode 100644 index 0000000..f71df01 --- /dev/null +++ b/llm.example.toml @@ -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: \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 diff --git a/src/ai/config.rs b/src/ai/config.rs new file mode 100644 index 0000000..baecbc2 --- /dev/null +++ b/src/ai/config.rs @@ -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, + 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, + /// Default chat model id. + pub chat: String, + /// Model for `describe_image` / the `describe_photo` tool. `None` = `chat`. + pub vision: Option, + 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>, + pub exclude: Vec, +} + +#[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, + /// `None` = reuse `primary_model` on the fallback server. + pub fallback_model: Option, + 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, + pub default_model: String, + /// Curated picker list for `/insights/openrouter/models`. + pub allowed: Vec, + pub embedding_model: Option, + pub http_referer: Option, + pub app_title: Option, + /// 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 { + (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: \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, + /// 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 = 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 { + 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> { + 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) -> Result { + 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 Option>(c: &mut LlmConfig, env: &mut EnvLayer) { + 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 { + get: F, + overrides: Vec<&'static str>, + invalid: Vec<&'static str>, +} + +impl Option> EnvLayer { + /// Untrimmed value; set-but-empty counts as set (an empty prefix is a + /// meaningful override). + fn raw(&mut self, key: &'static str) -> Option { + 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 { + 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, key: &'static str) { + if let Some(v) = self.string(key) { + *slot = Some(v); + } + } + + fn set_parsed(&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> { + 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 { + let map: HashMap = 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 { + 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()); + } +} diff --git a/src/ai/mod.rs b/src/ai/mod.rs index f491517..5d4cf5d 100644 --- a/src/ai/mod.rs +++ b/src/ai/mod.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index 3511b9c..b7bed1f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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());