Skip to main content

llm_manager/backend/
server.rs

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/// Manages a single llama.cpp server process.
14#[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
22/// Helper: add an argument to both the Command and the display parts list.
23fn 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
30/// Helper: add a flag (argument without value) to both the Command and display parts.
31fn 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
53/// Build the full llama-server command line from settings.
54/// Returns (Command, display_string) where the string is suitable for logging.
55pub 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    // ── Model ───────────────────────────────────────────────
67    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                // Add alias for router mode identification (uses the unique relative path)
72                push_arg(&mut cmd, &mut parts, "--alias", &model.display_name);
73            }
74        }
75        crate::models::ServerMode::Router => {
76            // Router mode: no model in CLI, use /load API to load models
77            if router_max_models > 0 {
78                push_arg(&mut cmd, &mut parts, "--models-max", router_max_models);
79            }
80            // Always pass --models-dir in router mode (global config setting)
81            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            // Should not be reached as Bench uses build_bench_cmd
87        }
88        crate::models::ServerMode::BenchTune => {
89            // Should not be reached as BenchTune uses benchmark tuning function
90        }
91    }
92
93    // Parse GGUF metadata for arch-specific override-kv keys
94    let gguf_meta = model
95        .map(|m| crate::models::GgufMetadata::from_path(&m.path))
96        .transpose();
97
98    // ── Loading ──────────────────────────────────────────────
99    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    // ── GPU ──────────────────────────────────────────────────
144    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    // Inject system prompt via chat template kwargs when it is not empty
234    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    // ── Sampling ─────────────────────────────────────────────
259    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    // ── RoPE ─────────────────────────────────────────────────
366    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    // ── General ──────────────────────────────────────────────
433
434    let display = parts.join(" ");
435    (cmd, display)
436}
437
438/// Build the full llama-bench command line.
439pub 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
465/// Spawn a llama.cpp server process (single model or router).
466/// Returns (ServerHandle, command_string) where command_string is the full CLI.
467pub 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        // Check if port is already in use (bind to the same host the server will use)
494        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    // BenchTune mode is handled separately in app.process_pending_spawn()
501    // and should never reach this function.
502    if server_mode == crate::models::ServerMode::BenchTune {
503        return Err("BenchTune mode is not supported in spawn_server".to_string());
504    }
505
506    // Resolve the backend binary (downloads if needed)
507    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    // Set platform-specific env vars so the binary can find its shared libraries
584    let bin_dir = binary.parent().unwrap();
585    match std::env::consts::OS {
586        "windows" => {
587            // On Windows, add bin_dir to PATH so llama-server.exe finds libllama.dll
588            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            // On macOS, set DYLD_LIBRARY_PATH for dylib loading
596            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            // On Linux, set LD_LIBRARY_PATH for so loading
607            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    // Background task: read stdout and stderr concurrently via separate tasks.
630    // Each stream gets its own task + mpsc channel so neither can block the other.
631    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        // Spawn a reader task for each stream
642        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        // Merge loop: block on whichever channel has data.
663        // When both are empty, select! sleeps with zero CPU cost.
664        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        // Wait for reader tasks to finish
683        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
715/// Check if the server is healthy and responsive.
716pub 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
730/// Kill a running server.
731pub 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
739/// Poll metrics from the server.
740pub 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    // We prefer the /metrics endpoint as it's more stable for system info.
748    // In router mode, we can specify the model via query parameter.
749    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 model-specific metrics fail with 404 or 400, try plain /metrics
764    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    // Prefer global metrics (includes model weights + KV cache) over slot-only (KV cache subset).
908    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    // ctx_used = tokens currently in the KV cache.
924    // ctx_max = the total context window size allocated by the server.
925    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    // ctx_max may be overridden in poll_metrics() by the user-configured value.
936
937    // Prefer actual GPU memory usage from nvidia-smi or amdgpu_top.
938    // llama-server's kv_cache_usage_bytes only reports KV cache (typically 10%
939    // of total VRAM); model weights are loaded into GPU memory but not tracked
940    // by the server, so we use system-level tools to report what users see on GPUs.
941    if model_name.is_none() {
942        // Prefer system-level VRAM over llama-server's KV-only value.
943        // System tools report actual GPU memory including model weights,
944        // which is what users see on their GPUs and expect to read.
945        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            // AMD fallback when nvidia-smi is not available.
957            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        // KV-only queries: use system tools as a last resort.
962        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    // Fallback for RAM and CPU using sysinfo (cross-platform)
972    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
987/// Get VRAM usage using nvidia-smi
988fn 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
1040/// Get VRAM usage using amdgpu_top
1041fn 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    // amdgpu_top --json output has a "devices" array (or sometimes just a list of objects depending on version)
1055    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        // Priority 1: Check root keys (newer amdgpu_top format as provided by user)
1065        // "VRAM Usage Size": 3070128128, "VRAM Size": 8589934592
1066        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        // Priority 2: Check nested VRAM object (alternative format)
1076        let vram_obj = device.get("VRAM");
1077        if let Some(vram) = vram_obj {
1078            // Check if it's the "Total VRAM Usage" format (usually MiB)
1079            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                // These are usually in MiB if they have a "unit" field
1090                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        // Priority 3: Check vram_usage key (older format)
1102        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
1126/// Cross-platform: Get RAM (RSS) and CPU usage for a PID.
1127/// Uses a persistent System instance so sysinfo can compute accurate
1128/// CPU deltas across calls (first call on a fresh instance is always 0).
1129fn 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(); // bytes
1152        let cpu = process.cpu_usage() as f64; // percentage
1153        return Ok((ram, cpu));
1154    }
1155
1156    Err(format!("Process not found: pid={}", pid))
1157}
1158
1159/// Load a model via the llama-server Router API.
1160/// Uses the canonical `/models/load` endpoint with the `"model"` JSON field.
1161/// Model is identified by its display_name (matches the `--alias` registered in llama.cpp).
1162pub 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    // Verify server is in router mode
1167    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
1192/// List all models and their status from the llama-server Router API.
1193pub 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            // Status can be a string or an object with a "value" field
1225            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
1243/// Unload a model via the llama-server Router API.
1244/// Uses the canonical `/models/unload` endpoint with the `"model"` JSON field.
1245pub 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}