refactor(ai): read LLM settings from LlmConfig instead of env
Every LLM env reader now goes through ai::config::llm_config(): build_ollama / build_llamacpp / build_openrouter take config sections (OPENROUTER_API_KEY is still read from env), and the backend switch, embedding dim + prefixes, Ollama / llama.cpp / TTS timeouts, TTS voice and ref length, agentic caps, chat turn timeout and the unified-search model read their sections. LocalLlm::from_env -> from_config; the test_daily_summary and populate_knowledge bins take Ollama defaults from the config. env_max_iterations / env_default_num_ctx are renamed to max_chat_iterations / default_num_ctx. The picker still uses picker.include (= LLAMA_SWAP_ALLOWED_MODELS) until discovery lands. Legacy env vars keep working through the config's override layer; checked against the deployed .env, which resolves to the same values. The LLM_BACKEND parsing tests move to ai::config. llm.example.toml now leaves the embed prefixes empty: copying the nomic prefixes onto a corpus embedded without them breaks search. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5b8a99a2b2
commit
aa3536afee
17 files changed
+144
-292
No files matched your search
+4
-2
@@ -39,8 +39,10 @@ exclude = ["nemotron3.5-lightning-30b-A3B"]
|
|||||||
# of them means `cargo run --bin reembed_embeddings`.
|
# of them means `cargo run --bin reembed_embeddings`.
|
||||||
model = "embed" # llama-swap id (Ollama always uses nomic-embed-text:v1.5)
|
model = "embed" # llama-swap id (Ollama always uses nomic-embed-text:v1.5)
|
||||||
dim = 768 # 768 nomic v1.5 | 1024 Qwen3-Embedding-0.6B
|
dim = 768 # 768 nomic v1.5 | 1024 Qwen3-Embedding-0.6B
|
||||||
query_prefix = "search_query: " # Qwen3: "Instruct: <task>\nQuery: "
|
# Prefixes default to empty. Set them only together with a re-embed; a
|
||||||
document_prefix = "search_document: " # Qwen3: ""
|
# corpus embedded without them won't match queries embedded with them.
|
||||||
|
query_prefix = "" # nomic v1.5: "search_query: " | Qwen3: "Instruct: <task>\nQuery: "
|
||||||
|
document_prefix = "" # nomic v1.5: "search_document: " | Qwen3: ""
|
||||||
|
|
||||||
[tts]
|
[tts]
|
||||||
# Chatterbox behind llama-swap; needs llamacpp.url but not backend = "llamacpp".
|
# Chatterbox behind llama-swap; needs llamacpp.url but not backend = "llamacpp".
|
||||||
|
|||||||
+18
-2
@@ -221,7 +221,7 @@ impl Default for AgenticConfig {
|
|||||||
Self {
|
Self {
|
||||||
max_iterations: 12,
|
max_iterations: 12,
|
||||||
chat_max_iterations: crate::ai::insight_chat::DEFAULT_MAX_ITERATIONS,
|
chat_max_iterations: crate::ai::insight_chat::DEFAULT_MAX_ITERATIONS,
|
||||||
chat_default_num_ctx: 32768,
|
chat_default_num_ctx: crate::ai::insight_chat::DEFAULT_NUM_CTX,
|
||||||
chat_turn_timeout_secs: 300,
|
chat_turn_timeout_secs: 300,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -500,7 +500,7 @@ mod tests {
|
|||||||
let c = r.config;
|
let c = r.config;
|
||||||
assert_eq!(c.backend, LocalBackend::Llamacpp);
|
assert_eq!(c.backend, LocalBackend::Llamacpp);
|
||||||
assert!(c.llamacpp.url.is_some());
|
assert!(c.llamacpp.url.is_some());
|
||||||
assert!(c.embedding.query_prefix.ends_with(' '));
|
assert_eq!(c.embedding.dim, 768);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -539,6 +539,22 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn llm_backend_env_is_case_insensitive_and_unknown_means_ollama() {
|
||||||
|
let backend = |v: &str| {
|
||||||
|
resolve(
|
||||||
|
None,
|
||||||
|
env_of(&[("LLM_BACKEND", v), ("LLAMA_SWAP_URL", "http://x/v1")]),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.config
|
||||||
|
.backend
|
||||||
|
};
|
||||||
|
assert_eq!(backend("LlamaCpp"), LocalBackend::Llamacpp);
|
||||||
|
assert_eq!(backend(" llamacpp "), LocalBackend::Llamacpp);
|
||||||
|
assert_eq!(backend("vllm"), LocalBackend::Ollama);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blank_env_values_count_as_unset() {
|
fn blank_env_values_count_as_unset() {
|
||||||
let r = resolve(
|
let r = resolve(
|
||||||
|
|||||||
+1
-4
@@ -867,10 +867,7 @@ pub async fn generate_agentic_insight_handler(
|
|||||||
.persona_system_prompt(user_id, &persona_id)
|
.persona_system_prompt(user_id, &persona_id)
|
||||||
});
|
});
|
||||||
|
|
||||||
let max_iterations: usize = std::env::var("AGENTIC_MAX_ITERATIONS")
|
let max_iterations = crate::ai::config::llm_config().agentic.max_iterations;
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(12);
|
|
||||||
|
|
||||||
// Spawn background task with timeout
|
// Spawn background task with timeout
|
||||||
let generator = app_state.insight_generator.clone();
|
let generator = app_state.insight_generator.clone();
|
||||||
|
|||||||
+19
-25
@@ -26,8 +26,8 @@ pub const DEFAULT_MAX_ITERATIONS: usize = 6;
|
|||||||
/// `effective_num_ctx`). Real conversations rarely pass ~16k tokens, so 32k
|
/// `effective_num_ctx`). Real conversations rarely pass ~16k tokens, so 32k
|
||||||
/// keeps the truncation pass from gutting history the server could hold
|
/// keeps the truncation pass from gutting history the server could hold
|
||||||
/// (which also destroys the server's KV-cache prefix reuse). Override
|
/// (which also destroys the server's KV-cache prefix reuse). Override
|
||||||
/// per-deploy with AGENTIC_CHAT_DEFAULT_NUM_CTX.
|
/// per-deploy with `agentic.chat_default_num_ctx` in llm.toml.
|
||||||
const DEFAULT_NUM_CTX: i32 = 32768;
|
pub(crate) const DEFAULT_NUM_CTX: i32 = 32768;
|
||||||
/// Headroom reserved for the model's response, deducted from the context
|
/// Headroom reserved for the model's response, deducted from the context
|
||||||
/// budget when deciding whether to truncate the replayed history.
|
/// budget when deciding whether to truncate the replayed history.
|
||||||
pub(crate) const RESPONSE_HEADROOM_TOKENS: usize = 2048;
|
pub(crate) const RESPONSE_HEADROOM_TOKENS: usize = 2048;
|
||||||
@@ -341,7 +341,7 @@ impl InsightChatService {
|
|||||||
let max_iterations = req
|
let max_iterations = req
|
||||||
.max_iterations
|
.max_iterations
|
||||||
.unwrap_or(DEFAULT_MAX_ITERATIONS)
|
.unwrap_or(DEFAULT_MAX_ITERATIONS)
|
||||||
.clamp(1, env_max_iterations());
|
.clamp(1, max_chat_iterations());
|
||||||
span.set_attribute("max_iterations", max_iterations as i64);
|
span.set_attribute("max_iterations", max_iterations as i64);
|
||||||
|
|
||||||
let stored_model = insight.model_version.clone();
|
let stored_model = insight.model_version.clone();
|
||||||
@@ -1043,7 +1043,7 @@ impl InsightChatService {
|
|||||||
let max_iterations = req
|
let max_iterations = req
|
||||||
.max_iterations
|
.max_iterations
|
||||||
.unwrap_or(DEFAULT_MAX_ITERATIONS)
|
.unwrap_or(DEFAULT_MAX_ITERATIONS)
|
||||||
.clamp(1, env_max_iterations());
|
.clamp(1, max_chat_iterations());
|
||||||
|
|
||||||
let stored_model = insight.model_version.clone();
|
let stored_model = insight.model_version.clone();
|
||||||
let overrides = SamplingOverrides {
|
let overrides = SamplingOverrides {
|
||||||
@@ -1246,7 +1246,7 @@ impl InsightChatService {
|
|||||||
let max_iterations = req
|
let max_iterations = req
|
||||||
.max_iterations
|
.max_iterations
|
||||||
.unwrap_or(DEFAULT_MAX_ITERATIONS)
|
.unwrap_or(DEFAULT_MAX_ITERATIONS)
|
||||||
.clamp(1, env_max_iterations());
|
.clamp(1, max_chat_iterations());
|
||||||
|
|
||||||
let overrides = SamplingOverrides {
|
let overrides = SamplingOverrides {
|
||||||
model: req.model.clone().filter(|m| !m.is_empty()),
|
model: req.model.clone().filter(|m| !m.is_empty()),
|
||||||
@@ -1526,7 +1526,7 @@ impl InsightChatService {
|
|||||||
let max_iterations = req
|
let max_iterations = req
|
||||||
.max_iterations
|
.max_iterations
|
||||||
.unwrap_or(DEFAULT_MAX_ITERATIONS)
|
.unwrap_or(DEFAULT_MAX_ITERATIONS)
|
||||||
.clamp(1, env_max_iterations());
|
.clamp(1, max_chat_iterations());
|
||||||
|
|
||||||
let stored_model = insight.model_version.clone();
|
let stored_model = insight.model_version.clone();
|
||||||
let overrides = SamplingOverrides {
|
let overrides = SamplingOverrides {
|
||||||
@@ -1721,7 +1721,7 @@ impl InsightChatService {
|
|||||||
let max_iterations = req
|
let max_iterations = req
|
||||||
.max_iterations
|
.max_iterations
|
||||||
.unwrap_or(DEFAULT_MAX_ITERATIONS)
|
.unwrap_or(DEFAULT_MAX_ITERATIONS)
|
||||||
.clamp(1, env_max_iterations());
|
.clamp(1, max_chat_iterations());
|
||||||
|
|
||||||
let overrides = SamplingOverrides {
|
let overrides = SamplingOverrides {
|
||||||
model: req.model.clone().filter(|m| !m.is_empty()),
|
model: req.model.clone().filter(|m| !m.is_empty()),
|
||||||
@@ -2415,25 +2415,19 @@ pub(crate) fn find_raw_cut(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read AGENTIC_CHAT_MAX_ITERATIONS once per call. Cheap; keeps the code
|
/// Cap on tool-calling iterations per chat turn (`agentic.chat_max_iterations`,
|
||||||
/// free of static globals and lets the operator change the cap by env without
|
/// validated >= 1). Per-request values are clamped to it.
|
||||||
/// a restart in test harnesses (the running server still caches via Default).
|
pub fn max_chat_iterations() -> usize {
|
||||||
pub fn env_max_iterations() -> usize {
|
crate::ai::config::llm_config().agentic.chat_max_iterations
|
||||||
std::env::var("AGENTIC_CHAT_MAX_ITERATIONS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.parse::<usize>().ok())
|
|
||||||
.unwrap_or(DEFAULT_MAX_ITERATIONS)
|
|
||||||
.max(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read AGENTIC_CHAT_DEFAULT_NUM_CTX once per call — the assumed context
|
/// Assumed context window for the truncation budget when the request omits
|
||||||
/// window for the truncation budget when the request omits `num_ctx`. Same
|
/// `num_ctx` and the backend can't report one (`agentic.chat_default_num_ctx`).
|
||||||
/// no-static-global rationale as `env_max_iterations` above.
|
/// Floored so the budget always leaves room for the response headroom.
|
||||||
pub(crate) fn env_default_num_ctx() -> i32 {
|
pub(crate) fn default_num_ctx() -> i32 {
|
||||||
std::env::var("AGENTIC_CHAT_DEFAULT_NUM_CTX")
|
crate::ai::config::llm_config()
|
||||||
.ok()
|
.agentic
|
||||||
.and_then(|s| s.parse::<i32>().ok())
|
.chat_default_num_ctx
|
||||||
.unwrap_or(DEFAULT_NUM_CTX)
|
|
||||||
.max(RESPONSE_HEADROOM_TOKENS as i32 + 1024)
|
.max(RESPONSE_HEADROOM_TOKENS as i32 + 1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2451,7 +2445,7 @@ pub(crate) async fn effective_num_ctx(
|
|||||||
(Some(r), Some(served)) => r.min(served),
|
(Some(r), Some(served)) => r.min(served),
|
||||||
(None, Some(served)) => served,
|
(None, Some(served)) => served,
|
||||||
(Some(r), None) => r,
|
(Some(r), None) => r,
|
||||||
(None, None) => env_default_num_ctx() as usize,
|
(None, None) => default_num_ctx() as usize,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-10
@@ -41,7 +41,6 @@ const DEFAULT_PRIMARY_MODEL: &str = "chat";
|
|||||||
const DEFAULT_VISION_MODEL: &str = "vision";
|
const DEFAULT_VISION_MODEL: &str = "vision";
|
||||||
const DEFAULT_EMBEDDING_MODEL: &str = "embed";
|
const DEFAULT_EMBEDDING_MODEL: &str = "embed";
|
||||||
const DEFAULT_TTS_MODEL: &str = "chatterbox";
|
const DEFAULT_TTS_MODEL: &str = "chatterbox";
|
||||||
const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 180;
|
|
||||||
/// How long a served-context-window lookup stays cached per model.
|
/// How long a served-context-window lookup stays cached per model.
|
||||||
const CONTEXT_WINDOW_CACHE_TTL: Duration = Duration::from_secs(600);
|
const CONTEXT_WINDOW_CACHE_TTL: Duration = Duration::from_secs(600);
|
||||||
|
|
||||||
@@ -90,10 +89,7 @@ pub struct LlamaCppClient {
|
|||||||
|
|
||||||
impl LlamaCppClient {
|
impl LlamaCppClient {
|
||||||
pub fn new(base_url: Option<String>, primary_model: Option<String>) -> Self {
|
pub fn new(base_url: Option<String>, primary_model: Option<String>) -> Self {
|
||||||
let timeout_secs = std::env::var("LLAMA_SWAP_REQUEST_TIMEOUT_SECONDS")
|
let timeout_secs = crate::ai::config::llm_config().llamacpp.timeout_secs;
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse::<u64>().ok())
|
|
||||||
.unwrap_or(DEFAULT_REQUEST_TIMEOUT_SECS);
|
|
||||||
let pm = primary_model.unwrap_or_else(|| DEFAULT_PRIMARY_MODEL.to_string());
|
let pm = primary_model.unwrap_or_else(|| DEFAULT_PRIMARY_MODEL.to_string());
|
||||||
Self {
|
Self {
|
||||||
client: Client::builder()
|
client: Client::builder()
|
||||||
@@ -287,11 +283,7 @@ impl LlamaCppClient {
|
|||||||
// TTS gets its own (longer) timeout: synthesizing a long, internally
|
// TTS gets its own (longer) timeout: synthesizing a long, internally
|
||||||
// chunked insight can take minutes, well past the shared chat/embedding
|
// chunked insight can take minutes, well past the shared chat/embedding
|
||||||
// client timeout. Per-request `.timeout()` overrides the client default.
|
// client timeout. Per-request `.timeout()` overrides the client default.
|
||||||
let tts_timeout = std::env::var("LLAMA_SWAP_TTS_REQUEST_TIMEOUT_SECONDS")
|
let tts_timeout = crate::ai::config::llm_config().tts.timeout_secs;
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse::<u64>().ok())
|
|
||||||
.filter(|n| *n > 0)
|
|
||||||
.unwrap_or(600);
|
|
||||||
|
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
|
|||||||
+10
-9
@@ -32,27 +32,28 @@ impl LocalLlm {
|
|||||||
Self { ollama, llamacpp }
|
Self { ollama, llamacpp }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Construct from the canonical env wiring shared with `AppState`.
|
/// Construct from `llm.toml`, the same wiring `AppState` uses.
|
||||||
pub fn from_env() -> Self {
|
pub fn from_config() -> Self {
|
||||||
|
let cfg = crate::ai::config::llm_config();
|
||||||
Self::new(
|
Self::new(
|
||||||
crate::state::build_ollama_from_env(),
|
crate::state::build_ollama(&cfg.ollama),
|
||||||
crate::state::build_llamacpp_from_env(),
|
crate::state::build_llamacpp(cfg),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Embed a search query (applies `EMBED_QUERY_PREFIX`). Callers must
|
/// Embed a search query (applies `embedding.query_prefix`). Callers must
|
||||||
/// pick query vs document — retrieval models treat the two sides
|
/// pick query vs document — retrieval models treat the two sides
|
||||||
/// differently and an unmarked embed invites prefix-mismatch bugs.
|
/// differently and an unmarked embed invites prefix-mismatch bugs.
|
||||||
pub async fn embed_query(&self, text: &str) -> Result<Vec<f32>> {
|
pub async fn embed_query(&self, text: &str) -> Result<Vec<f32>> {
|
||||||
super::embed_query(&self.ollama, self.llamacpp.as_deref(), text).await
|
super::embed_query(&self.ollama, self.llamacpp.as_deref(), text).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Embed corpus text (applies `EMBED_DOCUMENT_PREFIX`).
|
/// Embed corpus text (applies `embedding.document_prefix`).
|
||||||
pub async fn embed_document(&self, text: &str) -> Result<Vec<f32>> {
|
pub async fn embed_document(&self, text: &str) -> Result<Vec<f32>> {
|
||||||
super::embed_document(&self.ollama, self.llamacpp.as_deref(), text).await
|
super::embed_document(&self.ollama, self.llamacpp.as_deref(), text).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Single-shot local text generation via the `LLM_BACKEND`-selected
|
/// Single-shot local text generation via the `backend`-selected
|
||||||
/// client (offline tooling; chat turns belong to `ResolvedBackend`).
|
/// client (offline tooling; chat turns belong to `ResolvedBackend`).
|
||||||
pub async fn generate(&self, prompt: &str, system: Option<&str>) -> Result<String> {
|
pub async fn generate(&self, prompt: &str, system: Option<&str>) -> Result<String> {
|
||||||
if super::local_backend_is_llamacpp() {
|
if super::local_backend_is_llamacpp() {
|
||||||
@@ -60,8 +61,8 @@ impl LocalLlm {
|
|||||||
return <LlamaCppClient as LlmClient>::generate(lc, prompt, system, None).await;
|
return <LlamaCppClient as LlmClient>::generate(lc, prompt, system, None).await;
|
||||||
}
|
}
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"LLM_BACKEND=llamacpp but LlamaCppClient is unconfigured — \
|
"backend = \"llamacpp\" but LlamaCppClient is unconfigured — \
|
||||||
set LLAMA_SWAP_URL or switch to LLM_BACKEND=ollama"
|
set llamacpp.url in llm.toml or switch to backend = \"ollama\""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
self.ollama.generate(prompt, system).await
|
self.ollama.generate(prompt, system).await
|
||||||
|
|||||||
+20
-92
@@ -68,51 +68,26 @@ pub fn user_display_name() -> String {
|
|||||||
std::env::var("USER_NAME").unwrap_or_else(|_| "Me".to_string())
|
std::env::var("USER_NAME").unwrap_or_else(|_| "Me".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One switch for the "local" LLM stack: when `LLM_BACKEND=llamacpp` is
|
/// One switch for the "local" LLM stack: with `backend = "llamacpp"` in
|
||||||
/// set, chat / vision describe / embeddings all route through llama-swap
|
/// `llm.toml`, chat / vision describe / embeddings all route through
|
||||||
/// instead of Ollama. Any other value (including unset, the default) is
|
/// llama-swap instead of Ollama. Intentionally global — embeddings must be
|
||||||
/// Ollama. This is intentionally global — embeddings must be drawn from
|
/// drawn from a single source or similarity search across the index breaks
|
||||||
/// a single source or similarity search across the index breaks (mixed
|
/// (mixed vector spaces, possibly mixed dims). The `backend=hybrid`
|
||||||
/// vector spaces, possibly mixed dims). The `backend=hybrid` per-request
|
/// per-request override remains orthogonal: it always sends chat to
|
||||||
/// override remains orthogonal: it always sends chat to OpenRouter, and
|
/// OpenRouter, and uses this switch for the describe-then-inline vision pass.
|
||||||
/// uses `LLM_BACKEND` for the describe-then-inline vision pass.
|
|
||||||
pub fn local_backend_is_llamacpp() -> bool {
|
pub fn local_backend_is_llamacpp() -> bool {
|
||||||
matches!(
|
config::llm_config().backend == config::LocalBackend::Llamacpp
|
||||||
std::env::var("LLM_BACKEND")
|
|
||||||
.ok()
|
|
||||||
.as_deref()
|
|
||||||
.map(|s| s.trim().to_lowercase())
|
|
||||||
.as_deref(),
|
|
||||||
Some("llamacpp")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expected embedding dimensionality, env-overridable via `EMBEDDING_DIM`
|
/// Expected embedding dimensionality (`embedding.dim`, default 768 for
|
||||||
/// (default 768, nomic-embed-text). Every store/query dim check reads this —
|
/// nomic-embed-text). Every store/query dim check reads this — swapping to a
|
||||||
/// swapping to a different-dim model (e.g. Qwen3-Embedding-0.6B at 1024) is
|
/// different-dim model (e.g. Qwen3-Embedding-0.6B at 1024) is a config flip
|
||||||
/// then a config flip plus a `reembed_embeddings` run, not a code change.
|
/// plus a `reembed_embeddings` run, not a code change.
|
||||||
/// Cached for the process lifetime; a flip requires a restart anyway since
|
|
||||||
/// the corpus must be re-embedded with it.
|
|
||||||
pub fn embedding_dim() -> usize {
|
pub fn embedding_dim() -> usize {
|
||||||
static DIM: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
config::llm_config().embedding.dim
|
||||||
*DIM.get_or_init(|| {
|
|
||||||
std::env::var("EMBEDDING_DIM")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(768)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read an embedding prefix from the environment. `.env` values can't hold
|
/// Embed a search query. Applies `embedding.query_prefix` (default empty) —
|
||||||
/// real newlines, so a literal `\n` in the value is expanded — Qwen3-style
|
|
||||||
/// query instructions need one ("Instruct: ...\nQuery: ").
|
|
||||||
fn embed_prefix(key: &str) -> String {
|
|
||||||
std::env::var(key)
|
|
||||||
.map(|v| v.replace("\\n", "\n"))
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Embed a search query. Applies `EMBED_QUERY_PREFIX` (default empty) —
|
|
||||||
/// retrieval models distinguish query-side from document-side text:
|
/// retrieval models distinguish query-side from document-side text:
|
||||||
/// nomic v1.5 wants `search_query: `, Qwen3-Embedding wants
|
/// nomic v1.5 wants `search_query: `, Qwen3-Embedding wants
|
||||||
/// `Instruct: <task>\nQuery: `. Must pair with the document prefix the
|
/// `Instruct: <task>\nQuery: `. Must pair with the document prefix the
|
||||||
@@ -122,24 +97,24 @@ pub async fn embed_query(
|
|||||||
llamacpp: Option<&LlamaCppClient>,
|
llamacpp: Option<&LlamaCppClient>,
|
||||||
text: &str,
|
text: &str,
|
||||||
) -> anyhow::Result<Vec<f32>> {
|
) -> anyhow::Result<Vec<f32>> {
|
||||||
let prefixed = format!("{}{}", embed_prefix("EMBED_QUERY_PREFIX"), text);
|
let prefixed = format!("{}{}", config::llm_config().embedding.query_prefix, text);
|
||||||
embed_one(ollama, llamacpp, &prefixed).await
|
embed_one(ollama, llamacpp, &prefixed).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Embed corpus text (the stored side of retrieval). Applies
|
/// Embed corpus text (the stored side of retrieval). Applies
|
||||||
/// `EMBED_DOCUMENT_PREFIX` (default empty; nomic v1.5 wants
|
/// `embedding.document_prefix` (default empty; nomic v1.5 wants
|
||||||
/// `search_document: `, Qwen3-Embedding wants none).
|
/// `search_document: `, Qwen3-Embedding wants none).
|
||||||
pub async fn embed_document(
|
pub async fn embed_document(
|
||||||
ollama: &OllamaClient,
|
ollama: &OllamaClient,
|
||||||
llamacpp: Option<&LlamaCppClient>,
|
llamacpp: Option<&LlamaCppClient>,
|
||||||
text: &str,
|
text: &str,
|
||||||
) -> anyhow::Result<Vec<f32>> {
|
) -> anyhow::Result<Vec<f32>> {
|
||||||
let prefixed = format!("{}{}", embed_prefix("EMBED_DOCUMENT_PREFIX"), text);
|
let prefixed = format!("{}{}", config::llm_config().embedding.document_prefix, text);
|
||||||
embed_one(ollama, llamacpp, &prefixed).await
|
embed_one(ollama, llamacpp, &prefixed).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Embed a batch of strings via the configured local backend. Routes
|
/// Embed a batch of strings via the configured local backend. Routes
|
||||||
/// through llama-swap when `LLM_BACKEND=llamacpp` (and a client is
|
/// through llama-swap when `backend = "llamacpp"` (and a client is
|
||||||
/// configured), else Ollama. See [`local_backend_is_llamacpp`] for the
|
/// configured), else Ollama. See [`local_backend_is_llamacpp`] for the
|
||||||
/// rationale on consistency.
|
/// rationale on consistency.
|
||||||
pub async fn embed_many(
|
pub async fn embed_many(
|
||||||
@@ -152,8 +127,8 @@ pub async fn embed_many(
|
|||||||
return <LlamaCppClient as LlmClient>::generate_embeddings(lc, texts).await;
|
return <LlamaCppClient as LlmClient>::generate_embeddings(lc, texts).await;
|
||||||
}
|
}
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"LLM_BACKEND=llamacpp but LlamaCppClient is unconfigured — \
|
"backend = \"llamacpp\" but LlamaCppClient is unconfigured — \
|
||||||
set LLAMA_SWAP_URL or switch to LLM_BACKEND=ollama"
|
set llamacpp.url in llm.toml or switch to backend = \"ollama\""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
ollama.generate_embeddings(texts).await
|
ollama.generate_embeddings(texts).await
|
||||||
@@ -170,50 +145,3 @@ pub async fn embed_one(
|
|||||||
vecs.pop()
|
vecs.pop()
|
||||||
.ok_or_else(|| anyhow::anyhow!("embedding backend returned no embeddings"))
|
.ok_or_else(|| anyhow::anyhow!("embedding backend returned no embeddings"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod env_dispatch_tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Env vars are process-global, and the test harness runs in parallel —
|
|
||||||
/// without this lock the `LLM_BACKEND` tests race each other and flake.
|
|
||||||
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
|
||||||
|
|
||||||
fn with_env<F: FnOnce()>(key: &str, val: Option<&str>, f: F) {
|
|
||||||
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
||||||
let prev = std::env::var(key).ok();
|
|
||||||
match val {
|
|
||||||
Some(v) => unsafe { std::env::set_var(key, v) },
|
|
||||||
None => unsafe { std::env::remove_var(key) },
|
|
||||||
}
|
|
||||||
f();
|
|
||||||
match prev {
|
|
||||||
Some(v) => unsafe { std::env::set_var(key, v) },
|
|
||||||
None => unsafe { std::env::remove_var(key) },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn llm_backend_defaults_to_ollama() {
|
|
||||||
with_env("LLM_BACKEND", None, || {
|
|
||||||
assert!(!local_backend_is_llamacpp());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn llm_backend_llamacpp_case_insensitive() {
|
|
||||||
with_env("LLM_BACKEND", Some("LlamaCpp"), || {
|
|
||||||
assert!(local_backend_is_llamacpp());
|
|
||||||
});
|
|
||||||
with_env("LLM_BACKEND", Some(" llamacpp "), || {
|
|
||||||
assert!(local_backend_is_llamacpp());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn llm_backend_unknown_value_is_ollama() {
|
|
||||||
with_env("LLM_BACKEND", Some("vllm"), || {
|
|
||||||
assert!(!local_backend_is_llamacpp());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-10
@@ -23,17 +23,11 @@ pub use crate::ai::llm_client::{ToolCall, ToolCallFunction, ToolFunction};
|
|||||||
// Cache duration: 15 minutes
|
// Cache duration: 15 minutes
|
||||||
const CACHE_DURATION_SECS: u64 = 15 * 60;
|
const CACHE_DURATION_SECS: u64 = 15 * 60;
|
||||||
|
|
||||||
/// Default total request timeout for generation calls, in seconds.
|
/// Total request timeout for generation calls (`ollama.timeout_secs`,
|
||||||
/// Overridable via `OLLAMA_REQUEST_TIMEOUT_SECONDS` env var for slow
|
/// default 120). Raise it for slow CPU-offloaded models where inference can
|
||||||
/// CPU-offloaded models where inference can take several minutes.
|
/// take several minutes.
|
||||||
const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 120;
|
|
||||||
|
|
||||||
fn configured_request_timeout_secs() -> u64 {
|
fn configured_request_timeout_secs() -> u64 {
|
||||||
std::env::var("OLLAMA_REQUEST_TIMEOUT_SECONDS")
|
crate::ai::config::llm_config().ollama.timeout_secs
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse::<u64>().ok())
|
|
||||||
.filter(|&s| s > 0)
|
|
||||||
.unwrap_or(DEFAULT_REQUEST_TIMEOUT_SECS)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Embedding model used across the app. Callers that persist a
|
/// Embedding model used across the app. Callers that persist a
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ use uuid::Uuid;
|
|||||||
use crate::ai::backend::{BackendKind, SamplingOverrides};
|
use crate::ai::backend::{BackendKind, SamplingOverrides};
|
||||||
use crate::ai::handlers::ReplayQuery;
|
use crate::ai::handlers::ReplayQuery;
|
||||||
use crate::ai::insight_chat::{
|
use crate::ai::insight_chat::{
|
||||||
ChatStreamEvent, ContextFitter, DEFAULT_MAX_ITERATIONS, ForkInfo, env_max_iterations,
|
ChatStreamEvent, ContextFitter, DEFAULT_MAX_ITERATIONS, ForkInfo, max_chat_iterations,
|
||||||
render_tree_path,
|
render_tree_path,
|
||||||
};
|
};
|
||||||
use crate::ai::insight_generator::InsightGenerator;
|
use crate::ai::insight_generator::InsightGenerator;
|
||||||
@@ -954,7 +954,7 @@ impl PersonaChatSession {
|
|||||||
Some("hybrid") => BackendKind::Hybrid,
|
Some("hybrid") => BackendKind::Hybrid,
|
||||||
_ => BackendKind::Local,
|
_ => BackendKind::Local,
|
||||||
};
|
};
|
||||||
let max_iterations = DEFAULT_MAX_ITERATIONS.clamp(1, env_max_iterations());
|
let max_iterations = DEFAULT_MAX_ITERATIONS.clamp(1, max_chat_iterations());
|
||||||
let overrides = SamplingOverrides {
|
let overrides = SamplingOverrides {
|
||||||
model: req.model.clone().filter(|m| !m.is_empty()),
|
model: req.model.clone().filter(|m| !m.is_empty()),
|
||||||
num_ctx: req.num_ctx,
|
num_ctx: req.num_ctx,
|
||||||
|
|||||||
+4
-11
@@ -169,13 +169,9 @@ fn sanitize_voice_name(raw: &str) -> Option<String> {
|
|||||||
|
|
||||||
/// Reference-clip cap in seconds for voice cloning. Chatterbox is zero-shot —
|
/// Reference-clip cap in seconds for voice cloning. Chatterbox is zero-shot —
|
||||||
/// a clean ~10–20s sample is the sweet spot and more rarely helps. Tune via
|
/// a clean ~10–20s sample is the sweet spot and more rarely helps. Tune via
|
||||||
/// `LLAMA_SWAP_TTS_REF_SECONDS` (default 30).
|
/// `tts.ref_seconds` (default 30).
|
||||||
fn tts_ref_seconds() -> u32 {
|
fn tts_ref_seconds() -> u32 {
|
||||||
std::env::var("LLAMA_SWAP_TTS_REF_SECONDS")
|
crate::ai::config::llm_config().tts.ref_seconds
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.trim().parse::<u32>().ok())
|
|
||||||
.filter(|n| *n > 0)
|
|
||||||
.unwrap_or(30)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tag a (sanitized) voice name with the reference window used to create it:
|
/// Tag a (sanitized) voice name with the reference window used to create it:
|
||||||
@@ -228,12 +224,9 @@ fn resolve_ref_window(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Optional default voice for synthesis when the request doesn't name one.
|
/// Optional default voice for synthesis when the request doesn't name one.
|
||||||
/// Set `LLAMA_SWAP_TTS_VOICE=m` to read insights in a cloned voice by default.
|
/// Set `tts.voice = "m"` to read insights in a cloned voice by default.
|
||||||
fn default_voice() -> Option<String> {
|
fn default_voice() -> Option<String> {
|
||||||
std::env::var("LLAMA_SWAP_TTS_VOICE")
|
crate::ai::config::llm_config().tts.voice.clone()
|
||||||
.ok()
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Markdown / formatting strippers, compiled once. Insight text is markdown,
|
// Markdown / formatting strippers, compiled once. Insight text is markdown,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ async fn main() -> Result<()> {
|
|||||||
// LocalLlm dispatches per LLM_BACKEND, so embeddings written here land
|
// LocalLlm dispatches per LLM_BACKEND, so embeddings written here land
|
||||||
// in the same vector space the query side searches.
|
// in the same vector space the query side searches.
|
||||||
let llm = if args.generate_embeddings {
|
let llm = if args.generate_embeddings {
|
||||||
Some(LocalLlm::from_env())
|
Some(LocalLlm::from_config())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
// LocalLlm dispatches per LLM_BACKEND, so embeddings written here land
|
// LocalLlm dispatches per LLM_BACKEND, so embeddings written here land
|
||||||
// in the same vector space the query side searches.
|
// in the same vector space the query side searches.
|
||||||
let llm = LocalLlm::from_env();
|
let llm = LocalLlm::from_config();
|
||||||
|
|
||||||
let mut inserted_count = 0usize;
|
let mut inserted_count = 0usize;
|
||||||
let mut skipped_count = 0usize;
|
let mut skipped_count = 0usize;
|
||||||
|
|||||||
@@ -129,24 +129,19 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ollama config from env with CLI overrides.
|
// Ollama config from llm.toml with CLI overrides.
|
||||||
let primary_url = std::env::var("OLLAMA_PRIMARY_URL")
|
let ollama_cfg = &image_api::ai::config::llm_config().ollama;
|
||||||
.or_else(|_| std::env::var("OLLAMA_URL"))
|
let primary_url = ollama_cfg.primary_url.clone();
|
||||||
.unwrap_or_else(|_| "http://localhost:11434".to_string());
|
|
||||||
let fallback_url = std::env::var("OLLAMA_FALLBACK_URL").ok();
|
|
||||||
let primary_model = args
|
let primary_model = args
|
||||||
.model
|
.model
|
||||||
.clone()
|
.clone()
|
||||||
.or_else(|| std::env::var("OLLAMA_PRIMARY_MODEL").ok())
|
.unwrap_or_else(|| ollama_cfg.primary_model.clone());
|
||||||
.or_else(|| std::env::var("OLLAMA_MODEL").ok())
|
|
||||||
.unwrap_or_else(|| "nemotron-3-nano:30b".to_string());
|
|
||||||
let fallback_model = std::env::var("OLLAMA_FALLBACK_MODEL").ok();
|
|
||||||
|
|
||||||
let mut ollama = OllamaClient::new(
|
let mut ollama = OllamaClient::new(
|
||||||
primary_url.clone(),
|
primary_url.clone(),
|
||||||
fallback_url,
|
ollama_cfg.fallback_url.clone(),
|
||||||
primary_model.clone(),
|
primary_model.clone(),
|
||||||
fallback_model,
|
ollama_cfg.fallback_model.clone(),
|
||||||
)
|
)
|
||||||
.with_request_timeout(args.timeout_secs);
|
.with_request_timeout(args.timeout_secs);
|
||||||
|
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ async fn main() -> Result<()> {
|
|||||||
let mut conn = SqliteConnection::establish(&database_url)
|
let mut conn = SqliteConnection::establish(&database_url)
|
||||||
.with_context(|| format!("connecting to {}", database_url))?;
|
.with_context(|| format!("connecting to {}", database_url))?;
|
||||||
|
|
||||||
let llm = LocalLlm::from_env();
|
let llm = LocalLlm::from_config();
|
||||||
let model_version = llm.embedding_model_version();
|
let model_version = llm.embedding_model_version();
|
||||||
println!("Embedding via '{}'", model_version);
|
println!("Embedding via '{}'", model_version);
|
||||||
if args.dry_run {
|
if args.dry_run {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ async fn main() -> Result<()> {
|
|||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
let ollama_cfg = &image_api::ai::config::llm_config().ollama;
|
||||||
|
|
||||||
// Parse dates
|
// Parse dates
|
||||||
let start_date = NaiveDate::parse_from_str(&args.start, "%Y-%m-%d")
|
let start_date = NaiveDate::parse_from_str(&args.start, "%Y-%m-%d")
|
||||||
@@ -82,12 +83,7 @@ async fn main() -> Result<()> {
|
|||||||
if let Some(ref model) = args.model {
|
if let Some(ref model) = args.model {
|
||||||
println!("Model: {}", model);
|
println!("Model: {}", model);
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!("Model: {} (from llm config)", ollama_cfg.primary_model);
|
||||||
"Model: {} (from env)",
|
|
||||||
env::var("OLLAMA_PRIMARY_MODEL")
|
|
||||||
.or_else(|_| env::var("OLLAMA_MODEL"))
|
|
||||||
.unwrap_or_else(|_| "nemotron-3-nano:30b".to_string())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if args.test_mode {
|
if args.test_mode {
|
||||||
println!("⚠ TEST MODE: Results will NOT be saved to database");
|
println!("⚠ TEST MODE: Results will NOT be saved to database");
|
||||||
@@ -96,22 +92,15 @@ async fn main() -> Result<()> {
|
|||||||
println!();
|
println!();
|
||||||
|
|
||||||
// Initialize AI clients
|
// Initialize AI clients
|
||||||
let ollama_primary_url = env::var("OLLAMA_PRIMARY_URL")
|
// Use provided model or fall back to the configured one
|
||||||
.or_else(|_| env::var("OLLAMA_URL"))
|
let model_to_use = args
|
||||||
.unwrap_or_else(|_| "http://localhost:11434".to_string());
|
.model
|
||||||
|
.clone()
|
||||||
let ollama_fallback_url = env::var("OLLAMA_FALLBACK_URL").ok();
|
.unwrap_or_else(|| ollama_cfg.primary_model.clone());
|
||||||
|
|
||||||
// Use provided model or fallback to env
|
|
||||||
let model_to_use = args.model.clone().unwrap_or_else(|| {
|
|
||||||
env::var("OLLAMA_PRIMARY_MODEL")
|
|
||||||
.or_else(|_| env::var("OLLAMA_MODEL"))
|
|
||||||
.unwrap_or_else(|_| "nemotron-3-nano:30b".to_string())
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut ollama = OllamaClient::new(
|
let mut ollama = OllamaClient::new(
|
||||||
ollama_primary_url,
|
ollama_cfg.primary_url.clone(),
|
||||||
ollama_fallback_url.clone(),
|
ollama_cfg.fallback_url.clone(),
|
||||||
model_to_use.clone(),
|
model_to_use.clone(),
|
||||||
Some(model_to_use), // Use same model for fallback
|
Some(model_to_use), // Use same model for fallback
|
||||||
);
|
);
|
||||||
|
|||||||
+38
-87
@@ -1,5 +1,6 @@
|
|||||||
use crate::ai::apollo_client::ApolloClient;
|
use crate::ai::apollo_client::ApolloClient;
|
||||||
use crate::ai::clip_client::ClipClient;
|
use crate::ai::clip_client::ClipClient;
|
||||||
|
use crate::ai::config::{LlmConfig, OllamaConfig, OpenRouterConfig, llm_config};
|
||||||
use crate::ai::face_client::FaceClient;
|
use crate::ai::face_client::FaceClient;
|
||||||
use crate::ai::insight_chat::{ChatLockMap, InsightChatService};
|
use crate::ai::insight_chat::{ChatLockMap, InsightChatService};
|
||||||
use crate::ai::llamacpp::LlamaCppClient;
|
use crate::ai::llamacpp::LlamaCppClient;
|
||||||
@@ -224,13 +225,14 @@ impl AppState {
|
|||||||
impl Default for AppState {
|
impl Default for AppState {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
// Initialize AI clients
|
// Initialize AI clients
|
||||||
let ollama = build_ollama_from_env();
|
let llm = llm_config();
|
||||||
|
let ollama = build_ollama(&llm.ollama);
|
||||||
|
|
||||||
let openrouter = build_openrouter_from_env();
|
let openrouter = build_openrouter(&llm.openrouter);
|
||||||
let openrouter_allowed_models = parse_openrouter_allowed_models();
|
let openrouter_allowed_models = llm.openrouter.allowed.clone();
|
||||||
|
|
||||||
let llamacpp = build_llamacpp_from_env();
|
let llamacpp = build_llamacpp(llm);
|
||||||
let llamacpp_allowed_models = parse_llamacpp_allowed_models();
|
let llamacpp_allowed_models = llm.llamacpp.picker.include.clone().unwrap_or_default();
|
||||||
|
|
||||||
let sms_api_url =
|
let sms_api_url =
|
||||||
env::var("SMS_API_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
|
env::var("SMS_API_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
|
||||||
@@ -346,10 +348,7 @@ impl Default for AppState {
|
|||||||
|
|
||||||
// Turn registry for reconnectable chat turns. 5-minute timeout for
|
// Turn registry for reconnectable chat turns. 5-minute timeout for
|
||||||
// stale turns (background cleaner drops entries older than this).
|
// stale turns (background cleaner drops entries older than this).
|
||||||
let timeout_secs: u64 = env::var("INSIGHT_CHAT_TURN_TIMEOUT_SECS")
|
let timeout_secs = llm_config().agentic.chat_turn_timeout_secs;
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(300);
|
|
||||||
let turn_registry = Arc::new(TurnRegistry::new(timeout_secs));
|
let turn_registry = Arc::new(TurnRegistry::new(timeout_secs));
|
||||||
|
|
||||||
// Ensure preview clips directory exists
|
// Ensure preview clips directory exists
|
||||||
@@ -387,94 +386,46 @@ impl Default for AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build an `OpenRouterClient` from environment variables. Returns `None`
|
/// Build an `OpenRouterClient` from `[openrouter]`. Returns `None` when
|
||||||
/// when `OPENROUTER_API_KEY` is unset (the hybrid backend is then
|
/// `OPENROUTER_API_KEY` (env-only, it's a secret) is unset — the hybrid
|
||||||
/// unavailable and requests for it return a clear error).
|
/// backend is then unavailable and requests for it return a clear error.
|
||||||
fn build_openrouter_from_env() -> Option<Arc<OpenRouterClient>> {
|
fn build_openrouter(cfg: &OpenRouterConfig) -> Option<Arc<OpenRouterClient>> {
|
||||||
let api_key = env::var("OPENROUTER_API_KEY").ok()?;
|
let api_key = env::var("OPENROUTER_API_KEY").ok()?;
|
||||||
let base_url = env::var("OPENROUTER_BASE_URL").ok();
|
let mut client =
|
||||||
let default_model = env::var("OPENROUTER_DEFAULT_MODEL")
|
OpenRouterClient::new(api_key, cfg.base_url.clone(), cfg.default_model.clone());
|
||||||
.unwrap_or_else(|_| "anthropic/claude-sonnet-4".to_string());
|
client.set_attribution(cfg.http_referer.clone(), cfg.app_title.clone());
|
||||||
let mut client = OpenRouterClient::new(api_key, base_url, default_model);
|
if let Some(model) = &cfg.embedding_model {
|
||||||
client.set_attribution(
|
client.set_embedding_model(model.clone());
|
||||||
env::var("OPENROUTER_HTTP_REFERER").ok(),
|
|
||||||
env::var("OPENROUTER_APP_TITLE").ok(),
|
|
||||||
);
|
|
||||||
if let Ok(model) = env::var("OPENROUTER_EMBEDDING_MODEL") {
|
|
||||||
client.set_embedding_model(model);
|
|
||||||
}
|
|
||||||
// OPENROUTER_MAX_TOKENS=0 omits the cap (provider default).
|
|
||||||
if let Some(n) = env::var("OPENROUTER_MAX_TOKENS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.trim().parse::<u32>().ok())
|
|
||||||
{
|
|
||||||
client.set_max_tokens((n > 0).then_some(n));
|
|
||||||
}
|
}
|
||||||
|
client.set_max_tokens(cfg.max_tokens_cap());
|
||||||
Some(Arc::new(client))
|
Some(Arc::new(client))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse `OPENROUTER_ALLOWED_MODELS` (comma-separated) into a vec. Returns
|
/// Build the `OllamaClient` from `[ollama]` — shared by the server
|
||||||
/// empty when unset, in which case `/insights/openrouter/models` reports no
|
/// (`AppState::default`) and the standalone binaries.
|
||||||
/// curated picks and the server falls back to `OPENROUTER_DEFAULT_MODEL`.
|
pub fn build_ollama(cfg: &OllamaConfig) -> OllamaClient {
|
||||||
fn parse_openrouter_allowed_models() -> Vec<String> {
|
OllamaClient::new(
|
||||||
env::var("OPENROUTER_ALLOWED_MODELS")
|
cfg.primary_url.clone(),
|
||||||
.unwrap_or_default()
|
cfg.fallback_url.clone(),
|
||||||
.split(',')
|
cfg.primary_model.clone(),
|
||||||
.map(|s| s.trim().to_string())
|
cfg.fallback_model.clone(),
|
||||||
.filter(|s| !s.is_empty())
|
)
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the `OllamaClient` from environment variables — the canonical
|
/// Build a `LlamaCppClient` from `[llamacpp]` (+ the embed and TTS model
|
||||||
/// `OLLAMA_*` wiring shared by the server (`AppState::default`) and the
|
/// ids). Returns `None` when `llamacpp.url` is unset. The client is built
|
||||||
/// standalone binaries (which predate this helper and used to copy it).
|
/// whenever the URL is set (TTS and ad-hoc tooling use it even under
|
||||||
pub fn build_ollama_from_env() -> OllamaClient {
|
/// `backend = "ollama"`), but chat / embeddings only route through it when
|
||||||
let primary_url = env::var("OLLAMA_PRIMARY_URL").unwrap_or_else(|_| {
|
/// `backend = "llamacpp"`.
|
||||||
env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string())
|
pub fn build_llamacpp(cfg: &LlmConfig) -> Option<Arc<LlamaCppClient>> {
|
||||||
});
|
let base_url = cfg.llamacpp.url.clone()?;
|
||||||
let fallback_url = env::var("OLLAMA_FALLBACK_URL").ok();
|
let mut client = LlamaCppClient::new(Some(base_url), Some(cfg.llamacpp.chat.clone()));
|
||||||
let primary_model = env::var("OLLAMA_PRIMARY_MODEL")
|
client.set_embedding_model(cfg.embedding.model.clone());
|
||||||
.or_else(|_| env::var("OLLAMA_MODEL"))
|
client.set_vision_model(cfg.llamacpp.vision_model().to_string());
|
||||||
.unwrap_or_else(|_| "nemotron-3-nano:30b".to_string());
|
client.set_tts_model(cfg.tts.model.clone());
|
||||||
let fallback_model = env::var("OLLAMA_FALLBACK_MODEL").ok();
|
|
||||||
|
|
||||||
OllamaClient::new(primary_url, fallback_url, primary_model, fallback_model)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a `LlamaCppClient` from environment variables. Returns `None` when
|
|
||||||
/// `LLAMA_SWAP_URL` is unset. The client is constructed unconditionally
|
|
||||||
/// when the URL is set (so it's available even under `LLM_BACKEND=ollama`
|
|
||||||
/// for ad-hoc tooling), but the agentic / chat paths only route through it
|
|
||||||
/// when `LLM_BACKEND=llamacpp`. Slot ids default to the names the bundled
|
|
||||||
/// `llama-swap/config.yaml` uses — `chat` / `vision` / `embed`.
|
|
||||||
pub fn build_llamacpp_from_env() -> Option<Arc<LlamaCppClient>> {
|
|
||||||
let base_url = env::var("LLAMA_SWAP_URL").ok()?;
|
|
||||||
let primary_model = env::var("LLAMA_SWAP_PRIMARY_MODEL").ok();
|
|
||||||
let mut client = LlamaCppClient::new(Some(base_url), primary_model);
|
|
||||||
if let Ok(model) = env::var("LLAMA_SWAP_EMBEDDING_MODEL") {
|
|
||||||
client.set_embedding_model(model);
|
|
||||||
}
|
|
||||||
if let Ok(model) = env::var("LLAMA_SWAP_VISION_MODEL") {
|
|
||||||
client.set_vision_model(model);
|
|
||||||
}
|
|
||||||
if let Ok(model) = env::var("LLAMA_SWAP_TTS_MODEL") {
|
|
||||||
client.set_tts_model(model);
|
|
||||||
}
|
|
||||||
Some(Arc::new(client))
|
Some(Arc::new(client))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse `LLAMA_SWAP_ALLOWED_MODELS` (comma-separated) into a vec. Used to
|
|
||||||
/// populate the model picker when `LLM_BACKEND=llamacpp` — `/insights/models`
|
|
||||||
/// surfaces these slots with capabilities. Empty when unset.
|
|
||||||
fn parse_llamacpp_allowed_models() -> Vec<String> {
|
|
||||||
env::var("LLAMA_SWAP_ALLOWED_MODELS")
|
|
||||||
.unwrap_or_default()
|
|
||||||
.split(',')
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
impl AppState {
|
impl AppState {
|
||||||
/// Creates an AppState instance for testing with temporary directories
|
/// Creates an AppState instance for testing with temporary directories
|
||||||
|
|||||||
@@ -639,15 +639,15 @@ async fn translate<TagD: TagDao>(
|
|||||||
// llama-swap); local only, no hybrid, per the feature's design.
|
// llama-swap); local only, no hybrid, per the feature's design.
|
||||||
//
|
//
|
||||||
// Translation-model precedence:
|
// Translation-model precedence:
|
||||||
// 1. UNIFIED_SEARCH_MODEL env — pin a small, fast model that can stay
|
// 1. `unified_search_model` (llm.toml) — pin a small, fast model that can stay
|
||||||
// co-resident with CLIP (and the chat model) so translation never
|
// co-resident with CLIP (and the chat model) so translation never
|
||||||
// evicts them. This is the recommended setup on a tight VRAM budget.
|
// evicts them. This is the recommended setup on a tight VRAM budget.
|
||||||
// 2. the client-selected model — routes translation to whatever the user
|
// 2. the client-selected model — routes translation to whatever the user
|
||||||
// already has loaded (no swap) when no dedicated model is pinned.
|
// already has loaded (no swap) when no dedicated model is pinned.
|
||||||
// 3. None → resolve_backend uses the configured default local model.
|
// 3. None → resolve_backend uses the configured default local model.
|
||||||
let translation_model = std::env::var("UNIFIED_SEARCH_MODEL")
|
let translation_model = crate::ai::config::llm_config()
|
||||||
.ok()
|
.unified_search_model
|
||||||
.filter(|m| !m.trim().is_empty())
|
.clone()
|
||||||
.or_else(|| query.model.clone())
|
.or_else(|| query.model.clone())
|
||||||
.filter(|m| !m.trim().is_empty());
|
.filter(|m| !m.trim().is_empty());
|
||||||
let overrides = SamplingOverrides {
|
let overrides = SamplingOverrides {
|
||||||
|
|||||||
Reference in new issue
Block a user