LlmClient methods no longer take an OpenTelemetry Context. llm.request is now a tracing span (otel.kind=client, llm.provider/operation/model) that parents on the caller's ambient span; traceparent injection reads the span's OTel context via OpenTelemetrySpanExt. The src/ai feature spans (handlers, insight generation, chat, persona chat, daily summaries, TTS) move to #[instrument] / info_span!, and every LLM-bearing tokio::spawn carries its parent span with .instrument(): the streaming chat turn (chat_turn_stream_inner), async chat / persona turns, background insight jobs and TTS jobs. Sub-spans in the agentic loops (ai.chat.loop, ai.agentic.loop) instrument each LLM / tool await. DAO calls keep their explicit Context (derived from the tracing span) until the DAO migration (#141). tests/otel_llm_spans.rs pins nesting + traceparent for the blocking and streaming paths across a spawn on a multi-thread runtime. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
8.2 KiB
Rust
217 lines
8.2 KiB
Rust
pub mod apollo_client;
|
|
pub mod backend;
|
|
pub mod clip_client;
|
|
pub mod daily_summary_job;
|
|
pub mod face_client;
|
|
pub mod gpu;
|
|
pub mod handlers;
|
|
pub mod insight_chat;
|
|
pub mod insight_generator;
|
|
pub mod llamacpp;
|
|
pub mod llm_client;
|
|
pub mod local_llm;
|
|
pub mod nl_query;
|
|
pub mod ollama;
|
|
pub mod openrouter;
|
|
pub mod persona_chat;
|
|
pub mod pronunciation;
|
|
pub mod sms_client;
|
|
pub mod tts;
|
|
pub mod turn_registry;
|
|
|
|
// strip_summary_boilerplate is used by binaries (test_daily_summary), not the library
|
|
#[allow(unused_imports)]
|
|
pub use daily_summary_job::{
|
|
DAILY_SUMMARY_MESSAGE_LIMIT, DAILY_SUMMARY_SYSTEM_PROMPT, build_daily_summary_prompt,
|
|
generate_daily_summaries, strip_summary_boilerplate,
|
|
};
|
|
pub use handlers::{
|
|
cancel_generation_handler, cancel_turn_handler, chat_branches_handler, chat_history_handler,
|
|
chat_rewind_handler, chat_stream_handler, chat_switch_branch_handler, chat_turn_handler,
|
|
delete_insight_handler, export_training_data_handler, generate_agentic_insight_handler,
|
|
generate_insight_handler, generation_status_handler, get_all_insights_handler,
|
|
get_available_models_handler, get_insight_handler, get_insight_history_handler,
|
|
get_openrouter_models_handler, rate_insight_handler, turn_async_handler, turn_replay_handler,
|
|
};
|
|
pub use insight_generator::InsightGenerator;
|
|
pub use llamacpp::LlamaCppClient;
|
|
#[allow(unused_imports)]
|
|
pub use llm_client::{
|
|
ChatMessage, LlmClient, ModelCapabilities, Tool, ToolCall, ToolCallFunction, ToolFunction,
|
|
};
|
|
pub use persona_chat::{
|
|
persona_chat_branches_handler, persona_chat_conversations_handler,
|
|
persona_chat_create_conversation_handler, persona_chat_delete_conversation_handler,
|
|
persona_chat_history_handler, persona_chat_rewind_handler, persona_chat_switch_branch_handler,
|
|
persona_chat_turn_handler, persona_turn_cancel_handler, persona_turn_replay_handler,
|
|
};
|
|
// LocalLlm is constructed by binaries (reembed_embeddings, importers), not the server
|
|
#[allow(unused_imports)]
|
|
pub use local_llm::LocalLlm;
|
|
pub use ollama::{EMBEDDING_MODEL, OllamaClient};
|
|
pub use sms_client::{SmsApiClient, SmsMessage};
|
|
pub use tts::{
|
|
cancel_speech_job_handler, create_speech_job_handler, create_voice_from_library_handler,
|
|
create_voice_upload_handler, delete_voice_handler, list_voices_handler,
|
|
speech_job_status_handler, tts_speech_handler,
|
|
};
|
|
|
|
/// Display name used for the user in message transcripts and first-person
|
|
/// prompt text. Reads the `USER_NAME` env var; defaults to `"Me"`. Models
|
|
/// often confuse `"Me:"` in a transcript with their own role — setting
|
|
/// `USER_NAME=Cameron` (or similar) in the environment eliminates that
|
|
/// ambiguity across daily summaries, insight generation, and chat.
|
|
pub fn user_display_name() -> String {
|
|
std::env::var("USER_NAME").unwrap_or_else(|_| "Me".to_string())
|
|
}
|
|
|
|
/// One switch for the "local" LLM stack: when `LLM_BACKEND=llamacpp` is
|
|
/// set, chat / vision describe / embeddings all route through llama-swap
|
|
/// instead of Ollama. Any other value (including unset, the default) is
|
|
/// Ollama. This is intentionally global — embeddings must be drawn from
|
|
/// a single source or similarity search across the index breaks (mixed
|
|
/// vector spaces, possibly mixed dims). The `backend=hybrid` per-request
|
|
/// override remains orthogonal: it always sends chat to OpenRouter, and
|
|
/// uses `LLM_BACKEND` for the describe-then-inline vision pass.
|
|
pub fn local_backend_is_llamacpp() -> bool {
|
|
matches!(
|
|
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`
|
|
/// (default 768, nomic-embed-text). Every store/query dim check reads this —
|
|
/// swapping to a different-dim model (e.g. Qwen3-Embedding-0.6B at 1024) is
|
|
/// then a config flip 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 {
|
|
static DIM: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
|
*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
|
|
/// 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:
|
|
/// nomic v1.5 wants `search_query: `, Qwen3-Embedding wants
|
|
/// `Instruct: <task>\nQuery: `. Must pair with the document prefix the
|
|
/// corpus was embedded with or similarity degrades.
|
|
pub async fn embed_query(
|
|
ollama: &OllamaClient,
|
|
llamacpp: Option<&LlamaCppClient>,
|
|
text: &str,
|
|
) -> anyhow::Result<Vec<f32>> {
|
|
let prefixed = format!("{}{}", embed_prefix("EMBED_QUERY_PREFIX"), text);
|
|
embed_one(ollama, llamacpp, &prefixed).await
|
|
}
|
|
|
|
/// Embed corpus text (the stored side of retrieval). Applies
|
|
/// `EMBED_DOCUMENT_PREFIX` (default empty; nomic v1.5 wants
|
|
/// `search_document: `, Qwen3-Embedding wants none).
|
|
pub async fn embed_document(
|
|
ollama: &OllamaClient,
|
|
llamacpp: Option<&LlamaCppClient>,
|
|
text: &str,
|
|
) -> anyhow::Result<Vec<f32>> {
|
|
let prefixed = format!("{}{}", embed_prefix("EMBED_DOCUMENT_PREFIX"), text);
|
|
embed_one(ollama, llamacpp, &prefixed).await
|
|
}
|
|
|
|
/// Embed a batch of strings via the configured local backend. Routes
|
|
/// through llama-swap when `LLM_BACKEND=llamacpp` (and a client is
|
|
/// configured), else Ollama. See [`local_backend_is_llamacpp`] for the
|
|
/// rationale on consistency.
|
|
pub async fn embed_many(
|
|
ollama: &OllamaClient,
|
|
llamacpp: Option<&LlamaCppClient>,
|
|
texts: &[&str],
|
|
) -> anyhow::Result<Vec<Vec<f32>>> {
|
|
if local_backend_is_llamacpp() {
|
|
if let Some(lc) = llamacpp {
|
|
return <LlamaCppClient as LlmClient>::generate_embeddings(lc, texts).await;
|
|
}
|
|
anyhow::bail!(
|
|
"LLM_BACKEND=llamacpp but LlamaCppClient is unconfigured — \
|
|
set LLAMA_SWAP_URL or switch to LLM_BACKEND=ollama"
|
|
);
|
|
}
|
|
ollama.generate_embeddings(texts).await
|
|
}
|
|
|
|
/// Embed one string via the configured local backend. Single-text
|
|
/// convenience over [`embed_many`].
|
|
pub async fn embed_one(
|
|
ollama: &OllamaClient,
|
|
llamacpp: Option<&LlamaCppClient>,
|
|
text: &str,
|
|
) -> anyhow::Result<Vec<f32>> {
|
|
let mut vecs = embed_many(ollama, llamacpp, &[text]).await?;
|
|
vecs.pop()
|
|
.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());
|
|
});
|
|
}
|
|
}
|