Bladeren bron

web/report: settings button — per-agent client config for a recipe's served model

The recipes panel gains a settings button after the bench button. It opens a
dialog that answers "how do I point my agent at this lane?": pick the agent
(pi, Claude Code, Codex, Cline/Roo, generic OpenAI client), tick "include
provider" to get the endpoint/provider section as well, and copy the block.

Server side is pure (GET /api/model-settings?recipe=<name>, new
src/web/model_settings.rs): the served name (--served-model-name, else the
repo leaf), the context window (--max-model-len), the reasoning/tools flags
and the recipe title are read straight out of the recipe; the endpoint is the
node that currently hosts the recipe's container (fleet snapshot), else the
recipe's cluster rank-0 node, else a placeholder. Blocks are paste-ready (no
comment lines — those move to a hint under the block).

Also fixed two test-hygiene problems the new endpoint surfaced:
- the cwd race: the repo-route test chdirs while cwd-sensitive tests (raw
  config, recipe search) run in parallel threads — now serialized through a
  new ui_test_lock::cwd_guard();
- both Playwright suites resolved their script path relative to the cwd —
  now CARGO_MANIFEST_DIR-anchored.

Housekeeping to keep `make check` green (the 500-line gate was already red at
HEAD): bench target resolution + the tool runner moved to src/bench_target.rs,
the tmux-ignored-flags list moved to cli_sub.rs, and the model-settings tests
live in src/web/model_settings_tests.rs.
Alexander Korolev 2 dagen geleden
bovenliggende
commit
fd30bf160a
13 gewijzigde bestanden met toevoegingen van 927 en 164 verwijderingen
  1. 6 146
      src/bench.rs
  2. 151 0
      src/bench_target.rs
  3. 18 0
      src/cli_sub.rs
  4. 2 13
      src/main.rs
  5. 100 1
      src/report/page.html
  6. 1 0
      src/report/tests.rs
  7. 4 1
      src/report/ui_tests.rs
  8. 15 0
      src/ui_test_lock.rs
  9. 55 1
      src/web/api_tests.rs
  10. 2 0
      src/web/mod.rs
  11. 445 0
      src/web/model_settings.rs
  12. 121 0
      src/web/model_settings_tests.rs
  13. 7 2
      src/web/tests.rs

+ 6 - 146
src/bench.rs

@@ -6,13 +6,12 @@
 //! default; `sm121 bench -d` prints the exact command.
 
 use std::fs;
-use std::path::{Path, PathBuf};
-use std::process::Stdio;
-use std::sync::{Arc, Mutex};
+use std::path::PathBuf;
 
 use anyhow::Result;
 
-use crate::config::{Config, Recipe, load_recipe, select_cluster_by_name};
+use crate::bench_target::{pick_head, run_tool, sanitize, served_model};
+use crate::config::{Config, load_recipe, select_cluster_by_name};
 
 /// Fields of the `sm121 bench` subcommand (mirrored by the clap variant in
 /// `cli.rs`). Served name/port come from the recipe; the tool is on PATH.
@@ -56,146 +55,6 @@ pub struct Args {
 }
 
 /// Served model name from the recipe's `--served-model-name` arg.
-fn served_model(recipe: &Recipe) -> Option<String> {
-    let args = recipe.args.to_vec();
-    args.iter()
-        .position(|s| s == "--served-model-name")
-        .and_then(|i| args.get(i + 1))
-        .cloned()
-}
-
-/// Bench target node: the explicit `--node` override, else rank 0 (falling
-/// back to the first node). `--node` matters for serves pinned with
-/// `serve --node <n>` — the run's head may not be the cluster's rank 0
-/// (e.g. a solo Bolt on dgx3 inside `4nodes-switch` while dgx1 serves
-/// something else entirely).
-fn pick_head<'a>(
-    nodes: &'a [crate::config::Node],
-    cluster_name: &str,
-    node: Option<&str>,
-) -> anyhow::Result<&'a crate::config::Node> {
-    match node {
-        Some(name) => {
-            let bare = name.strip_suffix(".lan").unwrap_or(name);
-            nodes
-                .iter()
-                .find(|n| n.name.strip_suffix(".lan").unwrap_or(&n.name) == bare)
-                .ok_or_else(|| {
-                    anyhow::anyhow!("node '{}' is not part of cluster '{}'", name, cluster_name)
-                })
-        }
-        None => nodes
-            .iter()
-            .find(|n| n.rank == 0)
-            .or_else(|| nodes.first())
-            .ok_or_else(|| anyhow::anyhow!("cluster '{}' has no nodes", cluster_name)),
-    }
-}
-
-/// Keep the label filesystem-safe.
-fn sanitize(label: &str) -> String {
-    label
-        .chars()
-        .map(|c| {
-            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
-                c
-            } else {
-                '-'
-            }
-        })
-        .collect()
-}
-
-/// Run the bench tool in `cwd`, teeing stdout+stderr to the console and the
-/// log file; returns the exit code.
-///
-/// `append_log = false` truncates the log (main campaign phase); `true`
-/// appends (the depth-suite phase shares one log so its rows land in the
-/// report's Throughput table).
-///
-/// The child runs under a pty (`script -q /dev/null`) instead of raw pipes:
-/// tool-eval-bench's rich Live display deadlocks on a non-tty stdout pipe
-/// right after the perf phase (deterministic on the 2x lane; 4x runs got
-/// lucky) while it runs clean on a tty (2026-09-09). The wrapper is
-/// macOS-only (BSD `script`); elsewhere we fall back to the direct exec.
-///
-/// The pty makes the tool think it is interactive — rich redraws its
-/// spinner/progress bars in place (\r soup on one line) that once leaked
-/// into the saved .log (glm53-flash-eugr-2x, 2026-09-12).
-/// `TTY_COMPATIBLE=0` (rich's own knob) renders non-interactively (bars
-/// silent, one final frame) while the pty keeps the deadlock workaround;
-/// `NO_COLOR=1` covers non-rich output.
-fn run_tool(
-    bin: &str,
-    args: &[String],
-    cwd: &Path,
-    log_path: &Path,
-    append_log: bool,
-    ssh_host: Option<&str>,
-) -> Result<i32> {
-    let file = Arc::new(Mutex::new(if append_log {
-        fs::OpenOptions::new()
-            .create(true)
-            .append(true)
-            .open(log_path)?
-    } else {
-        fs::File::create(log_path)?
-    }));
-    // The command: local (the TTY wrapper or direct exec) or remote (SSH to
-    // the host — a pty via `ssh -t` keeps the rich Live display from
-    // deadlocking on the non-tty pipe; `stty -onlcr` keeps the log LF-only;
-    // TTY_COMPATIBLE/NO_COLOR match the local run). For the remote host the
-    // `cwd` is a remote path embedded in the shell string, so the local
-    // current_dir/env are skipped.
-    let mut cmd = if let Some(host) = ssh_host {
-        crate::bench_remote::remote_command(host, cwd, bin, args)
-    } else if cfg!(target_os = "macos")
-        && std::process::Command::new("/usr/bin/script")
-            .arg("-q")
-            .arg("/dev/null")
-            .arg("true")
-            .output()
-            .is_ok()
-    {
-        // Wrap in `sh` + `stty -onlcr`: the macOS pty's ONLCR would turn every
-        // `\n` the tool writes into `\r\n` on disk, and the report viewer
-        // then renders each line twice (blank line after every line).
-        // `stty -onlcr` disables the post-processing at the source; `exec`
-        // replaces the shell so the tool keeps the pty and the exit code.
-        let mut c = std::process::Command::new("/usr/bin/script");
-        c.arg("-q");
-        c.arg("/dev/null");
-        c.arg("sh");
-        c.arg("-c");
-        c.arg("stty -onlcr 2>/dev/null; exec \"$@\"");
-        c.arg("sh"); // $0 inside the -c script
-        c.arg(bin);
-        c.args(args);
-        c
-    } else {
-        let mut c = std::process::Command::new(bin);
-        c.args(args);
-        c
-    };
-    if ssh_host.is_none() {
-        cmd.current_dir(cwd);
-        cmd.env("TTY_COMPATIBLE", "0");
-        cmd.env("NO_COLOR", "1");
-    }
-    cmd.stdout(Stdio::piped());
-    cmd.stderr(Stdio::piped());
-    let mut child = cmd.spawn()?;
-    let stdout_pipe = child.stdout.take().expect("stdout pipe");
-    let stderr_pipe = child.stderr.take().expect("stderr pipe");
-    let f1 = file.clone();
-    let t1 = std::thread::spawn(move || crate::bench_io::pump(stdout_pipe, true, f1));
-    let t2 = std::thread::spawn(move || crate::bench_io::pump(stderr_pipe, false, file));
-    let status = child.wait()?;
-    t1.join().ok();
-    t2.join().ok();
-    Ok(status.code().unwrap_or(-1))
-}
-
 /// Run the bench and land `recipes/<lane>/reports/<stamp>_<label>.{md,json,log}`.
 pub async fn run(config: &Config, a: &Args, dry_run: bool) -> Result<()> {
     let recipe = load_recipe(&a.recipe, &config.recipe_sources).await?;
@@ -439,8 +298,9 @@ pub async fn run(config: &Config, a: &Args, dry_run: bool) -> Result<()> {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::config::Node;
-    use std::process::Command;
+    use crate::config::{Node, Recipe};
+    use std::process::{Command, Stdio};
+    use std::sync::{Arc, Mutex};
 
     fn node(name: &str, rank: u32) -> Node {
         Node {

+ 151 - 0
src/bench_target.rs

@@ -0,0 +1,151 @@
+//! Bench target resolution + the tool runner, split out of `bench.rs` at the
+//! 500-line cap: the served model name, the host node (`--node` / rank 0), a
+//! filesystem-safe label, and the pty-wrapped `tool-eval-bench` invocation
+//! that tees its output into the log file.
+
+use crate::config::Recipe;
+use anyhow::Result;
+use std::fs;
+use std::path::Path;
+use std::process::Stdio;
+use std::sync::{Arc, Mutex};
+
+pub(crate) fn served_model(recipe: &Recipe) -> Option<String> {
+    let args = recipe.args.to_vec();
+    args.iter()
+        .position(|s| s == "--served-model-name")
+        .and_then(|i| args.get(i + 1))
+        .cloned()
+}
+
+/// Bench target node: the explicit `--node` override, else rank 0 (falling
+/// back to the first node). `--node` matters for serves pinned with
+/// `serve --node <n>` — the run's head may not be the cluster's rank 0
+/// (e.g. a solo Bolt on dgx3 inside `4nodes-switch` while dgx1 serves
+/// something else entirely).
+pub(crate) fn pick_head<'a>(
+    nodes: &'a [crate::config::Node],
+    cluster_name: &str,
+    node: Option<&str>,
+) -> anyhow::Result<&'a crate::config::Node> {
+    match node {
+        Some(name) => {
+            let bare = name.strip_suffix(".lan").unwrap_or(name);
+            nodes
+                .iter()
+                .find(|n| n.name.strip_suffix(".lan").unwrap_or(&n.name) == bare)
+                .ok_or_else(|| {
+                    anyhow::anyhow!("node '{}' is not part of cluster '{}'", name, cluster_name)
+                })
+        }
+        None => nodes
+            .iter()
+            .find(|n| n.rank == 0)
+            .or_else(|| nodes.first())
+            .ok_or_else(|| anyhow::anyhow!("cluster '{}' has no nodes", cluster_name)),
+    }
+}
+
+/// Keep the label filesystem-safe.
+pub(crate) fn sanitize(label: &str) -> String {
+    label
+        .chars()
+        .map(|c| {
+            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
+                c
+            } else {
+                '-'
+            }
+        })
+        .collect()
+}
+
+/// Run the bench tool in `cwd`, teeing stdout+stderr to the console and the
+/// log file; returns the exit code.
+///
+/// `append_log = false` truncates the log (main campaign phase); `true`
+/// appends (the depth-suite phase shares one log so its rows land in the
+/// report's Throughput table).
+///
+/// The child runs under a pty (`script -q /dev/null`) instead of raw pipes:
+/// tool-eval-bench's rich Live display deadlocks on a non-tty stdout pipe
+/// right after the perf phase (deterministic on the 2x lane; 4x runs got
+/// lucky) while it runs clean on a tty (2026-09-09). The wrapper is
+/// macOS-only (BSD `script`); elsewhere we fall back to the direct exec.
+///
+/// The pty makes the tool think it is interactive — rich redraws its
+/// spinner/progress bars in place (\r soup on one line) that once leaked
+/// into the saved .log (glm53-flash-eugr-2x, 2026-09-12).
+/// `TTY_COMPATIBLE=0` (rich's own knob) renders non-interactively (bars
+/// silent, one final frame) while the pty keeps the deadlock workaround;
+/// `NO_COLOR=1` covers non-rich output.
+pub(crate) fn run_tool(
+    bin: &str,
+    args: &[String],
+    cwd: &Path,
+    log_path: &Path,
+    append_log: bool,
+    ssh_host: Option<&str>,
+) -> Result<i32> {
+    let file = Arc::new(Mutex::new(if append_log {
+        fs::OpenOptions::new()
+            .create(true)
+            .append(true)
+            .open(log_path)?
+    } else {
+        fs::File::create(log_path)?
+    }));
+    // The command: local (the TTY wrapper or direct exec) or remote (SSH to
+    // the host — a pty via `ssh -t` keeps the rich Live display from
+    // deadlocking on the non-tty pipe; `stty -onlcr` keeps the log LF-only;
+    // TTY_COMPATIBLE/NO_COLOR match the local run). For the remote host the
+    // `cwd` is a remote path embedded in the shell string, so the local
+    // current_dir/env are skipped.
+    let mut cmd = if let Some(host) = ssh_host {
+        crate::bench_remote::remote_command(host, cwd, bin, args)
+    } else if cfg!(target_os = "macos")
+        && std::process::Command::new("/usr/bin/script")
+            .arg("-q")
+            .arg("/dev/null")
+            .arg("true")
+            .output()
+            .is_ok()
+    {
+        // Wrap in `sh` + `stty -onlcr`: the macOS pty's ONLCR would turn every
+        // `\n` the tool writes into `\r\n` on disk, and the report viewer
+        // then renders each line twice (blank line after every line).
+        // `stty -onlcr` disables the post-processing at the source; `exec`
+        // replaces the shell so the tool keeps the pty and the exit code.
+        let mut c = std::process::Command::new("/usr/bin/script");
+        c.arg("-q");
+        c.arg("/dev/null");
+        c.arg("sh");
+        c.arg("-c");
+        c.arg("stty -onlcr 2>/dev/null; exec \"$@\"");
+        c.arg("sh"); // $0 inside the -c script
+        c.arg(bin);
+        c.args(args);
+        c
+    } else {
+        let mut c = std::process::Command::new(bin);
+        c.args(args);
+        c
+    };
+    if ssh_host.is_none() {
+        cmd.current_dir(cwd);
+        cmd.env("TTY_COMPATIBLE", "0");
+        cmd.env("NO_COLOR", "1");
+    }
+    cmd.stdout(Stdio::piped());
+    cmd.stderr(Stdio::piped());
+    let mut child = cmd.spawn()?;
+    let stdout_pipe = child.stdout.take().expect("stdout pipe");
+    let stderr_pipe = child.stderr.take().expect("stderr pipe");
+    let f1 = file.clone();
+    let t1 = std::thread::spawn(move || crate::bench_io::pump(stdout_pipe, true, f1));
+    let t2 = std::thread::spawn(move || crate::bench_io::pump(stderr_pipe, false, file));
+    let status = child.wait()?;
+    t1.join().ok();
+    t2.join().ok();
+    Ok(status.code().unwrap_or(-1))
+}

+ 18 - 0
src/cli_sub.rs

@@ -195,3 +195,21 @@ pub struct SyncImageArgs {
     #[arg(long)]
     pub source: Option<String>,
 }
+
+/// Docker-only launcher flags that the operator actually set — a
+/// `deploy = "tmux"` serve warns about each one and ignores it.
+pub fn tmux_unsupported_opts(opts: &crate::docker::ServeOpts) -> Vec<&'static str> {
+    [
+        (opts.sync_image, "--sync-image"),
+        (opts.sync_image_source.is_some(), "--sync-image-source"),
+        (opts.build_node.is_some(), "--build-node"),
+        (opts.force_build, "--force-build"),
+        (opts.keep, "--keep"),
+        (opts.solo.is_some(), "--solo/--no-solo"),
+        (opts.mirror.is_some(), "--mirror"),
+        (opts.fit.is_some(), "--fit"),
+    ]
+    .iter()
+    .filter_map(|(set, flag)| if *set { Some(*flag) } else { None })
+    .collect()
+}

+ 2 - 13
src/main.rs

@@ -26,6 +26,7 @@ mod bench_depth;
 mod bench_io;
 mod bench_remote;
 mod bench_report;
+mod bench_target;
 mod builtin_scripts;
 mod cli;
 mod cli_sub;
@@ -460,19 +461,7 @@ pub(crate) async fn handle_serve(
         }
         DeployTarget::Tmux => {
             // Warn about docker-only CLI opts that are silently ignored.
-            let ignored: Vec<&str> = [
-                (opts.sync_image, "--sync-image"),
-                (opts.sync_image_source.is_some(), "--sync-image-source"),
-                (opts.build_node.is_some(), "--build-node"),
-                (opts.force_build, "--force-build"),
-                (opts.keep, "--keep"),
-                (opts.solo.is_some(), "--solo/--no-solo"),
-                (opts.mirror.is_some(), "--mirror"),
-                (opts.fit.is_some(), "--fit"),
-            ]
-            .iter()
-            .filter_map(|(set, flag)| if *set { Some(*flag) } else { None })
-            .collect();
+            let ignored = crate::cli_sub::tmux_unsupported_opts(&opts);
             if !ignored.is_empty() {
                 tracing::warn!(
                     "{}",

+ 100 - 1
src/report/page.html

@@ -54,6 +54,24 @@
   @keyframes ring { 0% { transform: rotate(0) } 20% { transform: rotate(13deg) } 45% { transform: rotate(-11deg) } 70% { transform: rotate(6deg) } 100% { transform: rotate(0) } }
   #bell.ring svg { animation: ring 0.6s ease; transform-origin: 50% 0; }
   .dim { color: gray; }
+  /* Model-settings dialog (⚙ settings): per-agent client config for the
+     recipe's served model. */
+  #msdlg { position: fixed; inset: 0; z-index: 40; display: none; align-items: center; justify-content: center;
+           background: rgba(0, 0, 0, .45); }
+  #msdlg.on { display: flex; }
+  #msdlg .panel { background: canvas; color: canvastext; border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
+                  border-radius: 8px; padding: 14px 16px; width: min(760px, 94vw); max-height: 88vh; overflow: auto;
+                  box-shadow: 0 12px 40px rgba(0, 0, 0, .4); }
+  #msdlg h2 { font-size: 15px; margin: 0 0 4px; }
+  #msdlg .ep { font-family: ui-monospace, monospace; font-size: 11px; color: gray; margin-bottom: 10px; word-break: break-all; }
+  #msdlg .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 8px; }
+  #msdlg label.inc { font-size: 12px; display: inline-flex; gap: 5px; align-items: center; cursor: pointer; }
+  #msdlg .tgt { font-family: ui-monospace, monospace; font-size: 11px; color: gray; margin: 0 0 4px; }
+  #msdlg pre { margin: 0 0 10px; padding: 10px 12px; border-radius: 6px; font-size: 12px; line-height: 1.4; overflow: auto; max-height: 46vh;
+               background: color-mix(in srgb, currentColor 6%, canvas); border: 1px solid color-mix(in srgb, currentColor 18%, transparent); }
+  #msdlg .foot { display: flex; gap: 8px; justify-content: flex-end; }
+  #msdlg .err { color: #c0392b; font-size: 12px; }
+  #msdlg .meta { font-size: 11px; color: gray; margin-bottom: 8px; }
   /* Embedded in the dashboard's Recipes window: the window bar carries the
      title and the dashboard header carries the connection indicator. Both
      SSE streams come from the same server (only the paths differ), so the
@@ -105,6 +123,26 @@
   <div id="notifbox" style="display:none"></div>
 </div>
 <table id="tbl"><thead></thead><tbody></tbody></table>
+<div id="msdlg" role="dialog" aria-modal="true" aria-labelledby="msdtitle">
+  <div class="panel">
+    <h2 id="msdtitle">Model settings</h2>
+    <div class="ep" id="msdep"></div>
+    <div class="row">
+      <select id="msagent" title="pick the agent you want settings for"></select>
+      <label class="inc" title="include the endpoint/provider section (base URL, api key, wire api) as well as the model itself">
+        <input type="checkbox" id="msprov"> include provider
+      </label>
+    </div>
+    <div class="meta" id="msmeta"></div>
+    <div class="tgt" id="mstgt"></div>
+    <pre id="mscode"></pre>
+    <div class="err" id="mserr"></div>
+    <div class="foot">
+      <button id="mscopy">copy</button>
+      <button id="msclose">close</button>
+    </div>
+  </div>
+</div>
 <script>
 "use strict";
 // Auth (dashboard embedding): the same-origin dashboard page stores the
@@ -579,7 +617,8 @@ function setNote(recipe, text) {
 // dashboard via postMessage — same window as the cluster overview, not an
 // inline stream), and a bench launch (POST /api/bench — the standard campaign
 // as a job; the report lands in the shared recipe repo and re-appears in this
-// table via the live stream).
+// table via the live stream). The settings button opens the model-settings
+// dialog (per-agent client config, endpoint included on request).
 function actionBar(r) {
   if (!EMBED) return "";
   const running = r.up != null || r.fleet_up;
@@ -588,6 +627,7 @@ function actionBar(r) {
     : `<button class="act" data-act="serve" data-r="${esc(r.name)}" title="serve (dashboard job, cluster auto)">▶ serve</button>`;
   if (running) b += `<button class="act" data-act="logs" data-r="${esc(r.name)}" title="open a logs window (head node)">▤ logs</button>`;
   b += `<button class="act" data-act="bench" data-r="${esc(r.name)}" title="standard bench (seed 42, hardmode, perf c1,2) as a dashboard job — the report lands in the recipe repo">⚡ bench</button>`;
+  b += `<button class="act" data-act="settings" data-r="${esc(r.name)}" title="client settings for this recipe's model — pick your agent (pi, Claude Code, Codex, Cline, generic) and copy the block">⚙ settings</button>`;
   return `<div class="actbar">${b}<span class="actnote" data-r="${esc(r.name)}">${esc(NOTES[r.name] || "")}</span></div>`;
 }
 
@@ -597,6 +637,7 @@ document.addEventListener("click", async (e) => {
   e.stopPropagation(); // no row toggle under the action button
   const kind = b.dataset.act, recipe = b.dataset.r;
   if (kind === "logs") { parent.postMessage({ sm121: "openLogs", recipe }, "*"); return; }
+  if (kind === "settings") { msOpen(recipe); return; }
   const path = kind === "serve" ? "/api/serve" : kind === "stop" ? "/api/stop" : "/api/bench";
   b.disabled = true;
   try {
@@ -611,6 +652,64 @@ document.addEventListener("click", async (e) => {
   } catch (err) { setNote(recipe, `✗ ${kind}: ` + err); }
   setTimeout(() => { b.disabled = false; }, 3000);
 });
+// ---- model settings dialog (⚙ settings) -----------------------------------
+// Client configuration for the recipe's served model, per agent. The server
+// derives everything from the recipe file (served name, context window,
+// reasoning/tools flags) plus the endpoint (the node currently hosting it,
+// else the recipe's cluster head node), so the dialog opens instantly.
+const MS = { recipe: null, data: null, cache: {} };
+const msdlg = document.getElementById("msdlg");
+function msRender(agentId) {
+  const d = MS.data; if (!d) return;
+  const withProv = document.getElementById("msprov").checked;
+  const a = (d.agents || []).find(x => x.id === agentId) || (d.agents || [])[0];
+  if (!a) return;
+  document.getElementById("mstgt").textContent = a.target ? a.target : "";
+  document.getElementById("mscode").textContent = withProv ? a.full : a.model;
+  const m = d.model, e = d.endpoint;
+  document.getElementById("msdep").textContent =
+    `${e.api_url} · ${e.host}${e.node ? " (" + e.node + ")" : ""}${e.cluster ? " · " + e.cluster : ""}` +
+    (e.running ? " · running" : " · not running");
+  document.getElementById("msmeta").textContent =
+    `${m.served} · ${m.engine} · context ${m.context}${m.context_known ? "" : " (assumed)"}` +
+    `${m.reasoning ? " · reasoning" : ""}${m.tools ? " · tools" : ""} · repo ${m.repo}`;
+  document.getElementById("msagent").value = a.id;
+}
+async function msOpen(recipe) {
+  MS.recipe = recipe;
+  document.getElementById("msdlg").classList.add("on");
+  document.getElementById("mserr").textContent = "";
+  document.getElementById("mscode").textContent = "loading…";
+  document.getElementById("msdtitle").textContent = `Model settings — ${recipe}`;
+  let d = MS.cache[recipe];
+  if (!d) {
+    try {
+      const res = await fetch("/api/model-settings?recipe=" + encodeURIComponent(recipe));
+      const j = await res.json().catch(() => ({}));
+      if (!res.ok) throw new Error(j.error || j.message || String(res.status));
+      d = MS.cache[recipe] = j;
+    } catch (err) {
+      document.getElementById("mscode").textContent = "";
+      document.getElementById("mserr").textContent = "✗ " + err;
+      return;
+    }
+  }
+  MS.data = d;
+  const sel = document.getElementById("msagent");
+  sel.innerHTML = d.agents.map(a => `<option value="${esc(a.id)}">${esc(a.label)}</option>`).join("");
+  document.getElementById("msprov").checked = true;
+  msRender(d.agents[0] && d.agents[0].id);
+}
+document.getElementById("msagent").addEventListener("change", e => msRender(e.target.value));
+document.getElementById("msprov").addEventListener("change", () => msRender(document.getElementById("msagent").value));
+document.getElementById("msclose").addEventListener("click", () => msdlg.classList.remove("on"));
+msdlg.addEventListener("click", e => { if (e.target === msdlg) msdlg.classList.remove("on"); });
+document.addEventListener("keydown", e => { if (e.key === "Escape") msdlg.classList.remove("on"); });
+document.getElementById("mscopy").addEventListener("click", async (e) => {
+  const t = document.getElementById("mscode").textContent;
+  try { await navigator.clipboard.writeText(t); e.target.textContent = "copied ✓";
+        setTimeout(() => { e.target.textContent = "copy"; }, 1200); } catch { /* clipboard blocked */ }
+});
 const syncBtn = document.getElementById("syncbtn");
 if (EMBED) syncBtn.style.display = "";
 syncBtn.addEventListener("click", async () => {

+ 1 - 0
src/report/tests.rs

@@ -456,6 +456,7 @@ async fn repo_route_resolves_from_configured_sources() {
     // The add-on case: the process cwd has no docker-images/ — the synced
     // recipe-repo roots come from config.docker_image_sources instead.
     use crate::web::router;
+    let _cwd = crate::ui_test_lock::cwd_guard().await; // no other test may be cwd-sensitive now
     let base = std::env::temp_dir().join(format!("sm121-repo-route-{}", std::process::id()));
     std::fs::create_dir_all(base.join("docker-images/kindling")).unwrap();
     std::fs::write(

+ 4 - 1
src/report/ui_tests.rs

@@ -214,7 +214,10 @@ async fn report_playwright_checks() {
         std::process::Command::new("deno")
             .arg("run")
             .arg("-A")
-            .arg("src/report/report.playwright.mjs")
+            .arg(concat!(
+                env!("CARGO_MANIFEST_DIR"),
+                "/src/report/report.playwright.mjs"
+            ))
             .env("SM121_REPORT_URL", url)
             .env("SM121_REPORT_FIXTURE", fixture_path)
             .output()

+ 15 - 0
src/ui_test_lock.rs

@@ -14,6 +14,21 @@ use std::sync::{Mutex, MutexGuard, PoisonError};
 
 pub static PLAYWRIGHT_LOCK: Mutex<()> = Mutex::new(());
 
+/// Serializes the tests that change the process cwd (`set_current_dir`) with
+/// the tests that read cwd-relative state (`./config.toml`, the recipe search
+/// order). Cargo runs test functions in parallel threads of one process, so a
+/// chdir in one test would otherwise re-point the others mid-flight. A tokio
+/// mutex (not std) because the holders await their HTTP round-trips.
+fn cwd_mutex() -> &'static tokio::sync::Mutex<()> {
+    static LOCK: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
+    LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
+}
+
+/// Acquire the cwd lock; held until the guard is dropped.
+pub async fn cwd_guard() -> tokio::sync::MutexGuard<'static, ()> {
+    cwd_mutex().lock().await
+}
+
 /// Acquire the Playwright session lock; held until the guard is dropped.
 /// Tolerates a poisoned lock (a panicked holder) — the browser is the
 /// shared resource, not the lock's payload.

+ 55 - 1
src/web/api_tests.rs

@@ -1,7 +1,7 @@
 //! Web API round-trip tests (P2 actions, P3 log windows, P4 raw content) —
 //! split from `tests.rs` at the 500-line cap. Shared helpers live there.
 
-use super::tests::{app_state, boot, call};
+use super::tests::{app_state, boot, boot_with, call};
 use crate::web::events::Event;
 use axum::http::StatusCode;
 
@@ -195,6 +195,7 @@ async fn log_event_serializes_with_type_tag() {
 
 #[tokio::test]
 async fn raw_endpoints_serve_config_recipe_effective() {
+    let _cwd = crate::ui_test_lock::cwd_guard().await; // reads ./config.toml
     let (addr, _h) = boot().await;
     let base = format!("http://{addr}");
     let client = reqwest::Client::new();
@@ -273,3 +274,56 @@ async fn raw_endpoints_serve_config_recipe_effective() {
         "traversal name refused"
     );
 }
+
+/// The recipes panel's ⚙ settings button: the dialog body for a recipe that
+/// lives in a recipe source — endpoint (from the recipe's cluster head node),
+/// the model facts parsed out of `args`, and one block per agent.
+#[tokio::test]
+async fn model_settings_endpoint_renders_agent_blocks() {
+    let _cwd = crate::ui_test_lock::cwd_guard().await; // load_recipe probes cwd-relative paths
+    let dir = std::env::temp_dir().join(format!("sm121-ms-{}", std::process::id()));
+    let lane = dir.join("lane-x");
+    tokio::fs::create_dir_all(&lane).await.unwrap();
+    tokio::fs::write(
+        lane.join("recipe.toml"),
+        r#"
+engine = "vllm"
+name = "Lane X"
+model = "org/model-x"
+cluster = "fake"
+args = ["--served-model-name", "mx", "--max-model-len", "131072",
+        "--reasoning-parser", "deepseek_r1", "--enable-auto-tool-choice"]
+"#,
+    )
+    .await
+    .unwrap();
+    let mut cfg = super::tests::fake_config();
+    cfg.recipe_sources = vec![dir.to_string_lossy().to_string()];
+    let (addr, _h) = boot_with(cfg).await;
+
+    let res = call(addr, "/api/model-settings?recipe=lane-x").await;
+    assert_eq!(res.status(), StatusCode::OK);
+    let v: serde_json::Value = res.json().await.unwrap();
+    assert_eq!(v["model"]["served"], "mx");
+    assert_eq!(v["model"]["context"], 131_072);
+    assert_eq!(v["model"]["reasoning"], true);
+    assert!(v["endpoint"]["api_url"].as_str().unwrap().ends_with("/v1"));
+    let ids: Vec<String> = v["agents"]
+        .as_array()
+        .unwrap()
+        .iter()
+        .map(|a| a["id"].as_str().unwrap().to_string())
+        .collect();
+    assert!(ids.contains(&"pi".to_string()) && ids.contains(&"claude-code".to_string()));
+    // both variants exist and the model-only one carries no endpoint
+    let pi = &v["agents"][0];
+    assert!(pi["full"].as_str().unwrap().contains("baseUrl"));
+    assert!(!pi["model"].as_str().unwrap().contains("baseUrl"));
+
+    // unknown recipe → 404, traversal-ish name → 400
+    let nf = call(addr, "/api/model-settings?recipe=nope").await;
+    assert_eq!(nf.status(), StatusCode::NOT_FOUND);
+    let bad = call(addr, "/api/model-settings?recipe=../etc").await;
+    assert_eq!(bad.status(), StatusCode::BAD_REQUEST);
+    let _ = tokio::fs::remove_dir_all(&dir).await;
+}

+ 2 - 0
src/web/mod.rs

@@ -12,6 +12,7 @@ mod api;
 pub mod events;
 pub mod jobs;
 pub mod logs;
+pub mod model_settings;
 pub mod probe;
 pub mod ssh_stream;
 pub mod topo;
@@ -261,6 +262,7 @@ pub fn router(app: AppState) -> Router {
         .route("/api/serve", post(api::serve))
         .route("/api/stop", post(api::stop))
         .route("/api/bench", post(api::bench))
+        .route("/api/model-settings", get(model_settings::handle))
         .route("/api/power", post(api::power))
         .route("/api/recipes", get(api::recipes))
         .route("/api/repos/sync", post(api::repos_sync))

+ 445 - 0
src/web/model_settings.rs

@@ -0,0 +1,445 @@
+//! `GET /api/model-settings?recipe=<name>` — the recipes panel's "⚙ settings"
+//! dialog: the *client* configuration for the recipe's served model, laid out
+//! per agent (pi, Claude Code, Codex, Cline/Roo, a generic OpenAI client).
+//!
+//! Pure by design: everything is derived from the recipe file plus the
+//! endpoint (head node + port) the caller resolved — no SSH, no probe, so the
+//! dialog answers instantly even when the cluster is busy. The endpoint is
+//! informational; the snippets are copy-paste ready.
+
+use crate::config::Config;
+use crate::config::Recipe;
+use crate::web::events::Snapshot;
+use axum::{extract::Query, extract::State, http::StatusCode, response::Json};
+use serde::Deserialize;
+use serde_json::{Value, json};
+
+/// Where the recipe is (or would be) served from.
+#[derive(Clone, Debug)]
+pub struct Endpoint {
+    /// Cluster the recipe runs on (empty when unknown).
+    pub cluster: String,
+    /// Head node name (`dgx5.lan`) — empty when unknown.
+    pub node: String,
+    /// Host part of the API URL (`192.168.88.43`), or a placeholder.
+    pub host: String,
+    /// The recipe has a live container right now.
+    pub running: bool,
+}
+
+impl Endpoint {
+    fn base(&self, port: u16) -> String {
+        format!("http://{}:{port}", self.host)
+    }
+}
+
+/// The handful of model facts every client config needs.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ModelInfo {
+    /// Name the API answers to (`--served-model-name`, else the repo's leaf).
+    pub served: String,
+    /// Human title (the recipe's `name`).
+    pub title: String,
+    /// HF repo id.
+    pub repo: String,
+    pub engine: String,
+    /// Context window in tokens (`--max-model-len`, else a conservative 32K).
+    pub context: u64,
+    /// Context came from the recipe (not from the 32K fallback).
+    pub context_known: bool,
+    /// Recommended client output cap.
+    pub max_output: u64,
+    /// A reasoning parser is configured (the model thinks out loud).
+    pub reasoning: bool,
+    /// Tools/function calling are wired (`--enable-auto-tool-choice`).
+    pub tools: bool,
+}
+
+impl ModelInfo {
+    /// Derive from a recipe: `--served-model-name`, `--max-model-len` and the
+    /// reasoning/tool flags are read straight out of `args`.
+    pub fn from_recipe(r: &Recipe) -> Self {
+        let args = r.args.to_vec();
+        let flag = |name: &str| -> Option<String> {
+            args.iter()
+                .position(|a| a == name || a.starts_with(&format!("{name}=")))
+                .and_then(|i| match args.get(i) {
+                    Some(a) if a.contains('=') => a.split_once('=').map(|(_, v)| v.to_string()),
+                    _ => args.get(i + 1).cloned(),
+                })
+        };
+        let has = |name: &str| {
+            args.iter()
+                .any(|a| a == name || a.starts_with(&format!("{name}=")))
+        };
+        let served = flag("--served-model-name").unwrap_or_else(|| leaf(&r.model));
+        let context = flag("--max-model-len")
+            .and_then(|v| v.parse::<u64>().ok())
+            .or_else(|| flag("--context-length").and_then(|v| v.parse::<u64>().ok()));
+        let context_known = context.is_some();
+        let context = context.unwrap_or(32_768);
+        Self {
+            served,
+            title: if r.name.is_empty() {
+                leaf(&r.model)
+            } else {
+                r.name.clone()
+            },
+            repo: r.model.clone(),
+            engine: r.engine.clone(),
+            context,
+            context_known,
+            max_output: context.min(32_768),
+            reasoning: has("--reasoning-parser"),
+            tools: has("--enable-auto-tool-choice") || has("--tool-call-parser"),
+        }
+    }
+
+    /// Provider key for configs that need a stable id (`beam`, `deepseek-v4-1-flash`).
+    fn key(&self) -> String {
+        let mut out = String::new();
+        let mut dash = false;
+        for ch in self.served.chars() {
+            if ch.is_ascii_alphanumeric() {
+                out.push(ch.to_ascii_lowercase());
+                dash = false;
+            } else if !dash && !out.is_empty() {
+                out.push('-');
+                dash = true;
+            }
+        }
+        let trimmed = out.trim_end_matches('-').to_string();
+        if trimmed.is_empty() {
+            "local".to_string()
+        } else {
+            trimmed
+        }
+    }
+}
+
+fn leaf(model: &str) -> String {
+    model.rsplit('/').next().unwrap_or(model).to_string()
+}
+
+/// One agent's blocks. `model` is the settings a client needs for the model
+/// itself; `full` is the same thing with the endpoint/provider section on top.
+/// Both are paste-ready (no comments — a `// note` would break JSON/TOML);
+/// `hint` carries the one-line guidance instead.
+struct Agent {
+    id: &'static str,
+    label: &'static str,
+    /// Where the config lives (shown above the block).
+    target: &'static str,
+    model: String,
+    full: String,
+    hint: String,
+}
+
+/// Indent every line of `text` by `n` spaces (building nested config blocks).
+fn indent(text: &str, n: usize) -> String {
+    let pad = " ".repeat(n);
+    text.lines()
+        .map(|l| format!("{pad}{l}"))
+        .collect::<Vec<_>>()
+        .join("\n")
+}
+
+fn agents(info: &ModelInfo, base: &str) -> Vec<Agent> {
+    let key = info.key();
+    let api = format!("{base}/v1");
+    vec![
+        pi_agent(info, &key, &api),
+        claude_code_agent(info, &key, base),
+        codex_agent(info, &key, &api),
+        cline_agent(info, &key, &api),
+        generic_agent(info, &api),
+    ]
+}
+
+/// Join config lines (rustfmt-safe: no `\`-continuations that re-indent the
+/// generated text).
+fn join(lines: &[&str]) -> String {
+    lines.join("\n")
+}
+
+/// The `models[]` entry shared by pi and Cline, in a natural key order
+/// (serde_json sorts keys, which reads badly in a hand-edited config).
+fn pi_entry(info: &ModelInfo, pad: usize) -> String {
+    let body = join(&[
+        "{",
+        &format!("  \"id\": \"{}\",", info.served),
+        &format!("  \"name\": \"{}\",", info.title),
+        &format!("  \"contextWindow\": {},", info.context),
+        &format!("  \"maxTokens\": {},", info.max_output),
+        "  \"input\": [\"text\"],",
+        &format!("  \"reasoning\": {}", info.reasoning),
+        "}",
+    ]);
+    indent(&body, pad)
+}
+
+fn pi_agent(info: &ModelInfo, key: &str, api: &str) -> Agent {
+    let model = pi_entry(info, 0);
+    let entry = pi_entry(info, 8);
+    let full = join(&[
+        "{",
+        "  \"providers\": {",
+        &format!("    \"{key}\": {{"),
+        &format!("      \"baseUrl\": \"{api}\","),
+        "      \"api\": \"openai-completions\",",
+        "      \"apiKey\": \"dummy\",",
+        "      \"httpIdleTimeoutMs\": 0,",
+        "      \"models\": [",
+        &entry,
+        "      ]",
+        "    }",
+        "  }",
+        "}",
+    ]);
+    Agent {
+        id: "pi",
+        label: "pi",
+        target: "~/.pi/agent/models.json",
+        model,
+        full,
+        hint: format!(
+            "paste the entry under a provider's \"models\", then run: pi --provider {key} --model {}",
+            info.served
+        ),
+    }
+}
+
+fn claude_code_agent(info: &ModelInfo, _key: &str, base: &str) -> Agent {
+    let s = &info.served;
+    let model = join(&[
+        &format!("export ANTHROPIC_MODEL={s}"),
+        &format!("export ANTHROPIC_SMALL_FAST_MODEL={s}"),
+    ]);
+    let full = join(&[
+        "# vLLM serves the Anthropic /v1/messages API",
+        &format!("export ANTHROPIC_BASE_URL={base}"),
+        "export ANTHROPIC_AUTH_TOKEN=dummy",
+        &format!("export ANTHROPIC_MODEL={s}"),
+        &format!("export ANTHROPIC_SMALL_FAST_MODEL={s}"),
+    ]);
+    Agent {
+        id: "claude-code",
+        label: "Claude Code",
+        target: "shell env",
+        model,
+        full,
+        hint: format!("export these, then run: claude --model {s}"),
+    }
+}
+
+fn codex_agent(info: &ModelInfo, key: &str, api: &str) -> Agent {
+    let model = join(&[
+        &format!("model = \"{}\"", info.served),
+        &format!("model_provider = \"{key}\""),
+    ]);
+    let full = join(&[
+        &model,
+        "",
+        &format!("[model_providers.{key}]"),
+        &format!("name = \"{}\"", info.title),
+        &format!("base_url = \"{api}\""),
+        "env_key = \"OPENAI_API_KEY\"",
+        "wire_api = \"chat\"",
+    ]);
+    Agent {
+        id: "codex",
+        label: "Codex",
+        target: "~/.codex/config.toml",
+        model,
+        full,
+        hint: "export OPENAI_API_KEY=dummy — the key is only a placeholder".to_string(),
+    }
+}
+
+/// The Cline/Roo `openAiModelInfo` block (shared by both variants).
+fn cline_info(info: &ModelInfo) -> Vec<String> {
+    vec![
+        "  \"openAiModelInfo\": {".to_string(),
+        format!("    \"contextWindow\": {},", info.context),
+        format!("    \"maxTokens\": {},", info.max_output),
+        "    \"supportsImages\": false,".to_string(),
+        "    \"supportsPromptCache\": true,".to_string(),
+        format!("    \"description\": \"{}\"", info.title.replace('"', "'")),
+        "  }".to_string(),
+    ]
+}
+
+fn cline_agent(info: &ModelInfo, key: &str, api: &str) -> Agent {
+    let model_id = format!("  \"openAiModelId\": \"{}\",", info.served);
+    let mut model_lines = vec![
+        "{".to_string(),
+        "  \"apiProvider\": \"openai\",".to_string(),
+        model_id.clone(),
+    ];
+    model_lines.extend(cline_info(info));
+    model_lines.push("}".to_string());
+    let mut full_lines = vec![
+        "{".to_string(),
+        "  \"apiProvider\": \"openai\",".to_string(),
+        format!("  \"openAiBaseUrl\": \"{api}\","),
+        "  \"openAiApiKey\": \"dummy\",".to_string(),
+        model_id,
+    ];
+    full_lines.extend(cline_info(info));
+    full_lines.push("}".to_string());
+    let refs = |v: &Vec<String>| -> String { v.join("\n") };
+    Agent {
+        id: "cline",
+        label: "Cline / Roo (VS Code)",
+        target: "extension settings (OpenAI Compatible)",
+        model: refs(&model_lines),
+        full: refs(&full_lines),
+        hint: format!("provider key: {key}"),
+    }
+}
+
+fn generic_agent(info: &ModelInfo, api: &str) -> Agent {
+    let model = join(&[
+        "# model only — point your existing client at these",
+        &format!("model: {}", info.served),
+        &format!("context_window: {}", info.context),
+        &format!("max_output_tokens: {}", info.max_output),
+    ]);
+    let full = join(&[
+        &format!("export OPENAI_BASE_URL={api}"),
+        "export OPENAI_API_KEY=dummy".to_string().as_str(),
+        "",
+        &format!(
+            "curl -s \"$OPENAI_BASE_URL/chat/completions\" -H 'Content-Type: application/json' -d '{{\"model\":\"{}\",\"messages\":[{{\"role\":\"user\",\"content\":\"ping\"}}]}}'",
+            info.served
+        ),
+    ]);
+    Agent {
+        id: "generic",
+        label: "Generic OpenAI client",
+        target: "shell / SDK",
+        model,
+        full,
+        hint: "any OpenAI-compatible client works; the key is a placeholder".to_string(),
+    }
+}
+
+/// The `/api/model-settings` body.
+pub fn payload(recipe: &Recipe, ep: &Endpoint) -> Value {
+    let info = ModelInfo::from_recipe(recipe);
+    let base = ep.base(recipe.port);
+    let agents: Vec<Value> = agents(&info, &base)
+        .into_iter()
+        .map(|a| {
+            json!({
+                "id": a.id,
+                "label": a.label,
+                "target": a.target,
+                "model": a.model,
+                "full": a.full,
+                "hint": a.hint,
+            })
+        })
+        .collect();
+    json!({
+        "recipe": recipe.name,
+        "endpoint": {
+            "base_url": base,
+            "api_url": format!("{base}/v1"),
+            "port": recipe.port,
+            "host": ep.host,
+            "node": ep.node,
+            "cluster": ep.cluster,
+            "running": ep.running,
+        },
+        "model": {
+            "served": info.served,
+            "title": info.title,
+            "repo": info.repo,
+            "engine": info.engine,
+            "context": info.context,
+            "context_known": info.context_known,
+            "max_output": info.max_output,
+            "reasoning": info.reasoning,
+            "tools": info.tools,
+        },
+        "agents": agents,
+    })
+}
+
+/// Resolve where the recipe is served from: the node that currently hosts a
+/// container of it (fleet snapshot), else the recipe's own `cluster` field
+/// (rank-0 node), else a placeholder the operator fills in by hand.
+pub fn resolve_endpoint(
+    config: &Config,
+    snap: Option<&Snapshot>,
+    r: &Recipe,
+    name: &str,
+) -> Endpoint {
+    if let Some(snap) = snap {
+        for c in &snap.clusters {
+            for n in &c.nodes {
+                if n.containers.iter().any(|ct| ct.recipe == name) {
+                    return Endpoint {
+                        cluster: c.name.clone(),
+                        node: n.name.clone(),
+                        host: n.ssh_ip.clone(),
+                        running: true,
+                    };
+                }
+            }
+        }
+    }
+    if let Some(want) = &r.cluster
+        && let Some(c) = config
+            .clusters
+            .iter()
+            .find(|c| &c.name == want || c.name.starts_with(want.as_str()))
+        && let Some(n) = c.nodes.iter().min_by_key(|n| n.rank)
+    {
+        return Endpoint {
+            cluster: c.name.clone(),
+            node: n.name.clone(),
+            host: n.ssh_ip.clone(),
+            running: false,
+        };
+    }
+    Endpoint {
+        cluster: r.cluster.clone().unwrap_or_default(),
+        node: String::new(),
+        host: "<head-node>".to_string(),
+        running: false,
+    }
+}
+
+#[derive(Deserialize)]
+pub struct SettingsQuery {
+    pub recipe: String,
+}
+
+/// `GET /api/model-settings?recipe=<name>` — the "⚙ settings" dialog body.
+pub async fn handle(
+    State(app): State<super::AppState>,
+    Query(q): Query<SettingsQuery>,
+) -> Result<Json<Value>, (StatusCode, String)> {
+    let name = q.recipe;
+    if name.is_empty() || name.contains('/') || name.contains("..") {
+        return Err((StatusCode::BAD_REQUEST, "invalid recipe name".to_string()));
+    }
+    let recipe = crate::config::load_recipe(&name, &app.config.recipe_sources)
+        .await
+        .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
+    let snap = app
+        .last
+        .lock()
+        .unwrap_or_else(std::sync::PoisonError::into_inner)
+        .clone();
+    let ep = resolve_endpoint(&app.config, snap.as_ref(), &recipe, &name);
+    let mut body = payload(&recipe, &ep);
+    body["recipe"] = Value::String(name);
+    Ok(Json(body))
+}
+
+#[cfg(test)]
+#[path = "model_settings_tests.rs"]
+mod tests;

+ 121 - 0
src/web/model_settings_tests.rs

@@ -0,0 +1,121 @@
+//! Tests for `web::model_settings` (split at the 500-line cap).
+
+use super::*;
+
+fn recipe() -> Recipe {
+    let toml_str = r#"
+engine = "vllm"
+name = "Beam test lane"
+model = "reflection/Beam-1.0-501B-A23B-NVFP4"
+port = 30000
+args = ["--served-model-name", "beam", "--max-model-len", "393216",
+    "--reasoning-parser", "beam", "--enable-auto-tool-choice"]
+"#;
+    toml::from_str(toml_str).expect("recipe parses")
+}
+
+fn ep() -> Endpoint {
+    Endpoint {
+        cluster: "dgx58-switch".into(),
+        node: "dgx5.lan".into(),
+        host: "192.168.88.43".into(),
+        running: true,
+    }
+}
+
+#[test]
+fn info_comes_from_args() {
+    let i = ModelInfo::from_recipe(&recipe());
+    assert_eq!(i.served, "beam");
+    assert_eq!(i.context, 393_216);
+    assert!(i.context_known);
+    assert_eq!(i.max_output, 32_768);
+    assert!(i.reasoning && i.tools);
+    assert_eq!(i.key(), "beam");
+}
+
+#[test]
+fn served_name_falls_back_to_repo_leaf() {
+    let toml_str = r#"engine = "vllm"
+name = "1017 lane"
+model = "deepseek-ai/DeepSeek-V4.1-Flash-1017"
+args = ["--max-model-len=131072"]
+"#;
+    let r: Recipe = toml::from_str(toml_str).unwrap();
+    let i = ModelInfo::from_recipe(&r);
+    assert_eq!(i.served, "DeepSeek-V4.1-Flash-1017");
+    assert_eq!(i.context, 131_072);
+    assert!(!i.reasoning && !i.tools);
+    assert_eq!(i.key(), "deepseek-v4-1-flash-1017");
+}
+
+#[test]
+fn context_falls_back_conservatively() {
+    let r: Recipe = toml::from_str("engine = \"vllm\"\nname = \"n\"\nmodel = \"a/b\"\n").unwrap();
+    let i = ModelInfo::from_recipe(&r);
+    assert_eq!(i.context, 32_768);
+    assert!(!i.context_known);
+}
+
+#[test]
+fn endpoint_prefers_the_recipe_cluster() {
+    let cfg: Config = toml::from_str(
+        r#"
+[[clusters]]
+name = "dgx58-switch"
+nodes = [
+  { name = "dgx6.lan", ssh_ip = "10.0.0.6", rank = 1 },
+  { name = "dgx5.lan", ssh_ip = "10.0.0.5", rank = 0 },
+]
+"#,
+    )
+    .unwrap();
+    let r: Recipe = toml::from_str(
+        "engine = \"vllm\"\nname = \"n\"\nmodel = \"a/b\"\ncluster = \"dgx58-switch\"\n",
+    )
+    .unwrap();
+    let ep = resolve_endpoint(&cfg, None, &r, "lane");
+    assert_eq!(ep.host, "10.0.0.5", "rank 0 is the head node");
+    assert_eq!(ep.cluster, "dgx58-switch");
+    assert!(!ep.running);
+}
+
+#[test]
+fn endpoint_falls_back_to_a_placeholder() {
+    let cfg: Config = toml::from_str("ssh_user = \"u\"\n").unwrap();
+    let r: Recipe = toml::from_str("engine = \"vllm\"\nname = \"n\"\nmodel = \"a/b\"\n").unwrap();
+    let ep = resolve_endpoint(&cfg, None, &r, "lane");
+    assert_eq!(ep.host, "<head-node>");
+    assert!(!ep.running);
+}
+
+#[test]
+fn payload_carries_endpoint_and_agent_blocks() {
+    let p = payload(&recipe(), &ep());
+    assert_eq!(p["endpoint"]["base_url"], "http://192.168.88.43:30000");
+    assert_eq!(p["endpoint"]["api_url"], "http://192.168.88.43:30000/v1");
+    assert_eq!(p["model"]["served"], "beam");
+    assert_eq!(p["model"]["reasoning"], true);
+    let ids: Vec<&str> = p["agents"]
+        .as_array()
+        .unwrap()
+        .iter()
+        .map(|a| a["id"].as_str().unwrap())
+        .collect();
+    assert_eq!(ids, vec!["pi", "claude-code", "codex", "cline", "generic"]);
+    let pi = &p["agents"][0];
+    assert!(pi["model"].as_str().unwrap().contains("\"id\": \"beam\""));
+    assert!(pi["hint"].as_str().unwrap().contains("--provider beam"));
+    assert!(
+        !pi["full"].as_str().unwrap().contains("\n//"),
+        "blocks stay paste-ready (no comment lines)"
+    );
+    assert!(
+        pi["full"]
+            .as_str()
+            .unwrap()
+            .contains("http://192.168.88.43:30000/v1")
+    );
+    // the model-only block must not leak the endpoint
+    assert!(!pi["model"].as_str().unwrap().contains("192.168.88.43"));
+}

+ 7 - 2
src/web/tests.rs

@@ -76,7 +76,9 @@ pub(crate) async fn boot() -> (SocketAddr, std::sync::Arc<tokio::task::JoinHandl
     boot_with(fake_config()).await
 }
 
-async fn boot_with(config: Config) -> (SocketAddr, std::sync::Arc<tokio::task::JoinHandle<()>>) {
+pub(crate) async fn boot_with(
+    config: Config,
+) -> (SocketAddr, std::sync::Arc<tokio::task::JoinHandle<()>>) {
     boot_app(app_state_with(config, None)).await
 }
 
@@ -383,7 +385,10 @@ async fn ui_playwright_checks() {
         std::process::Command::new("deno")
             .arg("run")
             .arg("-A")
-            .arg("src/web/ui.playwright.mjs")
+            .arg(concat!(
+                env!("CARGO_MANIFEST_DIR"),
+                "/src/web/ui.playwright.mjs"
+            ))
             .env("SM121_WEB_URL", url)
             .output()
     })