voxora_traits/source.rs
1//! The [`ModelSource`] trait and the value types that describe where a
2//! model lives on disk and how it was acquired.
3
4use std::path::PathBuf;
5
6use async_trait::async_trait;
7
8use crate::engine::ModelCapabilities;
9use crate::error::AsrError;
10
11/// A descriptor for a model that can be enumerated without downloading it.
12#[derive(Debug, Clone, PartialEq, Eq)]
13#[non_exhaustive]
14pub struct ModelDescriptor {
15 /// Identifier as the source would accept it back
16 /// (e.g. `Qwen/Qwen3-ASR-0.6B`).
17 pub id: String,
18
19 /// Human-readable name, if the source provides one.
20 pub display_name: Option<String>,
21
22 /// Reported capabilities, if the source can determine them without
23 /// downloading the weights.
24 pub capabilities: Option<ModelCapabilities>,
25}
26
27impl ModelDescriptor {
28 /// Build a descriptor with just an id (no display name, no
29 /// capabilities).
30 pub fn new(id: impl Into<String>) -> Self {
31 Self {
32 id: id.into(),
33 display_name: None,
34 capabilities: None,
35 }
36 }
37
38 /// Build a descriptor with id, display name, and capabilities.
39 pub fn with_details(
40 id: impl Into<String>,
41 display_name: Option<String>,
42 capabilities: Option<ModelCapabilities>,
43 ) -> Self {
44 Self {
45 id: id.into(),
46 display_name,
47 capabilities,
48 }
49 }
50}
51
52/// Where a resolved model lives on disk.
53#[derive(Debug, Clone, PartialEq, Eq)]
54#[non_exhaustive]
55pub struct ModelDir {
56 /// Root directory of the model on disk.
57 pub path: PathBuf,
58
59 /// Specific file inside `path` (e.g. `ggml-large-v3.bin` for
60 /// single-file HF requests). `None` for whole-repo directories
61 /// where the engine picks the right file from the directory
62 /// listing. Populated by `voxora-hf` for 3-segment model ids
63 /// (`org/repo/file`) starting in 0.1.2.
64 pub entry: Option<PathBuf>,
65
66 /// Which source provided this model.
67 pub kind: ModelSourceKind,
68
69 /// Concrete quantization this model was serialized in.
70 pub quantization: Quantization,
71}
72
73impl ModelDir {
74 /// Build a `ModelDir` from its four fields, with `entry` left as
75 /// `None`. Whole-repo resolvers should keep using this constructor
76 /// so the engine falls back to `locate_model_file`-style
77 /// directory scanning.
78 pub fn new(path: PathBuf, kind: ModelSourceKind, quantization: Quantization) -> Self {
79 Self {
80 path,
81 entry: None,
82 kind,
83 quantization,
84 }
85 }
86
87 /// Build a `ModelDir` with an explicit `entry` naming the specific
88 /// file inside `path`. Used by `voxora-hf` for 3-segment model ids
89 /// (`org/repo/file`) so the engine does not have to lex-sort a
90 /// multi-file directory and accidentally pick the wrong file.
91 pub fn with_entry(
92 path: PathBuf,
93 entry: PathBuf,
94 kind: ModelSourceKind,
95 quantization: Quantization,
96 ) -> Self {
97 Self {
98 path,
99 entry: Some(entry),
100 kind,
101 quantization,
102 }
103 }
104}
105
106/// Class of model provider.
107///
108/// `#[non_exhaustive]` so new sources can be added without breaking
109/// downstream `match` arms.
110#[derive(Debug, Clone, PartialEq, Eq)]
111#[non_exhaustive]
112pub enum ModelSourceKind {
113 /// A directory already on disk; no download required.
114 Local,
115 /// Hugging Face Hub.
116 HuggingFace,
117}
118
119impl ModelSourceKind {
120 /// Stable string tag (`"local"`, `"huggingface"`, …) for logging.
121 pub fn tag(&self) -> &'static str {
122 match self {
123 ModelSourceKind::Local => "local",
124 ModelSourceKind::HuggingFace => "huggingface",
125 }
126 }
127}
128
129/// Concrete quantization variants a model was serialized in.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131#[non_exhaustive]
132pub enum Quantization {
133 /// 32-bit IEEE float.
134 F32,
135 /// 16-bit brain float.
136 Bf16,
137 /// 16-bit IEEE float.
138 F16,
139 /// GGUF `Q4_K` (whisper.cpp).
140 Q4K,
141 /// GGUF `Q8_0` (whisper.cpp).
142 Q8_0,
143}
144
145/// Caller's preferred quantization when one is not otherwise specified.
146#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
147#[non_exhaustive]
148pub enum QuantizationPreference {
149 /// Let the source pick a sensible default.
150 #[default]
151 Auto,
152 /// Prefer 32-bit float if available.
153 F32,
154 /// Prefer 16-bit brain float if available.
155 Bf16,
156 /// Prefer 16-bit IEEE float if available.
157 F16,
158 /// Prefer GGUF `Q4_K` if available.
159 Q4K,
160 /// Prefer GGUF `Q8_0` if available.
161 Q8_0,
162}
163
164/// Options controlling how [`ModelSource::resolve`] acquires a model.
165#[derive(Debug, Clone, Default, PartialEq, Eq)]
166#[non_exhaustive]
167pub struct ResolveOptions {
168 /// Preferred quantization; the source may pick a different one if
169 /// the preferred is not available for this model.
170 pub quantization: QuantizationPreference,
171
172 /// Auth token, if the source requires one. For Hugging Face this
173 /// overrides the `HF_TOKEN` environment variable.
174 pub token: Option<String>,
175
176 /// Specific git revision (branch, tag, or SHA) to pin the model to.
177 pub revision: Option<String>,
178}
179
180impl ResolveOptions {
181 /// Construct a [`ResolveOptions`] with the given revision;
182 /// everything else is `Auto` / `None`.
183 pub fn with_revision(revision: impl Into<String>) -> Self {
184 Self {
185 revision: Some(revision.into()),
186 ..Self::default()
187 }
188 }
189
190 /// Construct a [`ResolveOptions`] with the given token;
191 /// everything else is `Auto` / `None`.
192 pub fn with_token(token: impl Into<String>) -> Self {
193 Self {
194 token: Some(token.into()),
195 ..Self::default()
196 }
197 }
198}
199
200/// A source of models (Hugging Face, a local directory, future registries).
201///
202/// Acquisition is inherently asynchronous (network downloads), so this
203/// trait uses `async_trait` even though [`crate::AsrEngine`] is
204/// sync. The trait requires `Send + Sync` so a `Box<dyn ModelSource>`
205/// can move across thread boundaries inside an HTTP server or CLI.
206#[async_trait]
207pub trait ModelSource: Send + Sync {
208 /// Short, stable identifier for this source
209 /// (`"huggingface"`, `"local"`, …).
210 fn name(&self) -> &'static str;
211
212 /// Resolve a model id (e.g. `Qwen/Qwen3-ASR-0.6B`) to a concrete
213 /// [`ModelDir`] on disk, downloading if necessary.
214 async fn resolve(&self, model_id: &str, opts: &ResolveOptions) -> Result<ModelDir, AsrError>;
215
216 /// Query a model's capabilities without downloading the weights.
217 async fn capabilities_for(&self, model_id: &str) -> Result<ModelCapabilities, AsrError>;
218
219 /// List models known to this source. Defaults to
220 /// [`AsrError::Unsupported`] because not every source can enumerate.
221 async fn list_available(&self) -> Result<Vec<ModelDescriptor>, AsrError> {
222 Err(AsrError::Unsupported("list_available"))
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 /// In-memory `ModelSource` used to verify the trait is usable
231 /// without network access. Exercises the default `list_available`.
232 ///
233 /// The async methods are intentionally not called from these tests
234 /// so we do not need an executor in the test suite — that keeps
235 /// `voxora-traits` free of `tokio` and `futures` per the Phase 1
236 /// "build offline" rule. Behaviour of `resolve` /
237 /// `capabilities_for` is covered in `voxora-hf` (Phase 2).
238 struct FakeSource;
239
240 #[async_trait]
241 impl ModelSource for FakeSource {
242 fn name(&self) -> &'static str {
243 "fake"
244 }
245
246 async fn resolve(
247 &self,
248 model_id: &str,
249 _opts: &ResolveOptions,
250 ) -> Result<ModelDir, AsrError> {
251 Ok(ModelDir {
252 path: PathBuf::from(format!("/cache/{model_id}")),
253 entry: None,
254 kind: ModelSourceKind::Local,
255 quantization: Quantization::F16,
256 })
257 }
258
259 async fn capabilities_for(&self, _model_id: &str) -> Result<ModelCapabilities, AsrError> {
260 Ok(ModelCapabilities::UNKNOWN)
261 }
262 }
263
264 #[test]
265 fn default_list_available_returns_unsupported_via_dispatch() {
266 // We exercise the default-method dispatch without awaiting: a
267 // `&dyn ModelSource` knows the vtable, and calling any async
268 // method through it returns a future. We only assert that the
269 // trait is constructible and `name()` works synchronously.
270 let src: &dyn ModelSource = &FakeSource;
271 assert_eq!(src.name(), "fake");
272 }
273
274 #[test]
275 fn quantization_preference_default_is_auto() {
276 assert_eq!(
277 QuantizationPreference::default(),
278 QuantizationPreference::Auto
279 );
280 }
281
282 #[test]
283 fn resolve_options_default_is_auto_no_token_no_revision() {
284 let opts = ResolveOptions::default();
285 assert_eq!(opts.quantization, QuantizationPreference::Auto);
286 assert!(opts.token.is_none());
287 assert!(opts.revision.is_none());
288 }
289
290 #[test]
291 fn resolve_options_implements_eq() {
292 let a = ResolveOptions {
293 quantization: QuantizationPreference::F16,
294 token: Some("tok".into()),
295 revision: Some("main".into()),
296 };
297 let b = ResolveOptions {
298 quantization: QuantizationPreference::F16,
299 token: Some("tok".into()),
300 revision: Some("main".into()),
301 };
302 let c = ResolveOptions {
303 quantization: QuantizationPreference::Q4K,
304 token: Some("tok".into()),
305 revision: Some("main".into()),
306 };
307 assert_eq!(a, b);
308 assert_ne!(a, c);
309 }
310
311 #[test]
312 fn quantization_is_copy_and_eq() {
313 let a = Quantization::F16;
314 let b = a;
315 assert_eq!(a, b);
316 assert_ne!(a, Quantization::Q4K);
317 }
318
319 #[test]
320 fn model_source_kind_implements_eq() {
321 assert_eq!(ModelSourceKind::Local, ModelSourceKind::Local);
322 assert_ne!(ModelSourceKind::Local, ModelSourceKind::HuggingFace);
323 }
324
325 #[test]
326 fn model_dir_new_has_no_entry() {
327 let dir = ModelDir::new(
328 PathBuf::from("/cache/foo"),
329 ModelSourceKind::Local,
330 Quantization::F16,
331 );
332 assert!(
333 dir.entry.is_none(),
334 "ModelDir::new must default entry to None"
335 );
336 }
337
338 #[test]
339 fn model_dir_with_entry_records_specific_file() {
340 let entry = PathBuf::from("/cache/foo/ggml-large-v3.bin");
341 let dir = ModelDir::with_entry(
342 PathBuf::from("/cache/foo"),
343 entry.clone(),
344 ModelSourceKind::HuggingFace,
345 Quantization::F16,
346 );
347 assert_eq!(dir.entry.as_ref(), Some(&entry));
348 }
349}