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
Generated
+9
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 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,6 +67,10 @@ 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());
|
||||||
|
|||||||
Reference in new issue
Block a user