Skip to main content

ferrox_server/
cli.rs

1//! `ferrox-server`'s argument surface: the llama.cpp-shaped command line
2//! both front ends parse, and the environment it lowers to.
3//!
4//! Split out of `lib.rs` under this repo's "a new file beats a new
5//! section" rule, with no behaviour change: the struct, the
6//! multi-character short-option rewrite (`-ngl`, `-np`, `-hf`), the
7//! device/GPU-layer value types and `apply_cli_overrides` all moved
8//! verbatim, along with the tests that cover them.
9//!
10//! One rule this module holds to, because the repo has already paid for
11//! breaking it: **a flag that is accepted must reach the thing it
12//! names.** `ferrox bench --n-gpu-layers 0` was documented as forcing
13//! CPU and did not, because the backend was decided before the flag was
14//! read. Every override here runs in `apply_cli_overrides`, *before*
15//! the runtime and the model loader read the environment.
16
17use std::fmt;
18use std::net::{IpAddr, Ipv4Addr, SocketAddr};
19use std::path::PathBuf;
20use std::str::FromStr;
21
22use clap::{Parser, ValueEnum};
23
24// `PartialEq` so ferrox-cli's serve tests can assert that both front
25// ends parse a command line into the SAME arguments, rather than
26// asserting field by field and missing whichever one is added next.
27#[derive(Parser, Debug, PartialEq)]
28// No `version` here on purpose. This struct is both `ferrox-server`'s
29// own argv and the body of ferrox-cli's `serve` subcommand, and clap
30// gives an embedded subcommand its own `--version` derived from the
31// variant name: `ferrox serve --version` printed `ferrox-serve 0.10.0`,
32// naming a binary nobody ships. The front end's own `--version` is the
33// truth, and both report the same workspace version anyway.
34#[command(
35    name = "ferrox-server",
36    about = "OpenAI-compatible Ferrox inference server"
37)]
38pub struct ServerArgs {
39    /// Model path (GGUF file or Kimi checkpoint directory).
40    #[arg(short = 'm', long = "model", value_name = "FILE")]
41    model: Option<String>,
42
43    /// Hugging Face repo to serve, `user/repo[:QUANT]`, llama.cpp's
44    /// `-hf`.
45    ///
46    /// Downloads into the ferrox cache on first use and reuses it
47    /// after, so `-hf TheBloke/Mixtral-8x7B-Instruct-v0.1-GGUF:Q4_K_M`
48    /// is the whole command. The tag after the colon is a QUANT LABEL,
49    /// not a git revision, and it matches without regard to case.
50    #[arg(
51        long = "hf-repo",
52        visible_alias = "hf",
53        value_name = "REPO[:QUANT]",
54        conflicts_with = "model"
55    )]
56    hf_repo: Option<String>,
57
58    /// Exact filename inside `--hf-repo`, llama.cpp's `-hff`.
59    ///
60    /// For a repo whose quant labels do not disambiguate, or a file
61    /// whose name carries no quant at all.
62    #[arg(long = "hf-file", value_name = "FILE", requires = "hf_repo")]
63    hf_file: Option<String>,
64
65    /// Context size, llama.cpp's `-c`. Sets `FERROX_CB_MAX_CONTEXT`.
66    ///
67    /// Unset means the ceiling is derived at load from the weights and
68    /// the per-token KV against the device budget, capped at the
69    /// model's trained context, which is usually what you want.
70    #[arg(short = 'c', long = "ctx-size", value_name = "N")]
71    ctx_size: Option<usize>,
72
73    /// Require `Authorization: Bearer <key>`, llama.cpp's `--api-key`.
74    /// Sets `FERROX_API_KEY`, which also gates `/admin`.
75    #[arg(long = "api-key", value_name = "KEY")]
76    api_key: Option<String>,
77
78    /// Read the API key from a file, llama.cpp's `--api-key-file`.
79    ///
80    /// Preferred over `--api-key` on a shared host: an argument is
81    /// visible in `ps` to every user on the machine.
82    #[arg(long = "api-key-file", value_name = "PATH", conflicts_with = "api_key")]
83    api_key_file: Option<std::path::PathBuf>,
84
85    /// Name this model answers to in `/v1/models` and in responses,
86    /// llama.cpp's `--alias`. Sets `FERROX_MODEL_NAME`.
87    #[arg(long = "alias", visible_alias = "model-alias", value_name = "NAME")]
88    alias: Option<String>,
89
90    /// KV cache dtype, llama.cpp's `--cache-type-k`. Metal only; the
91    /// CPU and CUDA KV cache is the host `Vec<f32>`.
92    #[arg(long = "ctk", visible_alias = "cache-type-k", value_name = "TYPE")]
93    ctk: Option<String>,
94
95    /// Accepted and already the default: ferrox always compiles and
96    /// evaluates the GGUF's own `tokenizer.chat_template`. llama.cpp
97    /// needs `--jinja` to do that, so a command copied from there
98    /// carries it, and dying on an unknown flag would be a worse answer
99    /// than saying "yes, always".
100    #[arg(long = "jinja", default_value_t = false)]
101    jinja: bool,
102
103    /// Refused rather than ignored: ferrox has no
104    /// template-free/sniffing mode to fall back to. See `--jinja`.
105    #[arg(long = "no-jinja", default_value_t = false)]
106    no_jinja: bool,
107
108    /// Accepted; ferrox does no warm-up pass, so there is none to skip.
109    #[arg(long = "no-warmup", default_value_t = false)]
110    no_warmup: bool,
111
112    /// Accepted. Fused attention is a backend decision here, not a
113    /// request-time one: it is on wherever the Metal kernels support
114    /// the shape (`FERROX_METAL_ATTN`).
115    #[arg(long = "flash-attn", visible_alias = "fa", value_name = "MODE", num_args = 0..=1, default_missing_value = "auto")]
116    flash_attn: Option<String>,
117
118    /// IP address to listen on.
119    #[arg(long, value_name = "HOST")]
120    host: Option<IpAddr>,
121
122    /// Port to listen on. `0` asks the kernel for a free one; the
123    /// actually-bound address is then announced on stdout (see
124    /// [`announce_ready`]), which is how a supervising process is meant
125    /// to learn it.
126    #[arg(long, value_name = "PORT")]
127    port: Option<u16>,
128
129    /// CPU threads (sets FERROX_CPU_THREADS and RAYON_NUM_THREADS).
130    #[arg(short = 't', long = "threads", value_name = "N")]
131    threads: Option<usize>,
132
133    /// Device used for offloading (`none` disables GPU use).
134    #[arg(
135        long = "device",
136        visible_alias = "dev",
137        value_name = "DEVICE",
138        ignore_case = true
139    )]
140    device: Option<OffloadDevice>,
141
142    /// Print available offload devices and exit.
143    #[arg(long = "list-devices", default_value_t = false)]
144    pub(crate) list_devices: bool,
145
146    /// GPU layers: `0`, a positive number, `auto`, or `all`.
147    ///
148    /// Partial placement is not implemented yet; any value above zero
149    /// currently enables all supported operations on the selected backend.
150    #[arg(
151        long = "n-gpu-layers",
152        visible_aliases = ["gpu-layers", "ngl"],
153        value_name = "N"
154    )]
155    n_gpu_layers: Option<GpuLayers>,
156
157    /// MCP tool-server config JSON (stub: listed in `/v1/models` metadata).
158    #[arg(long = "mcp-config", value_name = "PATH")]
159    pub(crate) mcp_config: Option<PathBuf>,
160
161    /// Exit when stdin reaches EOF (for a supervising parent process).
162    ///
163    /// Opt-in on purpose: a server started with stdin redirected from
164    /// `/dev/null` -- systemd, cron, `nohup` -- sees EOF immediately,
165    /// and making this the default would turn those into a server that
166    /// exits the moment it starts. A parent that *wants* the guarantee
167    /// (the desktop shell) passes the flag and keeps the pipe open.
168    #[arg(long = "exit-on-stdin-close", default_value_t = false)]
169    pub(crate) exit_on_stdin_close: bool,
170
171    /// Share one batched decode worker across concurrent requests
172    /// (llama.cpp `-cb`). Also sets `FERROX_CONTINUOUS_BATCHING=1`.
173    #[arg(
174        long = "cont-batching",
175        visible_aliases = ["continuous-batching", "cb"],
176        default_value_t = false
177    )]
178    cont_batching: bool,
179
180    /// Disable auto continuous batching on Metal
181    /// (`FERROX_CONTINUOUS_BATCHING=0`).
182    #[arg(
183        long = "no-cont-batching",
184        default_value_t = false,
185        conflicts_with = "cont_batching"
186    )]
187    no_cont_batching: bool,
188
189    /// Max concurrent sequences under continuous batching (llama.cpp
190    /// `-np`). Sets `FERROX_CB_MAX_SEQS`; implies `--cont-batching`
191    /// unless `--no-cont-batching` is set.
192    #[arg(long = "parallel", visible_alias = "np", value_name = "N")]
193    parallel: Option<usize>,
194
195    /// Logical maximum prompt tokens per forward pass, llama.cpp's
196    /// `-b`. See [`crate::prefill_batch`] for how it and `-ub` resolve
197    /// to the one number ferrox keeps.
198    #[arg(long = "batch-size", visible_alias = "b", value_name = "N")]
199    batch_size: Option<usize>,
200
201    /// Physical maximum prompt tokens per forward pass, llama.cpp's
202    /// `-ub`. Clamped to `--batch-size` when both are given.
203    #[arg(long = "ubatch-size", visible_alias = "ub", value_name = "N")]
204    ubatch_size: Option<usize>,
205
206    /// Directory slot files are saved into and restored from,
207    /// llama.cpp's `--slot-save-path`. Sets `FERROX_SLOT_SAVE_PATH`.
208    ///
209    /// `POST /slots/{id_slot}?action=save|restore` refuses with a 501
210    /// naming this flag while it is unset, exactly as llama.cpp does
211    /// (`tools/server/server-context.cpp:4538`): a server that would
212    /// write KV state to disk should have been told where.
213    #[arg(long = "slot-save-path", value_name = "DIR")]
214    slot_save_path: Option<PathBuf>,
215
216    /// Start even though another ferrox process is already holding a
217    /// model. Off by default: two models on one box do not share it,
218    /// they thrash it, and both serve slower than either would alone.
219    /// `FERROX_ALLOW_MULTIPLE_INSTANCES=1` does the same.
220    #[arg(long = "allow-multiple-instances", default_value_t = false)]
221    pub(crate) allow_multiple_instances: bool,
222
223    /// Token budget for thinking, llama.cpp's `--reasoning-budget`: -1
224    /// for unrestricted, 0 for immediate end, N>0 for a token budget
225    /// (default: -1). The server default a request's
226    /// `reasoning_budget_tokens` falls back to when it is absent or -1;
227    /// sets `FERROX_REASONING_BUDGET`. Enforced in the sampler: once N
228    /// tokens of thought have followed the opener, the closer is forced
229    /// so the answer still arrives.
230    #[arg(
231        long = "reasoning-budget",
232        value_name = "N",
233        allow_hyphen_values = true
234    )]
235    reasoning_budget: Option<i64>,
236
237    /// Continue a trailing assistant message instead of starting a new
238    /// turn, llama.cpp's `--prefill-assistant` (the default). A request
239    /// can still say `continue_final_message: false`.
240    #[arg(long = "prefill-assistant", default_value_t = false)]
241    prefill_assistant: bool,
242
243    /// Treat a trailing assistant message as a complete turn,
244    /// llama.cpp's `--no-prefill-assistant`. Sets
245    /// `FERROX_PREFILL_ASSISTANT=0`; a request's own
246    /// `continue_final_message` still wins.
247    #[arg(
248        long = "no-prefill-assistant",
249        default_value_t = false,
250        conflicts_with = "prefill_assistant"
251    )]
252    no_prefill_assistant: bool,
253}
254
255impl ServerArgs {
256    /// Parses `ferrox-server`'s own argv, including the llama.cpp-style
257    /// multi-character short options (`-ngl`, `-dev`) that clap cannot
258    /// express and which are rewritten to their long forms first.
259    ///
260    /// Public because ferrox-cli's `serve` subcommand hands the same
261    /// arguments to the same parser rather than reimplementing it.
262    pub fn parse_llama_style<I>(argv: I) -> Self
263    where
264        I: IntoIterator<Item = String>,
265    {
266        Self::parse_from(rewrite_llama_style_argv(argv.into_iter().collect()))
267    }
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
271enum OffloadDevice {
272    Auto,
273    None,
274    Cpu,
275    Metal,
276    Cuda,
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280enum GpuLayers {
281    Auto,
282    All,
283    Count(u32),
284}
285
286impl GpuLayers {
287    fn offload_enabled(self) -> bool {
288        !matches!(self, Self::Count(0))
289    }
290}
291
292impl FromStr for GpuLayers {
293    type Err = String;
294
295    fn from_str(value: &str) -> Result<Self, Self::Err> {
296        match value {
297            "auto" => Ok(Self::Auto),
298            "all" => Ok(Self::All),
299            _ => value
300                .parse::<u32>()
301                .map(Self::Count)
302                .map_err(|_| "expected 0, a positive integer, 'auto', or 'all'".into()),
303        }
304    }
305}
306
307impl fmt::Display for GpuLayers {
308    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309        match self {
310            Self::Auto => f.write_str("auto"),
311            Self::All => f.write_str("all"),
312            Self::Count(value) => value.fmt(f),
313        }
314    }
315}
316
317/// Whether this build of the server has the Metal kernels compiled in.
318///
319/// Exists for the front ends that link this library: ferrox-cli's
320/// `metal` feature has to forward into ferrox-server
321/// (`ferrox-server?/metal`) or `ferrox serve --device metal` refuses on
322/// a Metal host while `ferrox run` on the same binary uses it. That
323/// mismatch is one Cargo manifest edit away and compiles cleanly, so
324/// ferrox-cli asserts on this constant at compile time.
325pub const BUILT_WITH_METAL: bool = cfg!(feature = "metal");
326
327/// Whether this build of the server has the CUDA kernels compiled in.
328/// See [`BUILT_WITH_METAL`].
329pub const BUILT_WITH_CUDA: bool = cfg!(feature = "cuda");
330
331fn rewrite_llama_style_argv(args: Vec<String>) -> Vec<String> {
332    args.into_iter()
333        .map(|arg| match arg.as_str() {
334            "-ngl" => "--n-gpu-layers".into(),
335            "-dev" => "--device".into(),
336            "-cb" => "--cont-batching".into(),
337            "-np" => "--parallel".into(),
338            "-b" => "--batch-size".into(),
339            "-ub" => "--ubatch-size".into(),
340            // One token in llama.cpp's hand-written parser. clap sees
341            // `-h` followed by `f` and prints help, which is what
342            // `ferrox serve -hf repo:Q4_K_M` did: the flag looked
343            // absent rather than mis-spelled.
344            "-hf" => "--hf-repo".into(),
345            "-hff" => "--hf-file".into(),
346            _ => arg,
347        })
348        .collect()
349}
350
351pub(crate) fn print_available_devices() {
352    println!("Available devices:");
353    println!("  CPU");
354
355    let metal = ferrox_metal::MetalProfile::detect();
356    if let Some(name) = metal.device_name {
357        println!("  Metal: {name}");
358    }
359
360    let cuda = ferrox_cuda::HardwareProfile::detect();
361    if cuda.cuda_available {
362        let name = cuda.cuda_device_name.as_deref().unwrap_or("unknown device");
363        println!("  CUDA: {name}");
364        if cuda.cuda_device_count > 1 {
365            println!("        ({} devices detected)", cuda.cuda_device_count);
366        }
367    }
368}
369
370fn cli_bind_addr(args: &ServerArgs, env_addr: Option<&str>) -> Option<String> {
371    if args.host.is_none() && args.port.is_none() {
372        return None;
373    }
374
375    let existing = env_addr.and_then(|value| value.parse::<SocketAddr>().ok());
376    let host = args
377        .host
378        .or_else(|| existing.map(|addr| addr.ip()))
379        .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
380    let port = args
381        .port
382        .or_else(|| existing.map(|addr| addr.port()))
383        .unwrap_or(8383);
384    Some(SocketAddr::new(host, port).to_string())
385}
386
387/// Resolves a `-hf` reference to a local path, downloading it once.
388///
389/// Progress goes to STDERR, not stdout: stdout carries the
390/// `ferrox.server.ready` line a supervising process parses, and a
391/// progress bar in the middle of it would break that contract.
392fn resolve_hf_repo(spec: &str, file: Option<&str>) -> anyhow::Result<String> {
393    let mut hf = ferrox_models::hub::HfRef::parse(spec);
394    if let Some(f) = file {
395        hf.file = Some(f.to_string());
396    }
397    eprintln!(
398        "ferrox: resolving {} on the Hub{}",
399        hf.repo,
400        hf.quant
401            .as_deref()
402            .map(|q| format!(" ({q})"))
403            .unwrap_or_default()
404    );
405
406    let mut last = std::time::Instant::now();
407    let mut draw = move |done: u64, total: Option<u64>| {
408        if last.elapsed() < std::time::Duration::from_millis(200) {
409            return;
410        }
411        last = std::time::Instant::now();
412        let mib = done as f64 / 1024.0 / 1024.0;
413        match total {
414            Some(t) if t > 0 => {
415                eprint!(
416                    "\r  {mib:>9.1} MiB  {:5.1}%",
417                    (done as f64 / t as f64) * 100.0
418                )
419            }
420            _ => eprint!("\r  {mib:>9.1} MiB"),
421        }
422    };
423
424    let (path, downloaded) = hf
425        .ensure_local(&mut draw)
426        .map_err(|e| anyhow::anyhow!("{e}"))?;
427    if downloaded {
428        eprintln!();
429        eprintln!("ferrox: downloaded {}", path.display());
430    } else {
431        eprintln!("ferrox: using cached {}", path.display());
432    }
433    Ok(path.to_string_lossy().into_owned())
434}
435
436pub(crate) fn apply_cli_overrides(args: &ServerArgs) -> anyhow::Result<()> {
437    if let Some(model) = &args.model {
438        // SAFETY: called before the runtime starts worker threads.
439        unsafe { std::env::set_var("FERROX_MODEL_PATH", model) };
440    }
441    if let Some(spec) = &args.hf_repo {
442        let path = resolve_hf_repo(spec, args.hf_file.as_deref())?;
443        // SAFETY: called before the runtime starts worker threads.
444        unsafe { std::env::set_var("FERROX_MODEL_PATH", &path) };
445    }
446    if let Some(n) = args.ctx_size {
447        if n == 0 {
448            anyhow::bail!("--ctx-size must be greater than zero");
449        }
450        // SAFETY: called before the runtime starts worker threads.
451        unsafe { std::env::set_var("FERROX_CB_MAX_CONTEXT", n.to_string()) };
452    }
453    if let Some(key) = &args.api_key {
454        // SAFETY: called before the runtime starts worker threads.
455        unsafe { std::env::set_var("FERROX_API_KEY", key) };
456    }
457    if let Some(path) = &args.api_key_file {
458        let key = std::fs::read_to_string(path)
459            .map_err(|e| anyhow::anyhow!("reading --api-key-file {}: {e}", path.display()))?;
460        let key = key.trim();
461        if key.is_empty() {
462            anyhow::bail!(
463                "--api-key-file {} is empty: an empty key would leave every route open, \
464                 which is the opposite of what passing the flag asked for",
465                path.display()
466            );
467        }
468        // SAFETY: called before the runtime starts worker threads.
469        unsafe { std::env::set_var("FERROX_API_KEY", key) };
470    }
471    if let Some(alias) = &args.alias {
472        // SAFETY: called before the runtime starts worker threads.
473        unsafe { std::env::set_var("FERROX_MODEL_NAME", alias) };
474    }
475    if let Some(ctk) = &args.ctk {
476        // SAFETY: called before the runtime starts worker threads.
477        unsafe { std::env::set_var("FERROX_CTK", ctk.trim()) };
478    }
479    // Refused by NAME rather than ignored. A prompt framed by a
480    // hand-written guess instead of the checkpoint's own template is
481    // the kind of wrong answer that reads as a model quality problem,
482    // so "ferrox cannot do that" is the honest reply.
483    if args.no_jinja {
484        anyhow::bail!(
485            "--no-jinja: ferrox has no template-free mode. It compiles and evaluates the GGUF's \
486             own tokenizer.chat_template, which is what llama.cpp's --jinja turns on, and there \
487             is no sniffing fallback to switch to. Use --no-cnv on `ferrox run` for a raw \
488             completion"
489        );
490    }
491    if let Some(mode) = &args.flash_attn {
492        let mode = mode.trim().to_ascii_lowercase();
493        if mode == "off" || mode == "disabled" || mode == "0" {
494            anyhow::bail!(
495                "--flash-attn off: fused attention is a backend property here, not a per-run \
496                 switch. Set FERROX_METAL_ATTN=0 to take the unfused Metal path, or --device cpu"
497            );
498        }
499    }
500
501    if let Some(addr) = cli_bind_addr(args, std::env::var("FERROX_ADDR").ok().as_deref()) {
502        // SAFETY: called before the runtime starts worker threads.
503        unsafe { std::env::set_var("FERROX_ADDR", addr) };
504    }
505
506    if let Some(threads) = args.threads {
507        if threads == 0 {
508            anyhow::bail!("--threads must be greater than zero");
509        }
510        // SAFETY: called before the runtime starts worker threads.
511        unsafe {
512            std::env::set_var("FERROX_CPU_THREADS", threads.to_string());
513            std::env::set_var("RAYON_NUM_THREADS", threads.to_string());
514        }
515    }
516
517    if args.device.is_none() && args.n_gpu_layers.is_none() {
518        // device overrides skipped
519    } else {
520        let layers = args.n_gpu_layers.unwrap_or(GpuLayers::Auto);
521        let device = if layers.offload_enabled() {
522            args.device.unwrap_or(OffloadDevice::Auto)
523        } else {
524            OffloadDevice::None
525        };
526
527        match device {
528            OffloadDevice::None | OffloadDevice::Cpu => unsafe {
529                std::env::set_var("FERROX_METAL", "0");
530                std::env::set_var("FERROX_METAL_ATTN", "0");
531                std::env::set_var("FERROX_CUDA", "0");
532            },
533            OffloadDevice::Auto => unsafe {
534                std::env::set_var("FERROX_METAL", "auto");
535                std::env::set_var("FERROX_CUDA", "auto");
536                if std::env::var_os("FERROX_METAL_ATTN").is_none() {
537                    std::env::set_var("FERROX_METAL_ATTN", "1");
538                }
539            },
540            OffloadDevice::Metal => {
541                #[cfg(not(feature = "metal"))]
542                {
543                    anyhow::bail!(
544                        "Metal requested but this binary was built without --features metal"
545                    );
546                }
547                #[cfg(feature = "metal")]
548                {
549                    if !ferrox_metal::MetalProfile::detect().available {
550                        anyhow::bail!("Metal requested but no Metal device is available");
551                    }
552                    unsafe {
553                        std::env::set_var("FERROX_METAL", "1");
554                        if std::env::var_os("FERROX_METAL_ATTN").is_none() {
555                            std::env::set_var("FERROX_METAL_ATTN", "1");
556                        }
557                        std::env::set_var("FERROX_CUDA", "0");
558                    }
559                }
560            }
561            OffloadDevice::Cuda => {
562                #[cfg(not(feature = "cuda"))]
563                {
564                    anyhow::bail!(
565                        "CUDA requested but this binary was built without --features cuda"
566                    );
567                }
568                #[cfg(feature = "cuda")]
569                {
570                    if !ferrox_cuda::HardwareProfile::detect().cuda_available {
571                        anyhow::bail!("CUDA requested but no CUDA device is available");
572                    }
573                    unsafe {
574                        std::env::set_var("FERROX_CUDA", "1");
575                        std::env::set_var("FERROX_METAL", "0");
576                        std::env::set_var("FERROX_METAL_ATTN", "0");
577                    }
578                }
579            }
580        }
581    }
582
583    if let Some(n) = args.parallel {
584        if n == 0 {
585            anyhow::bail!("--parallel must be greater than zero");
586        }
587        // SAFETY: called before the runtime starts worker threads.
588        unsafe { std::env::set_var("FERROX_CB_MAX_SEQS", n.to_string()) };
589    }
590
591    if let Some(dir) = &args.slot_save_path {
592        if !dir.is_dir() {
593            anyhow::bail!(
594                "--slot-save-path {} is not a directory. Slots are written into it by name, so \
595                 a path that does not exist would be discovered on the first save rather than \
596                 at startup",
597                dir.display()
598            );
599        }
600        // SAFETY: called before the runtime starts worker threads.
601        unsafe { std::env::set_var("FERROX_SLOT_SAVE_PATH", dir) };
602    }
603
604    for (flag, value) in [
605        ("--batch-size", args.batch_size),
606        ("--ubatch-size", args.ubatch_size),
607    ] {
608        if value == Some(0) {
609            anyhow::bail!("{flag} must be greater than zero");
610        }
611    }
612    if let Some(chunk) = crate::prefill_batch::effective_chunk(args.batch_size, args.ubatch_size) {
613        // Both spellings, from one number and one array of names: the
614        // private decode loop and the batch scheduler each read their
615        // own variable, and an operator who names `-ub` must not have
616        // to know which path this server happens to be serving on.
617        for key in crate::prefill_batch::PREFILL_CHUNK_ENV_KEYS {
618            // SAFETY: called before the runtime starts worker threads.
619            unsafe { std::env::set_var(key, chunk.to_string()) };
620        }
621    }
622
623    if let Some(budget) = args.reasoning_budget {
624        // The same range check the request field applies, so the flag
625        // and the field cannot admit different values.
626        crate::reasoning_budget::BudgetTokens::parse(budget)
627            .map_err(|why| anyhow::anyhow!("--reasoning-budget: {why}"))?;
628        // SAFETY: called before the runtime starts worker threads.
629        unsafe {
630            std::env::set_var(
631                crate::reasoning_budget::SERVER_DEFAULT_ENV,
632                budget.to_string(),
633            )
634        };
635    }
636    if args.prefill_assistant {
637        // SAFETY: called before the runtime starts worker threads.
638        unsafe { std::env::set_var(crate::continuation::PREFILL_ASSISTANT_ENV, "1") };
639    } else if args.no_prefill_assistant {
640        // SAFETY: called before the runtime starts worker threads.
641        unsafe { std::env::set_var(crate::continuation::PREFILL_ASSISTANT_ENV, "0") };
642    }
643
644    if args.cont_batching {
645        // SAFETY: called before the runtime starts worker threads.
646        unsafe { std::env::set_var("FERROX_CONTINUOUS_BATCHING", "1") };
647    } else if args.no_cont_batching {
648        // SAFETY: called before the runtime starts worker threads.
649        unsafe { std::env::set_var("FERROX_CONTINUOUS_BATCHING", "0") };
650    } else if args.parallel.is_some() {
651        // llama.cpp `-np` is only meaningful with continuous batching.
652        // SAFETY: called before the runtime starts worker threads.
653        unsafe { std::env::set_var("FERROX_CONTINUOUS_BATCHING", "1") };
654    }
655
656    Ok(())
657}
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    #[test]
663    fn parses_llama_server_style_options() {
664        let argv = [
665            "ferrox-server",
666            "-m",
667            "model.gguf",
668            "--host",
669            "::1",
670            "--port",
671            "9000",
672            "-t",
673            "4",
674            "-dev",
675            "Metal",
676            "-ngl",
677            "all",
678        ]
679        .into_iter()
680        .map(String::from)
681        .collect();
682        let args = ServerArgs::try_parse_from(rewrite_llama_style_argv(argv)).unwrap();
683
684        assert_eq!(args.model.as_deref(), Some("model.gguf"));
685        assert_eq!(args.host, Some(IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)));
686        assert_eq!(args.port, Some(9000));
687        assert_eq!(args.threads, Some(4));
688        assert_eq!(args.device, Some(OffloadDevice::Metal));
689        assert_eq!(args.n_gpu_layers, Some(GpuLayers::All));
690        assert_eq!(
691            cli_bind_addr(&args, Some("127.0.0.1:8383")).as_deref(),
692            Some("[::1]:9000")
693        );
694    }
695
696    #[test]
697    fn port_zero_survives_argument_parsing_as_a_real_request() {
698        // `--port 0` must reach the bind call intact: it is a request
699        // for a kernel-assigned port, not a missing value to default to
700        // 8383. The address it produces is deliberately provisional --
701        // the ready line reports what was actually bound.
702        let argv = ["ferrox-server", "--port", "0"]
703            .into_iter()
704            .map(String::from)
705            .collect();
706        let args = ServerArgs::try_parse_from(rewrite_llama_style_argv(argv)).unwrap();
707        assert_eq!(args.port, Some(0));
708        assert_eq!(
709            cli_bind_addr(&args, Some("127.0.0.1:8383")).as_deref(),
710            Some("127.0.0.1:0")
711        );
712    }
713
714    #[test]
715    fn parallel_flag_parses_and_rewrites_np() {
716        let argv = ["ferrox-server", "-np", "4"]
717            .into_iter()
718            .map(String::from)
719            .collect();
720        let args = ServerArgs::try_parse_from(rewrite_llama_style_argv(argv)).unwrap();
721        assert_eq!(args.parallel, Some(4));
722    }
723
724    /// `-b` and `-ub` are two tokens in llama.cpp's hand-written
725    /// parser and one token to clap, which sees `-b` as a short option
726    /// it has never heard of. The rewrite is what makes a copied
727    /// `llama-server ... -b 2048 -ub 512` command run here at all.
728    #[test]
729    fn batch_flags_parse_and_rewrite_their_llama_cpp_short_forms() {
730        let argv = ["ferrox-server", "-b", "2048", "-ub", "512"]
731            .into_iter()
732            .map(String::from)
733            .collect();
734        let args = ServerArgs::try_parse_from(rewrite_llama_style_argv(argv)).unwrap();
735        assert_eq!(args.batch_size, Some(2048));
736        assert_eq!(args.ubatch_size, Some(512));
737    }
738
739    /// Zero is not a batch size, and accepting it would make
740    /// `env_positive` panic the server later with a message naming an
741    /// environment variable the operator never set.
742    #[test]
743    fn a_zero_batch_size_is_refused_by_name_rather_than_lowered_to_the_environment() {
744        for flag in ["--batch-size", "--ubatch-size"] {
745            let args = ServerArgs::try_parse_from(
746                ["ferrox-server", flag, "0"].into_iter().map(String::from),
747            )
748            .unwrap();
749            let err = apply_cli_overrides(&args).unwrap_err().to_string();
750            assert!(err.contains(flag), "{flag}: {err}");
751        }
752    }
753
754    /// A path that is not a directory is refused at startup rather
755    /// than on the first save. The repo's rule about gates applies to
756    /// flags too: a `--slot-save-path` pointing at nothing looks
757    /// configured until somebody tries to use it, hours later.
758    #[test]
759    fn a_slot_save_path_that_is_not_a_directory_is_refused_at_startup() {
760        let args = ServerArgs::try_parse_from(
761            ["ferrox-server", "--slot-save-path", "/definitely/not/here"]
762                .into_iter()
763                .map(String::from),
764        )
765        .unwrap();
766        let err = apply_cli_overrides(&args).unwrap_err().to_string();
767        assert!(err.contains("--slot-save-path"), "{err}");
768        assert!(
769            std::env::var("FERROX_SLOT_SAVE_PATH").is_err(),
770            "a refused path must not have been lowered to the environment first"
771        );
772    }
773
774    /// llama.cpp's spelling and range (`common/arg.cpp:3608-3614`):
775    /// `-1`, `0` and `N` parse -- `-1` needs `allow_hyphen_values`, or
776    /// clap reads it as a flag -- and anything below `-1` is refused by
777    /// name before it is lowered to the environment.
778    #[test]
779    fn reasoning_budget_parses_llama_cpps_range_and_refuses_the_rest() {
780        for (value, expect) in [("-1", -1), ("0", 0), ("2000", 2000)] {
781            let args = ServerArgs::try_parse_from(
782                ["ferrox-server", "--reasoning-budget", value]
783                    .into_iter()
784                    .map(String::from),
785            )
786            .unwrap();
787            assert_eq!(args.reasoning_budget, Some(expect), "{value}");
788        }
789        let args = ServerArgs::try_parse_from(
790            ["ferrox-server", "--reasoning-budget", "-2"]
791                .into_iter()
792                .map(String::from),
793        )
794        .unwrap();
795        let err = apply_cli_overrides(&args).unwrap_err().to_string();
796        assert!(err.contains("--reasoning-budget"), "{err}");
797    }
798
799    /// Both spellings of llama.cpp's prefill switch parse, and they
800    /// conflict rather than letting the last one win silently.
801    #[test]
802    fn prefill_assistant_has_both_of_llama_cpps_spellings() {
803        let on = ServerArgs::try_parse_from(
804            ["ferrox-server", "--prefill-assistant"]
805                .into_iter()
806                .map(String::from),
807        )
808        .unwrap();
809        assert!(on.prefill_assistant && !on.no_prefill_assistant);
810        let off = ServerArgs::try_parse_from(
811            ["ferrox-server", "--no-prefill-assistant"]
812                .into_iter()
813                .map(String::from),
814        )
815        .unwrap();
816        assert!(off.no_prefill_assistant && !off.prefill_assistant);
817        assert!(ServerArgs::try_parse_from(
818            [
819                "ferrox-server",
820                "--prefill-assistant",
821                "--no-prefill-assistant"
822            ]
823            .into_iter()
824            .map(String::from),
825        )
826        .is_err());
827    }
828
829    #[test]
830    fn stdin_close_exit_is_opt_in() {
831        // Default off: a server whose stdin is /dev/null (systemd, cron,
832        // nohup) would otherwise exit the instant it started.
833        let args =
834            ServerArgs::try_parse_from(["ferrox-server"].into_iter().map(String::from)).unwrap();
835        assert!(!args.exit_on_stdin_close);
836        let args = ServerArgs::try_parse_from(
837            ["ferrox-server", "--exit-on-stdin-close"]
838                .into_iter()
839                .map(String::from),
840        )
841        .unwrap();
842        assert!(args.exit_on_stdin_close);
843    }
844}