1use std::fmt;
18use std::net::{IpAddr, Ipv4Addr, SocketAddr};
19use std::path::PathBuf;
20use std::str::FromStr;
21
22use clap::{Parser, ValueEnum};
23
24#[derive(Parser, Debug, PartialEq)]
28#[command(
35 name = "ferrox-server",
36 about = "OpenAI-compatible Ferrox inference server"
37)]
38pub struct ServerArgs {
39 #[arg(short = 'm', long = "model", value_name = "FILE")]
41 model: Option<String>,
42
43 #[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 #[arg(long = "hf-file", value_name = "FILE", requires = "hf_repo")]
63 hf_file: Option<String>,
64
65 #[arg(short = 'c', long = "ctx-size", value_name = "N")]
71 ctx_size: Option<usize>,
72
73 #[arg(long = "api-key", value_name = "KEY")]
76 api_key: Option<String>,
77
78 #[arg(long = "api-key-file", value_name = "PATH", conflicts_with = "api_key")]
83 api_key_file: Option<std::path::PathBuf>,
84
85 #[arg(long = "alias", visible_alias = "model-alias", value_name = "NAME")]
88 alias: Option<String>,
89
90 #[arg(long = "ctk", visible_alias = "cache-type-k", value_name = "TYPE")]
93 ctk: Option<String>,
94
95 #[arg(long = "jinja", default_value_t = false)]
101 jinja: bool,
102
103 #[arg(long = "no-jinja", default_value_t = false)]
106 no_jinja: bool,
107
108 #[arg(long = "no-warmup", default_value_t = false)]
110 no_warmup: bool,
111
112 #[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 #[arg(long, value_name = "HOST")]
120 host: Option<IpAddr>,
121
122 #[arg(long, value_name = "PORT")]
127 port: Option<u16>,
128
129 #[arg(short = 't', long = "threads", value_name = "N")]
131 threads: Option<usize>,
132
133 #[arg(
135 long = "device",
136 visible_alias = "dev",
137 value_name = "DEVICE",
138 ignore_case = true
139 )]
140 device: Option<OffloadDevice>,
141
142 #[arg(long = "list-devices", default_value_t = false)]
144 pub(crate) list_devices: bool,
145
146 #[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 #[arg(long = "mcp-config", value_name = "PATH")]
159 pub(crate) mcp_config: Option<PathBuf>,
160
161 #[arg(long = "exit-on-stdin-close", default_value_t = false)]
169 pub(crate) exit_on_stdin_close: bool,
170
171 #[arg(
174 long = "cont-batching",
175 visible_aliases = ["continuous-batching", "cb"],
176 default_value_t = false
177 )]
178 cont_batching: bool,
179
180 #[arg(
183 long = "no-cont-batching",
184 default_value_t = false,
185 conflicts_with = "cont_batching"
186 )]
187 no_cont_batching: bool,
188
189 #[arg(long = "parallel", visible_alias = "np", value_name = "N")]
193 parallel: Option<usize>,
194
195 #[arg(long = "batch-size", visible_alias = "b", value_name = "N")]
199 batch_size: Option<usize>,
200
201 #[arg(long = "ubatch-size", visible_alias = "ub", value_name = "N")]
204 ubatch_size: Option<usize>,
205
206 #[arg(long = "slot-save-path", value_name = "DIR")]
214 slot_save_path: Option<PathBuf>,
215
216 #[arg(long = "allow-multiple-instances", default_value_t = false)]
221 pub(crate) allow_multiple_instances: bool,
222}
223
224impl ServerArgs {
225 pub fn parse_llama_style<I>(argv: I) -> Self
232 where
233 I: IntoIterator<Item = String>,
234 {
235 Self::parse_from(rewrite_llama_style_argv(argv.into_iter().collect()))
236 }
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
240enum OffloadDevice {
241 Auto,
242 None,
243 Cpu,
244 Metal,
245 Cuda,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249enum GpuLayers {
250 Auto,
251 All,
252 Count(u32),
253}
254
255impl GpuLayers {
256 fn offload_enabled(self) -> bool {
257 !matches!(self, Self::Count(0))
258 }
259}
260
261impl FromStr for GpuLayers {
262 type Err = String;
263
264 fn from_str(value: &str) -> Result<Self, Self::Err> {
265 match value {
266 "auto" => Ok(Self::Auto),
267 "all" => Ok(Self::All),
268 _ => value
269 .parse::<u32>()
270 .map(Self::Count)
271 .map_err(|_| "expected 0, a positive integer, 'auto', or 'all'".into()),
272 }
273 }
274}
275
276impl fmt::Display for GpuLayers {
277 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
278 match self {
279 Self::Auto => f.write_str("auto"),
280 Self::All => f.write_str("all"),
281 Self::Count(value) => value.fmt(f),
282 }
283 }
284}
285
286pub const BUILT_WITH_METAL: bool = cfg!(feature = "metal");
295
296pub const BUILT_WITH_CUDA: bool = cfg!(feature = "cuda");
299
300fn rewrite_llama_style_argv(args: Vec<String>) -> Vec<String> {
301 args.into_iter()
302 .map(|arg| match arg.as_str() {
303 "-ngl" => "--n-gpu-layers".into(),
304 "-dev" => "--device".into(),
305 "-cb" => "--cont-batching".into(),
306 "-np" => "--parallel".into(),
307 "-b" => "--batch-size".into(),
308 "-ub" => "--ubatch-size".into(),
309 "-hf" => "--hf-repo".into(),
314 "-hff" => "--hf-file".into(),
315 _ => arg,
316 })
317 .collect()
318}
319
320pub(crate) fn print_available_devices() {
321 println!("Available devices:");
322 println!(" CPU");
323
324 let metal = ferrox_metal::MetalProfile::detect();
325 if let Some(name) = metal.device_name {
326 println!(" Metal: {name}");
327 }
328
329 let cuda = ferrox_cuda::HardwareProfile::detect();
330 if cuda.cuda_available {
331 let name = cuda.cuda_device_name.as_deref().unwrap_or("unknown device");
332 println!(" CUDA: {name}");
333 if cuda.cuda_device_count > 1 {
334 println!(" ({} devices detected)", cuda.cuda_device_count);
335 }
336 }
337}
338
339fn cli_bind_addr(args: &ServerArgs, env_addr: Option<&str>) -> Option<String> {
340 if args.host.is_none() && args.port.is_none() {
341 return None;
342 }
343
344 let existing = env_addr.and_then(|value| value.parse::<SocketAddr>().ok());
345 let host = args
346 .host
347 .or_else(|| existing.map(|addr| addr.ip()))
348 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
349 let port = args
350 .port
351 .or_else(|| existing.map(|addr| addr.port()))
352 .unwrap_or(8383);
353 Some(SocketAddr::new(host, port).to_string())
354}
355
356fn resolve_hf_repo(spec: &str, file: Option<&str>) -> anyhow::Result<String> {
362 let mut hf = ferrox_models::hub::HfRef::parse(spec);
363 if let Some(f) = file {
364 hf.file = Some(f.to_string());
365 }
366 eprintln!(
367 "ferrox: resolving {} on the Hub{}",
368 hf.repo,
369 hf.quant
370 .as_deref()
371 .map(|q| format!(" ({q})"))
372 .unwrap_or_default()
373 );
374
375 let mut last = std::time::Instant::now();
376 let mut draw = move |done: u64, total: Option<u64>| {
377 if last.elapsed() < std::time::Duration::from_millis(200) {
378 return;
379 }
380 last = std::time::Instant::now();
381 let mib = done as f64 / 1024.0 / 1024.0;
382 match total {
383 Some(t) if t > 0 => {
384 eprint!(
385 "\r {mib:>9.1} MiB {:5.1}%",
386 (done as f64 / t as f64) * 100.0
387 )
388 }
389 _ => eprint!("\r {mib:>9.1} MiB"),
390 }
391 };
392
393 let (path, downloaded) = hf
394 .ensure_local(&mut draw)
395 .map_err(|e| anyhow::anyhow!("{e}"))?;
396 if downloaded {
397 eprintln!();
398 eprintln!("ferrox: downloaded {}", path.display());
399 } else {
400 eprintln!("ferrox: using cached {}", path.display());
401 }
402 Ok(path.to_string_lossy().into_owned())
403}
404
405pub(crate) fn apply_cli_overrides(args: &ServerArgs) -> anyhow::Result<()> {
406 if let Some(model) = &args.model {
407 unsafe { std::env::set_var("FERROX_MODEL_PATH", model) };
409 }
410 if let Some(spec) = &args.hf_repo {
411 let path = resolve_hf_repo(spec, args.hf_file.as_deref())?;
412 unsafe { std::env::set_var("FERROX_MODEL_PATH", &path) };
414 }
415 if let Some(n) = args.ctx_size {
416 if n == 0 {
417 anyhow::bail!("--ctx-size must be greater than zero");
418 }
419 unsafe { std::env::set_var("FERROX_CB_MAX_CONTEXT", n.to_string()) };
421 }
422 if let Some(key) = &args.api_key {
423 unsafe { std::env::set_var("FERROX_API_KEY", key) };
425 }
426 if let Some(path) = &args.api_key_file {
427 let key = std::fs::read_to_string(path)
428 .map_err(|e| anyhow::anyhow!("reading --api-key-file {}: {e}", path.display()))?;
429 let key = key.trim();
430 if key.is_empty() {
431 anyhow::bail!(
432 "--api-key-file {} is empty: an empty key would leave every route open, \
433 which is the opposite of what passing the flag asked for",
434 path.display()
435 );
436 }
437 unsafe { std::env::set_var("FERROX_API_KEY", key) };
439 }
440 if let Some(alias) = &args.alias {
441 unsafe { std::env::set_var("FERROX_MODEL_NAME", alias) };
443 }
444 if let Some(ctk) = &args.ctk {
445 unsafe { std::env::set_var("FERROX_CTK", ctk.trim()) };
447 }
448 if args.no_jinja {
453 anyhow::bail!(
454 "--no-jinja: ferrox has no template-free mode. It compiles and evaluates the GGUF's \
455 own tokenizer.chat_template, which is what llama.cpp's --jinja turns on, and there \
456 is no sniffing fallback to switch to. Use --no-cnv on `ferrox run` for a raw \
457 completion"
458 );
459 }
460 if let Some(mode) = &args.flash_attn {
461 let mode = mode.trim().to_ascii_lowercase();
462 if mode == "off" || mode == "disabled" || mode == "0" {
463 anyhow::bail!(
464 "--flash-attn off: fused attention is a backend property here, not a per-run \
465 switch. Set FERROX_METAL_ATTN=0 to take the unfused Metal path, or --device cpu"
466 );
467 }
468 }
469
470 if let Some(addr) = cli_bind_addr(args, std::env::var("FERROX_ADDR").ok().as_deref()) {
471 unsafe { std::env::set_var("FERROX_ADDR", addr) };
473 }
474
475 if let Some(threads) = args.threads {
476 if threads == 0 {
477 anyhow::bail!("--threads must be greater than zero");
478 }
479 unsafe {
481 std::env::set_var("FERROX_CPU_THREADS", threads.to_string());
482 std::env::set_var("RAYON_NUM_THREADS", threads.to_string());
483 }
484 }
485
486 if args.device.is_none() && args.n_gpu_layers.is_none() {
487 } else {
489 let layers = args.n_gpu_layers.unwrap_or(GpuLayers::Auto);
490 let device = if layers.offload_enabled() {
491 args.device.unwrap_or(OffloadDevice::Auto)
492 } else {
493 OffloadDevice::None
494 };
495
496 match device {
497 OffloadDevice::None | OffloadDevice::Cpu => unsafe {
498 std::env::set_var("FERROX_METAL", "0");
499 std::env::set_var("FERROX_METAL_ATTN", "0");
500 std::env::set_var("FERROX_CUDA", "0");
501 },
502 OffloadDevice::Auto => unsafe {
503 std::env::set_var("FERROX_METAL", "auto");
504 std::env::set_var("FERROX_CUDA", "auto");
505 if std::env::var_os("FERROX_METAL_ATTN").is_none() {
506 std::env::set_var("FERROX_METAL_ATTN", "1");
507 }
508 },
509 OffloadDevice::Metal => {
510 #[cfg(not(feature = "metal"))]
511 {
512 anyhow::bail!(
513 "Metal requested but this binary was built without --features metal"
514 );
515 }
516 #[cfg(feature = "metal")]
517 {
518 if !ferrox_metal::MetalProfile::detect().available {
519 anyhow::bail!("Metal requested but no Metal device is available");
520 }
521 unsafe {
522 std::env::set_var("FERROX_METAL", "1");
523 if std::env::var_os("FERROX_METAL_ATTN").is_none() {
524 std::env::set_var("FERROX_METAL_ATTN", "1");
525 }
526 std::env::set_var("FERROX_CUDA", "0");
527 }
528 }
529 }
530 OffloadDevice::Cuda => {
531 #[cfg(not(feature = "cuda"))]
532 {
533 anyhow::bail!(
534 "CUDA requested but this binary was built without --features cuda"
535 );
536 }
537 #[cfg(feature = "cuda")]
538 {
539 if !ferrox_cuda::HardwareProfile::detect().cuda_available {
540 anyhow::bail!("CUDA requested but no CUDA device is available");
541 }
542 unsafe {
543 std::env::set_var("FERROX_CUDA", "1");
544 std::env::set_var("FERROX_METAL", "0");
545 std::env::set_var("FERROX_METAL_ATTN", "0");
546 }
547 }
548 }
549 }
550 }
551
552 if let Some(n) = args.parallel {
553 if n == 0 {
554 anyhow::bail!("--parallel must be greater than zero");
555 }
556 unsafe { std::env::set_var("FERROX_CB_MAX_SEQS", n.to_string()) };
558 }
559
560 if let Some(dir) = &args.slot_save_path {
561 if !dir.is_dir() {
562 anyhow::bail!(
563 "--slot-save-path {} is not a directory. Slots are written into it by name, so \
564 a path that does not exist would be discovered on the first save rather than \
565 at startup",
566 dir.display()
567 );
568 }
569 unsafe { std::env::set_var("FERROX_SLOT_SAVE_PATH", dir) };
571 }
572
573 for (flag, value) in [
574 ("--batch-size", args.batch_size),
575 ("--ubatch-size", args.ubatch_size),
576 ] {
577 if value == Some(0) {
578 anyhow::bail!("{flag} must be greater than zero");
579 }
580 }
581 if let Some(chunk) = crate::prefill_batch::effective_chunk(args.batch_size, args.ubatch_size) {
582 for key in crate::prefill_batch::PREFILL_CHUNK_ENV_KEYS {
587 unsafe { std::env::set_var(key, chunk.to_string()) };
589 }
590 }
591
592 if args.cont_batching {
593 unsafe { std::env::set_var("FERROX_CONTINUOUS_BATCHING", "1") };
595 } else if args.no_cont_batching {
596 unsafe { std::env::set_var("FERROX_CONTINUOUS_BATCHING", "0") };
598 } else if args.parallel.is_some() {
599 unsafe { std::env::set_var("FERROX_CONTINUOUS_BATCHING", "1") };
602 }
603
604 Ok(())
605}
606#[cfg(test)]
607mod tests {
608 use super::*;
609
610 #[test]
611 fn parses_llama_server_style_options() {
612 let argv = [
613 "ferrox-server",
614 "-m",
615 "model.gguf",
616 "--host",
617 "::1",
618 "--port",
619 "9000",
620 "-t",
621 "4",
622 "-dev",
623 "Metal",
624 "-ngl",
625 "all",
626 ]
627 .into_iter()
628 .map(String::from)
629 .collect();
630 let args = ServerArgs::try_parse_from(rewrite_llama_style_argv(argv)).unwrap();
631
632 assert_eq!(args.model.as_deref(), Some("model.gguf"));
633 assert_eq!(args.host, Some(IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)));
634 assert_eq!(args.port, Some(9000));
635 assert_eq!(args.threads, Some(4));
636 assert_eq!(args.device, Some(OffloadDevice::Metal));
637 assert_eq!(args.n_gpu_layers, Some(GpuLayers::All));
638 assert_eq!(
639 cli_bind_addr(&args, Some("127.0.0.1:8383")).as_deref(),
640 Some("[::1]:9000")
641 );
642 }
643
644 #[test]
645 fn port_zero_survives_argument_parsing_as_a_real_request() {
646 let argv = ["ferrox-server", "--port", "0"]
651 .into_iter()
652 .map(String::from)
653 .collect();
654 let args = ServerArgs::try_parse_from(rewrite_llama_style_argv(argv)).unwrap();
655 assert_eq!(args.port, Some(0));
656 assert_eq!(
657 cli_bind_addr(&args, Some("127.0.0.1:8383")).as_deref(),
658 Some("127.0.0.1:0")
659 );
660 }
661
662 #[test]
663 fn parallel_flag_parses_and_rewrites_np() {
664 let argv = ["ferrox-server", "-np", "4"]
665 .into_iter()
666 .map(String::from)
667 .collect();
668 let args = ServerArgs::try_parse_from(rewrite_llama_style_argv(argv)).unwrap();
669 assert_eq!(args.parallel, Some(4));
670 }
671
672 #[test]
677 fn batch_flags_parse_and_rewrite_their_llama_cpp_short_forms() {
678 let argv = ["ferrox-server", "-b", "2048", "-ub", "512"]
679 .into_iter()
680 .map(String::from)
681 .collect();
682 let args = ServerArgs::try_parse_from(rewrite_llama_style_argv(argv)).unwrap();
683 assert_eq!(args.batch_size, Some(2048));
684 assert_eq!(args.ubatch_size, Some(512));
685 }
686
687 #[test]
691 fn a_zero_batch_size_is_refused_by_name_rather_than_lowered_to_the_environment() {
692 for flag in ["--batch-size", "--ubatch-size"] {
693 let args = ServerArgs::try_parse_from(
694 ["ferrox-server", flag, "0"].into_iter().map(String::from),
695 )
696 .unwrap();
697 let err = apply_cli_overrides(&args).unwrap_err().to_string();
698 assert!(err.contains(flag), "{flag}: {err}");
699 }
700 }
701
702 #[test]
707 fn a_slot_save_path_that_is_not_a_directory_is_refused_at_startup() {
708 let args = ServerArgs::try_parse_from(
709 ["ferrox-server", "--slot-save-path", "/definitely/not/here"]
710 .into_iter()
711 .map(String::from),
712 )
713 .unwrap();
714 let err = apply_cli_overrides(&args).unwrap_err().to_string();
715 assert!(err.contains("--slot-save-path"), "{err}");
716 assert!(
717 std::env::var("FERROX_SLOT_SAVE_PATH").is_err(),
718 "a refused path must not have been lowered to the environment first"
719 );
720 }
721
722 #[test]
723 fn stdin_close_exit_is_opt_in() {
724 let args =
727 ServerArgs::try_parse_from(["ferrox-server"].into_iter().map(String::from)).unwrap();
728 assert!(!args.exit_on_stdin_close);
729 let args = ServerArgs::try_parse_from(
730 ["ferrox-server", "--exit-on-stdin-close"]
731 .into_iter()
732 .map(String::from),
733 )
734 .unwrap();
735 assert!(args.exit_on_stdin_close);
736 }
737}