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