#[cfg(feature = "bundled-embedder")]
pub mod bundled;
pub mod ollama;
pub mod stub;
#[cfg(feature = "bundled-embedder")]
pub use bundled::{
BUNDLED_EMBEDDER_DIM, BUNDLED_EMBEDDER_NAME, BUNDLED_EMBEDDER_VERSION,
BundledEmbedder,
};
pub use ollama::{DEFAULT_OLLAMA_DIM, DEFAULT_OLLAMA_MODEL, OllamaEmbedder};
pub use stub::{STUB_EMBEDDER_NAME, StubEmbedder};
use crate::config::EmbedderConfig;
use solo_core::{Embedder, Error, Result};
const ENV_EMBEDDER_KIND: &str = "SOLO_EMBEDDER";
const ENV_OLLAMA_BASE_URL: &str = "SOLO_OLLAMA_BASE_URL";
const ENV_OLLAMA_EMBED_MODEL: &str = "SOLO_OLLAMA_EMBED_MODEL";
pub(crate) const ENV_EMBEDDER_KIND_BUNDLED: &str = "bundled";
const ENV_BGE_M3_DIR: &str = "SOLO_BGE_M3_DIR";
const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434";
const BGE_M3_REMOVED_MESSAGE: &str =
"BGE-M3 was removed in v0.6.0; use SOLO_EMBEDDER=ollama (see docs).";
const BGE_M3_DIR_IGNORED_MESSAGE: &str =
"SOLO_BGE_M3_DIR is set but BGE-M3 was removed in v0.6.0; ignored. \
Set SOLO_EMBEDDER=ollama to use Ollama embeddings.";
pub(crate) const BUNDLED_EMBEDDER_FEATURE_OFF_MESSAGE: &str =
"Bundled embedder requested but this binary was compiled \
without the `bundled-embedder` Cargo feature. \
This typically means you're on a musl Linux release shard \
(Microsoft does not publish musl-targeted ONNX Runtime \
prebuilts). Options:\n \
- Use Ollama: SOLO_EMBEDDER=ollama (see docs/releases/v0.9.0.md)\n \
- Use Stub (test only): leave SOLO_EMBEDDER unset\n \
- Build from source with --features bundled-embedder on a \
non-musl target.";
pub fn build_embedder_from_env() -> Result<Box<dyn Embedder>> {
let kind = std::env::var(ENV_EMBEDDER_KIND).ok();
match kind.as_deref() {
Some(ENV_EMBEDDER_KIND_BUNDLED) => build_bundled_from_env(),
Some("ollama") => build_ollama_from_env(),
Some("bge-m3") => Err(Error::invalid_input(BGE_M3_REMOVED_MESSAGE)),
Some(other) if !other.is_empty() => Err(Error::invalid_input(format!(
"Unknown {ENV_EMBEDDER_KIND} value: {other:?}. \
Expected: bundled, ollama. ({BGE_M3_REMOVED_MESSAGE})"
))),
_ => {
if std::env::var_os(ENV_BGE_M3_DIR).is_some() {
emit_bge_m3_dir_ignored_warning();
}
tracing::warn!(
"no SOLO_EMBEDDER configured — falling back to StubEmbedder \
(32-dim BLAKE3 hash). Cluster membership and recall will \
be NON-SEMANTIC. Set SOLO_EMBEDDER=bundled or =ollama for \
real semantic vectors; see docs/embedder-setup.md. \
Consolidation refuses to run with the stub unless \
SOLO_ALLOW_STUB_EMBEDDER=1 is set."
);
Ok(Box::new(StubEmbedder::default_stub()))
}
}
}
#[cfg(feature = "bundled-embedder")]
fn build_bundled_from_env() -> Result<Box<dyn Embedder>> {
Ok(Box::new(BundledEmbedder::new()))
}
#[cfg(not(feature = "bundled-embedder"))]
fn build_bundled_from_env() -> Result<Box<dyn Embedder>> {
Err(Error::invalid_input(BUNDLED_EMBEDDER_FEATURE_OFF_MESSAGE))
}
fn build_ollama_from_env() -> Result<Box<dyn Embedder>> {
let base_url = std::env::var(ENV_OLLAMA_BASE_URL)
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_OLLAMA_BASE_URL.to_string());
let model = std::env::var(ENV_OLLAMA_EMBED_MODEL)
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_OLLAMA_MODEL.to_string());
let embedder = OllamaEmbedder::new(base_url, model, DEFAULT_OLLAMA_DIM)?;
Ok(Box::new(embedder))
}
fn emit_bge_m3_dir_ignored_warning() {
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| {
eprintln!("{BGE_M3_DIR_IGNORED_MESSAGE}");
});
}
pub async fn probe_embedder_config_from_env() -> Result<EmbedderConfig> {
let kind = std::env::var(ENV_EMBEDDER_KIND).ok();
match kind.as_deref() {
Some(ENV_EMBEDDER_KIND_BUNDLED) => probe_bundled_config(),
Some("ollama") => probe_ollama_config_from_env().await,
Some("bge-m3") => Err(Error::invalid_input(BGE_M3_REMOVED_MESSAGE)),
Some(other) if !other.is_empty() => Err(Error::invalid_input(format!(
"Unknown {ENV_EMBEDDER_KIND} value: {other:?}. \
Expected: bundled, ollama. ({BGE_M3_REMOVED_MESSAGE})"
))),
_ => {
if std::env::var_os(ENV_BGE_M3_DIR).is_some() {
emit_bge_m3_dir_ignored_warning();
}
Ok(default_embedder_identity())
}
}
}
fn default_embedder_identity() -> EmbedderConfig {
#[cfg(feature = "bundled-embedder")]
{
bundled_identity()
}
#[cfg(not(feature = "bundled-embedder"))]
{
stub_identity()
}
}
#[cfg(feature = "bundled-embedder")]
fn probe_bundled_config() -> Result<EmbedderConfig> {
Ok(bundled_identity())
}
#[cfg(not(feature = "bundled-embedder"))]
fn probe_bundled_config() -> Result<EmbedderConfig> {
Err(Error::invalid_input(BUNDLED_EMBEDDER_FEATURE_OFF_MESSAGE))
}
#[cfg(feature = "bundled-embedder")]
pub(crate) fn bundled_identity() -> EmbedderConfig {
EmbedderConfig {
name: BUNDLED_EMBEDDER_NAME.to_string(),
version: BUNDLED_EMBEDDER_VERSION.to_string(),
dim: BUNDLED_EMBEDDER_DIM as u32,
dtype: "f32".into(),
}
}
async fn probe_ollama_config_from_env() -> Result<EmbedderConfig> {
let base_url = std::env::var(ENV_OLLAMA_BASE_URL)
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_OLLAMA_BASE_URL.to_string());
let model = std::env::var(ENV_OLLAMA_EMBED_MODEL)
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_OLLAMA_MODEL.to_string());
let probe = OllamaEmbedder::new(base_url.clone(), model.clone(), DEFAULT_OLLAMA_DIM)?;
let dim = probe.probe_dim().await.map_err(|e| {
Error::embedder(format!(
"Failed to probe the Ollama embedder's dimension at `solo init`.\n\
Tried to embed a sentinel via {base_url}/api/embeddings using model `{model}`.\n\
Upstream error: {e}\n\
\n\
Options:\n \
- Start Ollama: `ollama serve` (or check that the daemon is running)\n \
- Pull the model: `ollama pull {model}`\n \
- Point at a different base URL: SOLO_OLLAMA_BASE_URL=<url>\n \
- Use a different embedder: unset SOLO_EMBEDDER (falls back to stub).\n \
\n\
Then re-run `solo init`."
))
})?;
Ok(EmbedderConfig {
name: format!("ollama:{model}"),
version: "v1".into(),
dim: dim as u32,
dtype: "f32".into(),
})
}
fn stub_identity() -> EmbedderConfig {
let stub = StubEmbedder::default_stub();
EmbedderConfig {
name: stub.name().to_string(),
version: stub.version().to_string(),
dim: stub.dim() as u32,
dtype: "f32".into(),
}
}
#[cfg(test)]
mod build_from_env_tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
const ALL_VARS: &[&str] = &[
ENV_EMBEDDER_KIND,
ENV_OLLAMA_BASE_URL,
ENV_OLLAMA_EMBED_MODEL,
ENV_BGE_M3_DIR,
];
struct EnvGuard;
impl Drop for EnvGuard {
fn drop(&mut self) {
for k in ALL_VARS {
unsafe { std::env::remove_var(k) };
}
}
}
fn fresh_env() -> EnvGuard {
for k in ALL_VARS {
unsafe { std::env::remove_var(k) };
}
EnvGuard
}
fn expect_err(result: Result<Box<dyn Embedder>>, msg: &str) -> Error {
match result {
Ok(_) => panic!("expected Err: {msg}"),
Err(e) => e,
}
}
#[test]
fn env_falls_back_to_stub_when_nothing_set() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
let e = build_embedder_from_env().expect("nothing set → stub");
assert_eq!(e.name(), "stub");
assert_eq!(e.version(), "v1");
assert_eq!(e.dim(), 32);
}
#[test]
fn env_picks_ollama_when_kind_is_ollama() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
unsafe { std::env::set_var(ENV_EMBEDDER_KIND, "ollama") };
let e = build_embedder_from_env().expect("ollama branch");
assert_eq!(e.name(), "ollama:nomic-embed-text");
assert_eq!(e.dim(), DEFAULT_OLLAMA_DIM);
}
#[test]
fn env_ollama_uses_custom_base_url_and_model() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
unsafe {
std::env::set_var(ENV_EMBEDDER_KIND, "ollama");
std::env::set_var(ENV_OLLAMA_BASE_URL, "http://my-host:31000");
std::env::set_var(ENV_OLLAMA_EMBED_MODEL, "mxbai-embed-large");
};
let e = build_embedder_from_env().expect("custom ollama");
assert_eq!(e.name(), "ollama:mxbai-embed-large");
}
#[test]
fn env_ollama_empty_base_url_falls_back_to_default() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
unsafe {
std::env::set_var(ENV_EMBEDDER_KIND, "ollama");
std::env::set_var(ENV_OLLAMA_BASE_URL, "");
};
let e = build_embedder_from_env().expect("empty base url ok");
assert_eq!(e.name(), "ollama:nomic-embed-text");
}
#[test]
fn env_unknown_kind_errors_cleanly() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
unsafe { std::env::set_var(ENV_EMBEDDER_KIND, "bogus") };
let err = expect_err(
build_embedder_from_env(),
"unknown kind must error, not panic",
);
let msg = format!("{err}");
assert!(
msg.contains("bogus"),
"error should name the offending value: {msg}"
);
assert!(
msg.contains("ollama"),
"error should list the valid option: {msg}"
);
}
#[test]
fn env_explicit_bge_m3_errors_with_removal_message() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
unsafe { std::env::set_var(ENV_EMBEDDER_KIND, "bge-m3") };
let err = expect_err(
build_embedder_from_env(),
"SOLO_EMBEDDER=bge-m3 must error after v0.6.0",
);
let msg = format!("{err}");
assert!(
msg.contains("v0.6.0"),
"error should name the removal version: {msg}"
);
assert!(
msg.contains("ollama") || msg.contains("SOLO_EMBEDDER=ollama"),
"error should point at the Ollama migration path: {msg}"
);
}
#[test]
fn env_legacy_bge_m3_dir_emits_warning_and_falls_through() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
unsafe {
std::env::set_var(ENV_BGE_M3_DIR, "C:/some/old/bge-m3/dir")
};
let e = build_embedder_from_env()
.expect("legacy SOLO_BGE_M3_DIR should soft-warn, not error");
assert_eq!(e.name(), "stub");
assert_eq!(e.dim(), 32);
assert!(BGE_M3_DIR_IGNORED_MESSAGE.contains("v0.6.0"));
assert!(BGE_M3_DIR_IGNORED_MESSAGE.contains("ollama"));
}
#[test]
fn env_empty_kind_string_falls_through_to_legacy() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
unsafe { std::env::set_var(ENV_EMBEDDER_KIND, "") };
let e = build_embedder_from_env().expect("empty kind = unset");
assert_eq!(e.name(), "stub");
}
#[cfg(feature = "bundled-embedder")]
#[test]
fn env_picks_bundled_when_kind_is_bundled_and_feature_on() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
unsafe { std::env::set_var(ENV_EMBEDDER_KIND, "bundled") };
let e = build_embedder_from_env().expect("bundled branch builds");
assert_eq!(e.name(), "bundled:all-MiniLM-L6-v2");
assert_eq!(e.version(), "v1");
assert_eq!(e.dim(), 384);
}
#[cfg(not(feature = "bundled-embedder"))]
#[test]
fn env_bundled_kind_rejects_when_feature_off_with_release_notes_pointer() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
unsafe { std::env::set_var(ENV_EMBEDDER_KIND, "bundled") };
let err = expect_err(
build_embedder_from_env(),
"feature-off bundled request must error",
);
let msg = format!("{err}");
assert!(msg.contains("bundled-embedder"), "names the feature: {msg}");
assert!(msg.contains("musl"), "names the platform context: {msg}");
assert!(msg.contains("ollama"), "points at the alternative: {msg}");
}
#[test]
fn env_unknown_kind_error_lists_bundled_as_option() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
unsafe { std::env::set_var(ENV_EMBEDDER_KIND, "wat") };
let err = expect_err(
build_embedder_from_env(),
"unknown kind must error with both options listed",
);
let msg = format!("{err}");
assert!(msg.contains("bundled"), "missing bundled hint: {msg}");
assert!(msg.contains("ollama"), "missing ollama hint: {msg}");
}
}
#[cfg(test)]
mod probe_config_from_env_tests {
use super::*;
use std::sync::Mutex;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
static ENV_LOCK: Mutex<()> = Mutex::new(());
const ALL_VARS: &[&str] = &[
ENV_EMBEDDER_KIND,
ENV_OLLAMA_BASE_URL,
ENV_OLLAMA_EMBED_MODEL,
ENV_BGE_M3_DIR,
];
struct EnvGuard;
impl Drop for EnvGuard {
fn drop(&mut self) {
for k in ALL_VARS {
unsafe { std::env::remove_var(k) };
}
}
}
fn fresh_env() -> EnvGuard {
for k in ALL_VARS {
unsafe { std::env::remove_var(k) };
}
EnvGuard
}
fn fixture(dim: usize, seed: u32) -> Vec<f32> {
(0..dim)
.map(|i| ((seed.wrapping_add(i as u32)) as f32) * 1e-3)
.collect()
}
#[tokio::test]
async fn probes_ollama_dim_when_kind_is_ollama() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/embeddings"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"embedding": fixture(768, 1)
})))
.expect(1)
.mount(&server)
.await;
unsafe {
std::env::set_var(ENV_EMBEDDER_KIND, "ollama");
std::env::set_var(ENV_OLLAMA_BASE_URL, server.uri());
};
let cfg = probe_embedder_config_from_env()
.await
.expect("probe should succeed against mock");
assert_eq!(cfg.name, format!("ollama:{}", DEFAULT_OLLAMA_MODEL));
assert_eq!(cfg.version, "v1");
assert_eq!(cfg.dim, 768);
assert_eq!(cfg.dtype, "f32");
}
#[tokio::test]
async fn probe_picks_up_non_default_dim_from_mock_response() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/embeddings"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"embedding": fixture(1024, 99)
})))
.expect(1)
.mount(&server)
.await;
unsafe {
std::env::set_var(ENV_EMBEDDER_KIND, "ollama");
std::env::set_var(ENV_OLLAMA_BASE_URL, server.uri());
std::env::set_var(ENV_OLLAMA_EMBED_MODEL, "mxbai-embed-large");
};
let cfg = probe_embedder_config_from_env().await.expect("probe ok");
assert_eq!(cfg.name, "ollama:mxbai-embed-large");
assert_eq!(cfg.dim, 1024, "probed dim must override the 768 placeholder");
}
#[tokio::test]
async fn probe_fails_clearly_when_ollama_unreachable() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
unsafe {
std::env::set_var(ENV_EMBEDDER_KIND, "ollama");
std::env::set_var(ENV_OLLAMA_BASE_URL, "http://127.0.0.1:1");
};
let err = probe_embedder_config_from_env()
.await
.expect_err("unreachable Ollama must error");
let msg = format!("{err}");
assert!(
msg.contains("Failed to probe the Ollama embedder"),
"missing top-line context: {msg}"
);
assert!(
msg.contains("ollama serve") && msg.contains("ollama pull"),
"missing remediation hints: {msg}"
);
assert!(
msg.contains("SOLO_OLLAMA_BASE_URL"),
"missing alt-URL hint: {msg}"
);
assert!(
msg.contains("SOLO_EMBEDDER"),
"missing backend-switch hint: {msg}"
);
}
#[cfg(not(feature = "bundled-embedder"))]
#[tokio::test]
async fn returns_stub_identity_when_no_env_set_feature_off() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
let cfg = probe_embedder_config_from_env().await.expect("ok");
assert_eq!(cfg.name, "stub");
assert_eq!(cfg.version, "v1");
assert_eq!(cfg.dim, 32);
assert_eq!(cfg.dtype, "f32");
}
#[cfg(feature = "bundled-embedder")]
#[tokio::test]
async fn returns_bundled_identity_when_feature_on_and_no_env_set() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
let cfg = probe_embedder_config_from_env().await.expect("ok");
assert_eq!(cfg.name, "bundled:all-MiniLM-L6-v2");
assert_eq!(cfg.version, "v1");
assert_eq!(cfg.dim, 384);
assert_eq!(cfg.dtype, "f32");
}
#[tokio::test]
async fn probe_explicit_bge_m3_errors_with_removal_message() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
unsafe { std::env::set_var(ENV_EMBEDDER_KIND, "bge-m3") };
let err = probe_embedder_config_from_env()
.await
.expect_err("bge-m3 must error after v0.6.0");
let msg = format!("{err}");
assert!(msg.contains("v0.6.0"));
assert!(msg.contains("ollama"));
}
#[tokio::test]
async fn probe_legacy_bge_m3_dir_falls_through_to_default() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
unsafe {
std::env::set_var(ENV_BGE_M3_DIR, "C:/old/bge-m3-dir")
};
let cfg = probe_embedder_config_from_env().await.expect("ok");
#[cfg(feature = "bundled-embedder")]
{
assert_eq!(cfg.name, "bundled:all-MiniLM-L6-v2");
assert_eq!(cfg.dim, 384);
}
#[cfg(not(feature = "bundled-embedder"))]
{
assert_eq!(cfg.name, "stub");
assert_eq!(cfg.dim, 32);
}
}
#[tokio::test]
async fn unknown_kind_errors_with_options_listed() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
unsafe { std::env::set_var(ENV_EMBEDDER_KIND, "bogus") };
let err = probe_embedder_config_from_env()
.await
.expect_err("unknown kind must error");
let msg = format!("{err}");
assert!(msg.contains("bogus"));
assert!(msg.contains("ollama"));
}
#[cfg(feature = "bundled-embedder")]
#[tokio::test]
async fn probe_bundled_returns_static_identity() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
unsafe { std::env::set_var(ENV_EMBEDDER_KIND, "bundled") };
let cfg = probe_embedder_config_from_env()
.await
.expect("bundled probe should succeed without network");
assert_eq!(cfg.name, "bundled:all-MiniLM-L6-v2");
assert_eq!(cfg.version, "v1");
assert_eq!(cfg.dim, 384);
assert_eq!(cfg.dtype, "f32");
}
#[cfg(not(feature = "bundled-embedder"))]
#[tokio::test]
async fn probe_bundled_errors_when_feature_off() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_env();
unsafe { std::env::set_var(ENV_EMBEDDER_KIND, "bundled") };
let err = probe_embedder_config_from_env()
.await
.expect_err("feature-off probe must error");
let msg = format!("{err}");
assert!(msg.contains("bundled-embedder"), "names the feature: {msg}");
assert!(msg.contains("musl"), "names the platform context: {msg}");
}
}