1use crate::error::{Result, RuntimeError};
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13#[non_exhaustive]
14pub enum ModelTask {
15 Embed,
17 Rerank,
19 Generate,
21 Raw,
23 EmbedImage,
25 EmbedAudio,
27 EmbedMultimodal,
29 EmbedSparse,
31 EmbedMultiVector,
33 EmbedHybrid,
36 Nlp,
38 DocumentExtract,
40 Transcribe,
42 Ocr,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
48#[serde(rename_all = "snake_case")]
49pub enum WarmupPolicy {
50 Eager,
53 #[default]
55 Lazy,
56 Background,
59}
60
61impl std::fmt::Display for WarmupPolicy {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 match self {
64 Self::Eager => write!(f, "eager"),
65 Self::Lazy => write!(f, "lazy"),
66 Self::Background => write!(f, "background"),
67 }
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct ModelAliasSpec {
90 pub alias: String,
93 pub task: ModelTask,
95 pub provider_id: String,
98 pub model_id: String,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub revision: Option<String>,
104 #[serde(default)]
106 pub warmup: WarmupPolicy,
107 #[serde(default)]
109 pub required: bool,
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub timeout: Option<u64>,
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub load_timeout: Option<u64>,
116 #[serde(skip_serializing_if = "Option::is_none")]
118 pub retry: Option<RetryConfig>,
119 #[serde(default)]
122 pub options: serde_json::Value,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct RetryConfig {
128 pub max_attempts: u32,
130 pub initial_backoff_ms: u64,
132}
133
134impl RetryConfig {
135 pub fn get_backoff(&self, attempt: u32) -> std::time::Duration {
139 std::time::Duration::from_millis(
140 self.initial_backoff_ms * 2u64.pow(attempt.saturating_sub(1)),
141 )
142 }
143}
144
145impl Default for RetryConfig {
146 fn default() -> Self {
147 Self {
148 max_attempts: 3,
149 initial_backoff_ms: 100,
150 }
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Hash)]
158pub struct ModelRuntimeKey {
159 pub task: ModelTask,
161 pub provider_id: String,
163 pub model_id: String,
165 pub revision: Option<String>,
167 pub variant_hash: u64,
171}
172
173impl ModelRuntimeKey {
174 pub fn new(spec: &ModelAliasSpec) -> Self {
177 let mut hasher = std::collections::hash_map::DefaultHasher::new();
178 use std::hash::Hasher;
179
180 hash_json_value(&spec.options, &mut hasher);
184
185 Self {
186 task: spec.task,
187 provider_id: spec.provider_id.clone(),
188 model_id: spec.model_id.clone(),
189 revision: spec.revision.clone(),
190 variant_hash: hasher.finish(),
191 }
192 }
193}
194
195fn hash_json_value<H: std::hash::Hasher>(value: &serde_json::Value, hasher: &mut H) {
202 use std::hash::Hash;
203
204 match value {
205 serde_json::Value::Null => {
206 0u8.hash(hasher);
207 }
208 serde_json::Value::Bool(v) => {
209 1u8.hash(hasher);
210 v.hash(hasher);
211 }
212 serde_json::Value::Number(v) => {
213 2u8.hash(hasher);
214 v.to_string().hash(hasher);
215 }
216 serde_json::Value::String(v) => {
217 3u8.hash(hasher);
218 v.hash(hasher);
219 }
220 serde_json::Value::Array(values) => {
221 4u8.hash(hasher);
222 values.len().hash(hasher);
223 for v in values {
224 hash_json_value(v, hasher);
225 }
226 }
227 serde_json::Value::Object(map) => {
228 5u8.hash(hasher);
229 map.len().hash(hasher);
230
231 let mut entries: Vec<_> = map.iter().collect();
232 entries.sort_by_key(|(k, _)| *k);
233 for (k, v) in entries {
234 k.hash(hasher);
235 hash_json_value(v, hasher);
236 }
237 }
238 }
239}
240
241impl ModelAliasSpec {
242 pub fn validate(&self) -> Result<()> {
245 if self.alias.is_empty() {
246 return Err(RuntimeError::Config("Alias cannot be empty".to_string()));
247 }
248 if !self.alias.contains('/') {
249 return Err(RuntimeError::Config(format!(
250 "Alias '{}' must be in 'task/name' format",
251 self.alias
252 )));
253 }
254 if self.timeout == Some(0) {
255 return Err(RuntimeError::Config(
256 "Inference timeout must be greater than 0".to_string(),
257 ));
258 }
259 if self.load_timeout == Some(0) {
260 return Err(RuntimeError::Config(
261 "Load timeout must be greater than 0".to_string(),
262 ));
263 }
264 Ok(())
265 }
266
267 pub fn from_json(value: serde_json::Value) -> Result<Self> {
269 let spec: Self = serde_json::from_value(value)
270 .map_err(|e| RuntimeError::Config(format!("Invalid ModelAliasSpec JSON: {}", e)))?;
271 spec.validate()?;
272 Ok(spec)
273 }
274
275 pub fn from_json_str(s: &str) -> Result<Self> {
277 let spec: Self = serde_json::from_str(s)
278 .map_err(|e| RuntimeError::Config(format!("Invalid ModelAliasSpec JSON: {}", e)))?;
279 spec.validate()?;
280 Ok(spec)
281 }
282}
283
284pub fn catalog_from_str(s: &str) -> Result<Vec<ModelAliasSpec>> {
286 let specs: Vec<ModelAliasSpec> = serde_json::from_str(s)
287 .map_err(|e| RuntimeError::Config(format!("Invalid catalog JSON: {}", e)))?;
288 for spec in &specs {
289 spec.validate()?;
290 }
291 Ok(specs)
292}
293
294pub fn catalog_from_file(path: impl AsRef<Path>) -> Result<Vec<ModelAliasSpec>> {
298 let path = path.as_ref();
299 let contents = std::fs::read_to_string(path).map_err(|e| {
300 RuntimeError::Config(format!(
301 "Failed to read catalog file '{}': {}",
302 path.display(),
303 e
304 ))
305 })?;
306 catalog_from_str(&contents)
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use serde_json::json;
313
314 const VALID_JSON: &str = r#"{
315 "alias": "embed/default",
316 "task": "embed",
317 "provider_id": "local/candle",
318 "model_id": "sentence-transformers/all-MiniLM-L6-v2"
319 }"#;
320
321 const VALID_CATALOG_JSON: &str = r#"[
322 {
323 "alias": "embed/default",
324 "task": "embed",
325 "provider_id": "local/candle",
326 "model_id": "sentence-transformers/all-MiniLM-L6-v2"
327 },
328 {
329 "alias": "chat/fast",
330 "task": "generate",
331 "provider_id": "local/mistralrs",
332 "model_id": "mistralai/Mistral-7B-v0.1",
333 "warmup": "background",
334 "required": false,
335 "options": { "isq": "Q4K" }
336 }
337 ]"#;
338
339 #[test]
340 fn from_json_str_parses_valid_spec() {
341 let spec = ModelAliasSpec::from_json_str(VALID_JSON).unwrap();
342 assert_eq!(spec.alias, "embed/default");
343 assert_eq!(spec.task, ModelTask::Embed);
344 assert_eq!(spec.provider_id, "local/candle");
345 assert_eq!(spec.warmup, WarmupPolicy::Lazy); assert!(!spec.required); }
348
349 #[test]
350 fn from_json_value_parses_valid_spec() {
351 let value = json!({
352 "alias": "embed/fast",
353 "task": "embed",
354 "provider_id": "local/onnx",
355 "model_id": "BAAI/bge-small-en-v1.5",
356 "required": true,
357 "warmup": "eager"
358 });
359 let spec = ModelAliasSpec::from_json(value).unwrap();
360 assert_eq!(spec.alias, "embed/fast");
361 assert_eq!(spec.warmup, WarmupPolicy::Eager);
362 assert!(spec.required);
363 }
364
365 #[test]
366 fn from_json_str_rejects_missing_slash_in_alias() {
367 let json = r#"{"alias":"noSlash","task":"embed","provider_id":"x","model_id":"y"}"#;
368 assert!(ModelAliasSpec::from_json_str(json).is_err());
369 }
370
371 #[test]
372 fn from_json_str_rejects_invalid_json() {
373 assert!(ModelAliasSpec::from_json_str("{not valid}").is_err());
374 }
375
376 #[test]
377 fn catalog_from_str_parses_array() {
378 let specs = catalog_from_str(VALID_CATALOG_JSON).unwrap();
379 assert_eq!(specs.len(), 2);
380 assert_eq!(specs[0].alias, "embed/default");
381 assert_eq!(specs[1].alias, "chat/fast");
382 assert_eq!(specs[1].options["isq"], "Q4K");
383 }
384
385 #[test]
386 fn catalog_from_str_rejects_invalid_spec() {
387 let json = r#"[{"alias":"bad","task":"embed","provider_id":"x","model_id":"y"}]"#;
388 assert!(catalog_from_str(json).is_err()); }
390
391 #[test]
392 fn catalog_from_file_reads_and_parses() {
393 let dir = std::env::temp_dir();
394 let path = dir.join("test_catalog.json");
395 std::fs::write(&path, VALID_CATALOG_JSON).unwrap();
396 let specs = catalog_from_file(&path).unwrap();
397 assert_eq!(specs.len(), 2);
398 std::fs::remove_file(&path).unwrap();
399 }
400
401 #[test]
402 fn catalog_from_file_errors_on_missing_file() {
403 assert!(catalog_from_file("/nonexistent/path/catalog.json").is_err());
404 }
405
406 #[test]
407 fn runtime_key_distinguishes_non_object_options() {
408 let mut spec_null = ModelAliasSpec::from_json_str(VALID_JSON).unwrap();
409 spec_null.options = serde_json::Value::Null;
410
411 let mut spec_bool = spec_null.clone();
412 spec_bool.options = json!(true);
413
414 let mut spec_array = spec_null.clone();
415 spec_array.options = json!(["a", 1]);
416
417 let key_null = ModelRuntimeKey::new(&spec_null);
418 let key_bool = ModelRuntimeKey::new(&spec_bool);
419 let key_array = ModelRuntimeKey::new(&spec_array);
420
421 assert_ne!(key_null, key_bool);
422 assert_ne!(key_null, key_array);
423 assert_ne!(key_bool, key_array);
424 }
425
426 #[test]
427 fn model_task_serde_round_trip_for_all_variants() {
428 let cases: &[(ModelTask, &str)] = &[
432 (ModelTask::Embed, "\"embed\""),
433 (ModelTask::Rerank, "\"rerank\""),
434 (ModelTask::Generate, "\"generate\""),
435 (ModelTask::Raw, "\"raw\""),
436 (ModelTask::EmbedImage, "\"embed_image\""),
437 (ModelTask::EmbedAudio, "\"embed_audio\""),
438 (ModelTask::EmbedMultimodal, "\"embed_multimodal\""),
439 (ModelTask::EmbedSparse, "\"embed_sparse\""),
440 (ModelTask::EmbedMultiVector, "\"embed_multi_vector\""),
441 (ModelTask::EmbedHybrid, "\"embed_hybrid\""),
442 (ModelTask::Nlp, "\"nlp\""),
443 (ModelTask::DocumentExtract, "\"document_extract\""),
444 (ModelTask::Transcribe, "\"transcribe\""),
445 (ModelTask::Ocr, "\"ocr\""),
446 ];
447 for (variant, wire) in cases {
448 let serialized = serde_json::to_string(variant).unwrap();
449 assert_eq!(&serialized, wire, "serialize {:?}", variant);
450 let parsed: ModelTask = serde_json::from_str(wire).unwrap();
451 assert_eq!(&parsed, variant, "deserialize {}", wire);
452 }
453 }
454
455 #[test]
456 fn runtime_key_nested_option_order_independence() {
457 let mut spec1 = ModelAliasSpec::from_json_str(VALID_JSON).unwrap();
458 spec1.options = json!({
459 "outer": {
460 "b": [3, 2, 1],
461 "a": {"y": 2, "x": 1}
462 }
463 });
464
465 let mut spec2 = ModelAliasSpec::from_json_str(VALID_JSON).unwrap();
466 spec2.options = json!({
467 "outer": {
468 "a": {"x": 1, "y": 2},
469 "b": [3, 2, 1]
470 }
471 });
472
473 let key1 = ModelRuntimeKey::new(&spec1);
474 let key2 = ModelRuntimeKey::new(&spec2);
475 assert_eq!(key1, key2);
476 }
477}