Files
openhuman/tests/ollama_embeddings_fallback_e2e.rs
T

235 lines
9.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Integration tests for the Local Ollama embeddings health-gate to cloud
//! fallback (PR #1555).
//!
//! Covers three scenarios exercised via the public API of
//! `openhuman_core::openhuman::memory`:
//!
//! 1. Local embeddings enabled + Ollama unreachable → falls back to cloud
//! provider with the correct cloud model dimensions.
//! 2. Local embeddings enabled + Ollama healthy → stays on local provider.
//! 3. Local embeddings DISABLED → cloud settings unchanged
//! regardless of Ollama state.
//!
//! `probe_ollama_reachable` and the once-per-process health-gate latch are
//! `pub(crate)`-private; the tests drive the observable behaviour through
//! `effective_embedding_settings` (sync, for scenario 3) and
//! `effective_embedding_settings_probed` (async, for scenarios 12), both of
//! which are `pub` and re-exported at `openhuman_core::openhuman::memory`.
//!
//! Run with: `cargo test --test ollama_embeddings_fallback_e2e`
use std::net::SocketAddr;
use std::sync::{Mutex, OnceLock};
use axum::{routing::get, Json, Router};
use openhuman_core::openhuman::config::MemoryConfig;
use openhuman_core::openhuman::embeddings::{
DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, DEFAULT_CLOUD_EMBEDDING_MODEL, DEFAULT_OLLAMA_DIMENSIONS,
DEFAULT_OLLAMA_MODEL,
};
use openhuman_core::openhuman::memory_store::factories::{
effective_embedding_settings, effective_embedding_settings_probed,
};
// ── Env isolation ─────────────────────────────────────────────────────────────
/// Serialises all tests in this file: `OPENHUMAN_OLLAMA_BASE_URL` is a
/// process-global env var that the production code reads at call time, so
/// concurrent mutation across tests would produce non-deterministic results.
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|p| p.into_inner())
}
/// RAII guard: sets `OPENHUMAN_OLLAMA_BASE_URL` while the lock is held and
/// restores (or removes) the original value on drop.
struct OllamaUrlGuard {
_lock: std::sync::MutexGuard<'static, ()>,
prev: Option<String>,
}
impl OllamaUrlGuard {
fn set(url: &str) -> Self {
let lock = env_lock();
let prev = std::env::var("OPENHUMAN_OLLAMA_BASE_URL").ok();
// SAFETY: guarded by ENV_LOCK — no concurrent env mutation in this test binary.
unsafe { std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", url) };
Self { _lock: lock, prev }
}
}
impl Drop for OllamaUrlGuard {
fn drop(&mut self) {
// SAFETY: same guard justification as OllamaUrlGuard::set.
match self.prev.take() {
Some(v) => unsafe { std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", v) },
None => unsafe { std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL") },
}
}
}
// ── Mock Ollama helper ────────────────────────────────────────────────────────
/// Spawns a minimal Axum server that mimics the Ollama `/api/tags` endpoint
/// (200 OK + JSON body). Returns the base URL, e.g. `"http://127.0.0.1:NNNNN"`.
async fn start_mock_ollama_200() -> String {
let app = Router::new().route(
"/api/tags",
get(|| async { Json(serde_json::json!({ "models": [] })) }),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr: SocketAddr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://127.0.0.1:{}", addr.port())
}
// ── Scenario 1: opted-in, Ollama unreachable → cloud fallback ────────────────
/// Port 1 on loopback is always refused on all supported platforms.
const UNREACHABLE_URL: &str = "http://127.0.0.1:1";
/// Scenario 1: local embeddings enabled + Ollama unreachable.
///
/// Verifies:
/// - effective provider flips to `"cloud"`.
/// - cloud model and dimensions match the well-known defaults.
/// - the diagnostic branch is exercised (the gate fires at most once
/// per process, but the fallback outcome is observable every call).
#[tokio::test]
async fn local_embeddings_enabled_ollama_unreachable_falls_back_to_cloud() {
let _env = OllamaUrlGuard::set(UNREACHABLE_URL);
let mem = MemoryConfig::default();
// Pass the default Ollama model name as `local_embedding_model` —
// same as `Config::workload_local_model("embeddings")` would when the
// `local_ai.usage.embeddings` flag is set.
let local_model = DEFAULT_OLLAMA_MODEL;
let (provider, model, dims) =
effective_embedding_settings_probed(&mem, Some(local_model)).await;
assert_eq!(
provider, "cloud",
"opted-in local embeddings with unreachable Ollama must fall back to cloud provider"
);
assert_eq!(
model, DEFAULT_CLOUD_EMBEDDING_MODEL,
"fallback must use the canonical cloud embedding model"
);
assert_eq!(
dims, DEFAULT_CLOUD_EMBEDDING_DIMENSIONS,
"fallback dimensions must match the canonical cloud embedding dimensions"
);
}
// ── Scenario 2: opted-in, Ollama healthy → stays on local provider ───────────
/// Scenario 2: local embeddings enabled + Ollama daemon responds 200 OK.
///
/// Verifies:
/// - effective provider remains `"ollama"`.
/// - dimensions are the Ollama default (not the cloud default).
#[tokio::test]
async fn local_embeddings_enabled_ollama_healthy_stays_on_local_provider() {
let mock_url = start_mock_ollama_200().await;
let _env = OllamaUrlGuard::set(&mock_url);
let mem = MemoryConfig::default();
let local_model = DEFAULT_OLLAMA_MODEL;
let (provider, model, dims) =
effective_embedding_settings_probed(&mem, Some(local_model)).await;
assert_eq!(
provider, "ollama",
"healthy Ollama must keep the local provider; got provider={provider} model={model} dims={dims}"
);
assert_eq!(
dims, DEFAULT_OLLAMA_DIMENSIONS,
"local provider must use Ollama default dimensions, not cloud defaults"
);
assert_ne!(
provider, "cloud",
"healthy Ollama must not fall back to cloud"
);
}
// ── Scenario 3: local embeddings DISABLED → cloud unchanged ──────────────────
/// Scenario 3a: no local-AI opt-in → the probed function keeps cloud settings
/// without touching Ollama at all (the probe is skipped when intended provider
/// is already `"cloud"`).
#[tokio::test]
async fn local_embeddings_disabled_probed_keeps_cloud_settings() {
// We deliberately point the URL at an unreachable host to prove that the
// probe is never issued on this path — if it were, the test would still
// pass due to fallback, but using an obviously-bad URL makes the intent
// explicit: Ollama state is irrelevant when local embeddings are off.
let _env = OllamaUrlGuard::set(UNREACHABLE_URL);
let mem = MemoryConfig::default(); // embedding_provider = "cloud" by default
let (provider, _, _) = effective_embedding_settings_probed(&mem, None).await;
assert_eq!(
provider, "cloud",
"with no local-AI opt-in the probed variant must keep the cloud provider"
);
}
/// Scenario 3b: synchronous variant — `effective_embedding_settings` (the
/// *intended*, non-probed selection) also keeps the MemoryConfig values when
/// `local_embedding_model` is `None`, regardless of Ollama state.
#[test]
fn local_embeddings_disabled_sync_keeps_memory_config_settings() {
let mut mem = MemoryConfig::default();
mem.embedding_provider = "cloud".to_string();
mem.embedding_model = DEFAULT_CLOUD_EMBEDDING_MODEL.to_string();
mem.embedding_dimensions = DEFAULT_CLOUD_EMBEDDING_DIMENSIONS;
// None = local embeddings not opted in.
let (provider, model, dims) = effective_embedding_settings(&mem, None);
assert_eq!(
provider, "cloud",
"sync selection with no opt-in must honour MemoryConfig.embedding_provider"
);
assert_eq!(
model, DEFAULT_CLOUD_EMBEDDING_MODEL,
"sync selection must honour MemoryConfig.embedding_model"
);
assert_eq!(
dims, DEFAULT_CLOUD_EMBEDDING_DIMENSIONS,
"sync selection must honour MemoryConfig.embedding_dimensions"
);
}
/// Scenario 3c: Ollama health state is irrelevant when local embeddings are
/// disabled — even with a custom `MemoryConfig` that names a cloud-like
/// provider, the output must match the config as-is (no Ollama probe).
#[tokio::test]
async fn local_embeddings_disabled_custom_config_untouched() {
let _env = OllamaUrlGuard::set(UNREACHABLE_URL);
let mut mem = MemoryConfig::default();
mem.embedding_provider = "openai".to_string();
mem.embedding_model = "text-embedding-3-small".to_string();
mem.embedding_dimensions = 1536;
// local_embedding_model = None → probed variant must return the config as-is.
let (provider, model, dims) = effective_embedding_settings_probed(&mem, None).await;
assert_eq!(provider, "openai");
assert_eq!(model, "text-embedding-3-small");
assert_eq!(
dims, 1536,
"custom cloud dimensions must pass through unchanged"
);
}