ソースを参照

feat(recipe): model-subfolder path refs via {{name}} substitution

Switch from bind-mount to placeholder substitution for repo/subfolder
extra_mounts. The launcher resolves the full HF cache path per-node
(refs/main -> latest snapshot) and substitutes {{<name>}} in args/env.

This fixes the symlink resolution issue: the HF cache symlinks
(../../../blobs/) only resolve from the full cache path, not from a
separate bind-mount at /models/mtp-draft. The HF cache is already
mounted at /root/.cache/huggingface, so the resolved path's symlinks
work correctly.

- PathRef replaces ModelMountSpec (repo, subfolder, name)
- substitute_path_refs() replaces {{name}} in args/env
- resolve_path_ref_on_node() reads refs/main via SSH
- path_ref_placeholder() for dry-run descriptive path
- Recipe uses {{mtp_draft}} in --speculative-config
- All 100 tests pass, clippy clean, fmt clean, ≤500 lines
Alexander Korolev 3 ヶ月 前
親
コミット
a7cd759af1
6 ファイル変更、180 行追加、116 行削除
  1. 10 8
      AGENTS.md
  2. 9 5
      recipes/glm52-hybrid-fp8-mxfp4/recipe.toml
  3. 20 14
      src/config/types.rs
  4. 95 63
      src/docker/command.rs
  5. 32 26
      src/docker/mod.rs
  6. 14 0
      src/pre_serve.rs

+ 10 - 8
AGENTS.md

@@ -45,16 +45,18 @@ container = "/root/.cache/jit"     # container-side target (required)
 read_only = false                 # optional, default false (adds :ro)
 env = "XDG_CACHE_HOME"            # optional: injects -e <name>=<container path>
 
-# Model subfolder mount (mutually exclusive with alias/host): resolves at
+# Model path reference (mutually exclusive with alias/host): resolves at
 # serve time, per node, from the HF cache — refs/main → latest snapshot, so
-# it survives `huggingface-cli download` updates with no fragile symlink.
-# `container` is the fixed stable path the recipe references in args. Absent
-# subfolder → serve aborts with a hint to `huggingface-cli download <repo>`.
+# the path survives `huggingface-cli download` updates with no fragile
+# symlink. `container` is the placeholder name (e.g. `mtp_draft`) that the
+# recipe references as `{{mtp_draft}}` in args/env. The launcher substitutes
+# the resolved full HF cache path per-node (no bind-mount — the HF cache is
+# already mounted at /root/.cache/huggingface, so symlinks resolve correctly).
+# Absent subfolder → serve aborts with a hint to `huggingface-cli download <repo>`.
 [[extra_mounts]]
-repo = "aidendle94/GLM-5.2-Hybrid-FP8-MXFP4"
-subfolder = "mtp-draft"           # optional; omit = mount whole snapshot dir
-container = "/models/mtp-draft"
-read_only = true
+repo = "aidendle94/GLM-5.2-Hybrid-FP8-MXPF4"
+subfolder = "mtp-draft"           # optional; omit = whole snapshot dir
+container = "mtp_draft"
 
 [mods]
 commands = [                         # run *inside* container before engine

+ 9 - 5
recipes/glm52-hybrid-fp8-mxfp4/recipe.toml

@@ -63,8 +63,10 @@ args = [
     "--max-num-seqs", "3",
     "--gpu-memory-utilization", "0.89",
     # Speculative decode — MTP k=4 with adaptive depths
+    # {{mtp_draft}} is substituted per-node with the resolved HF cache path
+    # (refs/main → latest snapshot) so symlinks resolve correctly.
     "--speculative-config", """
-'{"model":"/models/mtp-draft","method":"mtp","num_speculative_tokens":4,"draft_attention_backend":"B12X_MLA_SPARSE","adaptive_speculative_tokens_window":32}'""",
+'{"model":"{{mtp_draft}}","method":"mtp","num_speculative_tokens":4,"draft_attention_backend":"B12X_MLA_SPARSE","adaptive_speculative_tokens_window":32}'""",
     # Compilation — FULL_AND_PIECEWISE with all custom ops, 8 capture sizes
     "--compilation-config", """
 '{"cudagraph_mode":"FULL_AND_PIECEWISE","custom_ops":["all"],"cudagraph_capture_sizes":[1,2,3,4,5,6,8]}'""",
@@ -85,13 +87,15 @@ env = "XDG_CACHE_HOME"
 
 # MTP draft model — a subfolder of the main GLM-5.2 repo. Resolved at serve
 # time, per node, from the HF cache (refs/main → latest snapshot), so the
-# mount survives `huggingface-cli download` updates without a fragile symlink.
-# The recipe references the fixed /models/mtp-draft path in --speculative-config.
+# path survives `huggingface-cli download` updates without a fragile symlink.
+# The recipe references {{mtp_draft}} in --speculative-config; the launcher
+# substitutes the resolved full HF cache path per-node (no bind-mount — the
+# HF cache is already mounted at /root/.cache/huggingface, so symlinks
+# resolve correctly).
 [[extra_mounts]]
 repo = "aidendle94/GLM-5.2-Hybrid-FP8-MXFP4"
 subfolder = "mtp-draft"
-container = "/models/mtp-draft"
-read_only = true
+container = "mtp_draft"
 
 
 [env]

+ 20 - 14
src/config/types.rs

@@ -176,13 +176,16 @@ impl Config {
 ///   the default. `~` (and `~/`) at the start of the resolved path is expanded
 ///   to the remote SSH user's home. At least one of `alias` or `host` must be
 ///   set.
-/// - **Model subfolder** (`repo` + optional `subfolder`): the host path is
-///   resolved **at serve time, per node**, from the node's HF cache —
-///   `<hf_cache>/hub/<model_cache_dir_name(repo)>/snapshots/<refs/main>/<subfolder>`.
-///   This pins the current snapshot without a fragile symlink: `refs/main`
-///   always points at the latest commit, so the mount survives `huggingface-cli
-///   download` updates. The recipe references the fixed `container` path in
-///   args (e.g. `--speculative-config`), so a vLLM JSON arg stays stable.
+/// - **Model path reference** (`repo` + optional `subfolder`): NOT a bind-mount.
+///   The path is resolved **at serve time, per node**, from the node's HF
+///   cache: `<hf_cache>/hub/<model_cache_dir_name(repo)>/snapshots/<refs/main>/<subfolder>`.
+///   `refs/main` always points at the latest commit, so the path survives
+///   `huggingface-cli download` updates — no fragile symlink. The recipe
+///   references this path via a `{{<container>}}` placeholder in args/env
+///   (e.g. `--speculative-config`), which the launcher substitutes per-node
+///   before building the docker command. The full HF cache is already mounted
+///   at `/root/.cache/huggingface`, so the resolved path's symlinks resolve
+///   correctly (a separate bind-mount would break them).
 ///   If the subfolder is absent on a node, serve aborts with a hint to
 ///   `huggingface-cli download <repo>` (the launcher never downloads).
 #[derive(Clone, Debug, Default, Deserialize)]
@@ -196,21 +199,24 @@ pub struct ExtraMount {
     #[serde(default)]
     pub host: Option<String>,
     /// HF Hub repo id, e.g. `aidendle94/GLM-5.2-Hybrid-FP8-MXFP4`. When set,
-    /// the host path is resolved at serve time from the node's HF cache
-    /// (so `alias`/`host` must be empty).
+    /// the path is resolved at serve time from the node's HF cache and
+    /// substituted into args/env via `{{<container>}}` (so `alias`/`host`
+    /// must be empty).
     #[serde(default)]
     pub repo: Option<String>,
-    /// Subfolder within the repo snapshot to mount (e.g. `mtp-draft`).
-    /// Empty/omitted = mount the whole snapshot dir.
+    /// Subfolder within the repo snapshot (e.g. `mtp-draft`).
+    /// Empty/omitted = the whole snapshot dir.
     #[serde(default)]
     pub subfolder: Option<String>,
-    /// Container-side mount target (always the stable path referenced in args).
+    /// For static mounts: the container-side mount target. For repo-based
+    /// path references: the placeholder name (e.g. `mtp_draft`) that gets
+    /// substituted as `{{mtp_draft}}` in args/env.
     pub container: String,
-    /// Mount read-only (`:ro`).
+    /// Mount read-only (`:ro`). Only for static mounts.
     #[serde(default)]
     pub read_only: bool,
     /// If set, inject `-e <env>=<container path>` so recipes don't hardcode
-    /// the path in two places (mount + env).
+    /// the path in two places (mount + env). Only for static mounts.
     #[serde(default)]
     pub env: Option<String>,
 }

+ 95 - 63
src/docker/command.rs

@@ -25,16 +25,15 @@ pub(crate) struct DockerCommandCtx {
     pub init_port: u16,
     pub mods_remote_path: String,
     /// Static `extra_mounts` (alias/host) with host paths already resolved
-    /// once against `Config.mount_aliases` / `ssh_user`. Model-subfolder mounts
-    /// are NOT here — `start_cluster` resolves those per node and merges the
-    /// results into this `Vec` (as `ResolvedMount`s) before calling
-    /// [`build_docker_command`].
+    /// once against `Config.mount_aliases` / `ssh_user`. Path references
+    /// (`repo`/`subfolder`) are NOT here — `start_cluster` resolves those
+    /// per-node and substitutes `{{<name>}}` into the recipe args/env before
+    /// calling [`build_docker_command`].
     pub extra_mounts: Vec<ResolvedMount>,
 }
 
 /// A recipe `ExtraMount` after host-path resolution (alias + tilde), ready to
-/// turn into a `-v` flag / `-e` env pair. Also the final form a model-subfolder
-/// mount takes after per-node resolution.
+/// turn into a `-v` flag / `-e` env pair.
 #[derive(Clone)]
 pub(crate) struct ResolvedMount {
     pub host: String,
@@ -43,29 +42,29 @@ pub(crate) struct ResolvedMount {
     pub env: Option<String>,
 }
 
-/// A model-subfolder mount spec (`repo`+`subfolder`) before per-node
-/// resolution. The host path is resolved at serve time from each node's HF
-/// cache (so it can differ across nodes); `container` is the fixed path the
-/// recipe references in args.
+/// A model path reference (`repo`+`subfolder`) before per-node resolution.
+/// At serve time, the launcher resolves the path on each node (reading
+/// `refs/main`) and substitutes `{{<name>}}` in the recipe args/env with the
+/// resolved full HF cache path. The HF cache is already mounted at
+/// `/root/.cache/huggingface`, so the resolved path's symlinks resolve
+/// correctly (a separate bind-mount would break them).
 #[derive(Clone)]
-pub(crate) struct ModelMountSpec {
+pub(crate) struct PathRef {
     pub repo: String,
     pub subfolder: Option<String>,
-    pub container: String,
-    pub read_only: bool,
-    pub env: Option<String>,
+    pub name: String,
 }
 
 /// Partition a recipe's `extra_mounts` into static (resolved now) and
-/// model-subfolder (deferred to per-node resolution). Validates that a mount
-/// is one or the other — `repo` set means `alias`/`host` must be empty, and
-/// `subfolder` without `repo` is an error.
+/// path references (deferred to per-node resolution + substitution).
+/// Validates that a mount is one or the other — `repo` set means
+/// `alias`/`host` must be empty, and `subfolder` without `repo` is an error.
 pub(crate) fn resolve_extra_mounts(
     config: &Config,
     recipe: &Recipe,
 ) -> Result<ResolvedExtraMounts> {
     let mut static_mounts = Vec::new();
-    let mut model_mounts = Vec::new();
+    let mut path_refs = Vec::new();
     for m in &recipe.extra_mounts {
         if m.repo.as_deref().is_some_and(|r| !r.trim().is_empty()) {
             if m.alias.is_some() || m.host.is_some() {
@@ -74,16 +73,14 @@ pub(crate) fn resolve_extra_mounts(
                     m.container
                 );
             }
-            model_mounts.push(ModelMountSpec {
+            path_refs.push(PathRef {
                 repo: m.repo.as_ref().unwrap().trim().to_string(),
                 subfolder: m
                     .subfolder
                     .as_deref()
                     .filter(|s| !s.trim().is_empty())
                     .map(|s| s.trim().to_string()),
-                container: m.container.clone(),
-                read_only: m.read_only,
-                env: m.env.clone(),
+                name: m.container.clone(),
             });
         } else if m.subfolder.as_deref().is_some_and(|s| !s.trim().is_empty()) {
             anyhow::bail!("extra_mount '{}': `subfolder` requires `repo`", m.container);
@@ -105,33 +102,42 @@ pub(crate) fn resolve_extra_mounts(
     }
     Ok(ResolvedExtraMounts {
         static_mounts,
-        model_mounts,
+        path_refs,
     })
 }
 
 /// Result of [`resolve_extra_mounts`]: static mounts ready to clone per node,
-/// model-subfolder specs deferred to per-node resolution.
+/// path references deferred to per-node resolution + substitution.
 pub(crate) struct ResolvedExtraMounts {
     pub static_mounts: Vec<ResolvedMount>,
-    pub model_mounts: Vec<ModelMountSpec>,
+    pub path_refs: Vec<PathRef>,
+}
+
+/// Substitute `{{<name>}}` placeholders in `args` and `env` with resolved paths.
+/// `path_refs` maps placeholder name → resolved path (per-node). Done before
+/// building the docker command so vLLM args reference the correct snapshot.
+pub(crate) fn substitute_path_refs(recipe: &Recipe, path_refs: &[(String, String)]) -> Recipe {
+    let mut r = recipe.clone();
+    for (name, path) in path_refs {
+        let placeholder = format!("{{{{{}}}}}", name);
+        r.args = r.args.substitute(&placeholder, path);
+        for v in r.env.values_mut() {
+            *v = v.replace(&placeholder, path);
+        }
+    }
+    r
 }
 
-/// Dry-run placeholder for a model-subfolder mount: nodes may be off, so no
+/// Dry-run placeholder for a model path reference: nodes may be off, so no
 /// HF cache to read. Emits a descriptive path so the printed `docker run`
-/// command shows *what* would be mounted, not a broken lookup.
-pub(crate) fn model_mount_placeholder(spec: &ModelMountSpec) -> ResolvedMount {
+/// command shows *what* would be substituted, not a broken lookup.
+pub(crate) fn path_ref_placeholder(spec: &PathRef) -> String {
     let sub = spec.subfolder.as_deref().unwrap_or("");
-    let host = format!(
+    format!(
         "<hf_cache>/hub/{}/snapshots/<refs/main>/{}",
         crate::config::model_cache_dir_name(&spec.repo),
         sub
-    );
-    ResolvedMount {
-        host,
-        container: spec.container.clone(),
-        read_only: spec.read_only,
-        env: spec.env.clone(),
-    }
+    )
 }
 
 /// Collect NCCL cluster env vars for multi-node deployments.
@@ -393,16 +399,16 @@ mod tests {
             host: Some("~/.cache/models".into()),
             repo: None,
             subfolder: None,
-            container: "/models:ro".into(),
+            container: "/models".into(),
             read_only: true,
             env: Some("MODEL_DIR".into()),
         });
         let cmd = run_with(&r);
         // alias present in minimal_config() → alias host wins
-        assert!(cmd.contains("-v /mnt/nas/models:/models:ro:ro"));
+        assert!(cmd.contains("-v /mnt/nas/models:/models:ro"));
         assert!(cmd.contains("-v /home/user/.cache/huggingface:/root/.cache/huggingface"));
         // env injected pointing at the container path, before recipe env
-        assert!(cmd.contains("-e MODEL_DIR=/models:ro"));
+        assert!(cmd.contains("-e MODEL_DIR=/models"));
     }
 
     #[test]
@@ -424,40 +430,66 @@ mod tests {
     }
 
     #[test]
-    fn model_subfolder_mount_uses_placeholder_in_dry_run() {
-        // In build_docker_command (no per-node SSH resolution here), a model
-        // mount shows the descriptive placeholder host so the printed command
-        // documents what would be mounted. start_cluster replaces this with
-        // the real resolved path at serve time (or keeps it under dry-run).
+    fn model_path_ref_is_partitioned_not_mounted() {
+        // repo-based mounts are path references, NOT bind-mounts. They are
+        // partitioned into path_refs and resolved per-node via substitution.
         let mut r = base_recipe();
         r.extra_mounts.push(ExtraMount {
             alias: None,
             host: None,
             repo: Some("aidendle94/GLM-5.2-Hybrid-FP8-MXFP4".into()),
             subfolder: Some("mtp-draft".into()),
-            container: "/models/mtp-draft".into(),
+            container: "mtp_draft".into(),
             read_only: true,
-            env: Some("MTP_DRAFT_PATH".into()),
+            env: None,
         });
-        let cmd = run_with(&r);
-        // model_mounts are NOT in extra_mounts here (run_with doesn't resolve
-        // them per-node), so the -v flag for the model mount is absent — only
-        // start_cluster emits it. Verify the spec is parsed + the env is NOT
-        // injected yet (env injection follows extra_mounts, which is empty of
-        // the model mount here). This test guards the partition.
-        assert!(!cmd.contains("-v /models/mtp-draft"));
-        assert!(!cmd.contains("MTP_DRAFT_PATH"));
-        // But the recipe parsed into a model_mount spec:
         let resolved = resolve_extra_mounts(&minimal_config(), &r).unwrap();
-        assert_eq!(resolved.model_mounts.len(), 1);
+        assert_eq!(resolved.path_refs.len(), 1);
         assert_eq!(resolved.static_mounts.len(), 0);
-        let ph = model_mount_placeholder(&resolved.model_mounts[0]);
-        assert_eq!(ph.container, "/models/mtp-draft");
-        assert!(
-            ph.host
-                .contains("models--aidendle94--GLM-5.2-Hybrid-FP8-MXFP4")
+        let pr = &resolved.path_refs[0];
+        assert_eq!(pr.repo, "aidendle94/GLM-5.2-Hybrid-FP8-MXFP4");
+        assert_eq!(pr.subfolder.as_deref(), Some("mtp-draft"));
+        assert_eq!(pr.name, "mtp_draft");
+        // No -v flag for path refs in build_docker_command (substitution only)
+        let cmd = run_with(&r);
+        assert!(!cmd.contains("-v /models/mtp-draft"));
+    }
+
+    #[test]
+    fn substitute_path_refs_replaces_placeholders() {
+        let mut r = base_recipe();
+        r.args = RecipeArgs::List(vec![
+            "--speculative-config".into(),
+            "{\"model\":\"{{mtp_draft}}\"}".into(),
+        ]);
+        let path_refs = vec![(
+            "mtp_draft".to_string(),
+            "/root/.cache/huggingface/hub/models--aidendle94--GLM-5.2-Hybrid-FP8-MXPF4/snapshots/bb0d0858/mtp-draft".to_string(),
+        )];
+        let substituted = substitute_path_refs(&r, &path_refs);
+        if let RecipeArgs::List(args) = &substituted.args {
+            assert_eq!(
+                args[1],
+                "{\"model\":\"/root/.cache/huggingface/hub/models--aidendle94--GLM-5.2-Hybrid-FP8-MXPF4/snapshots/bb0d0858/mtp-draft\"}"
+            );
+        } else {
+            panic!("expected list args");
+        }
+    }
+
+    #[test]
+    fn substitute_path_refs_in_env() {
+        let mut r = base_recipe();
+        r.env
+            .insert("MTP_PATH".to_string(), "{{mtp_draft}}".to_string());
+        let path_refs = vec![(
+            "mtp_draft".to_string(),
+            "/root/.cache/huggingface/hub/models--aidendle94--GLM-5.2-Hybrid-FP8-MXPF4/snapshots/bb0d0858/mtp-draft".to_string(),
+        )];
+        let substituted = substitute_path_refs(&r, &path_refs);
+        assert_eq!(
+            substituted.env["MTP_PATH"],
+            "/root/.cache/huggingface/hub/models--aidendle94--GLM-5.2-Hybrid-FP8-MXPF4/snapshots/bb0d0858/mtp-draft"
         );
-        assert!(ph.host.contains("mtp-draft"));
-        assert!(ph.read_only);
     }
 }

+ 32 - 26
src/docker/mod.rs

@@ -278,7 +278,7 @@ async fn start_cluster(
     } else {
         recipe.tensor_parallel_size
     };
-    // Resolve static mounts once (alias/host + tilde). Model-subfolder mounts
+    // Resolve static mounts once (alias/host + tilde). Model path references
     // (`repo`+`subfolder`) are kept as specs and resolved per node inside the
     // spawn loop, since the HF snapshot hash can differ across nodes.
     let resolved = command::resolve_extra_mounts(config, recipe)?;
@@ -309,28 +309,37 @@ async fn start_cluster(
             extra_mounts: resolved.static_mounts.clone(),
         };
         let hf_cache = config.resolved_hf_cache_dir();
-        let model_specs = resolved.model_mounts.clone();
+        let path_refs = resolved.path_refs.clone();
         handles.push(tokio::spawn(async move {
             if !dry {
                 let _ = run_ssh(&config, &node, cleanup, dry).await;
             }
-            // Resolve model-subfolder mounts per node from that node's HF cache
+            // Resolve model path references per node from that node's HF cache
             // (the snapshot hash can differ across nodes). Dry-run skips SSH
-            // and emits a descriptive placeholder path instead.
-            let mut ctx = dm_ctx.clone();
-            for spec in &model_specs {
-                let resolved_mount = if dry {
-                    command::model_mount_placeholder(spec)
+            // and emits a descriptive placeholder path instead. The resolved
+            // paths are substituted into the recipe args/env via `{{<name>}}`
+            // before building the docker command — no bind-mount is created
+            // for path refs (the HF cache is already mounted at
+            // /root/.cache/huggingface, so symlinks resolve correctly).
+            let mut resolved_refs: Vec<(String, String)> = Vec::new();
+            for spec in &path_refs {
+                let resolved_path = if dry {
+                    command::path_ref_placeholder(spec)
                 } else {
-                    match resolve_model_mount_on_node(&config, &node, spec, &hf_cache).await {
-                        Ok(m) => m,
+                    match resolve_path_ref_on_node(&config, &node, spec, &hf_cache).await {
+                        Ok(p) => p,
                         Err(e) => {
                             return (node.name, node.rank, engine, Err(e));
                         }
                     }
                 };
-                ctx.extra_mounts.push(resolved_mount);
+                resolved_refs.push((spec.name.clone(), resolved_path));
             }
+            let recipe = command::substitute_path_refs(&dm_ctx.recipe, &resolved_refs);
+            let ctx = command::DockerCommandCtx {
+                recipe,
+                ..dm_ctx.clone()
+            };
             let cmd = command::build_docker_command(&ctx, &node, is_head).join(" ");
             let result = run_ssh_with_retry(&config, &node, cmd, dry, 2).await;
             (node.name, node.rank, engine, result)
@@ -359,20 +368,22 @@ async fn start_cluster(
     Ok(())
 }
 
-/// Resolve a model-subfolder mount's host path on a specific node by reading
-/// its HF cache: `<hf_cache>/hub/<model_cache_dir_name(repo)>/snapshots/<refs/main>[/<subfolder>]`.
-/// `refs/main` always points at the latest commit, so the mount survives
+/// Resolve a model path reference's full HF cache path on a specific node by
+/// reading its `refs/main`: `<hf_cache>/hub/<model_cache_dir_name(repo)>/snapshots/<refs/main>[/<subfolder>]`.
+/// `refs/main` always points at the latest commit, so the path survives
 /// `huggingface-cli download` updates (no fragile symlink). If the repo or
 /// subfolder is absent, returns an error pointing at `huggingface-cli download`
 /// (the launcher never downloads).
 ///
-/// Docker requires the bind-mount source to exist, so a `test -d` guards it.
-async fn resolve_model_mount_on_node(
+/// The resolved path is substituted into the recipe args/env via `{{<name>}}`
+/// — no bind-mount is created (the HF cache is already mounted at
+/// `/root/.cache/huggingface`, so the path's symlinks resolve correctly).
+async fn resolve_path_ref_on_node(
     config: &Config,
     node: &Node,
-    spec: &command::ModelMountSpec,
+    spec: &command::PathRef,
     hf_cache: &std::path::Path,
-) -> Result<command::ResolvedMount> {
+) -> Result<String> {
     let model_dir = crate::config::model_cache_dir_name(&spec.repo);
     let sub = spec.subfolder.as_deref().unwrap_or("");
     let hub_dir = format!("{}/hub/{}", hf_cache.display(), model_dir);
@@ -382,8 +393,8 @@ async fn resolve_model_mount_on_node(
          dir=\"{}/snapshots/$ref/{}\" && test -d \"$dir\" && printf '%s' \"$dir\"",
         hub_dir, hub_dir, sub
     );
-    let resolved_host = run_ssh(config, node, cmd, false).await?;
-    if resolved_host.trim().is_empty() {
+    let resolved_path = run_ssh(config, node, cmd, false).await?;
+    if resolved_path.trim().is_empty() {
         let hint = format!(
             "Model subfolder '{}' not found in HF cache on '{}': \
              repo '{}' is not downloaded there or 'refs/main' is missing. \
@@ -392,12 +403,7 @@ async fn resolve_model_mount_on_node(
         );
         anyhow::bail!(hint);
     }
-    Ok(command::ResolvedMount {
-        host: resolved_host,
-        container: spec.container.clone(),
-        read_only: spec.read_only,
-        env: spec.env.clone(),
-    })
+    Ok(resolved_path)
 }
 
 #[cfg(test)]

+ 14 - 0
src/pre_serve.rs

@@ -96,6 +96,20 @@ impl RecipeArgs {
             RecipeArgs::List(v) => v.clone(),
         }
     }
+
+    /// Replace all occurrences of `placeholder` with `replacement` in each
+    /// arg string. Used to substitute model path references (e.g.
+    /// `{{mtp_draft}}`) with resolved HF cache paths per-node.
+    pub fn substitute(&self, placeholder: &str, replacement: &str) -> RecipeArgs {
+        match self {
+            RecipeArgs::Inline(s) => RecipeArgs::Inline(s.replace(placeholder, replacement)),
+            RecipeArgs::List(v) => RecipeArgs::List(
+                v.iter()
+                    .map(|a| a.replace(placeholder, replacement))
+                    .collect(),
+            ),
+        }
+    }
 }
 
 impl std::fmt::Display for DistributedBackend {