1use crate::engine::{Engine, EngineCapabilities};
18use crate::types::*;
19use anyhow::{bail, Result};
20use std::collections::BTreeMap;
21use tracing::{debug, warn};
22
23const TRACE_TARGET: &str = "studio_worker::engine::multi";
26
27pub struct MultiEngine {
28 engines: Vec<Box<dyn Engine>>,
29}
30
31impl MultiEngine {
32 pub fn new(engines: Vec<Box<dyn Engine>>) -> Self {
33 Self { engines }
34 }
35
36 fn pick_for(&self, kind: TaskKind, model: &str) -> Option<&dyn Engine> {
42 for e in &self.engines {
43 if e.capabilities().supports(kind, model) {
44 debug!(
45 target: TRACE_TARGET,
46 op = "pick",
47 kind = kind.as_str(),
48 model,
49 sub_engine = e.name(),
50 r#match = "exact",
51 "engine selected"
52 );
53 return Some(e.as_ref());
54 }
55 }
56 warn!(
57 target: TRACE_TARGET,
58 op = "pick",
59 kind = kind.as_str(),
60 model,
61 "no engine claims this exact (kind, model) pair"
62 );
63 None
64 }
65}
66
67impl Engine for MultiEngine {
68 fn name(&self) -> &'static str {
69 "multi"
70 }
71
72 fn capabilities(&self) -> EngineCapabilities {
73 let mut map: BTreeMap<TaskKind, Vec<String>> = BTreeMap::new();
74 for e in &self.engines {
75 for (kind, models) in e.capabilities().supported_models_per_kind {
76 let entry = map.entry(kind).or_default();
77 for m in models {
78 if !entry.contains(&m) {
79 entry.push(m);
80 }
81 }
82 }
83 }
84 EngineCapabilities {
85 supported_models_per_kind: map,
86 }
87 }
88
89 fn dispatch(&self, model: &str, task: Task) -> Result<TaskResult> {
90 let kind = task.kind();
91 let Some(engine) = self.pick_for(kind, model) else {
92 bail!(
93 "no engine on this worker can serve model {} (kind={}); \
94 synthetic fallback is disabled",
95 model,
96 kind.as_str()
97 );
98 };
99 engine.dispatch(model, task)
100 }
101
102 fn dispatch_with_source(
103 &self,
104 model: &str,
105 task: Task,
106 source: &crate::types::ModelSource,
107 ) -> Result<TaskResult> {
108 let kind = task.kind();
109 let wanted = match source.engine {
115 crate::types::ModelEngine::SdCpp => "sdcpp",
116 crate::types::ModelEngine::LlamaCpp => "llama",
117 crate::types::ModelEngine::Onnx => "onnx",
118 crate::types::ModelEngine::Parakeet => "parakeet",
119 crate::types::ModelEngine::Synthetic => "synthetic",
120 };
121 for e in &self.engines {
122 if e.name() == wanted {
123 debug!(
124 target: TRACE_TARGET,
125 op = "pick",
126 kind = kind.as_str(),
127 model,
128 sub_engine = e.name(),
129 r#match = "model-source",
130 "engine selected by ModelSource.engine"
131 );
132 return e.dispatch_with_source(model, task, source);
133 }
134 }
135 warn!(
136 target: TRACE_TARGET,
137 op = "pick",
138 kind = kind.as_str(),
139 model,
140 sub_engine = wanted,
141 r#match = "model-source",
142 "requested engine not compiled into this worker"
143 );
144 bail!(
145 "no `{}` engine compiled into this worker (model `{}` requires it). \
146 Install the all-backends release build from \
147 https://github.com/webbertakken/studio-worker/releases/latest, \
148 or rebuild from source with `cargo install studio-worker --features all`.",
149 wanted,
150 model
151 );
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use crate::engine::SyntheticEngine;
159
160 struct StubEngine {
161 name: &'static str,
162 kinds: Vec<TaskKind>,
163 models: Vec<String>,
164 }
165
166 impl Engine for StubEngine {
167 fn name(&self) -> &'static str {
168 self.name
169 }
170 fn capabilities(&self) -> EngineCapabilities {
171 let mut map: BTreeMap<TaskKind, Vec<String>> = BTreeMap::new();
172 for k in &self.kinds {
173 map.insert(*k, self.models.clone());
174 }
175 EngineCapabilities {
176 supported_models_per_kind: map,
177 }
178 }
179 fn dispatch(&self, _model: &str, task: Task) -> Result<TaskResult> {
180 match task {
183 Task::Image(_) => Ok(TaskResult::Image {
184 bytes: self.name.as_bytes().to_vec(),
185 ext: "test".into(),
186 }),
187 Task::Llm(_) => Ok(TaskResult::Llm {
188 json: serde_json::json!({ "from": self.name }),
189 }),
190 _ => bail!("stub doesn't serve this"),
191 }
192 }
193 }
194
195 fn image_task() -> Task {
196 Task::Image(ImageParams {
197 prompt: "x".into(),
198 width: 64,
199 height: 64,
200 steps: 1,
201 ext: "webp".into(),
202 ..Default::default()
203 })
204 }
205
206 fn llm_task() -> Task {
207 Task::Llm(LlmParams {
208 messages: vec![],
209 max_tokens: 1,
210 temperature: 0.0,
211 ..Default::default()
212 })
213 }
214
215 #[test]
216 fn multi_picks_first_engine_supporting_the_kind_and_model() {
217 let a: Box<dyn Engine> = Box::new(StubEngine {
218 name: "a",
219 kinds: vec![TaskKind::Image],
220 models: vec!["alpha".into()],
221 });
222 let b: Box<dyn Engine> = Box::new(StubEngine {
223 name: "b",
224 kinds: vec![TaskKind::Image],
225 models: vec!["beta".into()],
226 });
227 let multi = MultiEngine::new(vec![a, b]);
228
229 let result = multi.dispatch("alpha", image_task()).unwrap();
230 match result {
231 TaskResult::Image { bytes, .. } => assert_eq!(bytes, b"a"),
232 _ => panic!("expected image"),
233 }
234 let result = multi.dispatch("beta", image_task()).unwrap();
235 match result {
236 TaskResult::Image { bytes, .. } => assert_eq!(bytes, b"b"),
237 _ => panic!("expected image"),
238 }
239 }
240
241 #[test]
242 fn multi_refuses_unknown_model_without_kind_fallback() {
243 let alpha_only: Box<dyn Engine> = Box::new(StubEngine {
248 name: "alpha",
249 kinds: vec![TaskKind::Image],
250 models: vec!["alpha-image".into()],
251 });
252 let llm_only: Box<dyn Engine> = Box::new(StubEngine {
253 name: "llm",
254 kinds: vec![TaskKind::Llm],
255 models: vec!["llama-some".into()],
256 });
257 let multi = MultiEngine::new(vec![alpha_only, llm_only]);
258
259 let err = multi.dispatch("unknown-model", llm_task()).unwrap_err();
260 let msg = err.to_string();
261 assert!(
262 msg.contains("no engine on this worker can serve model"),
263 "expected no-fallback error, got: {msg}"
264 );
265 assert!(msg.contains("unknown-model"));
266 }
267
268 #[test]
269 fn multi_errors_when_no_engine_serves_kind() {
270 let image_only: Box<dyn Engine> = Box::new(StubEngine {
271 name: "image",
272 kinds: vec![TaskKind::Image],
273 models: vec!["x".into()],
274 });
275 let multi = MultiEngine::new(vec![image_only]);
276 let err = multi.dispatch("x", llm_task()).unwrap_err();
277 let msg = err.to_string();
278 assert!(
279 msg.contains("no engine on this worker can serve model"),
280 "expected no-fallback error, got: {msg}"
281 );
282 }
283
284 #[test]
285 fn capabilities_union_across_all_engines() {
286 let img: Box<dyn Engine> = Box::new(SyntheticEngine::new());
287 let stub: Box<dyn Engine> = Box::new(StubEngine {
288 name: "extra",
289 kinds: vec![TaskKind::Image],
290 models: vec!["extra-image-model".into()],
291 });
292 let multi = MultiEngine::new(vec![img, stub]);
293 let caps = multi.capabilities();
294 let image = &caps.supported_models_per_kind[&TaskKind::Image];
295 assert!(image.contains(&"synthetic".to_string()));
296 assert!(image.contains(&"extra-image-model".to_string()));
297 }
298
299 #[test]
300 fn name_is_multi() {
301 let multi = MultiEngine::new(vec![]);
302 assert_eq!(multi.name(), "multi");
303 }
304
305 fn source_for(engine: crate::types::ModelEngine) -> crate::types::ModelSource {
309 crate::types::ModelSource {
310 engine,
311 files: vec![],
312 cli_defaults: crate::types::ModelCliDefaults {
313 cfg_scale: 1.0,
314 steps: 8,
315 width: 1024,
316 height: 1024,
317 sampling_method: None,
318 ..Default::default()
319 },
320 }
321 }
322
323 fn sd_cpp_source() -> crate::types::ModelSource {
324 source_for(crate::types::ModelEngine::SdCpp)
325 }
326
327 #[test]
332 fn dispatch_with_source_refuses_to_fall_back_to_synthetic_for_real_models() {
333 let synth: Box<dyn Engine> = Box::new(SyntheticEngine::new());
334 let multi = MultiEngine::new(vec![synth]);
335 let source = sd_cpp_source();
336 let err = multi
337 .dispatch_with_source("some-real-flux-model", image_task(), &source)
338 .unwrap_err()
339 .to_string();
340 assert!(
341 err.contains("no `sdcpp` engine compiled"),
342 "expected no-sdcpp-backend error, got: {err}"
343 );
344 }
345
346 #[test]
354 fn dispatch_with_source_warns_when_wanted_engine_missing() {
355 let logs = crate::test_support::capture(|| {
356 let synth: Box<dyn Engine> = Box::new(SyntheticEngine::new());
357 let multi = MultiEngine::new(vec![synth]);
358 let source = sd_cpp_source();
359 let _ = multi.dispatch_with_source("some-real-flux-model", image_task(), &source);
360 });
361 assert!(logs.contains("WARN"), "expected WARN, got: {logs}");
362 assert!(
363 logs.contains("studio_worker::engine::multi"),
364 "expected multi target, got: {logs}"
365 );
366 assert!(logs.contains("op=\"pick\""), "expected op field: {logs}");
367 assert!(
368 logs.contains("sdcpp"),
369 "expected wanted engine name in breadcrumb: {logs}"
370 );
371 assert!(
372 logs.contains("some-real-flux-model"),
373 "expected model id in breadcrumb: {logs}"
374 );
375 }
376
377 #[test]
381 fn dispatch_with_source_routes_synthetic_engine_for_synthetic_models() {
382 let synth: Box<dyn Engine> = Box::new(SyntheticEngine::new());
383 let multi = MultiEngine::new(vec![synth]);
384 let source = source_for(crate::types::ModelEngine::Synthetic);
385 let result = multi
386 .dispatch_with_source("synthetic", image_task(), &source)
387 .unwrap();
388 assert!(matches!(result, TaskResult::Image { .. }));
389 }
390
391 #[test]
397 fn dispatch_with_source_routes_onnx_to_the_onnx_backend() {
398 let onnx: Box<dyn Engine> = Box::new(StubEngine {
399 name: "onnx",
400 kinds: vec![TaskKind::Image],
401 models: vec![],
402 });
403 let multi = MultiEngine::new(vec![onnx]);
404 let source = source_for(crate::types::ModelEngine::Onnx);
405 let result = multi
406 .dispatch_with_source("lama", image_task(), &source)
407 .unwrap();
408 match result {
409 TaskResult::Image { bytes, .. } => assert_eq!(bytes, b"onnx"),
410 _ => panic!("expected the image to route to the onnx stub"),
411 }
412 }
413
414 #[test]
418 fn dispatch_with_source_routes_llamacpp_to_the_llama_backend() {
419 let llama: Box<dyn Engine> = Box::new(StubEngine {
420 name: "llama",
421 kinds: vec![TaskKind::Llm],
422 models: vec![],
423 });
424 let multi = MultiEngine::new(vec![llama]);
425 let source = source_for(crate::types::ModelEngine::LlamaCpp);
426 let result = multi
427 .dispatch_with_source("some-gguf", llm_task(), &source)
428 .unwrap();
429 match result {
430 TaskResult::Llm { json } => assert_eq!(json["from"], "llama"),
431 _ => panic!("expected the llm to route to the llama stub"),
432 }
433 }
434}