Prechádzať zdrojové kódy

engine: EngineDriver trait — first-class engine abstraction (Phase 1)

Replace the scattered recipe.engine == "sglang" branches with an
EngineDriver trait + driver_for() registry (vllm/sglang drivers). Six
call-sites now delegate: build_engine_args (model arg, distributed flags,
headless), docker collect_nccl_env (bind-IP env), tmux entrypoint fallback
+ port placement, tmux build_host_ip_export, doctor install hint. Pure
refactor — vllm/sglang behavior byte-identical, all tests green.

Design: specs/tech-architecture/engine-drivers.md
Alexander Korolev 1 týždeň pred
rodič
commit
5be604ccfe

+ 114 - 0
specs/tech-architecture/engine-drivers.md

@@ -0,0 +1,114 @@
+# First-class engine drivers (`EngineDriver` trait)
+
+## Goal
+
+Make third-party inference engines (`gr8`, `DGPP`, future ones) **first-class
+`engine` values** in a recipe — served/stopped/logged/health-probed/benched by
+the same `sm121` commands as `vllm`/`sglang` — instead of hand-deployed outside
+the launcher. The operator directive: engines like dgpp/gr8 must be
+"равноправные члены наравне с vllm/sglang/tmux".
+
+## Current state (the scattered branches this replaces)
+
+`engine` is a free `String` (default `"vllm"`); only `vllm`/`sglang` have any
+launcher logic, and it is duplicated across six sites, each branching on
+`recipe.engine == "sglang"`:
+
+| Site | Engine-specific behaviour |
+|---|---|
+| `engine_flags.rs::build_engine_args` | model arg (`--model-path` vs positional), distributed rendezvous flags (vLLM set vs SGLang set), `--headless` for vLLM workers |
+| `docker/command.rs::collect_nccl_env` | worker-IP env: `SGLANG_WORKER_IP` vs `VLLM_HOST_IP` |
+| `tmux/commands.rs` | entrypoint fallback (`python -m vllm…` / `python -m sglang…`), port on all nodes (SGLang) vs head-only (vLLM) |
+| `tmux/mod.rs::build_host_ip_export` | host-IP env var per engine |
+| `doctor.rs` | install hint (`pip install vllm` / `sglang`) |
+| `publish_render.rs` | SGLang special-case in the rendered bundle |
+
+## Design
+
+Three orthogonal axes, each a first-class concept:
+
+1. **`engine`** → an `EngineDriver` (the inference-engine semantics).
+2. **`deploy`** → `Docker` | `Tmux` | **`Native`** (new) — how the process is
+   started on a host (container / tmux session / bare SSH command).
+3. **`launch_shape`** → `PerNode` (every launch node runs the engine; vLLM,
+   SGLang, gr8) | `HeadCoordinated` (rank 0 runs a cluster manager that
+   brings up the peers itself; DGPP).
+
+### `EngineDriver` trait (in `src/engine.rs`)
+
+```rust
+pub enum LaunchShape { PerNode, HeadCoordinated }
+
+pub trait EngineDriver: Send + Sync {
+    fn name(&self) -> &'static str;
+    fn launch_shape(&self) -> LaunchShape { LaunchShape::PerNode }
+    fn default_deploy(&self) -> DeployTarget { DeployTarget::Docker }
+
+    // Model argument (positional for vLLM, --model-path for SGLang,
+    // --model for gr8, none for DGPP — the config file carries it).
+    fn model_args(&self, recipe: &Recipe) -> Vec<String>;
+    // Distributed rendezvous flags (vLLM/SGLang/gr8 sets; DGPP: none).
+    fn distributed_args(&self, ctx: &EngineFlagsCtx, node: &Node) -> Vec<String>;
+    // Non-head workers run headless (vLLM yes; SGLang/gr8/DGPP no).
+    fn headless_workers(&self) -> bool { false }
+    // Env var carrying the node's bind IP (VLLM_HOST_IP / SGLANG_WORKER_IP / none).
+    fn engine_ip_env(&self) -> Option<&'static str> { None }
+    // Port flag on every node (SGLang) vs head only (vLLM/gr8/DGPP).
+    fn port_on_all_nodes(&self) -> bool { false }
+    // tmux entrypoint fallback when the recipe sets none.
+    fn entrypoint_default(&self) -> Vec<String>;
+    // doctor install hint.
+    fn install_hint(&self) -> &'static str;
+}
+```
+
+A registry `driver_for(engine: &str) -> &dyn EngineDriver` maps the recipe's
+`engine` string to a driver (`vllm`, `sglang`, `gr8`, `dgpp`; unknown → the
+vLLM driver, matching today's default). `build_engine_args` and the five other
+sites delegate to it. **Phase 1 is a pure refactor** — vLLM/SGLang drivers
+reproduce the current behaviour byte-for-byte, all existing tests stay green.
+
+### `EngineFlagsCtx` gains `peers`
+
+gr8 takes `--peers <ip0>,<ip1>,…` (the ordered launch-node IPs; each rank
+finds itself in the list). Add `peers: &[String]` to the ctx (vLLM/SGLang
+ignore it).
+
+### gr8 driver (`kindlingai/gr8`)
+
+- `deploy = docker` (image `ghcr.io/kindlingai/gr8`), `launch_shape = PerNode`.
+- entrypoint `["/opt/gr8/gr8", "glm53", "up"]`; `model_args` → `--model <repo>`;
+  `distributed_args` → `--tp <N> --peers <list>`; no `--headless`, no engine-IP
+  env, port on head only (API :8100 on the first peer).
+- Recipe carries `--model-cache`, `--drafter`, `--shape` in `args`; the
+  `vm.compaction_proactiveness=0` prerequisite is a `before_serve` host hook.
+- Stop: label-based `docker stop` works (the container is the engine); the
+  engine's own `gr8 glm53 down` is an optional `after_stop`.
+
+### DGPP driver (`HawkBearPig/dgpp`)
+
+- `deploy = native` (new), `launch_shape = HeadCoordinated`.
+- The recipe names a DGPP deployment template (`cluster_<model>_<quant>_w<n>.json`)
+  shipped next to the recipe; the launcher fills nodes/ssh-user/ports from the
+  cluster + a per-recipe `.env`, uploads it to rank 0, and runs
+  `scripts/dgpp-cluster up --config <path>` in a tmux session on rank 0 only.
+- Readiness: `/v1/models` on :18080 (uniform). Stop:
+  `scripts/dgpp-cluster down --config <path>` (an `after_stop` / native stop).
+- No CLI model/dist flags — the config JSON carries model + world size + knobs.
+
+## Phasing
+
+1. **Trait + vLLM/SGLang drivers** — pure refactor, six call-sites delegate,
+   all tests green. (foundation)
+2. **gr8 driver** + `peers` ctx + a `glm53-full-tp4-gr8` recipe; dry-run + serve.
+3. **`Native` deploy + HeadCoordinated** + DGPP driver + config-JSON handling +
+   a `dgpp` recipe; serve.
+4. New-model recipes (Ling-3.1-flash, Step-5-Preview) — separate, gated on the
+   bleeding-edge-base decision (nightly/dedicated image, 5–8 Sparks).
+
+## Constraints
+
+- Files ≤500 lines — `engine.rs` holds the trait + registry + the four drivers;
+  split drivers into `engine/gr8.rs` etc. if it grows.
+- No `#[allow]` for clippy — refactor instead.
+- i18n: any new user-facing strings get keys in all six locales.

+ 5 - 6
src/docker/command.rs

@@ -190,7 +190,6 @@ pub(crate) fn substitute_path_refs(recipe: &Recipe, path_refs: &[(String, String
 /// IP env var is emitted (NCCL env vars are skipped), which will cause
 /// NCCL to fail at runtime with a visible error.
 fn collect_nccl_env<'a>(ctx: &'a DockerCommandCtx, node: &'a Node) -> Vec<(&'a str, Cow<'a, str>)> {
-    let is_sglang = ctx.recipe.engine == "sglang";
     let mut env: Vec<(&str, Cow<str>)> = Vec::new();
     if let Some(nccl) = ctx.nccl.as_ref() {
         let sock = nccl.socket_ifname.as_str();
@@ -226,12 +225,12 @@ fn collect_nccl_env<'a>(ctx: &'a DockerCommandCtx, node: &'a Node) -> Vec<(&'a s
         }
     }
     // Worker IP is always set for multi-node (collect_nccl_env is only
-    // called when nnodes > 1).
+    // called when nnodes > 1). The bind-IP env var is engine-specific
+    // (VLLM_HOST_IP / SGLANG_WORKER_IP; none for engines whose driver
+    // opts out).
     let worker_ip = Cow::Borrowed(node.effective_ib_ip());
-    if is_sglang {
-        env.push(("SGLANG_WORKER_IP", worker_ip));
-    } else {
-        env.push(("VLLM_HOST_IP", worker_ip));
+    if let Some(var) = crate::engine::driver_for(&ctx.recipe.engine).engine_ip_env() {
+        env.push((var, worker_ip));
     }
     // Per-node rendezvous env: entrypoint-driven images (e.g. aiden's spark
     // launchers) read NODE_RANK/MASTER_ADDR from the environment instead of

+ 1 - 1
src/docker/tail.rs

@@ -2,7 +2,7 @@
 //! name (entrypoint, model, args, auto-generated flags).
 
 use crate::config::{Node, Recipe};
-use crate::engine_flags::{EngineFlagsCtx, build_engine_args};
+use crate::engine::{EngineFlagsCtx, build_engine_args};
 
 /// The subset of [`DockerCommandCtx`] that `build_tail` needs.
 pub(crate) struct TailCtx<'a> {

+ 1 - 5
src/doctor.rs

@@ -307,11 +307,7 @@ fn generate_repair_dockerfile(base_image: &str, recipe: &Recipe) -> Result<Strin
         lines.push("RUN pip install ray[default] || pip install ray".to_string());
     }
 
-    let engine_install = match recipe.engine.as_str() {
-        "vllm" => "pip install vllm",
-        "sglang" => "pip install sglang",
-        _ => "",
-    };
+    let engine_install = crate::engine::driver_for(&recipe.engine).install_hint();
     if !engine_install.is_empty() {
         lines.push(format!("RUN {} || true", engine_install));
     }

+ 338 - 0
src/engine.rs

@@ -0,0 +1,338 @@
+//! First-class inference-engine drivers.
+//!
+//! A recipe's `engine` string selects an [`EngineDriver`] that encapsulates
+//! everything the launcher needs to know about that engine: how the model is
+//! passed, the distributed-rendezvous flags, whether workers run headless, the
+//! bind-IP env var, port placement, the tmux entrypoint fallback, and the
+//! doctor install hint. This replaces the `recipe.engine == "sglang"` branches
+//! that were previously duplicated across `engine_flags`, the docker env
+//! builder, the tmux command builder, doctor, and publish-render.
+//!
+//! `build_engine_args` is the shared argument builder used by both the Docker
+//! (`docker/tail.rs`) and tmux (`tmux/commands.rs`) deployment paths. It does
+//! **not** include the entrypoint, `--host`, or `--port` — those are
+//! deployment-specific and added by each path.
+
+use crate::config::{DistributedBackend, Node, Recipe};
+
+/// Context for [`build_engine_args`] — the subset of `DockerCommandCtx` /
+/// `TmuxManager` state that the shared flag builder needs.
+pub struct EngineFlagsCtx<'a> {
+    pub recipe: &'a Recipe,
+    pub tp_size: u32,
+    pub pp_size: u32,
+    pub nnodes: u32,
+    pub init_port: u16,
+    pub master_ib_ip: &'a str,
+}
+
+/// Per-engine launch semantics. See the module docs.
+pub trait EngineDriver: Send + Sync {
+    /// The model argument: positional (vLLM), `--model-path` (SGLang),
+    /// `--model` (gr8), or none (DGPP — the config file carries it). An empty
+    /// recipe model yields no model argument (entrypoint-driven images).
+    fn model_args(&self, recipe: &Recipe) -> Vec<String> {
+        if recipe.model.is_empty() {
+            Vec::new()
+        } else {
+            vec![recipe.model.clone()]
+        }
+    }
+
+    /// Distributed-rendezvous flags. Only called for multi-node distributed
+    /// runs (`nnodes > 1` with an Mp/Ray backend).
+    fn distributed_args(&self, ctx: &EngineFlagsCtx<'_>, node: &Node) -> Vec<String>;
+
+    /// Non-head workers run headless (vLLM yes; SGLang/gr8/DGPP no).
+    fn headless_workers(&self) -> bool {
+        false
+    }
+
+    /// Env var carrying the node's bind IP (`VLLM_HOST_IP` / `SGLANG_WORKER_IP`
+    /// / none).
+    fn engine_ip_env(&self) -> Option<&'static str> {
+        None
+    }
+
+    /// Emit the `--port` flag on every node (SGLang) vs the head only
+    /// (vLLM/gr8/DGPP).
+    fn port_on_all_nodes(&self) -> bool {
+        false
+    }
+
+    /// tmux entrypoint fallback when the recipe sets none.
+    fn entrypoint_default(&self) -> Vec<String>;
+
+    /// doctor install hint (empty = nothing to install, e.g. prebuilt images).
+    fn install_hint(&self) -> &'static str {
+        ""
+    }
+}
+
+/// vLLM: positional model, `--tensor-parallel-size`/`--nnodes`/`--master-addr`
+/// rendezvous, `--headless` workers, `VLLM_HOST_IP`.
+pub struct VllmDriver;
+
+impl EngineDriver for VllmDriver {
+    fn distributed_args(&self, ctx: &EngineFlagsCtx<'_>, node: &Node) -> Vec<String> {
+        let mut fp = vec![
+            "--tensor-parallel-size".into(),
+            ctx.tp_size.to_string(),
+            "--pipeline-parallel-size".into(),
+            ctx.pp_size.to_string(),
+            "--nnodes".into(),
+            ctx.nnodes.to_string(),
+            "--node-rank".into(),
+            node.rank.to_string(),
+            "--master-addr".into(),
+            ctx.master_ib_ip.to_string(),
+            "--master-port".into(),
+            ctx.init_port.to_string(),
+        ];
+        if ctx.recipe.distributed_backend == Some(DistributedBackend::Ray) {
+            fp.push("--distributed-executor-backend".into());
+            fp.push("ray".into());
+        }
+        fp
+    }
+
+    fn headless_workers(&self) -> bool {
+        true
+    }
+
+    fn engine_ip_env(&self) -> Option<&'static str> {
+        Some("VLLM_HOST_IP")
+    }
+
+    fn entrypoint_default(&self) -> Vec<String> {
+        vec!["python -m vllm.entrypoints.openai.api_server".into()]
+    }
+
+    fn install_hint(&self) -> &'static str {
+        "pip install vllm"
+    }
+}
+
+/// SGLang: `--model-path`, `--tp`/`--nnodes`/`--dist-init-addr` rendezvous,
+/// no headless (port on every node), `SGLANG_WORKER_IP`.
+pub struct SglangDriver;
+
+impl EngineDriver for SglangDriver {
+    fn model_args(&self, recipe: &Recipe) -> Vec<String> {
+        if recipe.model.is_empty() {
+            Vec::new()
+        } else {
+            vec!["--model-path".into(), recipe.model.clone()]
+        }
+    }
+
+    fn distributed_args(&self, ctx: &EngineFlagsCtx<'_>, node: &Node) -> Vec<String> {
+        vec![
+            "--tp".into(),
+            ctx.tp_size.to_string(),
+            "--nnodes".into(),
+            ctx.nnodes.to_string(),
+            "--node-rank".into(),
+            node.rank.to_string(),
+            "--dist-init-addr".into(),
+            format!("{}:{}", ctx.master_ib_ip, ctx.init_port),
+        ]
+    }
+
+    fn engine_ip_env(&self) -> Option<&'static str> {
+        Some("SGLANG_WORKER_IP")
+    }
+
+    fn port_on_all_nodes(&self) -> bool {
+        true
+    }
+
+    fn entrypoint_default(&self) -> Vec<String> {
+        vec!["python -m sglang.launch_server".into()]
+    }
+
+    fn install_hint(&self) -> &'static str {
+        "pip install sglang"
+    }
+}
+
+/// Resolve the driver for a recipe's `engine` string. Unknown engines fall
+/// back to the vLLM driver (matching the historical default: an engine with no
+/// dedicated driver still gets the vLLM-style tail).
+pub fn driver_for(engine: &str) -> &'static dyn EngineDriver {
+    match engine {
+        "sglang" => &SglangDriver,
+        "vllm" => &VllmDriver,
+        _ => &VllmDriver,
+    }
+}
+
+/// Build the engine-specific arguments for a recipe: model path, recipe args,
+/// and distributed-rendezvous flags. Delegates the engine-specific pieces to
+/// the [`EngineDriver`].
+///
+/// Does **not** include the entrypoint, `--host`, or `--port` — those are
+/// deployment-specific and added by each path:
+/// - Docker (`tail.rs`): always adds `--host 0.0.0.0 --port <port>`.
+/// - tmux (`commands.rs`): adds `--port <port>` on the head node (or all
+///   nodes for engines whose driver sets `port_on_all_nodes`).
+///
+/// Single-node runs emit **no** distributed flags — the engine defaults to
+/// single-node. Multi-node runs get the full rendezvous flag set.
+pub fn build_engine_args(ctx: &EngineFlagsCtx<'_>, node: &Node) -> Vec<String> {
+    let driver = driver_for(&ctx.recipe.engine);
+    let is_distributed = matches!(
+        ctx.recipe.distributed_backend,
+        Some(DistributedBackend::Mp | DistributedBackend::Ray)
+    );
+
+    let mut args = driver.model_args(ctx.recipe);
+
+    // Recipe-specific args (e.g. --kv-cache-dtype, --max-model-len).
+    args.extend(ctx.recipe.args.to_vec());
+
+    // Distributed flags — only for multi-node distributed runs.
+    if ctx.nnodes > 1 && is_distributed {
+        args.extend(driver.distributed_args(ctx, node));
+    }
+
+    // Headless workers (vLLM) in distributed mode.
+    if ctx.nnodes > 1 && driver.headless_workers() && node.rank > 0 && is_distributed {
+        args.push("--headless".into());
+    }
+
+    args
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::config::DeployTarget;
+    use crate::pre_serve::{RecipeArgs, VenvSetup};
+    use std::collections::HashMap;
+
+    pub(crate) fn base_recipe() -> Recipe {
+        Recipe {
+            build_profile: None,
+            parent_model: None,
+            archived: false,
+            engine: "vllm".into(),
+            name: "T".into(),
+            docker_image: "img".into(),
+            model: "mdl".into(),
+            port: 8000,
+            required_nodes: None,
+            pipeline_parallel_size: Some(1),
+            tensor_parallel_size: 1,
+            args: RecipeArgs::List(vec!["--x".into(), "4096".into()]),
+            env: HashMap::new(),
+            cluster: None,
+            entrypoint: vec!["vllm".into(), "serve".into()],
+            distributed_backend: Some(DistributedBackend::Mp),
+            before_serve: vec![],
+            after_stop: vec![],
+            solo: None,
+            deploy: DeployTarget::Docker,
+            venv: None,
+            venv_setup: VenvSetup::default(),
+            venv_setup_script: None,
+            mods: Default::default(),
+            shm_size: String::new(),
+            devices: vec![],
+            memory_limit: None,
+            cpus: None,
+            sync_image: true,
+            docker_image_dir: None,
+            extra_mounts: vec![],
+            extra_mounts_manifest: None,
+            build: Default::default(),
+            composed_model: None,
+            staging: vec![],
+        }
+    }
+
+    pub(crate) fn tnode(rank: u32) -> Node {
+        Node {
+            name: format!("n{}", rank),
+            ssh_ip: "10.0.0.1".into(),
+            ib_ip: Some("10.0.0.1".into()),
+            rank,
+            host_key: None,
+            entity: None,
+        }
+    }
+
+    fn ctx<'a>(r: &'a Recipe, tp: u32, nnodes: u32) -> EngineFlagsCtx<'a> {
+        EngineFlagsCtx {
+            recipe: r,
+            tp_size: tp,
+            pp_size: 1,
+            nnodes,
+            init_port: 25000,
+            master_ib_ip: "10.0.0.1",
+        }
+    }
+
+    #[test]
+    fn single_node_no_distributed_flags() {
+        let r = base_recipe();
+        let args = build_engine_args(&ctx(&r, 1, 1), &tnode(0));
+        assert_eq!(args, vec!["mdl", "--x", "4096"]);
+    }
+
+    #[test]
+    fn multi_node_vllm_mp_flags() {
+        let r = base_recipe();
+        let c = ctx(&r, 2, 2);
+        let head_args = build_engine_args(&c, &tnode(0));
+        assert!(head_args.contains(&"--tensor-parallel-size".into()));
+        assert!(head_args.contains(&"2".into()));
+        assert!(head_args.contains(&"--nnodes".into()));
+        assert!(head_args.contains(&"--master-addr".into()));
+        assert!(head_args.contains(&"10.0.0.1".into()));
+        assert!(head_args.contains(&"--master-port".into()));
+        assert!(head_args.contains(&"25000".into()));
+        assert!(!head_args.contains(&"--headless".into()));
+
+        let worker_args = build_engine_args(&c, &tnode(1));
+        assert!(worker_args.contains(&"--headless".into()));
+    }
+
+    #[test]
+    fn multi_node_sglang_flags() {
+        let mut r = base_recipe();
+        r.engine = "sglang".into();
+        let c = ctx(&r, 2, 2);
+        let args = build_engine_args(&c, &tnode(0));
+        assert!(args.contains(&"--model-path".into()));
+        assert!(args.contains(&"--tp".into()));
+        assert!(args.contains(&"--dist-init-addr".into()));
+        assert!(args.contains(&"10.0.0.1:25000".into()));
+        assert!(!args.contains(&"--headless".into()));
+    }
+
+    #[test]
+    fn ray_backend_adds_executor() {
+        let mut r = base_recipe();
+        r.distributed_backend = Some(DistributedBackend::Ray);
+        let c = ctx(&r, 1, 2);
+        let args = build_engine_args(&c, &tnode(0));
+        assert!(args.contains(&"--distributed-executor-backend".into()));
+        assert!(args.contains(&"ray".into()));
+    }
+
+    #[test]
+    fn driver_registry_maps_engines() {
+        // vLLM: headless workers, no port-on-all-nodes.
+        assert!(driver_for("vllm").headless_workers());
+        assert!(!driver_for("vllm").port_on_all_nodes());
+        // SGLang: port on every node, --model-path model arg.
+        assert!(driver_for("sglang").port_on_all_nodes());
+        assert_eq!(
+            driver_for("sglang").model_args(&base_recipe()),
+            vec!["--model-path".to_string(), "mdl".to_string()]
+        );
+        // Unknown engines fall back to the vLLM driver (historical default).
+        assert!(driver_for("something-new").headless_workers());
+    }
+}

+ 0 - 243
src/engine_flags.rs

@@ -1,243 +0,0 @@
-//! Shared engine-specific argument builder — used by both the Docker
-//! (`docker/tail.rs`) and tmux (`tmux/commands.rs`) deployment paths to
-//! produce the model, recipe args, and distributed-rendezvous flags.
-//!
-//! This consolidates the duplicated flag logic that previously lived in
-//! `tail.rs` and `commands.rs` separately. Each deployment path still
-//! adds its own entrypoint and host/port flags on top.
-
-use crate::config::{DistributedBackend, Node, Recipe};
-
-/// Context for [`build_engine_args`] — the subset of `DockerCommandCtx`
-/// / `TmuxManager` state that the shared flag builder needs.
-pub struct EngineFlagsCtx<'a> {
-    pub recipe: &'a Recipe,
-    pub tp_size: u32,
-    pub pp_size: u32,
-    pub nnodes: u32,
-    pub init_port: u16,
-    pub master_ib_ip: &'a str,
-}
-
-/// Build the engine-specific arguments for a recipe: model path, recipe
-/// args, and distributed-rendezvous flags.
-///
-/// Does **not** include the entrypoint, `--host`, or `--port` — those are
-/// deployment-specific and added by each path:
-/// - Docker (`tail.rs`): always adds `--host 0.0.0.0 --port <port>`.
-/// - tmux (`commands.rs`): adds `--port <port>` on the head node (or all
-///   nodes for SGLang).
-///
-/// Single-node runs emit **no** distributed flags — the engine defaults
-/// to single-node. Multi-node runs get the full rendezvous flag set.
-pub fn build_engine_args(ctx: &EngineFlagsCtx<'_>, node: &Node) -> Vec<String> {
-    let is_sglang = ctx.recipe.engine == "sglang";
-    let is_distributed = matches!(
-        ctx.recipe.distributed_backend,
-        Some(DistributedBackend::Mp | DistributedBackend::Ray)
-    );
-
-    let mut args = Vec::new();
-
-    // Model — SGLang uses --model-path, vLLM uses the bare model arg.
-    // An empty model is valid for entrypoint-driven images: the image's own
-    // entrypoint selects the model (e.g. via its MODEL_PATH env), so no
-    // positional model is emitted (the launcher's own tail args still are).
-    if !ctx.recipe.model.is_empty() {
-        if is_sglang {
-            args.push("--model-path".to_string());
-        }
-        args.push(ctx.recipe.model.clone());
-    }
-
-    // Recipe-specific args (e.g. --kv-cache-dtype, --max-model-len).
-    args.extend(ctx.recipe.args.to_vec());
-
-    // Distributed flags — only for multi-node runs.
-    if ctx.nnodes > 1 {
-        match ctx.recipe.distributed_backend {
-            Some(DistributedBackend::Ray | DistributedBackend::Mp) if is_sglang => {
-                args.extend(vec![
-                    "--tp".into(),
-                    ctx.tp_size.to_string(),
-                    "--nnodes".into(),
-                    ctx.nnodes.to_string(),
-                    "--node-rank".into(),
-                    node.rank.to_string(),
-                    "--dist-init-addr".into(),
-                    format!("{}:{}", ctx.master_ib_ip, ctx.init_port),
-                ]);
-            }
-            Some(DistributedBackend::Ray | DistributedBackend::Mp) => {
-                let mut fp = vec![
-                    "--tensor-parallel-size".into(),
-                    ctx.tp_size.to_string(),
-                    "--pipeline-parallel-size".into(),
-                    ctx.pp_size.to_string(),
-                    "--nnodes".into(),
-                    ctx.nnodes.to_string(),
-                    "--node-rank".into(),
-                    node.rank.to_string(),
-                    "--master-addr".into(),
-                    ctx.master_ib_ip.to_string(),
-                    "--master-port".into(),
-                    ctx.init_port.to_string(),
-                ];
-                if ctx.recipe.distributed_backend == Some(DistributedBackend::Ray) {
-                    fp.push("--distributed-executor-backend".into());
-                    fp.push("ray".into());
-                }
-                args.extend(fp);
-            }
-            _ => {}
-        }
-    }
-
-    // vLLM workers in distributed mode run headless (no port binding).
-    if ctx.nnodes > 1 && !is_sglang && node.rank > 0 && is_distributed {
-        args.push("--headless".into());
-    }
-
-    args
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::config::DeployTarget;
-    use crate::pre_serve::{RecipeArgs, VenvSetup};
-    use std::collections::HashMap;
-
-    fn base_recipe() -> Recipe {
-        Recipe {
-            build_profile: None,
-            parent_model: None,
-            archived: false,
-            engine: "vllm".into(),
-            name: "T".into(),
-            docker_image: "img".into(),
-            model: "mdl".into(),
-            port: 8000,
-            required_nodes: None,
-            pipeline_parallel_size: Some(1),
-            tensor_parallel_size: 1,
-            args: RecipeArgs::List(vec!["--x".into(), "4096".into()]),
-            env: HashMap::new(),
-            cluster: None,
-            entrypoint: vec!["vllm".into(), "serve".into()],
-            distributed_backend: Some(DistributedBackend::Mp),
-            before_serve: vec![],
-            after_stop: vec![],
-            solo: None,
-            deploy: DeployTarget::Docker,
-            venv: None,
-            venv_setup: VenvSetup::default(),
-            venv_setup_script: None,
-            mods: Default::default(),
-            shm_size: String::new(),
-            devices: vec![],
-            memory_limit: None,
-            cpus: None,
-            sync_image: true,
-            docker_image_dir: None,
-            extra_mounts: vec![],
-            extra_mounts_manifest: None,
-            build: Default::default(),
-            composed_model: None,
-            staging: vec![],
-        }
-    }
-
-    fn tnode(rank: u32) -> Node {
-        Node {
-            name: format!("n{}", rank),
-            ssh_ip: "10.0.0.1".into(),
-            ib_ip: Some("10.0.0.1".into()),
-            rank,
-            host_key: None,
-            entity: None,
-        }
-    }
-
-    #[test]
-    fn single_node_no_distributed_flags() {
-        let r = base_recipe();
-        let ctx = EngineFlagsCtx {
-            recipe: &r,
-            tp_size: 1,
-            pp_size: 1,
-            nnodes: 1,
-            init_port: 25000,
-            master_ib_ip: "10.0.0.1",
-        };
-        let args = build_engine_args(&ctx, &tnode(0));
-        // model + recipe args, no distributed flags
-        assert_eq!(args, vec!["mdl", "--x", "4096"]);
-    }
-
-    #[test]
-    fn multi_node_vllm_mp_flags() {
-        let r = base_recipe();
-        let ctx = EngineFlagsCtx {
-            recipe: &r,
-            tp_size: 2,
-            pp_size: 1,
-            nnodes: 2,
-            init_port: 25000,
-            master_ib_ip: "10.0.0.1",
-        };
-        let head_args = build_engine_args(&ctx, &tnode(0));
-        assert!(head_args.contains(&"--tensor-parallel-size".into()));
-        assert!(head_args.contains(&"2".into()));
-        assert!(head_args.contains(&"--nnodes".into()));
-        assert!(head_args.contains(&"2".into()));
-        assert!(head_args.contains(&"--master-addr".into()));
-        assert!(head_args.contains(&"10.0.0.1".into()));
-        assert!(head_args.contains(&"--master-port".into()));
-        assert!(head_args.contains(&"25000".into()));
-        // head node: no --headless
-        assert!(!head_args.contains(&"--headless".into()));
-
-        let worker_args = build_engine_args(&ctx, &tnode(1));
-        assert!(worker_args.contains(&"--headless".into()));
-    }
-
-    #[test]
-    fn multi_node_sglang_flags() {
-        let mut r = base_recipe();
-        r.engine = "sglang".into();
-        let ctx = EngineFlagsCtx {
-            recipe: &r,
-            tp_size: 2,
-            pp_size: 1,
-            nnodes: 2,
-            init_port: 25000,
-            master_ib_ip: "10.0.0.1",
-        };
-        let args = build_engine_args(&ctx, &tnode(0));
-        // SGLang uses --model-path, --tp, --dist-init-addr
-        assert!(args.contains(&"--model-path".into()));
-        assert!(args.contains(&"--tp".into()));
-        assert!(args.contains(&"--dist-init-addr".into()));
-        assert!(args.contains(&"10.0.0.1:25000".into()));
-        // SGLang never gets --headless
-        assert!(!args.contains(&"--headless".into()));
-    }
-
-    #[test]
-    fn ray_backend_adds_executor() {
-        let mut r = base_recipe();
-        r.distributed_backend = Some(DistributedBackend::Ray);
-        let ctx = EngineFlagsCtx {
-            recipe: &r,
-            tp_size: 1,
-            pp_size: 1,
-            nnodes: 2,
-            init_port: 25000,
-            master_ib_ip: "10.0.0.1",
-        };
-        let args = build_engine_args(&ctx, &tnode(0));
-        assert!(args.contains(&"--distributed-executor-backend".into()));
-        assert!(args.contains(&"ray".into()));
-    }
-}

+ 1 - 1
src/main.rs

@@ -35,7 +35,7 @@ mod detect;
 mod docker;
 mod doctor;
 mod ends;
-mod engine_flags;
+mod engine;
 mod exec;
 mod gconfig;
 mod handlers;

+ 7 - 16
src/tmux/commands.rs

@@ -1,5 +1,5 @@
 use crate::config::{Cluster, Node, Recipe};
-use crate::engine_flags::{EngineFlagsCtx, build_engine_args};
+use crate::engine::{EngineFlagsCtx, build_engine_args, driver_for};
 use crate::ssh::shq;
 use crate::tmux::TmuxManager;
 use anyhow::Result;
@@ -18,7 +18,7 @@ impl TmuxManager {
     pub fn build_launch_command(&self, recipe: &Recipe, node: &Node, cluster: &Cluster) -> String {
         let nnodes = cluster.nodes.len() as u32;
         let pp_size = recipe.pipeline_parallel_size.unwrap_or(nnodes);
-        let is_sglang = recipe.engine == "sglang";
+        let driver = driver_for(&recipe.engine);
 
         let mut parts: Vec<String> = Vec::new();
 
@@ -31,21 +31,11 @@ impl TmuxManager {
             String::new()
         };
 
-        // Entrypoint — from recipe when present, else hardcoded Python path.
+        // Entrypoint — from recipe when present, else the driver's default.
         if !recipe.entrypoint.is_empty() {
             parts.extend(recipe.entrypoint.clone());
         } else {
-            match recipe.engine.as_str() {
-                "vllm" => {
-                    parts.push("python -m vllm.entrypoints.openai.api_server".to_string());
-                }
-                "sglang" => {
-                    parts.push("python -m sglang.launch_server".to_string());
-                }
-                other => {
-                    parts.push(format!("python -m {}", other));
-                }
-            }
+            parts.extend(driver.entrypoint_default());
         }
 
         // Shared engine args: model, recipe args, distributed flags, headless.
@@ -60,8 +50,9 @@ impl TmuxManager {
         };
         parts.extend(build_engine_args(&ectx, node));
 
-        // Port — SGLang needs it on all nodes, vLLM only on head node.
-        if is_sglang || node.rank == 0 {
+        // Port — engines whose driver sets port_on_all_nodes (SGLang) need it
+        // on every node; others (vLLM) only on the head node.
+        if driver.port_on_all_nodes() || node.rank == 0 {
             parts.extend(vec!["--port".to_string(), recipe.port.to_string()]);
         }
 

+ 3 - 8
src/tmux/mod.rs

@@ -56,14 +56,9 @@ impl TmuxManager {
     /// Builds engine-specific host IP environment variable.
     pub fn build_host_ip_export(&self, engine: &str, node: &Node) -> Vec<String> {
         let ip = node.effective_ib_ip();
-        match engine {
-            "vllm" => vec![format!("export VLLM_HOST_IP={}", ip)],
-            "sglang" => vec![format!("export SGLANG_WORKER_IP={}", ip)],
-            _ => vec![
-                format!("export VLLM_HOST_IP={}", ip),
-                format!("export SGLANG_WORKER_IP={}", ip),
-            ],
-        }
+        crate::engine::driver_for(engine)
+            .engine_ip_env()
+            .map_or_else(Vec::new, |var| vec![format!("export {var}={}", ip)])
     }
 }