Skip to main content

frink_server/
cli.rs

1//! `frink-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.** `frink 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 frink-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 `frink-server`'s
29// own argv and the body of frink-cli's `serve` subcommand, and clap
30// gives an embedded subcommand its own `--version` derived from the
31// variant name: `frink serve --version` printed `frink-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 = "frink-server",
36    about = "OpenAI-compatible Frink 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 frink 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 `FRINK_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 `FRINK_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 `FRINK_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: frink 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: frink 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; frink 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 (`FRINK_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 FRINK_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 `FRINK_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    /// (`FRINK_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 `FRINK_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 frink 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 `FRINK_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    /// LoRA adapter GGUF (llama.cpp's `--lora`), applied at scale 1.
217    /// Repeatable; comma-separated values are accepted as upstream
218    /// accepts them. Sets `FRINK_LORA` together with `--lora-scaled`,
219    /// and every load -- the first and each `/admin/models/load` --
220    /// attaches the same adapters or refuses the checkpoint by name.
221    #[arg(long = "lora", value_name = "FILE", action = clap::ArgAction::Append)]
222    lora: Vec<String>,
223
224    /// LoRA adapter with a scale, `FILE:SCALE` (llama.cpp's
225    /// `--lora-scaled`). Repeatable; adapters are numbered in the order
226    /// given, every `--lora` before every `--lora-scaled`, and that
227    /// number is the `id` `POST /lora-adapters` and a request's `lora`
228    /// field address.
229    #[arg(long = "lora-scaled", value_name = "FILE:SCALE", action = clap::ArgAction::Append)]
230    lora_scaled: Vec<String>,
231
232    /// Load the adapters but apply none of them until a
233    /// `POST /lora-adapters` sets their scales (llama.cpp's
234    /// `--lora-init-without-apply`). Sets `FRINK_LORA_INIT_WITHOUT_APPLY`.
235    #[arg(long = "lora-init-without-apply", default_value_t = false)]
236    lora_init_without_apply: bool,
237
238    /// Start even though another frink process is already holding a
239    /// model. Off by default: two models on one box do not share it,
240    /// they thrash it, and both serve slower than either would alone.
241    /// `FRINK_ALLOW_MULTIPLE_INSTANCES=1` does the same.
242    #[arg(long = "allow-multiple-instances", default_value_t = false)]
243    pub(crate) allow_multiple_instances: bool,
244
245    /// Token budget for thinking, llama.cpp's `--reasoning-budget`: -1
246    /// for unrestricted, 0 for immediate end, N>0 for a token budget
247    /// (default: -1). The server default a request's
248    /// `reasoning_budget_tokens` falls back to when it is absent or -1;
249    /// sets `FRINK_REASONING_BUDGET`. Enforced in the sampler: once N
250    /// tokens of thought have followed the opener, the closer is forced
251    /// so the answer still arrives.
252    #[arg(
253        long = "reasoning-budget",
254        value_name = "N",
255        allow_hyphen_values = true
256    )]
257    reasoning_budget: Option<i64>,
258
259    /// Continue a trailing assistant message instead of starting a new
260    /// turn, llama.cpp's `--prefill-assistant` (the default). A request
261    /// can still say `continue_final_message: false`.
262    #[arg(long = "prefill-assistant", default_value_t = false)]
263    prefill_assistant: bool,
264
265    /// Treat a trailing assistant message as a complete turn,
266    /// llama.cpp's `--no-prefill-assistant`. Sets
267    /// `FRINK_PREFILL_ASSISTANT=0`; a request's own
268    /// `continue_final_message` still wins.
269    #[arg(
270        long = "no-prefill-assistant",
271        default_value_t = false,
272        conflicts_with = "prefill_assistant"
273    )]
274    no_prefill_assistant: bool,
275}
276
277impl ServerArgs {
278    /// Parses `frink-server`'s own argv, including the llama.cpp-style
279    /// multi-character short options (`-ngl`, `-dev`) that clap cannot
280    /// express and which are rewritten to their long forms first.
281    ///
282    /// Public because frink-cli's `serve` subcommand hands the same
283    /// arguments to the same parser rather than reimplementing it.
284    pub fn parse_llama_style<I>(argv: I) -> Self
285    where
286        I: IntoIterator<Item = String>,
287    {
288        Self::parse_from(rewrite_llama_style_argv(argv.into_iter().collect()))
289    }
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
293enum OffloadDevice {
294    Auto,
295    None,
296    Cpu,
297    Metal,
298    Cuda,
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302enum GpuLayers {
303    Auto,
304    All,
305    Count(u32),
306}
307
308impl GpuLayers {
309    fn offload_enabled(self) -> bool {
310        !matches!(self, Self::Count(0))
311    }
312}
313
314impl FromStr for GpuLayers {
315    type Err = String;
316
317    fn from_str(value: &str) -> Result<Self, Self::Err> {
318        match value {
319            "auto" => Ok(Self::Auto),
320            "all" => Ok(Self::All),
321            _ => value
322                .parse::<u32>()
323                .map(Self::Count)
324                .map_err(|_| "expected 0, a positive integer, 'auto', or 'all'".into()),
325        }
326    }
327}
328
329impl fmt::Display for GpuLayers {
330    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
331        match self {
332            Self::Auto => f.write_str("auto"),
333            Self::All => f.write_str("all"),
334            Self::Count(value) => value.fmt(f),
335        }
336    }
337}
338
339/// Whether this build of the server has the Metal kernels compiled in.
340///
341/// Exists for the front ends that link this library: frink-cli's
342/// `metal` feature has to forward into frink-server
343/// (`frink-server?/metal`) or `frink serve --device metal` refuses on
344/// a Metal host while `frink run` on the same binary uses it. That
345/// mismatch is one Cargo manifest edit away and compiles cleanly, so
346/// frink-cli asserts on this constant at compile time.
347pub const BUILT_WITH_METAL: bool = cfg!(feature = "metal");
348
349/// Whether this build of the server has the CUDA kernels compiled in.
350/// See [`BUILT_WITH_METAL`].
351pub const BUILT_WITH_CUDA: bool = cfg!(feature = "cuda");
352
353fn rewrite_llama_style_argv(args: Vec<String>) -> Vec<String> {
354    args.into_iter()
355        .map(|arg| match arg.as_str() {
356            "-ngl" => "--n-gpu-layers".into(),
357            "-dev" => "--device".into(),
358            "-cb" => "--cont-batching".into(),
359            "-np" => "--parallel".into(),
360            "-b" => "--batch-size".into(),
361            "-ub" => "--ubatch-size".into(),
362            // One token in llama.cpp's hand-written parser. clap sees
363            // `-h` followed by `f` and prints help, which is what
364            // `frink serve -hf repo:Q4_K_M` did: the flag looked
365            // absent rather than mis-spelled.
366            "-hf" => "--hf-repo".into(),
367            "-hff" => "--hf-file".into(),
368            _ => arg,
369        })
370        .collect()
371}
372
373pub(crate) fn print_available_devices() {
374    println!("Available devices:");
375    println!("  CPU");
376
377    let metal = frink_metal::MetalProfile::detect();
378    if let Some(name) = metal.device_name {
379        println!("  Metal: {name}");
380    }
381
382    let cuda = frink_cuda::HardwareProfile::detect();
383    if cuda.cuda_available {
384        let name = cuda.cuda_device_name.as_deref().unwrap_or("unknown device");
385        println!("  CUDA: {name}");
386        if cuda.cuda_device_count > 1 {
387            println!("        ({} devices detected)", cuda.cuda_device_count);
388        }
389    }
390}
391
392fn cli_bind_addr(args: &ServerArgs, env_addr: Option<&str>) -> Option<String> {
393    if args.host.is_none() && args.port.is_none() {
394        return None;
395    }
396
397    let existing = env_addr.and_then(|value| value.parse::<SocketAddr>().ok());
398    let host = args
399        .host
400        .or_else(|| existing.map(|addr| addr.ip()))
401        .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
402    let port = args
403        .port
404        .or_else(|| existing.map(|addr| addr.port()))
405        .unwrap_or(8383);
406    Some(SocketAddr::new(host, port).to_string())
407}
408
409/// Resolves a `-hf` reference to a local path, downloading it once.
410///
411/// Progress goes to STDERR, not stdout: stdout carries the
412/// `frink.server.ready` line a supervising process parses, and a
413/// progress bar in the middle of it would break that contract.
414fn resolve_hf_repo(spec: &str, file: Option<&str>) -> anyhow::Result<String> {
415    let mut hf = frink_models::hub::HfRef::parse(spec);
416    if let Some(f) = file {
417        hf.file = Some(f.to_string());
418    }
419    eprintln!(
420        "frink: resolving {} on the Hub{}",
421        hf.repo,
422        hf.quant
423            .as_deref()
424            .map(|q| format!(" ({q})"))
425            .unwrap_or_default()
426    );
427
428    let mut last = std::time::Instant::now();
429    let mut draw = move |done: u64, total: Option<u64>| {
430        if last.elapsed() < std::time::Duration::from_millis(200) {
431            return;
432        }
433        last = std::time::Instant::now();
434        let mib = done as f64 / 1024.0 / 1024.0;
435        match total {
436            Some(t) if t > 0 => {
437                eprint!(
438                    "\r  {mib:>9.1} MiB  {:5.1}%",
439                    (done as f64 / t as f64) * 100.0
440                )
441            }
442            _ => eprint!("\r  {mib:>9.1} MiB"),
443        }
444    };
445
446    let (path, downloaded) = hf
447        .ensure_local(&mut draw)
448        .map_err(|e| anyhow::anyhow!("{e}"))?;
449    if downloaded {
450        eprintln!();
451        eprintln!("frink: downloaded {}", path.display());
452    } else {
453        eprintln!("frink: using cached {}", path.display());
454    }
455    Ok(path.to_string_lossy().into_owned())
456}
457
458pub(crate) fn apply_cli_overrides(args: &ServerArgs) -> anyhow::Result<()> {
459    if let Some(model) = &args.model {
460        // SAFETY: called before the runtime starts worker threads.
461        unsafe { std::env::set_var("FRINK_MODEL_PATH", model) };
462    }
463    if let Some(spec) = &args.hf_repo {
464        let path = resolve_hf_repo(spec, args.hf_file.as_deref())?;
465        // SAFETY: called before the runtime starts worker threads.
466        unsafe { std::env::set_var("FRINK_MODEL_PATH", &path) };
467    }
468    if let Some(n) = args.ctx_size {
469        if n == 0 {
470            anyhow::bail!("--ctx-size must be greater than zero");
471        }
472        // SAFETY: called before the runtime starts worker threads.
473        unsafe { std::env::set_var("FRINK_CB_MAX_CONTEXT", n.to_string()) };
474    }
475    if let Some(key) = &args.api_key {
476        // SAFETY: called before the runtime starts worker threads.
477        unsafe { std::env::set_var("FRINK_API_KEY", key) };
478    }
479    if let Some(path) = &args.api_key_file {
480        let key = std::fs::read_to_string(path)
481            .map_err(|e| anyhow::anyhow!("reading --api-key-file {}: {e}", path.display()))?;
482        let key = key.trim();
483        if key.is_empty() {
484            anyhow::bail!(
485                "--api-key-file {} is empty: an empty key would leave every route open, \
486                 which is the opposite of what passing the flag asked for",
487                path.display()
488            );
489        }
490        // SAFETY: called before the runtime starts worker threads.
491        unsafe { std::env::set_var("FRINK_API_KEY", key) };
492    }
493    if let Some(alias) = &args.alias {
494        // SAFETY: called before the runtime starts worker threads.
495        unsafe { std::env::set_var("FRINK_MODEL_NAME", alias) };
496    }
497    if let Some(ctk) = &args.ctk {
498        // SAFETY: called before the runtime starts worker threads.
499        unsafe { std::env::set_var("FRINK_CTK", ctk.trim()) };
500    }
501    // Refused by NAME rather than ignored. A prompt framed by a
502    // hand-written guess instead of the checkpoint's own template is
503    // the kind of wrong answer that reads as a model quality problem,
504    // so "frink cannot do that" is the honest reply.
505    if args.no_jinja {
506        anyhow::bail!(
507            "--no-jinja: frink has no template-free mode. It compiles and evaluates the GGUF's \
508             own tokenizer.chat_template, which is what llama.cpp's --jinja turns on, and there \
509             is no sniffing fallback to switch to. Use --no-cnv on `frink run` for a raw \
510             completion"
511        );
512    }
513    if let Some(mode) = &args.flash_attn {
514        let mode = mode.trim().to_ascii_lowercase();
515        if mode == "off" || mode == "disabled" || mode == "0" {
516            anyhow::bail!(
517                "--flash-attn off: fused attention is a backend property here, not a per-run \
518                 switch. Set FRINK_METAL_ATTN=0 to take the unfused Metal path, or --device cpu"
519            );
520        }
521    }
522
523    if let Some(addr) = cli_bind_addr(args, std::env::var("FRINK_ADDR").ok().as_deref()) {
524        // SAFETY: called before the runtime starts worker threads.
525        unsafe { std::env::set_var("FRINK_ADDR", addr) };
526    }
527
528    if let Some(threads) = args.threads {
529        if threads == 0 {
530            anyhow::bail!("--threads must be greater than zero");
531        }
532        // SAFETY: called before the runtime starts worker threads.
533        unsafe {
534            std::env::set_var("FRINK_CPU_THREADS", threads.to_string());
535            std::env::set_var("RAYON_NUM_THREADS", threads.to_string());
536        }
537    }
538
539    if args.device.is_none() && args.n_gpu_layers.is_none() {
540        // device overrides skipped
541    } else {
542        let layers = args.n_gpu_layers.unwrap_or(GpuLayers::Auto);
543        let device = if layers.offload_enabled() {
544            args.device.unwrap_or(OffloadDevice::Auto)
545        } else {
546            OffloadDevice::None
547        };
548
549        match device {
550            OffloadDevice::None | OffloadDevice::Cpu => unsafe {
551                std::env::set_var("FRINK_METAL", "0");
552                std::env::set_var("FRINK_METAL_ATTN", "0");
553                std::env::set_var("FRINK_CUDA", "0");
554            },
555            OffloadDevice::Auto => unsafe {
556                std::env::set_var("FRINK_METAL", "auto");
557                std::env::set_var("FRINK_CUDA", "auto");
558                if std::env::var_os("FRINK_METAL_ATTN").is_none() {
559                    std::env::set_var("FRINK_METAL_ATTN", "1");
560                }
561            },
562            OffloadDevice::Metal => {
563                #[cfg(not(feature = "metal"))]
564                {
565                    anyhow::bail!(
566                        "Metal requested but this binary was built without --features metal"
567                    );
568                }
569                #[cfg(feature = "metal")]
570                {
571                    if !frink_metal::MetalProfile::detect().available {
572                        anyhow::bail!("Metal requested but no Metal device is available");
573                    }
574                    unsafe {
575                        std::env::set_var("FRINK_METAL", "1");
576                        if std::env::var_os("FRINK_METAL_ATTN").is_none() {
577                            std::env::set_var("FRINK_METAL_ATTN", "1");
578                        }
579                        std::env::set_var("FRINK_CUDA", "0");
580                    }
581                }
582            }
583            OffloadDevice::Cuda => {
584                #[cfg(not(feature = "cuda"))]
585                {
586                    anyhow::bail!(
587                        "CUDA requested but this binary was built without --features cuda"
588                    );
589                }
590                #[cfg(feature = "cuda")]
591                {
592                    if !frink_cuda::HardwareProfile::detect().cuda_available {
593                        anyhow::bail!("CUDA requested but no CUDA device is available");
594                    }
595                    unsafe {
596                        std::env::set_var("FRINK_CUDA", "1");
597                        std::env::set_var("FRINK_METAL", "0");
598                        std::env::set_var("FRINK_METAL_ATTN", "0");
599                    }
600                }
601            }
602        }
603    }
604
605    if let Some(n) = args.parallel {
606        if n == 0 {
607            anyhow::bail!("--parallel must be greater than zero");
608        }
609        // SAFETY: called before the runtime starts worker threads.
610        unsafe { std::env::set_var("FRINK_CB_MAX_SEQS", n.to_string()) };
611    }
612
613    let lora_specs = frink_models::lora_attach::LoraSpec::from_flags(&args.lora, &args.lora_scaled)
614        .map_err(|e| anyhow::anyhow!("{e}"))?;
615    if !lora_specs.is_empty() {
616        for spec in &lora_specs {
617            if !spec.path.is_file() {
618                anyhow::bail!(
619                    "--lora {}: no such file. An adapter that cannot be opened would be \
620                     discovered at model load rather than at startup",
621                    spec.path.display()
622                );
623            }
624        }
625        let value = lora_specs
626            .iter()
627            .map(|s| format!("{}:{}", s.path.display(), s.scale))
628            .collect::<Vec<_>>()
629            .join(",");
630        // SAFETY: called before the runtime starts worker threads.
631        unsafe { std::env::set_var(crate::lora::ENV_SPECS, value) };
632    }
633    if args.lora_init_without_apply {
634        // SAFETY: called before the runtime starts worker threads.
635        unsafe { std::env::set_var(crate::lora::ENV_INIT_WITHOUT_APPLY, "1") };
636    }
637
638    if let Some(dir) = &args.slot_save_path {
639        if !dir.is_dir() {
640            anyhow::bail!(
641                "--slot-save-path {} is not a directory. Slots are written into it by name, so \
642                 a path that does not exist would be discovered on the first save rather than \
643                 at startup",
644                dir.display()
645            );
646        }
647        // SAFETY: called before the runtime starts worker threads.
648        unsafe { std::env::set_var("FRINK_SLOT_SAVE_PATH", dir) };
649    }
650
651    for (flag, value) in [
652        ("--batch-size", args.batch_size),
653        ("--ubatch-size", args.ubatch_size),
654    ] {
655        if value == Some(0) {
656            anyhow::bail!("{flag} must be greater than zero");
657        }
658    }
659    if let Some(chunk) = crate::prefill_batch::effective_chunk(args.batch_size, args.ubatch_size) {
660        // Both spellings, from one number and one array of names: the
661        // private decode loop and the batch scheduler each read their
662        // own variable, and an operator who names `-ub` must not have
663        // to know which path this server happens to be serving on.
664        for key in crate::prefill_batch::PREFILL_CHUNK_ENV_KEYS {
665            // SAFETY: called before the runtime starts worker threads.
666            unsafe { std::env::set_var(key, chunk.to_string()) };
667        }
668    }
669
670    if let Some(budget) = args.reasoning_budget {
671        // The same range check the request field applies, so the flag
672        // and the field cannot admit different values.
673        crate::reasoning_budget::BudgetTokens::parse(budget)
674            .map_err(|why| anyhow::anyhow!("--reasoning-budget: {why}"))?;
675        // SAFETY: called before the runtime starts worker threads.
676        unsafe {
677            std::env::set_var(
678                crate::reasoning_budget::SERVER_DEFAULT_ENV,
679                budget.to_string(),
680            )
681        };
682    }
683    if args.prefill_assistant {
684        // SAFETY: called before the runtime starts worker threads.
685        unsafe { std::env::set_var(crate::continuation::PREFILL_ASSISTANT_ENV, "1") };
686    } else if args.no_prefill_assistant {
687        // SAFETY: called before the runtime starts worker threads.
688        unsafe { std::env::set_var(crate::continuation::PREFILL_ASSISTANT_ENV, "0") };
689    }
690
691    if args.cont_batching {
692        // SAFETY: called before the runtime starts worker threads.
693        unsafe { std::env::set_var("FRINK_CONTINUOUS_BATCHING", "1") };
694    } else if args.no_cont_batching {
695        // SAFETY: called before the runtime starts worker threads.
696        unsafe { std::env::set_var("FRINK_CONTINUOUS_BATCHING", "0") };
697    } else if args.parallel.is_some() {
698        // llama.cpp `-np` is only meaningful with continuous batching.
699        // SAFETY: called before the runtime starts worker threads.
700        unsafe { std::env::set_var("FRINK_CONTINUOUS_BATCHING", "1") };
701    }
702
703    Ok(())
704}
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    #[test]
710    fn parses_llama_server_style_options() {
711        let argv = [
712            "frink-server",
713            "-m",
714            "model.gguf",
715            "--host",
716            "::1",
717            "--port",
718            "9000",
719            "-t",
720            "4",
721            "-dev",
722            "Metal",
723            "-ngl",
724            "all",
725        ]
726        .into_iter()
727        .map(String::from)
728        .collect();
729        let args = ServerArgs::try_parse_from(rewrite_llama_style_argv(argv)).unwrap();
730
731        assert_eq!(args.model.as_deref(), Some("model.gguf"));
732        assert_eq!(args.host, Some(IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)));
733        assert_eq!(args.port, Some(9000));
734        assert_eq!(args.threads, Some(4));
735        assert_eq!(args.device, Some(OffloadDevice::Metal));
736        assert_eq!(args.n_gpu_layers, Some(GpuLayers::All));
737        assert_eq!(
738            cli_bind_addr(&args, Some("127.0.0.1:8383")).as_deref(),
739            Some("[::1]:9000")
740        );
741    }
742
743    #[test]
744    fn port_zero_survives_argument_parsing_as_a_real_request() {
745        // `--port 0` must reach the bind call intact: it is a request
746        // for a kernel-assigned port, not a missing value to default to
747        // 8383. The address it produces is deliberately provisional --
748        // the ready line reports what was actually bound.
749        let argv = ["frink-server", "--port", "0"]
750            .into_iter()
751            .map(String::from)
752            .collect();
753        let args = ServerArgs::try_parse_from(rewrite_llama_style_argv(argv)).unwrap();
754        assert_eq!(args.port, Some(0));
755        assert_eq!(
756            cli_bind_addr(&args, Some("127.0.0.1:8383")).as_deref(),
757            Some("127.0.0.1:0")
758        );
759    }
760
761    #[test]
762    fn parallel_flag_parses_and_rewrites_np() {
763        let argv = ["frink-server", "-np", "4"]
764            .into_iter()
765            .map(String::from)
766            .collect();
767        let args = ServerArgs::try_parse_from(rewrite_llama_style_argv(argv)).unwrap();
768        assert_eq!(args.parallel, Some(4));
769    }
770
771    /// `-b` and `-ub` are two tokens in llama.cpp's hand-written
772    /// parser and one token to clap, which sees `-b` as a short option
773    /// it has never heard of. The rewrite is what makes a copied
774    /// `llama-server ... -b 2048 -ub 512` command run here at all.
775    #[test]
776    fn batch_flags_parse_and_rewrite_their_llama_cpp_short_forms() {
777        let argv = ["frink-server", "-b", "2048", "-ub", "512"]
778            .into_iter()
779            .map(String::from)
780            .collect();
781        let args = ServerArgs::try_parse_from(rewrite_llama_style_argv(argv)).unwrap();
782        assert_eq!(args.batch_size, Some(2048));
783        assert_eq!(args.ubatch_size, Some(512));
784    }
785
786    /// Zero is not a batch size, and accepting it would make
787    /// `env_positive` panic the server later with a message naming an
788    /// environment variable the operator never set.
789    #[test]
790    fn a_zero_batch_size_is_refused_by_name_rather_than_lowered_to_the_environment() {
791        for flag in ["--batch-size", "--ubatch-size"] {
792            let args = ServerArgs::try_parse_from(
793                ["frink-server", flag, "0"].into_iter().map(String::from),
794            )
795            .unwrap();
796            let err = apply_cli_overrides(&args).unwrap_err().to_string();
797            assert!(err.contains(flag), "{flag}: {err}");
798        }
799    }
800
801    /// A path that is not a directory is refused at startup rather
802    /// than on the first save. The repo's rule about gates applies to
803    /// flags too: a `--slot-save-path` pointing at nothing looks
804    /// configured until somebody tries to use it, hours later.
805    #[test]
806    fn a_slot_save_path_that_is_not_a_directory_is_refused_at_startup() {
807        let args = ServerArgs::try_parse_from(
808            ["frink-server", "--slot-save-path", "/definitely/not/here"]
809                .into_iter()
810                .map(String::from),
811        )
812        .unwrap();
813        let err = apply_cli_overrides(&args).unwrap_err().to_string();
814        assert!(err.contains("--slot-save-path"), "{err}");
815        assert!(
816            std::env::var("FRINK_SLOT_SAVE_PATH").is_err(),
817            "a refused path must not have been lowered to the environment first"
818        );
819    }
820
821    /// llama.cpp's spelling and range (`common/arg.cpp:3608-3614`):
822    /// `-1`, `0` and `N` parse -- `-1` needs `allow_hyphen_values`, or
823    /// clap reads it as a flag -- and anything below `-1` is refused by
824    /// name before it is lowered to the environment.
825    #[test]
826    fn reasoning_budget_parses_llama_cpps_range_and_refuses_the_rest() {
827        for (value, expect) in [("-1", -1), ("0", 0), ("2000", 2000)] {
828            let args = ServerArgs::try_parse_from(
829                ["frink-server", "--reasoning-budget", value]
830                    .into_iter()
831                    .map(String::from),
832            )
833            .unwrap();
834            assert_eq!(args.reasoning_budget, Some(expect), "{value}");
835        }
836        let args = ServerArgs::try_parse_from(
837            ["frink-server", "--reasoning-budget", "-2"]
838                .into_iter()
839                .map(String::from),
840        )
841        .unwrap();
842        let err = apply_cli_overrides(&args).unwrap_err().to_string();
843        assert!(err.contains("--reasoning-budget"), "{err}");
844    }
845
846    /// Both spellings of llama.cpp's prefill switch parse, and they
847    /// conflict rather than letting the last one win silently.
848    #[test]
849    fn prefill_assistant_has_both_of_llama_cpps_spellings() {
850        let on = ServerArgs::try_parse_from(
851            ["frink-server", "--prefill-assistant"]
852                .into_iter()
853                .map(String::from),
854        )
855        .unwrap();
856        assert!(on.prefill_assistant && !on.no_prefill_assistant);
857        let off = ServerArgs::try_parse_from(
858            ["frink-server", "--no-prefill-assistant"]
859                .into_iter()
860                .map(String::from),
861        )
862        .unwrap();
863        assert!(off.no_prefill_assistant && !off.prefill_assistant);
864        assert!(ServerArgs::try_parse_from(
865            [
866                "frink-server",
867                "--prefill-assistant",
868                "--no-prefill-assistant"
869            ]
870            .into_iter()
871            .map(String::from),
872        )
873        .is_err());
874    }
875
876    #[test]
877    fn stdin_close_exit_is_opt_in() {
878        // Default off: a server whose stdin is /dev/null (systemd, cron,
879        // nohup) would otherwise exit the instant it started.
880        let args =
881            ServerArgs::try_parse_from(["frink-server"].into_iter().map(String::from)).unwrap();
882        assert!(!args.exit_on_stdin_close);
883        let args = ServerArgs::try_parse_from(
884            ["frink-server", "--exit-on-stdin-close"]
885                .into_iter()
886                .map(String::from),
887        )
888        .unwrap();
889        assert!(args.exit_on_stdin_close);
890    }
891}