use crate::config::SemantexConfig;
use crate::model::capabilities::backend_for;
use crate::model::manifest::{builtin_specs, load_user_manifest, merge, user_manifest_path};
use crate::model::spec::{ModelRole, ModelSpec, RoleData};
use crate::search::dense_backend::DenseBackendKind;
use anyhow::Result;
use std::path::Path;
pub struct ModelRegistry {
specs: Vec<ModelSpec>,
active_embedder_id: String,
active_reranker_id: String,
active_llm_id: String,
}
impl ModelRegistry {
pub fn from_config(config: &SemantexConfig, project_path: Option<&Path>) -> Result<Self> {
let user = match user_manifest_path(project_path) {
Some(path) => load_user_manifest(&path)?,
None => Vec::new(),
};
let specs = merge(builtin_specs(), user);
for s in &specs {
s.validate()?;
}
Ok(Self {
specs,
active_embedder_id: config.embedder.clone(),
active_reranker_id: config.reranker_model.clone(),
active_llm_id: config.llm_model.clone(),
})
}
pub fn resolve(&self, role: ModelRole, id: &str) -> Result<&ModelSpec> {
match self.specs.iter().find(|s| s.id == id) {
None => anyhow::bail!(
"no model `{id}` registered for role {role:?} \
(check `models.toml` and the {role:?} selection env var)"
),
Some(s) if s.role != role => {
anyhow::bail!("model `{id}` is registered as {:?}, not {role:?}", s.role)
}
Some(s) => Ok(s),
}
}
pub fn active_embedder(&self) -> Result<&ModelSpec> {
self.resolve(ModelRole::Embedder, &self.active_embedder_id)
}
pub fn active_reranker(&self) -> Result<&ModelSpec> {
self.resolve(ModelRole::Reranker, &self.active_reranker_id)
}
pub fn active_llm(&self) -> Result<Option<&ModelSpec>> {
if self.active_llm_id.trim().is_empty() {
return Ok(None);
}
self.resolve(ModelRole::Llm, &self.active_llm_id).map(Some)
}
pub fn embedder_backend_kind(&self) -> Result<DenseBackendKind> {
let spec = self.active_embedder()?;
match &spec.role_data {
RoleData::Embedder(_) => {}
_ => anyhow::bail!("active embedder `{}` is not an embedder spec", spec.id),
}
backend_for(&spec.capabilities)?.dense_kind()
}
pub fn resolve_dense_backend(
config: &SemantexConfig,
project_path: Option<&Path>,
) -> Result<DenseBackendKind> {
if let Some(kind) = DenseBackendKind::parse(&config.dense_backend) {
return Ok(kind);
}
let registry = Self::from_config(config, project_path)?;
registry.embedder_backend_kind()
}
pub fn active_embedder_fingerprint(&self) -> Result<String> {
let spec = self.active_embedder()?;
let RoleData::Embedder(data) = &spec.role_data else {
anyhow::bail!("active embedder `{}` is not an embedder spec", spec.id);
};
Ok(crate::model::EmbedderFingerprint::compute(
&spec.id,
data,
dense_context_enabled(),
))
}
pub fn resolve_embedder_fingerprint(
config: &SemantexConfig,
project_path: Option<&Path>,
) -> Result<String> {
Self::from_config(config, project_path)?.active_embedder_fingerprint()
}
}
#[must_use]
pub fn dense_context_enabled() -> bool {
std::env::var("SEMANTEX_DENSE_CONTEXT")
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
}
#[cfg(test)]
mod tests {
use super::*;
fn registry_from_builtins() -> ModelRegistry {
ModelRegistry::from_config(&SemantexConfig::default(), None).unwrap()
}
#[test]
fn resolves_active_embedder_default() {
let reg = registry_from_builtins();
let spec = reg.active_embedder().unwrap();
assert_eq!(spec.id, "lateon-colbert");
assert_eq!(spec.role, ModelRole::Embedder);
}
#[test]
fn active_embedder_backend_kind_is_colbert_plaid_for_default() {
let reg = registry_from_builtins();
assert_eq!(
reg.embedder_backend_kind().unwrap(),
DenseBackendKind::ColbertPlaid
);
}
#[test]
fn multi_vector_embedder_routes_to_colbert_plaid() {
let tmp = tempfile::TempDir::new().unwrap();
let project = tmp.path().join("proj");
let semantex_dir = project.join(".semantex");
std::fs::create_dir_all(&semantex_dir).unwrap();
std::fs::write(
semantex_dir.join("models.toml"),
r#"
[[model]]
id = "some-multivec"
role = "embedder"
[model.source]
kind = "hf"
repo = "example/some-multivec"
files = ["model.onnx", "tokenizer.json"]
[model.capabilities]
multi_vector = true
[model.embedder]
dims = 96
max_context = 512
query_prefix = ""
pooling = "late_interaction"
quant = "int8_symmetric"
"#,
)
.unwrap();
let cfg = SemantexConfig {
embedder: "some-multivec".to_string(),
..Default::default()
};
let reg = ModelRegistry::from_config(&cfg, Some(&project)).unwrap();
assert_eq!(
reg.embedder_backend_kind().unwrap(),
DenseBackendKind::ColbertPlaid,
"multi-vector embedder must route to the colbert-plaid backend"
);
}
#[test]
fn coderank_selection_routes_to_hnsw() {
let cfg = SemantexConfig {
embedder: "coderank-137m".to_string(),
..Default::default()
};
let reg = ModelRegistry::from_config(&cfg, None).unwrap();
assert_eq!(reg.active_embedder().unwrap().id, "coderank-137m");
assert_eq!(
reg.embedder_backend_kind().unwrap(),
DenseBackendKind::CoderankHnsw
);
}
#[test]
fn resolve_dense_backend_canonical_embedder_path() {
let cfg = SemantexConfig {
embedder: "coderank-137m".to_string(),
..Default::default()
};
assert_eq!(
ModelRegistry::resolve_dense_backend(&cfg, None).unwrap(),
DenseBackendKind::CoderankHnsw
);
}
#[test]
fn resolve_dense_backend_alias_parses_known_backend() {
let cfg = SemantexConfig {
dense_backend: "coderank-hnsw".to_string(),
..Default::default()
};
assert_eq!(
ModelRegistry::resolve_dense_backend(&cfg, None).unwrap(),
DenseBackendKind::CoderankHnsw
);
}
#[test]
fn resolve_dense_backend_unknown_alias_falls_through_to_embedder() {
let cfg = SemantexConfig {
embedder: "coderank-137m".to_string(),
dense_backend: "totally-made-up".to_string(),
..Default::default()
};
assert_eq!(
ModelRegistry::resolve_dense_backend(&cfg, None).unwrap(),
DenseBackendKind::CoderankHnsw
);
}
#[test]
fn resolve_dense_backend_colbert_plaid_alias_parses() {
let cfg = SemantexConfig {
dense_backend: "colbert-plaid".to_string(),
..Default::default()
};
assert_eq!(
ModelRegistry::resolve_dense_backend(&cfg, None).unwrap(),
DenseBackendKind::ColbertPlaid
);
}
#[test]
fn resolve_dense_backend_lateon_embedder_routes_to_colbert_plaid() {
assert_eq!(
SemantexConfig::default().dense_backend,
"",
"precondition: alias defaults empty"
);
let cfg = SemantexConfig {
embedder: "lateon-colbert".to_string(),
..Default::default()
};
assert_eq!(
ModelRegistry::resolve_dense_backend(&cfg, None).unwrap(),
DenseBackendKind::ColbertPlaid
);
}
#[test]
fn resolve_dense_backend_all_default_is_colbert_plaid() {
let cfg = SemantexConfig::default();
assert_eq!(
ModelRegistry::resolve_dense_backend(&cfg, None).unwrap(),
DenseBackendKind::ColbertPlaid
);
}
#[test]
fn resolves_active_reranker_default() {
let reg = registry_from_builtins();
assert_eq!(reg.active_reranker().unwrap().id, "bge-reranker-v2-m3");
}
#[test]
fn active_llm_is_none_by_default() {
let reg = registry_from_builtins();
assert!(
reg.active_llm().unwrap().is_none(),
"no LLM selected by default"
);
}
#[test]
fn unknown_active_id_errors_naming_the_id_and_role() {
let cfg = SemantexConfig {
embedder: "does-not-exist".to_string(),
..Default::default()
};
let reg = ModelRegistry::from_config(&cfg, None).unwrap();
let err = reg.active_embedder().expect_err("unknown id must error");
let msg = err.to_string();
assert!(msg.contains("does-not-exist"), "got: {msg}");
assert!(msg.contains("Embedder"), "got: {msg}");
}
#[test]
fn resolve_wrong_role_errors() {
let reg = registry_from_builtins();
let err = reg
.resolve(ModelRole::Embedder, "bge-reranker-v2-m3")
.unwrap_err();
assert!(err.to_string().contains("bge-reranker-v2-m3"));
}
#[cfg(feature = "llm")]
#[test]
fn llm_builtin_resolves_when_feature_on_and_selected() {
let cfg = SemantexConfig {
llm_model: "ollama-default".to_string(),
..Default::default()
};
let reg = ModelRegistry::from_config(&cfg, None).unwrap();
let spec = reg.active_llm().unwrap().expect("llm spec should resolve");
assert_eq!(spec.id, "ollama-default");
assert_eq!(spec.role, ModelRole::Llm);
}
}