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 = "frink-server",
36 about = "OpenAI-compatible Frink 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(
98 long = "ctk",
99 visible_alias = "cache-type-k",
100 value_name = "TYPE",
101 value_parser = frink_models::ctk::parse_value
102 )]
103 ctk: Option<String>,
104
105 #[arg(long = "jinja", default_value_t = false)]
111 jinja: bool,
112
113 #[arg(long = "no-jinja", default_value_t = false)]
116 no_jinja: bool,
117
118 #[arg(long = "no-warmup", default_value_t = false)]
120 no_warmup: bool,
121
122 #[arg(long = "flash-attn", visible_alias = "fa", value_name = "MODE", num_args = 0..=1, default_missing_value = "auto")]
126 flash_attn: Option<String>,
127
128 #[arg(long, value_name = "HOST")]
130 host: Option<IpAddr>,
131
132 #[arg(long, value_name = "PORT")]
137 port: Option<u16>,
138
139 #[arg(short = 't', long = "threads", value_name = "N")]
141 threads: Option<usize>,
142
143 #[arg(
145 long = "device",
146 visible_alias = "dev",
147 value_name = "DEVICE",
148 ignore_case = true
149 )]
150 device: Option<OffloadDevice>,
151
152 #[arg(long = "list-devices", default_value_t = false)]
154 pub(crate) list_devices: bool,
155
156 #[arg(
161 long = "n-gpu-layers",
162 visible_aliases = ["gpu-layers", "ngl"],
163 value_name = "N"
164 )]
165 n_gpu_layers: Option<GpuLayers>,
166
167 #[arg(long = "mcp-config", value_name = "PATH")]
169 pub(crate) mcp_config: Option<PathBuf>,
170
171 #[arg(long = "exit-on-stdin-close", default_value_t = false)]
179 pub(crate) exit_on_stdin_close: bool,
180
181 #[arg(
184 long = "cont-batching",
185 visible_aliases = ["continuous-batching", "cb"],
186 default_value_t = false
187 )]
188 cont_batching: bool,
189
190 #[arg(
193 long = "no-cont-batching",
194 default_value_t = false,
195 conflicts_with = "cont_batching"
196 )]
197 no_cont_batching: bool,
198
199 #[arg(long = "parallel", visible_alias = "np", value_name = "N")]
203 parallel: Option<usize>,
204
205 #[arg(long = "batch-size", visible_alias = "b", value_name = "N")]
209 batch_size: Option<usize>,
210
211 #[arg(long = "ubatch-size", visible_alias = "ub", value_name = "N")]
214 ubatch_size: Option<usize>,
215
216 #[arg(long = "slot-save-path", value_name = "DIR")]
224 slot_save_path: Option<PathBuf>,
225
226 #[arg(long = "lora", value_name = "FILE", action = clap::ArgAction::Append)]
232 lora: Vec<String>,
233
234 #[arg(long = "lora-scaled", value_name = "FILE:SCALE", action = clap::ArgAction::Append)]
240 lora_scaled: Vec<String>,
241
242 #[arg(long = "lora-init-without-apply", default_value_t = false)]
246 lora_init_without_apply: bool,
247
248 #[arg(long = "allow-multiple-instances", default_value_t = false)]
253 pub(crate) allow_multiple_instances: bool,
254
255 #[arg(
263 long = "reasoning-budget",
264 value_name = "N",
265 allow_hyphen_values = true
266 )]
267 reasoning_budget: Option<i64>,
268
269 #[arg(long = "prefill-assistant", default_value_t = false)]
273 prefill_assistant: bool,
274
275 #[arg(
280 long = "no-prefill-assistant",
281 default_value_t = false,
282 conflicts_with = "prefill_assistant"
283 )]
284 no_prefill_assistant: bool,
285}
286
287impl ServerArgs {
288 pub fn parse_llama_style<I>(argv: I) -> Self
295 where
296 I: IntoIterator<Item = String>,
297 {
298 Self::parse_from(rewrite_llama_style_argv(argv.into_iter().collect()))
299 }
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
303enum OffloadDevice {
304 Auto,
305 None,
306 Cpu,
307 Metal,
308 Cuda,
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312enum GpuLayers {
313 Auto,
314 All,
315 Count(u32),
316}
317
318impl GpuLayers {
319 fn offload_enabled(self) -> bool {
320 !matches!(self, Self::Count(0))
321 }
322}
323
324impl FromStr for GpuLayers {
325 type Err = String;
326
327 fn from_str(value: &str) -> Result<Self, Self::Err> {
328 match value {
329 "auto" => Ok(Self::Auto),
330 "all" => Ok(Self::All),
331 _ => value
332 .parse::<u32>()
333 .map(Self::Count)
334 .map_err(|_| "expected 0, a positive integer, 'auto', or 'all'".into()),
335 }
336 }
337}
338
339impl fmt::Display for GpuLayers {
340 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
341 match self {
342 Self::Auto => f.write_str("auto"),
343 Self::All => f.write_str("all"),
344 Self::Count(value) => value.fmt(f),
345 }
346 }
347}
348
349pub const BUILT_WITH_METAL: bool = cfg!(feature = "metal");
358
359pub const BUILT_WITH_CUDA: bool = cfg!(feature = "cuda");
362
363fn rewrite_llama_style_argv(args: Vec<String>) -> Vec<String> {
364 args.into_iter()
365 .map(|arg| match arg.as_str() {
366 "-ngl" => "--n-gpu-layers".into(),
367 "-dev" => "--device".into(),
368 "-cb" => "--cont-batching".into(),
369 "-np" => "--parallel".into(),
370 "-b" => "--batch-size".into(),
371 "-ub" => "--ubatch-size".into(),
372 "-hf" => "--hf-repo".into(),
377 "-hff" => "--hf-file".into(),
378 _ => arg,
379 })
380 .collect()
381}
382
383fn cli_bind_addr(args: &ServerArgs, env_addr: Option<&str>) -> Option<String> {
384 if args.host.is_none() && args.port.is_none() {
385 return None;
386 }
387
388 let existing = env_addr.and_then(|value| value.parse::<SocketAddr>().ok());
389 let host = args
390 .host
391 .or_else(|| existing.map(|addr| addr.ip()))
392 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
393 let port = args
394 .port
395 .or_else(|| existing.map(|addr| addr.port()))
396 .unwrap_or(8383);
397 Some(SocketAddr::new(host, port).to_string())
398}
399
400fn resolve_hf_repo(spec: &str, file: Option<&str>) -> anyhow::Result<String> {
406 let mut hf = frink_models::hub::HfRef::parse(spec);
407 if let Some(f) = file {
408 hf.file = Some(f.to_string());
409 }
410 eprintln!(
411 "frink: resolving {} on the Hub{}",
412 hf.repo,
413 hf.quant
414 .as_deref()
415 .map(|q| format!(" ({q})"))
416 .unwrap_or_default()
417 );
418
419 let mut last = std::time::Instant::now();
420 let mut draw = move |done: u64, total: Option<u64>| {
421 if last.elapsed() < std::time::Duration::from_millis(200) {
422 return;
423 }
424 last = std::time::Instant::now();
425 let mib = done as f64 / 1024.0 / 1024.0;
426 match total {
427 Some(t) if t > 0 => {
428 eprint!(
429 "\r {mib:>9.1} MiB {:5.1}%",
430 (done as f64 / t as f64) * 100.0
431 )
432 }
433 _ => eprint!("\r {mib:>9.1} MiB"),
434 }
435 };
436
437 let (path, downloaded) = hf
438 .ensure_local(&mut draw)
439 .map_err(|e| anyhow::anyhow!("{e}"))?;
440 if downloaded {
441 eprintln!();
442 eprintln!("frink: downloaded {}", path.display());
443 } else {
444 eprintln!("frink: using cached {}", path.display());
445 }
446 Ok(path.to_string_lossy().into_owned())
447}
448
449pub(crate) fn apply_cli_overrides(args: &ServerArgs) -> anyhow::Result<()> {
450 if let Some(model) = &args.model {
451 unsafe { std::env::set_var("FRINK_MODEL_PATH", model) };
453 }
454 if let Some(spec) = &args.hf_repo {
455 let path = resolve_hf_repo(spec, args.hf_file.as_deref())?;
456 unsafe { std::env::set_var("FRINK_MODEL_PATH", &path) };
458 }
459 if let Some(n) = args.ctx_size {
460 if n == 0 {
461 anyhow::bail!("--ctx-size must be greater than zero");
462 }
463 unsafe { std::env::set_var("FRINK_CB_MAX_CONTEXT", n.to_string()) };
465 }
466 if let Some(key) = &args.api_key {
467 unsafe { std::env::set_var("FRINK_API_KEY", key) };
469 }
470 if let Some(path) = &args.api_key_file {
471 let key = std::fs::read_to_string(path)
472 .map_err(|e| anyhow::anyhow!("reading --api-key-file {}: {e}", path.display()))?;
473 let key = key.trim();
474 if key.is_empty() {
475 anyhow::bail!(
476 "--api-key-file {} is empty: an empty key would leave every route open, \
477 which is the opposite of what passing the flag asked for",
478 path.display()
479 );
480 }
481 unsafe { std::env::set_var("FRINK_API_KEY", key) };
483 }
484 if let Some(alias) = &args.alias {
485 unsafe { std::env::set_var("FRINK_MODEL_NAME", alias) };
487 }
488 if let Some(ctk) = &args.ctk {
489 unsafe { std::env::set_var("FRINK_CTK", ctk.trim()) };
491 }
492 if args.no_jinja {
497 anyhow::bail!(
498 "--no-jinja: frink has no template-free mode. It compiles and evaluates the GGUF's \
499 own tokenizer.chat_template, which is what llama.cpp's --jinja turns on, and there \
500 is no sniffing fallback to switch to. Use --no-cnv on `frink run` for a raw \
501 completion"
502 );
503 }
504 if let Some(mode) = &args.flash_attn {
505 let mode = mode.trim().to_ascii_lowercase();
506 if mode == "off" || mode == "disabled" || mode == "0" {
507 anyhow::bail!(
508 "--flash-attn off: fused attention is a backend property here, not a per-run \
509 switch. Set FRINK_METAL_ATTN=0 to take the unfused Metal path, or --device cpu"
510 );
511 }
512 }
513
514 if let Some(addr) = cli_bind_addr(args, std::env::var("FRINK_ADDR").ok().as_deref()) {
515 unsafe { std::env::set_var("FRINK_ADDR", addr) };
517 }
518
519 if let Some(threads) = args.threads {
520 if threads == 0 {
521 anyhow::bail!("--threads must be greater than zero");
522 }
523 unsafe {
525 std::env::set_var("FRINK_CPU_THREADS", threads.to_string());
526 std::env::set_var("RAYON_NUM_THREADS", threads.to_string());
527 }
528 }
529
530 if args.device.is_none() && args.n_gpu_layers.is_none() {
531 } else {
533 let layers = args.n_gpu_layers.unwrap_or(GpuLayers::Auto);
534 let device = if layers.offload_enabled() {
535 args.device.unwrap_or(OffloadDevice::Auto)
536 } else {
537 OffloadDevice::None
538 };
539
540 match device {
541 OffloadDevice::None | OffloadDevice::Cpu => unsafe {
542 std::env::set_var("FRINK_METAL", "0");
543 std::env::set_var("FRINK_METAL_ATTN", "0");
544 std::env::set_var("FRINK_CUDA", "0");
545 },
546 OffloadDevice::Auto => unsafe {
547 std::env::set_var("FRINK_METAL", "auto");
548 std::env::set_var("FRINK_CUDA", "auto");
549 if std::env::var_os("FRINK_METAL_ATTN").is_none() {
550 std::env::set_var("FRINK_METAL_ATTN", "1");
551 }
552 },
553 OffloadDevice::Metal => {
554 #[cfg(not(feature = "metal"))]
555 {
556 anyhow::bail!(
557 "Metal requested but this binary was built without --features metal"
558 );
559 }
560 #[cfg(feature = "metal")]
561 {
562 if !frink_metal::MetalProfile::detect().available {
563 anyhow::bail!("Metal requested but no Metal device is available");
564 }
565 unsafe {
566 std::env::set_var("FRINK_METAL", "1");
567 if std::env::var_os("FRINK_METAL_ATTN").is_none() {
568 std::env::set_var("FRINK_METAL_ATTN", "1");
569 }
570 std::env::set_var("FRINK_CUDA", "0");
571 }
572 }
573 }
574 OffloadDevice::Cuda => {
575 #[cfg(not(feature = "cuda"))]
576 {
577 anyhow::bail!(
578 "CUDA requested but this binary was built without --features cuda"
579 );
580 }
581 #[cfg(feature = "cuda")]
582 {
583 if !frink_cuda::HardwareProfile::detect().cuda_available {
584 anyhow::bail!("CUDA requested but no CUDA device is available");
585 }
586 unsafe {
587 std::env::set_var("FRINK_CUDA", "1");
588 std::env::set_var("FRINK_METAL", "0");
589 std::env::set_var("FRINK_METAL_ATTN", "0");
590 }
591 }
592 }
593 }
594 }
595
596 if let Some(n) = args.parallel {
597 if n == 0 {
598 anyhow::bail!("--parallel must be greater than zero");
599 }
600 unsafe { std::env::set_var("FRINK_CB_MAX_SEQS", n.to_string()) };
602 }
603
604 let lora_specs = frink_models::lora_attach::LoraSpec::from_flags(&args.lora, &args.lora_scaled)
605 .map_err(|e| anyhow::anyhow!("{e}"))?;
606 if !lora_specs.is_empty() {
607 for spec in &lora_specs {
608 if !spec.path.is_file() {
609 anyhow::bail!(
610 "--lora {}: no such file. An adapter that cannot be opened would be \
611 discovered at model load rather than at startup",
612 spec.path.display()
613 );
614 }
615 }
616 let value = lora_specs
617 .iter()
618 .map(|s| format!("{}:{}", s.path.display(), s.scale))
619 .collect::<Vec<_>>()
620 .join(",");
621 unsafe { std::env::set_var(crate::lora::ENV_SPECS, value) };
623 }
624 if args.lora_init_without_apply {
625 unsafe { std::env::set_var(crate::lora::ENV_INIT_WITHOUT_APPLY, "1") };
627 }
628
629 if let Some(dir) = &args.slot_save_path {
630 if !dir.is_dir() {
631 anyhow::bail!(
632 "--slot-save-path {} is not a directory. Slots are written into it by name, so \
633 a path that does not exist would be discovered on the first save rather than \
634 at startup",
635 dir.display()
636 );
637 }
638 unsafe { std::env::set_var("FRINK_SLOT_SAVE_PATH", dir) };
640 }
641
642 for (flag, value) in [
643 ("--batch-size", args.batch_size),
644 ("--ubatch-size", args.ubatch_size),
645 ] {
646 if value == Some(0) {
647 anyhow::bail!("{flag} must be greater than zero");
648 }
649 }
650 if let Some(chunk) = crate::prefill_batch::effective_chunk(args.batch_size, args.ubatch_size) {
651 for key in crate::prefill_batch::PREFILL_CHUNK_ENV_KEYS {
656 unsafe { std::env::set_var(key, chunk.to_string()) };
658 }
659 }
660
661 if let Some(budget) = args.reasoning_budget {
662 crate::reasoning_budget::BudgetTokens::parse(budget)
665 .map_err(|why| anyhow::anyhow!("--reasoning-budget: {why}"))?;
666 unsafe {
668 std::env::set_var(
669 crate::reasoning_budget::SERVER_DEFAULT_ENV,
670 budget.to_string(),
671 )
672 };
673 }
674 if args.prefill_assistant {
675 unsafe { std::env::set_var(crate::continuation::PREFILL_ASSISTANT_ENV, "1") };
677 } else if args.no_prefill_assistant {
678 unsafe { std::env::set_var(crate::continuation::PREFILL_ASSISTANT_ENV, "0") };
680 }
681
682 if args.cont_batching {
683 unsafe { std::env::set_var("FRINK_CONTINUOUS_BATCHING", "1") };
685 } else if args.no_cont_batching {
686 unsafe { std::env::set_var("FRINK_CONTINUOUS_BATCHING", "0") };
688 } else if args.parallel.is_some() {
689 unsafe { std::env::set_var("FRINK_CONTINUOUS_BATCHING", "1") };
692 }
693
694 Ok(())
695}
696#[cfg(test)]
697mod tests {
698 use super::*;
699
700 #[test]
706 fn the_server_accepts_exactly_the_cache_types_the_cli_does() {
707 for good in ["f16", "q8_0", "q4_0", "fp8", "q5_1"] {
708 let a = ServerArgs::try_parse_from(["frink-server", "--ctk", good]);
709 assert!(a.is_ok(), "{good} was refused");
710 }
711 for bad in ["nonsense", "turbo4", "q3_k"] {
712 let e = ServerArgs::try_parse_from(["frink-server", "--ctk", bad])
713 .expect_err(&format!("{bad} was accepted"));
714 let msg = e.to_string();
715 assert!(msg.contains("unsupported cache type"), "{msg}");
716 }
717 }
718
719 #[test]
720 fn parses_llama_server_style_options() {
721 let argv = [
722 "frink-server",
723 "-m",
724 "model.gguf",
725 "--host",
726 "::1",
727 "--port",
728 "9000",
729 "-t",
730 "4",
731 "-dev",
732 "Metal",
733 "-ngl",
734 "all",
735 ]
736 .into_iter()
737 .map(String::from)
738 .collect();
739 let args = ServerArgs::try_parse_from(rewrite_llama_style_argv(argv)).unwrap();
740
741 assert_eq!(args.model.as_deref(), Some("model.gguf"));
742 assert_eq!(args.host, Some(IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)));
743 assert_eq!(args.port, Some(9000));
744 assert_eq!(args.threads, Some(4));
745 assert_eq!(args.device, Some(OffloadDevice::Metal));
746 assert_eq!(args.n_gpu_layers, Some(GpuLayers::All));
747 assert_eq!(
748 cli_bind_addr(&args, Some("127.0.0.1:8383")).as_deref(),
749 Some("[::1]:9000")
750 );
751 }
752
753 #[test]
754 fn port_zero_survives_argument_parsing_as_a_real_request() {
755 let argv = ["frink-server", "--port", "0"]
760 .into_iter()
761 .map(String::from)
762 .collect();
763 let args = ServerArgs::try_parse_from(rewrite_llama_style_argv(argv)).unwrap();
764 assert_eq!(args.port, Some(0));
765 assert_eq!(
766 cli_bind_addr(&args, Some("127.0.0.1:8383")).as_deref(),
767 Some("127.0.0.1:0")
768 );
769 }
770
771 #[test]
772 fn parallel_flag_parses_and_rewrites_np() {
773 let argv = ["frink-server", "-np", "4"]
774 .into_iter()
775 .map(String::from)
776 .collect();
777 let args = ServerArgs::try_parse_from(rewrite_llama_style_argv(argv)).unwrap();
778 assert_eq!(args.parallel, Some(4));
779 }
780
781 #[test]
786 fn batch_flags_parse_and_rewrite_their_llama_cpp_short_forms() {
787 let argv = ["frink-server", "-b", "2048", "-ub", "512"]
788 .into_iter()
789 .map(String::from)
790 .collect();
791 let args = ServerArgs::try_parse_from(rewrite_llama_style_argv(argv)).unwrap();
792 assert_eq!(args.batch_size, Some(2048));
793 assert_eq!(args.ubatch_size, Some(512));
794 }
795
796 #[test]
800 fn a_zero_batch_size_is_refused_by_name_rather_than_lowered_to_the_environment() {
801 for flag in ["--batch-size", "--ubatch-size"] {
802 let args = ServerArgs::try_parse_from(
803 ["frink-server", flag, "0"].into_iter().map(String::from),
804 )
805 .unwrap();
806 let err = apply_cli_overrides(&args).unwrap_err().to_string();
807 assert!(err.contains(flag), "{flag}: {err}");
808 }
809 }
810
811 #[test]
816 fn a_slot_save_path_that_is_not_a_directory_is_refused_at_startup() {
817 let args = ServerArgs::try_parse_from(
818 ["frink-server", "--slot-save-path", "/definitely/not/here"]
819 .into_iter()
820 .map(String::from),
821 )
822 .unwrap();
823 let err = apply_cli_overrides(&args).unwrap_err().to_string();
824 assert!(err.contains("--slot-save-path"), "{err}");
825 assert!(
826 std::env::var("FRINK_SLOT_SAVE_PATH").is_err(),
827 "a refused path must not have been lowered to the environment first"
828 );
829 }
830
831 #[test]
836 fn reasoning_budget_parses_llama_cpps_range_and_refuses_the_rest() {
837 for (value, expect) in [("-1", -1), ("0", 0), ("2000", 2000)] {
838 let args = ServerArgs::try_parse_from(
839 ["frink-server", "--reasoning-budget", value]
840 .into_iter()
841 .map(String::from),
842 )
843 .unwrap();
844 assert_eq!(args.reasoning_budget, Some(expect), "{value}");
845 }
846 let args = ServerArgs::try_parse_from(
847 ["frink-server", "--reasoning-budget", "-2"]
848 .into_iter()
849 .map(String::from),
850 )
851 .unwrap();
852 let err = apply_cli_overrides(&args).unwrap_err().to_string();
853 assert!(err.contains("--reasoning-budget"), "{err}");
854 }
855
856 #[test]
859 fn prefill_assistant_has_both_of_llama_cpps_spellings() {
860 let on = ServerArgs::try_parse_from(
861 ["frink-server", "--prefill-assistant"]
862 .into_iter()
863 .map(String::from),
864 )
865 .unwrap();
866 assert!(on.prefill_assistant && !on.no_prefill_assistant);
867 let off = ServerArgs::try_parse_from(
868 ["frink-server", "--no-prefill-assistant"]
869 .into_iter()
870 .map(String::from),
871 )
872 .unwrap();
873 assert!(off.no_prefill_assistant && !off.prefill_assistant);
874 assert!(ServerArgs::try_parse_from(
875 [
876 "frink-server",
877 "--prefill-assistant",
878 "--no-prefill-assistant"
879 ]
880 .into_iter()
881 .map(String::from),
882 )
883 .is_err());
884 }
885
886 #[test]
887 fn stdin_close_exit_is_opt_in() {
888 let args =
891 ServerArgs::try_parse_from(["frink-server"].into_iter().map(String::from)).unwrap();
892 assert!(!args.exit_on_stdin_close);
893 let args = ServerArgs::try_parse_from(
894 ["frink-server", "--exit-on-stdin-close"]
895 .into_iter()
896 .map(String::from),
897 )
898 .unwrap();
899 assert!(args.exit_on_stdin_close);
900 }
901}