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(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 = "lora", value_name = "FILE", action = clap::ArgAction::Append)]
222 lora: Vec<String>,
223
224 #[arg(long = "lora-scaled", value_name = "FILE:SCALE", action = clap::ArgAction::Append)]
230 lora_scaled: Vec<String>,
231
232 #[arg(long = "lora-init-without-apply", default_value_t = false)]
236 lora_init_without_apply: bool,
237
238 #[arg(long = "allow-multiple-instances", default_value_t = false)]
243 pub(crate) allow_multiple_instances: bool,
244
245 #[arg(
253 long = "reasoning-budget",
254 value_name = "N",
255 allow_hyphen_values = true
256 )]
257 reasoning_budget: Option<i64>,
258
259 #[arg(long = "prefill-assistant", default_value_t = false)]
263 prefill_assistant: bool,
264
265 #[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 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
339pub const BUILT_WITH_METAL: bool = cfg!(feature = "metal");
348
349pub 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 "-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
409fn 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 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 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 unsafe { std::env::set_var("FRINK_CB_MAX_CONTEXT", n.to_string()) };
474 }
475 if let Some(key) = &args.api_key {
476 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 unsafe { std::env::set_var("FRINK_API_KEY", key) };
492 }
493 if let Some(alias) = &args.alias {
494 unsafe { std::env::set_var("FRINK_MODEL_NAME", alias) };
496 }
497 if let Some(ctk) = &args.ctk {
498 unsafe { std::env::set_var("FRINK_CTK", ctk.trim()) };
500 }
501 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 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 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 } 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 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 unsafe { std::env::set_var(crate::lora::ENV_SPECS, value) };
632 }
633 if args.lora_init_without_apply {
634 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 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 for key in crate::prefill_batch::PREFILL_CHUNK_ENV_KEYS {
665 unsafe { std::env::set_var(key, chunk.to_string()) };
667 }
668 }
669
670 if let Some(budget) = args.reasoning_budget {
671 crate::reasoning_budget::BudgetTokens::parse(budget)
674 .map_err(|why| anyhow::anyhow!("--reasoning-budget: {why}"))?;
675 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 unsafe { std::env::set_var(crate::continuation::PREFILL_ASSISTANT_ENV, "1") };
686 } else if args.no_prefill_assistant {
687 unsafe { std::env::set_var(crate::continuation::PREFILL_ASSISTANT_ENV, "0") };
689 }
690
691 if args.cont_batching {
692 unsafe { std::env::set_var("FRINK_CONTINUOUS_BATCHING", "1") };
694 } else if args.no_cont_batching {
695 unsafe { std::env::set_var("FRINK_CONTINUOUS_BATCHING", "0") };
697 } else if args.parallel.is_some() {
698 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 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 #[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 #[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 #[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 #[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 #[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 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}