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 #[arg(
231 long = "reasoning-budget",
232 value_name = "N",
233 allow_hyphen_values = true
234 )]
235 reasoning_budget: Option<i64>,
236
237 #[arg(long = "prefill-assistant", default_value_t = false)]
241 prefill_assistant: bool,
242
243 #[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 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
317pub const BUILT_WITH_METAL: bool = cfg!(feature = "metal");
326
327pub 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 "-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
387fn 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 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 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 unsafe { std::env::set_var("FERROX_CB_MAX_CONTEXT", n.to_string()) };
452 }
453 if let Some(key) = &args.api_key {
454 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 unsafe { std::env::set_var("FERROX_API_KEY", key) };
470 }
471 if let Some(alias) = &args.alias {
472 unsafe { std::env::set_var("FERROX_MODEL_NAME", alias) };
474 }
475 if let Some(ctk) = &args.ctk {
476 unsafe { std::env::set_var("FERROX_CTK", ctk.trim()) };
478 }
479 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 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 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 } 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 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 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 for key in crate::prefill_batch::PREFILL_CHUNK_ENV_KEYS {
618 unsafe { std::env::set_var(key, chunk.to_string()) };
620 }
621 }
622
623 if let Some(budget) = args.reasoning_budget {
624 crate::reasoning_budget::BudgetTokens::parse(budget)
627 .map_err(|why| anyhow::anyhow!("--reasoning-budget: {why}"))?;
628 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 unsafe { std::env::set_var(crate::continuation::PREFILL_ASSISTANT_ENV, "1") };
639 } else if args.no_prefill_assistant {
640 unsafe { std::env::set_var(crate::continuation::PREFILL_ASSISTANT_ENV, "0") };
642 }
643
644 if args.cont_batching {
645 unsafe { std::env::set_var("FERROX_CONTINUOUS_BATCHING", "1") };
647 } else if args.no_cont_batching {
648 unsafe { std::env::set_var("FERROX_CONTINUOUS_BATCHING", "0") };
650 } else if args.parallel.is_some() {
651 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 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 #[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 #[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 #[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 #[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 #[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 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}