Skip to main content

studio_worker/engine/
multi.rs

1//! Composite engine that delegates per (kind, model).
2//!
3//! The worker no longer has an `engine` knob in its config: instead,
4//! `engine::build()` returns a `MultiEngine` populated with every
5//! backend compiled into the binary (synthetic always; llama /
6//! whisper / image-candle / video / tts when their cargo features
7//! are on).
8//!
9//! For each incoming job [`MultiEngine`] picks the first engine in
10//! the list that advertises support for the requested model.  If no
11//! engine claims the exact model it falls back to the first engine
12//! that handles the task kind at all.  Real backends are inserted
13//! ahead of synthetic so they win when both could serve the same
14//! (kind, model).  If nothing matches the dispatch fails with the
15//! "cannot serve <kind>" shape the studio's claim loop already
16//! knows how to handle.
17use crate::engine::{Engine, EngineCapabilities};
18use crate::types::*;
19use anyhow::{bail, Result};
20use std::collections::BTreeMap;
21use tracing::{debug, warn};
22
23/// Tracing target for the multi engine.  Stable so operators can
24/// filter with `RUST_LOG=studio_worker::engine::multi=debug`.
25const 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    /// Pick the engine that claims `(kind, model)` exactly.  No
37    /// kind-only fallback — the studio's `ModelSource` is
38    /// authoritative.  A model whose engine isn't on this worker is
39    /// rejected loudly so the operator sees what's missing instead of
40    /// silently routing through synthetic placeholder bytes.
41    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        // The studio knows exactly which engine should serve this
110        // job (source.engine); we route strictly to that backend.
111        // No silent fallback to synthetic for real-model offers —
112        // see DECISIONS.md "Synthetic fallback removed for real
113        // models".
114        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            // Return a sentinel result tagged with the engine name so we
181            // can verify routing in tests.
182            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        // An LLM engine is present, but no engine claims the
244        // specific model id.  Per the no-fallback policy the
245        // dispatch errors loudly instead of routing to the first
246        // engine that advertises the kind.
247        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    /// Build a `ModelSource` for `engine` with throwaway CLI defaults.
306    /// `dispatch_with_source` routes purely on `engine`, so the file
307    /// roster + params are irrelevant to the routing tests.
308    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    /// The no-fallback policy: when the studio asks for an `sd-cpp`
328    /// model but no sd-cpp engine is compiled in (e.g. CI / minimal
329    /// build), dispatch errors loudly instead of silently routing the
330    /// job to synthetic.
331    #[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    /// The no-match path of `dispatch_with_source` must emit a
347    /// structured breadcrumb on the `studio_worker::engine::multi`
348    /// target, symmetric with `pick_for`'s no-match `warn!`.  Without
349    /// it, an operator filtering `RUST_LOG=studio_worker::engine::multi`
350    /// to trace routing would see "engine selected" events but never
351    /// the rejections, making a wrong-engine offer impossible to
352    /// diagnose from the routing breadcrumbs alone.
353    #[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    /// Synthetic offers (engine == Synthetic) still route to the
378    /// synthetic engine.  This is *not* a fallback — the studio
379    /// explicitly asked for it.
380    #[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    /// `ModelSource.engine == Onnx` must route strictly to the engine
392    /// named `onnx` (the LaMa object-removal backend that serves
393    /// Find-the-Differences removals).  This arm had no test, so a
394    /// typo'd engine string or a dropped/reordered match arm would have
395    /// shipped silently and mis-routed every removal job.
396    #[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    /// `ModelSource.engine == LlamaCpp` must route strictly to the
415    /// engine named `llama`.  Symmetric cover for the second
416    /// previously-untested routing arm.
417    #[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}