Skip to main content

superfluid_daemon/
cli.rs

1//! The `superfluid` command line: its help text, the `serve` flag table and the defaults that let
2//! `superfluid serve <model>` start with no other flag.
3
4use std::path::{Path, PathBuf};
5
6use crate::runtime_pick::RuntimeId;
7
8pub const DEFAULT_HTTP: &str = "127.0.0.1:8453";
9
10pub struct Flag {
11    pub name: &'static str,
12    pub value: Option<&'static str>,
13    pub default: &'static str,
14    pub help: &'static str,
15}
16
17pub struct Group {
18    pub title: &'static str,
19    pub flags: &'static [Flag],
20}
21
22const fn f(name: &'static str, value: Option<&'static str>, default: &'static str, help: &'static str) -> Flag {
23    Flag { name, value, default, help }
24}
25
26pub const SERVE: &[Group] = &[
27    Group {
28        title: "Model",
29        flags: &[
30            f("--model", Some("<path|org/model[:tag]>"), "", "A model to serve, repeatable; the first is the default. Also the first bare argument."),
31            f("--runtime", Some("<id>[@<install>]|<model>=<id>"), "by format", "The runtime to serve with (basert, llamacpp, mlx); `auto` clears a pick."),
32            f("--offline", None, "", "Fetch nothing: a model id is only looked for in its runtime's cache."),
33            f("--pull-<name>", Some("[value]"), "", "An option for the runtime that pulls a model id (e.g. --pull-file, --pull-revision)."),
34            f("--<runtime>-<name>", Some("<value>"), "", "A knob of that runtime, e.g. --llamacpp-n-gpu-layers 20; `superfluid runtime options <id>` lists them."),
35            f("--model-dir", Some("<dir>"), "", "Every model in this directory is listed and loaded on first use."),
36            f("--idle-timeout", Some("<sec>"), "0 (never)", "Unload a model that has been idle this long (never the default model)."),
37            f("--basert-lib", Some("<path>"), "search", "Where libbaseRT is, for the basert runtime."),
38        ],
39    },
40    Group {
41        title: "Server",
42        flags: &[
43            f("--http", Some("<addr:port>"), DEFAULT_HTTP, "Serve the OpenAI-, Anthropic- and Ollama-compatible HTTP APIs here."),
44            f("--no-http", None, "", "Serve only the native session API on the unix socket."),
45            f("--host", Some("<addr>"), "127.0.0.1", "The HTTP address, as a host..."),
46            f("--port", Some("<N>"), "8453", "...and a port; either fills the other from its default."),
47            f("--socket", Some("<path>"), "$SUPERFLUID_HOME/superfluid.sock", "The unix socket for the native session API."),
48            f("--sessions", Some("<dir>"), "$SUPERFLUID_HOME/sessions", "The session store (write-ahead log, parks, media, files). One server per directory."),
49            f("--api-key", Some("<key>"), "none", "Require `Authorization: Bearer <key>` (or `X-Api-Key`) on HTTP requests."),
50            f("--key-policy", Some("<file.json>"), "", "Per-key classes, rate and concurrency limits; turns authentication on."),
51            f("--rate-limit", Some("<rpm>"), "0 (off)", "Requests per minute per client IP."),
52            f("--drain-timeout", Some("<sec>"), "60", "On SIGUSR1, stop accepting and give in-flight requests this long."),
53            f("--nonstream-keepalive", Some("<sec>"), "0 (off)", "Send 200 at admission and a space every <sec> until a non-streaming body is ready."),
54            f("--web", Some("<127.0.0.1:port>"), "off", "The browser-facing API, with a token minted to <sessions>/web-token. Loopback only."),
55            f("--web-origin", Some("<url>"), "", "An allowed Origin for --web, repeatable."),
56        ],
57    },
58    Group {
59        title: "Generation",
60        flags: &[
61            f("--max-context", Some("<N|auto>"), "auto", "The context window per lane; auto sizes it for the device where the runtime can, else 8192."),
62            f("--max-batch", Some("<N>"), "8", "Concurrent decode lanes."),
63            f("--max-tokens", Some("<N>"), "fill the context", "The generation cap for requests that set none."),
64            f("--kv-bits", Some("<0|4|8|16|84>"), "0 (auto)", "KV cache precision, on runtimes that take it (basert)."),
65            f("--dialect", Some("<auto|chatml|atem|template|raw>"), "auto", "The chat codec."),
66            f("--tool-call-parser", Some("<auto|json|atem|gemma|harmony|glm>"), "auto", "Override the tool-call format the dialect speaks."),
67            f("--temperature", Some("<F>"), "model's", "Default temperature (0..2) for requests that set none."),
68            f("--top-p", Some("<F>"), "model's", "Default nucleus truncation (0..1)."),
69            f("--top-k", Some("<N>"), "model's", "Default top-k truncation."),
70            f("--min-p", Some("<F>"), "model's", "Default min-p truncation (0..1)."),
71            f("--repeat-penalty", Some("<F>"), "model's", "Default repetition penalty (0..2); 1.0 disables it."),
72        ],
73    },
74    Group {
75        title: "Sessions and caching",
76        flags: &[
77            f("--park", None, "off", "Seal a finishing durable session's KV to disk, to resume it later by id. --no-park turns it off."),
78            f("--park-lossy", None, "off", "Park in the lossy Q8 tier (about half the size); implies --park."),
79            f("--park-budget-gb", Some("<N>"), "20", "Disk budget per park directory, oldest evicted first; 0 is unbounded."),
80            f("--sessions-cache", None, "off", "Treat the session log as a cache: start a fresh one at each start."),
81            f("--tool-lease-ms", Some("<N>"), "0 (none)", "A deadline for every tool call."),
82            f("--prefix-blob-budget-mb", Some("<N>"), "engine's", "The recurrent-state snapshot cache on hybrid models (basert)."),
83        ],
84    },
85    Group {
86        title: "Files API",
87        flags: &[
88            f("--files-max-bytes", Some("<N>"), "unbounded", "Refuse an upload that would push stored bytes past this."),
89            f("--files-expiry", Some("<sec>"), "never", "Delete a stored file this long after upload."),
90            f("--files-sweep", Some("<sec>"), "300", "How often the expiry sweep runs."),
91        ],
92    },
93    Group {
94        title: "Scheduling and QoS",
95        flags: &[
96            f("--tick-decode-budget", Some("<N>"), "max-batch x 32", "Decode tokens granted per tick across all lanes."),
97            f("--tick-target-ms", Some("<N>"), "2000", "The tick length decode grants and adaptive prefill are sized towards."),
98            f("--prefill-budget", Some("<N>"), "4096", "Prompt tokens prefilled per tick (64..4096); 0 adapts it to the tick target."),
99            f("--starvation-ticks", Some("<N>"), "8", "Serve an agent lane first after this many ticks without a slice."),
100            f("--class-lanes", Some("<class>=N[,...]"), "uncapped", "Concurrent-lane cap per QoS class."),
101            f("--http-default-qos", Some("<class>"), "agent", "QoS class for HTTP requests that name none (interactive, completion, agent, background)."),
102            f("--no-http-qos-header", None, "", "Ignore the client's x-superfluid-qos header."),
103            f("--http-allow-batch-invariant", None, "", "Let an HTTP request ask for a batch-invariant lane."),
104            f("--worker-process", None, "on", "Run each engine in its own worker process. --no-worker-process runs it in-process."),
105            f("--os-pressure", None, "on", "Relieve OS memory pressure. --no-os-pressure turns it off."),
106            f("--pressure-high", Some("<pct>"), "85", "KV pool occupancy above which cache is evicted."),
107            f("--pressure-low", Some("<pct>"), "70", "The occupancy eviction brings it down to."),
108            f("--pin-budget-pct", Some("<pct>"), "50", "Share of the KV pool pinned prefixes may hold."),
109        ],
110    },
111    Group {
112        title: "Speculative decoding",
113        flags: &[
114            f("--speculate", Some("<auto|prompt-lookup|mtp-head|<drafter>|off>"), "off", "Draft tokens and verify them; also dflash:, dspark:, eagle3:, draft-model: <path>."),
115            f("--spec-draft-tokens", Some("<N>"), "per strategy", "Draft depth, 1..15."),
116            f("--spec-adaptive", None, "on", "Adapt the depth to the acceptance rate. --no-spec-adaptive pins it."),
117            f("--spec-min-yield", Some("<F>"), "0.75", "The yield below which a lane stops drafting."),
118            f("--spec-yield-rounds", Some("<N>"), "24", "Rounds over which the yield is judged."),
119            f("--spec-throughput-gate", None, "on", "Check that speculation beats plain decoding. --no-spec-throughput-gate skips it."),
120            f("--spec-gate-probe-tokens", Some("<N>"), "16", "The gate's probe length."),
121            f("--spec-min-speedup", Some("<F>"), "1.08", "The speed-up the gate requires."),
122            f("--spec-gate-reprobe", Some("<N>"), "32", "Tokens between re-probes."),
123            f("--spec-gate-reprobe-max", Some("<N>"), "1024", "The ceiling the re-probe interval backs off to."),
124            f("--spec-max-temperature", Some("<F>"), "none", "Cap a model-default temperature on a speculating model."),
125            f("--dspark-confidence", Some("<F>"), "engine's", "The DSpark drafter's confidence threshold."),
126            f("--spec-bitexact", None, "off", "Make speculated and plain decoding bit-exact. --no-spec-bitexact turns it off."),
127        ],
128    },
129    Group {
130        title: "Code completion (FIM)",
131        flags: &[
132            f("--fim-model", Some("<path>"), "", "A dedicated fill-in-the-middle model in its own worker."),
133            f("--fim-max-context", Some("<N>"), "min(window, 8192)", "The FIM model's window."),
134            f("--fim-max-batch", Some("<N>"), "2", "The FIM model's lanes."),
135            f("--completion-deadline-ms", Some("<N>"), "0 (none)", "A completion that cannot start within this expires."),
136            f("--completion-rate", Some("<F>"), "10", "Completions per second per client (0 = unmetered)."),
137            f("--completion-burst", Some("<N>"), "20", "Completions a client may fire back to back."),
138        ],
139    },
140    Group {
141        title: "Logging and telemetry",
142        flags: &[
143            f("--log-filter", Some("<directive>"), "info", "Log level, or per target (superfluid_daemon::scheduler=debug,info)."),
144            f("--verbose", None, "", "The same as --log-filter debug; also -v."),
145            f("--log-dir", Some("<dir>"), "stderr", "Write JSON logs to rotating files here."),
146            f("--log-file", Some("<path>"), "", "Send stderr to this file (appended)."),
147            f("--tui", None, "on a terminal", "The live terminal monitor. --no-tui turns it off."),
148            f("--otlp-metrics", Some("<url>"), "off", "Push scheduler metrics to an OTLP/HTTP collector."),
149            f("--otlp-interval-ms", Some("<N>"), "15000", "The metrics push interval."),
150            f("--otlp-endpoint", Some("<url>"), "off", "Push operational traces (never content) to an OTLP/HTTP collector."),
151            f("--otlp-header", Some("<k=v>"), "", "A header on every OTLP request, repeatable."),
152            f("--otlp-service-name", Some("<name>"), "superfluid", "The service.name resource attribute."),
153            f("--otlp-filter", Some("<directive>"), "info,...scheduler=debug", "Which spans are exported."),
154            f("--otlp-queue", Some("<N>"), "8192", "The span export queue; a full queue drops spans."),
155            f("--otlp-batch-ms", Some("<N>"), "1000", "The span export interval."),
156            f("--otlp-timeout-ms", Some("<N>"), "5000", "The export request timeout."),
157        ],
158    },
159    Group {
160        title: "Fleet head",
161        flags: &[
162            f("--fleet", Some("<host:port>[,...]"), "off", "Serve as a fleet head over these superfluid-noded agents."),
163            f("--fleet-listen", Some("<addr:port>"), "off", "Take nodes that join (`superfluid node join`), announced on the local network."),
164            f("--fleet-auth", Some("<path>"), "$SUPERFLUID_HOME/fleet/token", "The fleet token: the links to the nodes are encrypted with it."),
165            f("--fleet-policy", Some("<load-aware|least-loaded|round-robin>"), "load-aware", "Session placement."),
166            f("--fleet-pool-high", Some("<pct>"), "90", "Pass over a node whose KV pool is at least this full."),
167            f("--fleet-conns-per-node", Some("<N>"), "lanes", "Connections to each node you list; by default as many as it has lanes."),
168        ],
169    },
170];
171
172/// Accepted spellings that are not rows of their own in the table: the `--no-` halves of the
173/// switches, short and legacy aliases, and the retired flags `serve` still reads.
174const SERVE_ALIASES: &[(&str, Option<&str>)] = &[
175    ("--no-park", None),
176    ("--no-worker-process", None),
177    ("--no-os-pressure", None),
178    ("--no-spec-adaptive", None),
179    ("--no-spec-throughput-gate", None),
180    ("--no-spec-bitexact", None),
181    ("--no-tui", None),
182    ("-v", None),
183    ("--help", None),
184    ("-h", None),
185    ("--max-batch-size", Some("--max-batch")),
186    ("--request-timeout", Some("--completion-deadline-ms")),
187];
188
189const SERVE_RETIRED_WITH_VALUE: &[&str] = &[
190    "--prefix-cache-file",
191    "--prefix-cache-save-interval",
192    "--files-dir",
193    "--media-dir",
194    "--metallib",
195    "--prefill-chunk",
196    "--gpu-wait-timeout-ms",
197    "--decode-replay",
198];
199
200const SERVE_RETIRED_SWITCHES: &[&str] =
201    &["--paged-kv", "--prefix-cache", "--continuous-batching", "--paged-weights", "--no-paged-weights-retry", "--no-baked-decode"];
202
203fn serve_flag(name: &str) -> Option<&'static Flag> {
204    SERVE.iter().flat_map(|g| g.flags.iter()).find(|fl| fl.name == name)
205}
206
207/// Whether `serve` reads `name` with a value after it: `Some(true)` it does, `Some(false)` it is a
208/// switch, `None` no such flag.
209pub fn serve_takes_value(name: &str) -> Option<bool> {
210    if let Some(fl) = serve_flag(name) {
211        return Some(fl.value.is_some());
212    }
213    if let Some((_, alias)) = SERVE_ALIASES.iter().find(|(n, _)| *n == name) {
214        return Some(alias.and_then(serve_flag).is_some_and(|fl| fl.value.is_some()));
215    }
216    if SERVE_RETIRED_WITH_VALUE.contains(&name) {
217        return Some(true);
218    }
219    SERVE_RETIRED_SWITCHES.contains(&name).then_some(false)
220}
221
222/// Splits `--flag=value` into `--flag value` for every flag `takes_value` says reads a value.
223/// `--pull-<name>=<value>` is left whole: its parser reads that form itself.
224pub fn split_equals(args: &[String], takes_value: impl Fn(&str) -> Option<bool>) -> Vec<String> {
225    let mut out = Vec::with_capacity(args.len());
226    for a in args {
227        match a.split_once('=') {
228            Some((flag, value)) if flag.starts_with("--") && !flag.starts_with("--pull-") && takes_value(flag) == Some(true) => {
229                out.push(flag.to_string());
230                out.push(value.to_string());
231            }
232            _ => out.push(a.clone()),
233        }
234    }
235    out
236}
237
238fn distance(a: &str, b: &str) -> usize {
239    let b: Vec<char> = b.chars().collect();
240    let mut row: Vec<usize> = (0..=b.len()).collect();
241    for (i, ca) in a.chars().enumerate() {
242        let mut prev = row[0];
243        row[0] = i + 1;
244        for (j, cb) in b.iter().enumerate() {
245            let cur = row[j + 1];
246            row[j + 1] = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(cur + 1);
247            prev = cur;
248        }
249    }
250    row[b.len()]
251}
252
253/// The known flag closest to `unknown`, when one is close enough to be what was meant.
254pub fn suggest<'a>(unknown: &str, known: impl IntoIterator<Item = &'a str>) -> Option<&'a str> {
255    let unknown = unknown.split_once('=').map_or(unknown, |(f, _)| f);
256    known
257        .into_iter()
258        .map(|k| (distance(unknown, k), k))
259        .filter(|(d, k)| *d <= 2.max(k.len() / 3) || (unknown.len() > 4 && k.starts_with(unknown)))
260        .min_by_key(|(d, _)| *d)
261        .map(|(_, k)| k)
262}
263
264pub fn serve_flag_names() -> impl Iterator<Item = &'static str> {
265    SERVE.iter().flat_map(|g| g.flags.iter().map(|fl| fl.name)).chain(SERVE_ALIASES.iter().map(|(n, _)| *n)).filter(|n| *n != "--pull-<name>")
266}
267
268pub fn version() -> String {
269    format!("superfluid {}", env!("CARGO_PKG_VERSION"))
270}
271
272pub fn overview() -> String {
273    format!(
274        "{}
275A serving daemon for local LLM inference: one scheduler, one session log and one set of APIs
276over llama.cpp, MLX and baseRT.
277
278Usage: superfluid <command> [options]
279
280Commands:
281  serve <model>        Serve a model over HTTP (OpenAI, Anthropic, Ollama) and a unix socket
282  launch <agent>       Run a coding agent (Claude Code, ...) against a local model
283  stop                 Stop the server `launch` started
284  fleet token          Print the fleet token, making it the first time
285  node join [<head>]   Serve a model to a fleet head from this machine
286  runtimes [<model>]   List the inference runtimes here, or which one serves a model
287  runtime <action>     Install, update, repair, remove or pin a runtime
288  compat               What this build was built for: the runtime contracts it checks at load
289  self <action>        Update superfluid itself, or switch between the versions installed here
290  session <action>     Inspect or export a session of a running server
291  media <action>       Store or garbage-collect media on a running server
292  top [<host:port>...] Monitor running servers, or a fleet, in the terminal
293  help [<command>]     Show help for a command
294
295Options:
296  -h, --help           Show help
297  -V, --version        Show the version
298
299Get started:
300  superfluid serve unsloth/Qwen3.8-27B-GGUF:UD-Q4_K_M     # llama.cpp; installed and pulled on first run
301  superfluid serve mlx-community/Qwen3-4B-4bit        # MLX on Apple silicon
302  superfluid serve ./model.gguf
303  curl http://{DEFAULT_HTTP}/v1/models
304
305Run 'superfluid <command> --help' for a command's options.
306Docs: https://github.com/basecompute/superfluid/tree/main/docs
307",
308        version()
309    )
310}
311
312pub fn serve_help() -> String {
313    let mut out = String::from(
314        "Serve one or more models.
315
316Usage: superfluid serve <model> [options]
317
318  <model> is a local path (a .gguf file, an MLX directory, a .base bundle) or a Hugging Face
319  style id, org/model[:tag]. An id is pulled by its runtime on first use; the runtime is taken
320  from the id (a *GGUF* repo -> llamacpp, mlx-community/* or *mlx* -> mlx, else basert) unless
321  --runtime names one. A runtime that is not installed is installed first, unless --offline.
322
323Examples:
324  superfluid serve unsloth/Qwen3.8-27B-GGUF:UD-Q4_K_M
325  superfluid serve ./Qwen3-4B-Q4_K_M.gguf --port 8000 --api-key secret
326  superfluid serve --model a.gguf --model b.gguf --max-batch 16
327
328Every option that takes a value accepts --flag value and --flag=value. A runtime's own knobs are
329--<runtime>-<name> <value>; `superfluid runtime options <id>` lists them.
330",
331    );
332    for g in SERVE {
333        out.push_str(&format!("\n{}:\n", g.title));
334        for fl in g.flags {
335            let head = match fl.value {
336                Some(v) => format!("{} {v}", fl.name),
337                None => fl.name.to_string(),
338            };
339            let default = if fl.default.is_empty() { String::new() } else { format!(" [default: {}]", fl.default) };
340            out.push_str(&row(&head, &format!("{}{default}", fl.help)));
341        }
342    }
343    out
344}
345
346const HEAD: usize = 32;
347const WIDTH: usize = 100;
348
349/// One option row: the flag in a column of its own, the text word-wrapped beside it.
350fn row(head: &str, text: &str) -> String {
351    let mut lines: Vec<String> = Vec::new();
352    let mut line = String::new();
353    for word in text.split_whitespace() {
354        if !line.is_empty() && HEAD + 2 + line.len() + 1 + word.len() > WIDTH {
355            lines.push(std::mem::take(&mut line));
356        }
357        if !line.is_empty() {
358            line.push(' ');
359        }
360        line.push_str(word);
361    }
362    lines.push(line);
363    let pad = " ".repeat(HEAD + 2);
364    let mut out = if head.chars().count() + 2 > HEAD {
365        format!("  {head}\n{pad}{}\n", lines[0])
366    } else {
367        format!("  {head:<w$}{}\n", lines[0], w = HEAD)
368    };
369    for l in &lines[1..] {
370        out.push_str(&format!("{pad}{l}\n"));
371    }
372    out
373}
374
375pub const RUNTIMES_HELP: &str = "List the inference runtimes here, or which one serves a model.
376
377Usage: superfluid runtimes [<model>] [--json]
378
379  With no model: one row per runtime and install, its state, version, the newest version its
380  adapter installs on its own here (INSTALLABLE; `superfluid runtime versions <id>` lists them
381  all), the formats it reads, its device and where its worker is, then what to do about any
382  that is not ready.
383  With a model: which runtime reads it and what that runtime serves and refuses (exit 1 if none).
384
385  DEVICE gives the memory budget: what the runtime plans a model and its cache against there,
386  the same rule for every runtime (a share of the device's working set, 85% on Apple silicon).
387  WORKER names an install by its name in the runtimes directory and superfluid's own workers by
388  where they came from; the footer says where the release and the installs are.
389
390Options:
391  --json    Print JSON: each device's own figure as `memory` beside its `budget`, and each
392            worker's whole path
393";
394
395pub const RUNTIME_HELP: &str = "Install and manage inference runtimes.
396
397Usage:
398  superfluid runtime versions <id> [--json]         List the versions the adapter installs on its own: which
399                                                    install here, as what, and which are installed
400  superfluid runtime install <id> [options]         Fetch and install a runtime for this machine
401  superfluid runtime update <id>                    Install the newest tested build beside the current one
402  superfluid runtime repair <id> [<install>]        Reinstall an install in place
403  superfluid runtime remove <id> [<install>|--all]  Remove one install, or with --all every one
404  superfluid runtime use <id> <install>|--auto      Pin the default install, or go back to the best-ranked
405  superfluid runtime options <id> [--json]          The knobs it takes on `serve`, as --<id>-<name> <value>
406
407  <id> is basert, llamacpp or mlx. Installs live under $SUPERFLUID_HOME/runtimes (~/.superfluid).
408  repair, remove and use also take an install as <id>@<install>, the way `superfluid runtimes` lists it.
409  remove <id> with no install removes the runtime's only install, and needs --all when it has more.
410
411Install options:
412  --version <V>          A runtime version (default: the newest this adapter was tested with)
413  --backend <B>          metal, cuda, vulkan, rocm, cpu, ... (default: the best for this machine)
414  --from <url|path>      An archive of your own (for mlx, a package index)
415  --sha256 <H>           The archive's digest, checked when given
416  --untested             Allow a version the adapter was not tested with
417  --dry-run              Print the plan and stop
418
419Examples:
420  superfluid runtime versions llamacpp
421  superfluid runtime install llamacpp
422  superfluid runtime install llamacpp --backend vulkan --dry-run
423  superfluid runtime remove llamacpp@b11284-vulkan
424";
425
426/// The runtime a `superfluid runtime <action>` acts on, and the arguments after it. `superfluid runtimes`
427/// lists an install other than the default as `<id>@<install>`: remove, repair and use read that as
428/// `<id> <install>`, and the actions that take only an id say how to ask for what was meant.
429pub fn runtime_target(action: &str, name: &str, rest: &[String]) -> Result<(RuntimeId, Vec<String>), String> {
430    let takes_install = matches!(action, "remove" | "repair" | "use");
431    let Some(id) = RuntimeId::parse(name) else {
432        let or_install = if takes_install { ", or <id>@<install> as `superfluid runtimes` lists it" } else { "" };
433        return Err(format!("'{name}' is not a runtime id (lowercase letters, digits, '.', '-', '_'){or_install}"));
434    };
435    let Some(install) = id.install() else { return Ok((id, rest.to_vec())) };
436    let base = id.base();
437    // No install's name begins with '-', and passed on after the id it would be read as an option.
438    if install.starts_with('-') {
439        let how = match (action, install) {
440            ("remove", "--all") => format!(": `superfluid runtime remove {base} --all` removes every install"),
441            ("use", "--auto") => format!(": `superfluid runtime use {base} --auto` goes back to the best-ranked"),
442            _ => String::new(),
443        };
444        return Err(format!("'{name}' names no install: an install's name does not begin with '-'{how}"));
445    }
446    if takes_install {
447        return match rest {
448            [] => Ok((base, vec![install.to_string()])),
449            [same] if same == install => Ok((base, rest.to_vec())),
450            [auto] if action == "use" && auto == "--auto" => Err(format!(
451                "'{name}' pins {install} and --auto unpins: `superfluid runtime use {base} --auto` goes back to the best-ranked"
452            )),
453            [all] if action == "remove" && all == "--all" => Err(format!(
454                "'{name}' names the install {install} and --all every one: `superfluid runtime remove {base} --all` removes them all"
455            )),
456            [other] => Err(format!("'{name}' names the install {install} and '{other}' names another: give one")),
457            // More than an install follows: the action's own usage refuses it.
458            _ => Ok((base, rest.to_vec())),
459        };
460    }
461    let how = match action {
462        "install" => match version_and_backend(install) {
463            Some((v, b)) => format!("; `superfluid runtime install {base} --version {v} --backend {b}` installs {install}"),
464            None => "; --version and --backend pick the build".to_string(),
465        },
466        "update" => format!("; it updates the default install: `superfluid runtime update {base}`"),
467        "versions" | "options" => format!(": `superfluid runtime {action} {base}`"),
468        _ => return Ok((id, rest.to_vec())),
469    };
470    Err(format!("{action} takes a runtime id, not an install ('{name}'){how}"))
471}
472
473/// An install name, `<version>-<backend>`, split where a backend the installers rank begins:
474/// `b11284-cuda-13.4` is version `b11284` on `cuda-13.4`. None for a build of one's own.
475fn version_and_backend(install: &str) -> Option<(&str, &str)> {
476    use superfluid_adapter_kit::install::backend_rank;
477    let unranked = backend_rank("");
478    install
479        .match_indices('-')
480        .map(|(at, _)| (&install[..at], &install[at + 1..]))
481        .find(|(_, backend)| backend_rank(backend) != unranked)
482        .filter(|(version, _)| *version != "custom")
483}
484
485pub const SESSION_HELP: &str = "Inspect or export a session of a running server.
486
487Usage:
488  superfluid session inspect <id> [--socket <path>] [--json]
489  superfluid session export <id> [--socket <path>] --format jsonl|otlp-jsonl [options]
490
491Options:
492  --socket <path>            The server's socket [default: $SUPERFLUID_HOME/superfluid.sock]
493  --json                     (inspect) Print JSON
494  --format <jsonl|otlp-jsonl> (export) Events as JSON lines, or OTLP trace lines [default: jsonl]
495  --out <dir>                (export) Write session-<id>.* here instead of stdout
496  --include-content          (export) Include token text
497  --model <name>             (export) Label the spans with this model
498  --endpoint <url>           (export) Push the traces to an OTLP/HTTP collector instead
499  --allow-content-egress     (export) Let --include-content cross the network with --endpoint
500";
501
502pub const MEDIA_HELP: &str = "Store or garbage-collect media on a running server.
503
504Usage:
505  superfluid media put <file> [--socket <path>]   Store a file; prints its hash
506  superfluid media gc [--socket <path>]           Drop media no session refers to
507
508Options:
509  --socket <path>    The server's socket [default: $SUPERFLUID_HOME/superfluid.sock]
510";
511
512pub const TOP_HELP: &str = "Monitor running servers in the terminal.
513
514Usage: superfluid top [--api-key <key>] [<host:port> ...]
515
516  Each address is a `superfluid serve` HTTP endpoint; a fleet head's shows every node joined to
517  it, as they come and go. With no address, the fleet head on the local network is found and
518  shown. q or Esc quits.
519
520Options:
521  --api-key <key>    Sent on every scrape, for servers behind --api-key
522";
523
524/// The help for a subcommand, by name.
525pub fn command_help(command: &str) -> Option<String> {
526    Some(match command {
527        "serve" => serve_help(),
528        "runtimes" => RUNTIMES_HELP.to_string(),
529        "runtime" => RUNTIME_HELP.to_string(),
530        "compat" => COMPAT_HELP.to_string(),
531        "self" => crate::selfupdate::HELP.to_string(),
532        "session" => SESSION_HELP.to_string(),
533        "media" => MEDIA_HELP.to_string(),
534        "top" => TOP_HELP.to_string(),
535        "launch" => crate::launch::HELP.to_string(),
536        "fleet" => crate::fleet_token::HELP.to_string(),
537        "node" => crate::fleet_join::HELP.to_string(),
538        "stop" => crate::launch::STOP_HELP.to_string(),
539        _ => return None,
540    })
541}
542
543pub const COMMANDS: &[&str] =
544    &["serve", "launch", "stop", "runtimes", "runtime", "compat", "self", "session", "media", "top", "fleet", "node", "help", "version"];
545
546pub const COMPAT_HELP: &str = "What this build was built and tested for, per runtime.
547
548Usage: superfluid compat [--json]
549
550  basert: the baseRT header version this build carries, the engine ABI number it checks and
551  the line accepted without one (0.3.x), and the engines it installs on its own. A libbaseRT
552  with the same ABI number serves, whatever its version.
553  llamacpp, mlx: the runtime versions each shipped adapter installs on its own, the ones it
554  was built and tested against (`--untested` installs another).
555  A release carries the same data beside its tarball, as compat.json, so a host can choose a
556  release before downloading it. `superfluid runtime versions <id>` lists one runtime's versions
557  with which install on this machine.
558  Exits 1, after printing the rest, when a shipped adapter fails to answer for its runtime.
559
560Options:
561  --json    Print JSON
562";
563
564
565/// `$SUPERFLUID_HOME/sessions`.
566pub fn default_sessions(home: &Path) -> PathBuf {
567    home.join("sessions")
568}
569
570/// `$SUPERFLUID_HOME/superfluid.sock`, or a per-user socket in the temporary directory when that
571/// path is too long for a unix socket (`sun_path` is 104 bytes on macOS, 108 on Linux).
572pub fn default_socket(home: &Path, sun_path_len: usize) -> PathBuf {
573    let sock = home.join("superfluid.sock");
574    if sock.as_os_str().len() < sun_path_len {
575        return sock;
576    }
577    // SAFETY: getuid has no preconditions and cannot fail.
578    let uid = unsafe { libc::getuid() };
579    let tmp = std::env::temp_dir().join(format!("superfluid-{uid}.sock"));
580    if tmp.as_os_str().len() < sun_path_len {
581        tmp
582    } else {
583        PathBuf::from(format!("/tmp/superfluid-{uid}.sock"))
584    }
585}
586
587/// The runtime a model id goes to when no `--runtime` names one: a GGUF repository to llama.cpp,
588/// an MLX conversion to MLX, anything else to baseRT.
589pub fn runtime_for_id(id: &str) -> &'static str {
590    let id = id.split_once(':').map_or(id, |(id, _)| id).to_ascii_lowercase();
591    let (org, repo) = id.split_once('/').unwrap_or(("", &id));
592    if repo.contains("gguf") {
593        "llamacpp"
594    } else if org == "mlx-community" || repo.contains("mlx") {
595        "mlx"
596    } else {
597        "basert"
598    }
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    fn strings(v: &[&str]) -> Vec<String> {
606        v.iter().map(|s| s.to_string()).collect()
607    }
608
609    #[test]
610    fn equals_forms_are_split_only_for_flags_that_take_a_value() {
611        let got = split_equals(
612            &strings(&["--port=8000", "--park", "--pull-file=m.gguf", "--model=a=b.gguf", "x=y", "--nope=1", "--no-http=1"]),
613            serve_takes_value,
614        );
615        assert_eq!(got, strings(&["--port", "8000", "--park", "--pull-file=m.gguf", "--model", "a=b.gguf", "x=y", "--nope=1", "--no-http=1"]));
616        assert_eq!(split_equals(&strings(&["--max-batch-size=4"]), serve_takes_value), strings(&["--max-batch-size", "4"]));
617    }
618
619    #[test]
620    fn a_near_miss_is_suggested_and_a_far_one_is_not() {
621        assert_eq!(suggest("--max-ctx", serve_flag_names()), Some("--max-context"));
622        assert_eq!(suggest("--temprature", serve_flag_names()), Some("--temperature"));
623        assert_eq!(suggest("--api_key", serve_flag_names()), Some("--api-key"));
624        assert_eq!(suggest("--sesions=/x", serve_flag_names()), Some("--sessions"));
625        assert_eq!(suggest("--frobnicate-everything", serve_flag_names()), None);
626    }
627
628    #[test]
629    fn a_runtime_is_read_from_a_model_id() {
630        assert_eq!(runtime_for_id("unsloth/Qwen3-0.6B-GGUF:Q4_K_M"), "llamacpp");
631        assert_eq!(runtime_for_id("bartowski/Llama-3.2-1B-Instruct-gguf"), "llamacpp");
632        assert_eq!(runtime_for_id("mlx-community/Qwen3-4B-4bit"), "mlx");
633        assert_eq!(runtime_for_id("someone/Qwen3-4B-MLX-8bit:main"), "mlx");
634        assert_eq!(runtime_for_id("basecompute/Qwen3-1.7B"), "basert");
635        assert_eq!(runtime_for_id("Qwen/Qwen3-0.6B"), "basert");
636    }
637
638    #[test]
639    fn an_install_is_named_after_the_id_or_with_an_at_the_way_runtimes_lists_it() {
640        let target = |action: &str, name: &str, rest: &[&str]| {
641            runtime_target(action, name, &strings(rest)).map(|(id, rest)| (id.to_string(), rest))
642        };
643        let basert = |rest: &[&str]| Ok(("basert".to_string(), strings(rest)));
644        for action in ["remove", "repair", "use"] {
645            assert_eq!(target(action, "basert@0.3.0-metal", &[]), basert(&["0.3.0-metal"]), "{action}");
646            assert_eq!(target(action, "basert", &["0.3.0-metal"]), basert(&["0.3.0-metal"]), "{action}");
647            assert_eq!(target(action, "basert@0.3.0-metal", &["0.3.0-metal"]), basert(&["0.3.0-metal"]), "both forms agree");
648            assert_eq!(
649                target(action, "basert@0.3.0-metal", &["0.4.2-metal"]),
650                Err("'basert@0.3.0-metal' names the install 0.3.0-metal and '0.4.2-metal' names another: give one".into()),
651                "{action}"
652            );
653        }
654        assert_eq!(target("remove", "basert", &[]), basert(&[]), "the bare id still names every install");
655        assert_eq!(target("use", "basert", &["--auto"]), basert(&["--auto"]));
656        assert_eq!(
657            target("use", "basert@0.4.2-metal", &["--auto"]),
658            Err("'basert@0.4.2-metal' pins 0.4.2-metal and --auto unpins: `superfluid runtime use basert --auto` goes back to the best-ranked".into())
659        );
660        assert_eq!(target("remove", "basert", &["--all"]), basert(&["--all"]));
661        assert_eq!(
662            target("remove", "basert@0.3.0-metal", &["--all"]),
663            Err("'basert@0.3.0-metal' names the install 0.3.0-metal and --all every one: `superfluid runtime remove basert --all` removes them all".into())
664        );
665        assert_eq!(target("remove", "basert@a", &["a", "b"]), basert(&["a", "b"]), "left to the action's usage");
666    }
667
668    #[test]
669    fn an_install_that_looks_like_an_option_is_refused_not_passed_on_as_one() {
670        let refused = |action: &str, name: &str| runtime_target(action, name, &[]).unwrap_err();
671        assert_eq!(
672            refused("remove", "basert@--all"),
673            "'basert@--all' names no install: an install's name does not begin with '-': \
674             `superfluid runtime remove basert --all` removes every install"
675        );
676        assert_eq!(
677            refused("use", "basert@--auto"),
678            "'basert@--auto' names no install: an install's name does not begin with '-': \
679             `superfluid runtime use basert --auto` goes back to the best-ranked"
680        );
681        for action in ["remove", "repair", "use", "install", "update", "versions", "options"] {
682            assert_eq!(
683                refused(action, "basert@-x"),
684                "'basert@-x' names no install: an install's name does not begin with '-'",
685                "{action}"
686            );
687        }
688        assert!(runtime_target("remove", "basert@--all", &strings(&["--all"])).is_err());
689    }
690
691    #[test]
692    fn an_action_that_takes_only_an_id_says_how_to_ask_for_the_install() {
693        let refused = |action: &str, name: &str| runtime_target(action, name, &[]).unwrap_err();
694        assert_eq!(
695            refused("install", "basert@0.3.0-metal"),
696            "install takes a runtime id, not an install ('basert@0.3.0-metal'); \
697             `superfluid runtime install basert --version 0.3.0 --backend metal` installs 0.3.0-metal"
698        );
699        assert!(refused("install", "llamacpp@b11284-cuda-13.4").ends_with("--version b11284 --backend cuda-13.4` installs b11284-cuda-13.4"));
700        assert!(refused("install", "basert@0.5.0-rc1-rocm").contains("--version 0.5.0-rc1 --backend rocm`"));
701        assert!(refused("install", "llamacpp@custom-metal").ends_with("; --version and --backend pick the build"));
702        assert_eq!(
703            refused("update", "basert@0.3.0-metal"),
704            "update takes a runtime id, not an install ('basert@0.3.0-metal'); it updates the default install: `superfluid runtime update basert`"
705        );
706        for action in ["versions", "options"] {
707            assert_eq!(
708                refused(action, "mlx@0.29.1-metal"),
709                format!("{action} takes a runtime id, not an install ('mlx@0.29.1-metal'): `superfluid runtime {action} mlx`")
710            );
711        }
712        assert_eq!(runtime_target("frob", "basert@a", &[]).unwrap().0.to_string(), "basert@a", "an unknown action gets its usage");
713    }
714
715    #[test]
716    fn a_name_that_is_no_runtime_id_is_refused_with_the_forms_it_takes() {
717        let not_an_id = "'Basert' is not a runtime id (lowercase letters, digits, '.', '-', '_')";
718        assert_eq!(runtime_target("install", "Basert", &[]).unwrap_err(), not_an_id);
719        assert_eq!(
720            runtime_target("remove", "Basert", &[]).unwrap_err(),
721            format!("{not_an_id}, or <id>@<install> as `superfluid runtimes` lists it")
722        );
723        for empty in ["basert@", "@0.3.0-metal", "basert@.hidden"] {
724            assert!(runtime_target("remove", empty, &[]).is_err(), "{empty}");
725        }
726    }
727
728    #[test]
729    fn the_default_socket_fits_a_unix_socket_path() {
730        assert_eq!(default_socket(Path::new("/home/u/.superfluid"), 104), Path::new("/home/u/.superfluid/superfluid.sock"));
731        let long = PathBuf::from(format!("/{}", "h".repeat(120)));
732        let s = default_socket(&long, 104);
733        assert!(s.as_os_str().len() < 104 && s.file_name().unwrap().to_string_lossy().starts_with("superfluid-"), "{}", s.display());
734    }
735
736    #[test]
737    fn serve_help_names_every_flag_in_its_table() {
738        let help = serve_help();
739        for name in SERVE.iter().flat_map(|g| g.flags.iter().map(|fl| fl.name)) {
740            assert!(help.contains(name), "{name}");
741        }
742        for line in help.lines() {
743            assert!(line.chars().count() <= WIDTH, "a help line runs on: {line}");
744        }
745    }
746
747    /// Every flag the `serve` parser matches is in the table (or an alias or a
748    /// retired flag), so `serve --help`, `--flag=value` and the suggestions know it.
749    #[test]
750    fn every_flag_serve_reads_is_known_here() {
751        let src = include_str!("serve.rs");
752        let start = src.find("SERVE-FLAGS-BEGIN").expect("marker");
753        let end = src.find("SERVE-FLAGS-END").expect("marker");
754        let body = &src[start..end];
755        let mut seen = 0;
756        for piece in body.split('"').skip(1).step_by(2) {
757            let long = piece.starts_with("--") && piece.len() > 2 && piece != "--pull-";
758            let short = piece.len() == 2 && piece.starts_with('-') && piece.as_bytes()[1].is_ascii_alphabetic();
759            if (long || short) && !piece.contains(' ') {
760                seen += 1;
761                assert!(serve_takes_value(piece).is_some(), "serve reads {piece}, which cli.rs does not list");
762            }
763        }
764        assert!(seen > 100, "the scan found {seen} flags");
765    }
766}