diff --git a/src/openhuman/agent/harness/session/builder.rs b/src/openhuman/agent/harness/session/builder.rs index d532acf0b..739eb9de1 100644 --- a/src/openhuman/agent/harness/session/builder.rs +++ b/src/openhuman/agent/harness/session/builder.rs @@ -592,8 +592,9 @@ impl Agent { &config.workspace_dir, )); - let memory: Arc = Arc::from(memory::create_memory_with_storage_and_routes( + let memory: Arc = Arc::from(memory::create_memory_with_local_ai( &config.memory, + &config.local_ai, &config.embedding_routes, Some(&config.storage.provider.config), &config.workspace_dir, diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index 39c5c94b7..960f6262b 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -184,8 +184,10 @@ pub async fn start_channels(config: Config) -> Result<()> { .clone() .unwrap_or_else(|| crate::openhuman::config::DEFAULT_MODEL.into()); let temperature = config.default_temperature; - let mem: Arc = Arc::from(memory::create_memory_with_storage( + let mem: Arc = Arc::from(memory::create_memory_with_local_ai( &config.memory, + &config.local_ai, + &[], Some(&config.storage.provider.config), &config.workspace_dir, )?); diff --git a/src/openhuman/config/schema/storage_memory.rs b/src/openhuman/config/schema/storage_memory.rs index bca55b494..6f81bef5d 100644 --- a/src/openhuman/config/schema/storage_memory.rs +++ b/src/openhuman/config/schema/storage_memory.rs @@ -48,13 +48,20 @@ pub struct MemoryConfig { } fn default_embedding_provider() -> String { - "ollama".into() + // Default to the OpenHuman backend (Voyage-backed `embedding-v1`) so a + // fresh install works without requiring a local Ollama daemon. Users + // who want fully-local embeddings can flip this to "ollama" in + // `config.toml` or enable `local_ai.usage.embeddings = true`, which is + // wired into the memory factory via [`LocalAiConfig::use_local_for_embeddings`]. + "cloud".into() } fn default_embedding_model() -> String { - "nomic-embed-text:latest".into() + // Keep this in sync with `embeddings::cloud::DEFAULT_CLOUD_EMBEDDING_MODEL`. + "embedding-v1".into() } fn default_embedding_dims() -> usize { - 768 + // Keep this in sync with `embeddings::cloud::DEFAULT_CLOUD_EMBEDDING_DIMENSIONS`. + 1024 } fn default_min_relevance_score() -> f64 { 0.4 diff --git a/src/openhuman/embeddings/cloud.rs b/src/openhuman/embeddings/cloud.rs new file mode 100644 index 000000000..3b674036c --- /dev/null +++ b/src/openhuman/embeddings/cloud.rs @@ -0,0 +1,152 @@ +//! Cloud embedding provider — routes through the OpenHuman backend's +//! `POST /openai/v1/embeddings` surface (Voyage-backed) using the same +//! session JWT that the `OpenHumanBackendProvider` chat path uses. +//! +//! This is the default embedder for a fresh install. The local Ollama path +//! stays available, but the user has to explicitly opt in (either by setting +//! `memory.embedding_provider = "ollama"` in `config.toml`, or by enabling +//! the local-AI runtime with `local_ai.usage.embeddings = true`). +//! +//! The JWT and API URL are resolved per call so a session refresh between +//! embed batches is picked up transparently — matching +//! [`crate::openhuman::providers::openhuman_backend::OpenHumanBackendProvider`]. + +use std::path::PathBuf; + +use async_trait::async_trait; + +use super::openai::OpenAiEmbedding; +use super::EmbeddingProvider; +use crate::api::config::effective_api_url; +use crate::openhuman::credentials::{AuthService, APP_SESSION_PROVIDER}; + +/// Default cloud embedding model — backed by `voyage-3.5` (1024 dims) on the +/// OpenHuman backend. See `tinyhumansai/backend#746`. +pub const DEFAULT_CLOUD_EMBEDDING_MODEL: &str = "embedding-v1"; + +/// Default output dimensionality for [`DEFAULT_CLOUD_EMBEDDING_MODEL`]. +pub const DEFAULT_CLOUD_EMBEDDING_DIMENSIONS: usize = 1024; + +/// OpenHuman-backend-backed embedding provider. +pub struct OpenHumanCloudEmbedding { + api_url: Option, + openhuman_dir: Option, + secrets_encrypt: bool, + model: String, + dims: usize, +} + +impl OpenHumanCloudEmbedding { + /// Construct a cloud embedder. `api_url` and `openhuman_dir` are looked up + /// per request; pass `None` to fall back to the runtime defaults + /// ([`effective_api_url`] / `~/.openhuman`). + pub fn new( + api_url: Option, + openhuman_dir: Option, + secrets_encrypt: bool, + model: impl Into, + dims: usize, + ) -> Self { + Self { + api_url: api_url + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + openhuman_dir, + secrets_encrypt, + model: model.into(), + dims, + } + } + + fn state_dir(&self) -> PathBuf { + self.openhuman_dir.clone().unwrap_or_else(|| { + directories::UserDirs::new() + .map(|d| d.home_dir().join(".openhuman")) + .unwrap_or_else(|| PathBuf::from(".openhuman")) + }) + } + + fn resolve_bearer(&self) -> anyhow::Result { + let auth = AuthService::new(&self.state_dir(), self.secrets_encrypt); + if let Some(t) = auth + .get_provider_bearer_token(APP_SESSION_PROVIDER, None)? + .filter(|s| !s.trim().is_empty()) + { + return Ok(t); + } + anyhow::bail!( + "No backend session for cloud embeddings: log in to OpenHuman, or set \ + memory.embedding_provider to \"ollama\" / \"none\" in config.toml" + ) + } + + fn base_url(&self) -> String { + let u = effective_api_url(&self.api_url); + format!("{}/openai/v1", u.trim_end_matches('/')) + } +} + +#[async_trait] +impl EmbeddingProvider for OpenHumanCloudEmbedding { + fn name(&self) -> &str { + "cloud" + } + + fn dimensions(&self) -> usize { + self.dims + } + + async fn embed(&self, texts: &[&str]) -> anyhow::Result>> { + if texts.is_empty() { + return Ok(Vec::new()); + } + let token = self.resolve_bearer()?; + let inner = OpenAiEmbedding::new(&self.base_url(), &token, &self.model, self.dims); + inner.embed(texts).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_and_dimensions() { + let p = OpenHumanCloudEmbedding::new( + None, + None, + true, + DEFAULT_CLOUD_EMBEDDING_MODEL, + DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, + ); + assert_eq!(p.name(), "cloud"); + assert_eq!(p.dimensions(), DEFAULT_CLOUD_EMBEDDING_DIMENSIONS); + } + + #[test] + fn base_url_appends_openai_v1() { + let p = OpenHumanCloudEmbedding::new( + Some("https://api.openhuman.example/".into()), + None, + true, + DEFAULT_CLOUD_EMBEDDING_MODEL, + DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, + ); + assert_eq!(p.base_url(), "https://api.openhuman.example/openai/v1"); + } + + #[tokio::test] + async fn embed_empty_returns_empty_without_auth() { + // Empty input should short-circuit *before* hitting the AuthService — + // otherwise the no-op path would spuriously fail in unauthenticated + // contexts (e.g. ingestion of an empty chunk batch). + let p = OpenHumanCloudEmbedding::new( + None, + None, + false, + DEFAULT_CLOUD_EMBEDDING_MODEL, + DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, + ); + assert!(p.embed(&[]).await.unwrap().is_empty()); + } +} diff --git a/src/openhuman/embeddings/factory.rs b/src/openhuman/embeddings/factory.rs index 79b10cb15..3560afc25 100644 --- a/src/openhuman/embeddings/factory.rs +++ b/src/openhuman/embeddings/factory.rs @@ -2,13 +2,17 @@ use std::sync::Arc; +use super::cloud::{ + OpenHumanCloudEmbedding, DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, DEFAULT_CLOUD_EMBEDDING_MODEL, +}; use super::provider_trait::EmbeddingProvider; use super::{NoopEmbedding, OllamaEmbedding, OpenAiEmbedding}; /// Creates an embedding provider based on the specified name and configuration. /// /// Supported provider names: -/// - `"ollama"` → local Ollama server (default, preferred) +/// - `"cloud"` → OpenHuman backend (Voyage-backed) — default, preferred +/// - `"ollama"` → local Ollama server (opt-in for offline-only installs) /// - `"openai"` → OpenAI API /// - `"custom:"` → OpenAI-compatible endpoint /// - `"none"` → no-op (keyword-only search, no embeddings) @@ -22,6 +26,9 @@ pub fn create_embedding_provider( dims: usize, ) -> anyhow::Result> { match provider { + "cloud" => Ok(Box::new(OpenHumanCloudEmbedding::new( + None, None, true, model, dims, + ))), "ollama" => Ok(Box::new(OllamaEmbedding::new("", model, dims))), "openai" => Ok(Box::new(OpenAiEmbedding::new( "https://api.openai.com", @@ -36,12 +43,28 @@ pub fn create_embedding_provider( "none" => Ok(Box::new(NoopEmbedding)), unknown => Err(anyhow::anyhow!( "unknown embedding provider: \"{unknown}\". \ - Supported: \"ollama\", \"openai\", \"custom:\", \"none\"" + Supported: \"cloud\", \"ollama\", \"openai\", \"custom:\", \"none\"" )), } } -/// Returns the default local embedding provider (Ollama-backed). +/// Returns the default embedding provider — cloud (OpenHuman backend, Voyage). +/// +/// The cloud embedder lazily resolves the session JWT and API URL on each +/// call, so this can be constructed before login completes; the first +/// `embed()` will fail with a clear message if the user is unauthenticated. +pub fn default_embedding_provider() -> Arc { + Arc::new(OpenHumanCloudEmbedding::new( + None, + None, + true, + DEFAULT_CLOUD_EMBEDDING_MODEL, + DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, + )) +} + +/// Returns the local Ollama-backed embedding provider. Only used when the +/// caller has explicitly opted into local-only embeddings. pub fn default_local_embedding_provider() -> Arc { Arc::new(OllamaEmbedding::default()) } diff --git a/src/openhuman/embeddings/mod.rs b/src/openhuman/embeddings/mod.rs index eb3d08866..284f39b37 100644 --- a/src/openhuman/embeddings/mod.rs +++ b/src/openhuman/embeddings/mod.rs @@ -2,11 +2,16 @@ //! //! Converts text into numerical vectors for semantic search. Providers: //! -//! - **Ollama** (default): Delegates to a local Ollama server — handles model -//! management, quantization, and GPU acceleration out of the box. +//! - **Cloud** (default): Routes through the OpenHuman backend's +//! `POST /openai/v1/embeddings` (Voyage-backed). The recommended path — +//! works on a fresh install without requiring a local Ollama daemon. +//! - **Ollama**: Local Ollama server. Opt-in for offline-only setups +//! (set `memory.embedding_provider = "ollama"` or enable +//! `local_ai.usage.embeddings`). //! - **OpenAI**: Cloud-based embeddings via the OpenAI API or compatible endpoints. //! - **Noop**: A fallback provider for keyword-only search. +pub mod cloud; mod factory; pub mod noop; pub mod ollama; @@ -14,7 +19,12 @@ pub mod openai; mod provider_trait; pub mod store; -pub use factory::{create_embedding_provider, default_local_embedding_provider}; +pub use cloud::{ + OpenHumanCloudEmbedding, DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, DEFAULT_CLOUD_EMBEDDING_MODEL, +}; +pub use factory::{ + create_embedding_provider, default_embedding_provider, default_local_embedding_provider, +}; pub use noop::NoopEmbedding; pub use ollama::{OllamaEmbedding, DEFAULT_OLLAMA_DIMENSIONS, DEFAULT_OLLAMA_MODEL}; pub use openai::OpenAiEmbedding; @@ -125,8 +135,27 @@ mod tests { .contains("fastembed")); } + #[test] + fn factory_cloud() { + let p = create_embedding_provider( + "cloud", + DEFAULT_CLOUD_EMBEDDING_MODEL, + DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, + ) + .unwrap(); + assert_eq!(p.name(), "cloud"); + assert_eq!(p.dimensions(), DEFAULT_CLOUD_EMBEDDING_DIMENSIONS); + } + // ── Default provider ───────────────────────────────────── + #[test] + fn default_provider_uses_cloud() { + let p = default_embedding_provider(); + assert_eq!(p.name(), "cloud"); + assert_eq!(p.dimensions(), DEFAULT_CLOUD_EMBEDDING_DIMENSIONS); + } + #[test] fn default_local_provider_uses_ollama() { let p = default_local_embedding_provider(); diff --git a/src/openhuman/memory/mod.rs b/src/openhuman/memory/mod.rs index e860eca7a..55b2933c1 100644 --- a/src/openhuman/memory/mod.rs +++ b/src/openhuman/memory/mod.rs @@ -32,10 +32,11 @@ pub use schemas::{ all_registered_controllers as all_memory_registered_controllers, }; pub use store::{ - create_memory, create_memory_for_migration, create_memory_with_storage, - create_memory_with_storage_and_routes, effective_memory_backend_name, MemoryClient, - MemoryClientRef, MemoryItemKind, MemoryState, NamespaceDocumentInput, NamespaceMemoryHit, - NamespaceQueryResult, NamespaceRetrievalContext, RetrievalScoreBreakdown, UnifiedMemory, + create_memory, create_memory_for_migration, create_memory_with_local_ai, + create_memory_with_storage, create_memory_with_storage_and_routes, + effective_embedding_settings, effective_memory_backend_name, MemoryClient, MemoryClientRef, + MemoryItemKind, MemoryState, NamespaceDocumentInput, NamespaceMemoryHit, NamespaceQueryResult, + NamespaceRetrievalContext, RetrievalScoreBreakdown, UnifiedMemory, }; pub use sync_status::{ all_memory_sync_status_controller_schemas, all_memory_sync_status_registered_controllers, diff --git a/src/openhuman/memory/store/client.rs b/src/openhuman/memory/store/client.rs index edec3ddb3..fac8e108c 100644 --- a/src/openhuman/memory/store/client.rs +++ b/src/openhuman/memory/store/client.rs @@ -31,11 +31,21 @@ pub type MemoryClientRef = Arc; /// be initialized. pub struct MemoryState(pub std::sync::Mutex>); -/// Local-only memory client backed by SQLite in the user's workspace directory. +/// SQLite-backed memory client rooted at the user's workspace directory. /// -/// All memory storage and retrieval happens on-device; there is no remote sync. -/// Remote/cloud memory sync is a future consideration — until then the memory -/// subsystem operates entirely locally via [`UnifiedMemory`]. +/// Storage (documents, vectors, graph) remains on-device via [`UnifiedMemory`]. +/// Embedding generation is delegated to whichever provider the +/// [`MemoryConfig.embedding_provider`](crate::openhuman::config::MemoryConfig) +/// resolves to — cloud (OpenHuman backend, the default returned by +/// [`crate::openhuman::embeddings::default_embedding_provider`]) or local Ollama +/// when explicitly opted into. The cloud embedder resolves its session JWT +/// lazily, so an unauthenticated session will surface as a clear error on the +/// first `embed` call rather than at client construction. +/// +/// Callers that need a non-default embedder should construct the underlying +/// store via [`crate::openhuman::memory::create_memory_with_storage_and_routes`] +/// (or [`crate::openhuman::memory::create_memory_with_local_ai`]) with the +/// appropriate `MemoryConfig.embedding_provider`. #[derive(Clone)] pub struct MemoryClient { /// The underlying memory implementation. @@ -94,8 +104,14 @@ impl MemoryClient { std::fs::create_dir_all(&workspace_dir) .map_err(|e| format!("Create workspace dir {}: {e}", workspace_dir.display()))?; - // Initialize the default local embedding provider (Ollama). - let embedder: Arc = embeddings::default_local_embedding_provider(); + // Default to cloud embeddings (OpenHuman backend, Voyage-backed). The + // cloud embedder is lazy: JWT + API URL are resolved per call, so an + // unauthenticated session produces a clear error on first embed rather + // than blocking client construction. Callers that need the local + // Ollama path should build their memory store via + // `create_memory_with_storage_and_routes` with the appropriate + // `MemoryConfig.embedding_provider`. + let embedder: Arc = embeddings::default_embedding_provider(); // Create the underlying UnifiedMemory instance. let memory = diff --git a/src/openhuman/memory/store/factories.rs b/src/openhuman/memory/store/factories.rs index 79e333b5e..c6fd199b1 100644 --- a/src/openhuman/memory/store/factories.rs +++ b/src/openhuman/memory/store/factories.rs @@ -11,11 +11,49 @@ use std::path::Path; use std::sync::Arc; -use crate::openhuman::config::{EmbeddingRouteConfig, MemoryConfig, StorageProviderConfig}; -use crate::openhuman::embeddings::{self, EmbeddingProvider}; +use crate::openhuman::config::{ + EmbeddingRouteConfig, LocalAiConfig, MemoryConfig, StorageProviderConfig, +}; +use crate::openhuman::embeddings::{ + self, EmbeddingProvider, DEFAULT_OLLAMA_DIMENSIONS, DEFAULT_OLLAMA_MODEL, +}; use crate::openhuman::memory::store::unified::UnifiedMemory; use crate::openhuman::memory::traits::Memory; +/// Returns the effective `(provider, model, dimensions)` triple for the +/// embedding backend. +/// +/// The user-facing default is `"cloud"` (OpenHuman backend, Voyage-backed) so +/// fresh installs work without a local Ollama daemon. When the user has +/// explicitly opted into local AI for embeddings — +/// [`LocalAiConfig::use_local_for_embeddings`] — we route through the local +/// Ollama embedder regardless of what `memory.embedding_provider` says, since +/// that toggle is a stronger statement of intent than the per-section default. +pub fn effective_embedding_settings( + memory: &MemoryConfig, + local_ai: Option<&LocalAiConfig>, +) -> (String, String, usize) { + if local_ai + .map(LocalAiConfig::use_local_for_embeddings) + .unwrap_or(false) + { + // Trim once and reuse — the emptiness check and the final model + // string must agree, otherwise a value like " bge-m3 " would pass + // through to Ollama with surrounding whitespace and 404. + let model = local_ai + .map(|c| c.embedding_model_id.trim()) + .filter(|m| !m.is_empty()) + .unwrap_or(DEFAULT_OLLAMA_MODEL) + .to_string(); + return ("ollama".to_string(), model, DEFAULT_OLLAMA_DIMENSIONS); + } + ( + memory.embedding_provider.clone(), + memory.embedding_model.clone(), + memory.embedding_dimensions, + ) +} + /// Returns the effective name of the memory backend being used. /// /// Currently, this always returns "namespace" as the unified memory system @@ -41,27 +79,85 @@ pub fn create_memory_with_storage( storage_provider: Option<&StorageProviderConfig>, workspace_dir: &Path, ) -> anyhow::Result> { - create_memory_with_storage_and_routes(config, &[], storage_provider, workspace_dir) + create_memory_full(config, &[], storage_provider, None, workspace_dir) +} + +/// Create a memory instance honoring both the `memory` and `local_ai` sections. +/// +/// Used by top-level entry points (agent harness, channels runtime) that have +/// the full `Config` in scope and want the local-AI opt-in to flip the +/// embedder to Ollama. +pub fn create_memory_with_local_ai( + memory: &MemoryConfig, + local_ai: &LocalAiConfig, + embedding_routes: &[EmbeddingRouteConfig], + storage_provider: Option<&StorageProviderConfig>, + workspace_dir: &Path, +) -> anyhow::Result> { + create_memory_full( + memory, + embedding_routes, + storage_provider, + Some(local_ai), + workspace_dir, + ) +} + +/// Back-compat wrapper preserved for existing call sites that don't have a +/// `LocalAiConfig` to pass. The local-AI opt-in is not honored on this path — +/// use [`create_memory_with_local_ai`] when both sections are available. +pub fn create_memory_with_storage_and_routes( + config: &MemoryConfig, + embedding_routes: &[EmbeddingRouteConfig], + storage_provider: Option<&StorageProviderConfig>, + workspace_dir: &Path, +) -> anyhow::Result> { + create_memory_full( + config, + embedding_routes, + storage_provider, + None, + workspace_dir, + ) } /// The most comprehensive factory function for creating a memory instance. /// /// This function initializes the embedding provider and then creates a /// `UnifiedMemory` instance. -pub fn create_memory_with_storage_and_routes( +fn create_memory_full( config: &MemoryConfig, _embedding_routes: &[EmbeddingRouteConfig], _storage_provider: Option<&StorageProviderConfig>, + local_ai: Option<&LocalAiConfig>, workspace_dir: &Path, ) -> anyhow::Result> { - // 1. Create the embedding provider based on config (Local vs Remote). - let embedder: Arc = Arc::from(embeddings::create_embedding_provider( - &config.embedding_provider, - &config.embedding_model, - config.embedding_dimensions, - )?); + // 1. Resolve the effective (provider, model, dims) — local-AI opt-in + // overrides the per-section default when both are present. + let (provider, model, dims) = effective_embedding_settings(config, local_ai); + log::debug!( + "[memory::factory] effective embedding settings: provider={} model={} dims={} (local_ai_opt_in={})", + provider, + model, + dims, + local_ai + .map(LocalAiConfig::use_local_for_embeddings) + .unwrap_or(false), + ); - // 2. Instantiate UnifiedMemory which handles SQLite and vector storage. + // 2. Create the embedding provider. + let embedder: Arc = Arc::from( + embeddings::create_embedding_provider(&provider, &model, dims).inspect_err(|err| { + log::warn!( + "[memory::factory] create_embedding_provider failed provider={} model={} dims={}: {err}", + provider, + model, + dims, + ); + })?, + ); + + // 3. Instantiate UnifiedMemory which handles SQLite and vector storage. let mem = UnifiedMemory::new(workspace_dir, embedder, config.sqlite_open_timeout_secs)?; Ok(Box::new(mem)) } diff --git a/src/openhuman/memory/store/mod.rs b/src/openhuman/memory/store/mod.rs index 43753efdb..d8778dfae 100644 --- a/src/openhuman/memory/store/mod.rs +++ b/src/openhuman/memory/store/mod.rs @@ -25,8 +25,9 @@ mod memory_trait; pub use client::{MemoryClient, MemoryClientRef, MemoryState}; pub use factories::{ - create_memory, create_memory_for_migration, create_memory_with_storage, - create_memory_with_storage_and_routes, effective_memory_backend_name, + create_memory, create_memory_for_migration, create_memory_with_local_ai, + create_memory_with_storage, create_memory_with_storage_and_routes, + effective_embedding_settings, effective_memory_backend_name, }; pub use types::{ GraphRelationRecord, MemoryItemKind, MemoryKvRecord, NamespaceDocumentInput,