1use std::fmt::Display;
2use std::net::IpAddr;
3use std::process::Stdio;
4use std::str::FromStr;
5use std::sync::LazyLock;
6use tokio::io::{AsyncBufReadExt, BufReader};
7use tokio::process::Command;
8use tokio::sync::mpsc;
9use tracing::{info, warn};
10
11use crate::config::Config;
12use crate::models::{
13 DiscoveredModel, ModelSettings, RopeScaling, ServerMetrics, clean_host, strip_gguf,
14};
15
16static HEALTH_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
18 reqwest::Client::builder()
19 .timeout(std::time::Duration::from_secs(1))
20 .build()
21 .unwrap()
22});
23
24static HTTP_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(reqwest::Client::new);
25
26struct VramCache {
28 nvidia: Option<(u64, u64)>,
29 amd: Option<(u64, u64)>,
30 stale: bool,
31}
32
33static VRAM_CACHE: LazyLock<std::sync::Mutex<VramCache>> = LazyLock::new(|| {
34 std::sync::Mutex::new(VramCache {
35 nvidia: None,
36 amd: None,
37 stale: true,
38 })
39});
40
41pub fn invalidate_vram_cache() {
43 let mut cache = VRAM_CACHE.lock().unwrap();
44 cache.nvidia = None;
45 cache.amd = None;
46 cache.stale = true;
47}
48
49#[derive(Clone)]
51pub struct ServerHandle {
52 pub port: u16,
53 pub host: String,
54 pub pid: u32,
55 pub kill_tx: mpsc::Sender<()>,
56}
57
58fn push_arg(cmd: &mut Command, parts: &mut Vec<String>, name: &str, value: impl Display) {
60 let val_str = value.to_string();
61 cmd.arg(name).arg(&val_str);
62 parts.push(name.to_string());
63 if val_str.contains(' ') || val_str.contains(';') || val_str.contains('"') {
65 parts.push(format!(
66 "\"{}\"",
67 val_str.replace('\\', "\\\\").replace('"', "\\\"")
68 ));
69 } else {
70 parts.push(val_str);
71 }
72}
73
74fn push_flag(cmd: &mut Command, parts: &mut Vec<String>, name: &str) {
76 cmd.arg(name);
77 parts.push(name.to_string());
78}
79
80fn push_gpu_layers(cmd: &mut Command, parts: &mut Vec<String>, settings: &ModelSettings) {
81 match settings.gpu_layers_mode {
82 crate::models::GpuLayersMode::Specific(n) => push_arg(cmd, parts, "-ngl", n),
83 crate::models::GpuLayersMode::All => push_arg(cmd, parts, "-ngl", "999"),
84 crate::models::GpuLayersMode::Auto => {}
85 }
86}
87
88fn push_spec_decoding(cmd: &mut Command, parts: &mut Vec<String>, settings: &ModelSettings) {
89 if !settings.spec_type.is_empty() {
90 push_arg(cmd, parts, "--spec-type", &settings.spec_type);
91 if settings.draft_tokens > 0 {
92 push_arg(cmd, parts, "--spec-draft-n-max", settings.draft_tokens);
93 }
94 }
95}
96
97pub fn build_server_cmd(
100 binary: &std::path::Path,
101 model: Option<&DiscoveredModel>,
102 settings: &ModelSettings,
103 config: &Config,
104 server_mode: crate::models::ServerMode,
105 router_max_models: u32,
106 model_config_used: bool,
107) -> (Command, String) {
108 let mut cmd = Command::new(binary);
109 let mut parts: Vec<String> = vec![binary.display().to_string()];
110
111 match server_mode {
113 crate::models::ServerMode::Normal => {
114 if let Some(model) = model {
115 push_arg(&mut cmd, &mut parts, "-m", model.path.display());
116 push_arg(&mut cmd, &mut parts, "--alias", &model.display_name);
118 }
119 }
120 crate::models::ServerMode::Router => {
121 if router_max_models > 0 {
123 push_arg(&mut cmd, &mut parts, "--models-max", router_max_models);
124 }
125 if let Some(dir) = config.models_dirs.first() {
127 push_arg(&mut cmd, &mut parts, "--models-dir", dir.display());
128 }
129 }
130 crate::models::ServerMode::Bench => {
131 }
133 crate::models::ServerMode::BenchTune => {
134 }
136 }
137
138 let gguf_meta = model
140 .map(|m| crate::models::GgufMetadata::from_path(&m.path))
141 .transpose();
142
143 push_arg(&mut cmd, &mut parts, "--threads", settings.threads);
145 push_arg(
146 &mut cmd,
147 &mut parts,
148 "--threads-batch",
149 settings.threads_batch,
150 );
151 let effective_ctx = (settings.context_length as f64 * settings.rope_scale as f64) as u32;
152 if !model_config_used {
153 push_arg(&mut cmd, &mut parts, "--ctx-size", effective_ctx);
154 }
155 push_arg(&mut cmd, &mut parts, "--ubatch-size", settings.ubatch_size);
156 if let Some(n) = settings.max_concurrent_predictions {
157 push_arg(&mut cmd, &mut parts, "--parallel", n);
158 }
159
160 push_flag(&mut cmd, &mut parts, "--no-warmup");
161
162 push_spec_decoding(&mut cmd, &mut parts, settings);
163
164 if let Some(cache_k) = settings.cache_type_k {
165 push_arg(&mut cmd, &mut parts, "--cache-type-k", cache_k);
166 }
167 if let Some(cache_v) = settings.cache_type_v {
168 push_arg(&mut cmd, &mut parts, "--cache-type-v", cache_v);
169 }
170
171 if settings.keep != 0 {
172 push_arg(&mut cmd, &mut parts, "--keep", settings.keep);
173 }
174 if settings.swa_full {
175 push_flag(&mut cmd, &mut parts, "--swa-full");
176 }
177 if settings.mlock {
178 push_flag(&mut cmd, &mut parts, "--mlock");
179 }
180 if !settings.mmap {
181 push_flag(&mut cmd, &mut parts, "--no-mmap");
182 }
183 if settings.numa != Default::default() {
184 push_arg(&mut cmd, &mut parts, "--numa", settings.numa.to_string());
185 }
186 if settings.kv_cache_offload {
187 push_flag(&mut cmd, &mut parts, "--kv-offload");
188 }
189
190 push_gpu_layers(&mut cmd, &mut parts, settings);
192
193 if settings.split_mode != Default::default() {
194 push_arg(
195 &mut cmd,
196 &mut parts,
197 "--split-mode",
198 settings.split_mode.to_string(),
199 );
200 }
201 if !settings.tensor_split.is_empty() {
202 push_arg(
203 &mut cmd,
204 &mut parts,
205 "--tensor-split",
206 &settings.tensor_split,
207 );
208 }
209 if settings.main_gpu != 0 {
210 push_arg(&mut cmd, &mut parts, "--main-gpu", settings.main_gpu);
211 }
212 if settings.fit {
213 push_arg(&mut cmd, &mut parts, "--fit", "on");
214 }
215
216 if let Some(ref lora) = settings.lora {
217 push_arg(&mut cmd, &mut parts, "--lora", lora.display());
218 }
219 if let Some((ref lora, scale)) = settings.lora_scaled {
220 push_arg(
221 &mut cmd,
222 &mut parts,
223 "--lora-scaled",
224 format!("{}:{}", lora.display(), scale),
225 );
226 }
227
228 let mut rpc_list = Vec::new();
229 if !settings.rpc.is_empty() {
230 rpc_list.push(settings.rpc.clone());
231 }
232 for worker in &config.rpc_workers {
233 if worker.selected {
234 if IpAddr::from_str(&worker.ip).is_ok() {
235 rpc_list.push(format!("{}:{}", worker.ip, worker.port));
236 }
237 }
238 }
239
240 if !rpc_list.is_empty() {
241 let joined_rpc = rpc_list.join(",");
242 push_arg(&mut cmd, &mut parts, "--rpc", joined_rpc);
243 }
244
245 if settings.embedding {
246 push_flag(&mut cmd, &mut parts, "--embedding");
247 }
248
249 if settings.expert_count > 0 {
250 let arch = gguf_meta
251 .as_ref()
252 .ok()
253 .and_then(|opt| opt.as_ref())
254 .map(|m| m.arch.as_str())
255 .unwrap_or("llama");
256 let arch = arch
257 .chars()
258 .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
259 .collect::<String>();
260 push_arg(
261 &mut cmd,
262 &mut parts,
263 "--override-kv",
264 format!(
265 "{}.expert_used_count=int:int:{}",
266 arch, settings.expert_count
267 ),
268 );
269 }
270
271 push_arg(
272 &mut cmd,
273 &mut parts,
274 "-fa",
275 if settings.flash_attn { "on" } else { "off" },
276 );
277
278 if settings.jinja {
279 push_flag(&mut cmd, &mut parts, "--jinja");
280 }
281
282 if let Some(ref t) = settings.chat_template {
284 if t.ends_with(".jinja") {
285 push_arg(&mut cmd, &mut parts, "--chat-template-file", t);
286 } else {
287 push_arg(&mut cmd, &mut parts, "--chat-template", t);
288 }
289 } else if settings.auto_chat_template {
290 let arch = gguf_meta
291 .as_ref()
292 .ok()
293 .and_then(|opt| opt.as_ref())
294 .map(|m| m.arch.as_str())
295 .unwrap_or("llama");
296 if let Some(template) = crate::models::arch_to_chat_template(arch) {
297 push_arg(&mut cmd, &mut parts, "--chat-template", template);
298 }
299 }
300
301 if !settings.system_prompt.is_empty() {
303 let mut merged = serde_json::Map::new();
304 if let Some(ref kwargs) = settings.chat_template_kwargs
305 && let Ok(obj) = serde_json::from_str::<serde_json::Value>(kwargs)
306 && let serde_json::Value::Object(map) = obj
307 {
308 for (k, v) in map {
309 merged.insert(k, v);
310 }
311 }
312 if merged.contains_key("system_prompt") {
314 tracing::warn!(
315 "chat_template_kwargs already contains 'system_prompt', will be overridden by settings"
316 );
317 }
318 merged.insert(
319 "system_prompt".to_string(),
320 serde_json::Value::String(settings.system_prompt.clone()),
321 );
322 push_arg(
323 &mut cmd,
324 &mut parts,
325 "--chat-template-kwargs",
326 serde_json::to_string(&merged).unwrap(),
327 );
328 } else if let Some(ref kwargs) = settings.chat_template_kwargs {
329 push_arg(&mut cmd, &mut parts, "--chat-template-kwargs", kwargs);
330 }
331
332 if settings.seed != -1 {
334 push_arg(&mut cmd, &mut parts, "--seed", settings.seed);
335 }
336 if let Some(max_tokens) = settings.max_tokens {
337 push_arg(&mut cmd, &mut parts, "--n-predict", max_tokens);
338 }
339 push_arg(
340 &mut cmd,
341 &mut parts,
342 "--temp",
343 format!("{:.2}", settings.temperature),
344 );
345
346 push_arg(&mut cmd, &mut parts, "--top-k", settings.top_k);
347
348 push_arg(
349 &mut cmd,
350 &mut parts,
351 "--top-p",
352 format!("{:.2}", settings.top_p),
353 );
354
355 push_arg(
356 &mut cmd,
357 &mut parts,
358 "--min-p",
359 format!("{:.2}", settings.min_p),
360 );
361
362 push_arg(
363 &mut cmd,
364 &mut parts,
365 "--typical",
366 format!("{:.2}", settings.typical_p),
367 );
368
369 if settings.mirostat != Default::default() {
370 push_arg(
371 &mut cmd,
372 &mut parts,
373 "--mirostat",
374 settings.mirostat.to_string(),
375 );
376 push_arg(
377 &mut cmd,
378 &mut parts,
379 "--mirostat-lr",
380 format!("{:.2}", settings.mirostat_lr),
381 );
382 push_arg(
383 &mut cmd,
384 &mut parts,
385 "--mirostat-ent",
386 format!("{:.2}", settings.mirostat_ent),
387 );
388 }
389
390 if settings.ignore_eos {
391 push_flag(&mut cmd, &mut parts, "--ignore-eos");
392 }
393
394 if !settings.samplers.0.is_empty() {
395 push_arg(
396 &mut cmd,
397 &mut parts,
398 "--samplers",
399 settings.samplers.to_string(),
400 );
401 }
402
403 if let Some(frequency) = settings.frequency_penalty {
404 push_arg(
405 &mut cmd,
406 &mut parts,
407 "--frequency-penalty",
408 format!("{:.2}", frequency),
409 );
410 }
411
412 if settings.dry_multiplier != 0.0 {
413 push_arg(
414 &mut cmd,
415 &mut parts,
416 "--dry-multiplier",
417 format!("{:.2}", settings.dry_multiplier),
418 );
419 push_arg(
420 &mut cmd,
421 &mut parts,
422 "--dry-base",
423 format!("{:.2}", settings.dry_base),
424 );
425 push_arg(
426 &mut cmd,
427 &mut parts,
428 "--dry-allowed-length",
429 settings.dry_allowed_length,
430 );
431 push_arg(
432 &mut cmd,
433 &mut parts,
434 "--dry-penalty-last-n",
435 settings.dry_penalty_last_n,
436 );
437 }
438
439 let rope_scaling = if settings.rope_yarn_enabled {
441 RopeScaling::Yarn
442 } else {
443 settings.rope_scaling
444 };
445 if rope_scaling != Default::default() {
446 push_arg(
447 &mut cmd,
448 &mut parts,
449 "--rope-scaling",
450 rope_scaling.to_string(),
451 );
452 }
453 if settings.rope_scale != 1.0 {
454 push_arg(
455 &mut cmd,
456 &mut parts,
457 "--rope-scale",
458 format!("{:.2}", settings.rope_scale),
459 );
460 }
461 if settings.rope_freq_base != 0.0 {
462 push_arg(
463 &mut cmd,
464 &mut parts,
465 "--rope-freq-base",
466 format!("{:.2}", settings.rope_freq_base),
467 );
468 }
469 if settings.rope_freq_scale != 1.0 {
470 push_arg(
471 &mut cmd,
472 &mut parts,
473 "--rope-freq-scale",
474 format!("{:.2}", settings.rope_freq_scale),
475 );
476 }
477
478 if settings.rope_yarn_enabled
479 && settings.rope_scale > 1.0
480 && let Some(meta) = gguf_meta.as_ref().ok().and_then(|x| x.as_ref())
481 {
482 push_arg(
483 &mut cmd,
484 &mut parts,
485 "--override-kv",
486 format!("{}.context_length=int:{}", meta.arch, effective_ctx),
487 );
488 let orig_ctx = meta.n_ctx_train;
489 push_arg(&mut cmd, &mut parts, "--yarn-orig-ctx", orig_ctx);
490 }
491
492 let resolved_host = clean_host(&settings.host);
493 push_arg(&mut cmd, &mut parts, "--host", resolved_host);
494 push_arg(&mut cmd, &mut parts, "--port", settings.port);
495 push_arg(&mut cmd, &mut parts, "--timeout", settings.timeout);
496
497 push_flag(&mut cmd, &mut parts, "--metrics");
498 if !settings.cache_prompt {
499 push_flag(&mut cmd, &mut parts, "--no-cache-prompt");
500 }
501 if settings.cache_reuse != 0 {
502 push_arg(&mut cmd, &mut parts, "--cache-reuse", settings.cache_reuse);
503 }
504 if !settings.webui {
505 push_flag(&mut cmd, &mut parts, "--no-webui");
506 }
507 let lv = match config.default.log_level.as_str() {
508 "error" => 0,
509 "warn" => 1,
510 "info" => 2,
511 "debug" => 3,
512 "trace" => 4,
513 _ => 1,
514 };
515 push_arg(&mut cmd, &mut parts, "-lv", lv);
516
517 let display = parts.join(" ");
520 (cmd, display)
521}
522
523pub fn build_bench_cmd(
525 binary: &std::path::Path,
526 model: &DiscoveredModel,
527 settings: &ModelSettings,
528) -> (Command, String) {
529 let mut cmd = Command::new(binary);
530 let mut parts: Vec<String> = vec![binary.display().to_string()];
531
532 push_arg(&mut cmd, &mut parts, "-m", model.path.display());
533 push_arg(&mut cmd, &mut parts, "-t", settings.threads);
534 push_arg(&mut cmd, &mut parts, "-b", settings.batch_size);
535
536 push_gpu_layers(&mut cmd, &mut parts, settings);
537
538 if settings.flash_attn {
539 push_arg(&mut cmd, &mut parts, "-fa", "1");
540 }
541
542 push_flag(&mut cmd, &mut parts, "--progress");
543
544 let display = parts.join(" ");
545 (cmd, display)
546}
547
548pub struct SpawnServerRequest<'a> {
551 pub config: &'a Config,
552 pub model: Option<&'a DiscoveredModel>,
553 pub settings: &'a ModelSettings,
554 pub log_tx: mpsc::Sender<String>,
555 pub progress_tx: Option<tokio::sync::broadcast::Sender<crate::models::DownloadState>>,
556 pub server_mode: crate::models::ServerMode,
557 pub router_max_models: u32,
558 pub exit_tx: mpsc::Sender<()>,
559}
560
561pub async fn spawn_server(req: SpawnServerRequest<'_>) -> Result<(ServerHandle, String), String> {
562 let SpawnServerRequest {
563 config,
564 model,
565 settings,
566 log_tx,
567 progress_tx,
568 server_mode,
569 router_max_models,
570 exit_tx,
571 } = req;
572 if server_mode != crate::models::ServerMode::Bench
573 && server_mode != crate::models::ServerMode::BenchTune
574 {
575 let port = settings.port;
576 let resolved_host = clean_host(&settings.host);
578 if std::net::TcpListener::bind(format!("{}:{}", resolved_host, port)).is_err() {
579 return Err(format!("Port {} is already in use", port));
580 }
581 }
582
583 if server_mode == crate::models::ServerMode::BenchTune {
586 return Err("BenchTune mode is not supported in spawn_server".to_string());
587 }
588
589 let backend_name = if server_mode == crate::models::ServerMode::Bench {
591 "llama-bench"
592 } else {
593 "llama-server"
594 };
595 let version_display = settings.get_active_backend_version_display();
596 info!(
597 "spawn_server: backend={}, requested_version={:?}, version_display={}",
598 settings.backend,
599 settings.get_active_backend_version(),
600 version_display
601 );
602 log_tx
603 .send(format!(
604 "Resolving {} (v{}) binary...",
605 backend_name, version_display
606 ))
607 .await
608 .ok();
609 let version_param = settings.get_active_backend_version().map(|s| s.as_str());
610
611 let server_binary = match crate::backend::hub::resolve_backend_binary(
612 settings.backend,
613 version_param,
614 Some(log_tx.clone()),
615 progress_tx,
616 )
617 .await
618 {
619 Ok(path) => {
620 info!("spawn_server: resolved binary path={}", path.display());
621 path
622 }
623 Err(e) => {
624 return Err(format!("Failed to resolve backend binary: {}", e));
625 }
626 };
627
628 let binary = if server_mode == crate::models::ServerMode::Bench {
629 server_binary.parent().unwrap().join("llama-bench")
630 } else {
631 server_binary
632 };
633
634 if !binary.exists() {
635 return Err(format!("Binary not found at: {}", binary.display()));
636 }
637 #[cfg(unix)]
638 {
639 use std::os::unix::fs::PermissionsExt;
640 if let Ok(metadata) = binary.metadata()
641 && metadata.permissions().mode() & 0o111 == 0
642 {
643 return Err(format!("Binary is not executable: {}", binary.display()));
644 }
645 }
646
647 let (mut cmd, cmd_string) = if server_mode == crate::models::ServerMode::Bench {
648 if let Some(m) = model {
649 build_bench_cmd(&binary, m, settings)
650 } else {
651 return Err("Model required for benchmark".to_string());
652 }
653 } else {
654 build_server_cmd(
655 &binary,
656 model,
657 settings,
658 config,
659 server_mode,
660 router_max_models,
661 false,
662 )
663 };
664
665 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
666
667 let bin_dir = binary.parent().unwrap();
669 match std::env::consts::OS {
670 "windows" => {
671 if let Ok(current) = std::env::var("PATH") {
673 cmd.env("PATH", format!("{};{}", bin_dir.display(), current));
674 } else {
675 cmd.env("PATH", bin_dir);
676 }
677 }
678 "macos" => {
679 if let Ok(current) = std::env::var("DYLD_LIBRARY_PATH") {
681 cmd.env(
682 "DYLD_LIBRARY_PATH",
683 format!("{}:{}", bin_dir.display(), current),
684 );
685 } else {
686 cmd.env("DYLD_LIBRARY_PATH", bin_dir);
687 }
688 }
689 _ => {
690 if let Ok(current) = std::env::var("LD_LIBRARY_PATH") {
692 cmd.env(
693 "LD_LIBRARY_PATH",
694 format!("{}:{}", bin_dir.display(), current),
695 );
696 } else {
697 cmd.env("LD_LIBRARY_PATH", bin_dir);
698 }
699 }
700 }
701
702 info!("Spawning: {}", cmd_string);
703 let _ = log_tx
704 .send(format!("{}: {}", backend_name, cmd_string))
705 .await;
706 let mut child = cmd
707 .spawn()
708 .map_err(|e| format!("Failed to spawn process: {}", e))?;
709 let pid = child.id().unwrap_or(0);
710
711 let (kill_tx, mut kill_rx) = mpsc::channel(1);
712
713 let log_tx_inner = log_tx.clone();
716 let exit_tx_inner = exit_tx.clone();
717 let backend_name_upper = backend_name.to_uppercase();
718 tokio::spawn(async move {
719 let stdout = child.stdout.take().unwrap();
720 let stderr = child.stderr.take().unwrap();
721
722 let (stdout_tx, mut stdout_rx) = mpsc::channel::<String>(64);
723 let (stderr_tx, mut stderr_rx) = mpsc::channel::<String>(64);
724
725 let mut std_out = Some(tokio::spawn(async move {
727 let reader = BufReader::new(stdout).lines();
728 tokio::pin!(reader);
729 while let Ok(Some(line)) = reader.next_line().await {
730 if stdout_tx.send(line).await.is_err() {
731 break;
732 }
733 }
734 }));
735
736 let mut std_err = Some(tokio::spawn(async move {
737 let reader = BufReader::new(stderr).lines();
738 tokio::pin!(reader);
739 while let Ok(Some(line)) = reader.next_line().await {
740 if stderr_tx.send(line).await.is_err() {
741 break;
742 }
743 }
744 }));
745
746 loop {
749 tokio::select! {
750 _ = kill_rx.recv() => {
751 let _ = child.kill().await;
752 if let Some(h) = std_out.take() { let _ = h.await; }
753 if let Some(h) = std_err.take() { let _ = h.await; }
754 break;
755 }
756 line = stdout_rx.recv() => {
757 if let Some(line) = line { let _ = log_tx_inner.send(line).await; } else { break; }
758 }
759 line = stderr_rx.recv() => {
760 if let Some(line) = line { let _ = log_tx_inner.send(line).await; } else { break; }
761 }
762 else => break,
763 }
764 }
765
766 if let Some(h) = std_out.take() {
768 let _ = h.await;
769 }
770 if let Some(h) = std_err.take() {
771 let _ = h.await;
772 }
773
774 let exit_code = child.wait().await.ok().and_then(|s| s.code());
775 let _ = exit_tx_inner.send(()).await;
776 let _ = log_tx_inner
777 .send(format!(
778 "{} exited with code {:?}",
779 backend_name_upper, exit_code
780 ))
781 .await;
782 });
783
784 Ok((
785 ServerHandle {
786 port: if server_mode == crate::models::ServerMode::Bench {
787 0
788 } else {
789 settings.port
790 },
791 host: settings.host.clone(),
792 pid,
793 kill_tx,
794 },
795 cmd_string,
796 ))
797}
798
799pub async fn check_health(host: &str, port: u16) -> bool {
801 let host = clean_host(host);
802 let url = format!("http://{}:{}/health", host, port);
803
804 match HEALTH_CLIENT.get(&url).send().await {
805 Ok(resp) => resp.status().is_success(),
806 Err(_) => false,
807 }
808}
809
810pub async fn kill_server(handle: ServerHandle) -> Result<(), String> {
812 handle
813 .kill_tx
814 .send(())
815 .await
816 .map_err(|_| "Server already stopped".to_string())
817}
818
819pub async fn get_metrics(
821 host: &str,
822 port: u16,
823 model_name: Option<&str>,
824 pid: Option<u32>,
825) -> Result<ServerMetrics, String> {
826 let host = clean_host(host);
827 let mut url = if let Some(model) = model_name {
830 let name = strip_gguf(model);
831 format!(
832 "http://{}:{}/metrics?model={}",
833 host,
834 port,
835 urlencoding::encode(&name)
836 )
837 } else {
838 format!("http://{}:{}/metrics", host, port)
839 };
840
841 let mut resp = HTTP_CLIENT
842 .get(&url)
843 .send()
844 .await
845 .map_err(|e| format!("Failed to get metrics: {}", e))?;
846
847 if (resp.status() == reqwest::StatusCode::NOT_FOUND
849 || resp.status() == reqwest::StatusCode::BAD_REQUEST)
850 && model_name.is_some()
851 {
852 url = format!("http://{}:{}/metrics", host, port);
853 resp = HTTP_CLIENT
854 .get(&url)
855 .send()
856 .await
857 .map_err(|e| format!("Failed to get metrics: {}", e))?;
858 }
859
860 if !resp.status().is_success() {
861 return Err(format!("Server returned {}", resp.status()));
862 }
863
864 let text = resp
865 .text()
866 .await
867 .map_err(|e| format!("Failed to read metrics: {}", e))?;
868
869 let mut m = ServerMetrics::default();
870
871 let mut ctx_max_slots = 0u32;
872 let mut ctx_used_slots = 0u32;
873 let mut ctx_used_global = 0u32;
874 let mut ctx_max_global = 0u32;
875
876 let mut vram_used_slots = 0u64;
877 let mut vram_total_slots = 0u64;
878 let mut vram_used_global = 0u64;
879 let mut vram_total_global = 0u64;
880
881 for line in text.lines() {
882 if line.starts_with('#') || line.is_empty() {
883 continue;
884 }
885
886 let parts: Vec<&str> = line.split_whitespace().collect();
887 if parts.len() < 2 {
888 continue;
889 }
890
891 let name_with_labels = parts[0];
892 let mut val = 0.0;
893 for part in parts.iter().skip(1) {
894 if let Ok(v) = part.parse::<f64>() {
895 val = v;
896 break;
897 }
898 }
899
900 let is_slot = name_with_labels.contains("slot=\"") || name_with_labels.contains("pool=\"");
901 let name = name_with_labels
902 .split('{')
903 .next()
904 .unwrap_or(name_with_labels);
905
906 match name {
907 "llama_kv_cache_usage_bytes"
908 | "kv_cache_usage_bytes"
909 | "llama_server_kv_cache_usage_bytes"
910 | "llama_server_kv_cache_used_bytes"
911 | "llama_server_vram_used_bytes" => {
912 if is_slot {
913 vram_used_slots += val as u64;
914 } else {
915 vram_used_global = vram_used_global.max(val as u64);
916 }
917 }
918 "llama_kv_cache_total_bytes"
919 | "kv_cache_total_bytes"
920 | "llama_server_kv_cache_total_bytes"
921 | "llama_server_vram_total_bytes" => {
922 if is_slot {
923 vram_total_slots += val as u64;
924 } else {
925 vram_total_global = vram_total_global.max(val as u64);
926 }
927 }
928 "llama_model_memory_usage_bytes"
929 | "model_memory_usage_bytes"
930 | "llama_server_model_memory_usage_bytes"
931 | "llama_server_memory_usage_bytes"
932 | "llama_server_ram_usage_bytes"
933 | "llama_server_mem_used_bytes" => {
934 m.ram_used = m.ram_used.max(val as u64);
935 }
936 "llama_kv_cache_tokens_used"
937 | "kv_cache_usage_tokens"
938 | "kv_cache_tokens_used"
939 | "llama_server_kv_cache_tokens_used"
940 | "llamacpp:n_tokens_used"
941 | "llama_server_n_tokens_used"
942 | "llama_server_n_past"
943 | "llamacpp:n_past" => {
944 if is_slot {
945 ctx_used_slots += val as u32;
946 } else {
947 ctx_used_global = ctx_used_global.max(val as u32);
948 }
949 }
950 "llama_kv_cache_tokens_total"
951 | "kv_cache_total_tokens"
952 | "kv_cache_tokens_total"
953 | "llama_server_kv_cache_tokens_total"
954 | "llamacpp:n_ctx"
955 | "llamacpp:n_tokens_max"
956 | "llama_server_n_ctx"
957 | "llama_server_n_tokens_max" => {
958 if is_slot {
959 ctx_max_slots += val as u32;
960 } else {
961 ctx_max_global = ctx_max_global.max(val as u32);
962 }
963 }
964 "llama_server_cpu_usage_percentage"
965 | "cpu_usage_percentage"
966 | "llama_server_cpu_usage"
967 | "llama_server_cpu_percent" => {
968 m.cpu_usage = m.cpu_usage.max(val);
969 }
970 "llamacpp:predicted_tokens_seconds"
971 | "llama_server_predicted_tokens_seconds"
972 | "llama_server_tps" => {
973 m.tps += val;
974 }
975 "llamacpp:prompt_tokens_seconds"
976 | "llama_server_prompt_tokens_seconds"
977 | "llama_server_prompt_tps" => {
978 m.prompt_tps += val;
979 }
980 "llamacpp:kv_cache_usage_ratio" | "llama_server_kv_cache_usage_ratio" => {
981 if !is_slot && ctx_max_global > 0 {
982 ctx_used_global = ctx_used_global.max((val * ctx_max_global as f64) as u32);
983 }
984 }
985 _ => {
986 tracing::debug!("Unknown metric: {}", name);
987 }
988 }
989 }
990
991 m.gpu_mem_used = if vram_used_global > 0 {
993 vram_used_global
994 } else if vram_used_slots > 0 {
995 vram_used_slots
996 } else {
997 0
998 };
999 m.gpu_mem_total = if vram_total_global > 0 {
1000 vram_total_global
1001 } else if vram_total_slots > 0 {
1002 vram_total_slots
1003 } else {
1004 0
1005 };
1006
1007 m.ctx_used = if ctx_used_slots > 0 {
1010 ctx_used_slots
1011 } else {
1012 ctx_used_global
1013 };
1014 m.ctx_max = if ctx_max_slots > 0 {
1015 ctx_max_slots
1016 } else {
1017 ctx_max_global
1018 };
1019 if model_name.is_none() {
1026 let set_if_better = |out: &mut ServerMetrics, used: u64, total: u64| {
1030 if out.gpu_mem_used == 0 || used > out.gpu_mem_used {
1031 out.gpu_mem_used = used;
1032 out.gpu_mem_total = total;
1033 }
1034 };
1035
1036 let cached_nvidia;
1037 let cached_amd;
1038 {
1039 let cache = VRAM_CACHE.lock().unwrap();
1040 cached_nvidia = cache.nvidia;
1041 cached_amd = cache.amd;
1042 }
1043
1044 if cached_nvidia.is_some() {
1045 if let Some((nv_used, nv_total)) = cached_nvidia {
1046 set_if_better(&mut m, nv_used, nv_total);
1047 }
1048 } else {
1049 let nv = tokio::task::spawn_blocking(get_nvidia_vram_metrics)
1050 .await
1051 .unwrap_or(Err("spawn join error".to_string()));
1052 if let Ok((used, total)) = nv {
1053 {
1054 let mut cache = VRAM_CACHE.lock().unwrap();
1055 cache.nvidia = Some((used, total));
1056 cache.stale = false;
1057 }
1058 set_if_better(&mut m, used, total);
1059 }
1060 }
1061
1062 if m.gpu_mem_total == 0 {
1063 if cached_amd.is_some() {
1064 if let Some((amd_used, amd_total)) = cached_amd {
1065 set_if_better(&mut m, amd_used, amd_total);
1066 }
1067 } else {
1068 let amd = tokio::task::spawn_blocking(get_amdgpu_vram_metrics)
1070 .await
1071 .unwrap_or(Err("spawn join error".to_string()));
1072 if let Ok((used, total)) = amd {
1073 {
1074 let mut cache = VRAM_CACHE.lock().unwrap();
1075 cache.amd = Some((used, total));
1076 cache.stale = false;
1077 }
1078 set_if_better(&mut m, used, total);
1079 }
1080 }
1081 }
1082 } else if m.gpu_mem_used == 0 {
1083 let cached_nvidia;
1085 let cached_amd;
1086 {
1087 let cache = VRAM_CACHE.lock().unwrap();
1088 cached_nvidia = cache.nvidia;
1089 cached_amd = cache.amd;
1090 }
1091
1092 if cached_nvidia.is_some() {
1093 if let Some((used, total)) = cached_nvidia {
1094 m.gpu_mem_used = used;
1095 m.gpu_mem_total = total;
1096 }
1097 } else {
1098 let nv = tokio::task::spawn_blocking(get_nvidia_vram_metrics)
1099 .await
1100 .unwrap_or(Err("spawn join error".to_string()));
1101 if let Ok((used, total)) = nv {
1102 {
1103 let mut cache = VRAM_CACHE.lock().unwrap();
1104 cache.nvidia = Some((used, total));
1105 cache.stale = false;
1106 }
1107 m.gpu_mem_used = used;
1108 m.gpu_mem_total = total;
1109 }
1110 }
1111
1112 if m.gpu_mem_used == 0 {
1113 if cached_amd.is_some() {
1114 if let Some((used, total)) = cached_amd {
1115 m.gpu_mem_used = used;
1116 m.gpu_mem_total = total;
1117 }
1118 } else {
1119 let amd = tokio::task::spawn_blocking(get_amdgpu_vram_metrics)
1120 .await
1121 .unwrap_or(Err("spawn join error".to_string()));
1122 if let Ok((used, total)) = amd {
1123 {
1124 let mut cache = VRAM_CACHE.lock().unwrap();
1125 cache.amd = Some((used, total));
1126 cache.stale = false;
1127 }
1128 m.gpu_mem_used = used;
1129 m.gpu_mem_total = total;
1130 }
1131 }
1132 }
1133 }
1134
1135 if let Some(p) = pid
1137 && let Ok((ram, cpu)) = get_process_metrics(p)
1138 {
1139 if m.ram_used == 0 {
1140 m.ram_used = ram;
1141 }
1142 if m.cpu_usage == 0.0 {
1143 m.cpu_usage = cpu;
1144 }
1145 }
1146
1147 m.loaded = ctx_max_global > 0 || vram_used_global > 0;
1148
1149 Ok(m)
1150}
1151
1152fn get_nvidia_vram_metrics() -> Result<(u64, u64), String> {
1154 let output = std::process::Command::new("nvidia-smi")
1155 .args([
1156 "--query-gpu=memory.used,memory.total",
1157 "--format=csv,noheader,nounits",
1158 ])
1159 .output()
1160 .map_err(|e| e.to_string())?;
1161
1162 if !output.status.success() {
1163 return Err("nvidia-smi failed".to_string());
1164 }
1165
1166 let stdout = String::from_utf8_lossy(&output.stdout);
1167 let mut total_used: u64 = 0;
1168 let mut total_total: u64 = 0;
1169 for line in stdout.lines() {
1170 let parts: Vec<&str> = line.split(',').collect();
1171 if parts.len() >= 2 {
1172 let used = match parts[0].trim().parse::<u64>() {
1173 Ok(v) => v,
1174 Err(_) => {
1175 warn!(
1176 "nvidia-smi: failed to parse used memory from '{}'",
1177 parts[0]
1178 );
1179 continue;
1180 }
1181 } * 1024
1182 * 1024;
1183 let total = match parts[1].trim().parse::<u64>() {
1184 Ok(v) => v,
1185 Err(_) => {
1186 warn!(
1187 "nvidia-smi: failed to parse total memory from '{}'",
1188 parts[1]
1189 );
1190 continue;
1191 }
1192 } * 1024
1193 * 1024;
1194 total_used += used;
1195 total_total += total;
1196 }
1197 }
1198 if total_total > 0 {
1199 return Ok((total_used, total_total));
1200 }
1201
1202 Err("Invalid output from nvidia-smi".to_string())
1203}
1204
1205fn get_amdgpu_vram_metrics() -> Result<(u64, u64), String> {
1207 let output = std::process::Command::new("amdgpu_top")
1208 .args(["-d", "--json"])
1209 .output()
1210 .map_err(|e| e.to_string())?;
1211
1212 if !output.status.success() {
1213 return Err("amdgpu_top failed".to_string());
1214 }
1215
1216 let json: serde_json::Value =
1217 serde_json::from_slice(&output.stdout).map_err(|e| e.to_string())?;
1218
1219 let devices = if json.is_array() {
1221 json.as_array()
1222 } else {
1223 json.get("devices").and_then(|d| d.as_array())
1224 };
1225
1226 if let Some(devices) = devices
1227 && let Some(device) = devices.first()
1228 {
1229 let root_used = device.get("VRAM Usage Size").and_then(|v| v.as_u64());
1232 let root_total = device.get("VRAM Size").and_then(|v| v.as_u64());
1233
1234 if let (Some(used), Some(total)) = (root_used, root_total)
1235 && total > 0
1236 {
1237 return Ok((used, total));
1238 }
1239
1240 let vram_obj = device.get("VRAM");
1242 if let Some(vram) = vram_obj {
1243 let nested_used = vram
1245 .get("Total VRAM Usage")
1246 .and_then(|v| v.get("value").or(Some(v)))
1247 .and_then(|v| v.as_u64());
1248 let nested_total = vram
1249 .get("Total VRAM")
1250 .and_then(|v| v.get("value").or(Some(v)))
1251 .and_then(|v| v.as_u64());
1252
1253 if let (Some(used), Some(total)) = (nested_used, nested_total) {
1254 let multiplier = if vram.get("Total VRAM").and_then(|v| v.get("unit")).is_some() {
1256 1024 * 1024
1257 } else {
1258 1
1259 };
1260 if total > 0 {
1261 return Ok((used * multiplier, total * multiplier));
1262 }
1263 }
1264 }
1265
1266 let vram_usage = device.get("vram_usage");
1268 if let Some(vram) = vram_usage {
1269 let used = vram
1270 .get("VRAM")
1271 .or_else(|| vram.get("usage"))
1272 .and_then(|v| v.get("value").or(Some(v)))
1273 .and_then(|v| v.as_u64())
1274 .unwrap_or(0);
1275 let total = vram
1276 .get("TotalVRAM")
1277 .or_else(|| vram.get("total"))
1278 .and_then(|v| v.get("value").or(Some(v)))
1279 .and_then(|v| v.as_u64())
1280 .unwrap_or(0);
1281
1282 if total > 0 {
1283 return Ok((used * 1024 * 1024, total * 1024 * 1024));
1284 }
1285 }
1286 }
1287
1288 Err("Could not find VRAM info in amdgpu_top output".to_string())
1289}
1290
1291fn get_process_metrics(pid: u32) -> Result<(u64, f64), String> {
1295 use std::sync::{LazyLock, Mutex};
1296 use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System};
1297
1298 static SYS: LazyLock<Mutex<System>> = LazyLock::new(|| {
1299 Mutex::new(System::new_with_specifics(
1300 RefreshKind::everything()
1301 .with_processes(ProcessRefreshKind::nothing().with_cpu().with_memory()),
1302 ))
1303 });
1304
1305 let mut sys = SYS.lock().unwrap();
1306 let pids = [Pid::from(pid as usize)];
1307 sys.refresh_processes_specifics(
1308 ProcessesToUpdate::Some(&pids),
1309 true,
1310 ProcessRefreshKind::nothing().with_cpu().with_memory(),
1311 );
1312
1313 let sys_pid = Pid::from(pid as usize);
1314
1315 if let Some(process) = sys.process(sys_pid) {
1316 let ram = process.memory(); let cpu = process.cpu_usage() as f64; return Ok((ram, cpu));
1319 }
1320
1321 Err(format!("Process not found: pid={}", pid))
1322}
1323
1324pub async fn load_model(host: &str, port: u16, model_id: &str) -> Result<(), String> {
1328 let host = clean_host(host);
1329
1330 let props_url = format!("http://{}:{}/props", host, port);
1331 if let Ok(res) = HTTP_CLIENT.get(&props_url).send().await
1332 && res.status().is_success()
1333 && let Ok(json) = res.json::<serde_json::Value>().await
1334 && json.get("role").and_then(|r| r.as_str()) != Some("router")
1335 {
1336 return Err(
1337 "Server is not in router mode. Start with --models-max to enable router mode."
1338 .to_string(),
1339 );
1340 }
1341
1342 let url = format!("http://{}:{}/models/load", host, port);
1343 let body = serde_json::json!({ "model": model_id });
1344
1345 match HTTP_CLIENT.post(&url).json(&body).send().await {
1346 Ok(res) if res.status().is_success() => Ok(()),
1347 Ok(res) => {
1348 let status = res.status();
1349 let error = res
1350 .text()
1351 .await
1352 .unwrap_or_else(|_| "Unknown error".to_string());
1353 if error.contains("model is already running") {
1354 Ok(())
1355 } else {
1356 Err(format!("Server returned {}: {}", status, error))
1357 }
1358 }
1359 Err(e) => Err(format!("Failed to send request: {}", e)),
1360 }
1361}
1362
1363pub async fn list_models(
1365 host: &str,
1366 port: u16,
1367) -> Result<Vec<(String, String, Option<String>)>, String> {
1368 let host = clean_host(host);
1369 let url = format!("http://{}:{}/models", host, port);
1370
1371 let res = HTTP_CLIENT
1372 .get(&url)
1373 .send()
1374 .await
1375 .map_err(|e| format!("Failed to list models: {}", e))?;
1376
1377 if !res.status().is_success() {
1378 return Err(format!("Server returned error {}", res.status()));
1379 }
1380
1381 let json: serde_json::Value = res
1382 .json()
1383 .await
1384 .map_err(|e| format!("Invalid JSON: {}", e))?;
1385
1386 let mut results = Vec::new();
1387 if let Some(data) = json.get("data").and_then(|d| d.as_array()) {
1388 for model in data {
1389 let id = model
1390 .get("id")
1391 .and_then(|v| v.as_str())
1392 .unwrap_or_default()
1393 .to_string();
1394 let status = model
1396 .get("status")
1397 .and_then(|s| s.get("value").or(Some(s)))
1398 .and_then(|v| v.as_str())
1399 .unwrap_or("unloaded")
1400 .to_string();
1401 let path = model
1402 .get("path")
1403 .and_then(|v| v.as_str())
1404 .map(|s| s.to_string());
1405
1406 results.push((id, status, path));
1407 }
1408 }
1409
1410 Ok(results)
1411}
1412
1413pub async fn unload_model(host: &str, port: u16, model_id: &str) -> Result<(), String> {
1416 let host = clean_host(host);
1417 let url = format!("http://{}:{}/models/unload", host, port);
1418 let body = serde_json::json!({ "model": model_id });
1419
1420 match HTTP_CLIENT.post(&url).json(&body).send().await {
1421 Ok(res) if res.status().is_success() => Ok(()),
1422 Ok(res) => {
1423 let status = res.status();
1424 let error = res
1425 .text()
1426 .await
1427 .unwrap_or_else(|_| "Unknown error".to_string());
1428 tracing::debug!(
1429 "Model unload failed (status {}, error: {}): this is expected if model was already unloaded",
1430 status,
1431 error
1432 );
1433 Ok(())
1434 }
1435 Err(e) => {
1436 tracing::debug!("Model unload request failed: {}", e);
1437 Ok(())
1438 }
1439 }
1440}