pub mod bge_m3;
pub mod bge_m3_inference;
pub mod ollama;
pub mod stub;
pub use bge_m3::{BgeM3Config, BgeM3Loader, BgeM3Manifest};
pub use bge_m3_inference::BgeM3Inference;
pub use ollama::{DEFAULT_OLLAMA_DIM, DEFAULT_OLLAMA_MODEL, OllamaEmbedder};
pub use stub::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";
const ENV_BGE_M3_DIR: &str = "SOLO_BGE_M3_DIR";
const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434";
pub const BGE_M3_DEPRECATION_WARNING: &str = "\
WARNING: the BGE-M3 embedder is deprecated and will be removed in v0.6.0.\n\
\n\
The recommended replacement is OllamaEmbedder, landing in v0.5.1.\n\
Once v0.5.1 ships you will be able to switch with:\n\
\n\
ollama pull nomic-embed-text\n\
export SOLO_EMBEDDER=ollama\n\
export SOLO_OLLAMA_EMBED_MODEL=nomic-embed-text\n\
\n\
(SOLO_EMBEDDER and SOLO_OLLAMA_EMBED_MODEL do not exist yet in v0.5.0\n\
— they ship with the OllamaEmbedder backend in v0.5.1.)\n\
\n\
See: https://github.com/CallMeJones/solo/issues/1";
pub fn build_embedder_from_env() -> Result<Box<dyn Embedder>> {
let kind = std::env::var(ENV_EMBEDDER_KIND).ok();
match kind.as_deref() {
Some("ollama") => build_ollama_from_env(),
Some("bge-m3") => {
emit_bge_m3_deprecation();
build_bge_m3_from_env()
}
Some(other) if !other.is_empty() => Err(Error::invalid_input(format!(
"Unknown {ENV_EMBEDDER_KIND} value: {other:?}. \
Expected one of: ollama, bge-m3."
))),
_ => {
if std::env::var_os(ENV_BGE_M3_DIR).is_some() {
emit_bge_m3_deprecation();
build_bge_m3_from_env()
} else {
Ok(Box::new(StubEmbedder::default_stub()))
}
}
}
}
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 build_bge_m3_from_env() -> Result<Box<dyn Embedder>> {
let dir = std::env::var(ENV_BGE_M3_DIR).map_err(|_| {
Error::invalid_input(format!(
"{ENV_BGE_M3_DIR} not set — required for SOLO_EMBEDDER=bge-m3. \
Point it at a HuggingFace BAAI/bge-m3 model directory \
(config.json + tokenizer.json + model.safetensors)."
))
})?;
let loader = BgeM3Loader::open(&dir)?;
loader.into_embedder()
}
fn emit_bge_m3_deprecation() {
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| {
eprintln!("{BGE_M3_DEPRECATION_WARNING}");
});
}
pub async fn probe_embedder_config_from_env() -> Result<EmbedderConfig> {
let kind = std::env::var(ENV_EMBEDDER_KIND).ok();
match kind.as_deref() {
Some("ollama") => probe_ollama_config_from_env().await,
Some("bge-m3") => {
emit_bge_m3_deprecation();
Ok(bge_m3_identity())
}
Some(other) if !other.is_empty() => Err(Error::invalid_input(format!(
"Unknown {ENV_EMBEDDER_KIND} value: {other:?}. \
Expected one of: ollama, bge-m3."
))),
_ => {
if std::env::var_os(ENV_BGE_M3_DIR).is_some() {
emit_bge_m3_deprecation();
Ok(bge_m3_identity())
} else {
Ok(stub_identity())
}
}
}
}
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) \
or point SOLO_BGE_M3_DIR at a local model directory.\n \
\n\
Then re-run `solo init`."
))
})?;
Ok(EmbedderConfig {
name: format!("ollama:{model}"),
version: "v1".into(),
dim: dim as u32,
dtype: "f32".into(),
})
}
fn bge_m3_identity() -> EmbedderConfig {
EmbedderConfig {
name: crate::embedder::bge_m3::BGE_M3_NAME.into(),
version: crate::embedder::bge_m3::BGE_M3_VERSION.into(),
dim: crate::embedder::bge_m3::BGE_M3_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") && msg.contains("bge-m3"),
"error should list valid options: {msg}"
);
}
#[test]
fn env_bge_m3_explicit_requires_dir() {
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(),
"bge-m3 without SOLO_BGE_M3_DIR must error",
);
let msg = format!("{err}");
assert!(
msg.contains(ENV_BGE_M3_DIR),
"error should mention SOLO_BGE_M3_DIR: {msg}"
);
}
#[test]
fn env_bge_m3_branch_selected_when_dir_set_and_no_kind() {
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:/definitely/not/a/real/path/xyz123") };
let err = expect_err(
build_embedder_from_env(),
"bad BGE-M3 dir must error",
);
let msg = format!("{err}");
assert!(
msg.to_ascii_lowercase().contains("bge-m3"),
"error should be BGE-M3-flavoured (branch selected): {msg}"
);
}
#[test]
fn env_bge_m3_explicit_branch_selected_when_dir_set_to_bad_path() {
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");
std::env::set_var(ENV_BGE_M3_DIR, "C:/definitely/not/a/real/path/xyz123");
};
let err = expect_err(
build_embedder_from_env(),
"bad BGE-M3 dir under explicit kind must error",
);
let msg = format!("{err}");
assert!(
msg.to_ascii_lowercase().contains("bge-m3"),
"error should be BGE-M3-flavoured: {msg}"
);
}
#[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(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_BGE_M3_DIR") || msg.contains("SOLO_EMBEDDER"),
"missing backend-switch hint: {msg}"
);
}
#[tokio::test]
async fn returns_stub_identity_when_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, "stub");
assert_eq!(cfg.version, "v1");
assert_eq!(cfg.dim, 32);
assert_eq!(cfg.dtype, "f32");
}
#[tokio::test]
async fn returns_bge_m3_identity_when_bge_m3_dir_set() {
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:/not/a/real/model/dir") };
let cfg = probe_embedder_config_from_env().await.expect("ok");
assert_eq!(cfg.name, "BAAI/bge-m3");
assert_eq!(cfg.version, "v1");
assert_eq!(cfg.dim, 1024);
assert_eq!(cfg.dtype, "f32");
}
#[tokio::test]
async fn returns_bge_m3_identity_when_kind_is_explicit_bge_m3() {
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 cfg = probe_embedder_config_from_env().await.expect("ok");
assert_eq!(cfg.name, "BAAI/bge-m3");
assert_eq!(cfg.dim, 1024);
}
#[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") && msg.contains("bge-m3"));
}
}