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