Skip to main content

llm_manager/backend/
server.rs

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
14/// Client for health checks (short timeout).
15static 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/// Manages a single llama.cpp server process.
25#[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
33/// Helper: add an argument to both the Command and the display parts list.
34fn 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    // Quote values that may contain spaces for shell safety
39    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
49/// Helper: add a flag (argument without value) to both the Command and display parts.
50fn 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
72/// Build the full llama-server command line from settings.
73/// Returns (Command, display_string) where the string is suitable for logging.
74pub 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    // ── Model ───────────────────────────────────────────────
86    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                // Add alias for router mode identification (uses the unique relative path)
91                push_arg(&mut cmd, &mut parts, "--alias", &model.display_name);
92            }
93        }
94        crate::models::ServerMode::Router => {
95            // Router mode: no model in CLI, use /load API to load models
96            if router_max_models > 0 {
97                push_arg(&mut cmd, &mut parts, "--models-max", router_max_models);
98            }
99            // Always pass --models-dir in router mode (global config setting)
100            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            // Should not be reached as Bench uses build_bench_cmd
106        }
107        crate::models::ServerMode::BenchTune => {
108            // Should not be reached as BenchTune uses benchmark tuning function
109        }
110    }
111
112    // Parse GGUF metadata for arch-specific override-kv keys
113    let gguf_meta = model
114        .map(|m| crate::models::GgufMetadata::from_path(&m.path))
115        .transpose();
116
117    // ── Loading ──────────────────────────────────────────────
118    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    // ── GPU ──────────────────────────────────────────────────
163    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    // Apply chat template: file path, built-in name, or auto-detect from arch
249    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    // Inject system prompt via chat template kwargs when it is not empty
268    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    // ── Sampling ─────────────────────────────────────────────
293    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    // ── RoPE ─────────────────────────────────────────────────
400    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    // ── General ──────────────────────────────────────────────
469
470    let display = parts.join(" ");
471    (cmd, display)
472}
473
474/// Build the full llama-bench command line.
475pub 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
499/// Spawn a llama.cpp server process (single model or router).
500/// Returns (ServerHandle, command_string) where command_string is the full CLI.
501pub 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        // Check if port is already in use (bind to the same host the server will use)
528        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    // BenchTune mode is handled separately in app.process_pending_spawn()
535    // and should never reach this function.
536    if server_mode == crate::models::ServerMode::BenchTune {
537        return Err("BenchTune mode is not supported in spawn_server".to_string());
538    }
539
540    // Resolve the backend binary (downloads if needed)
541    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    // Set platform-specific env vars so the binary can find its shared libraries
618    let bin_dir = binary.parent().unwrap();
619    match std::env::consts::OS {
620        "windows" => {
621            // On Windows, add bin_dir to PATH so llama-server.exe finds libllama.dll
622            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            // On macOS, set DYLD_LIBRARY_PATH for dylib loading
630            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            // On Linux, set LD_LIBRARY_PATH for so loading
641            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    // Background task: read stdout and stderr concurrently via separate tasks.
664    // Each stream gets its own task + mpsc channel so neither can block the other.
665    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        // Spawn a reader task for each stream
676        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        // Merge loop: block on whichever channel has data.
697        // When both are empty, select! sleeps with zero CPU cost.
698        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        // Wait for reader tasks to finish
717        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
749/// Check if the server is healthy and responsive.
750pub 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
760/// Kill a running server.
761pub 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
769/// Poll metrics from the server.
770pub 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    // We prefer the /metrics endpoint as it's more stable for system info.
778    // In router mode, we can specify the model via query parameter.
779    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 model-specific metrics fail with 404 or 400, try plain /metrics
793    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    // Prefer global metrics (includes model weights + KV cache) over slot-only (KV cache subset).
937    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    // ctx_used = tokens currently in the KV cache.
953    // ctx_max = the total context window size allocated by the server.
954    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    // ctx_max may be overridden in poll_metrics() by the user-configured value.
965
966    // Prefer actual GPU memory usage from nvidia-smi or amdgpu_top.
967    // llama-server's kv_cache_usage_bytes only reports KV cache (typically 10%
968    // of total VRAM); model weights are loaded into GPU memory but not tracked
969    // by the server, so we use system-level tools to report what users see on GPUs.
970    if model_name.is_none() {
971        // Prefer system-level VRAM over llama-server's KV-only value.
972        // System tools report actual GPU memory including model weights,
973        // which is what users see on their GPUs and expect to read.
974        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            // AMD fallback when nvidia-smi is not available.
986            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        // KV-only queries: use system tools as a last resort.
991        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    // Fallback for RAM and CPU using sysinfo (cross-platform)
1001    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
1017/// Get VRAM usage using nvidia-smi
1018fn 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
1070/// Get VRAM usage using amdgpu_top
1071fn 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    // amdgpu_top --json output has a "devices" array (or sometimes just a list of objects depending on version)
1085    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        // Priority 1: Check root keys (newer amdgpu_top format as provided by user)
1095        // "VRAM Usage Size": 3070128128, "VRAM Size": 8589934592
1096        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        // Priority 2: Check nested VRAM object (alternative format)
1106        let vram_obj = device.get("VRAM");
1107        if let Some(vram) = vram_obj {
1108            // Check if it's the "Total VRAM Usage" format (usually MiB)
1109            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                // These are usually in MiB if they have a "unit" field
1120                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        // Priority 3: Check vram_usage key (older format)
1132        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
1156/// Cross-platform: Get RAM (RSS) and CPU usage for a PID.
1157/// Uses a persistent System instance so sysinfo can compute accurate
1158/// CPU deltas across calls (first call on a fresh instance is always 0).
1159fn 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(); // bytes
1182        let cpu = process.cpu_usage() as f64; // percentage
1183        return Ok((ram, cpu));
1184    }
1185
1186    Err(format!("Process not found: pid={}", pid))
1187}
1188
1189/// Load a model via the llama-server Router API.
1190/// Uses the canonical `/models/load` endpoint with the `"model"` JSON field.
1191/// Model is identified by its display_name (matches the `--alias` registered in llama.cpp).
1192pub 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
1224/// List all models and their status from the llama-server Router API.
1225pub 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            // Status can be a string or an object with a "value" field
1256            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
1274/// Unload a model via the llama-server Router API.
1275/// Uses the canonical `/models/unload` endpoint with the `"model"` JSON field.
1276pub 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}