use std::sync::Arc;
use voxora_traits::{ModelDir, ModelSource, ResolveOptions};
use crate::descriptor::EngineDescriptor;
use crate::error::RegistryError;
use crate::id::ModelId;
#[derive(Debug, Clone)]
pub struct ResolvedModel {
pub descriptor: EngineDescriptor,
pub model_dir: ModelDir,
}
pub struct Registry {
descriptors: Vec<EngineDescriptor>,
source: Arc<dyn ModelSource>,
}
impl Registry {
pub fn new(source: Arc<dyn ModelSource>) -> Self {
Self {
descriptors: Vec::new(),
source,
}
}
pub fn register(mut self, descriptor: EngineDescriptor) -> Self {
self.descriptors.push(descriptor);
self
}
pub fn descriptors(&self) -> &[EngineDescriptor] {
&self.descriptors
}
pub(crate) fn descriptors_mut(&mut self) -> &mut Vec<EngineDescriptor> {
&mut self.descriptors
}
pub fn with_source(mut self, source: Arc<dyn ModelSource>) -> Self {
self.source = source;
self
}
pub async fn resolve(
&self,
id: &ModelId,
opts: &ResolveOptions,
) -> Result<ResolvedModel, RegistryError> {
let descriptor = self
.descriptors
.iter()
.find(|d| (d.accepts)(id))
.cloned()
.ok_or_else(|| RegistryError::NoMatchingDescriptor(id.canonical()))?;
let canonical = id.canonical();
let model_dir = self
.source
.resolve(&canonical, opts)
.await
.map_err(|e| RegistryError::Parse(format!("source resolve: {e}")))?;
if id.path.is_some() && model_dir.entry.is_none() {
return Err(RegistryError::MissingModelFile(model_dir.path.clone()));
}
Ok(ResolvedModel {
descriptor,
model_dir,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::id::SourceKind;
use async_trait::async_trait;
use voxora_engine::EngineFamily;
use voxora_traits::{AsrError, ModelCapabilities, ModelDir, ModelSourceKind, Quantization};
struct EchoSource;
#[async_trait]
impl ModelSource for EchoSource {
fn name(&self) -> &'static str {
"echo"
}
async fn resolve(
&self,
model_id: &str,
_opts: &ResolveOptions,
) -> Result<ModelDir, AsrError> {
Ok(ModelDir::with_entry(
std::path::PathBuf::from(format!("/cache/{model_id}")),
std::path::PathBuf::from(format!("/cache/{model_id}/model.bin")),
ModelSourceKind::Local,
Quantization::F16,
))
}
async fn capabilities_for(&self, _model_id: &str) -> Result<ModelCapabilities, AsrError> {
Ok(ModelCapabilities::UNKNOWN)
}
}
fn whisper_desc() -> EngineDescriptor {
EngineDescriptor::new(
EngineFamily::Whisper,
"whisper",
|id| {
matches!(id.source, SourceKind::HuggingFace)
&& id.repo.starts_with("ggerganov/whisper.cpp")
},
ModelCapabilities::UNKNOWN,
)
}
#[tokio::test]
async fn resolve_picks_first_matching_descriptor() {
let registry = Registry::new(Arc::new(EchoSource)).register(whisper_desc());
let id = ModelId::parse("ggerganov/whisper.cpp/ggml-tiny.bin").unwrap();
let resolved = registry
.resolve(&id, &ResolveOptions::default())
.await
.expect("resolve");
assert_eq!(resolved.descriptor.family, EngineFamily::Whisper);
assert!(resolved.model_dir.entry.is_some());
}
#[tokio::test]
async fn resolve_errors_when_no_descriptor_matches() {
let registry = Registry::new(Arc::new(EchoSource)).register(whisper_desc());
let id = ModelId::parse("Qwen/Qwen3-ASR-0.6B").unwrap();
let err = registry
.resolve(&id, &ResolveOptions::default())
.await
.expect_err("no match");
assert!(matches!(err, RegistryError::NoMatchingDescriptor(_)));
}
}