Просмотр исходного кода

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 дней назад
Родитель
Сommit
fd30bf160a
13 измененных файлов с 927 добавлено и 164 удалено
  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.
 //! default; `sm121 bench -d` prints the exact command.
 
 
 use std::fs;
 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 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
 /// 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.
 /// `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.
 /// 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}`.
 /// 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<()> {
 pub async fn run(config: &Config, a: &Args, dry_run: bool) -> Result<()> {
     let recipe = load_recipe(&a.recipe, &config.recipe_sources).await?;
     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)]
 #[cfg(test)]
 mod tests {
 mod tests {
     use super::*;
     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 {
     fn node(name: &str, rank: u32) -> Node {
         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)]
     #[arg(long)]
     pub source: Option<String>,
     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_io;
 mod bench_remote;
 mod bench_remote;
 mod bench_report;
 mod bench_report;
+mod bench_target;
 mod builtin_scripts;
 mod builtin_scripts;
 mod cli;
 mod cli;
 mod cli_sub;
 mod cli_sub;
@@ -460,19 +461,7 @@ pub(crate) async fn handle_serve(
         }
         }
         DeployTarget::Tmux => {
         DeployTarget::Tmux => {
             // Warn about docker-only CLI opts that are silently ignored.
             // 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() {
             if !ignored.is_empty() {
                 tracing::warn!(
                 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) } }
   @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; }
   #bell.ring svg { animation: ring 0.6s ease; transform-origin: 50% 0; }
   .dim { color: gray; }
   .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
   /* Embedded in the dashboard's Recipes window: the window bar carries the
      title and the dashboard header carries the connection indicator. Both
      title and the dashboard header carries the connection indicator. Both
      SSE streams come from the same server (only the paths differ), so the
      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 id="notifbox" style="display:none"></div>
 </div>
 </div>
 <table id="tbl"><thead></thead><tbody></tbody></table>
 <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>
 <script>
 "use strict";
 "use strict";
 // Auth (dashboard embedding): the same-origin dashboard page stores the
 // 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
 // dashboard via postMessage — same window as the cluster overview, not an
 // inline stream), and a bench launch (POST /api/bench — the standard campaign
 // 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
 // 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) {
 function actionBar(r) {
   if (!EMBED) return "";
   if (!EMBED) return "";
   const running = r.up != null || r.fleet_up;
   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>`;
     : `<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>`;
   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="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>`;
   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
   e.stopPropagation(); // no row toggle under the action button
   const kind = b.dataset.act, recipe = b.dataset.r;
   const kind = b.dataset.act, recipe = b.dataset.r;
   if (kind === "logs") { parent.postMessage({ sm121: "openLogs", recipe }, "*"); return; }
   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";
   const path = kind === "serve" ? "/api/serve" : kind === "stop" ? "/api/stop" : "/api/bench";
   b.disabled = true;
   b.disabled = true;
   try {
   try {
@@ -611,6 +652,64 @@ document.addEventListener("click", async (e) => {
   } catch (err) { setNote(recipe, `✗ ${kind}: ` + err); }
   } catch (err) { setNote(recipe, `✗ ${kind}: ` + err); }
   setTimeout(() => { b.disabled = false; }, 3000);
   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");
 const syncBtn = document.getElementById("syncbtn");
 if (EMBED) syncBtn.style.display = "";
 if (EMBED) syncBtn.style.display = "";
 syncBtn.addEventListener("click", async () => {
 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
     // The add-on case: the process cwd has no docker-images/ — the synced
     // recipe-repo roots come from config.docker_image_sources instead.
     // recipe-repo roots come from config.docker_image_sources instead.
     use crate::web::router;
     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()));
     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::create_dir_all(base.join("docker-images/kindling")).unwrap();
     std::fs::write(
     std::fs::write(

+ 4 - 1
src/report/ui_tests.rs

@@ -214,7 +214,10 @@ async fn report_playwright_checks() {
         std::process::Command::new("deno")
         std::process::Command::new("deno")
             .arg("run")
             .arg("run")
             .arg("-A")
             .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_URL", url)
             .env("SM121_REPORT_FIXTURE", fixture_path)
             .env("SM121_REPORT_FIXTURE", fixture_path)
             .output()
             .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(());
 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.
 /// Acquire the Playwright session lock; held until the guard is dropped.
 /// Tolerates a poisoned lock (a panicked holder) — the browser is the
 /// Tolerates a poisoned lock (a panicked holder) — the browser is the
 /// shared resource, not the lock's payload.
 /// 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) —
 //! 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.
 //! 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 crate::web::events::Event;
 use axum::http::StatusCode;
 use axum::http::StatusCode;
 
 
@@ -195,6 +195,7 @@ async fn log_event_serializes_with_type_tag() {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn raw_endpoints_serve_config_recipe_effective() {
 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 (addr, _h) = boot().await;
     let base = format!("http://{addr}");
     let base = format!("http://{addr}");
     let client = reqwest::Client::new();
     let client = reqwest::Client::new();
@@ -273,3 +274,56 @@ async fn raw_endpoints_serve_config_recipe_effective() {
         "traversal name refused"
         "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 events;
 pub mod jobs;
 pub mod jobs;
 pub mod logs;
 pub mod logs;
+pub mod model_settings;
 pub mod probe;
 pub mod probe;
 pub mod ssh_stream;
 pub mod ssh_stream;
 pub mod topo;
 pub mod topo;
@@ -261,6 +262,7 @@ pub fn router(app: AppState) -> Router {
         .route("/api/serve", post(api::serve))
         .route("/api/serve", post(api::serve))
         .route("/api/stop", post(api::stop))
         .route("/api/stop", post(api::stop))
         .route("/api/bench", post(api::bench))
         .route("/api/bench", post(api::bench))
+        .route("/api/model-settings", get(model_settings::handle))
         .route("/api/power", post(api::power))
         .route("/api/power", post(api::power))
         .route("/api/recipes", get(api::recipes))
         .route("/api/recipes", get(api::recipes))
         .route("/api/repos/sync", post(api::repos_sync))
         .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
     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
     boot_app(app_state_with(config, None)).await
 }
 }
 
 
@@ -383,7 +385,10 @@ async fn ui_playwright_checks() {
         std::process::Command::new("deno")
         std::process::Command::new("deno")
             .arg("run")
             .arg("run")
             .arg("-A")
             .arg("-A")
-            .arg("src/web/ui.playwright.mjs")
+            .arg(concat!(
+                env!("CARGO_MANIFEST_DIR"),
+                "/src/web/ui.playwright.mjs"
+            ))
             .env("SM121_WEB_URL", url)
             .env("SM121_WEB_URL", url)
             .output()
             .output()
     })
     })