Skip to main content

llm_manager/backend/
server.rs

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