From 25ddae0b96be463c1776daf013cf92040c303904 Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 28 Oct 2026 22:14:49 +0530 Subject: [PATCH 01/35] fix(nvpair-engine-manager): stop slow-starting engines cleanly Lifecycle fixes needed by an engine that takes a minute or more to start and forks helper processes, such as vLLM. None of them is engine-specific. - engine:status and engine:get-installed no longer wait for a running lifecycle operation, so a toggle during a slow start does not time out. - Shutdown caps the stop grace at 3s so the force kill lands inside the parent's window, and a SIGTERM no longer blocks on an idle stdin. - A stop also kills helper processes left in the engine's process group, so a crash mid-load does not orphan a process holding GPU memory. - An orphaned engine run by an interpreter (a Python package's script) is recognised by its command line, so the next launch can stop it. Signed-off-by: Tushar Khinvasara --- services/nvpair-engine-manager/README.md | 23 +- .../nvpair-engine-manager/executor_test.go | 37 +++ services/nvpair-engine-manager/lifecycle.go | 28 +- services/nvpair-engine-manager/manager.go | 11 +- .../nvpair-engine-manager/manager_test.go | 48 +++- services/nvpair-engine-manager/proc.go | 11 +- .../proc_group_unix_test.go | 245 ++++++++++++++++++ services/nvpair-engine-manager/proc_unix.go | 39 +++ .../nvpair-engine-manager/proc_windows.go | 8 + services/nvpair-engine-manager/spec.md | 2 +- services/nvpair-engine-manager/status.go | 16 +- 11 files changed, 453 insertions(+), 15 deletions(-) create mode 100644 services/nvpair-engine-manager/proc_group_unix_test.go diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index ced4bd404..ac89eabd2 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -186,8 +186,14 @@ allowance on supported Windows systems. The deadline remains finite: if Ollama never serves its readiness endpoint, engine-manager stops the owned process and reports the failed start. On Unix, stopping an owned process sends SIGTERM to its process group, waits the manifest's `stop.grace_s` (five seconds by -default), then escalates to SIGKILL; `signal:"kill"` skips the grace. Failed -startup cleanup uses the same policy. On Windows, the windowless engines we +default), then escalates to SIGKILL, which also takes any helper processes the +engine forked (vLLM's `EngineCore` holds the GPU) that outlived it; `signal:"kill"` +skips the grace. Failed startup cleanup uses the same policy. While the whole +service is shutting down the grace is capped at three seconds, whatever the +manifest says: the parent allows engine-manager only a few seconds to stop its +engines before it kills it, and engine-manager cannot escalate once killed, so a +longer grace would leave the engine orphaned (an engine still loading its model +does not answer a polite signal at all). On Windows, the windowless engines we spawn cannot receive a graceful (non-`/F`) close, so stopping uses immediate `taskkill /T /F`. @@ -204,7 +210,9 @@ would only collide on the port. Consequences worth knowing: - **An adopted engine has no child process the service owns**, so `engine:stop` (and `engine:restart`'s stop phase, and shutdown's cleanup) resolves the PID bound to the engine's port and terminates it **only when that process is - running the very binary NVPAIR manages for the engine** — reclaiming an orphan + running the very binary NVPAIR manages for the engine** (for an engine + installed as a Python package, such as vLLM, that is the interpreter running the + engine's script, read from the process's command line on Linux) — reclaiming an orphan a prior run left on our own managed port (e.g. an `ollama serve` on `11435` whose handle was lost after a crash). This is precise to the port, so a genuine third-party listener on a *different* port (Ollama's own desktop app @@ -352,4 +360,11 @@ termination, console hiding) are the only build-tagged Go Shuts down on stdin EOF (parent closed the pipe), `SIGINT`/`SIGTERM`, or a `shutdown` JSON-RPC request — stopping any running engines first so none -are orphaned. +are orphaned. A signal shuts down at once even while stdin is idle and an engine +is still starting; the start is cancelled and its process group force-killed +within the three-second shutdown grace (see Process-mode stops above). + +`engine:status` and `engine:get-installed` never wait for a lifecycle operation +(a start, stop, install or model switch can hold an engine for minutes): while +one runs they report what is already known and the operation reports its own +result. diff --git a/services/nvpair-engine-manager/executor_test.go b/services/nvpair-engine-manager/executor_test.go index 678d392cb..c8dda23c7 100644 --- a/services/nvpair-engine-manager/executor_test.go +++ b/services/nvpair-engine-manager/executor_test.go @@ -1044,3 +1044,40 @@ func rmCmd(path string) []string { } return []string{"rm", "-f", path} } + +// A start that takes a minute (vLLM loading weights) holds the engine's +// lifecycle lock for that minute. Status and the installed-engines listing are +// what the UI polls and what a toggle awaits with a short timeout, so they must +// answer from what is already known instead of queueing behind the operation. +func TestStatusDoesNotWaitForARunningLifecycleOperation(t *testing.T) { + ex := newTestExecutor(t, testEngineManifest(fakeEngineBin)) + st, err := ex.state("fake") + if err != nil { + t.Fatal(err) + } + st.opMu.Lock() // an operation is in flight + defer st.opMu.Unlock() + + answered := make(chan EngineStatus, 2) + go func() { + status, _ := ex.Status("fake") + answered <- status + }() + go func() { + for _, status := range ex.GetInstalled() { + if status.Engine == "fake" { + answered <- status + } + } + }() + for i := 0; i < 2; i++ { + select { + case status := <-answered: + if status.Engine != "fake" || status.DisplayName == "" { + t.Fatalf("status = %+v", status) + } + case <-time.After(2 * time.Second): + t.Fatal("status waited for a running lifecycle operation") + } + } +} diff --git a/services/nvpair-engine-manager/lifecycle.go b/services/nvpair-engine-manager/lifecycle.go index e1b2c53c2..6685bdfaa 100644 --- a/services/nvpair-engine-manager/lifecycle.go +++ b/services/nvpair-engine-manager/lifecycle.go @@ -280,7 +280,7 @@ func (e *Executor) bringUpProcess(ctx context.Context, st *engineState, engine s st.mu.Lock() st.stopping = true st.mu.Unlock() - proc.stop(stopGrace(rt)) + proc.stop(e.stopGraceFor(rt)) st.mu.Lock() st.proc = nil st.mu.Unlock() @@ -455,7 +455,7 @@ func (e *Executor) doStop(st *engineState, engine string) error { // we manage :11435) is never touched; we decline only a process // whose image we can't confirm is ours. pid, image, ok := pidOnPort(port) - if ok && isManagedInstallPath(binPath, st.installDir) && isOurEngineImage(image, binPath) { + if ok && isManagedInstallPath(binPath, st.installDir) && (isOurEngineImage(image, binPath) || procRunsScript(pid, binPath)) { st.mu.Lock() st.stopping = true if st.healthStop != nil { @@ -463,7 +463,7 @@ func (e *Executor) doStop(st *engineState, engine string) error { st.healthStop = nil } st.mu.Unlock() - grace := stopGrace(rt) + grace := e.stopGraceFor(rt) terminatePID(pid, grace) if !pidAlive(pid) { e.markStopped(st, engine) @@ -499,7 +499,7 @@ func (e *Executor) doStop(st *engineState, engine string) error { return e.reconcileFailedCommandStop(st, engine, rt.Ready == nil || !e.waitUnavailable(rt.Ready, port, time.Second), err) } } else if proc != nil { - proc.stop(stopGrace(rt)) + proc.stop(e.stopGraceFor(rt)) } e.markStopped(st, engine) @@ -550,6 +550,26 @@ func (e *Executor) runCommandStop(st *engineState, engine string, rt Runtime, po // stopGrace is how long to wait after a graceful stop before escalating to a // forced kill, taken from the manifest's stop spec: default 5s, the spec's // grace_s when set, or 0 when the spec asks for an immediate kill. +// shutdownStopGraceCap is the longest an engine is given to exit on a polite +// signal once the whole service is shutting down. The parent bounds the engine +// stop to a few seconds (the broker allows engine-manager 5s for it) and then +// kills engine-manager, which cannot escalate to a forced kill after that: the +// engine, running in its own process group, would be left behind. So at +// shutdown the force kill has to come inside that window, however generous the +// manifest's grace is for an ordinary stop. An engine that is still loading +// (vLLM with its weights half read) does not answer a polite signal at all. +const shutdownStopGraceCap = 3 * time.Second + +// stopGraceFor is the grace for stopping an engine now: the manifest's, capped +// during shutdown. +func (e *Executor) stopGraceFor(rt Runtime) time.Duration { + grace := stopGrace(rt) + if e.shuttingDown.Load() && grace > shutdownStopGraceCap { + return shutdownStopGraceCap + } + return grace +} + func stopGrace(rt Runtime) time.Duration { grace := 5 * time.Second if sp := rt.Stop; sp != nil { diff --git a/services/nvpair-engine-manager/manager.go b/services/nvpair-engine-manager/manager.go index af41f9ddb..96367fa62 100644 --- a/services/nvpair-engine-manager/manager.go +++ b/services/nvpair-engine-manager/manager.go @@ -125,7 +125,16 @@ func (m *Manager) Run(ctx context.Context) error { go m.exec.watchLoaded(ctx) go m.watchPeerSettings(ctx) - err := m.readLoop(ctx) + // The read blocks on stdin until the parent sends something or closes it, so + // a SIGTERM that cancels ctx would otherwise never reach the shutdown below: + // the process would sit there, with its engines running, until stdin closed. + readDone := make(chan error, 1) + go func() { readDone <- m.readLoop(ctx) }() + var err error + select { + case err = <-readDone: + case <-ctx.Done(): + } // Cancel first so any in-flight start aborts at its readiness wait, // then stop engines so the parent's exit doesn't orphan them. cancel() diff --git a/services/nvpair-engine-manager/manager_test.go b/services/nvpair-engine-manager/manager_test.go index 75685f8ac..484323c4f 100644 --- a/services/nvpair-engine-manager/manager_test.go +++ b/services/nvpair-engine-manager/manager_test.go @@ -8,8 +8,9 @@ import ( "bytes" "context" "encoding/json" + "io" "net" - + "sync" "testing" "time" @@ -130,3 +131,48 @@ func mustContain(t *testing.T, s, sub string) { t.Helper() require.Contains(t, s, sub) } + +// A SIGTERM cancels the manager's context while the read loop is blocked on +// stdin, which stays open and silent. Shutdown must not wait for the next +// message: it has to stop the engines and return, or the process hangs with its +// engines running until the parent closes the pipe. +func TestRunShutsDownWhenCancelledWhileStdinIsIdle(t *testing.T) { + ex := newTestExecutor(t, testEngineManifest(fakeEngineBin)) + if err := ex.Start(context.Background(), "fake"); err != nil { + t.Fatalf("start: %v", err) + } + + stdinRead, stdinWrite := io.Pipe() // never written to, never closed + t.Cleanup(func() { _ = stdinWrite.Close() }) + var out syncBuffer + m := NewManager(NewCodec(struct { + io.Reader + io.Writer + }{stdinRead, &out}), ex, nil) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- m.Run(ctx) }() + time.Sleep(200 * time.Millisecond) // let it reach the blocking read + cancel() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Run did not return after its context was cancelled") + } + if status, _ := ex.Status("fake"); status.Running { + t.Fatal("the engine was left running by the shutdown") + } +} + +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} diff --git a/services/nvpair-engine-manager/proc.go b/services/nvpair-engine-manager/proc.go index a1983ec0e..894fe274b 100644 --- a/services/nvpair-engine-manager/proc.go +++ b/services/nvpair-engine-manager/proc.go @@ -90,9 +90,11 @@ func (mp *managedProc) stop(grace time.Duration) { return // already exited default: } + pid := mp.cmd.Process.Pid if grace <= 0 { - _ = signalPID(mp.cmd.Process.Pid, true) + _ = signalPID(pid, true) <-mp.done + killGroupRemnants(pid) return } _ = gracefulSignal(mp.cmd) @@ -100,11 +102,16 @@ func (mp *managedProc) stop(grace time.Duration) { defer timer.Stop() select { case <-mp.done: + // The process we started exited, but an engine that forks helpers (vLLM's + // EngineCore) can leave them behind, holding GPU memory, when its main + // process goes down mid-start. Nothing else will ever clean them up. + killGroupRemnants(pid) return case <-timer.C: } - _ = signalPID(mp.cmd.Process.Pid, true) + _ = signalPID(pid, true) <-mp.done + killGroupRemnants(pid) } // terminatePID stops the process with the given PID (and its tree on diff --git a/services/nvpair-engine-manager/proc_group_unix_test.go b/services/nvpair-engine-manager/proc_group_unix_test.go new file mode 100644 index 000000000..7b178f039 --- /dev/null +++ b/services/nvpair-engine-manager/proc_group_unix_test.go @@ -0,0 +1,245 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +package main + +import ( + "context" + "os" + "runtime" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +// processGone reports whether pid is gone. A killed process whose parent has +// not reaped it yet is a zombie, which is as dead as a process gets. +func processGone(pid int) bool { + data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") + if err != nil { + return true + } + // "pid (comm) S ..." — comm may contain spaces and parentheses, so cut after the last ")". + fields := strings.Fields(string(data[strings.LastIndex(string(data), ")")+1:])) + return len(fields) > 0 && fields[0] == "Z" +} + +func waitGone(pid int, within time.Duration) bool { + deadline := time.Now().Add(within) + for time.Now().Before(deadline) { + if processGone(pid) { + return true + } + time.Sleep(20 * time.Millisecond) + } + return processGone(pid) +} + +// vLLM's main process can exit on a polite stop while the helper it forked +// (EngineCore, holding the GPU) is still alive, or never gets to answer one. +// Stopping an engine has to take the helpers with it: they are in the +// engine's process group and no one else will ever clean them up. +func TestStopKillsHelpersTheMainProcessLeftBehind(t *testing.T) { + // The main process exits on SIGTERM; its helper ignores it and has closed the + // engine's output pipes, so the stop sees the main process end and nothing + // else would ever notice the helper. + script := `trap 'exit 0' TERM; (trap '' TERM; exec sleep 300 >/dev/null 2>&1) & echo $!; while true; do sleep 0.1; done` + var mu sync.Mutex + childPID := make(chan int, 1) + proc, err := startManagedProc("sh", []string{"-c", script}, nil, func(stream, line string) { + mu.Lock() + defer mu.Unlock() + if pid, err := strconv.Atoi(strings.TrimSpace(line)); err == nil && stream == "stdout" { + select { + case childPID <- pid: + default: + } + } + }) + if err != nil { + t.Fatal(err) + } + var helper int + select { + case helper = <-childPID: + case <-time.After(5 * time.Second): + t.Fatal("the helper process never started") + } + t.Cleanup(func() { killGroupRemnants(proc.cmd.Process.Pid) }) + if processGone(helper) { + t.Fatal("the helper died before the stop") + } + + started := time.Now() + proc.stop(5 * time.Second) + // The main process answered the polite signal, so this must not have waited + // out the grace: what is under test is the cleanup after it exits. + if took := time.Since(started); took > 2*time.Second { + t.Fatalf("stop took %s; the main process should have exited on the graceful signal", took) + } + + if !waitGone(helper, 3*time.Second) { + t.Fatalf("helper process %d survived the engine being stopped", helper) + } +} + +// A process that does not answer the polite signal is force-killed after the +// grace, together with its helpers. +func TestStopForceKillsAProcessThatIgnoresTheGracefulSignal(t *testing.T) { + script := `trap '' TERM; (trap '' TERM; exec sleep 300) & echo $!; while true; do sleep 1; done` + childPID := make(chan int, 1) + proc, err := startManagedProc("sh", []string{"-c", script}, nil, func(stream, line string) { + if pid, err := strconv.Atoi(strings.TrimSpace(line)); err == nil && stream == "stdout" { + select { + case childPID <- pid: + default: + } + } + }) + if err != nil { + t.Fatal(err) + } + helper := <-childPID + t.Cleanup(func() { killGroupRemnants(proc.cmd.Process.Pid) }) + + started := time.Now() + proc.stop(500 * time.Millisecond) + if took := time.Since(started); took > 3*time.Second { + t.Fatalf("stop took %s", took) + } + if !waitGone(proc.cmd.Process.Pid, time.Second) || !waitGone(helper, 3*time.Second) { + t.Fatal("the process or its helper survived the forced stop") + } +} + +// Shutdown is bounded by the parent, so the force kill has to come well inside +// that bound however long the manifest's grace is. +func TestShutdownCapsTheStopGrace(t *testing.T) { + rt := Runtime{Stop: &StopSpec{Signal: "term", GraceS: 30}} + e := &Executor{} + if got := e.stopGraceFor(rt); got != 30*time.Second { + t.Fatalf("an ordinary stop got %s, want the manifest's 30s", got) + } + e.shuttingDown.Store(true) + if got := e.stopGraceFor(rt); got != shutdownStopGraceCap { + t.Fatalf("a shutdown stop got %s, want the %s cap", got, shutdownStopGraceCap) + } + if got := e.stopGraceFor(Runtime{Stop: &StopSpec{Signal: "term", GraceS: 1}}); got != time.Second { + t.Fatalf("a short grace was changed to %s", got) + } + if got := e.stopGraceFor(Runtime{Stop: &StopSpec{Signal: "kill"}}); got != 0 { + t.Fatalf("an immediate-kill spec got %s", got) + } +} + +// vLLM that is still reading its weights does not answer a polite stop, and its +// manifest allows a generous grace for an ordinary stop. When the whole service +// shuts down mid-start the parent allows only a few seconds before it kills +// engine-manager, so the engine has to be force-killed inside that time or it is +// orphaned for good (it runs in its own process group, so nothing else will). +func TestShutdownMidStartForceKillsAnEngineThatIgnoresTheGracefulSignal(t *testing.T) { + // A shell that ignores SIGTERM and forks a helper that does too, like vLLM's + // API server and its EngineCore while loading. + script := `trap '' TERM; (trap '' TERM; exec sleep 300) & echo $! > ` + "%s" + `; while true; do sleep 1; done` + pidFile := t.TempDir() + "/helper.pid" + + m := testEngineManifest("/bin/sh") + key := runtime.GOOS + "/" + runtime.GOARCH + p := m.Platforms[key] + p.Runtime.Args = []string{"-c", strings.Replace(script, "%s", pidFile, 1)} + p.Runtime.Env = nil + p.Runtime.Stop = &StopSpec{Signal: "term", GraceS: 30} // far longer than a shutdown can wait + p.Runtime.Ready = &Probe{HTTP: "http://127.0.0.1:{port}/", Status: 200, TimeoutS: 60} + p.Runtime.Health = nil + m.Platforms[key] = p + m.Actions = nil + ex := newTestExecutor(t, m) + + started := make(chan error, 1) + go func() { started <- ex.Start(context.Background(), "fake") }() + + var helper int + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if data, err := os.ReadFile(pidFile); err == nil { + if pid, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil { + helper = pid + break + } + } + time.Sleep(20 * time.Millisecond) + } + if helper == 0 { + t.Fatal("the engine never started") + } + st, _ := ex.state("fake") + for i := 0; i < 100; i++ { + st.mu.Lock() + proc := st.proc + st.mu.Unlock() + if proc != nil { + t.Cleanup(func() { killGroupRemnants(proc.cmd.Process.Pid) }) + break + } + time.Sleep(20 * time.Millisecond) + } + + began := time.Now() + ex.StopAll() + took := time.Since(began) + if took > shutdownStopGraceCap+3*time.Second { + t.Fatalf("shutdown took %s; the parent only waits a few seconds", took) + } + if !waitGone(helper, 2*time.Second) { + t.Fatalf("the engine's helper process %d was left running", helper) + } + select { + case <-started: + case <-time.After(3 * time.Second): + t.Fatal("the cancelled start never returned") + } +} + +// (The script ends in a command after the sleep so the shell is not replaced by it.) +// A Python-packaged engine (vLLM) runs as "interpreter script args", so its +// executable never matches the engine path. The script in the command line is +// what proves a leftover process is the engine this service manages. +func TestProcRunsScriptRecognisesAnInterpreterRunningTheEngineScript(t *testing.T) { + dir := t.TempDir() + script := dir + "/engine" + if err := os.WriteFile(script, []byte("#!/bin/sh\nsleep 30\ntrue\n"), 0o755); err != nil { + t.Fatal(err) + } + proc, err := startManagedProc("/bin/sh", []string{script, "serve", "--port", "1"}, nil, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { proc.stop(0) }) + pid := proc.cmd.Process.Pid + // /proc//cmdline reads empty for a moment while a new process finishes + // exec'ing. A leftover engine has been running far longer than that. + for i := 0; i < 200; i++ { + if raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline"); err == nil && len(raw) > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + + if !procRunsScript(pid, script) || !procRunsScript(pid, dir+"/./engine") { + t.Error("the engine script in the command line was not recognised") + } + if procRunsScript(pid, dir+"/other-engine") { + t.Error("an unrelated script was recognised") + } + // A bare word is not an engine path, even though it is one of the arguments. + if procRunsScript(pid, "serve") { + t.Error("a relative path was taken for the script") + } + if procRunsScript(0, script) || procRunsScript(pid, "") || procRunsScript(999999999, script) { + t.Error("an invalid pid or path matched") + } +} diff --git a/services/nvpair-engine-manager/proc_unix.go b/services/nvpair-engine-manager/proc_unix.go index 12c9b190d..47186a6ae 100644 --- a/services/nvpair-engine-manager/proc_unix.go +++ b/services/nvpair-engine-manager/proc_unix.go @@ -10,6 +10,7 @@ import ( "errors" "os" "os/exec" + "path/filepath" "strconv" "strings" "syscall" @@ -108,6 +109,34 @@ func procImage(pid int) string { return "" } +// procRunsScript reports whether pid was started as an interpreter running the +// engine script at binPath. An engine installed as a Python package (vLLM's +// venv/bin/vllm) is a script, so the process's executable is the interpreter and +// procImage never matches binPath; what does match is the script named in the +// command line, as one of its first arguments ("python /path/venv/bin/vllm +// serve ..."). Linux only: elsewhere there is no /proc to read, and the answer +// is false, which keeps the caller failing closed. +func procRunsScript(pid int, binPath string) bool { + if pid <= 0 || binPath == "" { + return false + } + raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline") + if err != nil { + return false + } + want := filepath.Clean(binPath) + if !filepath.IsAbs(want) { + return false // an engine path is absolute; a bare word could match any argument + } + args := strings.Split(strings.TrimRight(string(raw), "\x00"), "\x00") + for i := 0; i < len(args) && i < 3; i++ { + if args[i] != "" && filepath.Clean(args[i]) == want { + return true + } + } + return false +} + // signalPID sends SIGTERM (or SIGKILL when force) to the process group when // possible. The owned-process escalation and orphan reclaim share it so forced // stops reach forked helpers (model runners, etc.). @@ -125,6 +154,16 @@ func signalPID(pid int, force bool) error { return syscall.Kill(pid, sig) } +// killGroupRemnants force-kills whatever is left in the process group that +// configureSysProcAttr created for pid, which is the group's leader and so its +// id. It is called right after the leader has exited and been reaped; an empty +// group is the usual case and is not an error. +func killGroupRemnants(pid int) { + if pid > 1 { + _ = syscall.Kill(-pid, syscall.SIGKILL) + } +} + // pidAlive reports whether the PID still exists (signal 0 probes existence // without delivering a signal; EPERM means it exists but isn't ours). func pidAlive(pid int) bool { diff --git a/services/nvpair-engine-manager/proc_windows.go b/services/nvpair-engine-manager/proc_windows.go index 77fd21811..e9d6ac09a 100644 --- a/services/nvpair-engine-manager/proc_windows.go +++ b/services/nvpair-engine-manager/proc_windows.go @@ -54,6 +54,14 @@ func taskkill(cmd *exec.Cmd, force bool) error { return kc.Run() } +// procRunsScript is unused on Windows, where the engines are real executables +// and procImage identifies them. +func procRunsScript(int, string) bool { return false } + +// killGroupRemnants has nothing to do on Windows: every stop is taskkill /T, +// which already takes the whole process tree. +func killGroupRemnants(int) {} + var ( iphlpapi = windows.NewLazySystemDLL("iphlpapi.dll") procGetExtendedTcpTable = iphlpapi.NewProc("GetExtendedTcpTable") diff --git a/services/nvpair-engine-manager/spec.md b/services/nvpair-engine-manager/spec.md index c18f172f3..50b2d9a04 100644 --- a/services/nvpair-engine-manager/spec.md +++ b/services/nvpair-engine-manager/spec.md @@ -82,7 +82,7 @@ extensibility story for an open-source product. **Functional** - Load + validate per-engine JSON manifests (bundled + user dir); select the host `/` block; resolve placeholders (`{bin}`, `{cli}`, `{port}`, `{download}`, `{install_dir}`). Install commands also receive resolved download and destination paths in child-scoped `NVPAIR_INSTALL_*` environment variables so shell reparsing cannot corrupt them. - Support both `process` (owned foreground) and `command` (daemon + control-CLI) runtimes; execute detect / install / uninstall / start / stop / restart / status / health and HTTP **or** CLI actions; emit `engine:*` results and notifications. -- Bound process-mode stops: on Unix send SIGTERM to the owned process group, then SIGKILL after `runtime.stop.grace_s` (five seconds by default); `signal:"kill"` skips the grace. On Windows, windowless managed engines require immediate `taskkill /T /F`. +- Bound process-mode stops: on Unix send SIGTERM to the owned process group, then SIGKILL after `runtime.stop.grace_s` (five seconds by default, capped at three seconds while the service shuts down); the SIGKILL also takes any helper processes left in the group; `signal:"kill"` skips the grace. On Windows, windowless managed engines require immediate `taskkill /T /F`. - Emit `errors:report` / `errors:clear` on its stdio for the Broker to forward to `nvpair-errors`. **Non-functional** diff --git a/services/nvpair-engine-manager/status.go b/services/nvpair-engine-manager/status.go index 322e02903..9935c930a 100644 --- a/services/nvpair-engine-manager/status.go +++ b/services/nvpair-engine-manager/status.go @@ -67,7 +67,14 @@ func (e *Executor) StatusAtPort(engine string, probePort int) (EngineStatus, err } return EngineStatus{}, err } - st.opMu.Lock() + // A start, stop, install or model switch holds opMu for as long as it takes, + // and an engine like vLLM takes a minute or more to become ready. A status + // read must not wait that out: callers give it seconds, and the desktop + // treats it as a fast read. While an operation runs, what is already known + // about the engine is the answer, and the operation reports its own result. + if !st.opMu.TryLock() { + return e.snapshot(engine, st), nil + } defer st.opMu.Unlock() pathInstalled, _ := e.Detect(engine) st.mu.Lock() @@ -95,7 +102,12 @@ func (e *Executor) GetInstalled() []EngineStatus { out = append(out, EngineStatus{Engine: name, DisplayName: dn}) continue } - st.opMu.Lock() + // Same reasoning as StatusAtPort: one engine mid-start must not hold up + // the listing of every other engine. + if !st.opMu.TryLock() { + out = append(out, e.snapshot(name, st)) + continue + } pathInstalled, _ := e.Detect(name) st.mu.Lock() port := st.port From 41be635e969c38c17dd1c947bb4c98aeb61a6ecb Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 28 Oct 2026 22:17:01 +0530 Subject: [PATCH 02/35] feat(nvpair-engine-manager): list Hugging Face cache models with hf_cache Add an hf_cache manifest action that lists the models in a Hugging Face hub cache directory. It reads the disk, so it works while the engine is stopped, which lets an engine that serves one model per process (vLLM) show every model it could be switched to. A repository is listed only when its snapshot is a complete, text-model-shaped download: vision-only and GGUF-only repositories, interrupted downloads and links whose blob is gone are left out. Signed-off-by: Tushar Khinvasara --- services/nvpair-engine-manager/MANIFEST.md | 16 +- services/nvpair-engine-manager/actions.go | 3 + services/nvpair-engine-manager/hfcache.go | 219 ++++++++++++++++++ .../nvpair-engine-manager/hfcache_test.go | 182 +++++++++++++++ services/nvpair-engine-manager/registry.go | 23 +- .../nvpair-engine-manager/registry_test.go | 2 +- 6 files changed, 441 insertions(+), 4 deletions(-) create mode 100644 services/nvpair-engine-manager/hfcache.go create mode 100644 services/nvpair-engine-manager/hfcache_test.go diff --git a/services/nvpair-engine-manager/MANIFEST.md b/services/nvpair-engine-manager/MANIFEST.md index 5c9b2d3b0..8f22b0243 100644 --- a/services/nvpair-engine-manager/MANIFEST.md +++ b/services/nvpair-engine-manager/MANIFEST.md @@ -238,7 +238,7 @@ probes. ### Actions Each action is a config-declared operation exposed over `engine:action`. -Exactly one of `http`, `cmd`, or `remove_path`: +Exactly one of `http`, `cmd`, `remove_path`, or `hf_cache`: - **`http`** — call the engine's loopback control API. The caller's `params` are sent as the JSON request body by default. Set @@ -254,6 +254,17 @@ Exactly one of `http`, `cmd`, or `remove_path`: action `params` as placeholders); the resolved target must stay under the declared root. Symlink escapes and missing targets are rejected. Does **not** require the engine to be running. +- **`hf_cache`** — list the models in a Hugging Face hub cache directory. + `hf_cache.dir` is templated, normally `"{models_dir}/hub"`. The reply is + `{"data":[{"id","size"}]}`, so `result: {"array": "data", "field": "id"}` + turns it into a model list. A repository is listed when its snapshot is a + complete download holding a config, a tokenizer and weights: vision-only and + GGUF-only repositories, interrupted downloads (`.incomplete` blobs, shards + missing from the index or from a `-0000N-of-0000M` set, several unnumbered + weight files with no index) and links whose blob is gone are left out. This is + a check on file layout, not on whether the engine supports the architecture. + A missing directory is an empty cache. Does **not** require the engine to be + running. An optional **`result`** declares how `engine:models` extracts a normalized name list from the action's JSON response: `array` is the top-level array @@ -398,7 +409,8 @@ path, an `uninstall` block sets neither `run` nor `remove`, `uninstall.remove` is present without a `models_dir` to preserve, an `uninstall.remove` target is the model store or inside it (whether spelled literally or as `{models_dir}`), an action sets none or more than one of -`http`/`cmd`/`remove_path`, a `remove_path` action omits `root` or +`http`/`cmd`/`remove_path`/`hf_cache`, an `hf_cache` action omits +`dir`, a `remove_path` action omits `root` or `path`, `http.params_in` is not `body` or `query`, a `result` is set without both `array` and `field` (or a `result.match` without both `match.field` and a non-empty `match.in`), or diff --git a/services/nvpair-engine-manager/actions.go b/services/nvpair-engine-manager/actions.go index d01d8e522..7255ef4c8 100644 --- a/services/nvpair-engine-manager/actions.go +++ b/services/nvpair-engine-manager/actions.go @@ -101,6 +101,9 @@ func (e *Executor) dispatchAction(ctx context.Context, st *engineState, engine, if act.RemovePath != nil { return e.runRemovePathAction(ctx, st, act, params) } + if act.HFCache != nil { + return e.runHFCacheAction(st, act) + } if len(act.Cmd) > 0 { return e.runCmdAction(ctx, st, act, port, params) } diff --git a/services/nvpair-engine-manager/hfcache.go b/services/nvpair-engine-manager/hfcache.go new file mode 100644 index 000000000..53790af93 --- /dev/null +++ b/services/nvpair-engine-manager/hfcache.go @@ -0,0 +1,219 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// hfCacheModel is one usable model found in a Hugging Face hub cache. Size is +// the bytes the snapshot occupies on disk. +type hfCacheModel struct { + ID string `json:"id"` + Size int64 `json:"size"` +} + +// hfCacheResult is the wire shape of an hf_cache action, chosen so the ordinary +// `result: {array: "data", field: "id"}` extractor turns it into a model list. +type hfCacheResult struct { + Data []hfCacheModel `json:"data"` +} + +var hfTokenizerFiles = []string{"tokenizer.json", "tokenizer_config.json", "tokenizer.model", "vocab.json"} + +// hfShardRe matches the shard names Hugging Face repos use when they carry no +// index file, such as model-00003-of-00004.safetensors. +var hfShardRe = regexp.MustCompile(`^.+-(\d+)-of-(\d+)\.(?:safetensors|bin)$`) + +// runHFCacheAction lists the models in act.HFCache.Dir. It needs no running +// engine, so the list is available while the engine is stopped. +func (e *Executor) runHFCacheAction(st *engineState, act Action) (json.RawMessage, error) { + dir, err := resolvePlaceholders(act.HFCache.Dir, st.pathVars()) + if err != nil { + return nil, err + } + models, err := scanHFCache(expandPath(dir)) + if err != nil { + return nil, err + } + return json.Marshal(hfCacheResult{Data: models}) +} + +// scanHFCache returns the models in a Hugging Face hub cache directory that +// look servable: a model repository whose latest download is complete and holds +// a config, a tokenizer and weights. Vision-only and GGUF-only repositories +// carry no tokenizer or no safetensors/pytorch weights, and interrupted +// downloads leave shards or .incomplete blobs behind, so all of those are left +// out. This is a heuristic on file layout, not a check that the engine +// supports the architecture. +// +// A missing directory is an empty cache, not an error. +func scanHFCache(dir string) ([]hfCacheModel, error) { + entries, err := os.ReadDir(dir) + if errors.Is(err, fs.ErrNotExist) { + return []hfCacheModel{}, nil + } + if err != nil { + return nil, fmt.Errorf("read model cache: %w", err) + } + models := []hfCacheModel{} + for _, entry := range entries { + name := entry.Name() + if !strings.HasPrefix(name, "models--") || !entry.IsDir() { + continue + } + // Hugging Face stores "org/name" as "org--name" and forbids "--" inside + // either part, so the mapping reverses cleanly. + id := strings.ReplaceAll(strings.TrimPrefix(name, "models--"), "--", "/") + if id == "" { + continue + } + if size, ok := completeHFModel(filepath.Join(dir, name)); ok { + models = append(models, hfCacheModel{ID: id, Size: size}) + } + } + sort.Slice(models, func(i, j int) bool { + return strings.ToLower(models[i].ID) < strings.ToLower(models[j].ID) + }) + return models, nil +} + +// completeHFModel reports whether any snapshot of the repository at repoPath is +// a complete, servable model, and the size of the first such snapshot. +func completeHFModel(repoPath string) (int64, bool) { + if blobs, err := os.ReadDir(filepath.Join(repoPath, "blobs")); err == nil { + for _, blob := range blobs { + if strings.HasSuffix(blob.Name(), ".incomplete") { + return 0, false + } + } + } + snapshots, err := os.ReadDir(filepath.Join(repoPath, "snapshots")) + if err != nil { + return 0, false + } + for _, snapshot := range snapshots { + if !snapshot.IsDir() { + continue + } + if size, ok := completeHFSnapshot(filepath.Join(repoPath, "snapshots", snapshot.Name())); ok { + return size, true + } + } + return 0, false +} + +func completeHFSnapshot(path string) (int64, bool) { + entries, err := os.ReadDir(path) + if err != nil { + return 0, false + } + // Files are symlinks into blobs/. Stat follows them, so a link whose blob is + // missing is not a file we have. + files := map[string]int64{} + for _, entry := range entries { + info, err := os.Stat(filepath.Join(path, entry.Name())) + if err != nil || info.IsDir() { + continue + } + files[entry.Name()] = info.Size() + } + if _, ok := files["config.json"]; !ok || !hasAny(files, hfTokenizerFiles) { + return 0, false + } + + var weights []string + for name := range files { + if strings.HasSuffix(name, ".safetensors") { + weights = append(weights, name) + } + } + if len(weights) == 0 { + // Repositories that ship both formats are counted once, by safetensors. + for name := range files { + if strings.HasPrefix(name, "pytorch_model") && strings.HasSuffix(name, ".bin") { + weights = append(weights, name) + } + } + } + if len(weights) == 0 || !hfWeightsComplete(path, files, weights) { + return 0, false + } + + var size int64 + for _, bytes := range files { + size += bytes + } + return size, true +} + +// hfWeightsComplete verifies the weight files are all present. An index file +// lists every shard; without one, only shard names like +// model-00003-of-00004.safetensors say how many there should be, and several +// unnumbered weight files cannot be told apart from an interrupted download. +func hfWeightsComplete(snapshot string, files map[string]int64, weights []string) bool { + if _, ok := files["model.safetensors.index.json"]; ok { + raw, err := os.ReadFile(filepath.Join(snapshot, "model.safetensors.index.json")) + if err != nil { + return false + } + var index struct { + WeightMap map[string]string `json:"weight_map"` + } + if json.Unmarshal(raw, &index) != nil || len(index.WeightMap) == 0 { + return false + } + for _, shard := range index.WeightMap { + if _, ok := files[shard]; !ok { + return false + } + } + return true + } + if len(weights) == 1 { + return true + } + seen := map[int]bool{} + total := 0 + for _, name := range weights { + match := hfShardRe.FindStringSubmatch(name) + if match == nil { + return false + } + n, _ := strconv.Atoi(match[1]) + count, _ := strconv.Atoi(match[2]) + if total != 0 && count != total { + return false + } + total = count + seen[n] = true + } + if total == 0 || len(seen) != total { + return false + } + for n := 1; n <= total; n++ { + if !seen[n] { + return false + } + } + return true +} + +func hasAny(files map[string]int64, names []string) bool { + for _, name := range names { + if _, ok := files[name]; ok { + return true + } + } + return false +} diff --git a/services/nvpair-engine-manager/hfcache_test.go b/services/nvpair-engine-manager/hfcache_test.go new file mode 100644 index 000000000..d6faca8af --- /dev/null +++ b/services/nvpair-engine-manager/hfcache_test.go @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +// writeHFRepo builds a repository the way the Hugging Face hub cache lays it +// out: content in blobs/, and snapshots// symlinks into it. A +// file content of "" is written empty; names ending in ".incomplete" go to +// blobs/ only, as an interrupted download leaves them. +func writeHFRepo(t *testing.T, cache, id string, files map[string]string) string { + t.Helper() + repo := filepath.Join(cache, "models--"+strings.ReplaceAll(id, "/", "--")) + blobs := filepath.Join(repo, "blobs") + snapshot := filepath.Join(repo, "snapshots", "abc123") + for _, dir := range []string{blobs, snapshot} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + for name, content := range files { + blob := filepath.Join(blobs, "blob-"+name) + if err := os.WriteFile(blob, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + if strings.HasSuffix(name, ".incomplete") { + continue + } + if err := os.Symlink(blob, filepath.Join(snapshot, name)); err != nil { + t.Fatal(err) + } + } + return repo +} + +func llmFiles(extra map[string]string) map[string]string { + files := map[string]string{"config.json": "{}", "tokenizer.json": "{}"} + for name, content := range extra { + files[name] = content + } + return files +} + +func scannedIDs(t *testing.T, cache string) []string { + t.Helper() + models, err := scanHFCache(cache) + if err != nil { + t.Fatal(err) + } + ids := []string{} + for _, model := range models { + ids = append(ids, model.ID) + } + return ids +} + +func TestScanHFCacheListsCompleteModelsOnly(t *testing.T) { + cache := t.TempDir() + + writeHFRepo(t, cache, "Qwen/Qwen2.5-0.5B-Instruct", llmFiles(map[string]string{"model.safetensors": "weights"})) + writeHFRepo(t, cache, "plain/no-org-model", llmFiles(map[string]string{"pytorch_model.bin": "weights"})) + // Both formats of the same weights count once, not as an unverifiable shard set. + writeHFRepo(t, cache, "sentence-transformers/all-MiniLM-L6-v2", llmFiles(map[string]string{ + "model.safetensors": "a", "pytorch_model.bin": "b", "tf_model.h5": "c", + })) + // Sharded, with an index naming every shard. + writeHFRepo(t, cache, "Qwen/Qwen3-8B", llmFiles(map[string]string{ + "model-00001-of-00002.safetensors": "a", + "model-00002-of-00002.safetensors": "b", + "model.safetensors.index.json": `{"weight_map":{"x":"model-00001-of-00002.safetensors","y":"model-00002-of-00002.safetensors"}}`, + })) + // Sharded, no index, but the numbered names prove all four are present. + writeHFRepo(t, cache, "Qwen/Qwen3-VL-8B-Instruct", llmFiles(map[string]string{ + "model-00001-of-00004.safetensors": "a", "model-00002-of-00004.safetensors": "b", + "model-00003-of-00004.safetensors": "c", "model-00004-of-00004.safetensors": "d", + })) + + // Left out: each is missing something a servable model has. + writeHFRepo(t, cache, "Qwen/partial-index", llmFiles(map[string]string{ + "model-00001-of-00002.safetensors": "a", + "model.safetensors.index.json": `{"weight_map":{"x":"model-00001-of-00002.safetensors","y":"model-00002-of-00002.safetensors"}}`, + })) + writeHFRepo(t, cache, "Qwen/partial-numbered", llmFiles(map[string]string{ + "model-00001-of-00004.safetensors": "a", "model-00002-of-00004.safetensors": "b", "model-00004-of-00004.safetensors": "d", + })) + writeHFRepo(t, cache, "Qwen/unnumbered-shards-no-index", llmFiles(map[string]string{ + "layers-0.safetensors": "a", "layers-1.safetensors": "b", + })) + writeHFRepo(t, cache, "vision/detr", map[string]string{"config.json": "{}", "model.safetensors": "w", "preprocessor_config.json": "{}"}) + writeHFRepo(t, cache, "bartowski/model-GGUF", map[string]string{"model-Q4_K_M.gguf": "w"}) + writeHFRepo(t, cache, "no/config", map[string]string{"tokenizer.json": "{}", "model.safetensors": "w"}) + writeHFRepo(t, cache, "no/weights", llmFiles(nil)) + writeHFRepo(t, cache, "downloading/now", llmFiles(map[string]string{ + "model.safetensors": "w", "blob.incomplete": "partial", + })) + + // A file whose blob is gone is not a file we have. + broken := writeHFRepo(t, cache, "broken/link", llmFiles(map[string]string{"model.safetensors": "w"})) + if err := os.Remove(filepath.Join(broken, "blobs", "blob-model.safetensors")); err != nil { + t.Fatal(err) + } + + // Not a model repository, and not a directory at all. + if err := os.MkdirAll(filepath.Join(cache, "datasets--org--data", "snapshots", "x"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cache, "models--stray-file"), nil, 0o644); err != nil { + t.Fatal(err) + } + + want := []string{ + "plain/no-org-model", + "Qwen/Qwen2.5-0.5B-Instruct", + "Qwen/Qwen3-8B", + "Qwen/Qwen3-VL-8B-Instruct", + "sentence-transformers/all-MiniLM-L6-v2", + } + // plain/no-org-model is a two-part id here; the order is case-insensitive. + if got := scannedIDs(t, cache); !slices.Equal(got, want) { + t.Fatalf("models = %v, want %v", got, want) + } +} + +func TestScanHFCacheReportsSnapshotSize(t *testing.T) { + cache := t.TempDir() + writeHFRepo(t, cache, "org/model", llmFiles(map[string]string{"model.safetensors": strings.Repeat("x", 1000)})) + models, err := scanHFCache(cache) + if err != nil || len(models) != 1 { + t.Fatalf("models = %v, err = %v", models, err) + } + if want := int64(1000 + len("{}") + len("{}")); models[0].Size != want { + t.Fatalf("size = %d, want %d", models[0].Size, want) + } +} + +func TestScanHFCacheMissingDirectoryIsAnEmptyCache(t *testing.T) { + models, err := scanHFCache(filepath.Join(t.TempDir(), "never-created")) + if err != nil || models == nil || len(models) != 0 { + t.Fatalf("models = %#v, err = %v, want an empty non-nil list", models, err) + } +} + +// The wire shape has to be readable by the ordinary list extractor, so that +// list_models for such an engine needs no per-engine code. +func TestHFCacheActionResultFeedsTheModelListExtractor(t *testing.T) { + cache := t.TempDir() + writeHFRepo(t, cache, "Qwen/Qwen3-8B", llmFiles(map[string]string{"model.safetensors": "w"})) + + e := &Executor{} + raw, err := e.runHFCacheAction(&engineState{}, Action{HFCache: &ActionHFCache{Dir: cache}}) + if err != nil { + t.Fatal(err) + } + ids, ok := extractStringsResult(raw, &ActionResult{Array: "data", Field: "id"}) + if !ok || !slices.Equal(ids, []string{"Qwen/Qwen3-8B"}) { + t.Fatalf("extracted %v, %v from %s", ids, ok, raw) + } + var decoded hfCacheResult + if err := json.Unmarshal(raw, &decoded); err != nil || decoded.Data[0].Size == 0 { + t.Fatalf("size missing from %s: %v", raw, err) + } +} + +func TestHFCacheActionValidation(t *testing.T) { + if err := (&Action{HFCache: &ActionHFCache{Dir: " "}}).validate("list_models"); err == nil { + t.Error("an hf_cache action with no dir was accepted") + } + if err := (&Action{HFCache: &ActionHFCache{Dir: "{models_dir}/hub"}, Cmd: []string{"x"}}).validate("list_models"); err == nil { + t.Error("an action with two kinds was accepted") + } + if err := (&Action{HFCache: &ActionHFCache{Dir: "{models_dir}/hub"}}).validate("list_models"); err != nil { + t.Errorf("a valid hf_cache action was rejected: %v", err) + } +} diff --git a/services/nvpair-engine-manager/registry.go b/services/nvpair-engine-manager/registry.go index 3b3278871..2896a9c0e 100644 --- a/services/nvpair-engine-manager/registry.go +++ b/services/nvpair-engine-manager/registry.go @@ -257,6 +257,10 @@ type Action struct { HTTP *ActionHTTP `json:"http,omitempty"` Cmd []string `json:"cmd,omitempty"` RemovePath *ActionRemovePath `json:"remove_path,omitempty"` + // HFCache lists the models in a Hugging Face hub cache directory. It reads + // the disk and needs no running engine, so an engine that serves one model + // per process can still show everything it could be switched to. + HFCache *ActionHFCache `json:"hf_cache,omitempty"` // ProgressProtocol selects a narrowly defined streaming adapter for an // asynchronous pull HTTP action. Empty keeps the ordinary response-stream // behavior; named protocols are validated so a typo cannot silently fall @@ -314,6 +318,13 @@ type ActionRemovePath struct { Root string `json:"root"` } +// ActionHFCache names the Hugging Face hub cache directory to list. Dir is +// templated, normally "{models_dir}/hub". The result is {"data":[{"id","size"}]}, +// which the ordinary result extractor reads with array "data" and field "id". +type ActionHFCache struct { + Dir string `json:"dir"` +} + // ActionHTTP is a templated call against the engine's loopback base URL. // ParamsIn selects whether the caller's params are sent as the JSON request // body (the default) or as URL-encoded query parameters. BodySchema is @@ -883,6 +894,7 @@ func (a *Action) validate(name string) error { hasHTTP := a.HTTP != nil hasCmd := len(a.Cmd) > 0 hasRemovePath := a.RemovePath != nil + hasHFCache := a.HFCache != nil kinds := 0 if hasHTTP { kinds++ @@ -893,8 +905,14 @@ func (a *Action) validate(name string) error { if hasRemovePath { kinds++ } + if hasHFCache { + kinds++ + } if kinds != 1 { - return fmt.Errorf("action %q: exactly one of http, cmd, or remove_path is required", name) + return fmt.Errorf("action %q: exactly one of http, cmd, remove_path, or hf_cache is required", name) + } + if hasHFCache && strings.TrimSpace(a.HFCache.Dir) == "" { + return fmt.Errorf("action %q: hf_cache.dir is required", name) } if hasRemovePath { if strings.TrimSpace(a.RemovePath.Path) == "" || strings.TrimSpace(a.RemovePath.Root) == "" { @@ -1031,6 +1049,9 @@ func (m *Manifest) actionTemplatedStrings() []string { out = append(out, act.RemovePath.Root) // Path may template caller params (e.g. {model}); validated at call time. } + if act.HFCache != nil { + out = append(out, act.HFCache.Dir) + } } // Action http.path / cmd are resolved from runtime params (e.g. // {model}), so they're validated at call time, not statically here. diff --git a/services/nvpair-engine-manager/registry_test.go b/services/nvpair-engine-manager/registry_test.go index fedd89851..157af810d 100644 --- a/services/nvpair-engine-manager/registry_test.go +++ b/services/nvpair-engine-manager/registry_test.go @@ -317,7 +317,7 @@ func TestValidate_RejectsInvalidManifests(t *testing.T) { }, "json_match.value is required"}, {"action without http or cmd", func(m *Manifest) { m.Actions = map[string]Action{"x": {Description: "neither"}} - }, "exactly one of http, cmd, or remove_path"}, + }, "exactly one of http, cmd, remove_path, or hf_cache"}, {"action missing method", func(m *Manifest) { m.Actions = map[string]Action{"x": {HTTP: &ActionHTTP{Path: "/p"}}} }, "http.method and http.path"}, From 16718a83196fefae9fc50ce152616c01f3a97f1f Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 28 Oct 2026 22:18:42 +0530 Subject: [PATCH 03/35] feat(nvpair-engine-manager): switch an engine's model with launch_model Add a launch_model manifest action for an engine that has no load call because the model is a launch argument (vLLM). It takes {"model": ""}, writes the model into the saved launch settings under the declared flag, and restarts a running engine on it. If the new model does not come up, the previous one is restored and the error says so. A stopped engine only has its saved launch changed, and asking for the model already in the launch arguments restarts nothing. The action requires editable launch settings and a readiness probe on every platform, and refuses model ids that could be read as a flag, a path or a shell fragment. Signed-off-by: Tushar Khinvasara --- services/nvpair-engine-manager/MANIFEST.md | 17 +- services/nvpair-engine-manager/actions.go | 5 + services/nvpair-engine-manager/launchmodel.go | 133 ++++++++++ .../nvpair-engine-manager/launchmodel_test.go | 233 ++++++++++++++++++ services/nvpair-engine-manager/registry.go | 35 ++- .../nvpair-engine-manager/registry_test.go | 2 +- 6 files changed, 420 insertions(+), 5 deletions(-) create mode 100644 services/nvpair-engine-manager/launchmodel.go create mode 100644 services/nvpair-engine-manager/launchmodel_test.go diff --git a/services/nvpair-engine-manager/MANIFEST.md b/services/nvpair-engine-manager/MANIFEST.md index 8f22b0243..f09a3dffb 100644 --- a/services/nvpair-engine-manager/MANIFEST.md +++ b/services/nvpair-engine-manager/MANIFEST.md @@ -238,7 +238,7 @@ probes. ### Actions Each action is a config-declared operation exposed over `engine:action`. -Exactly one of `http`, `cmd`, `remove_path`, or `hf_cache`: +Exactly one of `http`, `cmd`, `remove_path`, `hf_cache`, or `launch_model`: - **`http`** — call the engine's loopback control API. The caller's `params` are sent as the JSON request body by default. Set @@ -265,6 +265,16 @@ Exactly one of `http`, `cmd`, `remove_path`, or `hf_cache`: a check on file layout, not on whether the engine supports the architecture. A missing directory is an empty cache. Does **not** require the engine to be running. +- **`launch_model`** — switch the model an engine serves, for an engine with no + load call because the model is a launch argument (vLLM). `launch_model.flag` + names the flag (`"--model"`). The action takes `{"model": ""}` (a + Hugging Face id such as `org/name`; anything that could be read as a flag, a + path or a shell fragment is refused), writes it into the saved launch + settings exactly as the settings editor would, and restarts a running engine + on it. If the new model does not come up, the previous one is restored and the + error says so; a stopped engine only has its saved launch changed, and asking + for the model already in the launch arguments restarts nothing. Requires + `runtime.editable_launch` and `runtime.ready` on every platform. An optional **`result`** declares how `engine:models` extracts a normalized name list from the action's JSON response: `array` is the top-level array @@ -409,8 +419,9 @@ path, an `uninstall` block sets neither `run` nor `remove`, `uninstall.remove` is present without a `models_dir` to preserve, an `uninstall.remove` target is the model store or inside it (whether spelled literally or as `{models_dir}`), an action sets none or more than one of -`http`/`cmd`/`remove_path`/`hf_cache`, an `hf_cache` action omits -`dir`, a `remove_path` action omits `root` or +`http`/`cmd`/`remove_path`/`hf_cache`/`launch_model`, an `hf_cache` action omits +`dir`, a `launch_model` flag is not a flag or a platform lacks +`editable_launch` or `ready`, a `remove_path` action omits `root` or `path`, `http.params_in` is not `body` or `query`, a `result` is set without both `array` and `field` (or a `result.match` without both `match.field` and a non-empty `match.in`), or diff --git a/services/nvpair-engine-manager/actions.go b/services/nvpair-engine-manager/actions.go index 7255ef4c8..29bad6cd6 100644 --- a/services/nvpair-engine-manager/actions.go +++ b/services/nvpair-engine-manager/actions.go @@ -31,6 +31,11 @@ func (e *Executor) Action(ctx context.Context, engine, action string, params jso if !ok { return nil, fmt.Errorf("engine %q has no action %q", engine, action) } + if act.LaunchModel != nil { + // Like restart_after, the restart gets the caller's context: it is a stop + // plus a readiness-probed start that the action timeout does not budget for. + return e.switchLaunchModel(ctx, engine, act.LaunchModel, params) + } res, err := e.dispatchAction(ctx, st, engine, action, act, params) if err != nil { return nil, err diff --git a/services/nvpair-engine-manager/launchmodel.go b/services/nvpair-engine-manager/launchmodel.go new file mode 100644 index 000000000..fe67c252f --- /dev/null +++ b/services/nvpair-engine-manager/launchmodel.go @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "slices" + "strings" + + settings "nvpair-shared/enginesettings" +) + +// hfModelIDRe accepts a Hugging Face repository id, "name" or "org/name". It +// refuses anything that could be read as a flag, a path or a shell fragment, +// since the value becomes an engine argument. +var hfModelIDRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,95}(/[A-Za-z0-9][A-Za-z0-9._-]{0,95})?$`) + +// switchLaunchModel is the launch_model action. An engine that serves one model +// per process has no load call: the model it serves is a launch argument, so +// "loading" a model means writing that argument into the saved launch settings +// and restarting the engine on it. This is the same path the settings editor +// takes, so the saved launch text always shows what the engine is running. +// +// A running engine is restarted, and if the new model does not come up (it does +// not fit in GPU memory, or the id does not exist) the previous model is +// restored, so a failed switch does not leave the engine down. A stopped engine +// only has its saved launch text changed. +func (e *Executor) switchLaunchModel(ctx context.Context, engine string, spec *ActionLaunchModel, params json.RawMessage) (json.RawMessage, error) { + var request struct { + Model string `json:"model"` + } + if err := json.Unmarshal(params, &request); err != nil { + return nil, errors.New(`launch_model params must be {"model": ""}`) + } + model := strings.TrimSpace(request.Model) + if !hfModelIDRe.MatchString(model) { + return nil, fmt.Errorf("%q is not a valid model id (want a Hugging Face id such as org/name)", request.Model) + } + + current, err := e.LaunchSettings(engine) + if err != nil { + return nil, err + } + if !current.Editable { + return nil, fmt.Errorf("the launch settings of engine %q cannot be changed: %s", engine, current.Reason) + } + text, changed, err := withLaunchFlag(current.LaunchText, spec.Flag, model) + if err != nil { + return nil, err + } + result := func(restarted bool) (json.RawMessage, error) { + return json.Marshal(struct { + Model string `json:"model"` + Restarted bool `json:"restarted"` + }{model, restarted}) + } + if !changed { + return result(false) + } + + apply := func(launchText string, resume bool) error { + _, err := e.ConfigureLaunch(ctx, settings.Configure{ + Engine: engine, + Settings: settings.Config{ServerPort: current.ServerPort, LaunchText: launchText}, + Resume: resume, + }, func() error { return nil }) + return err + } + if err := apply(text, false); err != nil { + if !current.Running { + return nil, err + } + // The engine's own error carries a long log tail, so say what happened first. + if restoreErr := apply(current.LaunchText, true); restoreErr != nil { + return nil, fmt.Errorf("switching to %s failed and restoring the previous model also failed (%v): %w", model, restoreErr, err) + } + return nil, fmt.Errorf("switching to %s failed; the previous model was restored: %w", model, err) + } + return result(current.Running) +} + +// withLaunchFlag returns launch text with flag set to value. Every existing +// "flag value" or "flag=value" collapses into one "flag value" at the position +// of the first, and every other argument is kept in place; when the flag is +// absent it is added before any "--" terminator. changed is false when the +// arguments already say the same thing, whatever their quoting: the launch text +// shown to a user is quoted canonically, so comparing text would call an +// unchanged launch changed. +func withLaunchFlag(text, flag, value string) (result string, changed bool, err error) { + tokens, err := parseLaunchText(text) + if err != nil { + return "", false, err + } + out := make([]string, 0, len(tokens)+2) + found := false + for i := 0; i < len(tokens); i++ { + token := tokens[i] + switch { + case token == "--": + if !found { + out = append(out, flag, value) + found = true + } + out = append(out, tokens[i:]...) + i = len(tokens) + case token == flag: + if !found { + out = append(out, flag, value) + found = true + } + if i+1 < len(tokens) { + i++ // the old value + } + case strings.HasPrefix(token, flag+"="): + if !found { + out = append(out, flag, value) + found = true + } + default: + out = append(out, token) + } + } + if !found { + out = append(out, flag, value) + } + result, err = formatLaunchText(out) + return result, !slices.Equal(tokens, out), err +} diff --git a/services/nvpair-engine-manager/launchmodel_test.go b/services/nvpair-engine-manager/launchmodel_test.go new file mode 100644 index 000000000..3751c9f28 --- /dev/null +++ b/services/nvpair-engine-manager/launchmodel_test.go @@ -0,0 +1,233 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "encoding/json" + "strings" + "testing" + + settings "nvpair-shared/enginesettings" +) + +func TestWithLaunchFlag(t *testing.T) { + for _, tc := range []struct { + name, text, want string + same bool // the arguments already said this + }{ + {"added when absent", "--max-model-len 4096", "--max-model-len 4096 --model org/new", false}, + {"added to empty text", "", "--model org/new", false}, + {"replaces a separate value", "--model org/old --max-model-len 4096", "--model org/new --max-model-len 4096", false}, + {"replaces an equals form", "--max-model-len 4096 --model=org/old --dtype auto", "--max-model-len 4096 --model org/new --dtype auto", false}, + {"keeps leading environment assignments", "VLLM_LOGGING_LEVEL=DEBUG --model org/old", "VLLM_LOGGING_LEVEL=DEBUG --model org/new", false}, + {"collapses a repeated flag", "--model a --x 1 --model=b", "--model org/new --x 1", false}, + {"goes before a terminator", "--x 1 -- positional", "--x 1 --model org/new -- positional", false}, + {"keeps quoted values", `--served-model-name "two words" --model org/old`, `--served-model-name "two words" --model org/new`, false}, + {"leaves a prefix-sharing flag alone", "--model-impl vllm --model org/old", "--model-impl vllm --model org/new", false}, + {"unchanged when already set", "--x 1 --model org/new", "--x 1 --model org/new", true}, + {"unchanged despite different quoting", `OLLAMA_HOST="127.0.0.1:1" --model org/new`, "OLLAMA_HOST=127.0.0.1:1 --model org/new", true}, + } { + t.Run(tc.name, func(t *testing.T) { + got, changed, err := withLaunchFlag(tc.text, "--model", "org/new") + if err != nil { + t.Fatal(err) + } + if got != tc.want || changed != !tc.same { + t.Fatalf("withLaunchFlag(%q) = %q (changed %v), want %q (changed %v)", tc.text, got, changed, tc.want, !tc.same) + } + }) + } + if _, _, err := withLaunchFlag(`--model "unterminated`, "--model", "x"); err == nil { + t.Error("launch text that does not parse was accepted") + } +} + +// switchModelExecutor is a running fake engine with editable launch settings +// and a launch_model action. The fake ignores arguments it does not know, so +// the "model" flag is carried in the saved launch text without changing how it +// serves. +func switchModelExecutor(t *testing.T, flag string) *Executor { + t.Helper() + e := settingsExecutor(t, false) + m, ok := e.reg.Get("fake") + if !ok { + t.Fatal("fake manifest missing") + } + m.Actions["load_model"] = Action{LaunchModel: &ActionLaunchModel{Flag: flag}} + if err := m.Validate(); err != nil { + t.Fatal(err) + } + if err := e.Start(context.Background(), "fake"); err != nil { + t.Fatalf("start: %v", err) + } + return e +} + +func loadModel(t *testing.T, e *Executor, model string) (json.RawMessage, error) { + t.Helper() + return e.Action(context.Background(), "fake", "load_model", json.RawMessage(`{"model":`+quoteJSON(model)+`}`)) +} + +func quoteJSON(s string) string { b, _ := json.Marshal(s); return string(b) } + +func launchTextOf(t *testing.T, e *Executor) string { + t.Helper() + state, err := e.LaunchSettings("fake") + if err != nil { + t.Fatal(err) + } + return state.LaunchText +} + +func TestLaunchModelSwitchesAndRestartsARunningEngine(t *testing.T) { + e := switchModelExecutor(t, "--model") + gen := startGeneration(t, e) + + raw, err := loadModel(t, e, "org/first") + if err != nil { + t.Fatalf("load first: %v", err) + } + var reply struct { + Model string `json:"model"` + Restarted bool `json:"restarted"` + } + if err := json.Unmarshal(raw, &reply); err != nil || reply.Model != "org/first" || !reply.Restarted { + t.Fatalf("reply = %s, %v", raw, err) + } + if text := launchTextOf(t, e); !strings.Contains(text, "--model org/first") { + t.Fatalf("launch text %q does not carry the model", text) + } + after := mustStatus(t, e) + if !after.Running || startGeneration(t, e) == gen { + t.Fatalf("engine was not restarted: generation %d, status %+v", gen, after) + } + + // A second switch replaces the first and keeps the rest of the launch text. + if _, err := e.ConfigureLaunch(context.Background(), settings.Configure{Engine: "fake", Settings: settings.Config{ + ServerPort: after.Port, LaunchText: launchTextOf(t, e) + " --max-model-len 4096", + }}, func() error { return nil }); err != nil { + t.Fatal(err) + } + if _, err := loadModel(t, e, "org/second"); err != nil { + t.Fatalf("load second: %v", err) + } + text := launchTextOf(t, e) + if !strings.Contains(text, "--model org/second") || strings.Contains(text, "org/first") || !strings.Contains(text, "--max-model-len 4096") { + t.Fatalf("launch text = %q", text) + } + + // Asking for the model already being served does not restart anything. + gen = startGeneration(t, e) + raw, err = loadModel(t, e, "org/second") + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &reply); err != nil || reply.Restarted || startGeneration(t, e) != gen { + t.Fatalf("a no-op switch restarted the engine: %s", raw) + } +} + +// startGeneration counts how many times the engine has been started. +func startGeneration(t *testing.T, e *Executor) int64 { + t.Helper() + st := settingsState(t, e) + st.mu.Lock() + defer st.mu.Unlock() + return st.gen +} + +func mustStatus(t *testing.T, e *Executor) EngineStatus { + t.Helper() + st, err := e.Status("fake") + if err != nil { + t.Fatal(err) + } + return st +} + +func TestLaunchModelOnAStoppedEngineOnlyChangesTheSavedLaunch(t *testing.T) { + e := switchModelExecutor(t, "--model") + if err := e.Stop("fake"); err != nil { + t.Fatal(err) + } + raw, err := loadModel(t, e, "org/later") + if err != nil { + t.Fatalf("load on a stopped engine: %v", err) + } + if !strings.Contains(string(raw), `"restarted":false`) { + t.Fatalf("reply = %s", raw) + } + if mustStatus(t, e).Running { + t.Fatal("switching the model started a stopped engine") + } + if text := launchTextOf(t, e); !strings.Contains(text, "--model org/later") { + t.Fatalf("launch text = %q", text) + } +} + +func TestLaunchModelRefusesAnythingThatIsNotAModelID(t *testing.T) { + e := switchModelExecutor(t, "--model") + before := launchTextOf(t, e) + for _, model := range []string{"", " ", "--help", "-x", "org/name --extra", "a b", "../etc/passwd", "/abs/path", "org//name", "a/b/c", "x;y", "$(id)", "org/na\nme"} { + if _, err := loadModel(t, e, model); err == nil { + t.Errorf("model %q was accepted", model) + } + } + if _, err := e.Action(context.Background(), "fake", "load_model", json.RawMessage(`{"nope":1}`)); err == nil { + t.Error("params without a model were accepted") + } + if _, err := e.Action(context.Background(), "fake", "load_model", nil); err == nil { + t.Error("missing params were accepted") + } + if got := launchTextOf(t, e); got != before || !mustStatus(t, e).Running { + t.Fatalf("a refused switch changed state: %q (was %q)", got, before) + } + for _, model := range []string{"gpt2", "Qwen/Qwen2.5-0.5B-Instruct", "org/name.v1_2-x"} { + if _, err := loadModel(t, e, model); err != nil { + t.Errorf("model %q was refused: %v", model, err) + } + } +} + +// If the new model does not come up, the engine goes back to the one it was +// serving instead of staying down. The fake engine exits at once when it is +// given "--fail-launch", which here stands in for a model that fails to load. +func TestLaunchModelRestoresThePreviousModelWhenTheNewOneFails(t *testing.T) { + e := switchModelExecutor(t, "--fail-launch") + before := launchTextOf(t, e) + + _, err := loadModel(t, e, "org/too-big") + if err == nil { + t.Fatal("a model that failed to start was reported as loaded") + } + // The note comes first: the engine's own error that follows it is a long log tail. + if !strings.HasPrefix(err.Error(), "switching to org/too-big failed; the previous model was restored: ") { + t.Fatalf("error does not lead with the restore note: %v", err) + } + if got := launchTextOf(t, e); got != before { + t.Fatalf("launch text = %q, want the previous %q", got, before) + } + if !mustStatus(t, e).Running { + t.Fatal("the engine was left down after a failed switch") + } +} + +func TestLaunchModelValidation(t *testing.T) { + for _, flag := range []string{"model", "--mod el", "--model=x", ""} { + if err := (&Action{LaunchModel: &ActionLaunchModel{Flag: flag}}).validate("load_model"); err == nil { + t.Errorf("launch_model flag %q was accepted", flag) + } + } + if err := (&Action{LaunchModel: &ActionLaunchModel{Flag: "--model"}}).validate("load_model"); err != nil { + t.Errorf("a valid launch_model was rejected: %v", err) + } + + // The restart needs editable launch settings and a readiness probe on every platform. + m := testEngineManifest(fakeEngineBin) + m.Actions["load_model"] = Action{LaunchModel: &ActionLaunchModel{Flag: "--model"}} + if err := m.Validate(); err == nil || !strings.Contains(err.Error(), "editable_launch") { + t.Errorf("launch_model without editable_launch: %v", err) + } +} diff --git a/services/nvpair-engine-manager/registry.go b/services/nvpair-engine-manager/registry.go index 2896a9c0e..5c2071105 100644 --- a/services/nvpair-engine-manager/registry.go +++ b/services/nvpair-engine-manager/registry.go @@ -261,6 +261,9 @@ type Action struct { // the disk and needs no running engine, so an engine that serves one model // per process can still show everything it could be switched to. HFCache *ActionHFCache `json:"hf_cache,omitempty"` + // LaunchModel makes the action switch the model an engine is serving, for an + // engine that has no load call because the model is a launch argument. + LaunchModel *ActionLaunchModel `json:"launch_model,omitempty"` // ProgressProtocol selects a narrowly defined streaming adapter for an // asynchronous pull HTTP action. Empty keeps the ordinary response-stream // behavior; named protocols are validated so a typo cannot silently fall @@ -325,6 +328,13 @@ type ActionHFCache struct { Dir string `json:"dir"` } +// ActionLaunchModel names the launch flag that selects the engine's model, such +// as "--model". The action takes {"model": ""}, writes it into the saved +// launch settings and restarts a running engine on it. +type ActionLaunchModel struct { + Flag string `json:"flag"` +} + // ActionHTTP is a templated call against the engine's loopback base URL. // ParamsIn selects whether the caller's params are sent as the JSON request // body (the default) or as URL-encoded query parameters. BodySchema is @@ -727,6 +737,19 @@ func (m *Manifest) Validate() error { // process is spawned, the reply races the engine's own startup, and the // caller can still be handed the pre-restart state. Fail at load rather // than ship a guarantee the manifest cannot keep. + // launch_model rewrites the saved launch settings and restarts the engine, + // so every platform needs editable launch settings and a readiness probe + // for the same reason as restart_after. + if a.LaunchModel != nil { + for key, p := range m.Platforms { + if p.Runtime.EditableLaunch == nil { + return fmt.Errorf("action %q: launch_model requires platform %q to declare runtime.editable_launch", name, key) + } + if p.Runtime.Ready == nil { + return fmt.Errorf("action %q: launch_model requires platform %q to declare runtime.ready", name, key) + } + } + } if a.RestartAfter { for key, p := range m.Platforms { if p.Runtime.Ready == nil { @@ -895,6 +918,7 @@ func (a *Action) validate(name string) error { hasCmd := len(a.Cmd) > 0 hasRemovePath := a.RemovePath != nil hasHFCache := a.HFCache != nil + hasLaunchModel := a.LaunchModel != nil kinds := 0 if hasHTTP { kinds++ @@ -908,8 +932,17 @@ func (a *Action) validate(name string) error { if hasHFCache { kinds++ } + if hasLaunchModel { + kinds++ + } if kinds != 1 { - return fmt.Errorf("action %q: exactly one of http, cmd, remove_path, or hf_cache is required", name) + return fmt.Errorf("action %q: exactly one of http, cmd, remove_path, hf_cache, or launch_model is required", name) + } + if hasLaunchModel { + flag := a.LaunchModel.Flag + if !strings.HasPrefix(flag, "-") || strings.ContainsAny(flag, " \t=") { + return fmt.Errorf("action %q: launch_model.flag %q must be a flag such as \"--model\"", name, flag) + } } if hasHFCache && strings.TrimSpace(a.HFCache.Dir) == "" { return fmt.Errorf("action %q: hf_cache.dir is required", name) diff --git a/services/nvpair-engine-manager/registry_test.go b/services/nvpair-engine-manager/registry_test.go index 157af810d..a2440d752 100644 --- a/services/nvpair-engine-manager/registry_test.go +++ b/services/nvpair-engine-manager/registry_test.go @@ -317,7 +317,7 @@ func TestValidate_RejectsInvalidManifests(t *testing.T) { }, "json_match.value is required"}, {"action without http or cmd", func(m *Manifest) { m.Actions = map[string]Action{"x": {Description: "neither"}} - }, "exactly one of http, cmd, remove_path, or hf_cache"}, + }, "exactly one of http, cmd, remove_path, hf_cache, or launch_model"}, {"action missing method", func(m *Manifest) { m.Actions = map[string]Action{"x": {HTTP: &ActionHTTP{Path: "/p"}}} }, "http.method and http.path"}, From 8dd501d5f1541561d8ebf3d2aa8036d915b0951a Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 28 Oct 2026 22:24:47 +0530 Subject: [PATCH 04/35] feat(services): add vLLM as an inference engine Register vllm alongside Ollama, LM Studio and llama.cpp: facade port 8000, engine port 8001, discovery key "vl". The proxy and broker treat it like any other managed engine, and the terminal interface gets its model wire. The engine-manager manifest (Linux amd64/arm64) is detect-first: an existing vllm is adopted, otherwise a sha256-pinned uv installs vllm 0.32.0 into a private venv after checking for an NVIDIA driver with CUDA 13+ and enough disk. vLLM serves one model per process, so its list_models is an hf_cache action, load_model is a launch_model action, and a new manifest field, advertised_models: loaded, advertises only the served model to peers and the proxy, since a request for any other cached model would fail at the engine. Signed-off-by: Tushar Khinvasara --- services/installer/linux/INSTALL.md | 6 +- services/installer/macos/INSTALL.md | 6 +- services/nvpair-engine-manager/MANIFEST.md | 13 ++- services/nvpair-engine-manager/README.md | 50 +++++++++++- .../nvpair-engine-manager/catalog_test.go | 8 +- .../launch_controls_test.go | 7 +- .../nvpair-engine-manager/manifests/vllm.json | 73 +++++++++++++++++ services/nvpair-engine-manager/models.go | 11 +++ services/nvpair-engine-manager/models_test.go | 79 ++++++++++++++++++- services/nvpair-engine-manager/registry.go | 21 +++++ services/nvpair-job-scheduler/spec.md | 10 +-- services/nvpair-proxy/README.md | 34 ++++---- services/nvpair-proxy/engines.go | 16 ++++ services/nvpair-proxy/engines_test.go | 14 ++++ services/nvpair-proxy/spec.md | 18 ++--- services/nvpair-proxy/twofacade_test.go | 2 +- services/nvpair-tui/ui/catalog_test.go | 4 +- services/nvpair-tui/ui/engineswire.go | 11 +++ services/nvpair-tui/ui/engineswire_test.go | 4 +- services/nvpair-tui/ui/nodedetail_test.go | 2 +- services/nvpair-tui/ui/proxystatus_test.go | 2 +- services/nvpair-ui-broker/ENGINE_SETTINGS.md | 2 +- services/nvpair-ui-broker/README.md | 25 +++--- services/nvpair-ui-broker/engineproxy.go | 1 + services/nvpair-ui-broker/engineproxy_test.go | 4 +- .../nvpair-ui-broker/enginesettings_test.go | 3 +- .../nvpair-ui-broker/proxyaddressing_test.go | 2 +- services/readme.md | 16 ++-- services/shared/engines/engines.go | 9 +++ services/shared/engines/engines_test.go | 4 +- services/shared/noderec/noderec.go | 5 +- 31 files changed, 384 insertions(+), 78 deletions(-) create mode 100644 services/nvpair-engine-manager/manifests/vllm.json diff --git a/services/installer/linux/INSTALL.md b/services/installer/linux/INSTALL.md index fad347ca5..171fa49fe 100644 --- a/services/installer/linux/INSTALL.md +++ b/services/installer/linux/INSTALL.md @@ -66,7 +66,7 @@ The workers store configuration in `~/.config/Nvidia Corporation/Personal AI Rou that directory too if you want a fully clean slate. Downloaded models are not in either directory. Each engine keeps them in its own -store — `~/.ollama`, `~/.lmstudio/models`, and `~/.llamacpp` — so deleting the +store — `~/.ollama`, `~/.lmstudio/models`, `~/.llamacpp`, and `~/.cache/huggingface` (vLLM) — so deleting the bundle or the configuration directory leaves them in place. ## Requirements @@ -84,9 +84,9 @@ bundle or the configuration directory leaves them in place. that other machines on the LAN are actually advertising. Run the broker with `--log-level debug` to see live discovery logs on stderr. - **"Bind: address already in use"**: the proxies listen on `11434` (Ollama), - `1234` (LM Studio), and `8080` (llama.cpp); another instance of the proxy or + `1234` (LM Studio), `8080` (llama.cpp), and `8000` (vLLM); another instance of the proxy or another process is holding one of them. Find the offender with - `ss -tlnp | grep -E ':(11434|1234|8080)\b'` and stop or kill it. + `ss -tlnp | grep -E ':(11434|1234|8080|8000)\b'` and stop or kill it. Report issues at the project's tracker. Include the broker's `--log-level debug` output and your distro / kernel version. diff --git a/services/installer/macos/INSTALL.md b/services/installer/macos/INSTALL.md index 27357ab98..83d12c06a 100644 --- a/services/installer/macos/INSTALL.md +++ b/services/installer/macos/INSTALL.md @@ -52,7 +52,7 @@ rm -rf "$HOME/Library/Application Support/Nvidia Corporation/Personal AI Router" ``` Downloaded models are not in either directory. Each engine keeps them in its own -store — `~/.ollama`, `~/.lmstudio/models`, and `~/.llamacpp` — so deleting the +store — `~/.ollama`, `~/.lmstudio/models`, `~/.llamacpp`, and `~/.cache/huggingface` (vLLM) — so deleting the bundle or the configuration directory leaves them in place. ## Requirements @@ -81,9 +81,9 @@ xattr -dr com.apple.quarantine /path/to/NVIDIA-Personal-AI-Router- advertising. Run the broker with `--log-level debug` to see live discovery logs on stderr. - **"Bind: address already in use"**: the proxies listen on `11434` (Ollama), - `1234` (LM Studio), and `8080` (llama.cpp); another instance of the proxy or + `1234` (LM Studio), `8080` (llama.cpp), and `8000` (vLLM); another instance of the proxy or another process is holding one of them. Find the offender with - `lsof -nP -iTCP:11434 -iTCP:1234 -iTCP:8080 -sTCP:LISTEN` and stop it. + `lsof -nP -iTCP:11434 -iTCP:1234 -iTCP:8080 -iTCP:8000 -sTCP:LISTEN` and stop it. Report issues at the project's tracker. Include the broker's `--log-level debug` output and your macOS version (`sw_vers`) and architecture (`uname -m`). diff --git a/services/nvpair-engine-manager/MANIFEST.md b/services/nvpair-engine-manager/MANIFEST.md index f09a3dffb..e3a99a8cb 100644 --- a/services/nvpair-engine-manager/MANIFEST.md +++ b/services/nvpair-engine-manager/MANIFEST.md @@ -334,6 +334,14 @@ placeholder before the action runs: declared `remove_path.root` via `lms ls --json`. When no indexed file matches, the runner falls back to `{root}/{model}` if that path exists. +A manifest-level **`advertised_models`** (`"list"`, the default, or `"loaded"`) +chooses what the node tells its peers and its own proxy it can serve. The proxy +sends a request to any node advertising the model, so an engine that serves one +model per process sets `"loaded"` and advertises only `loaded_models`, even when +its `list_models` offers many models it could be switched to. With `"loaded"`, +`list_models` is not queried for `engine:models`, and a `loaded_models` action is +required. + An optional **`restart_after`** restarts the engine once the action succeeds. Declare it on an action that changes state the engine only reads at startup: LM Studio's `delete_model` removes the files, but its server answers @@ -421,7 +429,8 @@ the model store or inside it (whether spelled literally or as `{models_dir}`), an action sets none or more than one of `http`/`cmd`/`remove_path`/`hf_cache`/`launch_model`, an `hf_cache` action omits `dir`, a `launch_model` flag is not a flag or a platform lacks -`editable_launch` or `ready`, a `remove_path` action omits `root` or +`editable_launch` or `ready`, `advertised_models` is unknown or `"loaded"` +without a `loaded_models` action, a `remove_path` action omits `root` or `path`, `http.params_in` is not `body` or `query`, a `result` is set without both `array` and `field` (or a `result.match` without both `match.field` and a non-empty `match.in`), or @@ -549,5 +558,5 @@ Caveats worth encoding when authoring these: - **Model store location**: leave each engine's model cache at the engine's own default — `~/.ollama`, `~/.llamacpp`, `~/.lmstudio/models` — and declare that path as `models_dir`. Never point one inside `{install_dir}` or anywhere else under the app data root: `uninstall` skips `models_dir`, but the app-level "remove all data" uninstall deletes the entire app data root and nothing can exempt a subdirectory of it. See Model stores. - **llama.cpp** ships prebuilt archives with **published SHA-256s**, so it's an ideal checksum-pinned `fetch`+`run` target (no placeholder SHA needed). - **LM Studio** model dir is settings-controlled (no documented relocation env); surface its config as flags/API, not env. -- **vLLM**: native Windows is unsupported (WSL only); the bind is a flag, not env. +- **vLLM**: native Windows is unsupported (WSL only); the bind is a flag, not env. The bundled `vllm.json` is the reference: it passes the model as `--model`, since manifest `runtime.args` have no `{model}` token, and installs through a sha256-pinned `uv` bootstrap with a detect-first path. - **GPT4All**: no first-class headless CLI today — out of scope for a config-only manager unless targeting its Python SDK. diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index ac89eabd2..03fb52af6 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -6,7 +6,7 @@ SPDX-License-Identifier: Apache-2.0 # nvpair-engine-manager A config-driven control plane for local inference engines. The bundled -manifests support Ollama, LM Studio, and llama.cpp. It manages everything about +manifests support Ollama, LM Studio, llama.cpp, and vLLM. It manages everything about an engine **except serving inference**: detect, user-mode install, start/stop/restart, health, and config-declared actions. Adding an engine is a JSON manifest, not code. @@ -39,6 +39,54 @@ supported NVIDIA/Metal devices can accelerate, while the dynamic CPU backend remains the fallback. Hardware acceptance, not `/health` alone, is required to claim GPU activation. +### vLLM + +The `vllm` manifest is Linux only (`linux/amd64` and `linux/arm64`; vLLM has no +native Windows or macOS GPU path). It runs `vllm serve` on loopback port `8001`, +and the proxy facade owns `8000`. vLLM serves **one model per process** and has +no load, unload, pull or delete API, so the manifest declares only `list_models` +and `loaded_models` (both `GET /v1/models`). Changing the model means editing the +launch arguments and restarting the engine. + +The default launch serves `Qwen/Qwen2.5-0.5B-Instruct` through +`--model `; edit that argument to serve another model. Only +`--host` and `--port` are managed controls, and there is no CORS control. +The default launch also passes `--gpu-memory-utilization 0.5`: vLLM reserves 92% +of GPU memory by default, even for a tiny model, which would starve every other +engine on the machine. Raise it in the launch arguments for a larger model. +The environment sets `VLLM_USE_FLASHINFER_SAMPLER=0`. vLLM's FlashInfer sampler +compiles GPU kernels on first start, and that build cannot handle the spaces in +PAIR's app-data folder (`Nvidia Corporation/Personal AI Router`), so the +built-in sampler is used instead. As a side effect no CUDA toolkit or C++ +compiler is needed. The same limit means anything that forces a FlashInfer +kernel build (for example choosing a FlashInfer attention backend) fails until +the install can live on a path without spaces. +Readiness polls `/health` for up to eight minutes while weights load. + +The model list is the Hugging Face cache (`~/.cache/huggingface/hub`), not what +vLLM happens to be serving: `list_models` is an `hf_cache` action listing the +complete, text-model-shaped downloads (see `MANIFEST.md`), and it works while +vLLM is stopped. Peers and the proxy, though, are only told about the model being +served (`advertised_models: loaded`), because the proxy routes a request to any +node advertising its model and vLLM cannot answer for the others. `load_model` +switches the served model: it writes `--model ` into the saved launch +arguments and restarts vLLM on it, restoring the previous model if the new one +does not start (for example it does not fit in GPU memory). + +Install is detect-first. `{install_dir}/venv/bin/vllm` (the managed install) and +`~/.local/bin/vllm` are checked in that order, and the first one found is +launched, so an existing install is adopted and nothing is downloaded. Otherwise +the install downloads a sha256-pinned `uv` release, lets `uv` fetch a managed +Python 3.12, creates `{install_dir}/venv` and installs a pinned `vllm` wheel from +PyPI. No source build happens. The pinned wheel is a CUDA 13 build, so the install +first checks that `nvidia-smi` exists, that the driver supports CUDA 13 or newer +(it reads either the `CUDA UMD Version` or the older `CUDA Version` field), and +that at least 15 GB of disk is free. It fails with a clear message otherwise, +before downloading anything. The +download is several GB, because `torch` and the CUDA libraries come with it. +Model weights download from Hugging Face on first start into `~/.cache/huggingface` +(`HF_HOME`), which no removal this service performs will delete. + ## Communication Bidirectional newline-delimited JSON-RPC 2.0 — the same conventions as diff --git a/services/nvpair-engine-manager/catalog_test.go b/services/nvpair-engine-manager/catalog_test.go index 98127021f..3efd145be 100644 --- a/services/nvpair-engine-manager/catalog_test.go +++ b/services/nvpair-engine-manager/catalog_test.go @@ -214,14 +214,18 @@ func TestNormalizeCatalogEngine_ResolvesSupportedEngineNames(t *testing.T) { test("hyphenated llama.cpp", "llama-cpp", "llamacpp") test("dotted llama.cpp", "llama.cpp", "llamacpp") test("other engine preserved", "vllm", "vllm") + test("unknown engine preserved", "nosuchengine", "nosuchengine") } // TestCatalogRejectsUnknownEngine checks an engine with no curated source errors // rather than returning an empty list that reads as "nothing available". func TestCatalog_RejectsUnknownEngine(t *testing.T) { c := newCatalogService() - _, err := c.Catalog(context.Background(), "vllm", "linux", "amd64", "") - assert.Error(t, err, "unknown engine returned a catalog") + // vLLM is a known engine that deliberately has no curated catalog. + for _, engine := range []string{"vllm", "nosuchengine"} { + _, err := c.Catalog(context.Background(), engine, "linux", "amd64", "") + assert.Error(t, err, "%s returned a catalog", engine) + } } // TestNormalizeHFRowsMarksMLX checks Apple-only quantizations are labelled diff --git a/services/nvpair-engine-manager/launch_controls_test.go b/services/nvpair-engine-manager/launch_controls_test.go index 7427a3ab1..3ea815e8f 100644 --- a/services/nvpair-engine-manager/launch_controls_test.go +++ b/services/nvpair-engine-manager/launch_controls_test.go @@ -112,7 +112,7 @@ func TestBundledLlamaCPP_DisablesCORSByDefault(t *testing.T) { func TestBundledManifests_DeclareReviewedNetworkingControls(t *testing.T) { reg := loadWithOverrides(t, t.TempDir()) // Adding a bundled engine requires an explicit networking review and cases. - wantEngines := []string{"llamacpp", "lmstudio", "ollama"} + wantEngines := []string{"llamacpp", "lmstudio", "ollama", "vllm"} names := reg.Names() slices.Sort(names) assert.Equal(t, wantEngines, names, "review networking controls for every bundled engine") @@ -135,6 +135,11 @@ func TestBundledManifests_DeclareReviewedNetworkingControls(t *testing.T) { require.Equal(t, []LaunchControl{{Value: "{server.host}", Flags: []string{"--host"}}, {Value: "{server.port}", Flags: []string{"--port"}}, {Value: "{cors.origins}", Flags: []string{"--cors-origins"}}}, policy.Controls, "incomplete llama.cpp controls") valid = []string{"--host 127.0.0.1 --port 23456", "--host=127.0.0.1 --port=23456", "--port 23456 --cors-origins https://example.test"} invalid = []string{"--host 0.0.0.0", "--host=::", "--port 0", "--port 65536", "--port", "--cors-origins=*", "--port 23456 --port 23457", "-- --host 0.0.0.0"} + case "vllm": + require.Equal(t, []string{"serve"}, policy.FixedArgs, "vLLM startup subcommand is not fixed") + require.Equal(t, []LaunchControl{{Value: "{server.host}", Flags: []string{"--host"}}, {Value: "{server.port}", Flags: []string{"--port"}}}, policy.Controls, "incomplete vLLM controls") + valid = []string{"--host 127.0.0.1 --port 23456", "--host=127.0.0.1 --port=23456", "--port 23456 --model Qwen/Qwen2.5-0.5B-Instruct"} + invalid = []string{"--host 0.0.0.0", "--host=::", "--port 0", "--port 65536", "--port", "--port 23456 --port 23457", "-- --host 0.0.0.0"} default: require.Equal(t, []LaunchControl{{Value: "{server.host}:{server.port}", Env: []string{"OLLAMA_HOST"}}, {Value: "{cors.origins}", Env: []string{"OLLAMA_ORIGINS"}}}, policy.Controls, "incomplete Ollama controls") valid = []string{`OLLAMA_HOST="127.0.0.1:23456"`, `OLLAMA_HOST='127.0.0.1:23456'`, "OLLAMA_HOST=127.0.0.1:23456"} diff --git a/services/nvpair-engine-manager/manifests/vllm.json b/services/nvpair-engine-manager/manifests/vllm.json new file mode 100644 index 000000000..b390ff184 --- /dev/null +++ b/services/nvpair-engine-manager/manifests/vllm.json @@ -0,0 +1,73 @@ +{ + "$schema": "../manifest.schema.json", + "engine": "vllm", + "display_name": "vLLM", + "manifest_version": 1, + "advertised_models": "loaded", + "install": { "mode": "user" }, + "models_dir": "~/.cache/huggingface", + "uninstall": { "remove": ["{install_dir}"] }, + "runtime": { + "bin": "{install_dir}/venv/bin/vllm", + "editable_launch": { + "fixed_args": ["serve"], + "controls": [ + { "flags": ["--host"], "value": "{server.host}" }, + { "flags": ["--port"], "value": "{server.port}" } + ] + }, + "args": ["serve", "--model", "Qwen/Qwen2.5-0.5B-Instruct", "--gpu-memory-utilization", "0.5", "--host", "{host}", "--port", "{port}"], + "env": { "HF_HOME": "{models_dir}", "VLLM_USE_FLASHINFER_SAMPLER": "0" }, + "bind": "127.0.0.1", + "port": 8001, + "ready": { "http": "http://127.0.0.1:{port}/health", "status": 200, "timeout_s": 480 }, + "stop": { "signal": "term", "grace_s": 15 }, + "health": { "http": "http://127.0.0.1:{port}/health", "status": 200, "interval_s": 5 } + }, + "platforms": { + "linux/amd64": { + "detect": ["{install_dir}/venv/bin/vllm", "~/.local/bin/vllm"], + "install": { + "fetch": { + "url": "https://github.com/astral-sh/uv/releases/download/0.13.1/uv-x86_64-unknown-linux-gnu.tar.gz", + "sha256": "eadd9d27c134ab5ac1ffd4473b97a741407f3ea238288f03121fc4deddf90220" + }, + "run": [ + "sh", "-c", + "set -eu; command -v nvidia-smi >/dev/null 2>&1 || { echo 'vLLM needs an NVIDIA GPU and driver (nvidia-smi not found)' >&2; exit 1; }; cuda_major=$(nvidia-smi -q 2>/dev/null | awk -F' *: *' '/^CUDA (UMD )?Version/ {split($2, v, \".\"); print v[1]; exit}'); [ \"${cuda_major:-0}\" -ge 13 ] 2>/dev/null || { echo \"vLLM needs an NVIDIA driver that supports CUDA 13 or newer (found: ${cuda_major:-none}). Update your NVIDIA driver and try again.\" >&2; exit 1; }; free_kb=$(df -Pk \"$2\" | awk 'NR==2 {print $4}'); [ \"${free_kb:-0}\" -ge 15728640 ] || { echo 'vLLM needs at least 15 GB of free disk space' >&2; exit 1; }; mkdir -p \"$2/uv\"; tar -xzf \"$1\" -C \"$2/uv\" --strip-components=1; export UV_PYTHON_INSTALL_DIR=\"$2/python\" UV_PYTHON_PREFERENCE=only-managed; \"$2/uv/uv\" venv --no-cache --python 3.12 \"$2/venv\"; \"$2/uv/uv\" pip install --no-cache --python \"$2/venv/bin/python\" \"vllm==0.32.0\"", + "vllm-install", "{download}", "{install_dir}" + ] + } + }, + "linux/arm64": { + "detect": ["{install_dir}/venv/bin/vllm", "~/.local/bin/vllm"], + "install": { + "fetch": { + "url": "https://github.com/astral-sh/uv/releases/download/0.13.1/uv-aarch64-unknown-linux-gnu.tar.gz", + "sha256": "9041c73c99fdc5892cf128140beb354eca9231d502068cc36e6cb53a8e59ad14" + }, + "run": [ + "sh", "-c", + "set -eu; command -v nvidia-smi >/dev/null 2>&1 || { echo 'vLLM needs an NVIDIA GPU and driver (nvidia-smi not found)' >&2; exit 1; }; cuda_major=$(nvidia-smi -q 2>/dev/null | awk -F' *: *' '/^CUDA (UMD )?Version/ {split($2, v, \".\"); print v[1]; exit}'); [ \"${cuda_major:-0}\" -ge 13 ] 2>/dev/null || { echo \"vLLM needs an NVIDIA driver that supports CUDA 13 or newer (found: ${cuda_major:-none}). Update your NVIDIA driver and try again.\" >&2; exit 1; }; free_kb=$(df -Pk \"$2\" | awk 'NR==2 {print $4}'); [ \"${free_kb:-0}\" -ge 15728640 ] || { echo 'vLLM needs at least 15 GB of free disk space' >&2; exit 1; }; mkdir -p \"$2/uv\"; tar -xzf \"$1\" -C \"$2/uv\" --strip-components=1; export UV_PYTHON_INSTALL_DIR=\"$2/python\" UV_PYTHON_PREFERENCE=only-managed; \"$2/uv/uv\" venv --no-cache --python 3.12 \"$2/venv\"; \"$2/uv/uv\" pip install --no-cache --python \"$2/venv/bin/python\" \"vllm==0.32.0\"", + "vllm-install", "{download}", "{install_dir}" + ] + } + } + }, + "actions": { + "list_models": { + "description": "List the models in the Hugging Face cache that vLLM could serve (complete downloads with a config, tokenizer and weights). Needs no running engine.", + "hf_cache": { "dir": "{models_dir}/hub" }, + "result": { "array": "data", "field": "id" } + }, + "load_model": { + "description": "Serve a different model (params: {\"model\": \"\"}). vLLM serves one model per process, so this saves the model into the launch arguments and restarts the engine on it; if the new model does not start, the previous one is restored.", + "launch_model": { "flag": "--model" } + }, + "loaded_models": { + "description": "List the model this vLLM server is serving (it serves exactly one). This is what peers and the proxy are told it can answer for.", + "http": { "method": "GET", "path": "/v1/models" }, + "result": { "array": "data", "field": "id" } + } + } +} diff --git a/services/nvpair-engine-manager/models.go b/services/nvpair-engine-manager/models.go index 31db17aa8..42551a7ad 100644 --- a/services/nvpair-engine-manager/models.go +++ b/services/nvpair-engine-manager/models.go @@ -80,6 +80,7 @@ func (e *Executor) ModelsResult(ctx context.Context) ModelsResult { listOK := make([]bool, len(engineNames)) loaded := make([][]string, len(engineNames)) loadedOK := make([]bool, len(engineNames)) + advertiseLoaded := make([]bool, len(engineNames)) var wg sync.WaitGroup for i, name := range engineNames { mf, ok := e.reg.Get(name) @@ -96,6 +97,13 @@ func (e *Executor) ModelsResult(ctx context.Context) ModelsResult { if listSpec == nil && loadedSpec == nil { continue } + // An engine that can only answer for what it is running advertises its + // loaded set. Its list_models is a view of what could be loaded, which + // is not what a peer or the proxy may route to, so it is not queried here. + if mf.AdvertisedModels == advertisedModelsLoaded && loadedSpec != nil { + advertiseLoaded[i] = true + listSpec = nil + } wg.Add(1) go func(i int, name string, listSpec, loadedSpec *ActionResult) { defer wg.Done() @@ -133,6 +141,9 @@ func (e *Executor) ModelsResult(ctx context.Context) ModelsResult { res := ModelsResult{Models: []string{}} seen := map[string]bool{} for i, models := range perEngine { + if advertiseLoaded[i] { + models, listOK[i] = loaded[i], loadedOK[i] + } if listOK[i] { if res.ByEngine == nil { res.ByEngine = make(map[string][]string) diff --git a/services/nvpair-engine-manager/models_test.go b/services/nvpair-engine-manager/models_test.go index 6a8c71474..d838fcc2e 100644 --- a/services/nvpair-engine-manager/models_test.go +++ b/services/nvpair-engine-manager/models_test.go @@ -9,7 +9,7 @@ import ( "encoding/json" "fmt" "net/http" - + "reflect" "testing" "github.com/stretchr/testify/assert" @@ -320,3 +320,80 @@ func TestSameStringSet_IgnoresOrdering(t *testing.T) { test("duplicates matter", []string{"x", "x"}, []string{"x", "y"}, false) test("same multiset with dups", []string{"x", "x", "y"}, []string{"y", "x", "x"}, true) } + +// An engine that serves one model per process can list many models on disk but +// answers for only the one it is running. The proxy routes to any node that +// advertises a model, so advertising the on-disk list would send requests to an +// engine that cannot answer them. advertised_models: "loaded" keeps the local +// list_models view (what the UI offers) apart from what is advertised. +func TestModelsResultAdvertisesLoadedModelsWhenDeclared(t *testing.T) { + cache := t.TempDir() + writeHFRepo(t, cache, "other/cached-model", llmFiles(map[string]string{"model.safetensors": "w"})) + + m := loadedActionManifest() + m.AdvertisedModels = advertisedModelsLoaded + m.Actions["list_models"] = Action{ + HFCache: &ActionHFCache{Dir: cache}, + Result: &ActionResult{Array: "data", Field: "id"}, + } + if err := m.Validate(); err != nil { + t.Fatal(err) + } + ex := newTestExecutor(t, m) + ctx := context.Background() + t.Cleanup(func() { _ = ex.Stop("fake") }) + if err := ex.Start(ctx, "fake"); err != nil { + t.Fatalf("start: %v", err) + } + + res := ex.ModelsResult(ctx) + served := []string{"llama3.2:1b"} // what the fake engine is running + if want := map[string][]string{"fake": served}; !reflect.DeepEqual(res.ByEngine, want) { + t.Fatalf("advertised ByEngine = %v, want only the loaded model %v", res.ByEngine, want) + } + if !reflect.DeepEqual(res.Models, served) { + t.Fatalf("advertised Models = %v, want %v", res.Models, served) + } + if want := map[string][]string{"fake": served}; !reflect.DeepEqual(res.LoadedByEngine, want) { + t.Fatalf("LoadedByEngine = %v, want %v", res.LoadedByEngine, want) + } + + // The local view still offers what is on disk, running or not. + for _, running := range []bool{true, false} { + if !running { + if err := ex.Stop("fake"); err != nil { + t.Fatal(err) + } + } + raw, err := ex.Action(ctx, "fake", "list_models", nil) + if err != nil { + t.Fatalf("list_models (running=%v): %v", running, err) + } + if ids, ok := extractStringsResult(raw, m.Actions["list_models"].Result); !ok || !reflect.DeepEqual(ids, []string{"other/cached-model"}) { + t.Fatalf("list_models (running=%v) = %v, %v", running, ids, ok) + } + } + // Stopped: nothing is running, so nothing is advertised. + if res := ex.ModelsResult(ctx); len(res.ByEngine) != 0 || len(res.Models) != 0 { + t.Fatalf("a stopped engine advertised %v / %v", res.ByEngine, res.Models) + } +} + +func TestAdvertisedModelsValidation(t *testing.T) { + m := testEngineManifest(fakeEngineBin) + m.AdvertisedModels = advertisedModelsLoaded + if err := m.Validate(); err == nil { + t.Error(`advertised_models "loaded" without a loaded_models action was accepted`) + } + m.AdvertisedModels = "everything" + if err := m.Validate(); err == nil { + t.Error("an unknown advertised_models value was accepted") + } + m = loadedActionManifest() + for _, value := range []string{"", advertisedModelsList, advertisedModelsLoaded} { + m.AdvertisedModels = value + if err := m.Validate(); err != nil { + t.Errorf("advertised_models %q rejected: %v", value, err) + } + } +} diff --git a/services/nvpair-engine-manager/registry.go b/services/nvpair-engine-manager/registry.go index 5c2071105..e2182e220 100644 --- a/services/nvpair-engine-manager/registry.go +++ b/services/nvpair-engine-manager/registry.go @@ -63,6 +63,12 @@ type Manifest struct { ManifestVersion int `json:"manifest_version"` Platforms map[string]Platform `json:"platforms"` Actions map[string]Action `json:"actions,omitempty"` + // AdvertisedModels says which models this node tells its peers and its own + // proxy it can serve: "list" (the default) advertises list_models, "loaded" + // advertises loaded_models. An engine that serves one model per process + // can list many models it has on disk but only answer for the one it is + // running, and the proxy sends a request to any node advertising its model. + AdvertisedModels string `json:"advertised_models,omitempty"` } // Platform is the per-`/` block. Variance lives here @@ -758,9 +764,24 @@ func (m *Manifest) Validate() error { } } } + switch m.AdvertisedModels { + case "", advertisedModelsList: + case advertisedModelsLoaded: + if _, ok := m.Actions["loaded_models"]; !ok { + return fmt.Errorf("advertised_models %q requires a loaded_models action", m.AdvertisedModels) + } + default: + return fmt.Errorf("advertised_models %q invalid (want %q or %q)", m.AdvertisedModels, advertisedModelsList, advertisedModelsLoaded) + } return m.validatePlaceholders() } +// advertisedModels values for Manifest.AdvertisedModels. +const ( + advertisedModelsList = "list" + advertisedModelsLoaded = "loaded" +) + func (p *Platform) validate(key string) error { if policy := p.Runtime.EditableLaunch; policy != nil { if policy.StartIndex < 0 || (p.Runtime.modeOrDefault() == "command" && policy.StartIndex >= len(p.Runtime.Start)) { diff --git a/services/nvpair-job-scheduler/spec.md b/services/nvpair-job-scheduler/spec.md index cc1bbef56..1513e3a0e 100644 --- a/services/nvpair-job-scheduler/spec.md +++ b/services/nvpair-job-scheduler/spec.md @@ -55,7 +55,7 @@ capacity, model locality, latency, and affinity can grow later behind the same - **Simultaneous local burst**: before each auto-routed inference forward, the proxy atomically increments a local reservation for its chosen node. Concurrent requests therefore see one another even before workload feedback completes. -- **Cross-engine contention**: Ollama, LM Studio, and llama.cpp workloads all +- **Cross-engine contention**: Ollama, LM Studio, llama.cpp, and vLLM workloads all consume the destination node's execution resources, so any one of them changes every engine output. - **Periodic reconciliation**: the runtime-adjustable timer recomputes the same @@ -205,7 +205,7 @@ Requests (Broker → scheduler): | Method | Params | Result | |--------|--------|--------| -| `scheduler:get-status` | — | `{ interval_ms, engines: { ollama: EngineSchedule, lmstudio: EngineSchedule, llamacpp: EngineSchedule } }` | +| `scheduler:get-status` | — | `{ interval_ms, engines: { ollama: EngineSchedule, lmstudio: EngineSchedule, llamacpp: EngineSchedule, vllm: EngineSchedule } }` | | `scheduler:get-interval` | — | `{ interval_ms }` | | `scheduler:set-interval` | `{ interval_ms }` | `{ interval_ms }` — live; clamped to the floor (§7.5); effective next tick | | `scheduler:tick` | — | `{ ticked: true }` — force an immediate recompute + re-emit (tests/debug) | @@ -215,7 +215,7 @@ Requests (Broker → scheduler): `EngineSchedule`: ```json { - engine: string // "ollama" | "lmstudio" | "llamacpp" + engine: string // "ollama" | "lmstudio" | "llamacpp" | "vllm" emitted: [NodeRank] // last order emitted (empty if none yet) lastEmittedAt: number // epoch ms; 0 if never } @@ -289,8 +289,8 @@ Let `D` = current discovered-node `hostUuid`s: `scheduledOn` (not `originatedFrom`) is the key: we care where work runs, not where it came from. Engine remains part of workload identity and selects the downstream -proxy output, but it does not partition the load metric: Ollama, LM Studio, and -llama.cpp normally contend for the same node-level GPU, VRAM, CPU, and memory. +proxy output, but it does not partition the load metric: Ollama, LM Studio, +llama.cpp, and vLLM normally contend for the same node-level GPU, VRAM, CPU, and memory. Until resource affinity is observable, total node queue depth is the conservative signal. diff --git a/services/nvpair-proxy/README.md b/services/nvpair-proxy/README.md index 06968b631..059268303 100644 --- a/services/nvpair-proxy/README.md +++ b/services/nvpair-proxy/README.md @@ -73,7 +73,7 @@ parameter, because one flag cannot carry multiple engines' plans. | Field | Default | Description | |-------|---------|-------------| -| `engine` | *(required)* | Which engine to front: `ollama`, `lmstudio`, or `llamacpp`. An unknown name is rejected with the accepted values. | +| `engine` | *(required)* | Which engine to front: `ollama`, `lmstudio`, `llamacpp`, or `vllm`. An unknown name is rejected with the accepted values. | | `port` | per engine, see below | HTTP listen port for request forwarding. Must be 1–65535, or omitted for the engine's standalone default. `0` means "the default" rather than "pick an ephemeral port", and any other out-of-range value is rejected, because the facade announces the requested port in its `ready` notification and the broker would be told `0`. | | `aliasAddresses` | *(empty)* | Optional secondary `host:port` values for the same routing handler, one per loopback family so `localhost` resolves either way. Only literal loopback addresses are accepted; the broker uses this for a safe inherited local `OLLAMA_HOST`, and the aliases are not advertised to peers. Accepted only for an engine with an inherited host variable — today Ollama alone — and rejected for any other. | | `ignorePersistedPort` | `false` | Use `port` even when a saved port exists (used by broker-managed startup) | @@ -83,10 +83,10 @@ parameter, because one flag cannot carry multiple engines' plans. Everything engine-specific is one entry in `engines.go`, plus the shared identity in `nvpair-shared/engines`. -| | `"engine":"ollama"` | `"engine":"lmstudio"` | `"engine":"llamacpp"` | -|---|---|---|---| -| Facade id — error-ID prefix and broker relay namespace | `ollama-proxy` | `lmstudio-proxy` | `llamacpp-proxy` | -| Discovery service key | `ol` | `lm` | `lc` | +| | `"engine":"ollama"` | `"engine":"lmstudio"` | `"engine":"llamacpp"` | `"engine":"vllm"` | +|---|---|---|---|---| +| Facade id — error-ID prefix and broker relay namespace | `ollama-proxy` | `lmstudio-proxy` | `llamacpp-proxy` | `vllm-proxy` | +| Discovery service key | `ol` | `lm` | `lc` | `vl` | The **log component, supervisor label, and TUI health crash key are not in that table**: they name the process (`nvpair-proxy`), not a facade, because one @@ -95,17 +95,17 @@ Facade-scoped log records carry an `engine` field instead. The supervisor label and the health crash key are matched against each other, so they move together — see `nvpair-shared/engines` and `spec.md` §9. -| | `"engine":"ollama"` | `"engine":"lmstudio"` | `"engine":"llamacpp"` | -|---|---|---|---| -| Engine's own client-facing port | 11434 | 1234 | 8080 | -| Where PAIR relocates the engine | 11435 | 1235 | 8081 | -| Standalone port, used when `port` is omitted | 11435 | 1234 | 8080 | -| Persisted-port file (declared, not derived) | `proxy-port.json` | `lmstudio-proxy-port.json` | `llamacpp-proxy-port.json` | -| Model-list routes | `GET /api/tags` (native), `GET /v1/models` (OpenAI) | `GET /v1/models` (OpenAI) | `GET /models`, `GET /v1/models` (OpenAI; both query upstream `/models`) | -| Inference routes | `/api/generate`, `/api/chat`, `/api/embeddings`, `/api/embed`, plus the OpenAI and Anthropic Messages sets below | OpenAI and Anthropic Messages sets below | OpenAI set below | -| Model naming | untagged means `:latest`, so `llama3` and `llama3:latest` are one model | identifiers compared byte for byte | identifiers compared byte for byte | +| | `"engine":"ollama"` | `"engine":"lmstudio"` | `"engine":"llamacpp"` | `"engine":"vllm"` | +|---|---|---|---|---| +| Engine's own client-facing port | 11434 | 1234 | 8080 | 8000 | +| Where PAIR relocates the engine | 11435 | 1235 | 8081 | 8001 | +| Standalone port, used when `port` is omitted | 11435 | 1234 | 8080 | 8000 | +| Persisted-port file (declared, not derived) | `proxy-port.json` | `lmstudio-proxy-port.json` | `llamacpp-proxy-port.json` | `vllm-proxy-port.json` | +| Model-list routes | `GET /api/tags` (native), `GET /v1/models` (OpenAI) | `GET /v1/models` (OpenAI) | `GET /models`, `GET /v1/models` (OpenAI; both query upstream `/models`) | `GET /v1/models` (OpenAI) | +| Inference routes | `/api/generate`, `/api/chat`, `/api/embeddings`, `/api/embed`, plus the OpenAI and Anthropic Messages sets below | OpenAI and Anthropic Messages sets below | OpenAI set below | OpenAI set below | +| Model naming | untagged means `:latest`, so `llama3` and `llama3:latest` are one model | identifiers compared byte for byte | identifiers compared byte for byte | identifiers compared byte for byte | -All three facades classify these OpenAI-compatible POST routes as inference: +All four facades classify these OpenAI-compatible POST routes as inference: - `/v1/chat/completions` — [Chat Completions](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create) - `/v1/completions` — [Completions](https://developers.openai.com/api/reference/resources/completions/methods/create) @@ -119,8 +119,8 @@ The route table is a **classifier, not an allowlist**. An unlisted path is forwarded verbatim, which is how `/api/show`, `/api/pull`, `/api/ps`, `/api/version` and `OPTIONS` preflights keep working. -The broker enables all three facades by default. Local llama.cpp-compatible -clients use `8080`, while the managed `llama-server` stays on `8081`. +The broker enables all four facades by default. Local llama.cpp-compatible +clients use `8080`, while the managed `llama-server` stays on `8081`. vLLM clients use `8000`, while the managed `vllm serve` stays on `8001`. `--proxy-engines` can restrict a standalone broker or TUI to a subset. ### HTTP Reverse Proxy diff --git a/services/nvpair-proxy/engines.go b/services/nvpair-proxy/engines.go index 52a7544a7..7a4c89d70 100644 --- a/services/nvpair-proxy/engines.go +++ b/services/nvpair-proxy/engines.go @@ -151,6 +151,13 @@ var llamaCPPBaseRoutes = []route{ {Path: "/v1/models", UpstreamPath: "/models", Role: roleModelListOpenAIGET}, } +// vllmBaseRoutes is the engine-specific surface that vLLM exposes before the +// shared OpenAI routes are added. vLLM serves one model per process, so the +// fleet inventory is what makes /v1/models useful across nodes. +var vllmBaseRoutes = []route{ + {Path: "/v1/models", Role: roleModelListOpenAIGET}, +} + // openAIInferenceRoutes is the OpenAI-compatible inference surface. var openAIInferenceRoutes = []route{ {Path: "/v1/chat/completions", Role: roleInferencePOST}, @@ -170,9 +177,11 @@ func buildProfiles() []engineProfile { ollama, _ := engines.ByName("ollama") lmstudio, _ := engines.ByName("lmstudio") llamacpp, _ := engines.ByName("llamacpp") + vllm, _ := engines.ByName("vllm") ollamaRoutes := slices.Concat(ollamaBaseRoutes, openAIInferenceRoutes, anthropicInferenceRoutes) lmStudioRoutes := slices.Concat(lmStudioBaseRoutes, openAIInferenceRoutes, anthropicInferenceRoutes) llamaCPPRoutes := slices.Concat(llamaCPPBaseRoutes, openAIInferenceRoutes) + vllmRoutes := slices.Concat(vllmBaseRoutes, openAIInferenceRoutes) return []engineProfile{ { @@ -200,6 +209,13 @@ func buildProfiles() []engineProfile { ModelNaming: exactID, ReservedPersistedPort: 8081, }, + { + Engine: vllm, + StandalonePort: 8000, + Routes: vllmRoutes, + ModelNaming: exactID, + ReservedPersistedPort: 8001, + }, } } diff --git a/services/nvpair-proxy/engines_test.go b/services/nvpair-proxy/engines_test.go index 5c855b7e5..c6f72d2fb 100644 --- a/services/nvpair-proxy/engines_test.go +++ b/services/nvpair-proxy/engines_test.go @@ -104,6 +104,20 @@ func TestRoleFor_ClassifiesOnlyDeclaredRoutes(t *testing.T) { reject("lmstudio has no show", lmstudio, http.MethodPost, "/api/show") reject("llamacpp models post passthrough", llamacpp, http.MethodPost, "/models") + + vllm, ok := profileFor("vllm") + if !ok { + t.Fatal("vllm profile missing") + } + accept("vllm chat", vllm, http.MethodPost, "/v1/chat/completions", roleInferencePOST) + accept("vllm completions", vllm, http.MethodPost, "/v1/completions", roleInferencePOST) + accept("vllm embeddings", vllm, http.MethodPost, "/v1/embeddings", roleInferencePOST) + accept("vllm responses", vllm, http.MethodPost, "/v1/responses", roleInferencePOST) + accept("vllm list", vllm, http.MethodGet, "/v1/models", roleModelListOpenAIGET) + // vLLM's own /health and /tokenize are forwarded verbatim, not classified. + reject("vllm health passthrough", vllm, http.MethodGet, "/health") + reject("vllm no native routes", vllm, http.MethodPost, "/api/chat") + reject("vllm wrong method on list", vllm, http.MethodPost, "/v1/models") } func TestIsInferenceRequest_FollowsTheProfile(t *testing.T) { diff --git a/services/nvpair-proxy/spec.md b/services/nvpair-proxy/spec.md index 716541d3c..34a1188f4 100644 --- a/services/nvpair-proxy/spec.md +++ b/services/nvpair-proxy/spec.md @@ -99,7 +99,7 @@ The process starts with **no engine and no listener**. The broker then sends one A flag cannot express this. The broker plans a different port for each engine — Ollama's managed facade wants `:11434`, LM Studio's wants `:1234`, and -llama.cpp's managed facade wants `:8080`; any may be absent so the child keeps +llama.cpp's managed facade wants `:8080`, and vLLM's wants `:8000`; any may be absent so the child keeps its own persisted port — and a single-valued flag carries only one plan. ### 3.1 Why one process @@ -108,7 +108,7 @@ Between scheduler snapshots a facade takes short-lived **reservations** for work it has dispatched but that the scheduler has not yet observed. Those live in the process, and every facade shares them. If each engine had its own proxy process, none could see the others' reservations, so simultaneous bursts across -Ollama, LM Studio, and llama.cpp could select the same node, each incorrectly +Ollama, LM Studio, llama.cpp, and vLLM could select the same node, each incorrectly believing it was idle. Sharing the map is the reason the engines share a process. @@ -163,7 +163,7 @@ For a model-bearing inference request: 1. Filter a request-local discovery snapshot to nodes whose per-engine inventory advertises the requested model. Ollama normalizes the implicit `:latest` tag; - LM Studio and llama.cpp ids match exactly. An empty owner set returns a local + LM Studio, llama.cpp, and vLLM ids match exactly. An empty owner set returns a local `502` without contacting an engine. 2. Order the eligible owners: explicit `node/select` pin, then the scheduler's priority list, then deterministic default ordering. @@ -555,12 +555,12 @@ untouched, the primary listener stays up, and a warning is reported. ## 8. Ports -| | Ollama | LM Studio | llama.cpp | -| --- | --- | --- | --- | -| Engine's own client-facing port | 11434 | 1234 | 8080 | -| Where PAIR relocates the engine | 11435 | 1235 | 8081 | -| Standalone port, when `port` is omitted | 11435 | 1234 | 8080 | -| Persisted-port file | `proxy-port.json` | `lmstudio-proxy-port.json` | `llamacpp-proxy-port.json` | +| | Ollama | LM Studio | llama.cpp | vLLM | +| --- | --- | --- | --- | --- | +| Engine's own client-facing port | 11434 | 1234 | 8080 | 8000 | +| Where PAIR relocates the engine | 11435 | 1235 | 8081 | 8001 | +| Standalone port, when `port` is omitted | 11435 | 1234 | 8080 | 8000 | +| Persisted-port file | `proxy-port.json` | `lmstudio-proxy-port.json` | `llamacpp-proxy-port.json` | `vllm-proxy-port.json` | A port chosen at runtime via `set-port` is persisted per engine and restored when that facade is enabled, taking precedence over the requested port, so the diff --git a/services/nvpair-proxy/twofacade_test.go b/services/nvpair-proxy/twofacade_test.go index b35e1bb38..f392f54eb 100644 --- a/services/nvpair-proxy/twofacade_test.go +++ b/services/nvpair-proxy/twofacade_test.go @@ -193,7 +193,7 @@ func TestAddressedRequest_ReachesOnlyItsOwnFacade(t *testing.T) { } // An engine with no facade resolves to nothing rather than to a sibling. - assert.Nil(t, p.facadeFor("vllm"), "unknown engine resolved to the") + assert.Nil(t, p.facadeFor("nosuchengine"), "unknown engine resolved to the") assert.Nil(t, p.facadeFor(""), "an unaddressed message resolved to the") } diff --git a/services/nvpair-tui/ui/catalog_test.go b/services/nvpair-tui/ui/catalog_test.go index c2f066667..9c4957ef7 100644 --- a/services/nvpair-tui/ui/catalog_test.go +++ b/services/nvpair-tui/ui/catalog_test.go @@ -325,9 +325,9 @@ func TestCatalog_IgnoresOtherEnginesReply(t *testing.T) { // TestCatalogEmptyCatalogIsDistinctFromNoMatch checks the two empty states read // differently, because the fixes are different. func TestCatalogEmptyCatalog_IsDistinctFromNoMatch(t *testing.T) { - b := newCatalogBrowser(nil, "vllm", "vLLM", "this-host", false) + b := newCatalogBrowser(nil, "nosuchengine", "No Such Engine", "this-host", false) b.SetSize(100, 24) - b.update(catalogLoadedMsg{engine: "vllm", models: nil}) + b.update(catalogLoadedMsg{engine: "nosuchengine", models: nil}) assert.Contains(t, b.View(), "No catalog available") } diff --git a/services/nvpair-tui/ui/engineswire.go b/services/nvpair-tui/ui/engineswire.go index 453afb755..b9a2b547e 100644 --- a/services/nvpair-tui/ui/engineswire.go +++ b/services/nvpair-tui/ui/engineswire.go @@ -133,6 +133,16 @@ var modelWires = map[string]modelWire{ "delete": modelOnly("delete_model"), "pull": modelOnly("pull_model"), }, + // vLLM serves one model per process and declares only list_models and + // loaded_models, so engine-manager refuses all four of these as undeclared + // actions. The spelling is still present so a caller gets that explicit + // refusal instead of this table's "no wire" error. + engines.NameVLLM: { + "load": modelOnly("load_model"), + "unload": modelOnly("unload_model"), + "delete": modelOnly("delete_model"), + "pull": modelOnly("pull_model"), + }, } // downloadExamples show what a download name looks like for each engine. The @@ -142,6 +152,7 @@ var downloadExamples = map[string]string{ engines.NameOllama: "llama3.2", engines.NameLMStudio: "lmstudio-community/Qwen3-8B-GGUF", engines.NameLlamaCPP: "ggml-org/gemma-3-1b-it-GGUF:Q4_K_M", + engines.NameVLLM: "Qwen/Qwen2.5-0.5B-Instruct", } // downloadPrompt is the placeholder for the download-by-name field, with an diff --git a/services/nvpair-tui/ui/engineswire_test.go b/services/nvpair-tui/ui/engineswire_test.go index ab2128349..f80d2f5c7 100644 --- a/services/nvpair-tui/ui/engineswire_test.go +++ b/services/nvpair-tui/ui/engineswire_test.go @@ -191,7 +191,7 @@ func TestDownloadPrompt_UsesTheEnginesSpelling(t *testing.T) { assert.Contains(t, downloadExamples, e.Name, "every engine needs a download example") } assert.Contains(t, downloadPrompt(engines.NameLlamaCPP, "llama.cpp"), ":Q4_K_M", "llama.cpp prompt must show the quantization suffix") - assert.Equal(t, "model name for vLLM", downloadPrompt("vllm", "vLLM")) + assert.Equal(t, "model name for No Such Engine", downloadPrompt("nosuchengine", "No Such Engine")) } // TestEveryEngineHasAModelWire is what makes adding an engine a matter of @@ -205,7 +205,7 @@ func TestEveryEngine_HasAModelWire(t *testing.T) { require.NoError(t, err, "%s must have a local wire for %s", e.Name, op) } } - _, err := modelActionWire("vllm", "load", "m") + _, err := modelActionWire("nosuchengine", "load", "m") assert.Error(t, err, "an engine with no wire must not be given one") } diff --git a/services/nvpair-tui/ui/nodedetail_test.go b/services/nvpair-tui/ui/nodedetail_test.go index 6a168c5ee..a2d1f3747 100644 --- a/services/nvpair-tui/ui/nodedetail_test.go +++ b/services/nvpair-tui/ui/nodedetail_test.go @@ -218,7 +218,7 @@ func TestProxyIndexForEngine_NormalizesNamesAndRejectsUnknownEngine(t *testing.T assert.Equal(t, 1, p.indexForEngine("lmstudio")) // Case and padding must not decide whether a port renders. assert.Equal(t, 0, p.indexForEngine(" OLLAMA "), "normalisation") - assert.Equal(t, -1, p.indexForEngine("vllm"), "unknown engine") + assert.Equal(t, -1, p.indexForEngine("nosuchengine"), "unknown engine") } // seedSettings puts a settings snapshot in the cache, as a fetch or a push diff --git a/services/nvpair-tui/ui/proxystatus_test.go b/services/nvpair-tui/ui/proxystatus_test.go index a4e46c6aa..4f4a9ef48 100644 --- a/services/nvpair-tui/ui/proxystatus_test.go +++ b/services/nvpair-tui/ui/proxystatus_test.go @@ -109,7 +109,7 @@ func TestPortForEngine_DistinguishesDownFromUnknown(t *testing.T) { p := newProxyTracker() _, ready := p.portForEngine("ollama") assert.False(t, ready, "a proxy that has never reported reads as ready") - port, ready := p.portForEngine("vllm") + port, ready := p.portForEngine("nosuchengine") assert.False(t, ready) assert.Zero(t, port) } diff --git a/services/nvpair-ui-broker/ENGINE_SETTINGS.md b/services/nvpair-ui-broker/ENGINE_SETTINGS.md index 5d5f5728e..c3ebccf0b 100644 --- a/services/nvpair-ui-broker/ENGINE_SETTINGS.md +++ b/services/nvpair-ui-broker/ENGINE_SETTINGS.md @@ -16,7 +16,7 @@ propagation is not part of this API. | `engine:preview-settings` | `{engine, nodeId?, expectedRevision, settings, resolution?}` | Normalized settings, errors, conflict, restart/rebind summary | | `engine:apply-settings` | Preview request plus `requestId` | `{revision, phase}` acknowledgement | -`engine` is `ollama`, `lmstudio`, or `llamacpp`. `settings` contains all three +`engine` is `ollama`, `lmstudio`, `llamacpp`, or `vllm`. `settings` contains all three fields: `serverPort`, `proxyPort`, `launchText`. The last field contains arguments and leading environment assignments, without the executable or startup subcommand. The argument grammar is diff --git a/services/nvpair-ui-broker/README.md b/services/nvpair-ui-broker/README.md index 37f8fc0d7..10b0af5c5 100644 --- a/services/nvpair-ui-broker/README.md +++ b/services/nvpair-ui-broker/README.md @@ -23,7 +23,7 @@ namespace: | --- | --- | --- | | `nvpair-node-scanner` | Discovery daemon: advertises this host's one `_nvpair-node._tcp` record and browses the LAN | `discovery:*` | | `nvpair-node-info` | Local GPU / CPU / memory inventory over HTTP at `/v1/node-info` | — (HTTP only) | -| `nvpair-proxy` | One process hosting an inference proxy and router facade per enabled engine | `ollama-proxy:*`, `lmstudio-proxy:*`, `llamacpp-proxy:*` | +| `nvpair-proxy` | One process hosting an inference proxy and router facade per enabled engine | `ollama-proxy:*`, `lmstudio-proxy:*`, `llamacpp-proxy:*`, `vllm-proxy:*` | | `nvpair-engine-manager` | Local engine and model control plane; also serves `GET /v1/models` to peers | `engine:*` | | `nvpair-cluster-manager` | Node identity, trusted-node store, PIN pairing | `cluster:*`, `nodes:*` | | `nvpair-workload-manager` | Cluster workload relay between this node and peers | `workloads:*` | @@ -39,9 +39,9 @@ lifecycle, and relay rules. Two responsibilities live in the broker itself rather than in a worker: -- **Engine advertising.** The broker polls local Ollama, LM Studio, and - llama.cpp every 5 s and registers each running engine's promoted facade port - (`ol` / `lm` / `lc`) with the discovery daemon, so they are carried in this +- **Engine advertising.** The broker polls local Ollama, LM Studio, + llama.cpp, and vLLM every 5 s and registers each running engine's promoted facade port + (`ol` / `lm` / `lc` / `vl`) with the discovery daemon, so they are carried in this host's single `_nvpair-node` record. The model list is not part of that record — it is served over HTTP by `nvpair-engine-manager` on the `em` service and fetched by a peer's daemon @@ -74,7 +74,7 @@ Bidirectional newline-delimited JSON-RPC 2.0 — same conventions as every other | `--scanner-path ` | `./nvpair-node-scanner[.exe]` in the CWD | Explicit path to the `nvpair-node-scanner` binary the broker should spawn | | `--node-info-path ` | `./nvpair-node-info[.exe]` in the CWD | Explicit path to the `nvpair-node-info` binary the broker should spawn. When omitted and no default sibling exists, the broker runs without the local inventory server (non-fatal); when set to an invalid path, the broker exits with an error | | `--proxy-path ` | `./nvpair-proxy[.exe]` in the CWD | Explicit path to the `nvpair-proxy` binary. One process fronts every engine: the broker spawns it once and then sends a `facade/enable` per entry in `--proxy-engines`. Same optional semantics as `--node-info-path`: an absent default sibling means no local proxies (non-fatal); an invalid explicit path exits with an error | -| `--proxy-engines ` | `ollama,lmstudio,llamacpp` | Which engines to front with a proxy. An unrecognized name exits with an error rather than being skipped, so a typo cannot look like it worked. An engine left out is not started **and not prepared** — the broker will not relocate an engine whose facade nothing is going to claim | +| `--proxy-engines ` | `ollama,lmstudio,llamacpp,vllm` | Which engines to front with a proxy. An unrecognized name exits with an error rather than being skipped, so a typo cannot look like it worked. An engine left out is not started **and not prepared** — the broker will not relocate an engine whose facade nothing is going to claim | | `--workload-manager-path ` | `./nvpair-workload-manager[.exe]` in the CWD | Explicit path to the `nvpair-workload-manager` binary the broker spawns for the cluster workload relay. Same optional semantics as `--node-info-path`: an absent default sibling means no workload relay (non-fatal); an invalid explicit path exits with an error | | `--errors-path ` | `./nvpair-errors[.exe]` in the CWD | Explicit path to the `nvpair-errors` binary the broker spawns (with `--peer-sync`) for the service-error pipeline. Same optional semantics as `--node-info-path`: an absent default sibling means the error pipeline is disabled — producers' errors are dropped (non-fatal); an invalid explicit path exits with an error | | `--engine-manager-path ` | `./nvpair-engine-manager[.exe]` in the CWD | Explicit path to the `nvpair-engine-manager` binary the broker spawns for engine management. Same optional semantics as `--node-info-path` | @@ -97,7 +97,7 @@ On startup — **before** emitting `app:ready` — the broker spawns the scanner **`nvpair-node-info`** is spawned next. It's a server, not an event source: it stands up the local `/v1/node-info` HTTP endpoint (GPU/CPU/memory inventory). It does not advertise itself — the broker registers its `ni` port with the scanner daemon, which carries it in the node record, and a peer's daemon fetches `/v1/node-info` over plain HTTP to enrich the node. The broker doesn't read anything back from node-info's stdout (drained and discarded). Spawning it is **optional**: if the binary can't be resolved (and no `--node-info-path` override was given) the broker logs a warning and continues serving discovery without it. **Engine advertising.** The broker runs an internal 5 s poll loop against local -Ollama (`GET /`), LM Studio (`GET /v1/models`), and llama.cpp (`GET /health`) +Ollama (`GET /`), LM Studio (`GET /v1/models`), llama.cpp (`GET /health`), and vLLM (`GET /health`) at their configured backend ports and reconciles this node's engine registration with the scanner daemon: @@ -122,6 +122,9 @@ withdrawn or answered with an error on its own, leaving the others serving. Ollama's standalone default is `:11435`; with managed port ownership enabled (the default), the broker starts settings and engine-manager first, claims `:11434` with that facade, and only then moves a stopped default-port Ollama backend to a free port. Custom backend ports are preserved. When the inherited `OLLAMA_HOST` names a distinct local plaintext port, the broker also gives the facade that normalized loopback-only alias so clients already using the variable enter the same routing path; `localhost` reserves both canonical loopback families atomically, while remote and HTTPS targets are ignored. The alias port is reserved against every configured engine, local or remote engine start override, every facade's control plane, and every managed engine's backend port plan, so a backend that has to move can never land on the alias. A running Ollama or unknown owner on either requested port is never stopped or moved: the primary uses a safe fallback when needed, and an occupied alias remains with its owner while the broker reports a warning. +vLLM is a managed engine handled exactly like llama.cpp below, with its +engine on `:8001` and its default-enabled facade on `:8000` or a safe fallback. + llama.cpp is a managed engine: engine-manager owns its lifecycle and starts it on its configured loopback port, `:8081` by default. The broker places its default-enabled facade on `:8080` or a safe fallback, without automatically @@ -160,7 +163,7 @@ Two classes of proxy notification are **not** re-emitted under any telemetry, and discovery snapshot. It smooths fresh utilization into pressure 0–3, uses neutral pressure 1 for invalid/missing/older-than-10-second samples, and orders by `pending + gpuPressure`, then pressure, then stable UUID. Load is -node-wide across Ollama, LM Studio, and llama.cpp because they normally contend +node-wide across Ollama, LM Studio, llama.cpp, and vLLM because they normally contend for the same resources. Each engine-specific `schedule:priority` carries `{engine,nodes,ranks}` and refreshes when order, pending counts, or pressure changes. The broker caches, generation-orders, and replays the full @@ -427,7 +430,7 @@ Two relay-specific error cases: - If no proxy is being supervised (or it has exited), the broker replies with error `-32000` `"ollama-proxy not available"`. - `ollama-proxy:shutdown` is **refused** with error `-32601` — the broker owns the proxy's lifecycle, so a client can't terminate it independently. Shut the broker down instead (which tears the proxy down with it). -#### `ollama-proxy:set-port` / `lmstudio-proxy:set-port` / `llamacpp-proxy:set-port` +#### `ollama-proxy:set-port` / `lmstudio-proxy:set-port` / `llamacpp-proxy:set-port` / `vllm-proxy:set-port` **Intercepted, not relayed verbatim.** For every engine profile, a port-only caller (none in tree; `nvpair-tui` uses `engine:apply-settings`) gets to move a single port without rendering the whole launch settings form, but the change still runs through the same authoritative settings operation the desktop editor uses, so a port set from the terminal cannot diverge from one set from the UI. The namespace selects the engine. The broker reads that engine's current settings, substitutes the requested proxy port, and applies the result while preserving the server port and launch arguments. Accepted settings are saved in the settings journal. @@ -444,7 +447,7 @@ Automatic conflict resolution still exists, but only for a port the user did not The LM Studio counterpart of the `ollama-proxy:*` surface runs the supervised `lmstudio-proxy` on compatibility port `:1234` and tracks the managed LM Studio backend on `:1235`. With managed port ownership enabled (the default), the broker identifies and moves an existing LM Studio server through engine-manager before allowing the proxy to claim `1234`; unknown owners are left untouched and force a warned proxy fallback. Disabling managed ownership preserves explicit custom backend and proxy ports. `lmstudio-proxy:get-status` reports the actual bound port; `lmstudio-proxy:subscribe` / `lmstudio-proxy:unsubscribe` opt into / out of its `lmstudio-proxy:` stream; `lmstudio-proxy:set-port` uses the settings operation described above; and other `lmstudio-proxy:` requests are relayed with the prefix translated (`nodes/list`, `node/select`, `node/add-manual`, `node/remove-manual`, ...). `lmstudio-proxy:shutdown` is refused because the broker owns lifecycle ordering. Workload and error events feed the shared streams exactly as Ollama's do. -The same broker-local status, subscription, and port-setting methods apply to `llamacpp-proxy:*`. llama.cpp port changes use the same validation, journal, and application path; remaining methods use the generic facade relay, with `llamacpp-proxy:shutdown` refused. +The same broker-local status, subscription, and port-setting methods apply to `llamacpp-proxy:*` and `vllm-proxy:*`. llama.cpp port changes use the same validation, journal, and application path; remaining methods use the generic facade relay, with `llamacpp-proxy:shutdown` refused. #### `ollama-proxy:subscribe` @@ -617,8 +620,8 @@ Attach to a pre-existing endpoint: ## What this version intentionally does NOT do (yet) - **Engine-advertise control surface.** Engine registration is auto-driven only: - the broker tracks local Ollama, LM Studio, and llama.cpp and registers `ol`, - `lm`, or `lc` with the daemon while each is up. There's no manual-advertise + the broker tracks local Ollama, LM Studio, llama.cpp, and vLLM and registers `ol`, + `lm`, `lc`, or `vl` with the daemon while each is up. There's no manual-advertise RPC (custom service, port, name, or TXT), and no way to advertise anything other than the detected engines. - **node-info control surface.** node-info is spawned and torn down with the broker, and the broker pushes it only two things over stdin: the log level, and this node's cluster principal (`nodeinfo:set-cluster-identity`, sent on spawn and on every membership or pin-set change, because node-info holds no cluster dir and so cannot read membership itself). Otherwise it's hands-off: the broker registers its port with the daemon (which enriches over plain HTTP) but doesn't pass through TLS material (`--cert` / `--key` / `--client-ca`) or a custom `--port`, and exposes no RPC to query or reconfigure it. It runs with its own defaults plus those two pushes. diff --git a/services/nvpair-ui-broker/engineproxy.go b/services/nvpair-ui-broker/engineproxy.go index 43a5ff91b..0b1e102b8 100644 --- a/services/nvpair-ui-broker/engineproxy.go +++ b/services/nvpair-ui-broker/engineproxy.go @@ -165,6 +165,7 @@ func buildEngineProxyProfiles() []engineProxyProfile { // llama.cpp's managed process is stopped directly by engine-manager. "lmstudio": {Ownership: managedEngine, HealthProbePath: "/v1/models"}, "llamacpp": {Ownership: managedEngine, HealthProbePath: "/health"}, + "vllm": {Ownership: managedEngine, HealthProbePath: "/health"}, } out := make([]engineProxyProfile, 0, len(engines.All())) for _, e := range engines.All() { diff --git a/services/nvpair-ui-broker/engineproxy_test.go b/services/nvpair-ui-broker/engineproxy_test.go index 78c372372..665fc44dd 100644 --- a/services/nvpair-ui-broker/engineproxy_test.go +++ b/services/nvpair-ui-broker/engineproxy_test.go @@ -127,13 +127,13 @@ func TestParseProxyEngines_ValidatesSelectedEngineNames(t *testing.T) { require.Equal(t, append([]string{}, want...), append([]string{}, got...)) }) } - test("default set", "ollama,lmstudio,llamacpp", []string{"ollama", "lmstudio", "llamacpp"}, false) + test("default set", "ollama,lmstudio,llamacpp,vllm", []string{"ollama", "lmstudio", "llamacpp", "vllm"}, false) test("single engine", "lmstudio", []string{"lmstudio"}, false) test("whitespace and blanks are tolerated", " ollama , , lmstudio ", []string{"ollama", "lmstudio"}, false) test("duplicates collapse", "ollama,ollama", []string{"ollama"}, false) test("empty selects nothing", "", nil, false) // Silently fronting the engines it did recognize would look like the flag worked. - test("unknown engine fails", "ollama,vllm", nil, true) + test("unknown engine fails", "ollama,nosuchengine", nil, true) } // An engine left out of --proxy-engines is not started, and neither is any diff --git a/services/nvpair-ui-broker/enginesettings_test.go b/services/nvpair-ui-broker/enginesettings_test.go index e276c579c..75b9a5173 100644 --- a/services/nvpair-ui-broker/enginesettings_test.go +++ b/services/nvpair-ui-broker/enginesettings_test.go @@ -48,7 +48,7 @@ func newSettingsHarness(t *testing.T) *settingsHarness { func newSettingsHarnessForEngine(t *testing.T, engine string) *settingsHarness { t.Helper() - ports := make([]int, 4) + ports := make([]int, 5) listeners := []net.Listener{} for i := range ports { ln, err := net.Listen("tcp", "127.0.0.1:0") @@ -67,6 +67,7 @@ func newSettingsHarnessForEngine(t *testing.T, engine string) *settingsHarness { "ollama": {ready: true, port: ports[1]}, "lmstudio": {ready: true, port: ports[2]}, "llamacpp": {ready: true, port: ports[3]}, + "vllm": {ready: true, port: ports[4]}, }} for _, profile := range engineProxyProfiles { h.b.setEngineProxyHandle(profile, proxy) diff --git a/services/nvpair-ui-broker/proxyaddressing_test.go b/services/nvpair-ui-broker/proxyaddressing_test.go index 436b40b79..b907a5786 100644 --- a/services/nvpair-ui-broker/proxyaddressing_test.go +++ b/services/nvpair-ui-broker/proxyaddressing_test.go @@ -197,7 +197,7 @@ func TestReadiness_IsTrackedPerEngine(t *testing.T) { // An engine that never announced itself is not ready, rather than // inheriting a sibling's port. - ready, port := p.Status("vllm") + ready, port := p.Status("nosuchengine") assert.False(t, ready, "an unannounced engine reported ready") assert.Equal(t, 0, port, "an unannounced engine reported a port") } diff --git a/services/readme.md b/services/readme.md index a880ed572..b37ed04e4 100644 --- a/services/readme.md +++ b/services/readme.md @@ -12,7 +12,7 @@ node offers and where to reach them. What a discovered node can actually serve is a separate question, answered after discovery. A node may be running [Ollama](https://ollama.com/), LM Studio, -llama.cpp, any combination, or none, and its model inventory is fetched over +llama.cpp, vLLM, any combination, or none, and its model inventory is fetched over HTTP from its engine-manager rather than crammed into mDNS TXT records, which are too small to carry it. @@ -28,11 +28,13 @@ can serve that loopback-only address from the same router when it is free — `localhost` is claimed on IPv4 and IPv6 together, and remote or HTTPS targets are never intercepted. -The llama.cpp facade is enabled by default alongside Ollama and LM Studio. It -exposes OpenAI-compatible traffic on `http://localhost:8080` while the managed -router runs on `8081`; a safe fallback is reported when the facade port is -occupied. The desktop and TUI expose its managed lifecycle, downloads, -inventory, and load/unload actions. `--proxy-engines` can still restrict the +The llama.cpp and vLLM facades are enabled by default alongside Ollama and LM Studio. +The llama.cpp facade exposes OpenAI-compatible traffic on `http://localhost:8080` +while the managed router runs on `8081`. The vLLM facade (the engine itself is +Linux only) uses `http://localhost:8000` with its managed engine on `8001`. A safe fallback is reported when a facade port is +occupied. The desktop and TUI expose each engine's managed lifecycle and +inventory, plus llama.cpp's downloads and load/unload actions; vLLM serves one +model per process, so changing its model means restarting it. `--proxy-engines` can still restrict the facades a standalone broker or TUI starts. PAIR ships a graphical UI alongside these services. The UI launches @@ -70,7 +72,7 @@ configuration. The broker feeds every accepted local or peer workload transition plus compact GPU telemetry to the scheduler. Queued and running work is counted by destination -node across Ollama, LM Studio, and llama.cpp together. Fresh maximum-GPU +node across Ollama, LM Studio, llama.cpp, and vLLM together. Fresh maximum-GPU utilization is smoothed into pressure 0–3; missing or stale telemetry is neutral. Rankings use `pending + gpuPressure`, and each facade adds local reservations before choosing, so bursts spread without waiting for workload diff --git a/services/shared/engines/engines.go b/services/shared/engines/engines.go index ed5a7d9a1..3db23e56e 100644 --- a/services/shared/engines/engines.go +++ b/services/shared/engines/engines.go @@ -131,6 +131,7 @@ const ( NameOllama = "ollama" NameLMStudio = "lmstudio" NameLlamaCPP = "llamacpp" + NameVLLM = "vllm" ) // all is the ordered engine set. Ollama is first; see the package comment. @@ -159,6 +160,14 @@ var all = []Engine{ EnginePortBase: 8081, PortFile: "llamacpp-proxy-port.json", }, + { + Name: NameVLLM, + DisplayName: "vLLM", + DiscoveryService: noderec.ServiceVLLM, + FacadePort: 8000, + EnginePortBase: 8001, + PortFile: "vllm-proxy-port.json", + }, } // All returns the engine set in preparation order. The result is a copy, so a diff --git a/services/shared/engines/engines_test.go b/services/shared/engines/engines_test.go index 38994adce..1de832f86 100644 --- a/services/shared/engines/engines_test.go +++ b/services/shared/engines/engines_test.go @@ -23,7 +23,7 @@ func TestOllama_IsPreparedFirst(t *testing.T) { } func TestEngineNames_ReturnsCanonicalOrder(t *testing.T) { - assert.Equal(t, []string{"ollama", "lmstudio", "llamacpp"}, Names()) + assert.Equal(t, []string{"ollama", "lmstudio", "llamacpp", "vllm"}, Names()) } // There are two proxy identities: ComponentName per facade, ProxyComponent per @@ -112,7 +112,7 @@ func TestEngineLookup_ResolvesDeclaredNamesAndKeys(t *testing.T) { assert.Equal(t, e.ComponentName(), byName.ComponentName()) } - _, ok := ByName("vllm") + _, ok := ByName("nosuchengine") assert.False(t, ok, "ByName should report ok=false for an unknown engine") } diff --git a/services/shared/noderec/noderec.go b/services/shared/noderec/noderec.go index f44a263d6..4fb022c5e 100644 --- a/services/shared/noderec/noderec.go +++ b/services/shared/noderec/noderec.go @@ -10,7 +10,7 @@ // whose TXT map carries a schema version, the node's identity, its LAN address, // and one compact key per local service port, e.g.: // -// v=1;uuid=;cluster-uuid=;ip=192.168.1.10;ni=14318;ol=11434;lm=1234;lc=8080;er=14319;wl=14320;cl=14321;em=14322 +// v=1;uuid=;cluster-uuid=;ip=192.168.1.10;ni=14318;ol=11434;lm=1234;lc=8080;vl=8000;er=14319;wl=14320;cl=14321;em=14322 // // Design decisions this package encodes: // - SRV port is a fixed, NON-authoritative constant; consumers ignore it and @@ -88,6 +88,7 @@ const ( ServiceOllama ServiceKey = "ol" ServiceLMStudio ServiceKey = "lm" ServiceLlamaCPP ServiceKey = "lc" + ServiceVLLM ServiceKey = "vl" ServiceErrors ServiceKey = "er" ServiceWorkload ServiceKey = "wl" ServiceCluster ServiceKey = "cl" @@ -105,7 +106,7 @@ const ( // serviceKeyOrder is the deterministic emit order for service ports in TXT. var serviceKeyOrder = []ServiceKey{ - ServiceNodeInfo, ServiceOllama, ServiceLMStudio, ServiceLlamaCPP, + ServiceNodeInfo, ServiceOllama, ServiceLMStudio, ServiceLlamaCPP, ServiceVLLM, ServiceErrors, ServiceWorkload, ServiceCluster, ServiceEngineManager, ServiceEngineControl, } From 1b9e57dc7a875556f0c91ad7cf57003c144e1462 Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 28 Oct 2026 22:25:11 +0530 Subject: [PATCH 05/35] test(services): cover the bundled vLLM manifest and proxy interop Check the bundled vLLM manifest's install, launch and model actions, that the install refuses drivers without CUDA 13, and, across real binaries, that the broker starts a vLLM proxy and the facade serves the model list and routes exact model ids. Signed-off-by: Tushar Khinvasara --- .../vllm_manifest_test.go | 196 ++++++++++++++++++ services/tests/vllm_interop_test.go | 165 +++++++++++++++ 2 files changed, 361 insertions(+) create mode 100644 services/nvpair-engine-manager/vllm_manifest_test.go create mode 100644 services/tests/vllm_interop_test.go diff --git a/services/nvpair-engine-manager/vllm_manifest_test.go b/services/nvpair-engine-manager/vllm_manifest_test.go new file mode 100644 index 000000000..c8a0fad27 --- /dev/null +++ b/services/nvpair-engine-manager/vllm_manifest_test.go @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" +) + +// vLLM is Linux-only, installs through a pinned uv bootstrap, serves exactly +// one model per process and has no load, unload, pull or delete API. These +// are the properties the rest of the stack (UI capabilities, the model wire, +// the install gate) relies on, so a manifest edit that drops one fails here. +func TestBundledVLLMManifest(t *testing.T) { + reg := loadWithOverrides(t, t.TempDir()) + manifest, ok := reg.Get("vllm") + if !ok { + t.Fatal("vllm manifest not loaded") + } + + if got := len(manifest.Platforms); got != 2 { + t.Fatalf("vllm declares %d platforms, want linux/amd64 and linux/arm64 only", got) + } + for _, key := range []string{"linux/amd64", "linux/arm64"} { + platform, ok := manifest.Platforms[key] + if !ok { + t.Fatalf("vllm has no %s platform block", key) + } + // Our managed install must win over an external one. + if len(platform.Detect) < 2 || !strings.HasPrefix(platform.Detect[0], "{install_dir}/") { + t.Errorf("%s detect order = %v, want the managed install first", key, platform.Detect) + } + if platform.Install == nil || platform.Install.Fetch == nil || platform.Install.Fetch.SHA256 == "" { + t.Errorf("%s: the uv bootstrap must be a sha256-pinned fetch", key) + continue + } + if len(platform.Install.Script) != 0 { + t.Errorf("%s: install must not use the unpinned script escape hatch", key) + } + if !strings.Contains(strings.Join(platform.Install.Run, " "), "vllm==") { + t.Errorf("%s: the vLLM version must be pinned", key) + } + rt := platform.Runtime + if rt.EditableLaunch == nil || !slices.Equal(rt.EditableLaunch.FixedArgs, []string{"serve"}) { + t.Errorf("%s: launch must start with the fixed serve subcommand", key) + } + if platform.ModelsDir != "~/.cache/huggingface" { + t.Errorf("%s: models_dir = %q, want the Hugging Face cache", key, platform.ModelsDir) + } + // vLLM compiles FlashInfer sampling kernels at first start and that build + // cannot cope with the spaces in PAIR's app-data path + // ("Nvidia Corporation/Personal AI Router"), so it must stay off. It also + // keeps a system CUDA toolkit and C++ compiler from being required. + if rt.Env["VLLM_USE_FLASHINFER_SAMPLER"] != "0" { + t.Errorf("%s: the FlashInfer sampler must be disabled", key) + } + // vLLM reserves 92% of GPU memory by default, which starves every other + // engine on the machine even for a tiny model. + if !slices.Contains(rt.Args, "--gpu-memory-utilization") { + t.Errorf("%s: the default launch must cap GPU memory use", key) + } + if rt.Port != 8001 { + t.Errorf("%s: engine port = %d, want 8001 (the facade owns 8000)", key, rt.Port) + } + } + + for _, name := range []string{"list_models", "loaded_models"} { + if _, ok := manifest.Actions[name]; !ok { + t.Errorf("vllm must declare %s", name) + } + } + // list_models is what is on disk; only the model being served is advertised. + if list := manifest.Actions["list_models"]; list.HFCache == nil || list.HFCache.Dir != "{models_dir}/hub" { + t.Errorf("list_models must list the Hugging Face cache, got %+v", list) + } + if manifest.AdvertisedModels != advertisedModelsLoaded { + t.Errorf("advertised_models = %q, want %q: peers and the proxy must only be offered the served model", manifest.AdvertisedModels, advertisedModelsLoaded) + } + // Switching the model is a launch-argument change plus a restart, since the + // engine has no load call. + if load := manifest.Actions["load_model"]; load.LaunchModel == nil || load.LaunchModel.Flag != "--model" { + t.Errorf("load_model must switch the model through --model, got %+v", load) + } + for _, name := range []string{"unload_model", "pull_model", "delete_model"} { + if _, ok := manifest.Actions[name]; ok { + t.Errorf("vllm serves one model per process and must not declare %s", name) + } + } +} + +// The vLLM wheel is a CUDA 13 build, so an older driver installs cleanly and +// then fails at startup, after a multi-GB download. The install script has to +// refuse before downloading anything. This runs the manifest's own script, with +// its placeholders resolved the way the executor resolves them, against a fake +// nvidia-smi. Recent drivers print "CUDA UMD Version" and mark the older +// "CUDA Version" field deprecated, so both spellings have to be understood. +func TestVLLMInstallRefusesDriversWithoutCUDA13(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("the vLLM install script is Linux only") + } + tools := map[string]string{} + for _, name := range []string{"sh", "awk", "df", "mkdir", "tar"} { + path, err := exec.LookPath(name) + if err != nil { + t.Skipf("%s is not available", name) + } + tools[name] = path + } + + reg := loadWithOverrides(t, t.TempDir()) + manifest, ok := reg.Get("vllm") + if !ok { + t.Fatal("vllm manifest not loaded") + } + + cases := []struct { + name string + smiOutput *string + wantCUDA bool + wantInText string + }{ + {name: "no nvidia-smi", wantInText: "nvidia-smi not found"}, + {name: "old driver", smiOutput: ptr("CUDA Version : 12.4\n"), wantInText: "CUDA 13 or newer (found: 12)"}, + {name: "unrecognised output", smiOutput: ptr("Driver Version : 1.2.3\n"), wantInText: "CUDA 13 or newer (found: none)"}, + {name: "current driver format", smiOutput: ptr("CUDA Version : 13.4 [Deprecated; will be removed in CUDA 14.0. Use CUDA UMD Version instead]\nCUDA UMD Version : 13.4\n"), wantCUDA: true}, + {name: "older CUDA 13 driver format", smiOutput: ptr("CUDA Version : 13.0\n"), wantCUDA: true}, + {name: "UMD only", smiOutput: ptr("CUDA UMD Version : 14.0\n"), wantCUDA: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + bin := t.TempDir() + for name, path := range tools { + if name != "sh" { + if err := os.Symlink(path, filepath.Join(bin, name)); err != nil { + t.Fatal(err) + } + } + } + if tc.smiOutput != nil { + script := "#!/bin/sh\nprintf '%s' '" + *tc.smiOutput + "'\n" + if err := os.WriteFile(filepath.Join(bin, "nvidia-smi"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + } + + installDir := t.TempDir() + argv, err := resolveArgs(manifest.Platforms["linux/amd64"].Install.Run, map[string]string{ + "download": filepath.Join(installDir, "missing-uv.tar.gz"), + "install_dir": installDir, + }) + if err != nil { + t.Fatal(err) + } + // The executor expands OS environment references in every install + // argument after resolving placeholders (install.go), so "$name" in + // the script would be replaced before the shell ever saw it. Apply + // the same expansion, and require it to be a no-op: only positional + // parameters and "${name:-default}" forms survive it. + for i := range argv { + if expanded := expandPath(argv[i]); expanded != argv[i] { + t.Fatalf("the executor rewrites install argument %d, which breaks the script:\n%s\n-> %s", i, argv[i], expanded) + } + } + cmd := exec.Command(tools["sh"], argv[1:]...) + cmd.Env = []string{"PATH=" + bin} + out, runErr := cmd.CombinedOutput() + text := string(out) + + if tc.wantCUDA { + // The driver check passed. The script goes on to fail later, + // because the archive does not exist; it must not blame the driver. + if strings.Contains(text, "NVIDIA driver") { + t.Fatalf("a CUDA 13 driver was refused: %s", text) + } + return + } + if runErr == nil { + t.Fatalf("install was not refused: %s", text) + } + if !strings.Contains(text, tc.wantInText) { + t.Fatalf("output %q does not contain %q", text, tc.wantInText) + } + if _, err := os.Stat(filepath.Join(installDir, "uv")); err == nil { + t.Fatal("the script started installing before it refused") + } + }) + } +} + +func ptr(s string) *string { return &s } diff --git a/services/tests/vllm_interop_test.go b/services/tests/vllm_interop_test.go new file mode 100644 index 000000000..1a54767bf --- /dev/null +++ b/services/tests/vllm_interop_test.go @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func TestVLLMProxyIsIncludedInBrokerDefaults(t *testing.T) { + stdin, msgs, stderr, cleanup := startBrokerWith(t, "--proxy-path", proxyBin) + t.Cleanup(cleanup) + go func() { + for range stderr { + } + }() + + waitForMethod(t, msgs, "app:ready", 10*time.Second) + if port := waitEngineProxyReady(t, "vllm-proxy", stdin, msgs, 15*time.Second); port <= 0 { + t.Fatalf("default vLLM proxy port = %d, want a listening facade", port) + } +} + +func TestBrokerVLLMProxySetPortRejectsInvalidPorts(t *testing.T) { + stdin, msgs, stderr, cleanup := startBrokerWith(t, + "--proxy-path", proxyBin, "--proxy-engines", "vllm", + ) + t.Cleanup(cleanup) + go func() { + for range stderr { + } + }() + waitForMethod(t, msgs, "app:ready", 10*time.Second) + for i, tc := range []struct{ name, params string }{ + {"missing port", `{}`}, + {"zero port", `{"port":0}`}, + {"negative port", `{"port":-1}`}, + {"oversized port", `{"port":65536}`}, + } { + t.Run(tc.name, func(t *testing.T) { + id := 7400 + i + if _, err := fmt.Fprintf(stdin, `{"jsonrpc":"2.0","id":%d,"method":"vllm-proxy:set-port","params":%s}`+"\n", id, tc.params); err != nil { + t.Fatalf("write proxy port request: %v", err) + } + response := waitForResponse(t, msgs, 10*time.Second) + if response.ID == nil || string(*response.ID) != fmt.Sprint(id) { + t.Fatalf("unexpected response ID: %+v", response) + } + if response.Error == nil || response.Error.Code != -32602 || response.Error.Message != "port must be between 1 and 65535" { + t.Fatalf("error = %+v, want broker invalid-port rejection", response.Error) + } + }) + } +} + +// vLLM serves one model per process under its Hugging Face id, so a facade in +// front of it must route only the exact id a node advertises. +func TestVLLMFacadeServesModelListAndRoutesExactModelIDs(t *testing.T) { + const model = "Qwen/Qwen2.5-0.5B-Instruct" + var modelListHits atomic.Int32 + var inferenceHits atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/models": + modelListHits.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{ + "object": "list", + "data": []map[string]string{{"id": model}}, + }) + case r.Method == http.MethodPost && r.URL.Path == "/v1/chat/completions": + inferenceHits.Add(1) + _, _ = io.Copy(io.Discard, r.Body) + _, _ = io.WriteString(w, `{"choices":[]}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(upstream.Close) + + stdin, msgs, stderr, cleanup := startBrokerWith(t, + "--proxy-path", proxyBin, "--proxy-engines", "vllm", + ) + t.Cleanup(cleanup) + go func() { + for range stderr { + } + }() + + waitForMethod(t, msgs, "app:ready", 10*time.Second) + proxyPort := waitEngineProxyReady(t, "vllm-proxy", stdin, msgs, 15*time.Second) + callBrokerRPC(t, stdin, msgs, 7500, "vllm-proxy:node/add-manual", map[string]any{ + "id": "vllm-owner", + "host": "127.0.0.1", + "port": portOfURL(t, upstream.URL), + "addresses": []string{"127.0.0.1"}, + "models": []string{model}, + }) + + client := &http.Client{Timeout: 5 * time.Second} + t.Cleanup(client.CloseIdleConnections) + + t.Run("serves the OpenAI model list", func(t *testing.T) { + response, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/v1/models", proxyPort)) + if err != nil { + t.Fatalf("get model list: %v", err) + } + defer response.Body.Close() + var list struct { + Object string `json:"object"` + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.NewDecoder(response.Body).Decode(&list); err != nil { + t.Fatalf("decode model list: %v", err) + } + found := false + for _, item := range list.Data { + if item.ID == model { + found = true + } + } + if response.StatusCode != http.StatusOK || list.Object != "list" || !found || modelListHits.Load() == 0 { + t.Fatalf("model list status=%d body=%+v upstreamHits=%d", + response.StatusCode, list, modelListHits.Load()) + } + }) + + post := func(t *testing.T, requestedModel string) int { + t.Helper() + endpoint := fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", proxyPort) + response, err := client.Post(endpoint, "application/json", + bytes.NewBufferString(fmt.Sprintf(`{"model":%q,"messages":[]}`, requestedModel))) + if err != nil { + t.Fatalf("post inference: %v", err) + } + if _, err := io.Copy(io.Discard, response.Body); err != nil { + t.Fatalf("read inference response: %v", err) + } + if err := response.Body.Close(); err != nil { + t.Fatalf("close inference response: %v", err) + } + return response.StatusCode + } + t.Run("routes only the exact advertised model id", func(t *testing.T) { + if status := post(t, model); status != http.StatusOK { + t.Fatalf("matching model status = %d, want 200", status) + } + if status := post(t, "qwen/qwen2.5-0.5b-instruct"); status != http.StatusBadGateway { + t.Fatalf("case-changed model status = %d, want 502", status) + } + if got := inferenceHits.Load(); got != 1 { + t.Fatalf("upstream inference hits = %d, want only the exact match", got) + } + }) +} From bb2065d3c17d1b01fc0efa7ee1965d84b685b85a Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Fri, 16 Oct 2026 15:08:22 +0530 Subject: [PATCH 06/35] feat(desktop): add vLLM engine support and docs Show vLLM next to the other engines: type, display name, links, welcome-screen default (Linux only), proxy identity and port state, icon and downloaded-model matcher. The icon is an original lettermark, not the vLLM project logo. vLLM serves one model per process and has no pull, eject or delete call, so a new hasPullModel capability hides Add model for it. Its model list is the Hugging Face cache and Load switches the served model by restarting vLLM; that load is observed so a failure is attributed to the model row, and the row stays locked for up to 20 minutes while the engine restarts. Document the engine across the desktop and top-level docs, tick the roadmap item, and add the vLLM model store to the data-reset notes. Signed-off-by: Tushar Khinvasara --- README.md | 8 +-- desktop/docs/architecture.md | 4 +- desktop/docs/frontend-api.md | 6 +- desktop/docs/services-backend.md | 2 +- desktop/docs/services-parity.md | 23 +++++++ .../electron/service-bridge/empty-handlers.ts | 14 ++++- .../electron/service-bridge/modular-state.ts | 6 +- .../electron/service-bridge/proxy-engines.ts | 10 +++- desktop/src/shared/constants/engines.ts | 14 +++-- desktop/src/ui/assets/engine-icons/vllm.svg | 10 ++++ desktop/src/ui/components/EngineIcon.tsx | 9 +++ .../components/ModelManager/ModelManager.tsx | 4 +- .../ServiceSettings/WipeAppDataCard.tsx | 4 +- .../src/ui/constants/engine-capabilities.ts | 24 ++++++++ desktop/src/ui/constants/welcome.ts | 3 +- .../src/ui/stores/pending-actions.store.ts | 9 +++ desktop/src/ui/types/engine-manifest.ts | 7 +++ .../src/ui/utils/match-downloaded-model.ts | 3 +- .../tests/modular/engine-command-load.test.ts | 34 +++++++++++ desktop/tests/modular/engine-identity.test.ts | 35 ++++++++++- .../modular/inference-demo-lifecycle.test.ts | 3 +- .../modular/pending-actions-delete.test.ts | 20 +++++++ .../modular/proxy-engine-identity.test.ts | 60 ++++++++++++++++++- docs/engine-lifecycle.mdx | 20 ++++++- docs/engine-settings.mdx | 7 ++- docs/getting-started.mdx | 4 +- docs/overview.mdx | 2 +- 27 files changed, 308 insertions(+), 37 deletions(-) create mode 100644 desktop/src/ui/assets/engine-icons/vllm.svg diff --git a/README.md b/README.md index 276b02d60..df7dc5c48 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ one, and both report live GPU and memory use throughout. | **Architectures** | x64 and arm64 on all three. Windows on ARM is experimental. | | **Installers** | Windows `.exe`; Linux `.deb`; macOS `.dmg`. On other Linux distributions, [build from source](docs/building.mdx). | | **Mixing nodes** | Windows, Linux, and macOS nodes can all be paired with each other | -| **Inference engines** | Ollama, LM Studio, and llama.cpp | +| **Inference engines** | Ollama, LM Studio, llama.cpp, and vLLM (Linux only) | **PAIR running on a machine does not mean an engine will.** PAIR itself runs on any supported Windows, Linux, or macOS machine. Each engine sets its own requirements @@ -100,7 +100,7 @@ you want by its full filename instead. **Settings → Service** and read the status there. - **Get an engine running.** On the node's card, open **Engine settings** and - select **Install** next to Ollama, LM Studio, or llama.cpp. PAIR downloads and + select **Install** next to Ollama, LM Studio, llama.cpp, or (on Linux) vLLM. PAIR downloads and sets the engine up for you, so nothing needs to be in place beforehand. ![The Install engines dialog with Ollama downloading, reporting progress as it installs.](docs/assets/onboarding/engine-lifecycle/01-engine-installing.png) @@ -198,7 +198,7 @@ Your data means settings, logs, cluster identity and certificates, and any engin PAIR installed for you. An engine the uninstaller cannot remove — one still holding a file open, say — is left in place rather than stopping the uninstall; remove it with its own uninstaller. **Model weights are not touched** — they live in the -engine's own storage (`~/.ollama`, `~/.llamacpp`, `~/.lmstudio/models`), so +engine's own storage (`~/.ollama`, `~/.llamacpp`, `~/.lmstudio/models`, `~/.cache/huggingface`), so removing PAIR does not delete the models you downloaded. Delete those through the engine, or by removing its directory. An engine you installed yourself is also left alone. @@ -283,7 +283,7 @@ feedback and contributions will help shape priorities. ### Engines and integrations - [x] llama.cpp support. -- [ ] vLLM support. +- [x] vLLM support. - [ ] EXO support. - [ ] ComfyUI integration. - [ ] Unsloth support. diff --git a/desktop/docs/architecture.md b/desktop/docs/architecture.md index 44d48e57b..94928ec35 100644 --- a/desktop/docs/architecture.md +++ b/desktop/docs/architecture.md @@ -250,7 +250,7 @@ not select or pin proxy routes. An NVPAIR-launched engine binds to loopback and is never directly LAN-reachable. Peers reach it only through the node's proxy over a cluster-mTLS ingress, so discovery advertises the promoted proxy port for `ol`/`lm` rather than the -engine's private port; llama.cpp uses the additional `lc` key. Transport +engine's private port; llama.cpp uses the additional `lc` key and vLLM the `vl` key. Transport security is backend-owned; Electron only reflects the advertised proxy port and reads a remote engine's real port from `engine:remote-get-installed` facts. @@ -408,7 +408,7 @@ deletes finish. Unpackaged (`electron-vite dev`) builds omit relaunch and the UI tells the developer to run `npm start` again — quitting Electron tears down the Vite renderer server, so auto-relaunch would come up with nothing to load. -The engines' model stores (`~/.ollama`, `~/.llamacpp`, `~/.lmstudio/models`) and +The engines' model stores (`~/.ollama`, `~/.llamacpp`, `~/.lmstudio/models`, `~/.cache/huggingface`) and the application install tree are never removed. The reset does uninstall the engines PAIR installed, through `engine:uninstall-managed` while the broker is still running, so an engine a vendor installer placed in the user's home is diff --git a/desktop/docs/frontend-api.md b/desktop/docs/frontend-api.md index 6209ca269..7e99175d3 100644 --- a/desktop/docs/frontend-api.md +++ b/desktop/docs/frontend-api.md @@ -99,7 +99,11 @@ They do not imply a WebSocket connection. Browser clients are not supported. - `onSettingsDisconnected(callback)` reports that a node's settings authority became unreachable, so its cached snapshot is stale. -`EngineType` currently includes `ollama`, `lm-studio`, and `llama-cpp`. +`EngineType` currently includes `ollama`, `lm-studio`, `llama-cpp`, and `vllm`. +vLLM serves one model per process, so it offers no eject, delete, Add model or +catalog search. Its model list is the Hugging Face cache, and `loadModel` on one +of them switches the served model by restarting vLLM on it (the capability flag +`hasPullModel: false` hides Add model). `searchHub(engineType, query?)` serves Ollama's locked local catalog and cached live Hugging Face catalogs for LM Studio and llama.cpp. A non-empty llama.cpp query performs an explicit bounded search across public Hugging Face GGUF diff --git a/desktop/docs/services-backend.md b/desktop/docs/services-backend.md index 8168d7f3f..3f50ae2f4 100644 --- a/desktop/docs/services-backend.md +++ b/desktop/docs/services-backend.md @@ -116,7 +116,7 @@ reserved for inference clients. | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | | `app:ready` | Complete broker startup and refresh snapshots | `state:request-refresh` | | `discovery:nodes-changed` | Replace discovery snapshot and diff nodes | `discovery:nodes-changed`, `nodes:upsert`, `nodes:remove` | -| `ollama-proxy:ready` / `lmstudio-proxy:ready` / `llamacpp-proxy:ready` | Record engine proxy port | `engines:state-changed` | +| `ollama-proxy:ready` / `lmstudio-proxy:ready` / `llamacpp-proxy:ready` / `vllm-proxy:ready` | Record engine proxy port | `engines:state-changed` | | proxy `node/*` | Update per-engine node presence; the advertised port is the peer's promoted proxy port (not the engine's private loopback port) | node and engine pushes | | `engine:ready` / `engine:state-changed` | Update engine facts and models | `engines:state-changed` | | `engine:settings-changed` | Validate and republish the owning node's settings snapshot | `engines:settings-changed` | diff --git a/desktop/docs/services-parity.md b/desktop/docs/services-parity.md index 36bba35f6..9c1b27af9 100644 --- a/desktop/docs/services-parity.md +++ b/desktop/docs/services-parity.md @@ -25,6 +25,7 @@ history. | Ollama routing | Complete | Broker relay and backend scheduler drive proxy routing | | LM Studio routing | Complete | Parallel broker relay and scheduler path | | llama.cpp backend | Integrated | Desktop and TUI expose install, lifecycle, catalog pull, inventory, load/unload, endpoints, routing, and demo traffic | +| vLLM backend | Integrated (Linux) | Desktop and TUI expose install, lifecycle, the cached-model list, switching the served model (Load), endpoints and routing; no eject, delete, pull or catalog; no demo traffic | | Local engine lifecycle | Complete | Install, start, stop, uninstall, update, and port configuration | | Remote engine lifecycle | Partial | Remote install, start, stop, status, and model pull are supported | | Engine models | Partial | Core list, pull, load, unload, and supported delete actions are wired | @@ -182,6 +183,28 @@ Current limits are explicit: the manual-node worker does not probe llama.cpp, the catalog offers no alternate quantizations, and package selection is fixed rather than driver-aware or offline-repacked. +### vLLM support + +The bundled `vllm` manifest is Linux only. It installs a pinned `vllm` wheel +into a private virtualenv through a checksum-pinned `uv` bootstrap, or adopts a +`vllm` already installed at a detected path, then runs `vllm serve` on `8001` +behind the proxy facade on `8000`. vLLM serves one model per process and has no +load, unload, pull or delete API, so the desktop hides Eject, Delete and Add +model for it and offers no catalog. Its model list is the Hugging Face cache +(`list_models` is an `hf_cache` action, so it works while the engine is +stopped), and Load switches the served model through `load_model` (a +`launch_model` action): it saves `--model ` into the launch arguments and +restarts the engine, restoring the previous model if the new one does not start. +Only the served model is advertised to peers and the proxy +(`advertised_models: loaded`), since a request for any other cached model would +fail at the engine. Weights live in the shared +Hugging Face cache (`~/.cache/huggingface`), which uninstall and app-data reset +never remove. + +Current limits are explicit: the manual-node worker does not probe vLLM, the +Inference Demo does not use it, and the icon is a placeholder lettermark rather +than the vLLM project logo. + ## Engine lifecycle `nvpair-engine-manager` is authoritative for installed, running, healthy, and diff --git a/desktop/src/electron/service-bridge/empty-handlers.ts b/desktop/src/electron/service-bridge/empty-handlers.ts index e00893226..890b20911 100644 --- a/desktop/src/electron/service-bridge/empty-handlers.ts +++ b/desktop/src/electron/service-bridge/empty-handlers.ts @@ -260,18 +260,26 @@ function routeEngineManagerCommand(payload: WsInvokeRequest<'engine:command'>): // (`/api/generate`) with no prompt: Ollama loads the model into VRAM // and returns immediately (`done_reason: "load"`) without generating. // LM Studio and llama.cpp declare real `load_model` manifest actions. + // vLLM's `load_model` switches the served model by restarting the + // engine on it, which takes minutes and can fail (the model may not + // fit), so like Ollama's its rejection is observed and attributed + // to the pending model row. // All require the engine running (HTTP/CLI action), matching the // disabled rule in ModelRow.tsx. // See docs/services-parity.md#models. if (payload.model) { - if (payload.engineType === 'ollama') { + if (payload.engineType === 'ollama' || payload.engineType === 'vllm') { supervisor.sendProcess( 'broker', 'engine:action', { engine, - action: 'run_model', - params: { model: payload.model, stream: false } + ...(payload.engineType === 'ollama' + ? { + action: 'run_model', + params: { model: payload.model, stream: false } + } + : { action: 'load_model', params: { model: payload.model } }) }, failAction('load model', { nodeId: payload.nodeId, diff --git a/desktop/src/electron/service-bridge/modular-state.ts b/desktop/src/electron/service-bridge/modular-state.ts index 0c7390bf7..39717869c 100644 --- a/desktop/src/electron/service-bridge/modular-state.ts +++ b/desktop/src/electron/service-bridge/modular-state.ts @@ -168,7 +168,8 @@ function emptyEngines(): Record { return { ollama: emptyPresence(), 'lm-studio': emptyPresence(), - 'llama-cpp': emptyPresence() + 'llama-cpp': emptyPresence(), + vllm: emptyPresence() } } @@ -890,7 +891,8 @@ class ModularBridgeState { private proxyPorts: Record = { ollama: 0, 'lm-studio': 0, - 'llama-cpp': 0 + 'llama-cpp': 0, + vllm: 0 } private selfId: string | null = null /** diff --git a/desktop/src/electron/service-bridge/proxy-engines.ts b/desktop/src/electron/service-bridge/proxy-engines.ts index 447246af0..1a3226552 100644 --- a/desktop/src/electron/service-bridge/proxy-engines.ts +++ b/desktop/src/electron/service-bridge/proxy-engines.ts @@ -8,8 +8,8 @@ import type { EngineManagerName, EngineType } from '@/shared/types/engines' * separate from the complete engine registry so an engine can be declared * before every proxy-state consumer is ready to handle it. */ -export type ProxyEngine = Extract -export type ProxyNodeSource = 'ollama-proxy' | 'lmstudio-proxy' | 'llamacpp-proxy' +export type ProxyEngine = Extract +export type ProxyNodeSource = 'ollama-proxy' | 'lmstudio-proxy' | 'llamacpp-proxy' | 'vllm-proxy' interface ProxyEngineIdentity { managerName: EngineManagerName @@ -28,10 +28,14 @@ const PROXY_ENGINE_IDENTITIES: Record = { 'llama-cpp': { managerName: 'llamacpp', source: 'llamacpp-proxy' + }, + vllm: { + managerName: 'vllm', + source: 'vllm-proxy' } } -export const PROXY_ENGINES: readonly ProxyEngine[] = ['ollama', 'lm-studio', 'llama-cpp'] +export const PROXY_ENGINES: readonly ProxyEngine[] = ['ollama', 'lm-studio', 'llama-cpp', 'vllm'] export const PROXY_NODE_SOURCES: readonly ProxyNodeSource[] = PROXY_ENGINES.map( engine => PROXY_ENGINE_IDENTITIES[engine].source diff --git a/desktop/src/shared/constants/engines.ts b/desktop/src/shared/constants/engines.ts index 15970f2ce..f9c13c9e7 100644 --- a/desktop/src/shared/constants/engines.ts +++ b/desktop/src/shared/constants/engines.ts @@ -5,11 +5,11 @@ import { EngineType, ModelExpiry } from '@/shared/types/engines' // The engines `nvpair-engine-manager` ships a manifest for, and therefore the // only ones PAIR can install, run or route to. -export const EngineTypes = ['ollama', 'lm-studio', 'llama-cpp'] as const +export const EngineTypes = ['ollama', 'lm-studio', 'llama-cpp', 'vllm'] as const // Kept as a distinct export so a future engine can ship behind it rather than // appearing the moment its type exists. -export const EnabledEngineTypes: EngineType[] = ['ollama', 'lm-studio', 'llama-cpp'] +export const EnabledEngineTypes: EngineType[] = ['ollama', 'lm-studio', 'llama-cpp', 'vllm'] /** * How `nvpair-engine-manager` spells each engine on the wire. This is the @@ -19,7 +19,8 @@ export const EnabledEngineTypes: EngineType[] = ['ollama', 'lm-studio', 'llama-c export const EngineManagerNames = { ollama: 'ollama', 'lm-studio': 'lmstudio', - 'llama-cpp': 'llamacpp' + 'llama-cpp': 'llamacpp', + vllm: 'vllm' } as const satisfies Record export const EngineSources = ['bundled', 'detected', 'installed'] as const @@ -27,7 +28,8 @@ export const EngineSources = ['bundled', 'detected', 'installed'] as const export const EngineDisplayNames: Record = { ollama: 'Ollama', 'lm-studio': 'LM Studio', - 'llama-cpp': 'llama.cpp' + 'llama-cpp': 'llama.cpp', + vllm: 'vLLM' } as const /** Default docs/install URLs for built-in backends. Single source of truth for UI and adapter buildInfo(). */ @@ -37,6 +39,10 @@ export const EngineDefaultLinks: Record + + + + + + + + + diff --git a/desktop/src/ui/components/EngineIcon.tsx b/desktop/src/ui/components/EngineIcon.tsx index bb1618a0e..326d4dbae 100644 --- a/desktop/src/ui/components/EngineIcon.tsx +++ b/desktop/src/ui/components/EngineIcon.tsx @@ -6,6 +6,7 @@ import { type EngineType } from '@/shared/types/engines' import ollamaIcon from '@/ui/assets/engine-icons/ollama.png?inline' import lmStudioIcon from '@/ui/assets/engine-icons/lm-studio.png?inline' import llamaCppIcon from '@/ui/assets/engine-icons/llama-cpp.svg?inline' +import vllmIcon from '@/ui/assets/engine-icons/vllm.svg?inline' export default function EngineIcon({ type, size = 32 }: { type: EngineType; size?: number }) { const dimension = `${size}px` @@ -48,5 +49,13 @@ export default function EngineIcon({ type, size = 32 }: { type: EngineType; size ) } + if (type === 'vllm') { + return ( +
+ vLLM +
+ ) + } + return null } diff --git a/desktop/src/ui/components/ModelManager/ModelManager.tsx b/desktop/src/ui/components/ModelManager/ModelManager.tsx index e63dacf29..7faea7db4 100644 --- a/desktop/src/ui/components/ModelManager/ModelManager.tsx +++ b/desktop/src/ui/components/ModelManager/ModelManager.tsx @@ -165,8 +165,8 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId ) const isRunning = useMemo(() => backend.processStatus === 'running', [backend.processStatus]) const supportsSearch = useMemo( - () => !hasModelSearchOnlyWhenRunning || isRunning, - [hasModelSearchOnlyWhenRunning, isRunning] + () => (caps?.hasPullModel ?? false) && (!hasModelSearchOnlyWhenRunning || isRunning), + [caps?.hasPullModel, hasModelSearchOnlyWhenRunning, isRunning] ) const handleDownload = useCallback( diff --git a/desktop/src/ui/components/ServiceSettings/WipeAppDataCard.tsx b/desktop/src/ui/components/ServiceSettings/WipeAppDataCard.tsx index b29ea06bb..4e6f24888 100644 --- a/desktop/src/ui/components/ServiceSettings/WipeAppDataCard.tsx +++ b/desktop/src/ui/components/ServiceSettings/WipeAppDataCard.tsx @@ -17,8 +17,8 @@ function wipeMessage() { Your downloaded models (for example ~/.ollama, ~/.llamacpp - , and ~/.lmstudio/models) are not deleted, and neither - are engines you installed yourself. + , ~/.lmstudio/models, and ~/.cache/huggingface) are{' '} + not deleted, and neither are engines you installed yourself. If an engine {APP_DISPLAY_NAME} installed cannot be removed, the reset stops before diff --git a/desktop/src/ui/constants/engine-capabilities.ts b/desktop/src/ui/constants/engine-capabilities.ts index 2626a2e42..6f8d0d121 100644 --- a/desktop/src/ui/constants/engine-capabilities.ts +++ b/desktop/src/ui/constants/engine-capabilities.ts @@ -20,6 +20,7 @@ export const EngineCapabilities: Record = { hasModelSearchOnlyWhenRunning: true, modelOpsWhenStopped: false, hasDeleteModel: true, + hasPullModel: true, engineHub: { label: 'Ollama', url: 'https://ollama.com/library' } }, 'lm-studio': { @@ -38,6 +39,7 @@ export const EngineCapabilities: Record = { hasModelSearchOnlyWhenRunning: true, modelOpsWhenStopped: false, hasDeleteModel: true, + hasPullModel: true, // LM Studio answers /v1/models from an index it builds at startup and // exposes no rescan, so nvpair-engine-manager's delete_model restarts the // server. Deleting therefore interrupts inference and needs a warning. @@ -57,9 +59,31 @@ export const EngineCapabilities: Record = { modelOpsWhenStopped: false, // The router removes cached models in-process via DELETE /models. hasDeleteModel: true, + hasPullModel: true, engineHub: { label: 'llama.cpp', url: 'https://huggingface.co/models?library=gguf' } + }, + vllm: { + hasExpiry: false, + // vLLM serves one model per process and has no unload API; changing + // the model means restarting the engine with different launch arguments. + hasEject: false, + // Linux only: vLLM has no native Windows or macOS GPU path. + hasInstall: ['linux'], + hasEnginePort: true, + hasInstallPath: false, + hasProxyWebUI: false, + hasPreferredNode: false, + hasCrashAlert: false, + hasModelSearchOnlyWhenRunning: true, + modelOpsWhenStopped: false, + // The engine manager declares no delete_model for vLLM; weights live in + // the shared Hugging Face cache. + hasDeleteModel: false, + // No pull_model either. The model list is what the Hugging Face cache + // already holds, and Load switches the served model by restarting vLLM. + hasPullModel: false } } diff --git a/desktop/src/ui/constants/welcome.ts b/desktop/src/ui/constants/welcome.ts index 4db425f71..d235f8d03 100644 --- a/desktop/src/ui/constants/welcome.ts +++ b/desktop/src/ui/constants/welcome.ts @@ -14,7 +14,8 @@ export const WELCOME_STEP_SUB_HEADINGS = ['', 'You can update later by clicking export const WELCOME_ENGINE_DEFAULT_SELECTED: Record = { ollama: true, 'lm-studio': true, - 'llama-cpp': true + 'llama-cpp': true, + vllm: true } export function getWelcomeEngineCandidates(os: PlatformDisplayName): EngineType[] { diff --git a/desktop/src/ui/stores/pending-actions.store.ts b/desktop/src/ui/stores/pending-actions.store.ts index 4b1e1a294..a70c1bff3 100644 --- a/desktop/src/ui/stores/pending-actions.store.ts +++ b/desktop/src/ui/stores/pending-actions.store.ts @@ -39,6 +39,12 @@ import { useEngineStatusStore } from '@/ui/stores/engine-status.store' const PENDING_TIMEOUT_MS = 60_000 /** Keep Ollama Load locked one safety-net interval beyond the longest (11m) backend path. */ const OLLAMA_LOAD_PENDING_TIMEOUT_MS = 12 * 60_000 +/** + * vLLM Load restarts the engine on the new model: up to 8 minutes of readiness + * plus the same again if the previous model has to be restored. Keep the row + * locked past that. + */ +const VLLM_LOAD_PENDING_TIMEOUT_MS = 20 * 60_000 /** * Safety net for a delete on an engine whose backend restarts to pick the * deletion up (`EngineCaps.restartsOnModelDelete` / the manifest's @@ -110,6 +116,9 @@ function timeoutFor(command: EngineCommandType, engineType: EngineType): number if (command === 'loadModel' && engineType === 'ollama') { return OLLAMA_LOAD_PENDING_TIMEOUT_MS } + if (command === 'loadModel' && engineType === 'vllm') { + return VLLM_LOAD_PENDING_TIMEOUT_MS + } if (command === 'deleteModel' && EngineCapabilities[engineType]?.restartsOnModelDelete) { return RESTART_DELETE_TIMEOUT_MS } diff --git a/desktop/src/ui/types/engine-manifest.ts b/desktop/src/ui/types/engine-manifest.ts index 1cd998bfe..767400c0c 100644 --- a/desktop/src/ui/types/engine-manifest.ts +++ b/desktop/src/ui/types/engine-manifest.ts @@ -36,6 +36,13 @@ export interface EngineCaps { modelOpsWhenStopped: boolean /** When true, show the Delete action in the model action menu. */ hasDeleteModel: boolean + /** + * When true, the engine can download a model on request (a `pull_model` + * action), so the model list offers "Add model". An engine that serves one + * model per process has no such call: it lists what is already on disk and + * switches between those models with Load. + */ + hasPullModel: boolean /** * When true, the engine restarts as part of deleting a model, so Delete asks * for confirmation first. The engine manager owns the restart; this flag only diff --git a/desktop/src/ui/utils/match-downloaded-model.ts b/desktop/src/ui/utils/match-downloaded-model.ts index cdce8576a..57610cf15 100644 --- a/desktop/src/ui/utils/match-downloaded-model.ts +++ b/desktop/src/ui/utils/match-downloaded-model.ts @@ -43,7 +43,8 @@ const matchExactName: DownloadedMatcher = (hubEntry, downloaded) => downloaded.n const MATCHERS: Partial> = { ollama: matchOllama, 'lm-studio': matchHfPullKeyOrName, - 'llama-cpp': matchExactName + 'llama-cpp': matchExactName, + vllm: matchExactName } export function isHubEntryDownloaded( diff --git a/desktop/tests/modular/engine-command-load.test.ts b/desktop/tests/modular/engine-command-load.test.ts index c26ae49db..f7aefcfaf 100644 --- a/desktop/tests/modular/engine-command-load.test.ts +++ b/desktop/tests/modular/engine-command-load.test.ts @@ -70,6 +70,40 @@ describe('local model load command', () => { ) }) + it('switches the vLLM model through load_model and attributes a failure to the model row', async () => { + await handleServiceBridgeInvoke('engine:command', { + command: 'loadModel', + engineType: 'vllm', + nodeId: 'local-node', + model: 'Qwen/Qwen3-8B' + }) + + expect(mocks.supervisor.sendProcess).toHaveBeenCalledOnce() + const [name, method, params, onFailure, observeResponse] = + mocks.supervisor.sendProcess.mock.calls[0] + // Observed: the switch restarts the engine and can fail, and the + // backend reports that failure only through this response. + expect([name, method, params, observeResponse]).toEqual([ + 'broker', + 'engine:action', + { engine: 'vllm', action: 'load_model', params: { model: 'Qwen/Qwen3-8B' } }, + true + ]) + + onFailure('did not become ready; the previous model was restored', true) + expect(mocks.supervisor.reportError).toHaveBeenCalledWith( + 'Failed to load model on vllm: did not become ready; the previous model was restored', + 'error', + 'engine-cmd:load model:vllm', + { + nodeId: 'local-node', + engineType: 'vllm', + operation: 'load', + modelName: 'Qwen/Qwen3-8B' + } + ) + }) + it('leaves the LM Studio load path unobserved', async () => { await handleServiceBridgeInvoke('engine:command', { command: 'loadModel', diff --git a/desktop/tests/modular/engine-identity.test.ts b/desktop/tests/modular/engine-identity.test.ts index 9456e583e..2714df83b 100644 --- a/desktop/tests/modular/engine-identity.test.ts +++ b/desktop/tests/modular/engine-identity.test.ts @@ -16,8 +16,8 @@ import { gatewayEndpointDisplayUrl } from '@/ui/utils/gateway-inference-paths' describe('engine identity', () => { it('enables llama.cpp workflows on every platform', () => { - expect(EngineTypes).toEqual(['ollama', 'lm-studio', 'llama-cpp']) - expect(EnabledEngineTypes).toEqual(['ollama', 'lm-studio', 'llama-cpp']) + expect(EngineTypes).toEqual(['ollama', 'lm-studio', 'llama-cpp', 'vllm']) + expect(EnabledEngineTypes).toEqual(['ollama', 'lm-studio', 'llama-cpp', 'vllm']) expect(getWelcomeEngineCandidates('Windows')).toContain('llama-cpp') expect(getWelcomeEngineCandidates('MacOS')).toContain('llama-cpp') expect(getWelcomeEngineCandidates('Linux')).toContain('llama-cpp') @@ -27,10 +27,39 @@ describe('engine identity', () => { expect(WELCOME_ENGINE_DEFAULT_SELECTED).toEqual({ ollama: true, 'lm-studio': true, - 'llama-cpp': true + 'llama-cpp': true, + vllm: true }) }) + it('offers vLLM only where it can be installed', () => { + expect(getWelcomeEngineCandidates('Linux')).toContain('vllm') + expect(getWelcomeEngineCandidates('Windows')).not.toContain('vllm') + expect(getWelcomeEngineCandidates('MacOS')).not.toContain('vllm') + }) + + it('maps vLLM to its engine-manager wire id and one-model-per-process capabilities', () => { + expect(EngineManagerNames.vllm).toBe('vllm') + expect(engineManagerName('vllm')).toBe('vllm') + expect(engineTypeFromManagerName('vllm')).toBe('vllm') + expect(isEngineType('vllm')).toBe(true) + expect(EngineDisplayNames.vllm).toBe('vLLM') + // vLLM has no load, unload or delete API and no public catalog. + expect(EngineCapabilities.vllm).toMatchObject({ + hasEject: false, + hasDeleteModel: false, + hasInstall: ['linux'], + hasEnginePort: true + }) + expect(EngineCapabilities.vllm.engineHub).toBeUndefined() + }) + + it('offers Add model only to engines that can download on request', () => { + for (const engineType of EngineTypes) { + expect(EngineCapabilities[engineType].hasPullModel).toBe(engineType !== 'vllm') + } + }) + it('maps the desktop id to the sole engine-manager wire id', () => { expect(EngineManagerNames['llama-cpp']).toBe('llamacpp') expect(engineManagerName('llama-cpp')).toBe('llamacpp') diff --git a/desktop/tests/modular/inference-demo-lifecycle.test.ts b/desktop/tests/modular/inference-demo-lifecycle.test.ts index 54cb20fb0..f355a7663 100644 --- a/desktop/tests/modular/inference-demo-lifecycle.test.ts +++ b/desktop/tests/modular/inference-demo-lifecycle.test.ts @@ -50,7 +50,8 @@ const POISONED_ENV: Record = { const proxyPorts: Record = { ollama: 11434, 'lm-studio': 1234, - 'llama-cpp': 8080 + 'llama-cpp': 8080, + vllm: 8000 } /** Model inventory each probe returns. Mutable so a test can return none. */ diff --git a/desktop/tests/modular/pending-actions-delete.test.ts b/desktop/tests/modular/pending-actions-delete.test.ts index fb1640d8f..8c00f23d4 100644 --- a/desktop/tests/modular/pending-actions-delete.test.ts +++ b/desktop/tests/modular/pending-actions-delete.test.ts @@ -244,6 +244,26 @@ describe('pending-actions store: model actions', () => { ).toBeUndefined() }) + it('keeps a vLLM load locked while the engine restarts on the new model', () => { + usePendingActionsStore.getState().begin({ + command: 'loadModel', + engineType: 'vllm', + nodeId: 'local-node', + model: 'Qwen/Qwen3-8B' + }) + + // Well past the default net: readiness alone can take 8 minutes. + vi.advanceTimersByTime(12 * 60_000) + expect( + usePendingActionsStore.getState().getModelPending('local-node', 'vllm', 'Qwen/Qwen3-8B') + ).toBe('loadModel') + + vi.advanceTimersByTime(8 * 60_000 + 1_000) + expect( + usePendingActionsStore.getState().getModelPending('local-node', 'vllm', 'Qwen/Qwen3-8B') + ).toBeUndefined() + }) + it('leaves the default net in place for LM Studio loads', () => { usePendingActionsStore.getState().begin({ command: 'loadModel', diff --git a/desktop/tests/modular/proxy-engine-identity.test.ts b/desktop/tests/modular/proxy-engine-identity.test.ts index 69031984f..8fe3f0a91 100644 --- a/desktop/tests/modular/proxy-engine-identity.test.ts +++ b/desktop/tests/modular/proxy-engine-identity.test.ts @@ -19,11 +19,17 @@ import { engineManagerName } from '@/shared/utils/engines' describe('proxy engine identity', () => { it('declares the complete proxy set in stable order', () => { - expect(PROXY_ENGINES).toEqual(['ollama', 'lm-studio', 'llama-cpp']) - expect(PROXY_NODE_SOURCES).toEqual(['ollama-proxy', 'lmstudio-proxy', 'llamacpp-proxy']) + expect(PROXY_ENGINES).toEqual(['ollama', 'lm-studio', 'llama-cpp', 'vllm']) + expect(PROXY_NODE_SOURCES).toEqual([ + 'ollama-proxy', + 'lmstudio-proxy', + 'llamacpp-proxy', + 'vllm-proxy' + ]) expect(isProxyEngine('ollama')).toBe(true) expect(isProxyEngine('lm-studio')).toBe(true) expect(isProxyEngine('llama-cpp')).toBe(true) + expect(isProxyEngine('vllm')).toBe(true) }) it('round-trips engine, manager, and relay source identities', () => { @@ -35,6 +41,7 @@ describe('proxy engine identity', () => { expect(proxyEngineFromSource('other-proxy')).toBeNull() expect(proxyEngineFromManagerId('other')).toBeNull() expect(proxyEngineFromManagerId('llamacpp')).toBe('llama-cpp') + expect(proxyEngineFromManagerId('vllm')).toBe('vllm') }) it('routes each proxy notification to the mapped engine', () => { @@ -192,4 +199,53 @@ describe('proxy engine identity', () => { )?.models ).toEqual([expect.objectContaining({ name: model, status: 'loaded' })]) }) + it('projects local vLLM lifecycle and its single served model into the initial snapshot', () => { + const state = getModularBridgeState() + const nodeId = 'proxy-identity-vllm-local' + const model = 'Qwen/Qwen2.5-0.5B-Instruct' + state.setSelfId(nodeId) + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { + nodes: [ + { + hostUuid: nodeId, + name: 'proxy-identity-vllm-host', + ipAddress: '127.0.0.1', + port: 14318 + } + ] + } + }) + state.handleNotification({ + source: 'vllm-proxy', + method: 'ready', + params: { port: 8000 } + }) + state.applyEngineManagerStatus({ + engine: 'vllm', + installed: true, + running: true, + healthy: true, + port: 8001 + }) + state.setLocalEngineModels('vllm', [model]) + state.applyLocalLoadedModels({ vllm: [model] }) + + const initial = state.getEngineInitialState() + expect( + initial.statuses.find( + status => status.nodeId === nodeId && status.engineType === 'vllm' + ) + ).toMatchObject({ + processStatus: 'running', + enginePort: 8001, + proxyPort: 8000 + }) + expect( + initial.models.find(models => models.nodeId === nodeId && models.engineType === 'vllm') + ?.models + ).toEqual([expect.objectContaining({ name: model, status: 'loaded' })]) + }) }) diff --git a/docs/engine-lifecycle.mdx b/docs/engine-lifecycle.mdx index 028f59127..44054cb49 100644 --- a/docs/engine-lifecycle.mdx +++ b/docs/engine-lifecycle.mdx @@ -9,7 +9,7 @@ SPDX-License-Identifier: Apache-2.0 */} An **engine** is the local inference runtime NVIDIA PAIR uses to -run models. Today that means Ollama, LM Studio, or llama.cpp on a given machine. +run models. Today that means Ollama, LM Studio, llama.cpp, or vLLM on a given machine. PAIR can install and run those engines for you, or work with a supported existing Ollama or LM Studio installation. This page explains what you can expect when you install, start, stop, update, or remove an engine. @@ -88,6 +88,22 @@ When you install an engine, consider the following: demand and is not part of the PAIR installer. Package choice is not driver-aware; llama.cpp retains CPU fallback when GPU acceleration is unavailable. +- vLLM is available on Linux with an NVIDIA GPU. If a `vllm` is already + installed at a known location (`~/.local/bin/vllm`), PAIR uses it and + downloads nothing. Otherwise PAIR downloads a checksum-pinned `uv`, creates a + private Python environment and installs a pinned vLLM release into it; this + is several GiB because it includes PyTorch and the CUDA libraries, and PAIR + first checks for an NVIDIA driver that supports CUDA 13 or newer and about + 15 GB of free disk, and stops with a clear message if either is missing. + Nothing is compiled. vLLM serves a single model per process. Its model list + shows the complete text models already in your Hugging Face cache + (`~/.cache/huggingface`), and **Load** on one of them switches vLLM to it by + restarting the engine, which takes as long as the model needs to load; if the + new model does not start, the previous one is restored. There is no Eject, + Delete or Add model: to use a model that is not cached, name it in the launch + arguments (`--model `), and vLLM downloads it on start. Other + nodes and the proxy see only the model vLLM is serving, so a request for a + different cached model is refused until you switch to it. PAIR-managed llama.cpp models sleep after five minutes without inference work. This releases model and KV-cache memory, and the next request wakes the model @@ -130,7 +146,7 @@ an engine PAIR installed. Use it when you no longer want PAIR to own that engine on the machine. Uninstalling an engine does **not** remove its models. Downloaded model files stay -on disk in the engine's own storage — `~/.ollama`, `~/.llamacpp`, or +on disk in the engine's own storage — `~/.ollama`, `~/.llamacpp`, `~/.cache/huggingface`, or `~/.lmstudio/models` — so uninstalling and reinstalling does not cost you re-downloading them. The same is true of removing PAIR itself, or of resetting its data. Both remove the engines PAIR installed as far as they can, and never diff --git a/docs/engine-settings.mdx b/docs/engine-settings.mdx index 63af45394..bb3847f43 100644 --- a/docs/engine-settings.mdx +++ b/docs/engine-settings.mdx @@ -8,7 +8,7 @@ SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All SPDX-License-Identifier: Apache-2.0 */} -Open a device's Ollama, LM Studio, or llama.cpp row and expand **Settings**. +Open a device's Ollama, LM Studio, llama.cpp, or vLLM row and expand **Settings**. Edit the server port, proxy port and engine arguments, then select the green **Apply** button. A paired device that supports settings can be edited from another device in the cluster. @@ -85,6 +85,7 @@ configuration consistent: | Ollama | `OLLAMA_HOST=127.0.0.1:` | The host in `OLLAMA_HOST` | `OLLAMA_ORIGINS` | | LM Studio | `--port`, `-p` | `--bind`, `LMS_SERVER_HOST` | `--cors` | | llama.cpp | `--port` | `--host` | `--cors-origins` | +| vLLM | `--port` | `--host` | Not managed | For LM Studio, `--port 1235`, `--port=1235`, `-p 1235`, `-p1235` and `-p=1235` all synchronize the same server-port field. Conflicting repetitions and invalid @@ -94,6 +95,10 @@ add or remove the switch locally. Origin lists are normalized before validation; conflicting repeated lists are rejected. Other vendor options remain opaque. llama.cpp must remain in router mode; model-selection arguments such as `-m` or `-hf` switch it to an incompatible single-model server and fail readiness. +vLLM always serves one model: its launch arguments name that model with +`--model `, and changing it restarts the engine. By default it +uses at most half of the GPU's memory (`--gpu-memory-utilization 0.5`) so other +engines can run alongside it; raise that value for a larger model. Changing the server-port field updates the port in the command. Editing the command's port updates the numeric field. If both are edited before validation diff --git a/docs/getting-started.mdx b/docs/getting-started.mdx index 876bb35c2..f37faa1ff 100644 --- a/docs/getting-started.mdx +++ b/docs/getting-started.mdx @@ -35,7 +35,7 @@ inference; two or more on the same local network let you try pairing and routing Nothing else has to be in place first: -- **Engines.** PAIR installs and starts Ollama, LM Studio, or llama.cpp for you +- **Engines.** PAIR installs and starts Ollama, LM Studio, llama.cpp, or (on Linux) vLLM for you in step 4. Existing Ollama and LM Studio installations can also be detected and used. - **Models.** PAIR downloads models for you in step 4. A request needs only one @@ -210,6 +210,7 @@ engine would normally listen on: - `11434` for Ollama. - `1234` for LM Studio. - `8080` for llama.cpp. +- `8000` for vLLM. Tools already pointed at those ports keep working without reconfiguration, and the engine moves to the next free port. @@ -239,6 +240,7 @@ The engine therefore has to understand the style you send. | Ollama | Works | Works | | LM Studio | Works | Not available | | llama.cpp | Works | Not available | +| vLLM | Works | Not available | **If you are unsure, send the OpenAI-style request.** It works with every supported engine, and most tools and SDKs use it. Use the Ollama style diff --git a/docs/overview.mdx b/docs/overview.mdx index 04ebe1c48..9183c6178 100644 --- a/docs/overview.mdx +++ b/docs/overview.mdx @@ -42,7 +42,7 @@ These terms have specific meanings in PAIR: - **Cluster** — the set of nodes you have paired together. A node belongs to at most one cluster, and it must leave before it can join another. - **Engine** — the local inference server that runs models: Ollama, LM Studio, - or llama.cpp. PAIR can install, start, stop, and update these engines; it can + llama.cpp, or vLLM. PAIR can install, start, stop, and update these engines; it can also adopt supported existing Ollama and LM Studio installations. - **Model** — what you prepare on each node. Nodes do not share models, so a node can serve a request only for a model it already holds. Preparing the same From 07cdf21fe88c936c7b7320dee8abe052c66e6571 Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Fri, 16 Oct 2026 21:10:07 +0530 Subject: [PATCH 07/35] fix(nvpair-engine-manager): cap vLLM default context length Models with a 256K default context need more KV cache than the default 0.5 GPU memory share provides and fail to start. Pass --max-model-len 32768 by default and document it. Signed-off-by: Tushar Khinvasara --- docs/engine-settings.mdx | 4 +++- services/nvpair-engine-manager/README.md | 3 +++ services/nvpair-engine-manager/manifests/vllm.json | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/engine-settings.mdx b/docs/engine-settings.mdx index bb3847f43..88a116380 100644 --- a/docs/engine-settings.mdx +++ b/docs/engine-settings.mdx @@ -98,7 +98,9 @@ or `-hf` switch it to an incompatible single-model server and fail readiness. vLLM always serves one model: its launch arguments name that model with `--model `, and changing it restarts the engine. By default it uses at most half of the GPU's memory (`--gpu-memory-utilization 0.5`) so other -engines can run alongside it; raise that value for a larger model. +engines can run alongside it; raise that value for a larger model. It also caps +the context at 32K tokens (`--max-model-len 32768`), because models with a very +long default context would otherwise not fit in that memory and fail to start. Changing the server-port field updates the port in the command. Editing the command's port updates the numeric field. If both are edited before validation diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index 03fb52af6..464b6dc53 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -54,6 +54,9 @@ The default launch serves `Qwen/Qwen2.5-0.5B-Instruct` through The default launch also passes `--gpu-memory-utilization 0.5`: vLLM reserves 92% of GPU memory by default, even for a tiny model, which would starve every other engine on the machine. Raise it in the launch arguments for a larger model. +It also passes `--max-model-len 32768`: many current models advertise a context of +256K tokens or more, and vLLM refuses to start unless the KV cache for that full +length fits in the memory it was given. Raise it only if the GPU has room. The environment sets `VLLM_USE_FLASHINFER_SAMPLER=0`. vLLM's FlashInfer sampler compiles GPU kernels on first start, and that build cannot handle the spaces in PAIR's app-data folder (`Nvidia Corporation/Personal AI Router`), so the diff --git a/services/nvpair-engine-manager/manifests/vllm.json b/services/nvpair-engine-manager/manifests/vllm.json index b390ff184..98b881273 100644 --- a/services/nvpair-engine-manager/manifests/vllm.json +++ b/services/nvpair-engine-manager/manifests/vllm.json @@ -16,7 +16,7 @@ { "flags": ["--port"], "value": "{server.port}" } ] }, - "args": ["serve", "--model", "Qwen/Qwen2.5-0.5B-Instruct", "--gpu-memory-utilization", "0.5", "--host", "{host}", "--port", "{port}"], + "args": ["serve", "--model", "Qwen/Qwen2.5-0.5B-Instruct", "--gpu-memory-utilization", "0.5", "--max-model-len", "32768", "--host", "{host}", "--port", "{port}"], "env": { "HF_HOME": "{models_dir}", "VLLM_USE_FLASHINFER_SAMPLER": "0" }, "bind": "127.0.0.1", "port": 8001, From 8f65d1e85b611163db365470b3ca21bc9698b557 Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Fri, 16 Oct 2026 21:10:07 +0530 Subject: [PATCH 08/35] fix(desktop): keep vLLM shown as starting while a model switch restarts it vLLM serves one model per process, so loading another restarts the engine. Show 'starting' instead of 'stopped', keep the model list, and await the load call so the switch does not look like the engine was disabled. Signed-off-by: Tushar Khinvasara --- desktop/docs/services-parity.md | 5 +- .../electron/service-bridge/empty-handlers.ts | 33 ++++-- .../electron/service-bridge/modular-state.ts | 54 ++++++++- .../service-bridge/modular-supervisor.ts | 4 + .../src/shared/constants/modular-runtime.ts | 8 ++ .../src/ui/stores/pending-actions.store.ts | 9 +- .../tests/modular/engine-command-load.test.ts | 98 +++++++++++----- .../modular/lmstudio-stale-model.test.ts | 51 ++++++++ .../tests/modular/model-switch-status.test.ts | 111 ++++++++++++++++++ 9 files changed, 327 insertions(+), 46 deletions(-) create mode 100644 desktop/tests/modular/model-switch-status.test.ts diff --git a/desktop/docs/services-parity.md b/desktop/docs/services-parity.md index 9c1b27af9..36f1a72d2 100644 --- a/desktop/docs/services-parity.md +++ b/desktop/docs/services-parity.md @@ -197,7 +197,10 @@ stopped), and Load switches the served model through `load_model` (a restarts the engine, restoring the previous model if the new one does not start. Only the served model is advertised to peers and the proxy (`advertised_models: loaded`), since a request for any other cached model would -fail at the engine. Weights live in the shared +fail at the engine. A switch passes through a stopped engine for the length of the +restart, so the desktop marks the engine as switching: its card shows starting +rather than stopped, and its model list is kept, until the load request finishes +(success, or failure with the previous model restored). Weights live in the shared Hugging Face cache (`~/.cache/huggingface`), which uninstall and app-data reset never remove. diff --git a/desktop/src/electron/service-bridge/empty-handlers.ts b/desktop/src/electron/service-bridge/empty-handlers.ts index 890b20911..9a59edfa1 100644 --- a/desktop/src/electron/service-bridge/empty-handlers.ts +++ b/desktop/src/electron/service-bridge/empty-handlers.ts @@ -7,6 +7,7 @@ import type { ClusterNode, ClusterNodeIdentity, Invite } from '@/shared/types/cl import type { EngineType } from '@/shared/types/engines' import type { ServiceError } from '@/shared/types/errors' import { + MODEL_SWITCH_TIMEOUT_MS, MODULAR_CLUSTER_MANAGER_PORT, MODULAR_ENGINE_LIFECYCLE_CALL_TIMEOUT_MS } from '@/shared/constants/modular-runtime' @@ -268,18 +269,36 @@ function routeEngineManagerCommand(payload: WsInvokeRequest<'engine:command'>): // disabled rule in ModelRow.tsx. // See docs/services-parity.md#models. if (payload.model) { - if (payload.engineType === 'ollama' || payload.engineType === 'vllm') { + if (payload.engineType === 'vllm') { + // vLLM switches model by restarting, so the engine passes through + // "stopped" on the way. Show that as starting rather than as + // turned off, and end it when the request finishes: the reply + // arrives once the new model is serving (or the old one restored). + state.beginLocalModelSwitch('vllm') + void supervisor + .callProcess( + 'broker', + 'engine:action', + { engine, action: 'load_model', params: { model: payload.model } }, + MODEL_SWITCH_TIMEOUT_MS + ) + .catch(err => + failAction('load model', { + nodeId: payload.nodeId, + engineType: payload.engineType, + operation: 'load', + modelName: payload.model + })(getErrorString(err)) + ) + .finally(() => state.clearLocalModelSwitch('vllm')) + } else if (payload.engineType === 'ollama') { supervisor.sendProcess( 'broker', 'engine:action', { engine, - ...(payload.engineType === 'ollama' - ? { - action: 'run_model', - params: { model: payload.model, stream: false } - } - : { action: 'load_model', params: { model: payload.model } }) + action: 'run_model', + params: { model: payload.model, stream: false } }, failAction('load model', { nodeId: payload.nodeId, diff --git a/desktop/src/electron/service-bridge/modular-state.ts b/desktop/src/electron/service-bridge/modular-state.ts index 39717869c..db48f564c 100644 --- a/desktop/src/electron/service-bridge/modular-state.ts +++ b/desktop/src/electron/service-bridge/modular-state.ts @@ -18,6 +18,7 @@ import type { Workload, WorkloadState } from '@/shared/types/workloads' import type { ClusterNode, Invite } from '@/shared/types/cluster' import type { WsInvokeResponse } from '@/shared/types/ws-channels' import { + MODEL_SWITCH_TIMEOUT_MS, MODULAR_NODE_INFO_SELF_HOST, MODULAR_SUPERSEDE_MIN_AGE_MS } from '@/shared/constants/modular-runtime' @@ -930,6 +931,15 @@ class ModularBridgeState { */ private pendingEngineOps = new Map() private pendingOpTimers = new Map>() + /** + * Engines in the middle of a model switch, which restarts the engine. Unlike + * a pending op this is not resolved by `engine:state-changed`: the restart + * reports "stopped" first, and showing that as the engine being off made it + * look disabled. It ends when the switch request itself finishes (success or + * failure), with a timeout in case that never arrives. + */ + private modelSwitches = new Set() + private modelSwitchTimers = new Map>() /** * Optimistic transitional status for a remote peer while a user-initiated * `engine:remote-*` op is in flight on this node (the client). Keyed by @@ -1353,6 +1363,42 @@ class ModularBridgeState { this.emitLocalEngineStatus(engineType) } + /** + * Mark an engine as switching model. While it is down for the restart the UI + * shows it as starting, not stopped, and its model list is kept. The caller + * ends it with {@link clearLocalModelSwitch} when the request finishes. + */ + beginLocalModelSwitch(engineType: EngineType): void { + if (!this.selfId) return + this.dropModelSwitch(engineType) + this.modelSwitches.add(engineType) + this.modelSwitchTimers.set( + engineType, + setTimeout(() => this.clearLocalModelSwitch(engineType), MODEL_SWITCH_TIMEOUT_MS) + ) + this.emitLocalEngineStatus(engineType) + } + + /** End a model switch and show the engine's real status again. */ + clearLocalModelSwitch(engineType: EngineType): void { + if (!this.modelSwitches.has(engineType)) return + this.dropModelSwitch(engineType) + this.emitLocalEngineStatus(engineType) + } + + isLocalModelSwitching(engineType: EngineType): boolean { + return this.modelSwitches.has(engineType) + } + + private dropModelSwitch(engineType: EngineType): void { + const timer = this.modelSwitchTimers.get(engineType) + if (timer) { + clearTimeout(timer) + this.modelSwitchTimers.delete(engineType) + } + this.modelSwitches.delete(engineType) + } + /** * Begin an optimistic transitional status for a local-engine lifecycle op so * the UI shows a spinner / progress line immediately. The backend never emits @@ -2052,9 +2098,11 @@ class ModularBridgeState { nodeId, processStatus: facts.running ? 'running' - : facts.installed - ? 'stopped' - : 'not-installed', + : this.modelSwitches.has(engineType) + ? 'starting' + : facts.installed + ? 'stopped' + : 'not-installed', // The engine-manager reports the manifest's configured port for a // pinned-port engine (for example managed llama.cpp on 8081 behind // its 8080 proxy facade) whether it is running or merely installed, diff --git a/desktop/src/electron/service-bridge/modular-supervisor.ts b/desktop/src/electron/service-bridge/modular-supervisor.ts index 519f7b91a..3bd83b5e4 100644 --- a/desktop/src/electron/service-bridge/modular-supervisor.ts +++ b/desktop/src/electron/service-bridge/modular-supervisor.ts @@ -1990,6 +1990,10 @@ class ModularSupervisor { if (!engineType) return if (!booleanValue(obj.running)) { + // A model switch restarts the engine, so it reports stopped on the + // way. The list of models does not change, and clearing it made the + // card empty until the engine was back. + if (getModularBridgeState().isLocalModelSwitching(engineType)) return this.beginModelRefresh(engineType) this.stoppedModelEngines.add(engineType) this.stoppedModelSentinels.add(engineType) diff --git a/desktop/src/shared/constants/modular-runtime.ts b/desktop/src/shared/constants/modular-runtime.ts index 7486df965..acfc96575 100644 --- a/desktop/src/shared/constants/modular-runtime.ts +++ b/desktop/src/shared/constants/modular-runtime.ts @@ -79,6 +79,14 @@ export const MODULAR_ENGINE_LIFECYCLE_CALL_TIMEOUT_MS = 14 * 60_000 // docs/services-parity.md. export const MODULAR_MODEL_ACTION_TIMEOUT_MS = 120_000 +/** + * How long a model switch on an engine that restarts to change model (vLLM) may + * take before the app stops waiting for it. The restart can need up to eight + * minutes to become ready, and a failed switch restores the previous model with a + * second restart. + */ +export const MODEL_SWITCH_TIMEOUT_MS = 20 * 60_000 + // Upper bound for `engine:catalog`. The engine manager allows its own LM Studio // fetch 20s, and only replies once that resolves, so the RPC envelope has to sit // outside that budget or a slow catalogue rejects here while the backend is diff --git a/desktop/src/ui/stores/pending-actions.store.ts b/desktop/src/ui/stores/pending-actions.store.ts index a70c1bff3..e776af582 100644 --- a/desktop/src/ui/stores/pending-actions.store.ts +++ b/desktop/src/ui/stores/pending-actions.store.ts @@ -28,6 +28,7 @@ * state. */ import { create } from 'zustand' +import { MODEL_SWITCH_TIMEOUT_MS } from '@/shared/constants/modular-runtime' import { EngineCapabilities } from '@/ui/constants/engine-capabilities' import type { EngineCommandPayload, EngineCommandType } from '@/shared/types/engine-api' import type { EngineProcessStatus, EngineType } from '@/shared/types/engines' @@ -39,12 +40,8 @@ import { useEngineStatusStore } from '@/ui/stores/engine-status.store' const PENDING_TIMEOUT_MS = 60_000 /** Keep Ollama Load locked one safety-net interval beyond the longest (11m) backend path. */ const OLLAMA_LOAD_PENDING_TIMEOUT_MS = 12 * 60_000 -/** - * vLLM Load restarts the engine on the new model: up to 8 minutes of readiness - * plus the same again if the previous model has to be restored. Keep the row - * locked past that. - */ -const VLLM_LOAD_PENDING_TIMEOUT_MS = 20 * 60_000 +/** vLLM Load restarts the engine on the new model; keep the row locked as long as the app waits for it. */ +const VLLM_LOAD_PENDING_TIMEOUT_MS = MODEL_SWITCH_TIMEOUT_MS /** * Safety net for a delete on an engine whose backend restarts to pick the * deletion up (`EngineCaps.restartsOnModelDelete` / the manifest's diff --git a/desktop/tests/modular/engine-command-load.test.ts b/desktop/tests/modular/engine-command-load.test.ts index f7aefcfaf..39aa4b242 100644 --- a/desktop/tests/modular/engine-command-load.test.ts +++ b/desktop/tests/modular/engine-command-load.test.ts @@ -5,11 +5,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ state: { - getSelfId: vi.fn(() => 'local-node') + getSelfId: vi.fn(() => 'local-node'), + beginLocalModelSwitch: vi.fn(), + clearLocalModelSwitch: vi.fn() }, supervisor: { hasProcess: vi.fn(() => true), sendProcess: vi.fn(), + callProcess: vi.fn(), reportError: vi.fn() } })) @@ -70,38 +73,75 @@ describe('local model load command', () => { ) }) - it('switches the vLLM model through load_model and attributes a failure to the model row', async () => { + describe('vLLM model switch', () => { + const load = () => + handleServiceBridgeInvoke('engine:command', { + command: 'loadModel', + engineType: 'vllm', + nodeId: 'local-node', + model: 'Qwen/Qwen3-8B' + }) + const flush = () => new Promise(resolve => setTimeout(resolve, 0)) + + it('awaits load_model and shows the engine as starting, not stopped, until it finishes', async () => { + let finish: (value: undefined) => void = () => undefined + mocks.supervisor.callProcess.mockReturnValue( + new Promise(resolve => { + finish = resolve + }) + ) + await load() + + // The switch restarts the engine, so it is marked before the request is sent. + expect(mocks.state.beginLocalModelSwitch).toHaveBeenCalledWith('vllm') + expect(mocks.supervisor.sendProcess).not.toHaveBeenCalled() + expect(mocks.supervisor.callProcess).toHaveBeenCalledWith( + 'broker', + 'engine:action', + { engine: 'vllm', action: 'load_model', params: { model: 'Qwen/Qwen3-8B' } }, + 20 * 60_000 + ) + // Still in progress: the marker stays. + await flush() + expect(mocks.state.clearLocalModelSwitch).not.toHaveBeenCalled() + + finish(undefined) + await flush() + expect(mocks.state.clearLocalModelSwitch).toHaveBeenCalledWith('vllm') + expect(mocks.supervisor.reportError).not.toHaveBeenCalled() + }) + + it('attributes a failure to the model row and ends the marker', async () => { + mocks.supervisor.callProcess.mockRejectedValue( + new Error('did not become ready; the previous model was restored') + ) + await load() + await flush() + + expect(mocks.supervisor.reportError).toHaveBeenCalledWith( + 'Failed to load model on vllm: did not become ready; the previous model was restored', + 'error', + 'engine-cmd:load model:vllm', + { + nodeId: 'local-node', + engineType: 'vllm', + operation: 'load', + modelName: 'Qwen/Qwen3-8B' + } + ) + // A failed switch will not bring the engine back by itself. + expect(mocks.state.clearLocalModelSwitch).toHaveBeenCalledWith('vllm') + }) + }) + + it('does not mark an Ollama load as a model switch', async () => { await handleServiceBridgeInvoke('engine:command', { command: 'loadModel', - engineType: 'vllm', + engineType: 'ollama', nodeId: 'local-node', - model: 'Qwen/Qwen3-8B' + model: 'llama3.2' }) - - expect(mocks.supervisor.sendProcess).toHaveBeenCalledOnce() - const [name, method, params, onFailure, observeResponse] = - mocks.supervisor.sendProcess.mock.calls[0] - // Observed: the switch restarts the engine and can fail, and the - // backend reports that failure only through this response. - expect([name, method, params, observeResponse]).toEqual([ - 'broker', - 'engine:action', - { engine: 'vllm', action: 'load_model', params: { model: 'Qwen/Qwen3-8B' } }, - true - ]) - - onFailure('did not become ready; the previous model was restored', true) - expect(mocks.supervisor.reportError).toHaveBeenCalledWith( - 'Failed to load model on vllm: did not become ready; the previous model was restored', - 'error', - 'engine-cmd:load model:vllm', - { - nodeId: 'local-node', - engineType: 'vllm', - operation: 'load', - modelName: 'Qwen/Qwen3-8B' - } - ) + expect(mocks.state.beginLocalModelSwitch).not.toHaveBeenCalled() }) it('leaves the LM Studio load path unobserved', async () => { diff --git a/desktop/tests/modular/lmstudio-stale-model.test.ts b/desktop/tests/modular/lmstudio-stale-model.test.ts index 23a79fa9a..414b20dad 100644 --- a/desktop/tests/modular/lmstudio-stale-model.test.ts +++ b/desktop/tests/modular/lmstudio-stale-model.test.ts @@ -506,3 +506,54 @@ describe('LM Studio model reconciliation', () => { hasProcess.mockRestore() }) }) + +describe('model list during a vLLM model switch', () => { + const nodeId = 'vllm-switch-list-node' + + function vllmModels() { + return getModularBridgeState() + .getEngineInitialState() + .models.find(models => models.nodeId === nodeId && models.engineType === 'vllm') + ?.models.map(model => model.name) + } + + it('keeps the cached-model list while the engine restarts, and clears it when it is turned off', () => { + const state = getModularBridgeState() + const supervisor = getModularSupervisor() + state.setSelfId(nodeId) + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { + nodes: [ + { + hostUuid: nodeId, + name: 'vllm-switch-host', + ipAddress: '127.0.0.1', + port: 14318 + } + ] + } + }) + state.applyEngineManagerStatus({ + engine: 'vllm', + installed: true, + running: true, + healthy: true, + port: 8001 + }) + state.setLocalEngineModels('vllm', ['Qwen/Qwen3-8B', 'Qwen/Qwen3-32B']) + expect(vllmModels()).toEqual(['Qwen/Qwen3-8B', 'Qwen/Qwen3-32B']) + + // A switch restarts the engine, which reports stopped on its way down. The + // list of models has not changed, so the card must not empty out. + state.beginLocalModelSwitch('vllm') + supervisor.refreshManagedEngineModels({ engine: 'vllm', running: false }) + expect(vllmModels()).toEqual(['Qwen/Qwen3-8B', 'Qwen/Qwen3-32B']) + + // Without a switch, stopping the engine clears the list as it does for every engine. + state.clearLocalModelSwitch('vllm') + supervisor.refreshManagedEngineModels({ engine: 'vllm', running: false }) + expect(vllmModels()).toEqual([]) + }) +}) diff --git a/desktop/tests/modular/model-switch-status.test.ts b/desktop/tests/modular/model-switch-status.test.ts new file mode 100644 index 000000000..cf0c37758 --- /dev/null +++ b/desktop/tests/modular/model-switch-status.test.ts @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) +vi.mock('@/electron/window', () => ({ createOverviewWindow: vi.fn() })) + +import { getModularBridgeState } from '@/electron/service-bridge/modular-state' +import type { EngineType } from '@/shared/types/engines' + +const NODE = 'model-switch-self' + +function vllmStatus(running: boolean) { + return { engine: 'vllm', installed: true, running, healthy: running, port: 8001 } +} + +function processStatus(engineType: EngineType) { + return getModularBridgeState() + .getEngineInitialState() + .statuses.find(status => status.nodeId === NODE && status.engineType === engineType) + ?.processStatus +} + +describe('engine status while a model switch restarts the engine', () => { + beforeEach(() => { + const state = getModularBridgeState() + state.setSelfId(NODE) + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { + nodes: [ + { hostUuid: NODE, name: 'switch-host', ipAddress: '127.0.0.1', port: 14318 } + ] + } + }) + state.applyEngineManagerStatus(vllmStatus(true)) + }) + + afterEach(() => { + vi.useRealTimers() + getModularBridgeState().clearLocalModelSwitch('vllm') + }) + + it('shows starting, not stopped, while the engine is down for the restart', () => { + const state = getModularBridgeState() + expect(processStatus('vllm')).toBe('running') + + // The engine is still serving the old model until it is stopped. + state.beginLocalModelSwitch('vllm') + expect(processStatus('vllm')).toBe('running') + + // The restart reports the engine stopped on its way down. That is not the + // engine being turned off, so it must not read as stopped. + state.applyEngineManagerStatus(vllmStatus(false)) + expect(processStatus('vllm')).toBe('starting') + expect(state.isLocalModelSwitching('vllm')).toBe(true) + + // Back up: it is running, and a stray later notification does not undo that. + state.applyEngineManagerStatus(vllmStatus(true)) + expect(processStatus('vllm')).toBe('running') + state.clearLocalModelSwitch('vllm') + expect(state.isLocalModelSwitching('vllm')).toBe(false) + expect(processStatus('vllm')).toBe('running') + }) + + it('does not leave a stale marker that makes a later manual stop look like starting', () => { + const state = getModularBridgeState() + state.beginLocalModelSwitch('vllm') // a switch that needed no restart + state.clearLocalModelSwitch('vllm') // the request finished + state.applyEngineManagerStatus(vllmStatus(false)) // the user turns it off + expect(processStatus('vllm')).toBe('stopped') + }) + + it('shows the real status again when the switch fails', () => { + const state = getModularBridgeState() + state.beginLocalModelSwitch('vllm') + state.applyEngineManagerStatus(vllmStatus(false)) + expect(processStatus('vllm')).toBe('starting') + + state.clearLocalModelSwitch('vllm') + expect(processStatus('vllm')).toBe('stopped') + expect(state.isLocalModelSwitching('vllm')).toBe(false) + }) + + it('gives up after a bounded time so a lost notification cannot leave it spinning', () => { + vi.useFakeTimers() + const state = getModularBridgeState() + state.beginLocalModelSwitch('vllm') + state.applyEngineManagerStatus(vllmStatus(false)) + expect(processStatus('vllm')).toBe('starting') + + vi.advanceTimersByTime(20 * 60_000 + 1_000) + expect(processStatus('vllm')).toBe('stopped') + }) + + it('leaves other engines alone', () => { + const state = getModularBridgeState() + state.beginLocalModelSwitch('vllm') + state.applyEngineManagerStatus({ + engine: 'llamacpp', + installed: true, + running: false, + healthy: false, + port: 8081 + }) + expect(processStatus('llama-cpp')).toBe('stopped') + expect(state.isLocalModelSwitching('llama-cpp')).toBe(false) + }) +}) From 57a2b05f6f5013caa6943d20d4da59178349dfcb Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 21 Oct 2026 10:35:08 +0530 Subject: [PATCH 09/35] feat(nvpair-engine-manager): size vLLM context, GPU share and tool calling per model vLLM refuses a context above the model's own limit, reserves 92% of the GPU by default, and rejects tool_choice auto without a tool-call parser. Agent clients such as Hermes need a 64K window and tool calling. Add a manifest auto_launch (hf_context) step that reads the served model's config.json and the free GPU memory at every start and appends --max-model-len (64K target, shrunk to fit), YaRN --hf-overrides when the model's window is shorter, --gpu-memory-utilization sized to the weights and KV cache, and --enable-auto-tool-choice with the hermes parser for Qwen families. Arguments the user saved always win, nothing is written back to saved settings, and tensor/pipeline-parallel launches are left alone. Drop the fixed 0.5 GPU share and 32K cap from the default launch. Signed-off-by: Tushar Khinvasara --- docs/engine-settings.mdx | 15 +- services/nvpair-engine-manager/MANIFEST.md | 14 + services/nvpair-engine-manager/README.md | 25 +- services/nvpair-engine-manager/launchauto.go | 349 ++++++++++++++++++ .../nvpair-engine-manager/launchauto_test.go | 257 +++++++++++++ services/nvpair-engine-manager/lifecycle.go | 1 + .../nvpair-engine-manager/manifests/vllm.json | 3 +- services/nvpair-engine-manager/registry.go | 25 ++ .../vllm_manifest_test.go | 14 +- 9 files changed, 687 insertions(+), 16 deletions(-) create mode 100644 services/nvpair-engine-manager/launchauto.go create mode 100644 services/nvpair-engine-manager/launchauto_test.go diff --git a/docs/engine-settings.mdx b/docs/engine-settings.mdx index 88a116380..1742eb5f4 100644 --- a/docs/engine-settings.mdx +++ b/docs/engine-settings.mdx @@ -96,11 +96,16 @@ conflicting repeated lists are rejected. Other vendor options remain opaque. llama.cpp must remain in router mode; model-selection arguments such as `-m` or `-hf` switch it to an incompatible single-model server and fail readiness. vLLM always serves one model: its launch arguments name that model with -`--model `, and changing it restarts the engine. By default it -uses at most half of the GPU's memory (`--gpu-memory-utilization 0.5`) so other -engines can run alongside it; raise that value for a larger model. It also caps -the context at 32K tokens (`--max-model-len 32768`), because models with a very -long default context would otherwise not fit in that memory and fail to start. +`--model `, and changing it restarts the engine. At every start +PAIR sizes the rest for that model: a 64K context (`--max-model-len`, shrunk if +the free GPU memory cannot hold it), YaRN rope scaling when the model's own window +is smaller (for example Qwen3-8B's 40,960 tokens), and a GPU memory share just large +enough for the weights and that context, so other engines can run alongside it. +Arguments you add to the launch text, such as your own `--max-model-len` or +`--gpu-memory-utilization`, take precedence, and a launch with +`--tensor-parallel-size` or `--pipeline-parallel-size` is left as written. For +Qwen models PAIR also turns on tool calling (`--enable-auto-tool-choice` with the +`hermes` parser), which agent clients require. Changing the server-port field updates the port in the command. Editing the command's port updates the numeric field. If both are edited before validation diff --git a/services/nvpair-engine-manager/MANIFEST.md b/services/nvpair-engine-manager/MANIFEST.md index e3a99a8cb..ca2a0aa0e 100644 --- a/services/nvpair-engine-manager/MANIFEST.md +++ b/services/nvpair-engine-manager/MANIFEST.md @@ -342,6 +342,20 @@ its `list_models` offers many models it could be switched to. With `"loaded"`, `list_models` is not queried for `engine:models`, and a `loaded_models` action is required. +A runtime **`auto_launch`** (`{"kind": "hf_context", "context_tokens": 65536}`) +lets the manager add start-time arguments that depend on the model and the +machine. For `hf_context` it reads the served model's `config.json` from the +Hugging Face cache and the free GPU memory, then appends `--max-model-len` +(the target, the model's own limit when it cannot be extended, or what fits), +`--hf-overrides` with YaRN rope scaling when the target exceeds the model's +native window, and `--gpu-memory-utilization` sized to weights plus KV cache. +Arguments the user saved always win and nothing added is written back to saved +settings, so a model switch is re-sized for the new model. It also adds +`--enable-auto-tool-choice` and the matching `--tool-call-parser` for model +families known to work (Qwen: `hermes`). Tensor- or +pipeline-parallel launches are left alone. It requires a process-mode runtime +with `editable_launch`. + An optional **`restart_after`** restarts the engine once the action succeeds. Declare it on an action that changes state the engine only reads at startup: LM Studio's `delete_model` removes the files, but its server answers diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index 464b6dc53..1c62e564a 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -51,12 +51,25 @@ launch arguments and restarting the engine. The default launch serves `Qwen/Qwen2.5-0.5B-Instruct` through `--model `; edit that argument to serve another model. Only `--host` and `--port` are managed controls, and there is no CORS control. -The default launch also passes `--gpu-memory-utilization 0.5`: vLLM reserves 92% -of GPU memory by default, even for a tiny model, which would starve every other -engine on the machine. Raise it in the launch arguments for a larger model. -It also passes `--max-model-len 32768`: many current models advertise a context of -256K tokens or more, and vLLM refuses to start unless the KV cache for that full -length fits in the memory it was given. Raise it only if the GPU has room. +The context window and GPU share are chosen automatically at every start +(`auto_launch`, kind `hf_context`), not pinned in the saved launch. The manager +reads the model's `config.json` from the Hugging Face cache and the free GPU +memory, then adds: `--max-model-len` of 64K (PAIR's agent-ready default; a +model with a larger window is capped there, and a smaller one is shrunk to what +fits), a YaRN `--hf-overrides` when the model's own window is below 64K (for +example Qwen3-8B, which stops at 40,960), and a `--gpu-memory-utilization` just +large enough for the weights, that KV cache and overhead, instead of vLLM's +92% default. A model with its own rope scaling is never overridden. Anything +the user saves in the launch arguments wins, and a tensor- or pipeline-parallel +launch is left untouched. A model that is not yet downloaded gets only a capped +memory share. The added arguments appear in the engine-manager log +(`engine launch adjusted for the model and GPU`) and not in the saved settings, +so switching models re-sizes for the new one. +It also enables tool calling, which agents such as Hermes need on every request +(vLLM answers HTTP 400 to `tool_choice: "auto"` otherwise): for the Qwen families +it adds `--enable-auto-tool-choice --tool-call-parser hermes`. Other families are +left at vLLM's default until their parser has been tried; name one in the launch +arguments to enable it. The environment sets `VLLM_USE_FLASHINFER_SAMPLER=0`. vLLM's FlashInfer sampler compiles GPU kernels on first start, and that build cannot handle the spaces in PAIR's app-data folder (`Nvidia Corporation/Personal AI Router`), so the diff --git a/services/nvpair-engine-manager/launchauto.go b/services/nvpair-engine-manager/launchauto.go new file mode 100644 index 000000000..9d9f00fe0 --- /dev/null +++ b/services/nvpair-engine-manager/launchauto.go @@ -0,0 +1,349 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "math" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + gib = int64(1) << 30 + // vllmOverhead covers activations, CUDA graphs and the CUDA context on top of + // weights and KV cache. Measured on Qwen3-8B it is well under this. + vllmOverhead = 3 * gib + // weightsSlack is headroom over the snapshot size for runtime copies. + weightsSlack = 1.08 + // freeMargin is left unclaimed on the GPU for the desktop and other engines. + freeMargin = gib + // minAutoContext is the smallest window worth shrinking to; below it the + // model is left to vLLM, whose own error then names the real shortfall. + minAutoContext = 4096 + maxAutoUtil = 0.95 + minAutoUtil = 0.15 +) + +// gpuMemory reports total and free bytes on GPU 0. It is a variable so tests +// can supply a machine. +var gpuMemory = nvidiaSMIMemory + +func nvidiaSMIMemory() (total, free int64, ok bool) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=memory.total,memory.free", "--format=csv,noheader,nounits").Output() + if err != nil { + return 0, 0, false + } + line, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n") + parts := strings.Split(line, ",") + if len(parts) != 2 { + return 0, 0, false + } + t, errT := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64) + f, errF := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) + if errT != nil || errF != nil || t <= 0 { + return 0, 0, false + } + return t * 1024 * 1024, f * 1024 * 1024, true +} + +// applyAutoLaunch appends the arguments the manifest's auto_launch asks for. +// It runs after the user's launch has been validated and never changes an +// argument the user supplied, so anything set in the engine settings wins. +func applyAutoLaunch(engine string, rt Runtime, command launchCommand, modelsDir string) launchCommand { + if rt.AutoLaunch == nil || rt.AutoLaunch.Kind != autoLaunchHFContext { + return command + } + hub := filepath.Join(expandPath(modelsDir), "hub") + extra, note := planHFContext(command.Args, hub, int64(rt.AutoLaunch.ContextTokens)) + if tools := planToolCalling(command.Args, hub); len(tools) > 0 { + extra = append(extra, tools...) + note += " tool calling enabled" + } + if len(extra) == 0 { + return command + } + slog.Info("engine launch adjusted for the model and GPU", "engine", engine, "added_args", extra, "reason", note) + command.Args = append(append([]string{}, command.Args...), extra...) + return command +} + +// launchFlagValue returns the value of a flag in either "--flag value" or +// "--flag=value" form, and whether the flag is present at all. +func launchFlagValue(args []string, flag string) (string, bool) { + for i, arg := range args { + if arg == flag { + if i+1 < len(args) { + return args[i+1], true + } + return "", true + } + if value, ok := strings.CutPrefix(arg, flag+"="); ok { + return value, true + } + } + return "", false +} + +type hfModelConfig struct { + MaxPositionEmbeddings int64 `json:"max_position_embeddings"` + NumHiddenLayers int64 `json:"num_hidden_layers"` + NumAttentionHeads int64 `json:"num_attention_heads"` + NumKeyValueHeads int64 `json:"num_key_value_heads"` + HiddenSize int64 `json:"hidden_size"` + HeadDim int64 `json:"head_dim"` + ModelType string `json:"model_type"` + TorchDtype string `json:"torch_dtype"` + Dtype string `json:"dtype"` + RopeScaling json.RawMessage `json:"rope_scaling"` + RopeParameters json.RawMessage `json:"rope_parameters"` + TextConfig *hfModelConfig `json:"text_config"` +} + +// ropeExtended reports whether the model already carries a rope-scaling +// definition other than plain defaults. Overriding one would change the model's +// behaviour in ways this code cannot judge, so such models are only capped. +func (c hfModelConfig) ropeExtended() bool { + for _, raw := range []json.RawMessage{c.RopeScaling, c.RopeParameters} { + var fields map[string]any + if json.Unmarshal(raw, &fields) != nil { + continue + } + if kind, ok := fields["rope_type"].(string); ok && kind != "" && kind != "default" { + return true + } + if kind, ok := fields["type"].(string); ok && kind != "" && kind != "default" { + return true + } + if _, ok := fields["factor"]; ok { + return true + } + } + return false +} + +func (c hfModelConfig) dtypeBytes() int64 { + d := c.Dtype + if d == "" { + d = c.TorchDtype + } + if d == "float32" { + return 4 + } + return 2 +} + +// readHFModel finds the model's complete snapshot in the Hugging Face hub cache +// and returns its parsed config and size on disk. +func readHFModel(hubDir, id string) (cfg hfModelConfig, size int64, ok bool) { + repo := filepath.Join(hubDir, "models--"+strings.ReplaceAll(id, "/", "--")) + snapshots, err := os.ReadDir(filepath.Join(repo, "snapshots")) + if err != nil { + return cfg, 0, false + } + for _, snapshot := range snapshots { + dir := filepath.Join(repo, "snapshots", snapshot.Name()) + bytes, complete := completeHFSnapshot(dir) + if !complete { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, "config.json")) + if err != nil || json.Unmarshal(raw, &cfg) != nil { + continue + } + return cfg, bytes, true + } + return cfg, 0, false +} + +// planHFContext decides the extra vLLM arguments for the model named in args: +// +// - --max-model-len: the target window, or the model's own limit when that is +// lower and cannot be extended, or what fits in GPU memory when less. +// - --hf-overrides: YaRN rope scaling when the target exceeds the model's +// native window, so a 40K model can serve 64K. +// - --gpu-memory-utilization: just enough for weights, KV cache and overhead, +// not vLLM's 92% default that would starve other engines. +// +// A flag the user already set is never touched. Tensor or pipeline parallel +// launches are left alone entirely: sizing them needs more than GPU 0. +func planHFContext(args []string, hubDir string, target int64) (extra []string, note string) { + if _, ok := launchFlagValue(args, "--tensor-parallel-size"); ok { + return nil, "" + } + if _, ok := launchFlagValue(args, "--pipeline-parallel-size"); ok { + return nil, "" + } + model, ok := launchFlagValue(args, "--model") + if !ok || model == "" { + return nil, "" + } + _, userLen := launchFlagValue(args, "--max-model-len") + _, userOverrides := launchFlagValue(args, "--hf-overrides") + userUtilText, userUtil := launchFlagValue(args, "--gpu-memory-utilization") + _, userKV := launchFlagValue(args, "--kv-cache-memory-bytes") + if userLen && userUtil { + return nil, "" + } + + total, free, haveGPU := gpuMemory() + cfg, weights, cached := readHFModel(hubDir, model) + if !cached { + // Not downloaded yet, so its size and limits are unknown. Only the memory + // share can be bounded sensibly. + if haveGPU && !userUtil && !userKV { + util := math.Floor(math.Min(0.5, float64(free-freeMargin)/float64(total))*100) / 100 + if util >= minAutoUtil { + return []string{"--gpu-memory-utilization", fmt.Sprintf("%.2f", util)}, "model not in cache" + } + } + return nil, "" + } + text := cfg + if cfg.TextConfig != nil { + text = *cfg.TextConfig + } + native := text.MaxPositionEmbeddings + layers := text.NumHiddenLayers + kvHeads := text.NumKeyValueHeads + if kvHeads == 0 { + kvHeads = text.NumAttentionHeads + } + headDim := text.HeadDim + if headDim == 0 && text.NumAttentionHeads > 0 { + headDim = text.HiddenSize / text.NumAttentionHeads + } + if native == 0 || layers == 0 || kvHeads == 0 || headDim == 0 { + return nil, "" + } + perToken := 2 * layers * kvHeads * headDim * text.dtypeBytes() + fixed := int64(float64(weights)*weightsSlack) + vllmOverhead + + ctx := target + var overrides string + extend := native < target && !text.ropeExtended() + if native < target && !extend { + ctx = native + } + + // The GPU share and window must fit together. + var budget int64 + switch { + case userUtil: + util, err := strconv.ParseFloat(userUtilText, 64) + if err != nil || !haveGPU { + return nil, "" + } + budget = int64(util * float64(total)) + case haveGPU: + budget = free - freeMargin + } + if haveGPU && budget > 0 && fixed+perToken*ctx > budget { + fit := (budget - fixed) / perToken + fit -= fit % 1024 + if fit >= minAutoContext { + ctx = fit + } + } + if !userLen { + if ctx > native { + if !extend { + ctx = native + } else { + factor := math.Ceil(float64(ctx)/float64(native)*100) / 100 + scaling := map[string]any{ + "rope_type": "yarn", "factor": factor, + "original_max_position_embeddings": native, + } + fields := map[string]any{"rope_scaling": scaling, "max_position_embeddings": int64(math.Ceil(float64(native) * factor))} + if cfg.TextConfig != nil { + fields = map[string]any{"text_config": fields} + } + if raw, err := json.Marshal(fields); err == nil { + overrides = string(raw) + } + } + } + extra = append(extra, "--max-model-len", strconv.FormatInt(ctx, 10)) + if overrides != "" && !userOverrides { + extra = append(extra, "--hf-overrides", overrides) + } + } + if !userUtil && !userKV && haveGPU { + utilCtx := ctx + if userLen { + if v, _ := launchFlagValue(args, "--max-model-len"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 { + utilCtx = n + } + } + } + need := fixed + perToken*utilCtx + util := math.Ceil(float64(need)/float64(total)*100) / 100 + // The cap rounds down so the request never exceeds what is free. + capUtil := math.Floor(math.Min(maxAutoUtil, float64(free-freeMargin)/float64(total))*100) / 100 + util = math.Max(minAutoUtil, math.Min(util, capUtil)) + if util >= minAutoUtil { + extra = append(extra, "--gpu-memory-utilization", fmt.Sprintf("%.2f", util)) + } + } + if len(extra) == 0 { + return nil, "" + } + return extra, fmt.Sprintf("native window %d, target %d, weights %.1f GiB, GPU free %.1f GiB", native, target, float64(weights)/float64(gib), float64(free)/float64(gib)) +} + +// toolCallParsers maps a model's config.json model_type to the vLLM tool-call +// parser that understands its tool-call format. Only families whose parser has +// been run against a real model here are listed; others keep vLLM's default, +// where a request that sets tool_choice "auto" is rejected, and can name a +// parser in the launch arguments. +var toolCallParsers = map[string]string{ + "qwen2": "hermes", + "qwen2_moe": "hermes", + "qwen3": "hermes", + "qwen3_moe": "hermes", + "qwen2_vl": "hermes", + "qwen2_5_vl": "hermes", + "qwen3_vl": "hermes", + "qwen3_vl_moe": "hermes", +} + +// planToolCalling returns the arguments that let clients send tool_choice +// "auto", which agents such as Hermes do on every request. vLLM answers HTTP 400 +// unless both --enable-auto-tool-choice and --tool-call-parser are set. A flag +// the user already supplied is left as given. +func planToolCalling(args []string, hubDir string) []string { + model, ok := launchFlagValue(args, "--model") + if !ok || model == "" { + return nil + } + _, hasParser := launchFlagValue(args, "--tool-call-parser") + _, hasEnable := launchFlagValue(args, "--enable-auto-tool-choice") + if hasParser && hasEnable { + return nil + } + var extra []string + if !hasParser { + cfg, _, cached := readHFModel(hubDir, model) + parser := toolCallParsers[cfg.ModelType] + if !cached || parser == "" { + return nil + } + extra = append(extra, "--tool-call-parser", parser) + } + if !hasEnable { + extra = append(extra, "--enable-auto-tool-choice") + } + return extra +} diff --git a/services/nvpair-engine-manager/launchauto_test.go b/services/nvpair-engine-manager/launchauto_test.go new file mode 100644 index 000000000..84869c964 --- /dev/null +++ b/services/nvpair-engine-manager/launchauto_test.go @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" +) + +const qwen8BConfig = `{"max_position_embeddings":40960,"num_hidden_layers":36,"num_attention_heads":32,"num_key_value_heads":8,"head_dim":128,"torch_dtype":"bfloat16"}` + +// cachedModel writes a repository whose weights file reports the given size +// without using disk (a sparse file). +func cachedModel(t *testing.T, id, config string, weightBytes int64) string { + t.Helper() + cache := t.TempDir() + cachedModelAt(t, cache, id, config, weightBytes) + return cache +} + +func cachedModelAt(t *testing.T, cache, id, config string, weightBytes int64) { + t.Helper() + repo := writeHFRepo(t, cache, id, llmFiles(map[string]string{"config.json": config, "model.safetensors": "x"})) + if err := os.Truncate(filepath.Join(repo, "blobs", "blob-model.safetensors"), weightBytes); err != nil { + t.Fatal(err) + } +} + +func withGPU(t *testing.T, totalGiB, freeGiB int64) { + t.Helper() + old := gpuMemory + gpuMemory = func() (int64, int64, bool) { return totalGiB * gib, freeGiB * gib, true } + t.Cleanup(func() { gpuMemory = old }) +} + +func withoutGPU(t *testing.T) { + t.Helper() + old := gpuMemory + gpuMemory = func() (int64, int64, bool) { return 0, 0, false } + t.Cleanup(func() { gpuMemory = old }) +} + +func flagOf(t *testing.T, args []string, flag string) string { + t.Helper() + v, _ := launchFlagValue(args, flag) + return v +} + +func utilOf(t *testing.T, args []string) float64 { + t.Helper() + v, err := strconv.ParseFloat(flagOf(t, args, "--gpu-memory-utilization"), 64) + if err != nil { + t.Fatalf("no usable --gpu-memory-utilization in %v", args) + } + return v +} + +func TestAutoContextExtendsAShortModelToTheTarget(t *testing.T) { + withGPU(t, 48, 48) + cache := cachedModel(t, "Qwen/Qwen3-8B", qwen8BConfig, 16_400_000_000) + extra, _ := planHFContext([]string{"serve", "--model", "Qwen/Qwen3-8B"}, cache, 65536) + + if got := flagOf(t, extra, "--max-model-len"); got != "65536" { + t.Fatalf("max-model-len = %q, want 65536 (the model alone stops at 40960): %v", got, extra) + } + var overrides struct { + RopeScaling struct { + Type string `json:"rope_type"` + Factor float64 `json:"factor"` + Original int64 `json:"original_max_position_embeddings"` + } `json:"rope_scaling"` + MaxPositions int64 `json:"max_position_embeddings"` + } + if err := json.Unmarshal([]byte(flagOf(t, extra, "--hf-overrides")), &overrides); err != nil { + t.Fatalf("hf-overrides is not valid JSON: %v (%v)", err, extra) + } + if overrides.RopeScaling.Type != "yarn" || overrides.RopeScaling.Original != 40960 || overrides.RopeScaling.Factor < 1.59 || overrides.MaxPositions < 65536 { + t.Fatalf("overrides = %+v, want yarn over the 40960 native window reaching 65536", overrides) + } + // 16.4 GB of weights plus about 9.7 GB of KV cache for 64K plus overhead is + // about 60% of a 48 GiB card; vLLM's own default would take 92%. + if util := utilOf(t, extra); util < 0.55 || util > 0.7 { + t.Fatalf("gpu-memory-utilization = %.2f, want about 0.6", util) + } +} + +func TestAutoContextCapsALongModelAtTheTarget(t *testing.T) { + withGPU(t, 48, 48) + cfg := strings.Replace(qwen8BConfig, "40960", "262144", 1) + cache := cachedModel(t, "Qwen/Long", cfg, 16_400_000_000) + extra, _ := planHFContext([]string{"--model", "Qwen/Long"}, cache, 65536) + if got := flagOf(t, extra, "--max-model-len"); got != "65536" { + t.Fatalf("max-model-len = %q, want the 65536 target, not the 262144 native window", got) + } + if slices.Contains(extra, "--hf-overrides") { + t.Fatalf("a model that already supports the target needs no rope override: %v", extra) + } +} + +func TestAutoContextLeavesModelsWithRopeScalingAlone(t *testing.T) { + withGPU(t, 48, 48) + cfg := strings.Replace(qwen8BConfig, `"torch_dtype"`, `"rope_scaling":{"rope_type":"yarn","factor":4.0},"torch_dtype"`, 1) + cache := cachedModel(t, "Qwen/Scaled", cfg, 16_400_000_000) + extra, _ := planHFContext([]string{"--model", "Qwen/Scaled"}, cache, 131072) + if slices.Contains(extra, "--hf-overrides") { + t.Fatalf("must not override a model's own rope scaling: %v", extra) + } + if got := flagOf(t, extra, "--max-model-len"); got != "40960" { + t.Fatalf("max-model-len = %q, want the model's own 40960", got) + } +} + +func TestAutoContextShrinksToWhatFitsInFreeMemory(t *testing.T) { + withGPU(t, 48, 22) + cache := cachedModel(t, "Qwen/Qwen3-8B", qwen8BConfig, 16_400_000_000) + extra, _ := planHFContext([]string{"--model", "Qwen/Qwen3-8B"}, cache, 65536) + + n, err := strconv.ParseInt(flagOf(t, extra, "--max-model-len"), 10, 64) + if err != nil || n < 4096 || n >= 65536 || n%1024 != 0 { + t.Fatalf("max-model-len = %v, want a multiple of 1024 below 64K that fits 22 GiB free: %v", n, extra) + } + if slices.Contains(extra, "--hf-overrides") { + t.Fatalf("a window inside the native limit needs no rope override: %v", extra) + } + if util := utilOf(t, extra); util > 22.0/48.0 { + t.Fatalf("gpu-memory-utilization %.2f exceeds the free share %.2f", util, 22.0/48.0) + } +} + +func TestAutoContextNeverOverridesWhatTheUserSet(t *testing.T) { + withGPU(t, 48, 48) + cache := cachedModel(t, "Qwen/Qwen3-8B", qwen8BConfig, 16_400_000_000) + + extra, _ := planHFContext([]string{"--model", "Qwen/Qwen3-8B", "--max-model-len=8192"}, cache, 65536) + if slices.Contains(extra, "--max-model-len") || slices.Contains(extra, "--hf-overrides") { + t.Fatalf("the user's context must stand untouched: %v", extra) + } + if util := utilOf(t, extra); util > 0.45 { + t.Fatalf("an 8K window needs far less than %.2f of the GPU", util) + } + + extra, _ = planHFContext([]string{"--model", "Qwen/Qwen3-8B", "--gpu-memory-utilization", "0.5"}, cache, 65536) + if slices.Contains(extra, "--gpu-memory-utilization") { + t.Fatalf("the user's GPU share must stand untouched: %v", extra) + } + n, _ := strconv.ParseInt(flagOf(t, extra, "--max-model-len"), 10, 64) + if n >= 65536 || n < 4096 { + t.Fatalf("a 0.5 share (24 GiB) cannot hold 64K of Qwen3-8B; max-model-len = %d", n) + } + + extra, _ = planHFContext([]string{"--model", "Qwen/Qwen3-8B", "--max-model-len", "4096", "--gpu-memory-utilization", "0.3"}, cache, 65536) + if len(extra) != 0 { + t.Fatalf("with both set there is nothing to add: %v", extra) + } +} + +func TestAutoContextSkipsShardedLaunches(t *testing.T) { + withGPU(t, 48, 48) + cache := cachedModel(t, "Qwen/Qwen3-8B", qwen8BConfig, 16_400_000_000) + for _, flag := range []string{"--tensor-parallel-size", "--pipeline-parallel-size"} { + if extra, _ := planHFContext([]string{"--model", "Qwen/Qwen3-8B", flag, "2"}, cache, 65536); len(extra) != 0 { + t.Fatalf("%s: sizing a sharded launch needs more than GPU 0; got %v", flag, extra) + } + } +} + +func TestAutoContextForAModelNotInTheCache(t *testing.T) { + withGPU(t, 48, 20) + extra, _ := planHFContext([]string{"--model", "org/not-downloaded"}, t.TempDir(), 65536) + if slices.Contains(extra, "--max-model-len") { + t.Fatalf("an unknown model's limits are unknown: %v", extra) + } + if util := utilOf(t, extra); util > 19.0/48.0+0.001 { + t.Fatalf("gpu-memory-utilization %.2f must leave a margin of the 20 GiB free", util) + } +} + +func TestAutoContextWithoutAGPUReading(t *testing.T) { + withoutGPU(t) + cache := cachedModel(t, "Qwen/Qwen3-8B", qwen8BConfig, 16_400_000_000) + extra, _ := planHFContext([]string{"--model", "Qwen/Qwen3-8B"}, cache, 65536) + if flagOf(t, extra, "--max-model-len") != "65536" { + t.Fatalf("the window does not depend on the GPU reading: %v", extra) + } + if slices.Contains(extra, "--gpu-memory-utilization") { + t.Fatalf("without a reading the GPU share is left to vLLM: %v", extra) + } +} + +func TestAutoContextWrapsOverridesForAMultimodalConfig(t *testing.T) { + withGPU(t, 48, 48) + cfg := `{"architectures":["Qwen3VLForConditionalGeneration"],"text_config":` + qwen8BConfig + `}` + cache := cachedModel(t, "Qwen/VL", cfg, 16_400_000_000) + extra, _ := planHFContext([]string{"--model", "Qwen/VL"}, cache, 65536) + var overrides map[string]json.RawMessage + if err := json.Unmarshal([]byte(flagOf(t, extra, "--hf-overrides")), &overrides); err != nil { + t.Fatal(err) + } + if _, ok := overrides["text_config"]; !ok { + t.Fatalf("a multimodal model's rope settings live under text_config: %v", extra) + } +} + +func TestApplyAutoLaunchAppendsAfterTheUsersArguments(t *testing.T) { + withGPU(t, 48, 48) + // models_dir/hub is the cache. + parent := t.TempDir() + cachedModelAt(t, filepath.Join(parent, "hub"), "Qwen/Qwen3-8B", qwen8BConfig, 16_400_000_000) + rt := Runtime{AutoLaunch: &AutoLaunch{Kind: autoLaunchHFContext, ContextTokens: 65536}} + in := launchCommand{Bin: "vllm", Args: []string{"serve", "--model", "Qwen/Qwen3-8B"}} + out := applyAutoLaunch("vllm", rt, in, parent) + if !slices.Equal(out.Args[:3], in.Args) || flagOf(t, out.Args, "--max-model-len") != "65536" { + t.Fatalf("args = %v", out.Args) + } + if len(in.Args) != 3 { + t.Fatalf("the caller's arguments were modified: %v", in.Args) + } + if got := applyAutoLaunch("vllm", Runtime{}, in, parent); !slices.Equal(got.Args, in.Args) { + t.Fatalf("an engine without auto_launch must be untouched: %v", got.Args) + } +} + +func TestToolCallingIsEnabledForKnownFamilies(t *testing.T) { + cases := map[string]string{"qwen3": "hermes", "qwen2": "hermes", "qwen3_vl": "hermes"} + for modelType, parser := range cases { + cfg := strings.Replace(qwen8BConfig, `{`, `{"model_type":"`+modelType+`",`, 1) + cache := cachedModel(t, "org/m", cfg, 1_000_000) + extra := planToolCalling([]string{"--model", "org/m"}, cache) + if flagOf(t, extra, "--tool-call-parser") != parser || !slices.Contains(extra, "--enable-auto-tool-choice") { + t.Errorf("%s: got %v, want parser %s and auto tool choice", modelType, extra, parser) + } + } +} + +func TestToolCallingLeavesUnknownFamiliesAndUserChoicesAlone(t *testing.T) { + unknown := cachedModel(t, "org/m", strings.Replace(qwen8BConfig, `{`, `{"model_type":"someother",`, 1), 1_000_000) + if extra := planToolCalling([]string{"--model", "org/m"}, unknown); len(extra) != 0 { + t.Fatalf("a family with no verified parser must be left as vLLM defaults: %v", extra) + } + if extra := planToolCalling([]string{"--model", "org/not-cached"}, t.TempDir()); len(extra) != 0 { + t.Fatalf("an uncached model has no known family: %v", extra) + } + qwen := cachedModel(t, "org/q", strings.Replace(qwen8BConfig, `{`, `{"model_type":"qwen3",`, 1), 1_000_000) + if extra := planToolCalling([]string{"--model", "org/q", "--tool-call-parser", "qwen3_xml", "--enable-auto-tool-choice"}, qwen); len(extra) != 0 { + t.Fatalf("the user set both: %v", extra) + } + extra := planToolCalling([]string{"--model", "org/q", "--tool-call-parser", "qwen3_xml"}, qwen) + if slices.Contains(extra, "--tool-call-parser") || !slices.Contains(extra, "--enable-auto-tool-choice") { + t.Fatalf("the user's parser must stand and only the enable flag be added: %v", extra) + } +} diff --git a/services/nvpair-engine-manager/lifecycle.go b/services/nvpair-engine-manager/lifecycle.go index 6685bdfaa..eea6e011b 100644 --- a/services/nvpair-engine-manager/lifecycle.go +++ b/services/nvpair-engine-manager/lifecycle.go @@ -240,6 +240,7 @@ func (e *Executor) bringUpProcess(ctx context.Context, st *engineState, engine s if err := validateEffectiveLaunch(rt, launch, vars["host"], vars["port"]); err != nil { return err } + launch = applyAutoLaunch(engine, rt, launch, vars["models_dir"]) diagnostics := newStartupOutput(launchDiagnosticArgs(rt), launch.Env) defer diagnostics.close() diff --git a/services/nvpair-engine-manager/manifests/vllm.json b/services/nvpair-engine-manager/manifests/vllm.json index 98b881273..fc62c6cf8 100644 --- a/services/nvpair-engine-manager/manifests/vllm.json +++ b/services/nvpair-engine-manager/manifests/vllm.json @@ -16,7 +16,8 @@ { "flags": ["--port"], "value": "{server.port}" } ] }, - "args": ["serve", "--model", "Qwen/Qwen2.5-0.5B-Instruct", "--gpu-memory-utilization", "0.5", "--max-model-len", "32768", "--host", "{host}", "--port", "{port}"], + "args": ["serve", "--model", "Qwen/Qwen2.5-0.5B-Instruct", "--host", "{host}", "--port", "{port}"], + "auto_launch": { "kind": "hf_context", "context_tokens": 65536 }, "env": { "HF_HOME": "{models_dir}", "VLLM_USE_FLASHINFER_SAMPLER": "0" }, "bind": "127.0.0.1", "port": 8001, diff --git a/services/nvpair-engine-manager/registry.go b/services/nvpair-engine-manager/registry.go index e2182e220..b5c026bd6 100644 --- a/services/nvpair-engine-manager/registry.go +++ b/services/nvpair-engine-manager/registry.go @@ -156,6 +156,10 @@ type Runtime struct { // a non-nil empty slice keeps only fixed and managed arguments. Use // omitzero to omit nil while preserving explicit empty arrays in JSON. LaunchArgs []string `json:"launch_args,omitzero"` + // AutoLaunch lets the manager add startup arguments that depend on the model + // being served and the machine, at every start. Arguments the user saved + // always win, and nothing added here is written back to saved settings. + AutoLaunch *AutoLaunch `json:"auto_launch,omitempty"` // Mode selects the lifecycle model: "process" means PAIR owns the launched // foreground process; "command" means PAIR invokes commands that manage a // separate daemon. Empty defaults to "process". @@ -194,6 +198,19 @@ type Runtime struct { Health *Probe `json:"health,omitempty"` } +// AutoLaunch configures start-time launch arguments. The only kind is +// "hf_context": read the model's config.json from the Hugging Face cache and +// set the context length, any rope scaling it needs, and the GPU memory share. +type AutoLaunch struct { + Kind string `json:"kind"` + // ContextTokens is the context window to aim for. Models that natively + // support less are extended where that is safe; models that support more are + // capped here so the KV cache stays affordable. + ContextTokens int `json:"context_tokens"` +} + +const autoLaunchHFContext = "hf_context" + type EditableLaunch struct { FixedArgs []string `json:"fixed_args"` Controls []LaunchControl `json:"controls"` @@ -783,6 +800,14 @@ const ( ) func (p *Platform) validate(key string) error { + if auto := p.Runtime.AutoLaunch; auto != nil { + if auto.Kind != autoLaunchHFContext || auto.ContextTokens < 1024 { + return fmt.Errorf("platform %q: auto_launch needs kind %q and context_tokens of at least 1024", key, autoLaunchHFContext) + } + if p.Runtime.EditableLaunch == nil || p.Runtime.modeOrDefault() != "process" { + return fmt.Errorf("platform %q: auto_launch requires a process-mode runtime with editable_launch", key) + } + } if policy := p.Runtime.EditableLaunch; policy != nil { if policy.StartIndex < 0 || (p.Runtime.modeOrDefault() == "command" && policy.StartIndex >= len(p.Runtime.Start)) { return fmt.Errorf("platform %q: invalid editable launch definition", key) diff --git a/services/nvpair-engine-manager/vllm_manifest_test.go b/services/nvpair-engine-manager/vllm_manifest_test.go index c8a0fad27..df67f9b50 100644 --- a/services/nvpair-engine-manager/vllm_manifest_test.go +++ b/services/nvpair-engine-manager/vllm_manifest_test.go @@ -60,10 +60,16 @@ func TestBundledVLLMManifest(t *testing.T) { if rt.Env["VLLM_USE_FLASHINFER_SAMPLER"] != "0" { t.Errorf("%s: the FlashInfer sampler must be disabled", key) } - // vLLM reserves 92% of GPU memory by default, which starves every other - // engine on the machine even for a tiny model. - if !slices.Contains(rt.Args, "--gpu-memory-utilization") { - t.Errorf("%s: the default launch must cap GPU memory use", key) + // vLLM reserves 92% of GPU memory by default and refuses a context above + // the model's own limit. Both are decided per model at start, so the + // manifest must not pin them in the saved launch. + if rt.AutoLaunch == nil || rt.AutoLaunch.Kind != autoLaunchHFContext || rt.AutoLaunch.ContextTokens != 65536 { + t.Errorf("%s: vllm must size its context and GPU share automatically for a 64K window, got %+v", key, rt.AutoLaunch) + } + for _, pinned := range []string{"--gpu-memory-utilization", "--max-model-len"} { + if slices.Contains(rt.Args, pinned) { + t.Errorf("%s: %s must not be pinned in the default launch", key, pinned) + } } if rt.Port != 8001 { t.Errorf("%s: engine port = %d, want 8001 (the facade owns 8000)", key, rt.Port) From edc9b11e6fa1bdc1684f1689a40282277dbb8ee8 Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 21 Oct 2026 12:15:08 +0530 Subject: [PATCH 10/35] feat(nvpair-engine-manager): download and browse Hugging Face models for vLLM vLLM has no pull API, so a model not already cached could only be used by naming it in the launch arguments. Add an hf_pull action kind for pull_model: it reads the repo's file list and sizes from Hugging Face, selects the files vLLM reads, and runs the hf tool from the engine's virtualenv, reporting progress from the bytes landing in the hub cache. It needs no running engine and stops when cancelled or stalled. Add a vLLM engine:catalog source: open, text-generating safetensors repos of a few publishers (text-generation and image-text-to-text), with parameter count and size, plus a searchable query like llama.cpp's. Signed-off-by: Tushar Khinvasara --- services/nvpair-engine-manager/MANIFEST.md | 5 + services/nvpair-engine-manager/README.md | 12 +- services/nvpair-engine-manager/catalog.go | 60 ++++- .../nvpair-engine-manager/catalog_test.go | 3 +- .../nvpair-engine-manager/catalog_vllm.go | 190 +++++++++++++ .../catalog_vllm_test.go | 121 +++++++++ services/nvpair-engine-manager/hfpull.go | 249 ++++++++++++++++++ services/nvpair-engine-manager/hfpull_test.go | 180 +++++++++++++ .../nvpair-engine-manager/manifests/vllm.json | 4 + services/nvpair-engine-manager/pull.go | 7 + services/nvpair-engine-manager/registry.go | 27 +- .../nvpair-engine-manager/registry_test.go | 2 +- .../vllm_manifest_test.go | 6 +- 13 files changed, 848 insertions(+), 18 deletions(-) create mode 100644 services/nvpair-engine-manager/catalog_vllm.go create mode 100644 services/nvpair-engine-manager/catalog_vllm_test.go create mode 100644 services/nvpair-engine-manager/hfpull.go create mode 100644 services/nvpair-engine-manager/hfpull_test.go diff --git a/services/nvpair-engine-manager/MANIFEST.md b/services/nvpair-engine-manager/MANIFEST.md index ca2a0aa0e..8dd774e0f 100644 --- a/services/nvpair-engine-manager/MANIFEST.md +++ b/services/nvpair-engine-manager/MANIFEST.md @@ -342,6 +342,11 @@ its `list_models` offers many models it could be switched to. With `"loaded"`, `list_models` is not queried for `engine:models`, and a `loaded_models` action is required. +An **`hf_pull`** action (`{"tool": "hf", "dir": "{models_dir}/hub"}`, only as +`pull_model`) downloads a Hugging Face repository with the named tool, looked up +in the directory that holds the engine binary, and reports byte progress taken +from the cache directory. It needs no running engine. + A runtime **`auto_launch`** (`{"kind": "hf_context", "context_tokens": 65536}`) lets the manager add start-time arguments that depend on the model and the machine. For `hf_context` it reads the served model's `config.json` from the diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index 1c62e564a..c13cb0f6e 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -89,6 +89,16 @@ switches the served model: it writes `--model ` into the saved launch arguments and restarts vLLM on it, restoring the previous model if the new one does not start (for example it does not fit in GPU memory). +`pull_model` is an `hf_pull` action: it asks Hugging Face for the repo's file list +and sizes, picks the files vLLM reads (safetensors, config and tokenizer; not +GGUF, ONNX, pickle copies of weights that also exist as safetensors, or a +duplicate consolidated file), and runs the `hf` tool that ships beside `vllm` in +the engine's virtualenv to download them into the hub cache. Progress is the +share of those bytes that has landed in the cache, a download that makes no +progress for the pull timeout is abandoned, and a cancelled one is stopped. It +needs no running engine. A gated repo needs its terms accepted and `HF_TOKEN` +(or a prior `hf auth login`) in the environment. + Install is detect-first. `{install_dir}/venv/bin/vllm` (the managed install) and `~/.local/bin/vllm` are checked in that order, and the first one found is launched, so an existing install is adopted and nothing is downloaded. Otherwise @@ -132,7 +142,7 @@ Requests (caller → service): | `engine:action` | `{ engine, action, params }` | the engine's raw response. `action:"pull_model"` is streamed: it emits live `engine:pull-progress` notifications and returns the pull's terminal result (see below). An action whose manifest declares `restart_after` (LM Studio's `delete_model`) restarts a running engine before replying, so the response also means the engine is back and healthy | | `engine:logs` | `{ engine }` | `{ lines: [LogLine] }` | | `engine:errors` | — | `{ errors: [ServiceError] }` | -| `engine:catalog` | `{ engine, platform?, arch?, query? }` | `{ models: [{ id, name, author, url, size?, downloads, likes, updatedAt, tags, family?, parameterSize?, appleOnly? }], source, platform?, arch?, fetchedAt?, searchable?, query? }` — the models an engine can **download**, as opposed to `engine:models`, which reports what is already installed. Each engine has one curated source. Ollama's has no public API, so it is a locked list compiled into this binary from `catalog/ollama-models.json`, regenerated on demand by a developer (`npm run scrape:ollama-models` in `desktop/`) who reviews the diff; serving it needs no network. LM Studio's catalog *is* the `lmstudio-community` Hugging Face org, whose repo ids are exactly what `lms get` accepts, so it is fetched live and cached for six hours, with concurrent callers coalesced onto one request, a failed attempt — with or without a list to fall back on — followed by a backoff before the next. llama.cpp downloads any GGUF repo as `repo:quantization`; its browse list is the GGUF repos of `ggml-org`, `bartowski`, and `unsloth`, fetched and cached the same way, keeping only open, text-generating repos that ship a `Q4_K_M` file, each offered as `repo:Q4_K_M`. It is the one `searchable` source: a `query` searches all of Hugging Face under the same rules, each query cached on its own (the 20 most recently used are kept), and the reply echoes it. A query longer than 100 characters is refused rather than shortened, since a shortened one searches for something the operator did not type. Other sources ignore a query and return their whole list for the client to filter. `platform` and `arch` are the GOOS and GOARCH the models will be **installed on**, which is not always this host — a client driving a peer should name that peer. Omitting both means this host; naming a platform without an arch leaves the arch unknown rather than borrowing this host's. MLX quantizations only install on Apple Silicon, so they are marked `appleOnly` and kept only for a `darwin`/`arm64` target — an Intel Mac does not get them; the reply echoes the `platform` and `arch` it filtered for. `name` is pull-ready: it can be handed to `pull_model` verbatim. An engine with no curated source is an error, not an empty list. The Ollama reply is a single multi-megabyte frame, so every hop on its path must allow `jsonrpc.WorkerFrameBytes`. | +| `engine:catalog` | `{ engine, platform?, arch?, query? }` | `{ models: [{ id, name, author, url, size?, downloads, likes, updatedAt, tags, family?, parameterSize?, appleOnly? }], source, platform?, arch?, fetchedAt?, searchable?, query? }` — the models an engine can **download**, as opposed to `engine:models`, which reports what is already installed. Each engine has one curated source. Ollama's has no public API, so it is a locked list compiled into this binary from `catalog/ollama-models.json`, regenerated on demand by a developer (`npm run scrape:ollama-models` in `desktop/`) who reviews the diff; serving it needs no network. LM Studio's catalog *is* the `lmstudio-community` Hugging Face org, whose repo ids are exactly what `lms get` accepts, so it is fetched live and cached for six hours, with concurrent callers coalesced onto one request, a failed attempt — with or without a list to fall back on — followed by a backoff before the next. vLLM serves Hugging Face repos by their repo id: its browse list is the open, text-generating safetensors repos of `Qwen`, `nvidia`, `mistralai`, `microsoft` and `ibm-granite` (both `text-generation` and `image-text-to-text`), with parameter count and size, and, like llama.cpp, a `query` searches all of Hugging Face under the same rules. llama.cpp downloads any GGUF repo as `repo:quantization`; its browse list is the GGUF repos of `ggml-org`, `bartowski`, and `unsloth`, fetched and cached the same way, keeping only open, text-generating repos that ship a `Q4_K_M` file, each offered as `repo:Q4_K_M`. It is the one `searchable` source: a `query` searches all of Hugging Face under the same rules, each query cached on its own (the 20 most recently used are kept), and the reply echoes it. A query longer than 100 characters is refused rather than shortened, since a shortened one searches for something the operator did not type. Other sources ignore a query and return their whole list for the client to filter. `platform` and `arch` are the GOOS and GOARCH the models will be **installed on**, which is not always this host — a client driving a peer should name that peer. Omitting both means this host; naming a platform without an arch leaves the arch unknown rather than borrowing this host's. MLX quantizations only install on Apple Silicon, so they are marked `appleOnly` and kept only for a `darwin`/`arm64` target — an Intel Mac does not get them; the reply echoes the `platform` and `arch` it filtered for. `name` is pull-ready: it can be handed to `pull_model` verbatim. An engine with no curated source is an error, not an empty list. The Ollama reply is a single multi-megabyte frame, so every hop on its path must allow `jsonrpc.WorkerFrameBytes`. | | `engine:models` | — | `{ models: [string], modelsByEngine: { : [string] }, loadedByEngine: { : [string] } }` — the flat de-duplicated union of every running engine's models, the per-engine breakdown keyed by engine name, and the per-engine set of models currently **loaded in memory** (all normalized from each engine's `list_models` / `loaded_models` action `result` spec). `modelsByEngine` carries a key for every running engine whose inventory was successfully queried, including an empty list = "running, no models available"; a missing key means not running / not queryable / invalid response. `loadedByEngine` uses the same known-empty distinction for residency and also omits engines with no loaded endpoint. The `/v1/models` HTTP surface returns the same shape. | | `engine:remote-get-installed` | `{ node }` | `{ engines: [EngineStatus] }` fetched from the remote node over `ec` mTLS | | `engine:remote-install` | `{ node, engine, start? }` | `{ opId, status: EngineStatus }` after the remote install (live progress via `engine:remote-progress`) | diff --git a/services/nvpair-engine-manager/catalog.go b/services/nvpair-engine-manager/catalog.go index ed52b1199..a027605fa 100644 --- a/services/nvpair-engine-manager/catalog.go +++ b/services/nvpair-engine-manager/catalog.go @@ -215,11 +215,19 @@ type hfModelRow struct { Gated json.RawMessage `json:"gated"` PipelineTag string `json:"pipeline_tag"` Siblings []hfSibling `json:"siblings"` + // Safetensors is present when the listing asks to expand it: the parameter + // count per tensor type, which gives a model's size without a second request. + Safetensors struct { + Parameters map[string]int64 `json:"parameters"` + Total int64 `json:"total"` + } `json:"safetensors"` } // hfSibling is one file in a Hugging Face repo. type hfSibling struct { RFilename string `json:"rfilename"` + // Size is reported only when the repo is asked for with blobs=true. + Size int64 `json:"size"` } // catalogService answers engine:catalog. The embedded list is parsed once; each @@ -234,6 +242,7 @@ type catalogService struct { lmStudio *listingCache llamaCPP *listingCache + vllm *listingCache // searchMu guards the llama.cpp searches, kept by lowercased query with // searchOrder from least to most recently used. @@ -286,6 +295,7 @@ func newCatalogService() *catalogService { } c.lmStudio = &listingCache{label: "lm studio catalog", fetch: c.fetchLmStudio} c.llamaCPP = &listingCache{label: "llama.cpp catalog", fetch: c.fetchLlamaCPP} + c.vllm = &listingCache{label: "vllm catalog", fetch: c.fetchVLLM} return c } @@ -345,6 +355,28 @@ func (c *catalogService) Catalog(ctx context.Context, engine, platform, arch, qu Searchable: true, Query: query, }, nil + case "vllm": + query, err := catalogQuery(query) + if err != nil { + return CatalogResult{}, err + } + models, fetchedAt, err := c.vllmCatalog(ctx, query) + if err != nil { + return CatalogResult{}, err + } + source := hfModelsAPI + "?filter=safetensors&author=" + strings.Join(vllmPublishers, ",") + if query != "" { + source = hfModelsAPI + "?filter=safetensors&search=" + url.QueryEscape(query) + } + return CatalogResult{ + Models: models, + Source: source, + Platform: platform, + Arch: arch, + FetchedAt: fetchedAt.UTC().Format(time.RFC3339), + Searchable: true, + Query: query, + }, nil default: return CatalogResult{}, fmt.Errorf("no model catalog for engine %q", engine) } @@ -402,6 +434,8 @@ func normalizeCatalogEngine(engine string) string { return "lmstudio" case "llama-cpp", "llama.cpp", "llamacpp": return "llamacpp" + case "vllm": + return "vllm" default: return e } @@ -579,22 +613,24 @@ func (c *catalogService) llamaCPPCatalog(ctx context.Context, query string) ([]C // llamaCPPSearch is the cache for one query, created on first use. Queries // differing only in case share one. func (c *catalogService) llamaCPPSearch(query string) *listingCache { - key := strings.ToLower(query) + return c.searchCache("llamacpp:"+strings.ToLower(query), "llama.cpp search", func(ctx context.Context) ([]CatalogModel, error) { + rows, err := c.fetchHF(ctx, llamaCPPListing("search", query)) + if err != nil { + return nil, err + } + return normalizeLlamaCPPRows(rows), nil + }) +} + +// searchCache returns the cache for one search, creating it on first use and +// marking it most recently used. The set of possible queries is unbounded, so +// the least recently used are evicted past llamaCPPSearchCacheLimit. +func (c *catalogService) searchCache(key, label string, fetch func(context.Context) ([]CatalogModel, error)) *listingCache { c.searchMu.Lock() defer c.searchMu.Unlock() search, ok := c.llamaSearches[key] if !ok { - search = &listingCache{ - label: "llama.cpp search", - emptyOK: true, - fetch: func(ctx context.Context) ([]CatalogModel, error) { - rows, err := c.fetchHF(ctx, llamaCPPListing("search", query)) - if err != nil { - return nil, err - } - return normalizeLlamaCPPRows(rows), nil - }, - } + search = &listingCache{label: label, emptyOK: true, fetch: fetch} c.llamaSearches[key] = search } for i, k := range c.searchOrder { diff --git a/services/nvpair-engine-manager/catalog_test.go b/services/nvpair-engine-manager/catalog_test.go index 3efd145be..a8b8b3b84 100644 --- a/services/nvpair-engine-manager/catalog_test.go +++ b/services/nvpair-engine-manager/catalog_test.go @@ -221,8 +221,7 @@ func TestNormalizeCatalogEngine_ResolvesSupportedEngineNames(t *testing.T) { // rather than returning an empty list that reads as "nothing available". func TestCatalog_RejectsUnknownEngine(t *testing.T) { c := newCatalogService() - // vLLM is a known engine that deliberately has no curated catalog. - for _, engine := range []string{"vllm", "nosuchengine"} { + for _, engine := range []string{"nosuchengine"} { _, err := c.Catalog(context.Background(), engine, "linux", "amd64", "") assert.Error(t, err, "%s returned a catalog", engine) } diff --git a/services/nvpair-engine-manager/catalog_vllm.go b/services/nvpair-engine-manager/catalog_vllm.go new file mode 100644 index 000000000..6442f03e7 --- /dev/null +++ b/services/nvpair-engine-manager/catalog_vllm.go @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/url" + "sort" + "strings" + "sync" + "time" +) + +// The vLLM catalogue: Hugging Face repos that ship safetensors weights and +// answer chat or completion requests. The download name is the repo id itself, +// which is what vLLM serves and what the Hugging Face cache stores. +const vllmListingLimit = 50 + +// vllmPublishers are the Hugging Face accounts whose repos make up the browse +// list: publishers of widely used open-weight models that vLLM serves directly. +// Gated repos are dropped, so a publisher that gates most of its models +// contributes only its open ones. +var vllmPublishers = []string{"Qwen", "nvidia", "mistralai", "microsoft", "ibm-granite"} + +// vllmPipelines are the pipeline tags listed. Vision-language chat models carry +// image-text-to-text and are served by vLLM like any other chat model. +var vllmPipelines = []string{"text-generation", "image-text-to-text"} + +func (c *catalogService) vllmCatalog(ctx context.Context, query string) ([]CatalogModel, time.Time, error) { + if query == "" { + return c.vllm.get(ctx) + } + return c.searchCache("vllm:"+strings.ToLower(query), "vllm search", func(ctx context.Context) ([]CatalogModel, error) { + rows, err := c.fetchVLLMListings(ctx, "search", []string{query}) + if err != nil { + return nil, err + } + return normalizeVLLMRows(rows), nil + }).get(ctx) +} + +func (c *catalogService) fetchVLLM(ctx context.Context) ([]CatalogModel, error) { + rows, err := c.fetchVLLMListings(ctx, "author", vllmPublishers) + if err != nil { + return nil, err + } + return normalizeVLLMRows(rows), nil +} + +// fetchVLLMListings reads one listing per value and pipeline. Any request +// failing fails the whole fetch, so a refresh never replaces the cached list +// with one silently missing a publisher. +func (c *catalogService) fetchVLLMListings(ctx context.Context, field string, values []string) ([]hfModelRow, error) { + type job struct{ value, pipeline string } + var jobs []job + for _, v := range values { + for _, p := range vllmPipelines { + jobs = append(jobs, job{v, p}) + } + } + batches := make([][]hfModelRow, len(jobs)) + errs := make([]error, len(jobs)) + var wg sync.WaitGroup + for i, j := range jobs { + wg.Add(1) + go func() { + defer wg.Done() + batches[i], errs[i] = c.fetchHF(ctx, vllmListing(field, j.value, j.pipeline)) + }() + } + wg.Wait() + if err := errors.Join(errs...); err != nil { + return nil, err + } + var rows []hfModelRow + for _, b := range batches { + rows = append(rows, b...) + } + return rows, nil +} + +// vllmListing is a listing request narrowed by one field, author or search, and +// one pipeline. expand asks for exactly the fields used, including the tensor +// parameter counts that give a model's size. +func vllmListing(field, value, pipeline string) url.Values { + q := url.Values{} + q.Set(field, value) + q.Set("pipeline_tag", pipeline) + q.Set("filter", "safetensors") + q.Set("sort", "downloads") + q.Set("direction", "-1") + q.Set("limit", fmt.Sprint(vllmListingLimit)) + for _, f := range []string{"safetensors", "downloads", "likes", "lastModified", "createdAt", "tags", "gated", "private", "pipeline_tag"} { + q.Add("expand[]", f) + } + return q +} + +func normalizeVLLMRows(rows []hfModelRow) []CatalogModel { + seen := make(map[string]int, len(rows)) + out := make([]CatalogModel, 0, len(rows)) + for _, r := range rows { + model, ok := vllmModel(r) + if !ok { + continue + } + if idx, dup := seen[model.ID]; dup { + out[idx] = model + continue + } + seen[model.ID] = len(out) + out = append(out, model) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Downloads > out[j].Downloads }) + return out +} + +// vllmModel turns one listing entry into a download, or reports it is not one. +// Gated and private repos fail to download without credentials, MLX is Apple's +// framework, and a listing can still include embedding models the chat endpoint +// cannot serve. +func vllmModel(r hfModelRow) (CatalogModel, bool) { + repo := strings.TrimSpace(r.ID) + if repo == "" { + repo = strings.TrimSpace(r.ModelID) + } + if !llamaCPPRepoPattern.MatchString(repo) { + return CatalogModel{}, false + } + if r.Private || string(bytes.TrimSpace(r.Gated)) != "false" { + return CatalogModel{}, false + } + if !hasTag(r.Tags, "safetensors") || !isGenerative(r) || isMLX(repo, r.Tags) { + return CatalogModel{}, false + } + updated := r.LastModified + if updated == "" { + updated = r.CreatedAt + } + if _, err := time.Parse(time.RFC3339, updated); err != nil { + return CatalogModel{}, false + } + return CatalogModel{ + ID: repo, + Name: repo, + Author: repo[:strings.Index(repo, "/")], + URL: "https://huggingface.co/" + repo, + Size: safetensorsBytes(r.Safetensors.Parameters), + Downloads: max(r.Downloads, 0), + Likes: max(r.Likes, 0), + UpdatedAt: updated, + Tags: r.Tags, + ParameterSize: parameterLabel(r.Safetensors.Total), + }, true +} + +// tensorBytes is the storage size of a safetensors element type. A type not +// listed makes the total unknown, which is reported as no size at all. +var tensorBytes = map[string]uint64{ + "F64": 8, "F32": 4, "BF16": 2, "F16": 2, "I64": 8, "I32": 4, "I16": 2, + "I8": 1, "U8": 1, "F8_E4M3": 1, "F8_E5M2": 1, "BOOL": 1, +} + +func safetensorsBytes(parameters map[string]int64) uint64 { + var total uint64 + for kind, count := range parameters { + width, ok := tensorBytes[kind] + if !ok || count < 0 { + return 0 + } + total += uint64(count) * width + } + return total +} + +// parameterLabel renders a parameter count the way model names do: 600M, 8B. +func parameterLabel(n int64) string { + switch { + case n >= 1_000_000_000: + return strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1e9), ".0") + "B" + case n >= 1_000_000: + return fmt.Sprintf("%dM", (n+500_000)/1_000_000) + default: + return "" + } +} diff --git a/services/nvpair-engine-manager/catalog_vllm_test.go b/services/nvpair-engine-manager/catalog_vllm_test.go new file mode 100644 index 000000000..f75745e5d --- /dev/null +++ b/services/nvpair-engine-manager/catalog_vllm_test.go @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "slices" + "sync" + "testing" +) + +func vllmRow(id string, mutate func(*map[string]any)) map[string]any { + row := map[string]any{ + "id": id, "gated": false, "private": false, "downloads": 100, "likes": 5, + "lastModified": "2026-01-02T03:04:05.000Z", "pipeline_tag": "text-generation", + "tags": []string{"transformers", "safetensors", "text-generation"}, + "safetensors": map[string]any{"parameters": map[string]int64{"BF16": 8_000_000_000}, "total": 8_000_000_000}, + } + if mutate != nil { + mutate(&row) + } + return row +} + +func TestVLLMCatalogKeepsOpenSafetensorsChatModels(t *testing.T) { + var mu sync.Mutex + var queries []map[string][]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + queries = append(queries, r.URL.Query()) + mu.Unlock() + if r.URL.Query().Get("pipeline_tag") == "image-text-to-text" { + _ = json.NewEncoder(w).Encode([]any{vllmRow("Qwen/Vision-8B", func(m *map[string]any) { (*m)["pipeline_tag"] = "image-text-to-text"; (*m)["downloads"] = 900 })}) + return + } + _ = json.NewEncoder(w).Encode([]any{ + vllmRow("Qwen/Chat-8B", nil), + vllmRow("Qwen/Gated", func(m *map[string]any) { (*m)["gated"] = "auto" }), + vllmRow("Qwen/Private", func(m *map[string]any) { (*m)["private"] = true }), + vllmRow("Qwen/Embed", func(m *map[string]any) { (*m)["pipeline_tag"] = "feature-extraction" }), + vllmRow("Qwen/NoSafetensors", func(m *map[string]any) { (*m)["tags"] = []string{"gguf", "text-generation"} }), + vllmRow("mlx-community/Chat-4bit", func(m *map[string]any) { (*m)["tags"] = []string{"safetensors", "mlx", "text-generation"} }), + vllmRow("../bad", nil), + }) + })) + defer srv.Close() + c := newCatalogService() + c.baseURL = srv.URL + + res, err := c.Catalog(context.Background(), "vllm", "", "", "") + if err != nil { + t.Fatal(err) + } + var ids []string + for _, m := range res.Models { + ids = append(ids, m.ID) + } + if !slices.Equal(ids, []string{"Qwen/Vision-8B", "Qwen/Chat-8B"}) { + t.Fatalf("ids = %v, want the open chat models, most downloaded first", ids) + } + if !res.Searchable { + t.Fatal("the vLLM catalog can be searched") + } + m := res.Models[1] + if m.Name != "Qwen/Chat-8B" || m.ParameterSize != "8B" || m.Size != 16_000_000_000 || m.Author != "Qwen" { + t.Fatalf("model = %+v, want a pull-ready name, 8B and 16 GB", m) + } + pipelines := map[string]bool{} + for _, q := range queries { + pipelines[q["pipeline_tag"][0]] = true + if q["filter"][0] != "safetensors" || !slices.Contains(q["expand[]"], "safetensors") { + t.Fatalf("a listing must filter on safetensors and expand them: %v", q) + } + } + if !pipelines["text-generation"] || !pipelines["image-text-to-text"] { + t.Fatalf("both chat pipelines must be listed: %v", pipelines) + } +} + +func TestVLLMCatalogSearchesWithTheQuery(t *testing.T) { + var mu sync.Mutex + var searches []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + searches = append(searches, r.URL.Query().Get("search")) + mu.Unlock() + _ = json.NewEncoder(w).Encode([]any{vllmRow("someone/Llama-Chat", nil)}) + })) + defer srv.Close() + c := newCatalogService() + c.baseURL = srv.URL + + res, err := c.Catalog(context.Background(), "vllm", "", "", " llama ") + if err != nil { + t.Fatal(err) + } + if res.Query != "llama" || len(res.Models) != 1 || res.Models[0].ID != "someone/Llama-Chat" { + t.Fatalf("result = %+v", res) + } + if len(searches) == 0 || searches[0] != "llama" { + t.Fatalf("the upstream must be searched with the trimmed query: %v", searches) + } + if _, err := c.Catalog(context.Background(), "vllm", "", "", string(make([]byte, maxCatalogQuery+1))); err == nil { + t.Fatal("an over-long query is refused") + } +} + +func TestParameterLabelAndSize(t *testing.T) { + for n, want := range map[int64]string{751_632_384: "752M", 8_190_000_000: "8.2B", 32_000_000_000: "32B", 5_000: ""} { + if got := parameterLabel(n); got != want { + t.Errorf("parameterLabel(%d) = %q, want %q", n, got, want) + } + } + if safetensorsBytes(map[string]int64{"BF16": 10, "F32": 1}) != 24 || safetensorsBytes(map[string]int64{"MYSTERY": 1}) != 0 { + t.Error("size sums known tensor types and is unknown when one is not recognised") + } +} diff --git a/services/nvpair-engine-manager/hfpull.go b/services/nvpair-engine-manager/hfpull.go new file mode 100644 index 000000000..e75f91fe8 --- /dev/null +++ b/services/nvpair-engine-manager/hfpull.go @@ -0,0 +1,249 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// hfAPIBase is the Hugging Face model API. A variable so tests can point it at +// a local server. +var hfAPIBase = hfModelsAPI + +const ( + hfPollInterval = time.Second + // hfStopGrace is how long a cancelled download gets to exit before it is killed. + hfStopGrace = 3 * time.Second + // hfErrorTail is how many trailing output lines a failed download reports. + hfErrorTail = 4 +) + +// hfDroppedSuffixes are file types a vLLM-served model never reads. Skipping +// them keeps a repo that ships several formats from downloading each of them. +var hfDroppedSuffixes = []string{".gguf", ".onnx", ".h5", ".msgpack", ".ot", ".tflite"} + +// hfAlternateWeights are dropped when the repo also has safetensors. +var hfAlternateWeights = []string{".bin", ".pt", ".pth"} + +// selectHFFiles chooses the repository files to download and their total size. +// A repo with safetensors does not also need its pickle weights, nor the +// consolidated copy some publishers add beside the sharded one. File names that +// could be read as an option or escape the cache are refused. +func selectHFFiles(siblings []hfSibling) (files []string, total int64, err error) { + hasSafetensors, hasSharded := false, false + for _, f := range siblings { + name := strings.ToLower(f.RFilename) + if strings.HasSuffix(name, ".safetensors") { + hasSafetensors = true + if !strings.HasPrefix(filepath.Base(name), "consolidated") { + hasSharded = true + } + } + } + hasWeights := false + for _, f := range siblings { + name := f.RFilename + lower := strings.ToLower(name) + if name == "" || strings.HasPrefix(name, "-") || strings.HasPrefix(name, "/") || strings.Contains(name, "..") { + continue + } + if strings.HasPrefix(lower, "original/") || hasSuffixAny(lower, hfDroppedSuffixes) { + continue + } + if hasSafetensors && hasSuffixAny(lower, hfAlternateWeights) { + continue + } + if hasSharded && strings.HasSuffix(lower, ".safetensors") && strings.HasPrefix(filepath.Base(lower), "consolidated") { + continue + } + if strings.HasSuffix(lower, ".safetensors") || hasSuffixAny(lower, hfAlternateWeights) { + hasWeights = true + } + files = append(files, name) + total += f.Size + } + if !hasWeights { + return nil, 0, errors.New("the repository has no safetensors or PyTorch weights this engine can load") + } + return files, total, nil +} + +func hasSuffixAny(s string, suffixes []string) bool { + for _, suffix := range suffixes { + if strings.HasSuffix(s, suffix) { + return true + } + } + return false +} + +// hfRepoFiles lists a repository's files with sizes. +func (e *Executor) hfRepoFiles(ctx context.Context, id string) ([]hfSibling, error) { + ctx, cancel := context.WithTimeout(ctx, catalogHTTPTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, hfAPIBase+"/"+id+"?blobs=true", nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "PAIR/1.0") + if token := os.Getenv("HF_TOKEN"); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := e.client.Do(req) + if err != nil { + var urlErr *url.Error + if errors.As(err, &urlErr) { + err = urlErr.Err + } + return nil, fmt.Errorf("look up the model on Hugging Face: %w", err) + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNotFound: + return nil, errors.New("no such model on Hugging Face") + case http.StatusUnauthorized, http.StatusForbidden: + return nil, errors.New("this model needs a Hugging Face login: accept its terms on huggingface.co and set HF_TOKEN") + default: + return nil, fmt.Errorf("Hugging Face returned %s", resp.Status) + } + var repo struct { + Siblings []hfSibling `json:"siblings"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, maxCatalogBody)).Decode(&repo); err != nil { + return nil, fmt.Errorf("decode the Hugging Face reply: %w", err) + } + return repo.Siblings, nil +} + +// hfBlobBytes totals what is in a repository's blobs directory, which holds +// finished files and the .incomplete ones still being written. +func hfBlobBytes(repoDir string) int64 { + entries, err := os.ReadDir(filepath.Join(repoDir, "blobs")) + if err != nil { + return 0 + } + var total int64 + for _, entry := range entries { + if info, err := entry.Info(); err == nil && !info.IsDir() { + total += info.Size() + } + } + return total +} + +// pullHFModel downloads a Hugging Face repository into the hub cache with the +// engine's `hf` tool. Progress is the share of the repository's bytes that has +// landed in the cache, which stays honest across resumed and parallel files. +// A download that makes no progress for the pull timeout is abandoned. +func (e *Executor) pullHFModel(ctx context.Context, st *engineState, engine, model string, act Action) (json.RawMessage, error) { + id := strings.TrimSpace(model) + if !hfModelIDRe.MatchString(id) { + return nil, fmt.Errorf("pull %q: not a valid Hugging Face model id (use org/name)", model) + } + vars := st.pathVars() + hubDir, err := resolvePlaceholders(act.HFPull.Dir, vars) + if err != nil { + return nil, err + } + hubDir = expandPath(hubDir) + + st.mu.Lock() + binPath := st.binPath + st.mu.Unlock() + if binPath == "" { + bin, err := resolvePlaceholders(st.plat.Runtime.Bin, vars) + if err != nil { + return nil, err + } + binPath = expandPath(bin) + } + tool := filepath.Join(filepath.Dir(binPath), act.HFPull.Tool) + if _, err := os.Stat(tool); err != nil { + return nil, fmt.Errorf("pull %q: the Hugging Face tool %q was not found next to %s; install it with `pip install huggingface_hub` or reinstall the engine", id, act.HFPull.Tool, binPath) + } + + e.emitPullProgress(ProgressEvent{Engine: engine, Op: "pull", Stage: "pulling", Message: id}) + siblings, err := e.hfRepoFiles(ctx, id) + if err != nil { + return nil, fmt.Errorf("pull %q: %w", id, err) + } + files, total, err := selectHFFiles(siblings) + if err != nil { + return nil, fmt.Errorf("pull %q: %w", id, err) + } + + repoDir := filepath.Join(hubDir, "models--"+strings.ReplaceAll(id, "/", "--")) + args := append([]string{"download", id}, files...) + args = append(args, "--cache-dir", hubDir) + var tailMu sync.Mutex + var tail []string + proc, err := startManagedProc(tool, args, map[string]string{ + "HF_HUB_DISABLE_PROGRESS_BARS": "1", + "HF_HUB_DISABLE_TELEMETRY": "1", + }, func(_, line string) { + if line = strings.TrimSpace(line); line != "" { + tailMu.Lock() + defer tailMu.Unlock() + tail = append(tail, line) + if len(tail) > hfErrorTail { + tail = tail[1:] + } + } + }) + if err != nil { + return nil, fmt.Errorf("pull %q: start the download: %w", id, err) + } + + pullCtx, watchdog := newPullProgressWatchdog(ctx, e.pullProgressTimeout) + defer watchdog.stop() + ticker := time.NewTicker(hfPollInterval) + defer ticker.Stop() + lastPct := -1 + for { + select { + case <-proc.done: + if proc.cmd.ProcessState == nil || !proc.cmd.ProcessState.Success() { + tailMu.Lock() + reason := strings.Join(tail, "; ") + tailMu.Unlock() + return nil, fmt.Errorf("pull %q: download failed: %s", id, reason) + } + if _, complete := completeHFModel(repoDir); !complete { + return nil, fmt.Errorf("pull %q: the download finished but the model in the cache is incomplete", id) + } + e.emitPullProgress(ProgressEvent{Engine: engine, Op: "pull", Stage: "success", Percent: 100, Message: id}) + return json.Marshal(map[string]any{"model": id, "bytes": total}) + case <-pullCtx.Done(): + proc.stop(hfStopGrace) + return nil, fmt.Errorf("pull %q: %w", id, watchdog.resolveError(pullCtx, context.Cause(pullCtx))) + case <-ticker.C: + done := hfBlobBytes(repoDir) + watchdog.recordProgress("bytes", done) + if total <= 0 { + continue + } + pct := int(done * 100 / total) + if pct > 99 { + pct = 99 + } + if pct != lastPct && pct > 0 { + lastPct = pct + e.emitPullProgress(ProgressEvent{Engine: engine, Op: "pull", Stage: "downloading", Percent: pct, Message: id}) + } + } + } +} diff --git a/services/nvpair-engine-manager/hfpull_test.go b/services/nvpair-engine-manager/hfpull_test.go new file mode 100644 index 000000000..b1d5dd9af --- /dev/null +++ b/services/nvpair-engine-manager/hfpull_test.go @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" + "testing" + "time" +) + +func TestSelectHFFiles(t *testing.T) { + file := func(name string, size int64) hfSibling { return hfSibling{RFilename: name, Size: size} } + t.Run("safetensors repo skips pickle weights and consolidated copies", func(t *testing.T) { + files, total, err := selectHFFiles([]hfSibling{ + file("config.json", 10), file("tokenizer.json", 20), + file("model-00001-of-00002.safetensors", 1000), file("model-00002-of-00002.safetensors", 1000), + file("model.safetensors.index.json", 5), + file("consolidated.safetensors", 2000), file("pytorch_model.bin", 2000), + file("original/params.json", 5), file("model.gguf", 3000), file("-rf", 1), file("../escape", 1), + }) + if err != nil { + t.Fatal(err) + } + want := []string{"config.json", "tokenizer.json", "model-00001-of-00002.safetensors", "model-00002-of-00002.safetensors", "model.safetensors.index.json"} + if !slices.Equal(files, want) || total != 10+20+1000+1000+5 { + t.Fatalf("files = %v total = %d", files, total) + } + }) + t.Run("a repo with only pickle weights keeps them", func(t *testing.T) { + files, _, err := selectHFFiles([]hfSibling{file("config.json", 1), file("pytorch_model.bin", 9)}) + if err != nil || !slices.Contains(files, "pytorch_model.bin") { + t.Fatalf("files = %v err = %v", files, err) + } + }) + t.Run("only a consolidated file is kept when nothing else exists", func(t *testing.T) { + files, _, err := selectHFFiles([]hfSibling{file("params.json", 1), file("consolidated.safetensors", 9)}) + if err != nil || !slices.Contains(files, "consolidated.safetensors") { + t.Fatalf("files = %v err = %v", files, err) + } + }) + t.Run("a GGUF-only repo is refused", func(t *testing.T) { + if _, _, err := selectHFFiles([]hfSibling{file("config.json", 1), file("m.gguf", 9)}); err == nil { + t.Fatal("expected an error: nothing vLLM can load") + } + }) +} + +// hfPullFixture builds an executor whose engine binary sits beside a fake `hf` +// tool, plus a fake Hugging Face API. script is the body of the fake tool. +func hfPullFixture(t *testing.T, siblings []hfSibling, apiStatus int, script string) (e *Executor, hub string, events *[]ProgressEvent) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("uses a shell script as the hf tool") + } + dir := t.TempDir() + hub = filepath.Join(dir, "hub") + bin := filepath.Join(dir, "vllm") + for path, body := range map[string]string{bin: "#!/bin/sh\nexit 0\n", filepath.Join(dir, "hf"): "#!/bin/sh\n" + script} { + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + } + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if apiStatus != http.StatusOK { + http.Error(w, "x", apiStatus) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"siblings": siblings}) + })) + t.Cleanup(api.Close) + old := hfAPIBase + hfAPIBase = api.URL + t.Cleanup(func() { hfAPIBase = old }) + + m := testEngineManifest(bin) + m.Engine = "vllm" + m.Actions = map[string]Action{"pull_model": {HFPull: &ActionHFPull{Tool: "hf", Dir: hub}}} + reg := NewRegistry() + reg.engines[m.Engine] = m + var mu sync.Mutex + collected := []ProgressEvent{} + e = NewExecutor(reg, NewReporter(nil), func(method string, params any) { + fields, ok := params.(map[string]any) + if !ok || method != "engine:pull-progress" { + return + } + ev := ProgressEvent{Stage: fields["stage"].(string)} + if pct, ok := fields["percent"].(int); ok { + ev.Percent = pct + } + mu.Lock() + collected = append(collected, ev) + mu.Unlock() + }, t.TempDir()) + return e, hub, &collected +} + +// The fake tool lays out a Hugging Face cache the way `hf download` does. +const fakeHFDownload = ` +repo="$(echo "$@" | sed 's/.*--cache-dir //')/models--Qwen--Test" +mkdir -p "$repo/blobs" "$repo/snapshots/abc" +printf '{}' > "$repo/blobs/c"; printf '{}' > "$repo/blobs/t"; head -c 1000 /dev/zero > "$repo/blobs/w" +ln -s ../../blobs/c "$repo/snapshots/abc/config.json" +ln -s ../../blobs/t "$repo/snapshots/abc/tokenizer.json" +ln -s ../../blobs/w "$repo/snapshots/abc/model.safetensors" +` + +func TestPullHFModelDownloadsAndReportsSuccess(t *testing.T) { + e, hub, events := hfPullFixture(t, []hfSibling{ + {RFilename: "config.json", Size: 2}, {RFilename: "tokenizer.json", Size: 2}, {RFilename: "model.safetensors", Size: 1000}, + }, http.StatusOK, fakeHFDownload) + + res, err := e.PullModelStream(context.Background(), "vllm", "Qwen/Test", nil) + if err != nil { + t.Fatalf("pull: %v", err) + } + if !strings.Contains(string(res), "Qwen/Test") { + t.Fatalf("result = %s", res) + } + models, err := scanHFCache(hub) + if err != nil || len(models) != 1 || models[0].ID != "Qwen/Test" { + t.Fatalf("the downloaded model must show in the cache list: %v %v", models, err) + } + last := (*events)[len(*events)-1] + if last.Stage != "success" || last.Percent != 100 { + t.Fatalf("last progress = %+v, want success at 100", last) + } +} + +func TestPullHFModelReportsAFailedDownload(t *testing.T) { + e, _, _ := hfPullFixture(t, []hfSibling{{RFilename: "model.safetensors", Size: 10}}, http.StatusOK, + `echo "connection reset by peer" >&2; exit 1`) + _, err := e.PullModelStream(context.Background(), "vllm", "Qwen/Test", nil) + if err == nil || !strings.Contains(err.Error(), "connection reset by peer") { + t.Fatalf("err = %v, want the tool's own message", err) + } +} + +func TestPullHFModelRefusesBadInputsBeforeDownloading(t *testing.T) { + cases := []struct { + name, model string + status int + want string + }{ + {"not a repo id", "../etc/passwd", http.StatusOK, "valid Hugging Face model id"}, + {"option-looking id", "--help", http.StatusOK, "valid Hugging Face model id"}, + {"unknown repo", "Qwen/Nope", http.StatusNotFound, "no such model"}, + {"gated repo", "meta/Gated", http.StatusForbidden, "HF_TOKEN"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + e, _, _ := hfPullFixture(t, nil, tc.status, "touch ran-anyway; exit 0") + _, err := e.PullModelStream(context.Background(), "vllm", tc.model, nil) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want %q", err, tc.want) + } + }) + } +} + +func TestPullHFModelStopsWhenCancelled(t *testing.T) { + e, _, _ := hfPullFixture(t, []hfSibling{{RFilename: "model.safetensors", Size: 10}}, http.StatusOK, "sleep 30") + ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond) + defer cancel() + start := time.Now() + _, err := e.PullModelStream(ctx, "vllm", "Qwen/Test", nil) + if err == nil || time.Since(start) > 10*time.Second { + t.Fatalf("a cancelled download must stop promptly: err=%v after %s", err, time.Since(start)) + } +} diff --git a/services/nvpair-engine-manager/manifests/vllm.json b/services/nvpair-engine-manager/manifests/vllm.json index fc62c6cf8..46a82f54c 100644 --- a/services/nvpair-engine-manager/manifests/vllm.json +++ b/services/nvpair-engine-manager/manifests/vllm.json @@ -65,6 +65,10 @@ "description": "Serve a different model (params: {\"model\": \"\"}). vLLM serves one model per process, so this saves the model into the launch arguments and restarts the engine on it; if the new model does not start, the previous one is restored.", "launch_model": { "flag": "--model" } }, + "pull_model": { + "description": "Download a model from Hugging Face into the shared cache (params: {\"model\": \"\"}). Needs no running engine. Once downloaded it appears in list_models and can be served with load_model.", + "hf_pull": { "tool": "hf", "dir": "{models_dir}/hub" } + }, "loaded_models": { "description": "List the model this vLLM server is serving (it serves exactly one). This is what peers and the proxy are told it can answer for.", "http": { "method": "GET", "path": "/v1/models" }, diff --git a/services/nvpair-engine-manager/pull.go b/services/nvpair-engine-manager/pull.go index 97d8fb565..5d464536b 100644 --- a/services/nvpair-engine-manager/pull.go +++ b/services/nvpair-engine-manager/pull.go @@ -122,6 +122,13 @@ func (e *Executor) PullModelStream(ctx context.Context, engine, model string, pa params, _ = json.Marshal(map[string]string{"name": model, "model": model}) } + // Hugging Face repository (vLLM): download with the engine's own `hf` tool and + // report progress from the bytes landing in the cache. It needs no running + // engine, so a model can be fetched before it is first served. + if act.HFPull != nil { + return e.pullHFModel(ctx, st, engine, model, act) + } + // CLI action (e.g. lms get): no structured line progress; emit a start // marker and return the final result via the existing runner. if len(act.Cmd) > 0 { diff --git a/services/nvpair-engine-manager/registry.go b/services/nvpair-engine-manager/registry.go index b5c026bd6..6e872c8fd 100644 --- a/services/nvpair-engine-manager/registry.go +++ b/services/nvpair-engine-manager/registry.go @@ -287,6 +287,9 @@ type Action struct { // LaunchModel makes the action switch the model an engine is serving, for an // engine that has no load call because the model is a launch argument. LaunchModel *ActionLaunchModel `json:"launch_model,omitempty"` + // HFPull downloads a Hugging Face repository into the hub cache with the + // engine's own `hf` tool, reporting byte progress. Only pull_model uses it. + HFPull *ActionHFPull `json:"hf_pull,omitempty"` // ProgressProtocol selects a narrowly defined streaming adapter for an // asynchronous pull HTTP action. Empty keeps the ordinary response-stream // behavior; named protocols are validated so a typo cannot silently fall @@ -351,6 +354,15 @@ type ActionHFCache struct { Dir string `json:"dir"` } +// ActionHFPull downloads a model repository. Tool is the file name of the +// Hugging Face CLI, looked up in the directory that holds the engine binary +// (a vLLM virtualenv ships `hf` beside `vllm`). Dir is the hub cache directory, +// normally "{models_dir}/hub". +type ActionHFPull struct { + Tool string `json:"tool"` + Dir string `json:"dir"` +} + // ActionLaunchModel names the launch flag that selects the engine's model, such // as "--model". The action takes {"model": ""}, writes it into the saved // launch settings and restarts a running engine on it. @@ -965,6 +977,7 @@ func (a *Action) validate(name string) error { hasRemovePath := a.RemovePath != nil hasHFCache := a.HFCache != nil hasLaunchModel := a.LaunchModel != nil + hasHFPull := a.HFPull != nil kinds := 0 if hasHTTP { kinds++ @@ -981,8 +994,17 @@ func (a *Action) validate(name string) error { if hasLaunchModel { kinds++ } + if hasHFPull { + kinds++ + } if kinds != 1 { - return fmt.Errorf("action %q: exactly one of http, cmd, remove_path, hf_cache, or launch_model is required", name) + return fmt.Errorf("action %q: exactly one of http, cmd, remove_path, hf_cache, launch_model, or hf_pull is required", name) + } + if hasHFPull { + tool := a.HFPull.Tool + if name != pullModelAction || tool == "" || tool != filepath.Base(tool) || strings.TrimSpace(a.HFPull.Dir) == "" { + return fmt.Errorf("action %q: hf_pull belongs to pull_model and needs a tool file name and a dir", name) + } } if hasLaunchModel { flag := a.LaunchModel.Flag @@ -1128,6 +1150,9 @@ func (m *Manifest) actionTemplatedStrings() []string { out = append(out, act.RemovePath.Root) // Path may template caller params (e.g. {model}); validated at call time. } + if act.HFPull != nil { + out = append(out, act.HFPull.Dir) + } if act.HFCache != nil { out = append(out, act.HFCache.Dir) } diff --git a/services/nvpair-engine-manager/registry_test.go b/services/nvpair-engine-manager/registry_test.go index a2440d752..0595e49f1 100644 --- a/services/nvpair-engine-manager/registry_test.go +++ b/services/nvpair-engine-manager/registry_test.go @@ -317,7 +317,7 @@ func TestValidate_RejectsInvalidManifests(t *testing.T) { }, "json_match.value is required"}, {"action without http or cmd", func(m *Manifest) { m.Actions = map[string]Action{"x": {Description: "neither"}} - }, "exactly one of http, cmd, remove_path, hf_cache, or launch_model"}, + }, "exactly one of http, cmd, remove_path, hf_cache, launch_model, or hf_pull"}, {"action missing method", func(m *Manifest) { m.Actions = map[string]Action{"x": {HTTP: &ActionHTTP{Path: "/p"}}} }, "http.method and http.path"}, diff --git a/services/nvpair-engine-manager/vllm_manifest_test.go b/services/nvpair-engine-manager/vllm_manifest_test.go index df67f9b50..b4f6da79d 100644 --- a/services/nvpair-engine-manager/vllm_manifest_test.go +++ b/services/nvpair-engine-manager/vllm_manifest_test.go @@ -93,11 +93,15 @@ func TestBundledVLLMManifest(t *testing.T) { if load := manifest.Actions["load_model"]; load.LaunchModel == nil || load.LaunchModel.Flag != "--model" { t.Errorf("load_model must switch the model through --model, got %+v", load) } - for _, name := range []string{"unload_model", "pull_model", "delete_model"} { + for _, name := range []string{"unload_model", "delete_model"} { if _, ok := manifest.Actions[name]; ok { t.Errorf("vllm serves one model per process and must not declare %s", name) } } + // Downloads go through the hf tool that ships in the engine's virtualenv. + if pull := manifest.Actions["pull_model"]; pull.HFPull == nil || pull.HFPull.Tool != "hf" || pull.HFPull.Dir != "{models_dir}/hub" { + t.Errorf("pull_model must download through hf into the hub cache, got %+v", pull) + } } // The vLLM wheel is a CUDA 13 build, so an older driver installs cleanly and From 79325c96bb64097d4b3c2adda49a6d3f7efddb9c Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 21 Oct 2026 12:15:08 +0530 Subject: [PATCH 11/35] feat(desktop): offer Add model and hub search for vLLM Enable hasPullModel for vLLM, give it an engine hub, let the model hub search the upstream catalog for any engine that reports it (a new hubSearch capability, set for llama.cpp and vLLM), and warm the vLLM catalog with the others. Update the docs. Signed-off-by: Tushar Khinvasara --- desktop/docs/frontend-api.md | 15 ++--- desktop/docs/services-parity.md | 58 +++++++++---------- .../electron/service-bridge/model-catalog.ts | 1 + .../components/ModelHub/ModelHubContent.tsx | 3 +- .../src/ui/constants/engine-capabilities.ts | 13 ++++- desktop/src/ui/types/engine-manifest.ts | 10 +++- desktop/tests/modular/engine-identity.test.ts | 12 ++-- desktop/tests/modular/model-catalog.test.ts | 4 +- docs/engine-lifecycle.mdx | 7 ++- 9 files changed, 70 insertions(+), 53 deletions(-) diff --git a/desktop/docs/frontend-api.md b/desktop/docs/frontend-api.md index 7e99175d3..25577045d 100644 --- a/desktop/docs/frontend-api.md +++ b/desktop/docs/frontend-api.md @@ -100,14 +100,15 @@ They do not imply a WebSocket connection. Browser clients are not supported. became unreachable, so its cached snapshot is stale. `EngineType` currently includes `ollama`, `lm-studio`, `llama-cpp`, and `vllm`. -vLLM serves one model per process, so it offers no eject, delete, Add model or -catalog search. Its model list is the Hugging Face cache, and `loadModel` on one -of them switches the served model by restarting vLLM on it (the capability flag -`hasPullModel: false` hides Add model). +vLLM serves one model per process, so it offers no eject or delete. Its model +list is the Hugging Face cache, `pullModel` downloads a Hugging Face repo into +that cache (no running engine needed), and `loadModel` on a cached model switches +the served model by restarting vLLM on it. `searchHub(engineType, query?)` serves Ollama's locked local catalog and cached -live Hugging Face catalogs for LM Studio and llama.cpp. A non-empty llama.cpp -query performs an explicit bounded search across public Hugging Face GGUF -repositories; other calls return the populated engine catalog. llama.cpp pull +live Hugging Face catalogs for LM Studio, llama.cpp and vLLM. A non-empty +llama.cpp or vLLM query performs an explicit bounded search across public +Hugging Face repositories (GGUF for llama.cpp, safetensors chat models for vLLM); +other calls return the populated engine catalog. llama.cpp pull IDs are exact `owner/repository:quantization` values; load, unload, and delete all use that same exact id. diff --git a/desktop/docs/services-parity.md b/desktop/docs/services-parity.md index 36f1a72d2..0aac03854 100644 --- a/desktop/docs/services-parity.md +++ b/desktop/docs/services-parity.md @@ -16,26 +16,26 @@ history. ## Status summary -| Domain | Status | Current state | -| -------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| Process supervision | Complete | Electron starts only `nvpair-ui-broker`; the broker supervises all workers | -| Discovery | Complete | Broker discovery snapshots drive available nodes and node state | -| Node telemetry | Integrated with direct poll | Electron polls advertised `/v1/node-info` (plain HTTP); remote OS and some remote telemetry are backend-limited | -| Manual nodes | Complete with local persistence | Broker owns probing, durable entries, restart replay, and proxy registration | -| Ollama routing | Complete | Broker relay and backend scheduler drive proxy routing | -| LM Studio routing | Complete | Parallel broker relay and scheduler path | -| llama.cpp backend | Integrated | Desktop and TUI expose install, lifecycle, catalog pull, inventory, load/unload, endpoints, routing, and demo traffic | -| vLLM backend | Integrated (Linux) | Desktop and TUI expose install, lifecycle, the cached-model list, switching the served model (Load), endpoints and routing; no eject, delete, pull or catalog; no demo traffic | -| Local engine lifecycle | Complete | Install, start, stop, uninstall, update, and port configuration | -| Remote engine lifecycle | Partial | Remote install, start, stop, status, and model pull are supported | -| Engine models | Partial | Core list, pull, load, unload, and supported delete actions are wired | -| Engine version metadata | Backend-limited | The backend status surface does not provide a complete version/install-owner/update contract | -| Errors | Complete | Broker-relayed registry with peer synchronization | -| Workloads | Complete after subscription | Local and peer workload events feed one Electron catalog | -| Cluster pairing | Complete | PIN pairing, identity, membership, leave, and removal | -| Cluster transport security | Backend-owned | Node-to-node transport security, including the proxies' cluster-mTLS inference ingress, is entirely backend; Personal AI Router implements none | -| Settings | Partial | Cluster identity plus per-engine ports and engine arguments, local and remote; inert backend settings are not surfaced | -| Model catalog search | Backend-owned | `engine:catalog` serves the locked Ollama list and the cached LM Studio and llama.cpp catalogs, and searches Hugging Face for llama.cpp | +| Domain | Status | Current state | +| -------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Process supervision | Complete | Electron starts only `nvpair-ui-broker`; the broker supervises all workers | +| Discovery | Complete | Broker discovery snapshots drive available nodes and node state | +| Node telemetry | Integrated with direct poll | Electron polls advertised `/v1/node-info` (plain HTTP); remote OS and some remote telemetry are backend-limited | +| Manual nodes | Complete with local persistence | Broker owns probing, durable entries, restart replay, and proxy registration | +| Ollama routing | Complete | Broker relay and backend scheduler drive proxy routing | +| LM Studio routing | Complete | Parallel broker relay and scheduler path | +| llama.cpp backend | Integrated | Desktop and TUI expose install, lifecycle, catalog pull, inventory, load/unload, endpoints, routing, and demo traffic | +| vLLM backend | Integrated (Linux) | Desktop and TUI expose install, lifecycle, the cached-model list, switching the served model (Load), Hugging Face catalog search and download, endpoints and routing; no eject or delete; no demo traffic | +| Local engine lifecycle | Complete | Install, start, stop, uninstall, update, and port configuration | +| Remote engine lifecycle | Partial | Remote install, start, stop, status, and model pull are supported | +| Engine models | Partial | Core list, pull, load, unload, and supported delete actions are wired | +| Engine version metadata | Backend-limited | The backend status surface does not provide a complete version/install-owner/update contract | +| Errors | Complete | Broker-relayed registry with peer synchronization | +| Workloads | Complete after subscription | Local and peer workload events feed one Electron catalog | +| Cluster pairing | Complete | PIN pairing, identity, membership, leave, and removal | +| Cluster transport security | Backend-owned | Node-to-node transport security, including the proxies' cluster-mTLS inference ingress, is entirely backend; Personal AI Router implements none | +| Settings | Partial | Cluster identity plus per-engine ports and engine arguments, local and remote; inert backend settings are not surfaced | +| Model catalog search | Backend-owned | `engine:catalog` serves the locked Ollama list and the cached LM Studio and llama.cpp catalogs, and searches Hugging Face for llama.cpp | ## Supervision @@ -510,15 +510,15 @@ its effect is observable. The following responsibilities remain in Personal AI Router because the backend does not provide an equivalent client-facing contract: -| Responsibility | Location | -| ----------------------------------------------------------- | ------------------------------------------------ | -| Poll node telemetry over `/v1/node-info` | `node-info-poller.ts` | -| Bridge the local node into engine proxies | `modular-supervisor.ts` | -| Present optimistic engine transition state | `pending-actions.store.ts`, bridge state | -| Relay the backend model catalogue to the renderer | `service-bridge/model-catalog.ts` | -| Accumulate and reconcile receiver-side pending invites | `modular-state.ts`, `modular-supervisor.ts` | -| Mirror backend-coupled runtime defaults not yet reported | `modular-runtime.ts` | -| Collapse a superseded node row before the scanner proves it | `modular-state.ts`, `modular-runtime.ts` | +| Responsibility | Location | +| ----------------------------------------------------------- | ------------------------------------------- | +| Poll node telemetry over `/v1/node-info` | `node-info-poller.ts` | +| Bridge the local node into engine proxies | `modular-supervisor.ts` | +| Present optimistic engine transition state | `pending-actions.store.ts`, bridge state | +| Relay the backend model catalogue to the renderer | `service-bridge/model-catalog.ts` | +| Accumulate and reconcile receiver-side pending invites | `modular-state.ts`, `modular-supervisor.ts` | +| Mirror backend-coupled runtime defaults not yet reported | `modular-runtime.ts` | +| Collapse a superseded node row before the scanner proves it | `modular-state.ts`, `modular-runtime.ts` | These are current integration boundaries. Do not add compatibility shims or duplicate backend services around them. diff --git a/desktop/src/electron/service-bridge/model-catalog.ts b/desktop/src/electron/service-bridge/model-catalog.ts index efd24666b..60bd896bb 100644 --- a/desktop/src/electron/service-bridge/model-catalog.ts +++ b/desktop/src/electron/service-bridge/model-catalog.ts @@ -124,4 +124,5 @@ export async function getEngineHubModels( export function warmEngineHubs(): void { void getEngineHubModels('lm-studio') void getEngineHubModels('llama-cpp') + void getEngineHubModels('vllm') } diff --git a/desktop/src/ui/components/ModelHub/ModelHubContent.tsx b/desktop/src/ui/components/ModelHub/ModelHubContent.tsx index 354709b75..8ee2c5be9 100644 --- a/desktop/src/ui/components/ModelHub/ModelHubContent.tsx +++ b/desktop/src/ui/components/ModelHub/ModelHubContent.tsx @@ -12,6 +12,7 @@ import { mergeModelHubResults, searchEngineHub } from '@/ui/utils/model-hub-sear import { resolveStoredSort, writeStoredSort } from '@/ui/utils/model-hub-content-storage' import { isHubEntryDownloaded } from '@/ui/utils/match-downloaded-model' import { EngineType } from '@/shared/types/engines' +import { EngineCapabilities } from '@/ui/constants/engine-capabilities' import type { ModelItem } from '@/ui/types/engine-info' import getErrorString from '@/shared/utils/get-error-string' @@ -184,7 +185,7 @@ export const ModelHubContent = ({ async (nextQuery: string) => { const normalized = nextQuery.trim() setQuery(nextQuery) - if (engine !== 'llama-cpp' || normalized.length === 0) return + if (!engine || !EngineCapabilities[engine].hubSearch || normalized.length === 0) return const generation = ++searchGenRef.current submittedQueryRef.current = normalized diff --git a/desktop/src/ui/constants/engine-capabilities.ts b/desktop/src/ui/constants/engine-capabilities.ts index 6f8d0d121..b0d58dbce 100644 --- a/desktop/src/ui/constants/engine-capabilities.ts +++ b/desktop/src/ui/constants/engine-capabilities.ts @@ -60,6 +60,7 @@ export const EngineCapabilities: Record = { // The router removes cached models in-process via DELETE /models. hasDeleteModel: true, hasPullModel: true, + hubSearch: true, engineHub: { label: 'llama.cpp', url: 'https://huggingface.co/models?library=gguf' @@ -82,8 +83,14 @@ export const EngineCapabilities: Record = { // The engine manager declares no delete_model for vLLM; weights live in // the shared Hugging Face cache. hasDeleteModel: false, - // No pull_model either. The model list is what the Hugging Face cache - // already holds, and Load switches the served model by restarting vLLM. - hasPullModel: false + // The engine manager downloads a Hugging Face repo into the shared cache + // with the engine's own `hf` tool (pull_model). The model list is what + // that cache holds, and Load switches the served model by restarting vLLM. + hasPullModel: true, + hubSearch: true, + engineHub: { + label: 'vLLM', + url: 'https://huggingface.co/models?pipeline_tag=text-generation&library=safetensors&sort=trending' + } } } diff --git a/desktop/src/ui/types/engine-manifest.ts b/desktop/src/ui/types/engine-manifest.ts index 767400c0c..3fd012bdc 100644 --- a/desktop/src/ui/types/engine-manifest.ts +++ b/desktop/src/ui/types/engine-manifest.ts @@ -38,11 +38,15 @@ export interface EngineCaps { hasDeleteModel: boolean /** * When true, the engine can download a model on request (a `pull_model` - * action), so the model list offers "Add model". An engine that serves one - * model per process has no such call: it lists what is already on disk and - * switches between those models with Load. + * action), so the model list offers "Add model". */ hasPullModel: boolean + /** + * When true, the model hub can search the whole upstream catalog for a typed + * query (the backend's `engine:catalog` reports `searchable`), instead of only + * filtering the browse list. + */ + hubSearch?: boolean /** * When true, the engine restarts as part of deleting a model, so Delete asks * for confirmation first. The engine manager owns the restart; this flag only diff --git a/desktop/tests/modular/engine-identity.test.ts b/desktop/tests/modular/engine-identity.test.ts index 2714df83b..a81ba5b66 100644 --- a/desktop/tests/modular/engine-identity.test.ts +++ b/desktop/tests/modular/engine-identity.test.ts @@ -44,19 +44,21 @@ describe('engine identity', () => { expect(engineTypeFromManagerName('vllm')).toBe('vllm') expect(isEngineType('vllm')).toBe(true) expect(EngineDisplayNames.vllm).toBe('vLLM') - // vLLM has no load, unload or delete API and no public catalog. + // vLLM has no unload or delete, but downloads from Hugging Face. expect(EngineCapabilities.vllm).toMatchObject({ hasEject: false, hasDeleteModel: false, + hasPullModel: true, + hubSearch: true, hasInstall: ['linux'], - hasEnginePort: true + hasEnginePort: true, + engineHub: { label: 'vLLM' } }) - expect(EngineCapabilities.vllm.engineHub).toBeUndefined() }) - it('offers Add model only to engines that can download on request', () => { + it('offers Add model to every engine, vLLM included', () => { for (const engineType of EngineTypes) { - expect(EngineCapabilities[engineType].hasPullModel).toBe(engineType !== 'vllm') + expect(EngineCapabilities[engineType].hasPullModel).toBe(true) } }) diff --git a/desktop/tests/modular/model-catalog.test.ts b/desktop/tests/modular/model-catalog.test.ts index e65d0953f..d2931eb96 100644 --- a/desktop/tests/modular/model-catalog.test.ts +++ b/desktop/tests/modular/model-catalog.test.ts @@ -127,8 +127,8 @@ describe('model catalog relay', () => { // cost a multi-megabyte reply for nothing. it('warms only the live-fetched catalogs', () => { warmEngineHubs() - expect(mocks.supervisor.callProcess).toHaveBeenCalledTimes(2) - for (const engine of ['lmstudio', 'llamacpp']) { + expect(mocks.supervisor.callProcess).toHaveBeenCalledTimes(3) + for (const engine of ['lmstudio', 'llamacpp', 'vllm']) { expect(mocks.supervisor.callProcess).toHaveBeenCalledWith( 'broker', 'engine:catalog', diff --git a/docs/engine-lifecycle.mdx b/docs/engine-lifecycle.mdx index 44054cb49..273119308 100644 --- a/docs/engine-lifecycle.mdx +++ b/docs/engine-lifecycle.mdx @@ -99,9 +99,10 @@ When you install an engine, consider the following: shows the complete text models already in your Hugging Face cache (`~/.cache/huggingface`), and **Load** on one of them switches vLLM to it by restarting the engine, which takes as long as the model needs to load; if the - new model does not start, the previous one is restored. There is no Eject, - Delete or Add model: to use a model that is not cached, name it in the launch - arguments (`--model `), and vLLM downloads it on start. Other + new model does not start, the previous one is restored. **Add model** browses + and searches Hugging Face (open safetensors chat models) and downloads the one + you pick into the same cache, with a progress bar, even while vLLM is stopped; + it then appears in the list. There is no Eject or Delete. Other nodes and the proxy see only the model vLLM is serving, so a request for a different cached model is refused until you switch to it. From 29b994ad83d745187be8915f72de219ca325c16c Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 21 Oct 2026 14:11:11 +0530 Subject: [PATCH 12/35] feat: delete cached Hugging Face models for vLLM vLLM had no delete, so a cached model could only be removed by hand. Add an hf_cache delete variant for delete_model: it removes a model's folder and download locks from the hub cache, needs no running engine, refuses a path outside the cache, and refuses the model the engine is set to serve (running or not), failing closed when that cannot be determined. Enable hasDeleteModel for vLLM in the desktop, with a new deleteBlockedWhileLoaded capability that disables Delete on the loaded model. Update the docs. Signed-off-by: Tushar Khinvasara --- desktop/docs/frontend-api.md | 5 +- desktop/docs/services-parity.md | 46 +++++----- .../ui/components/ModelManager/ModelRow.tsx | 8 +- .../src/ui/constants/engine-capabilities.ts | 7 +- desktop/src/ui/types/engine-manifest.ts | 6 ++ desktop/tests/modular/engine-identity.test.ts | 6 +- docs/engine-lifecycle.mdx | 2 +- services/nvpair-engine-manager/README.md | 2 + services/nvpair-engine-manager/actions.go | 2 +- services/nvpair-engine-manager/hfcache.go | 68 +++++++++++++- .../nvpair-engine-manager/hfcache_test.go | 92 ++++++++++++++++++- .../nvpair-engine-manager/manifests/vllm.json | 4 + services/nvpair-engine-manager/registry.go | 7 ++ .../vllm_manifest_test.go | 10 +- 14 files changed, 227 insertions(+), 38 deletions(-) diff --git a/desktop/docs/frontend-api.md b/desktop/docs/frontend-api.md index 25577045d..1ce195435 100644 --- a/desktop/docs/frontend-api.md +++ b/desktop/docs/frontend-api.md @@ -100,8 +100,9 @@ They do not imply a WebSocket connection. Browser clients are not supported. became unreachable, so its cached snapshot is stale. `EngineType` currently includes `ollama`, `lm-studio`, `llama-cpp`, and `vllm`. -vLLM serves one model per process, so it offers no eject or delete. Its model -list is the Hugging Face cache, `pullModel` downloads a Hugging Face repo into +vLLM serves one model per process, so it offers no eject. Its model +list is the Hugging Face cache, `deleteModel` removes a cached model (not the one +it is set to serve), `pullModel` downloads a Hugging Face repo into that cache (no running engine needed), and `loadModel` on a cached model switches the served model by restarting vLLM on it. `searchHub(engineType, query?)` serves Ollama's locked local catalog and cached diff --git a/desktop/docs/services-parity.md b/desktop/docs/services-parity.md index 0aac03854..db8165c14 100644 --- a/desktop/docs/services-parity.md +++ b/desktop/docs/services-parity.md @@ -16,26 +16,26 @@ history. ## Status summary -| Domain | Status | Current state | -| -------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Process supervision | Complete | Electron starts only `nvpair-ui-broker`; the broker supervises all workers | -| Discovery | Complete | Broker discovery snapshots drive available nodes and node state | -| Node telemetry | Integrated with direct poll | Electron polls advertised `/v1/node-info` (plain HTTP); remote OS and some remote telemetry are backend-limited | -| Manual nodes | Complete with local persistence | Broker owns probing, durable entries, restart replay, and proxy registration | -| Ollama routing | Complete | Broker relay and backend scheduler drive proxy routing | -| LM Studio routing | Complete | Parallel broker relay and scheduler path | -| llama.cpp backend | Integrated | Desktop and TUI expose install, lifecycle, catalog pull, inventory, load/unload, endpoints, routing, and demo traffic | -| vLLM backend | Integrated (Linux) | Desktop and TUI expose install, lifecycle, the cached-model list, switching the served model (Load), Hugging Face catalog search and download, endpoints and routing; no eject or delete; no demo traffic | -| Local engine lifecycle | Complete | Install, start, stop, uninstall, update, and port configuration | -| Remote engine lifecycle | Partial | Remote install, start, stop, status, and model pull are supported | -| Engine models | Partial | Core list, pull, load, unload, and supported delete actions are wired | -| Engine version metadata | Backend-limited | The backend status surface does not provide a complete version/install-owner/update contract | -| Errors | Complete | Broker-relayed registry with peer synchronization | -| Workloads | Complete after subscription | Local and peer workload events feed one Electron catalog | -| Cluster pairing | Complete | PIN pairing, identity, membership, leave, and removal | -| Cluster transport security | Backend-owned | Node-to-node transport security, including the proxies' cluster-mTLS inference ingress, is entirely backend; Personal AI Router implements none | -| Settings | Partial | Cluster identity plus per-engine ports and engine arguments, local and remote; inert backend settings are not surfaced | -| Model catalog search | Backend-owned | `engine:catalog` serves the locked Ollama list and the cached LM Studio and llama.cpp catalogs, and searches Hugging Face for llama.cpp | +| Domain | Status | Current state | +| -------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Process supervision | Complete | Electron starts only `nvpair-ui-broker`; the broker supervises all workers | +| Discovery | Complete | Broker discovery snapshots drive available nodes and node state | +| Node telemetry | Integrated with direct poll | Electron polls advertised `/v1/node-info` (plain HTTP); remote OS and some remote telemetry are backend-limited | +| Manual nodes | Complete with local persistence | Broker owns probing, durable entries, restart replay, and proxy registration | +| Ollama routing | Complete | Broker relay and backend scheduler drive proxy routing | +| LM Studio routing | Complete | Parallel broker relay and scheduler path | +| llama.cpp backend | Integrated | Desktop and TUI expose install, lifecycle, catalog pull, inventory, load/unload, endpoints, routing, and demo traffic | +| vLLM backend | Integrated (Linux) | Desktop and TUI expose install, lifecycle, the cached-model list, switching the served model (Load), Hugging Face catalog search and download, endpoints and routing; delete of cached models; no eject; no demo traffic | +| Local engine lifecycle | Complete | Install, start, stop, uninstall, update, and port configuration | +| Remote engine lifecycle | Partial | Remote install, start, stop, status, and model pull are supported | +| Engine models | Partial | Core list, pull, load, unload, and supported delete actions are wired | +| Engine version metadata | Backend-limited | The backend status surface does not provide a complete version/install-owner/update contract | +| Errors | Complete | Broker-relayed registry with peer synchronization | +| Workloads | Complete after subscription | Local and peer workload events feed one Electron catalog | +| Cluster pairing | Complete | PIN pairing, identity, membership, leave, and removal | +| Cluster transport security | Backend-owned | Node-to-node transport security, including the proxies' cluster-mTLS inference ingress, is entirely backend; Personal AI Router implements none | +| Settings | Partial | Cluster identity plus per-engine ports and engine arguments, local and remote; inert backend settings are not surfaced | +| Model catalog search | Backend-owned | `engine:catalog` serves the locked Ollama list and the cached LM Studio and llama.cpp catalogs, and searches Hugging Face for llama.cpp | ## Supervision @@ -189,8 +189,10 @@ The bundled `vllm` manifest is Linux only. It installs a pinned `vllm` wheel into a private virtualenv through a checksum-pinned `uv` bootstrap, or adopts a `vllm` already installed at a detected path, then runs `vllm serve` on `8001` behind the proxy facade on `8000`. vLLM serves one model per process and has no -load, unload, pull or delete API, so the desktop hides Eject, Delete and Add -model for it and offers no catalog. Its model list is the Hugging Face cache +load or unload API, so the desktop hides Eject for it. It downloads and deletes +through the shared Hugging Face cache (`pull_model` is an `hf_pull` action, +`delete_model` an `hf_cache` delete that refuses the model it is set to serve), +and has a Hugging Face catalog. Its model list is the Hugging Face cache (`list_models` is an `hf_cache` action, so it works while the engine is stopped), and Load switches the served model through `load_model` (a `launch_model` action): it saves `--model ` into the launch arguments and diff --git a/desktop/src/ui/components/ModelManager/ModelRow.tsx b/desktop/src/ui/components/ModelManager/ModelRow.tsx index b571dffc3..84bfd83f0 100644 --- a/desktop/src/ui/components/ModelManager/ModelRow.tsx +++ b/desktop/src/ui/components/ModelManager/ModelRow.tsx @@ -63,12 +63,18 @@ function ModelRowInner({ }) } if (capabilities.hasDeleteModel) { - items.push({ id: 'delete', children: 'Delete', danger: true, disabled: busy }) + items.push({ + id: 'delete', + children: 'Delete', + danger: true, + disabled: busy || (isLoaded && !!capabilities.deleteBlockedWhileLoaded) + }) } return items }, [ capabilities.hasEject, capabilities.hasDeleteModel, + capabilities.deleteBlockedWhileLoaded, isLoaded, isRunning, model.downloaded, diff --git a/desktop/src/ui/constants/engine-capabilities.ts b/desktop/src/ui/constants/engine-capabilities.ts index b0d58dbce..dcf0dc412 100644 --- a/desktop/src/ui/constants/engine-capabilities.ts +++ b/desktop/src/ui/constants/engine-capabilities.ts @@ -80,9 +80,10 @@ export const EngineCapabilities: Record = { hasCrashAlert: false, hasModelSearchOnlyWhenRunning: true, modelOpsWhenStopped: false, - // The engine manager declares no delete_model for vLLM; weights live in - // the shared Hugging Face cache. - hasDeleteModel: false, + // delete_model removes a model from the shared Hugging Face cache. The + // model vLLM is serving cannot be deleted: the engine manager refuses it. + hasDeleteModel: true, + deleteBlockedWhileLoaded: true, // The engine manager downloads a Hugging Face repo into the shared cache // with the engine's own `hf` tool (pull_model). The model list is what // that cache holds, and Load switches the served model by restarting vLLM. diff --git a/desktop/src/ui/types/engine-manifest.ts b/desktop/src/ui/types/engine-manifest.ts index 3fd012bdc..84cc201e0 100644 --- a/desktop/src/ui/types/engine-manifest.ts +++ b/desktop/src/ui/types/engine-manifest.ts @@ -53,6 +53,12 @@ export interface EngineCaps { * tells the UI that deleting has that side effect. */ restartsOnModelDelete?: boolean + /** + * When true, the model an engine is serving cannot be deleted, so Delete is + * disabled for it. An engine that serves one model per process breaks if its + * model files go, so the backend refuses it and the UI does not offer it. + */ + deleteBlockedWhileLoaded?: boolean /** * If the engine has its own public model catalog (e.g. ollama.com), provide the * hub config here. Presence doubles as the truthy flag for the hub source selector. diff --git a/desktop/tests/modular/engine-identity.test.ts b/desktop/tests/modular/engine-identity.test.ts index a81ba5b66..e242b1dea 100644 --- a/desktop/tests/modular/engine-identity.test.ts +++ b/desktop/tests/modular/engine-identity.test.ts @@ -44,10 +44,12 @@ describe('engine identity', () => { expect(engineTypeFromManagerName('vllm')).toBe('vllm') expect(isEngineType('vllm')).toBe(true) expect(EngineDisplayNames.vllm).toBe('vLLM') - // vLLM has no unload or delete, but downloads from Hugging Face. + // vLLM has no unload, but downloads from and deletes in the Hugging Face + // cache, except the model it is serving. expect(EngineCapabilities.vllm).toMatchObject({ hasEject: false, - hasDeleteModel: false, + hasDeleteModel: true, + deleteBlockedWhileLoaded: true, hasPullModel: true, hubSearch: true, hasInstall: ['linux'], diff --git a/docs/engine-lifecycle.mdx b/docs/engine-lifecycle.mdx index 273119308..1b83d157c 100644 --- a/docs/engine-lifecycle.mdx +++ b/docs/engine-lifecycle.mdx @@ -102,7 +102,7 @@ When you install an engine, consider the following: new model does not start, the previous one is restored. **Add model** browses and searches Hugging Face (open safetensors chat models) and downloads the one you pick into the same cache, with a progress bar, even while vLLM is stopped; - it then appears in the list. There is no Eject or Delete. Other + it then appears in the list. **Delete** removes a cached model from disk, except the one vLLM is set to serve (switch first). There is no Eject, since vLLM cannot unload a model without stopping. Other nodes and the proxy see only the model vLLM is serving, so a request for a different cached model is refused until you switch to it. diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index c13cb0f6e..e32b27f6d 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -89,6 +89,8 @@ switches the served model: it writes `--model ` into the saved launch arguments and restarts vLLM on it, restoring the previous model if the new one does not start (for example it does not fit in GPU memory). +`delete_model` removes a model's folder from the hub cache (an `hf_cache` action with `delete`); the model vLLM is set to serve, running or not, is refused so a switch has to come first. + `pull_model` is an `hf_pull` action: it asks Hugging Face for the repo's file list and sizes, picks the files vLLM reads (safetensors, config and tokenizer; not GGUF, ONNX, pickle copies of weights that also exist as safetensors, or a diff --git a/services/nvpair-engine-manager/actions.go b/services/nvpair-engine-manager/actions.go index 29bad6cd6..5a7255fb5 100644 --- a/services/nvpair-engine-manager/actions.go +++ b/services/nvpair-engine-manager/actions.go @@ -107,7 +107,7 @@ func (e *Executor) dispatchAction(ctx context.Context, st *engineState, engine, return e.runRemovePathAction(ctx, st, act, params) } if act.HFCache != nil { - return e.runHFCacheAction(st, act) + return e.runHFCacheAction(st, engine, act, params) } if len(act.Cmd) > 0 { return e.runCmdAction(ctx, st, act, port, params) diff --git a/services/nvpair-engine-manager/hfcache.go b/services/nvpair-engine-manager/hfcache.go index 53790af93..73c220ab4 100644 --- a/services/nvpair-engine-manager/hfcache.go +++ b/services/nvpair-engine-manager/hfcache.go @@ -37,11 +37,14 @@ var hfShardRe = regexp.MustCompile(`^.+-(\d+)-of-(\d+)\.(?:safetensors|bin)$`) // runHFCacheAction lists the models in act.HFCache.Dir. It needs no running // engine, so the list is available while the engine is stopped. -func (e *Executor) runHFCacheAction(st *engineState, act Action) (json.RawMessage, error) { +func (e *Executor) runHFCacheAction(st *engineState, engine string, act Action, params json.RawMessage) (json.RawMessage, error) { dir, err := resolvePlaceholders(act.HFCache.Dir, st.pathVars()) if err != nil { return nil, err } + if act.HFCache.Delete { + return e.deleteHFModel(engine, expandPath(dir), params) + } models, err := scanHFCache(expandPath(dir)) if err != nil { return nil, err @@ -217,3 +220,66 @@ func hasAny(files map[string]int64, names []string) bool { } return false } + +// deleteHFModel removes one model's repository folder from the hub cache, and +// its download locks. The model the engine is serving, or will serve at its next +// start, is refused: deleting it would break the running engine, or make the next +// start download the model again without being asked. +func (e *Executor) deleteHFModel(engine, hubDir string, params json.RawMessage) (json.RawMessage, error) { + var p struct { + Model string `json:"model"` + } + if err := json.Unmarshal(params, &p); err != nil || !hfModelIDRe.MatchString(strings.TrimSpace(p.Model)) { + return nil, fmt.Errorf("delete: params.model must be a Hugging Face model id such as org/name") + } + id := strings.TrimSpace(p.Model) + served, err := e.servedModel(engine) + if err != nil { + return nil, fmt.Errorf("delete: cannot tell which model %s is set to serve, so nothing was deleted: %w", engine, err) + } + if served == id { + return nil, fmt.Errorf("delete: %s is the model %s is set to serve; switch to another model first", id, engine) + } + name := "models--" + strings.ReplaceAll(id, "/", "--") + repo := filepath.Join(hubDir, name) + if rel, err := filepath.Rel(hubDir, repo); err != nil || rel != name { + return nil, fmt.Errorf("delete: refusing a path outside the model cache") + } + if _, err := os.Stat(repo); err != nil { + return nil, fmt.Errorf("delete: %s is not in the model cache", id) + } + freed := hfBlobBytes(repo) + if err := os.RemoveAll(repo); err != nil { + return nil, fmt.Errorf("delete %s: %w", id, err) + } + _ = os.RemoveAll(filepath.Join(hubDir, ".locks", name)) + return json.Marshal(map[string]any{"deleted": id, "freed": freed}) +} + +// servedModel returns the model the engine is set to serve, from its effective +// launch arguments. It fails when that cannot be determined, so a caller that +// must not touch the served model fails closed. The engine's binary is detected +// first, because the launch cannot be resolved before that. +func (e *Executor) servedModel(engine string) (string, error) { + state, err := e.LaunchSettings(engine) + if err != nil { + return "", err + } + if state.LaunchText == "" { + if _, err := e.Detect(engine); err != nil { + return "", err + } + if state, err = e.LaunchSettings(engine); err != nil { + return "", err + } + } + tokens, err := parseLaunchText(state.LaunchText) + if err != nil { + return "", err + } + model, ok := launchFlagValue(tokens, "--model") + if !ok || model == "" { + return "", fmt.Errorf("the launch arguments name no --model") + } + return model, nil +} diff --git a/services/nvpair-engine-manager/hfcache_test.go b/services/nvpair-engine-manager/hfcache_test.go index d6faca8af..35d9f7e93 100644 --- a/services/nvpair-engine-manager/hfcache_test.go +++ b/services/nvpair-engine-manager/hfcache_test.go @@ -4,9 +4,11 @@ package main import ( + "context" "encoding/json" "os" "path/filepath" + "runtime" "slices" "strings" "testing" @@ -155,7 +157,7 @@ func TestHFCacheActionResultFeedsTheModelListExtractor(t *testing.T) { writeHFRepo(t, cache, "Qwen/Qwen3-8B", llmFiles(map[string]string{"model.safetensors": "w"})) e := &Executor{} - raw, err := e.runHFCacheAction(&engineState{}, Action{HFCache: &ActionHFCache{Dir: cache}}) + raw, err := e.runHFCacheAction(&engineState{}, "vllm", Action{HFCache: &ActionHFCache{Dir: cache}}, nil) if err != nil { t.Fatal(err) } @@ -180,3 +182,91 @@ func TestHFCacheActionValidation(t *testing.T) { t.Errorf("a valid hf_cache action was rejected: %v", err) } } + +func TestHFCacheDeleteActionValidation(t *testing.T) { + del := &ActionHFCache{Dir: "{models_dir}/hub", Delete: true} + if err := (&Action{HFCache: del}).validate("delete_model"); err != nil { + t.Errorf("a delete_model hf_cache delete was rejected: %v", err) + } + if err := (&Action{HFCache: del}).validate("list_models"); err == nil { + t.Error("hf_cache.delete is only for delete_model") + } +} + +func TestDeleteHFModel(t *testing.T) { + cache := t.TempDir() + e := switchModelExecutor(t, "--model") + if _, err := loadModel(t, e, "org/served"); err != nil { + t.Fatalf("load: %v", err) + } + m, _ := e.reg.Get("fake") + m.Actions["delete_model"] = Action{HFCache: &ActionHFCache{Dir: cache, Delete: true}} + writeHFRepo(t, cache, "org/served", llmFiles(map[string]string{"model.safetensors": "w"})) + writeHFRepo(t, cache, "org/other", llmFiles(map[string]string{"model.safetensors": "wwww"})) + if err := os.MkdirAll(filepath.Join(cache, ".locks", "models--org--other"), 0o755); err != nil { + t.Fatal(err) + } + del := func(model string) (json.RawMessage, error) { + return e.Action(context.Background(), "fake", "delete_model", json.RawMessage(`{"model":`+quoteJSON(model)+`}`)) + } + + if _, err := del("org/served"); err == nil || !strings.Contains(err.Error(), "switch to another model first") { + t.Fatalf("the model being served must be refused: %v", err) + } + if _, err := os.Stat(filepath.Join(cache, "models--org--served")); err != nil { + t.Fatal("a refused delete must leave the model in place") + } + + res, err := del("org/other") + if err != nil || !strings.Contains(string(res), `"deleted":"org/other"`) { + t.Fatalf("delete: %s %v", res, err) + } + for _, gone := range []string{"models--org--other", filepath.Join(".locks", "models--org--other")} { + if _, err := os.Stat(filepath.Join(cache, gone)); err == nil { + t.Fatalf("%s should have been removed", gone) + } + } + if ids := scannedIDs(t, cache); !slices.Equal(ids, []string{"org/served"}) { + t.Fatalf("remaining models = %v", ids) + } + + for _, bad := range []string{"org/missing", "../escape", "--help", ""} { + if _, err := del(bad); err == nil { + t.Errorf("deleting %q should fail", bad) + } + } +} + +// With the bundled manifest and no saved settings the served model is the +// default launch's, even in a manager that has not been asked about the engine +// yet. Deleting also fails closed, rather than assuming nothing is served, when +// the served model cannot be told. +func TestDeleteHFModelRefusesTheBundledDefaultModel(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("the bundled vllm manifest is Linux only") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) + cache := filepath.Join(home, ".cache", "huggingface", "hub") + writeHFRepo(t, cache, "Qwen/Qwen2.5-0.5B-Instruct", llmFiles(map[string]string{"model.safetensors": "w"})) + writeHFRepo(t, cache, "org/other", llmFiles(map[string]string{"model.safetensors": "w"})) + + reg := loadWithOverrides(t, t.TempDir()) + e := NewExecutor(reg, NewReporter(nil), func(string, any) {}, t.TempDir()) + act := func(model string) error { + _, err := e.Action(context.Background(), "vllm", "delete_model", json.RawMessage(`{"model":`+quoteJSON(model)+`}`)) + return err + } + + // The bundled default launch serves Qwen/Qwen2.5-0.5B-Instruct. + if err := act("Qwen/Qwen2.5-0.5B-Instruct"); err == nil { + t.Fatal("the default served model was deleted by a manager that had not detected the engine") + } + if _, err := os.Stat(filepath.Join(cache, "models--Qwen--Qwen2.5-0.5B-Instruct")); err != nil { + t.Fatal("the served model is gone from the cache") + } + if err := act("org/other"); err != nil { + t.Fatalf("another model should be deletable: %v", err) + } +} diff --git a/services/nvpair-engine-manager/manifests/vllm.json b/services/nvpair-engine-manager/manifests/vllm.json index 46a82f54c..35c99a5bc 100644 --- a/services/nvpair-engine-manager/manifests/vllm.json +++ b/services/nvpair-engine-manager/manifests/vllm.json @@ -65,6 +65,10 @@ "description": "Serve a different model (params: {\"model\": \"\"}). vLLM serves one model per process, so this saves the model into the launch arguments and restarts the engine on it; if the new model does not start, the previous one is restored.", "launch_model": { "flag": "--model" } }, + "delete_model": { + "description": "Delete a downloaded model from the Hugging Face cache (params: {\"model\": \"\"}). The model vLLM is set to serve is refused: switch to another model first.", + "hf_cache": { "dir": "{models_dir}/hub", "delete": true } + }, "pull_model": { "description": "Download a model from Hugging Face into the shared cache (params: {\"model\": \"\"}). Needs no running engine. Once downloaded it appears in list_models and can be served with load_model.", "hf_pull": { "tool": "hf", "dir": "{models_dir}/hub" } diff --git a/services/nvpair-engine-manager/registry.go b/services/nvpair-engine-manager/registry.go index 6e872c8fd..fbbc71141 100644 --- a/services/nvpair-engine-manager/registry.go +++ b/services/nvpair-engine-manager/registry.go @@ -352,6 +352,10 @@ type ActionRemovePath struct { // which the ordinary result extractor reads with array "data" and field "id". type ActionHFCache struct { Dir string `json:"dir"` + // Delete turns the action into the removal of one model, named by the + // caller's {"model": ""} param, from that cache. Only a + // delete_model action may set it. + Delete bool `json:"delete,omitempty"` } // ActionHFPull downloads a model repository. Tool is the file name of the @@ -1015,6 +1019,9 @@ func (a *Action) validate(name string) error { if hasHFCache && strings.TrimSpace(a.HFCache.Dir) == "" { return fmt.Errorf("action %q: hf_cache.dir is required", name) } + if hasHFCache && a.HFCache.Delete && name != "delete_model" { + return fmt.Errorf("action %q: hf_cache.delete belongs to delete_model", name) + } if hasRemovePath { if strings.TrimSpace(a.RemovePath.Path) == "" || strings.TrimSpace(a.RemovePath.Root) == "" { return fmt.Errorf("action %q: remove_path.path and remove_path.root are required", name) diff --git a/services/nvpair-engine-manager/vllm_manifest_test.go b/services/nvpair-engine-manager/vllm_manifest_test.go index b4f6da79d..aabb60e81 100644 --- a/services/nvpair-engine-manager/vllm_manifest_test.go +++ b/services/nvpair-engine-manager/vllm_manifest_test.go @@ -93,10 +93,12 @@ func TestBundledVLLMManifest(t *testing.T) { if load := manifest.Actions["load_model"]; load.LaunchModel == nil || load.LaunchModel.Flag != "--model" { t.Errorf("load_model must switch the model through --model, got %+v", load) } - for _, name := range []string{"unload_model", "delete_model"} { - if _, ok := manifest.Actions[name]; ok { - t.Errorf("vllm serves one model per process and must not declare %s", name) - } + if _, ok := manifest.Actions["unload_model"]; ok { + t.Errorf("vllm serves one model per process and must not declare unload_model") + } + // Deleting is a removal from the shared cache that needs no running engine. + if del := manifest.Actions["delete_model"]; del.HFCache == nil || !del.HFCache.Delete || del.HFCache.Dir != "{models_dir}/hub" { + t.Errorf("delete_model must remove from the hub cache, got %+v", del) } // Downloads go through the hf tool that ships in the engine's virtualenv. if pull := manifest.Actions["pull_model"]; pull.HFPull == nil || pull.HFPull.Tool != "hf" || pull.HFPull.Dir != "{models_dir}/hub" { From d8603196d1ddc41a1a97ee278f303831bbc1b458 Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 21 Oct 2026 14:44:21 +0530 Subject: [PATCH 13/35] feat: eject vLLM models by stopping the engine, and free blobs on delete vLLM cannot drop its model while running, so Eject (unload_model) is a new stop_engine action that stops the engine and frees all of its GPU memory. Load (launch_model) on a stopped engine now saves the choice and starts it, restoring the previous choice if it does not start. Enable hasEject for vLLM and keep its model list, Add model and Load available while stopped, since the list is read from disk. Delete removed only a model's folder, but recent Hugging Face tooling keeps large files in a shared hub/blobs store linked from each repo, so the weights stayed on disk. Delete now also removes shared files no other model uses, with their sidecars, and reports the bytes freed. Signed-off-by: Tushar Khinvasara --- desktop/docs/frontend-api.md | 5 +- desktop/docs/services-parity.md | 44 +++++------ .../service-bridge/modular-supervisor.ts | 5 +- desktop/src/shared/constants/engines.ts | 7 ++ .../ui/components/ModelManager/ModelRow.tsx | 7 +- .../src/ui/constants/engine-capabilities.ts | 13 ++-- desktop/tests/modular/engine-identity.test.ts | 18 ++++- .../modular/lmstudio-stale-model.test.ts | 38 ++++++++-- docs/engine-lifecycle.mdx | 2 +- services/nvpair-engine-manager/MANIFEST.md | 4 + services/nvpair-engine-manager/README.md | 4 +- services/nvpair-engine-manager/actions.go | 3 + services/nvpair-engine-manager/hfcache.go | 70 ++++++++++++++++++ .../nvpair-engine-manager/hfcache_test.go | 69 +++++++++++++++++ services/nvpair-engine-manager/launchmodel.go | 58 ++++++++++++++- .../nvpair-engine-manager/launchmodel_test.go | 74 ++++++++++++++++++- .../nvpair-engine-manager/manifests/vllm.json | 4 + services/nvpair-engine-manager/registry.go | 13 +++- .../nvpair-engine-manager/registry_test.go | 2 +- .../vllm_manifest_test.go | 5 +- 20 files changed, 396 insertions(+), 49 deletions(-) diff --git a/desktop/docs/frontend-api.md b/desktop/docs/frontend-api.md index 1ce195435..401d92bae 100644 --- a/desktop/docs/frontend-api.md +++ b/desktop/docs/frontend-api.md @@ -100,8 +100,9 @@ They do not imply a WebSocket connection. Browser clients are not supported. became unreachable, so its cached snapshot is stale. `EngineType` currently includes `ollama`, `lm-studio`, `llama-cpp`, and `vllm`. -vLLM serves one model per process, so it offers no eject. Its model -list is the Hugging Face cache, `deleteModel` removes a cached model (not the one +vLLM serves one model per process, so `unloadModel` (Eject) stops the engine, +which frees its GPU memory, and `loadModel` on a stopped engine starts it. Its model +list is the Hugging Face cache, which stays available while the engine is stopped, `deleteModel` removes a cached model (not the one it is set to serve), `pullModel` downloads a Hugging Face repo into that cache (no running engine needed), and `loadModel` on a cached model switches the served model by restarting vLLM on it. diff --git a/desktop/docs/services-parity.md b/desktop/docs/services-parity.md index db8165c14..1f209db42 100644 --- a/desktop/docs/services-parity.md +++ b/desktop/docs/services-parity.md @@ -16,26 +16,26 @@ history. ## Status summary -| Domain | Status | Current state | -| -------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Process supervision | Complete | Electron starts only `nvpair-ui-broker`; the broker supervises all workers | -| Discovery | Complete | Broker discovery snapshots drive available nodes and node state | -| Node telemetry | Integrated with direct poll | Electron polls advertised `/v1/node-info` (plain HTTP); remote OS and some remote telemetry are backend-limited | -| Manual nodes | Complete with local persistence | Broker owns probing, durable entries, restart replay, and proxy registration | -| Ollama routing | Complete | Broker relay and backend scheduler drive proxy routing | -| LM Studio routing | Complete | Parallel broker relay and scheduler path | -| llama.cpp backend | Integrated | Desktop and TUI expose install, lifecycle, catalog pull, inventory, load/unload, endpoints, routing, and demo traffic | -| vLLM backend | Integrated (Linux) | Desktop and TUI expose install, lifecycle, the cached-model list, switching the served model (Load), Hugging Face catalog search and download, endpoints and routing; delete of cached models; no eject; no demo traffic | -| Local engine lifecycle | Complete | Install, start, stop, uninstall, update, and port configuration | -| Remote engine lifecycle | Partial | Remote install, start, stop, status, and model pull are supported | -| Engine models | Partial | Core list, pull, load, unload, and supported delete actions are wired | -| Engine version metadata | Backend-limited | The backend status surface does not provide a complete version/install-owner/update contract | -| Errors | Complete | Broker-relayed registry with peer synchronization | -| Workloads | Complete after subscription | Local and peer workload events feed one Electron catalog | -| Cluster pairing | Complete | PIN pairing, identity, membership, leave, and removal | -| Cluster transport security | Backend-owned | Node-to-node transport security, including the proxies' cluster-mTLS inference ingress, is entirely backend; Personal AI Router implements none | -| Settings | Partial | Cluster identity plus per-engine ports and engine arguments, local and remote; inert backend settings are not surfaced | -| Model catalog search | Backend-owned | `engine:catalog` serves the locked Ollama list and the cached LM Studio and llama.cpp catalogs, and searches Hugging Face for llama.cpp | +| Domain | Status | Current state | +| -------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Process supervision | Complete | Electron starts only `nvpair-ui-broker`; the broker supervises all workers | +| Discovery | Complete | Broker discovery snapshots drive available nodes and node state | +| Node telemetry | Integrated with direct poll | Electron polls advertised `/v1/node-info` (plain HTTP); remote OS and some remote telemetry are backend-limited | +| Manual nodes | Complete with local persistence | Broker owns probing, durable entries, restart replay, and proxy registration | +| Ollama routing | Complete | Broker relay and backend scheduler drive proxy routing | +| LM Studio routing | Complete | Parallel broker relay and scheduler path | +| llama.cpp backend | Integrated | Desktop and TUI expose install, lifecycle, catalog pull, inventory, load/unload, endpoints, routing, and demo traffic | +| vLLM backend | Integrated (Linux) | Desktop and TUI expose install, lifecycle, the cached-model list, switching the served model (Load), Hugging Face catalog search and download, endpoints and routing; eject (stops the engine), delete of cached models; no demo traffic | +| Local engine lifecycle | Complete | Install, start, stop, uninstall, update, and port configuration | +| Remote engine lifecycle | Partial | Remote install, start, stop, status, and model pull are supported | +| Engine models | Partial | Core list, pull, load, unload, and supported delete actions are wired | +| Engine version metadata | Backend-limited | The backend status surface does not provide a complete version/install-owner/update contract | +| Errors | Complete | Broker-relayed registry with peer synchronization | +| Workloads | Complete after subscription | Local and peer workload events feed one Electron catalog | +| Cluster pairing | Complete | PIN pairing, identity, membership, leave, and removal | +| Cluster transport security | Backend-owned | Node-to-node transport security, including the proxies' cluster-mTLS inference ingress, is entirely backend; Personal AI Router implements none | +| Settings | Partial | Cluster identity plus per-engine ports and engine arguments, local and remote; inert backend settings are not surfaced | +| Model catalog search | Backend-owned | `engine:catalog` serves the locked Ollama list and the cached LM Studio and llama.cpp catalogs, and searches Hugging Face for llama.cpp | ## Supervision @@ -189,7 +189,9 @@ The bundled `vllm` manifest is Linux only. It installs a pinned `vllm` wheel into a private virtualenv through a checksum-pinned `uv` bootstrap, or adopts a `vllm` already installed at a detected path, then runs `vllm serve` on `8001` behind the proxy facade on `8000`. vLLM serves one model per process and has no -load or unload API, so the desktop hides Eject for it. It downloads and deletes +load or unload API, so Eject (`unload_model`, a `stop_engine` action) stops the +engine to free the GPU and Load on a stopped engine starts it; its model list +(`list_models` on disk) is kept while stopped. It downloads and deletes through the shared Hugging Face cache (`pull_model` is an `hf_pull` action, `delete_model` an `hf_cache` delete that refuses the model it is set to serve), and has a Hugging Face catalog. Its model list is the Hugging Face cache diff --git a/desktop/src/electron/service-bridge/modular-supervisor.ts b/desktop/src/electron/service-bridge/modular-supervisor.ts index 3bd83b5e4..e559fe704 100644 --- a/desktop/src/electron/service-bridge/modular-supervisor.ts +++ b/desktop/src/electron/service-bridge/modular-supervisor.ts @@ -12,6 +12,7 @@ import { type JsonRpcNotification, type JsonValue } from './json-rpc-subprocess' +import { EnginesListingModelsWhenStopped } from '@/shared/constants/engines' import { createStructuredLogger } from '@/shared/utils/log' import getErrorString from '@/shared/utils/get-error-string' import { currentPlatform } from '@/shared/utils/platform' @@ -1989,7 +1990,9 @@ class ModularSupervisor { const engineType = getModularBridgeState().modelPullTarget(engine) if (!engineType) return - if (!booleanValue(obj.running)) { + // An engine whose list is on disk keeps it while stopped: read it like a + // running engine's instead of clearing it. + if (!booleanValue(obj.running) && !EnginesListingModelsWhenStopped.includes(engineType)) { // A model switch restarts the engine, so it reports stopped on the // way. The list of models does not change, and clearing it made the // card empty until the engine was back. diff --git a/desktop/src/shared/constants/engines.ts b/desktop/src/shared/constants/engines.ts index f9c13c9e7..2c6973b29 100644 --- a/desktop/src/shared/constants/engines.ts +++ b/desktop/src/shared/constants/engines.ts @@ -11,6 +11,13 @@ export const EngineTypes = ['ollama', 'lm-studio', 'llama-cpp', 'vllm'] as const // appearing the moment its type exists. export const EnabledEngineTypes: EngineType[] = ['ollama', 'lm-studio', 'llama-cpp', 'vllm'] +/** + * Engines whose model list is read from disk, so it is available while the engine + * is stopped. For every other engine the list comes from the running engine and is + * cleared when it stops. The `modelOpsWhenStopped` capability must agree. + */ +export const EnginesListingModelsWhenStopped: readonly EngineType[] = ['vllm'] + /** * How `nvpair-engine-manager` spells each engine on the wire. This is the * single translation table — use `engineManagerName()` and diff --git a/desktop/src/ui/components/ModelManager/ModelRow.tsx b/desktop/src/ui/components/ModelManager/ModelRow.tsx index 84bfd83f0..80d77a7c9 100644 --- a/desktop/src/ui/components/ModelManager/ModelRow.tsx +++ b/desktop/src/ui/components/ModelManager/ModelRow.tsx @@ -49,7 +49,11 @@ function ModelRowInner({ items.push({ id: 'load', children: 'Load', - disabled: busy || isLoaded || !model.downloaded || !isRunning + disabled: + busy || + isLoaded || + !model.downloaded || + (!isRunning && !capabilities.modelOpsWhenStopped) }) if (capabilities.hasEject) { items.push({ @@ -75,6 +79,7 @@ function ModelRowInner({ capabilities.hasEject, capabilities.hasDeleteModel, capabilities.deleteBlockedWhileLoaded, + capabilities.modelOpsWhenStopped, isLoaded, isRunning, model.downloaded, diff --git a/desktop/src/ui/constants/engine-capabilities.ts b/desktop/src/ui/constants/engine-capabilities.ts index dcf0dc412..cf92288f0 100644 --- a/desktop/src/ui/constants/engine-capabilities.ts +++ b/desktop/src/ui/constants/engine-capabilities.ts @@ -68,9 +68,10 @@ export const EngineCapabilities: Record = { }, vllm: { hasExpiry: false, - // vLLM serves one model per process and has no unload API; changing - // the model means restarting the engine with different launch arguments. - hasEject: false, + // vLLM serves one model per process and cannot drop it while running, so + // Eject stops the engine, which frees all of its GPU memory. Load starts + // it again on the chosen model. + hasEject: true, // Linux only: vLLM has no native Windows or macOS GPU path. hasInstall: ['linux'], hasEnginePort: true, @@ -78,8 +79,10 @@ export const EngineCapabilities: Record = { hasProxyWebUI: false, hasPreferredNode: false, hasCrashAlert: false, - hasModelSearchOnlyWhenRunning: true, - modelOpsWhenStopped: false, + // The cache is on disk, so the model list, Add model, Delete and Load all + // work while the engine is stopped (Load starts it). + hasModelSearchOnlyWhenRunning: false, + modelOpsWhenStopped: true, // delete_model removes a model from the shared Hugging Face cache. The // model vLLM is serving cannot be deleted: the engine manager refuses it. hasDeleteModel: true, diff --git a/desktop/tests/modular/engine-identity.test.ts b/desktop/tests/modular/engine-identity.test.ts index e242b1dea..ed758282e 100644 --- a/desktop/tests/modular/engine-identity.test.ts +++ b/desktop/tests/modular/engine-identity.test.ts @@ -7,6 +7,7 @@ import { EngineDefaultLinks, EngineDisplayNames, EngineManagerNames, + EnginesListingModelsWhenStopped, EngineTypes } from '@/shared/constants/engines' import { engineManagerName, engineTypeFromManagerName, isEngineType } from '@/shared/utils/engines' @@ -44,10 +45,13 @@ describe('engine identity', () => { expect(engineTypeFromManagerName('vllm')).toBe('vllm') expect(isEngineType('vllm')).toBe(true) expect(EngineDisplayNames.vllm).toBe('vLLM') - // vLLM has no unload, but downloads from and deletes in the Hugging Face - // cache, except the model it is serving. + // vLLM cannot unload a model while running, so Eject stops the engine; it + // downloads from and deletes in the Hugging Face cache, except the model it + // is serving, and its model list is on disk, so it works while stopped. expect(EngineCapabilities.vllm).toMatchObject({ - hasEject: false, + hasEject: true, + modelOpsWhenStopped: true, + hasModelSearchOnlyWhenRunning: false, hasDeleteModel: true, deleteBlockedWhileLoaded: true, hasPullModel: true, @@ -58,6 +62,14 @@ describe('engine identity', () => { }) }) + it('keeps the capability and the supervisor agreeing on which model lists survive a stop', () => { + for (const engineType of EngineTypes) { + expect(EngineCapabilities[engineType].modelOpsWhenStopped).toBe( + EnginesListingModelsWhenStopped.includes(engineType) + ) + } + }) + it('offers Add model to every engine, vLLM included', () => { for (const engineType of EngineTypes) { expect(EngineCapabilities[engineType].hasPullModel).toBe(true) diff --git a/desktop/tests/modular/lmstudio-stale-model.test.ts b/desktop/tests/modular/lmstudio-stale-model.test.ts index 414b20dad..7f0f1e06a 100644 --- a/desktop/tests/modular/lmstudio-stale-model.test.ts +++ b/desktop/tests/modular/lmstudio-stale-model.test.ts @@ -517,9 +517,8 @@ describe('model list during a vLLM model switch', () => { ?.models.map(model => model.name) } - it('keeps the cached-model list while the engine restarts, and clears it when it is turned off', () => { + function startVllmNode() { const state = getModularBridgeState() - const supervisor = getModularSupervisor() state.setSelfId(nodeId) state.handleNotification({ source: 'broker', @@ -543,6 +542,13 @@ describe('model list during a vLLM model switch', () => { port: 8001 }) state.setLocalEngineModels('vllm', ['Qwen/Qwen3-8B', 'Qwen/Qwen3-32B']) + return state + } + + it('keeps the cached-model list while the engine restarts for a switch', () => { + const state = startVllmNode() + const supervisor = getModularSupervisor() + const call = vi.spyOn(supervisor, 'callProcess') expect(vllmModels()).toEqual(['Qwen/Qwen3-8B', 'Qwen/Qwen3-32B']) // A switch restarts the engine, which reports stopped on its way down. The @@ -550,10 +556,32 @@ describe('model list during a vLLM model switch', () => { state.beginLocalModelSwitch('vllm') supervisor.refreshManagedEngineModels({ engine: 'vllm', running: false }) expect(vllmModels()).toEqual(['Qwen/Qwen3-8B', 'Qwen/Qwen3-32B']) - - // Without a switch, stopping the engine clears the list as it does for every engine. state.clearLocalModelSwitch('vllm') + call.mockRestore() + }) + + // vLLM's list is the Hugging Face cache on disk, so a stopped (or ejected) + // engine still has it: the list is read, not cleared, unlike every other engine. + it('reads the cached-model list from disk while the engine is stopped', async () => { + const state = startVllmNode() + const supervisor = getModularSupervisor() + const hasProcess = vi.spyOn(supervisor, 'hasProcess').mockReturnValue(true) + const call = vi + .spyOn(supervisor, 'callProcess') + .mockResolvedValue({ data: [{ id: 'Qwen/Qwen3-8B' }, { id: 'org/new' }] }) + supervisor.refreshManagedEngineModels({ engine: 'vllm', running: false }) - expect(vllmModels()).toEqual([]) + expect(vllmModels()).toEqual(['Qwen/Qwen3-8B', 'Qwen/Qwen3-32B']) + await Promise.resolve() + await Promise.resolve() + + expect(call).toHaveBeenCalledWith('broker', 'engine:action', { + engine: 'vllm', + action: 'list_models' + }) + expect(vllmModels()).toEqual(['Qwen/Qwen3-8B', 'org/new']) + expect(state.getEngineInitialState().statuses.length).toBeGreaterThan(0) + call.mockRestore() + hasProcess.mockRestore() }) }) diff --git a/docs/engine-lifecycle.mdx b/docs/engine-lifecycle.mdx index 1b83d157c..4a887d62d 100644 --- a/docs/engine-lifecycle.mdx +++ b/docs/engine-lifecycle.mdx @@ -102,7 +102,7 @@ When you install an engine, consider the following: new model does not start, the previous one is restored. **Add model** browses and searches Hugging Face (open safetensors chat models) and downloads the one you pick into the same cache, with a progress bar, even while vLLM is stopped; - it then appears in the list. **Delete** removes a cached model from disk, except the one vLLM is set to serve (switch first). There is no Eject, since vLLM cannot unload a model without stopping. Other + it then appears in the list. **Delete** removes a cached model from disk, except the one vLLM is set to serve (switch first). **Eject** stops vLLM, which frees all of its GPU memory (vLLM cannot drop its model and keep running); the model list stays visible and **Load** starts it again on the model you pick. Other nodes and the proxy see only the model vLLM is serving, so a request for a different cached model is refused until you switch to it. diff --git a/services/nvpair-engine-manager/MANIFEST.md b/services/nvpair-engine-manager/MANIFEST.md index 8dd774e0f..273a34fc3 100644 --- a/services/nvpair-engine-manager/MANIFEST.md +++ b/services/nvpair-engine-manager/MANIFEST.md @@ -347,6 +347,10 @@ An **`hf_pull`** action (`{"tool": "hf", "dir": "{models_dir}/hub"}`, only as in the directory that holds the engine binary, and reports byte progress taken from the cache directory. It needs no running engine. +A **`stop_engine`** action (only as `unload_model`) stops the engine, for one that +serves a single model per process and so cannot unload it while running. A +`launch_model` action on a stopped engine saves the choice and starts it. + A runtime **`auto_launch`** (`{"kind": "hf_context", "context_tokens": 65536}`) lets the manager add start-time arguments that depend on the model and the machine. For `hf_context` it reads the served model's `config.json` from the diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index e32b27f6d..41c361577 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -89,7 +89,9 @@ switches the served model: it writes `--model ` into the saved launch arguments and restarts vLLM on it, restoring the previous model if the new one does not start (for example it does not fit in GPU memory). -`delete_model` removes a model's folder from the hub cache (an `hf_cache` action with `delete`); the model vLLM is set to serve, running or not, is refused so a switch has to come first. +`unload_model` is a `stop_engine` action: vLLM cannot drop its model while running, so ejecting stops the engine, which frees all of its GPU memory and keeps the saved model choice; a model other than the served one is refused. `load_model` on a stopped engine saves the choice and starts it, restoring the previous choice if it does not start. + +`delete_model` removes a model's folder from the hub cache, and the large files it links to in the cache's shared `blobs/` store when no other model uses them (an `hf_cache` action with `delete`); the model vLLM is set to serve, running or not, is refused so a switch has to come first. `pull_model` is an `hf_pull` action: it asks Hugging Face for the repo's file list and sizes, picks the files vLLM reads (safetensors, config and tokenizer; not diff --git a/services/nvpair-engine-manager/actions.go b/services/nvpair-engine-manager/actions.go index 5a7255fb5..5734ada9d 100644 --- a/services/nvpair-engine-manager/actions.go +++ b/services/nvpair-engine-manager/actions.go @@ -36,6 +36,9 @@ func (e *Executor) Action(ctx context.Context, engine, action string, params jso // plus a readiness-probed start that the action timeout does not budget for. return e.switchLaunchModel(ctx, engine, act.LaunchModel, params) } + if act.StopEngine { + return e.ejectByStopping(ctx, engine, params) + } res, err := e.dispatchAction(ctx, st, engine, action, act, params) if err != nil { return nil, err diff --git a/services/nvpair-engine-manager/hfcache.go b/services/nvpair-engine-manager/hfcache.go index 73c220ab4..748b3d6da 100644 --- a/services/nvpair-engine-manager/hfcache.go +++ b/services/nvpair-engine-manager/hfcache.go @@ -248,14 +248,84 @@ func (e *Executor) deleteHFModel(engine, hubDir string, params json.RawMessage) if _, err := os.Stat(repo); err != nil { return nil, fmt.Errorf("delete: %s is not in the model cache", id) } + // Recent Hugging Face tooling keeps large files in one store shared by every + // repo, hub/blobs//, and leaves symlinks in each repo's own blobs + // directory. Removing the repo alone would leave the weights behind, using the + // disk the delete was meant to free. + shared := sharedHFBlobs(hubDir, repo) freed := hfBlobBytes(repo) if err := os.RemoveAll(repo); err != nil { return nil, fmt.Errorf("delete %s: %w", id, err) } _ = os.RemoveAll(filepath.Join(hubDir, ".locks", name)) + freed += removeUnreferencedHFBlobs(hubDir, shared) return json.Marshal(map[string]any{"deleted": id, "freed": freed}) } +// hfBlobTarget resolves one symlink in a repo's blobs directory to the file it +// names, and reports whether that file is inside the shared store under +// hubDir/blobs. Anything else, including a link that points elsewhere, is left alone. +func hfBlobTarget(hubDir, link string) (string, bool) { + dest, err := os.Readlink(link) + if err != nil { + return "", false + } + if !filepath.IsAbs(dest) { + dest = filepath.Join(filepath.Dir(link), dest) + } + dest = filepath.Clean(dest) + rel, err := filepath.Rel(filepath.Join(hubDir, "blobs"), dest) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", false + } + return dest, true +} + +// sharedHFBlobs lists the shared-store files a repo's blobs directory links to. +func sharedHFBlobs(hubDir, repo string) []string { + entries, err := os.ReadDir(filepath.Join(repo, "blobs")) + if err != nil { + return nil + } + var targets []string + for _, entry := range entries { + if target, ok := hfBlobTarget(hubDir, filepath.Join(repo, "blobs", entry.Name())); ok { + targets = append(targets, target) + } + } + return targets +} + +// removeUnreferencedHFBlobs deletes the given shared-store files that no +// remaining repo links to, with their .refs and .lock sidecars, and returns the +// bytes freed. A file another model still uses stays. +func removeUnreferencedHFBlobs(hubDir string, candidates []string) int64 { + if len(candidates) == 0 { + return 0 + } + inUse := map[string]bool{} + repos, _ := filepath.Glob(filepath.Join(hubDir, "models--*", "blobs", "*")) + for _, link := range repos { + if target, ok := hfBlobTarget(hubDir, link); ok { + inUse[target] = true + } + } + var freed int64 + for _, target := range candidates { + if inUse[target] { + continue + } + if info, err := os.Stat(target); err == nil && info.Mode().IsRegular() { + freed += info.Size() + } + for _, path := range []string{target, target + ".refs", target + ".lock"} { + _ = os.Remove(path) + } + _ = os.Remove(filepath.Dir(target)) // only succeeds when empty + } + return freed +} + // servedModel returns the model the engine is set to serve, from its effective // launch arguments. It fails when that cannot be determined, so a caller that // must not touch the served model fails closed. The engine's binary is detected diff --git a/services/nvpair-engine-manager/hfcache_test.go b/services/nvpair-engine-manager/hfcache_test.go index 35d9f7e93..a46520b70 100644 --- a/services/nvpair-engine-manager/hfcache_test.go +++ b/services/nvpair-engine-manager/hfcache_test.go @@ -270,3 +270,72 @@ func TestDeleteHFModelRefusesTheBundledDefaultModel(t *testing.T) { t.Fatalf("another model should be deletable: %v", err) } } + +// Recent Hugging Face tooling stores big files once in hub/blobs// and +// links to them from each repo's own blobs directory. Deleting a repo has to +// free those files unless another repo still links to them. +func TestRemoveUnreferencedHFBlobs(t *testing.T) { + hub := t.TempDir() + write := func(path string, size int) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, make([]byte, size), 0o444); err != nil { + t.Fatal(err) + } + } + store := func(hash string) string { return filepath.Join(hub, "blobs", hash[:2], hash) } + link := func(repo, name, target string) { + t.Helper() + dir := filepath.Join(hub, "models--"+repo, "blobs") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + rel, _ := filepath.Rel(dir, target) + if err := os.Symlink(rel, filepath.Join(dir, name)); err != nil { + t.Fatal(err) + } + } + for _, h := range []string{"aa11", "bb22"} { + write(store(h), 1000) + write(store(h)+".refs", 1) + write(store(h)+".lock", 0) + } + outside := filepath.Join(t.TempDir(), "precious") + write(outside, 5) + link("org--one", "w1", store("aa11")) // only "one" uses aa11 + link("org--one", "w2", store("bb22")) // shared with "two" + link("org--two", "w2", store("bb22")) + link("org--one", "evil", outside) // not in the store, never touched + + repoOne := filepath.Join(hub, "models--org--one") + targets := sharedHFBlobs(hub, repoOne) + if len(targets) != 2 { + t.Fatalf("shared targets = %v, want the two inside the store", targets) + } + if err := os.RemoveAll(repoOne); err != nil { + t.Fatal(err) + } + if freed := removeUnreferencedHFBlobs(hub, targets); freed != 1000 { + t.Fatalf("freed = %d, want only the file nobody else uses (1000)", freed) + } + for path, want := range map[string]bool{ + store("aa11"): false, store("aa11") + ".refs": false, store("aa11") + ".lock": false, + store("bb22"): true, outside: true, + } { + if _, err := os.Stat(path); (err == nil) != want { + t.Errorf("%s exists = %v, want %v", path, err == nil, want) + } + } + + repoTwo := filepath.Join(hub, "models--org--two") + targets = sharedHFBlobs(hub, repoTwo) + _ = os.RemoveAll(repoTwo) + if freed := removeUnreferencedHFBlobs(hub, targets); freed != 1000 { + t.Fatalf("freed = %d once the last user is gone, want 1000", freed) + } + if _, err := os.Stat(store("bb22")); err == nil { + t.Error("the shared file must go with its last user") + } +} diff --git a/services/nvpair-engine-manager/launchmodel.go b/services/nvpair-engine-manager/launchmodel.go index fe67c252f..54903d083 100644 --- a/services/nvpair-engine-manager/launchmodel.go +++ b/services/nvpair-engine-manager/launchmodel.go @@ -59,9 +59,31 @@ func (e *Executor) switchLaunchModel(ctx context.Context, engine string, spec *A Restarted bool `json:"restarted"` }{model, restarted}) } - if !changed { + if !changed && current.Running { return result(false) } + if !current.Running { + // Load on a stopped engine, such as after Eject, means serve the model: save + // the choice and start. A start that fails puts the previous choice back. + if changed { + if _, err := e.ConfigureLaunch(ctx, settings.Configure{ + Engine: engine, + Settings: settings.Config{ServerPort: current.ServerPort, LaunchText: text}, + }, func() error { return nil }); err != nil { + return nil, err + } + } + if err := e.Start(ctx, engine); err != nil { + if changed { + _, _ = e.ConfigureLaunch(ctx, settings.Configure{ + Engine: engine, + Settings: settings.Config{ServerPort: current.ServerPort, LaunchText: current.LaunchText}, + }, func() error { return nil }) + } + return nil, fmt.Errorf("starting %s failed; the previous model was restored: %w", model, err) + } + return result(true) + } apply := func(launchText string, resume bool) error { _, err := e.ConfigureLaunch(ctx, settings.Configure{ @@ -131,3 +153,37 @@ func withLaunchFlag(text, flag, value string) (result string, changed bool, err result, err = formatLaunchText(out) return result, !slices.Equal(tokens, out), err } + +// ejectByStopping ejects the served model by stopping the engine, which is the +// only way an engine with one model per process gives its GPU memory back. A +// stopped engine has nothing loaded, so that is a successful no-op. A model +// other than the served one is refused, since stopping would eject the wrong one. +func (e *Executor) ejectByStopping(ctx context.Context, engine string, params json.RawMessage) (json.RawMessage, error) { + var request struct { + Model string `json:"model"` + } + if len(params) > 0 && string(params) != "null" { + if err := json.Unmarshal(params, &request); err != nil { + return nil, errors.New(`unload params must be {"model": ""}`) + } + } + state, err := e.LaunchSettings(engine) + if err != nil { + return nil, err + } + stopped := func(did bool) (json.RawMessage, error) { + return json.Marshal(map[string]any{"model": request.Model, "stopped": did}) + } + if !state.Running { + return stopped(false) + } + if model := strings.TrimSpace(request.Model); model != "" { + if served, err := e.servedModel(engine); err == nil && served != model { + return nil, fmt.Errorf("%s is not the model %s is serving (%s)", model, engine, served) + } + } + if err := e.Stop(engine); err != nil { + return nil, err + } + return stopped(true) +} diff --git a/services/nvpair-engine-manager/launchmodel_test.go b/services/nvpair-engine-manager/launchmodel_test.go index 3751c9f28..eea0cb292 100644 --- a/services/nvpair-engine-manager/launchmodel_test.go +++ b/services/nvpair-engine-manager/launchmodel_test.go @@ -147,7 +147,9 @@ func mustStatus(t *testing.T, e *Executor) EngineStatus { return st } -func TestLaunchModelOnAStoppedEngineOnlyChangesTheSavedLaunch(t *testing.T) { +// Load on a stopped engine (after Eject, say) serves the model: it saves the +// choice and starts the engine, whether or not the model changed. +func TestLaunchModelOnAStoppedEngineStartsIt(t *testing.T) { e := switchModelExecutor(t, "--model") if err := e.Stop("fake"); err != nil { t.Fatal(err) @@ -156,15 +158,79 @@ func TestLaunchModelOnAStoppedEngineOnlyChangesTheSavedLaunch(t *testing.T) { if err != nil { t.Fatalf("load on a stopped engine: %v", err) } - if !strings.Contains(string(raw), `"restarted":false`) { + if !strings.Contains(string(raw), `"restarted":true`) { t.Fatalf("reply = %s", raw) } - if mustStatus(t, e).Running { - t.Fatal("switching the model started a stopped engine") + if !mustStatus(t, e).Running { + t.Fatal("loading a model into a stopped engine must start it") } if text := launchTextOf(t, e); !strings.Contains(text, "--model org/later") { t.Fatalf("launch text = %q", text) } + + // The same model again, on an engine stopped once more, still starts it. + if err := e.Stop("fake"); err != nil { + t.Fatal(err) + } + if _, err := loadModel(t, e, "org/later"); err != nil || !mustStatus(t, e).Running { + t.Fatalf("an unchanged model on a stopped engine must start it: %v", err) + } +} + +func TestLaunchModelOnAStoppedEngineRestoresTheChoiceWhenItWillNotStart(t *testing.T) { + e := switchModelExecutor(t, "--fail-launch") + before := launchTextOf(t, e) + if err := e.Stop("fake"); err != nil { + t.Fatal(err) + } + if _, err := loadModel(t, e, "org/broken"); err == nil || !strings.Contains(err.Error(), "previous model was restored") { + t.Fatalf("err = %v, want the failed start reported with the restore", err) + } + if mustStatus(t, e).Running { + t.Fatal("the engine must stay stopped when the model does not start") + } + if got := launchTextOf(t, e); got != before { + t.Fatalf("launch text = %q, want the previous %q", got, before) + } +} + +func ejectEngine(t *testing.T, e *Executor, model string) (json.RawMessage, error) { + t.Helper() + m, _ := e.reg.Get("fake") + m.Actions["unload_model"] = Action{StopEngine: true} + if err := m.Validate(); err != nil { + t.Fatal(err) + } + return e.Action(context.Background(), "fake", "unload_model", json.RawMessage(`{"model":`+quoteJSON(model)+`}`)) +} + +func TestEjectStopsTheEngineAndFreesItsMemory(t *testing.T) { + e := switchModelExecutor(t, "--model") + if _, err := loadModel(t, e, "org/served"); err != nil { + t.Fatal(err) + } + if _, err := ejectEngine(t, e, "org/other"); err == nil || !strings.Contains(err.Error(), "not the model") { + t.Fatalf("ejecting a model that is not served must be refused: %v", err) + } + if !mustStatus(t, e).Running { + t.Fatal("a refused eject stopped the engine") + } + + raw, err := ejectEngine(t, e, "org/served") + if err != nil || !strings.Contains(string(raw), `"stopped":true`) { + t.Fatalf("eject: %s %v", raw, err) + } + if mustStatus(t, e).Running { + t.Fatal("eject must stop the engine") + } + if text := launchTextOf(t, e); !strings.Contains(text, "--model org/served") { + t.Fatalf("eject must keep the model choice so Load can serve it again: %q", text) + } + + raw, err = ejectEngine(t, e, "org/served") + if err != nil || !strings.Contains(string(raw), `"stopped":false`) { + t.Fatalf("ejecting from a stopped engine is a no-op: %s %v", raw, err) + } } func TestLaunchModelRefusesAnythingThatIsNotAModelID(t *testing.T) { diff --git a/services/nvpair-engine-manager/manifests/vllm.json b/services/nvpair-engine-manager/manifests/vllm.json index 35c99a5bc..ffae1a798 100644 --- a/services/nvpair-engine-manager/manifests/vllm.json +++ b/services/nvpair-engine-manager/manifests/vllm.json @@ -65,6 +65,10 @@ "description": "Serve a different model (params: {\"model\": \"\"}). vLLM serves one model per process, so this saves the model into the launch arguments and restarts the engine on it; if the new model does not start, the previous one is restored.", "launch_model": { "flag": "--model" } }, + "unload_model": { + "description": "Eject the model vLLM is serving (params: {\"model\": \"\"}). vLLM cannot drop its model and keep running, so this stops the engine, which frees all of its GPU memory. Load starts it again.", + "stop_engine": true + }, "delete_model": { "description": "Delete a downloaded model from the Hugging Face cache (params: {\"model\": \"\"}). The model vLLM is set to serve is refused: switch to another model first.", "hf_cache": { "dir": "{models_dir}/hub", "delete": true } diff --git a/services/nvpair-engine-manager/registry.go b/services/nvpair-engine-manager/registry.go index fbbc71141..a7e090413 100644 --- a/services/nvpair-engine-manager/registry.go +++ b/services/nvpair-engine-manager/registry.go @@ -290,6 +290,11 @@ type Action struct { // HFPull downloads a Hugging Face repository into the hub cache with the // engine's own `hf` tool, reporting byte progress. Only pull_model uses it. HFPull *ActionHFPull `json:"hf_pull,omitempty"` + // StopEngine makes the action stop the engine. It is how an engine that serves + // one model per process ejects that model: the process cannot drop its model + // and keep running, but stopping it frees all of its GPU memory. Only + // unload_model may set it. + StopEngine bool `json:"stop_engine,omitempty"` // ProgressProtocol selects a narrowly defined streaming adapter for an // asynchronous pull HTTP action. Empty keeps the ordinary response-stream // behavior; named protocols are validated so a typo cannot silently fall @@ -1001,8 +1006,14 @@ func (a *Action) validate(name string) error { if hasHFPull { kinds++ } + if a.StopEngine { + kinds++ + } if kinds != 1 { - return fmt.Errorf("action %q: exactly one of http, cmd, remove_path, hf_cache, launch_model, or hf_pull is required", name) + return fmt.Errorf("action %q: exactly one of http, cmd, remove_path, hf_cache, launch_model, hf_pull, or stop_engine is required", name) + } + if a.StopEngine && name != "unload_model" { + return fmt.Errorf("action %q: stop_engine belongs to unload_model", name) } if hasHFPull { tool := a.HFPull.Tool diff --git a/services/nvpair-engine-manager/registry_test.go b/services/nvpair-engine-manager/registry_test.go index 0595e49f1..37973b21c 100644 --- a/services/nvpair-engine-manager/registry_test.go +++ b/services/nvpair-engine-manager/registry_test.go @@ -317,7 +317,7 @@ func TestValidate_RejectsInvalidManifests(t *testing.T) { }, "json_match.value is required"}, {"action without http or cmd", func(m *Manifest) { m.Actions = map[string]Action{"x": {Description: "neither"}} - }, "exactly one of http, cmd, remove_path, hf_cache, launch_model, or hf_pull"}, + }, "exactly one of http, cmd, remove_path, hf_cache, launch_model, hf_pull, or stop_engine"}, {"action missing method", func(m *Manifest) { m.Actions = map[string]Action{"x": {HTTP: &ActionHTTP{Path: "/p"}}} }, "http.method and http.path"}, diff --git a/services/nvpair-engine-manager/vllm_manifest_test.go b/services/nvpair-engine-manager/vllm_manifest_test.go index aabb60e81..a6fa5418f 100644 --- a/services/nvpair-engine-manager/vllm_manifest_test.go +++ b/services/nvpair-engine-manager/vllm_manifest_test.go @@ -93,8 +93,9 @@ func TestBundledVLLMManifest(t *testing.T) { if load := manifest.Actions["load_model"]; load.LaunchModel == nil || load.LaunchModel.Flag != "--model" { t.Errorf("load_model must switch the model through --model, got %+v", load) } - if _, ok := manifest.Actions["unload_model"]; ok { - t.Errorf("vllm serves one model per process and must not declare unload_model") + // vLLM cannot drop its model and keep running, so Eject stops the engine. + if unload := manifest.Actions["unload_model"]; !unload.StopEngine { + t.Errorf("unload_model must stop the engine, got %+v", unload) } // Deleting is a removal from the shared cache that needs no running engine. if del := manifest.Actions["delete_model"]; del.HFCache == nil || !del.HFCache.Delete || del.HFCache.Dir != "{models_dir}/hub" { From 4e8d2f4487ecc40208bf6d7a243eb5b38e349067 Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 21 Oct 2026 17:57:06 +0530 Subject: [PATCH 14/35] feat(nvpair-engine-manager): put a gateway in front of vLLM Add an optional runtime front: PAIR listens on the engine's public port and runs the engine itself on a loopback port at a fixed offset, forwarding requests unchanged, streaming responses, cancelling the engine's request when the client leaves, and answering 503 while the engine is not up. Only the launch that is spawned is repointed; the launch users see and validate keeps the public port. A stale engine on the internal port from a crashed run is reclaimed at the next start. This is the base for idle unload and wake on request. Only vllm.json declares a front, and the hooks in shared lifecycle code do nothing without one; a test checks that every other engine's launch is unchanged. Signed-off-by: Tushar Khinvasara --- services/nvpair-engine-manager/MANIFEST.md | 11 + services/nvpair-engine-manager/README.md | 7 + services/nvpair-engine-manager/executor.go | 3 + services/nvpair-engine-manager/front.go | 240 +++++++++++ services/nvpair-engine-manager/front_test.go | 391 ++++++++++++++++++ services/nvpair-engine-manager/lifecycle.go | 24 ++ .../nvpair-engine-manager/manifests/vllm.json | 1 + services/nvpair-engine-manager/registry.go | 20 + .../testdata/fakeengine/main.go | 7 + 9 files changed, 704 insertions(+) create mode 100644 services/nvpair-engine-manager/front.go create mode 100644 services/nvpair-engine-manager/front_test.go diff --git a/services/nvpair-engine-manager/MANIFEST.md b/services/nvpair-engine-manager/MANIFEST.md index 273a34fc3..a7b7a408f 100644 --- a/services/nvpair-engine-manager/MANIFEST.md +++ b/services/nvpair-engine-manager/MANIFEST.md @@ -347,6 +347,17 @@ An **`hf_pull`** action (`{"tool": "hf", "dir": "{models_dir}/hub"}`, only as in the directory that holds the engine binary, and reports byte progress taken from the cache directory. It needs no running engine. +A runtime **`front`** (`{"backend_port_offset": 10000}`) puts a PAIR-owned +HTTP gateway on the engine's public port and runs the engine itself on a loopback +port at that offset (8001 becomes 18001). Clients, the proxy and probes keep using +the public port; the gateway forwards every request unchanged, streams responses +as they arrive, stops the engine's work when the client leaves, and answers 503 +until the engine is ready. The launch a user sees and edits keeps the public +port: only the spawned process is repointed. The fixed offset lets a stale engine +left by a crashed run be found and reclaimed. It requires a process-mode runtime +whose `editable_launch` has a flag control for `{server.port}`, and only +`vllm.json` declares one. + A **`stop_engine`** action (only as `unload_model`) stops the engine, for one that serves a single model per process and so cannot unload it while running. A `launch_model` action on a stopped engine saves the choice and starts it. diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index 41c361577..d57b0669d 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -89,6 +89,13 @@ switches the served model: it writes `--model ` into the saved launch arguments and restarts vLLM on it, restoring the previous model if the new one does not start (for example it does not fit in GPU memory). +vLLM is reached through a PAIR gateway (`front`): the public port (8001) is the +gateway, and the `vllm serve` process listens on a loopback port 10000 higher +(18001). Every request is forwarded unchanged and responses stream through, so +clients see no difference; the cost measured on Qwen2.5-0.5B was about 2 ms per +request. If the engine is not up, the gateway answers 503. A vLLM left running +on the internal port by a crashed run is stopped and replaced on the next start. + `unload_model` is a `stop_engine` action: vLLM cannot drop its model while running, so ejecting stops the engine, which frees all of its GPU memory and keeps the saved model choice; a model other than the served one is refused. `load_model` on a stopped engine saves the choice and starts it, restoring the previous choice if it does not start. `delete_model` removes a model's folder from the hub cache, and the large files it links to in the cache's shared `blobs/` store when no other model uses them (an `hf_cache` action with `delete`); the model vLLM is set to serve, running or not, is refused so a switch has to come first. diff --git a/services/nvpair-engine-manager/executor.go b/services/nvpair-engine-manager/executor.go index f71584570..e5cf7fda9 100644 --- a/services/nvpair-engine-manager/executor.go +++ b/services/nvpair-engine-manager/executor.go @@ -73,6 +73,9 @@ type engineState struct { healthStop context.CancelFunc // startCancel lets StopAll unblock doStart before waiting on opMu. startCancel context.CancelFunc + // front is the gateway in front of an engine that declares one (Runtime.Front); + // nil for every other engine. + front *frontGateway } // Executor owns engine lifecycle for every engine known on this host. diff --git a/services/nvpair-engine-manager/front.go b/services/nvpair-engine-manager/front.go new file mode 100644 index 000000000..29b20af25 --- /dev/null +++ b/services/nvpair-engine-manager/front.go @@ -0,0 +1,240 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "log/slog" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +// Front puts a PAIR-owned HTTP gateway on an engine's public port and runs the +// engine itself on a loopback port derived from it. Clients, the proxy and every +// probe keep using the public port, so nothing outside this file sees the +// difference; the engine's process is what is behind it. Only an engine whose +// manifest declares it gets this; every other engine is untouched. +type Front struct { + // BackendPortOffset is added to the public port to get the engine's own + // port, so the port is a fixed function of the public one. A stale engine from + // a crashed run can then be found and reclaimed, which a random port would + // prevent. + BackendPortOffset int `json:"backend_port_offset"` +} + +const ( + frontMaxPort = 65535 + frontDialTimeout = 5 * time.Second + frontHeaderTimeout = 10 * time.Second + frontIdleTimeout = 90 * time.Second +) + +// frontBackendPort is the port the engine itself listens on for a public port. +func frontBackendPort(f *Front, public int) (int, error) { + backend := public + f.BackendPortOffset + if backend > frontMaxPort { + backend = public - f.BackendPortOffset + } + if backend < 1024 || backend > frontMaxPort || backend == public { + return 0, fmt.Errorf("no usable backend port for public port %d with offset %d", public, f.BackendPortOffset) + } + return backend, nil +} + +// frontGateway forwards every request to the engine on its backend port and +// streams the response back. It adds, removes and logs nothing: it is a +// transparent hop, and request or response bodies never pass through a log. +type frontGateway struct { + srv *http.Server + ln net.Listener + once sync.Once +} + +func newFrontHandler(backendPort int) http.Handler { + target := &url.URL{Scheme: "http", Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(backendPort))} + return &httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + r.SetURL(target) + }, + // Flush each write as it arrives: completions stream as server-sent events. + FlushInterval: -1, + Transport: &http.Transport{ + // The environment's proxy settings must never apply to a loopback hop. + Proxy: nil, + DialContext: (&net.Dialer{Timeout: frontDialTimeout}).DialContext, + DisableCompression: true, + MaxIdleConnsPerHost: 16, + IdleConnTimeout: frontIdleTimeout, + }, + ErrorLog: log.New(io.Discard, "", 0), + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, context.Canceled) { + return // the client left; there is nobody to answer + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error":"the engine is not ready"}`)) + }, + } +} + +func startFrontGateway(host string, public, backend int) (*frontGateway, error) { + ln, err := net.Listen("tcp", net.JoinHostPort(host, strconv.Itoa(public))) + if err != nil { + return nil, fmt.Errorf("listen on port %d: %w", public, err) + } + g := &frontGateway{ln: ln, srv: &http.Server{ + Handler: newFrontHandler(backend), + ReadHeaderTimeout: frontHeaderTimeout, + IdleTimeout: frontIdleTimeout, + ErrorLog: log.New(io.Discard, "", 0), + }} + go func() { _ = g.srv.Serve(ln) }() + return g, nil +} + +// close stops the gateway and drops its connections, as killing the engine does. +// It is safe to call more than once and on a nil gateway. +func (g *frontGateway) close() { + if g == nil { + return + } + g.once.Do(func() { + _ = g.srv.Close() + // Closed here as well: Server.Close only closes a listener Serve has begun + // to track, and a restart needs the port free the moment close returns. + _ = g.ln.Close() + }) +} + +// closeWhen closes the gateway when the engine process exits, however that +// happens, so a crashed engine does not leave a gateway answering for it. +func (g *frontGateway) closeWhen(done <-chan struct{}) { + go func() { + <-done + g.close() + }() +} + +// startFront prepares and starts the gateway for an engine about to be spawned: +// it clears a stale engine on the backend port, then opens the public port. The +// caller starts the engine afterwards, and closes the gateway if that fails. +func (e *Executor) startFront(st *engineState, engine string, rt Runtime, host string, public int) (*frontGateway, error) { + backend, err := frontBackendPort(rt.Front, public) + if err != nil { + return nil, err + } + if err := e.reclaimFrontBackend(st, engine, backend); err != nil { + return nil, err + } + g, err := startFrontGateway(host, public, backend) + if err != nil { + return nil, err + } + st.mu.Lock() + previous := st.front + st.front = g + st.mu.Unlock() + previous.close() + return g, nil +} + +// reclaimFrontBackend frees the backend port when it holds an engine that PAIR +// started in an earlier run and lost, as doStop does for the public port. Anything +// else on the port is not ours to touch, and is reported. +func (e *Executor) reclaimFrontBackend(st *engineState, engine string, backend int) error { + if ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(backend))); err == nil { + _ = ln.Close() + return nil + } + st.mu.Lock() + binPath := st.binPath + st.mu.Unlock() + pid, image, ok := pidOnPort(backend) + if !ok || !isManagedInstallPath(binPath, st.installDir) || !(isOurEngineImage(image, binPath) || procRunsScript(pid, binPath)) { + return fmt.Errorf("cannot start engine %q: its internal port %d is in use by another program", engine, backend) + } + slog.Info("reclaiming a stale engine on its internal port", "engine", engine, "port", backend, "pid", pid) + terminatePID(pid, e.stopGraceFor(st.plat.Runtime)) + if ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(backend))); err == nil { + _ = ln.Close() + return nil + } + return fmt.Errorf("cannot start engine %q: its internal port %d is still in use after stopping a stale engine", engine, backend) +} + +// frontBackendValue renders a networking control's value for the engine's own +// listener: loopback and the backend port. A control that carries anything else, +// such as CORS origins, is not about where the engine listens and is left alone. +func frontBackendValue(template string, backend int) (string, bool) { + value := strings.ReplaceAll(template, "{server.host}", "127.0.0.1") + value = strings.ReplaceAll(value, "{server.port}", strconv.Itoa(backend)) + if value == template || strings.Contains(value, "{") { + return "", false + } + return value, true +} + +// applyFrontBackend points the engine's launch at its backend port. The launch +// the user sees and validates keeps the public port; only the process that is +// spawned listens elsewhere, on loopback. It is a no-op for an engine with no +// front. +func applyFrontBackend(rt Runtime, command launchCommand, public int) (launchCommand, error) { + if rt.Front == nil || rt.EditableLaunch == nil { + return command, nil + } + backend, err := frontBackendPort(rt.Front, public) + if err != nil { + return command, err + } + args := append([]string(nil), command.Args...) + seen := map[*LaunchControl]bool{} + policy := rt.EditableLaunch + for i := len(policy.FixedArgs); i < len(args); i++ { + control, _, last, err := policy.readControl(args, i) + if err != nil { + return command, err + } + if control == nil { + continue + } + value, ok := frontBackendValue(control.Value, backend) + if !ok || len(control.Flags) == 0 { + i = last + continue + } + seen[control] = true + token := args[i] + switch { + case last > i: // "--flag value" + args[last] = value + case strings.Contains(token, "="): // "--flag=value" + args[i] = token[:strings.Index(token, "=")+1] + value + default: // "-pVALUE" + args[i] = token[:2] + value + } + i = last + } + for ci := range policy.Controls { + control := &policy.Controls[ci] + if seen[control] || len(control.Flags) == 0 { + continue + } + if value, ok := frontBackendValue(control.Value, backend); ok { + args = append(args, control.Flags[0], value) + } + } + command.Args = args + return command, nil +} diff --git a/services/nvpair-engine-manager/front_test.go b/services/nvpair-engine-manager/front_test.go new file mode 100644 index 000000000..cb6d8679e --- /dev/null +++ b/services/nvpair-engine-manager/front_test.go @@ -0,0 +1,391 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "runtime" + "slices" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestFrontBackendPort(t *testing.T) { + f := &Front{BackendPortOffset: 10000} + for public, want := range map[int]int{8001: 18001, 11435: 21435, 60000: 50000} { + if got, err := frontBackendPort(f, public); err != nil || got != want { + t.Errorf("frontBackendPort(%d) = %d, %v; want %d", public, got, err, want) + } + } + if _, err := frontBackendPort(&Front{BackendPortOffset: 60000}, 20000); err == nil { + t.Error("a backend port outside the usable range was accepted") + } +} + +func vllmLikePolicy() *EditableLaunch { + return &EditableLaunch{FixedArgs: []string{"serve"}, Controls: []LaunchControl{ + {Flags: []string{"--host"}, Value: "{server.host}"}, + {Flags: []string{"--port"}, Value: "{server.port}"}, + }} +} + +func TestApplyFrontBackendRepointsOnlyTheListener(t *testing.T) { + rt := Runtime{Front: &Front{BackendPortOffset: 10000}, EditableLaunch: vllmLikePolicy()} + in := launchCommand{Bin: "vllm", Args: []string{"serve", "--model", "Qwen/Qwen3-8B", "--host", "0.0.0.0", "--port", "8001", "--max-model-len", "4096"}} + original := slices.Clone(in.Args) + + out, err := applyFrontBackend(rt, in, 8001) + if err != nil { + t.Fatal(err) + } + want := []string{"serve", "--model", "Qwen/Qwen3-8B", "--host", "127.0.0.1", "--port", "18001", "--max-model-len", "4096"} + if !slices.Equal(out.Args, want) { + t.Fatalf("args = %v, want %v", out.Args, want) + } + if !slices.Equal(in.Args, original) { + t.Fatalf("the caller's arguments were modified: %v", in.Args) + } + + eq, err := applyFrontBackend(rt, launchCommand{Args: []string{"serve", "--host=0.0.0.0", "--port=8001"}}, 8001) + if err != nil || !slices.Equal(eq.Args, []string{"serve", "--host=127.0.0.1", "--port=18001"}) { + t.Fatalf("equals form: %v %v", eq.Args, err) + } + + missing, err := applyFrontBackend(rt, launchCommand{Args: []string{"serve", "--model", "m"}}, 8001) + if err != nil || !slices.Equal(missing.Args, []string{"serve", "--model", "m", "--host", "127.0.0.1", "--port", "18001"}) { + t.Fatalf("absent flags must be added: %v %v", missing.Args, err) + } +} + +func TestApplyFrontBackendIsANoOpWithoutAFront(t *testing.T) { + in := launchCommand{Bin: "x", Args: []string{"serve", "--port", "8001"}} + out, err := applyFrontBackend(Runtime{EditableLaunch: vllmLikePolicy()}, in, 8001) + if err != nil || !slices.Equal(out.Args, in.Args) { + t.Fatalf("an engine with no front must be untouched: %v %v", out.Args, err) + } +} + +// Only vLLM declares a front. Every other engine must keep launching exactly as +// it did, on its own port, with no gateway. +func TestOnlyVLLMHasAFront(t *testing.T) { + reg := loadWithOverrides(t, t.TempDir()) + for _, name := range reg.Names() { + manifest, _ := reg.Get(name) + for key, platform := range manifest.Platforms { + if (platform.Runtime.Front != nil) != (name == "vllm") { + t.Errorf("%s %s: front = %+v", name, key, platform.Runtime.Front) + } + command := launchCommand{Bin: "bin", Args: []string{"serve", "--port", "1234", "--host", "h"}} + out, err := applyFrontBackend(platform.Runtime, command, 1234) + if name != "vllm" && (err != nil || !slices.Equal(out.Args, command.Args)) { + t.Errorf("%s %s: launch changed to %v (%v)", name, key, out.Args, err) + } + } + } + vllm, _ := reg.Get("vllm") + if front := vllm.Platforms["linux/amd64"].Runtime.Front; front == nil || front.BackendPortOffset != 10000 { + t.Errorf("vllm front = %+v", front) + } +} + +func TestFrontValidation(t *testing.T) { + good := Platform{Runtime: Runtime{Bin: "vllm", Port: 8001, Front: &Front{BackendPortOffset: 10000}, EditableLaunch: vllmLikePolicy()}} + if err := good.validate("linux/amd64"); err != nil { + t.Fatalf("a valid front was rejected: %v", err) + } + for name, mutate := range map[string]func(p *Platform){ + "no offset": func(p *Platform) { p.Runtime.Front = &Front{} }, + "offset too large": func(p *Platform) { p.Runtime.Front = &Front{BackendPortOffset: 70000} }, + "no editable launch": func(p *Platform) { p.Runtime.EditableLaunch = nil }, + "command mode": func(p *Platform) { p.Runtime.Mode = "command" }, + "no port flag control": func(p *Platform) { + p.Runtime.EditableLaunch = &EditableLaunch{Controls: []LaunchControl{{Flags: []string{"--host"}, Value: "{server.host}"}}} + }, + } { + p := good + mutate(&p) + if err := p.validate("linux/amd64"); err == nil { + t.Errorf("%s: accepted", name) + } + } +} + +func gatewayFor(t *testing.T, backend http.Handler) (publicURL string) { + t.Helper() + srv := httptest.NewServer(backend) + t.Cleanup(srv.Close) + _, backendPort, _ := net.SplitHostPort(strings.TrimPrefix(srv.URL, "http://")) + bp, _ := strconv.Atoi(backendPort) + public, err := freePort() + if err != nil { + t.Fatal(err) + } + g, err := startFrontGateway("127.0.0.1", public, bp) + if err != nil { + t.Fatal(err) + } + t.Cleanup(g.close) + return fmt.Sprintf("http://127.0.0.1:%d", public) +} + +func TestFrontGatewayForwardsRequestsUnchanged(t *testing.T) { + var gotBody []byte + var gotHeader http.Header + url := gatewayFor(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + gotHeader = r.Header.Clone() + w.Header().Set("X-Engine", "yes") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTeapot) + fmt.Fprintf(w, `{"method":%q,"path":%q,"query":%q}`, r.Method, r.URL.Path, r.URL.RawQuery) + })) + + body := bytes.Repeat([]byte("x"), 5<<20) // a large request, as a vision prompt can be + req, _ := http.NewRequest(http.MethodPost, url+"/v1/chat/completions?a=1&b=two", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer secret") + req.Header.Set("Accept-Encoding", "gzip") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + reply, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusTeapot || resp.Header.Get("X-Engine") != "yes" { + t.Fatalf("status/headers not preserved: %d %v", resp.StatusCode, resp.Header) + } + if string(reply) != `{"method":"POST","path":"/v1/chat/completions","query":"a=1&b=two"}` { + t.Fatalf("reply = %s", reply) + } + if !bytes.Equal(gotBody, body) { + t.Fatalf("the body was altered (%d bytes in, %d out)", len(body), len(gotBody)) + } + if gotHeader.Get("Authorization") != "Bearer secret" || gotHeader.Get("Accept-Encoding") != "gzip" { + t.Fatalf("headers not preserved: %v", gotHeader) + } +} + +func TestFrontGatewayStreamsAsItArrives(t *testing.T) { + release := make(chan struct{}) + url := gatewayFor(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "data: one\n\n") + w.(http.Flusher).Flush() + <-release + fmt.Fprint(w, "data: two\n\n") + })) + resp, err := http.Get(url + "/stream") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + first := make([]byte, len("data: one\n\n")) + done := make(chan error, 1) + go func() { _, err := io.ReadFull(resp.Body, first); done <- err }() + select { + case err := <-done: + if err != nil || string(first) != "data: one\n\n" { + t.Fatalf("first event = %q, %v", first, err) + } + case <-time.After(5 * time.Second): + t.Fatal("the first event was held back until the response ended") + } + close(release) + rest, _ := io.ReadAll(resp.Body) + if string(rest) != "data: two\n\n" { + t.Fatalf("rest = %q", rest) + } +} + +func TestFrontGatewayCancelsTheEngineRequestWhenTheClientLeaves(t *testing.T) { + var cancelled atomic.Bool + started := make(chan struct{}) + url := gatewayFor(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // The server notices a client leaving only once the body has been read. + _, _ = io.Copy(io.Discard, r.Body) + close(started) + select { + case <-r.Context().Done(): + cancelled.Store(true) + case <-time.After(10 * time.Second): + } + })) + ctx, cancel := context.WithCancel(context.Background()) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url+"/v1/completions", strings.NewReader("{}")) + go func() { _, _ = http.DefaultClient.Do(req) }() + <-started + cancel() + deadline := time.Now().Add(5 * time.Second) + for !cancelled.Load() && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } + if !cancelled.Load() { + t.Fatal("the engine kept generating after the client left") + } +} + +func TestFrontGatewayAnswers503WhileTheEngineIsDown(t *testing.T) { + public, _ := freePort() + dead, _ := freePort() // nothing listens here + g, err := startFrontGateway("127.0.0.1", public, dead) + if err != nil { + t.Fatal(err) + } + defer g.close() + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/health", public)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusServiceUnavailable || !strings.Contains(string(body), "not ready") { + t.Fatalf("got %d %s, want 503 while the engine is not up", resp.StatusCode, body) + } +} + +func TestFrontGatewayCloseFreesThePortAndIsIdempotent(t *testing.T) { + public, _ := freePort() + g, err := startFrontGateway("127.0.0.1", public, 1) + if err != nil { + t.Fatal(err) + } + g.close() + g.close() + (*frontGateway)(nil).close() + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", public)) + if err != nil { + t.Fatalf("the public port was not released: %v", err) + } + ln.Close() +} + +// frontExecutor builds an engine whose process listens where the launch flag +// says, so the gateway in front of it can be exercised end to end. +func frontExecutor(t *testing.T) (e *Executor, public, backend int) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("uses the fake engine's --listen flag") + } + var err error + for attempt := 0; attempt < 20; attempt++ { + public, err = freePort() + if err != nil { + t.Fatal(err) + } + if ln, lerr := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", public+1)); lerr == nil { + ln.Close() + break + } + } + backend = public + 1 + m := testEngineManifest(fakeEngineBin) + m.Engine = "fronted" + key := runtime.GOOS + "/" + runtime.GOARCH + p := m.Platforms[key] + p.Runtime.Port = public + p.Runtime.Env = nil + p.Runtime.Args = []string{"serve", "--listen", "{host}:{port}"} + p.Runtime.EditableLaunch = &EditableLaunch{FixedArgs: []string{"serve"}, Controls: []LaunchControl{ + {Flags: []string{"--listen"}, Value: "{server.host}:{server.port}"}, + }} + p.Runtime.Front = &Front{BackendPortOffset: 1} + m.Platforms[key] = p + if err := m.Validate(); err != nil { + t.Fatal(err) + } + e = newTestExecutor(t, m) + e.overrideDir = t.TempDir() + if _, err := e.Detect("fronted"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = e.Stop("fronted") }) + return e, public, backend +} + +func statusCode(url string) int { + resp, err := http.Get(url) + if err != nil { + return 0 + } + defer resp.Body.Close() + return resp.StatusCode +} + +func TestFrontedEngineIsReachedThroughItsPublicPort(t *testing.T) { + e, public, backend := frontExecutor(t) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatalf("start: %v", err) + } + if got := statusCode(fmt.Sprintf("http://127.0.0.1:%d/api/tags", public)); got != http.StatusOK { + t.Fatalf("public port answered %d", got) + } + if got := statusCode(fmt.Sprintf("http://127.0.0.1:%d/api/tags", backend)); got != http.StatusOK { + t.Fatalf("the engine is not on its backend port: %d", got) + } + st, err := e.Status("fronted") + if err != nil || !st.Running || !st.Healthy || st.Port != public { + t.Fatalf("status = %+v, %v; want running and healthy on the public port %d", st, err, public) + } + + if err := e.Stop("fronted"); err != nil { + t.Fatal(err) + } + for _, port := range []int{public, backend} { + if got := statusCode(fmt.Sprintf("http://127.0.0.1:%d/", port)); got != 0 { + t.Fatalf("port %d still answers after stop (%d)", port, got) + } + } + + // A restart takes the public port straight back. + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatalf("restart: %v", err) + } + if got := statusCode(fmt.Sprintf("http://127.0.0.1:%d/api/tags", public)); got != http.StatusOK { + t.Fatalf("after restart the public port answered %d", got) + } +} + +func TestFrontedEngineGatewayGoesWithACrashedEngine(t *testing.T) { + e, public, _ := frontExecutor(t) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + _ = statusCode(fmt.Sprintf("http://127.0.0.1:%d/exit", public)) // the engine dies + deadline := time.Now().Add(10 * time.Second) + for statusCode(fmt.Sprintf("http://127.0.0.1:%d/", public)) != 0 && time.Now().Before(deadline) { + time.Sleep(50 * time.Millisecond) + } + if got := statusCode(fmt.Sprintf("http://127.0.0.1:%d/", public)); got != 0 { + t.Fatalf("the gateway kept answering for a dead engine (%d)", got) + } + if st, _ := e.Status("fronted"); st.Running { + t.Fatalf("a crashed engine is reported running: %+v", st) + } +} + +func TestFrontedEngineRefusesABackendPortSomethingElseHolds(t *testing.T) { + e, public, backend := frontExecutor(t) + squatter, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", backend)) + if err != nil { + t.Skipf("backend port taken: %v", err) + } + defer squatter.Close() + + err = e.Start(context.Background(), "fronted") + if err == nil || !strings.Contains(err.Error(), "in use by another program") { + t.Fatalf("err = %v, want the occupied internal port reported", err) + } + if got := statusCode(fmt.Sprintf("http://127.0.0.1:%d/", public)); got != 0 { + t.Fatalf("a failed start left a gateway on the public port (%d)", got) + } +} diff --git a/services/nvpair-engine-manager/lifecycle.go b/services/nvpair-engine-manager/lifecycle.go index eea6e011b..09aea8310 100644 --- a/services/nvpair-engine-manager/lifecycle.go +++ b/services/nvpair-engine-manager/lifecycle.go @@ -187,7 +187,20 @@ func (e *Executor) doStart(ctx context.Context, st *engineState, engine string, st.stopping = false st.mu.Unlock() + // An engine with a front is reached through a PAIR gateway on its public port + // while the process itself listens elsewhere. The gateway is up before the + // process so that readiness probes on the public port see it; it answers 503 + // until the engine is ready. + var gateway *frontGateway + if rt.Front != nil { + g, err := e.startFront(st, engine, rt, vars["host"], port) + if err != nil { + return err + } + gateway = g + } if err := e.bringUp(ctx, st, engine, rt, port, vars); err != nil { + gateway.close() return err } @@ -208,6 +221,9 @@ func (e *Executor) doStart(ctx context.Context, st *engineState, engine string, st.mu.Unlock() if proc != nil { e.watch(st, engine, proc) + if gateway != nil { + gateway.closeWhen(proc.done) + } } if rt.Health != nil { @@ -241,6 +257,9 @@ func (e *Executor) bringUpProcess(ctx context.Context, st *engineState, engine s return err } launch = applyAutoLaunch(engine, rt, launch, vars["models_dir"]) + if launch, err = applyFrontBackend(rt, launch, port); err != nil { + return err + } diagnostics := newStartupOutput(launchDiagnosticArgs(rt), launch.Env) defer diagnostics.close() @@ -607,7 +626,12 @@ func (e *Executor) markStopped(st *engineState, engine string) { st.stopping = true st.adopted = false st.proc = nil + front := st.front + st.front = nil st.mu.Unlock() + // Closed here, not left to the process-exit watcher, so that a restart can + // take the public port back at once. + front.close() // A deliberately-stopped engine has no live unhealthy/crash condition. e.reporter.clear(unhealthyID(engine)) e.reporter.clear(exitedID(engine)) diff --git a/services/nvpair-engine-manager/manifests/vllm.json b/services/nvpair-engine-manager/manifests/vllm.json index ffae1a798..4443ff028 100644 --- a/services/nvpair-engine-manager/manifests/vllm.json +++ b/services/nvpair-engine-manager/manifests/vllm.json @@ -17,6 +17,7 @@ ] }, "args": ["serve", "--model", "Qwen/Qwen2.5-0.5B-Instruct", "--host", "{host}", "--port", "{port}"], + "front": { "backend_port_offset": 10000 }, "auto_launch": { "kind": "hf_context", "context_tokens": 65536 }, "env": { "HF_HOME": "{models_dir}", "VLLM_USE_FLASHINFER_SAMPLER": "0" }, "bind": "127.0.0.1", diff --git a/services/nvpair-engine-manager/registry.go b/services/nvpair-engine-manager/registry.go index a7e090413..f08661584 100644 --- a/services/nvpair-engine-manager/registry.go +++ b/services/nvpair-engine-manager/registry.go @@ -156,6 +156,9 @@ type Runtime struct { // a non-nil empty slice keeps only fixed and managed arguments. Use // omitzero to omit nil while preserving explicit empty arrays in JSON. LaunchArgs []string `json:"launch_args,omitzero"` + // Front, when set, puts a PAIR gateway on the engine's public port and runs the + // engine on a derived loopback port behind it. See front.go. + Front *Front `json:"front,omitempty"` // AutoLaunch lets the manager add startup arguments that depend on the model // being served and the machine, at every start. Arguments the user saved // always win, and nothing added here is written back to saved settings. @@ -821,6 +824,23 @@ const ( ) func (p *Platform) validate(key string) error { + if front := p.Runtime.Front; front != nil { + if front.BackendPortOffset < 1 || front.BackendPortOffset > 60000 { + return fmt.Errorf("platform %q: front.backend_port_offset must be between 1 and 60000", key) + } + if p.Runtime.EditableLaunch == nil || p.Runtime.modeOrDefault() != "process" { + return fmt.Errorf("platform %q: front requires a process-mode runtime with editable_launch", key) + } + hasPort := false + for _, control := range p.Runtime.EditableLaunch.Controls { + if _, ok := frontBackendValue(control.Value, 1); ok && len(control.Flags) > 0 && strings.Contains(control.Value, "{server.port}") { + hasPort = true + } + } + if !hasPort { + return fmt.Errorf("platform %q: front needs a flag-based launch control for {server.port}", key) + } + } if auto := p.Runtime.AutoLaunch; auto != nil { if auto.Kind != autoLaunchHFContext || auto.ContextTokens < 1024 { return fmt.Errorf("platform %q: auto_launch needs kind %q and context_tokens of at least 1024", key, autoLaunchHFContext) diff --git a/services/nvpair-engine-manager/testdata/fakeengine/main.go b/services/nvpair-engine-manager/testdata/fakeengine/main.go index 9f12ba821..6657a674a 100644 --- a/services/nvpair-engine-manager/testdata/fakeengine/main.go +++ b/services/nvpair-engine-manager/testdata/fakeengine/main.go @@ -171,6 +171,13 @@ func main() { if addr == "" { addr = strings.TrimPrefix(os.Getenv("OLLAMA_HOST"), "http://") } + // --listen host:port overrides the address for tests of an engine that is told + // where to listen by a flag, as vLLM is. + for i := 1; i+1 < len(os.Args); i++ { + if os.Args[i] == "--listen" { + addr = os.Args[i+1] + } + } if addr == "" { addr = "127.0.0.1:0" } From 37fadc53dc0431a9be2017a18fa727dd939e2a9e Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 21 Oct 2026 18:56:39 +0530 Subject: [PATCH 15/35] feat(nvpair-engine-manager): unload an idle vLLM and keep its engine on An engine behind a gateway now tracks inference activity at the gateway (not GET/HEAD/OPTIONS, so health checks and the model list do not count, and a request in flight keeps the engine loaded). After front.idle_timeout_s (600 s for vLLM, 0 never) with none, the manager stops the engine's process, freeing the GPU, while the gateway and the ON state stay. The status reports idle, /health still answers, inference is refused with 503, and the model it is set to serve is still advertised with nothing marked loaded. Eject on such an engine now unloads and keeps it ON like the other engines, and Load of the model it is set to serve loads it again; a failed load leaves the engine idle. Waking on an incoming request follows. Signed-off-by: Tushar Khinvasara --- services/nvpair-engine-manager/MANIFEST.md | 9 +- services/nvpair-engine-manager/README.md | 12 +- services/nvpair-engine-manager/executor.go | 6 + services/nvpair-engine-manager/front.go | 284 ++++++++++++++++-- services/nvpair-engine-manager/front_test.go | 284 +++++++++++++++++- services/nvpair-engine-manager/hfcache.go | 15 + services/nvpair-engine-manager/launchmodel.go | 23 ++ services/nvpair-engine-manager/lifecycle.go | 19 +- .../nvpair-engine-manager/manifests/vllm.json | 2 +- services/nvpair-engine-manager/models.go | 13 + services/nvpair-engine-manager/registry.go | 3 + services/nvpair-engine-manager/status.go | 1 + .../testdata/fakeengine/main.go | 6 + 13 files changed, 640 insertions(+), 37 deletions(-) diff --git a/services/nvpair-engine-manager/MANIFEST.md b/services/nvpair-engine-manager/MANIFEST.md index a7b7a408f..a1d1d56fd 100644 --- a/services/nvpair-engine-manager/MANIFEST.md +++ b/services/nvpair-engine-manager/MANIFEST.md @@ -353,7 +353,14 @@ port at that offset (8001 becomes 18001). Clients, the proxy and probes keep usi the public port; the gateway forwards every request unchanged, streams responses as they arrive, stops the engine's work when the client leaves, and answers 503 until the engine is ready. The launch a user sees and edits keeps the public -port: only the spawned process is repointed. The fixed offset lets a stale engine +port: only the spawned process is repointed. `idle_timeout_s` (default 600 for +vLLM, 0 never) unloads the engine after that long with no inference request: +its process is stopped and its GPU memory freed, while the gateway, and the +engine's ON state, stay. Only requests that change something count as activity +(not GET, HEAD or OPTIONS, which is what health checks, monitors and the model +list use), and a request still running keeps the engine loaded. While unloaded +the gateway answers `/health` with 200, refuses inference with 503, and +`engine:status` reports `idle: true`. The fixed offset lets a stale engine left by a crashed run be found and reclaimed. It requires a process-mode runtime whose `editable_launch` has a flag control for `{server.port}`, and only `vllm.json` declares one. diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index d57b0669d..92d42e174 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -96,7 +96,17 @@ clients see no difference; the cost measured on Qwen2.5-0.5B was about 2 ms per request. If the engine is not up, the gateway answers 503. A vLLM left running on the internal port by a crashed run is stopped and replaced on the next start. -`unload_model` is a `stop_engine` action: vLLM cannot drop its model while running, so ejecting stops the engine, which frees all of its GPU memory and keeps the saved model choice; a model other than the served one is refused. `load_model` on a stopped engine saves the choice and starts it, restoring the previous choice if it does not start. +After 10 minutes with no inference request (`front.idle_timeout_s`, 0 to never) +the manager stops the `vllm serve` process, which frees the GPU, but keeps the +gateway and the engine ON: `engine:status` reports `idle: true`, `/health` still +answers 200, and `engine:models` still advertises the model it is set to serve +with nothing loaded. Health checks, monitors and the model list do not count as +activity, and a request still running keeps the engine loaded. Eject +(`unload_model`) does the same on demand. `load_model` of the model it is set to +serve loads it again; a request while unloaded is answered 503 until it loads on +demand arrives. A failed load leaves the engine idle, not stopped. + +`unload_model` is a `stop_engine` action: vLLM cannot drop its model while running, so ejecting stops its process, which frees all of its GPU memory, while the gateway and the engine stay ON (an engine with no gateway is stopped) and the saved model choice is kept; a model other than the served one is refused. `load_model` on a stopped engine saves the choice and starts it, restoring the previous choice if it does not start. `delete_model` removes a model's folder from the hub cache, and the large files it links to in the cache's shared `blobs/` store when no other model uses them (an `hf_cache` action with `delete`); the model vLLM is set to serve, running or not, is refused so a switch has to come first. diff --git a/services/nvpair-engine-manager/executor.go b/services/nvpair-engine-manager/executor.go index e5cf7fda9..4b60b6b54 100644 --- a/services/nvpair-engine-manager/executor.go +++ b/services/nvpair-engine-manager/executor.go @@ -40,6 +40,9 @@ type EngineStatus struct { Running bool `json:"running"` Healthy bool `json:"healthy"` Port int `json:"port,omitempty"` + // Idle is true for an engine that is ON with its model unloaded because it + // was idle, and will load again on demand. Omitted otherwise. + Idle bool `json:"idle,omitempty"` } // engineState is the per-engine runtime state. @@ -76,6 +79,9 @@ type engineState struct { // front is the gateway in front of an engine that declares one (Runtime.Front); // nil for every other engine. front *frontGateway + // idle is set while front's engine is unloaded for being idle: the engine is + // ON (running is true) but has no process. + idle bool } // Executor owns engine lifecycle for every engine known on this host. diff --git a/services/nvpair-engine-manager/front.go b/services/nvpair-engine-manager/front.go index 29b20af25..96b44ca24 100644 --- a/services/nvpair-engine-manager/front.go +++ b/services/nvpair-engine-manager/front.go @@ -31,13 +31,17 @@ type Front struct { // a crashed run can then be found and reclaimed, which a random port would // prevent. BackendPortOffset int `json:"backend_port_offset"` + // IdleTimeoutS unloads the engine, stopping its process while the gateway and + // the engine's ON state stay, after this many seconds with no inference + // request. Zero never unloads. + IdleTimeoutS int `json:"idle_timeout_s,omitempty"` } const ( - frontMaxPort = 65535 - frontDialTimeout = 5 * time.Second - frontHeaderTimeout = 10 * time.Second - frontIdleTimeout = 90 * time.Second + frontMaxPort = 65535 + frontDialTimeout = 5 * time.Second + frontHeaderTimeout = 10 * time.Second + frontConnIdleTimeout = 90 * time.Second ) // frontBackendPort is the port the engine itself listens on for a public port. @@ -55,10 +59,111 @@ func frontBackendPort(f *Front, public int) (int, error) { // frontGateway forwards every request to the engine on its backend port and // streams the response back. It adds, removes and logs nothing: it is a // transparent hop, and request or response bodies never pass through a log. +// +// It also keeps the one fact idle unload needs: when the engine last did +// inference work, and whether any is in flight. type frontGateway struct { - srv *http.Server - ln net.Listener - once sync.Once + srv *http.Server + ln net.Listener + proxy http.Handler + once sync.Once + closed chan struct{} + + mu sync.Mutex + inflight int + lastActive time.Time + // up is true while the engine behind the gateway is serving. + up bool + // idle is true while the engine has been unloaded for being idle. The + // gateway stays, answering health checks, and refuses inference. + idle bool + // epoch identifies the engine process the gateway currently belongs to, so + // the exit of an earlier process, stopped on purpose, does not close it. + epoch int +} + +// isActivity reports whether a request is inference work. Reads such as health +// checks, metrics and the model list are made by the proxy and by monitors on a +// timer, and must not keep an engine loaded. +func isActivity(r *http.Request) bool { + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return false + } + return true +} + +func (g *frontGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { + activity := isActivity(r) + g.mu.Lock() + if g.idle { + g.mu.Unlock() + if r.URL.Path == "/health" && (r.Method == http.MethodGet || r.Method == http.MethodHead) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"idle"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error":"the model is unloaded because the engine was idle"}`)) + return + } + if activity { + g.inflight++ + g.lastActive = time.Now() + } + g.mu.Unlock() + if activity { + defer func() { + g.mu.Lock() + g.inflight-- + g.lastActive = time.Now() + g.mu.Unlock() + }() + } + g.proxy.ServeHTTP(w, r) +} + +// markUp records that the engine behind the gateway is serving, and starts its +// idle clock. +func (g *frontGateway) markUp() { + g.mu.Lock() + g.up, g.idle = true, false + g.lastActive = time.Now() + g.mu.Unlock() +} + +// expired reports whether the engine has been idle for timeout. +func (g *frontGateway) expired(timeout time.Duration) bool { + g.mu.Lock() + defer g.mu.Unlock() + return g.up && !g.idle && g.inflight == 0 && time.Since(g.lastActive) >= timeout +} + +// enterIdle flips the gateway to idle, atomically with the check that no request +// is in flight, so a request cannot reach an engine that is being stopped. force +// skips the idle-time and in-flight conditions, for an explicit eject. +func (g *frontGateway) enterIdle(timeout time.Duration, force bool) bool { + g.mu.Lock() + defer g.mu.Unlock() + if !g.up || g.idle { + return false + } + if !force && (g.inflight > 0 || time.Since(g.lastActive) < timeout) { + return false + } + g.up, g.idle = false, true + g.epoch++ // the process about to be stopped must not take the gateway with it + return true +} + +func (g *frontGateway) alive() bool { + select { + case <-g.closed: + return false + default: + return true + } } func newFrontHandler(backendPort int) http.Handler { @@ -75,7 +180,7 @@ func newFrontHandler(backendPort int) http.Handler { DialContext: (&net.Dialer{Timeout: frontDialTimeout}).DialContext, DisableCompression: true, MaxIdleConnsPerHost: 16, - IdleConnTimeout: frontIdleTimeout, + IdleConnTimeout: frontConnIdleTimeout, }, ErrorLog: log.New(io.Discard, "", 0), ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { @@ -94,12 +199,13 @@ func startFrontGateway(host string, public, backend int) (*frontGateway, error) if err != nil { return nil, fmt.Errorf("listen on port %d: %w", public, err) } - g := &frontGateway{ln: ln, srv: &http.Server{ - Handler: newFrontHandler(backend), + g := &frontGateway{ln: ln, proxy: newFrontHandler(backend), closed: make(chan struct{})} + g.srv = &http.Server{ + Handler: g, ReadHeaderTimeout: frontHeaderTimeout, - IdleTimeout: frontIdleTimeout, + IdleTimeout: frontConnIdleTimeout, ErrorLog: log.New(io.Discard, "", 0), - }} + } go func() { _ = g.srv.Serve(ln) }() return g, nil } @@ -111,6 +217,7 @@ func (g *frontGateway) close() { return } g.once.Do(func() { + close(g.closed) _ = g.srv.Close() // Closed here as well: Server.Close only closes a listener Serve has begun // to track, and a restart needs the port free the moment close returns. @@ -119,35 +226,153 @@ func (g *frontGateway) close() { } // closeWhen closes the gateway when the engine process exits, however that -// happens, so a crashed engine does not leave a gateway answering for it. +// happens, so a crashed engine does not leave a gateway answering for it. A +// process stopped for being idle is not a crash: that stop moves the epoch on, +// and the gateway stays. func (g *frontGateway) closeWhen(done <-chan struct{}) { + g.mu.Lock() + g.epoch++ + mine := g.epoch + g.mu.Unlock() go func() { <-done - g.close() + g.mu.Lock() + current := g.epoch == mine + g.mu.Unlock() + if current { + g.close() + } }() } -// startFront prepares and starts the gateway for an engine about to be spawned: -// it clears a stale engine on the backend port, then opens the public port. The -// caller starts the engine afterwards, and closes the gateway if that fails. -func (e *Executor) startFront(st *engineState, engine string, rt Runtime, host string, public int) (*frontGateway, error) { +// startFront prepares the gateway for an engine about to be spawned. A gateway +// left by an idle unload is reused, since the engine is only being woken; +// otherwise it clears a stale engine on the backend port and opens the public +// port. The caller starts the engine afterwards, and closes a new gateway if that +// fails. reused reports which case it was. +func (e *Executor) startFront(st *engineState, engine string, rt Runtime, host string, public int) (g *frontGateway, reused bool, err error) { + st.mu.Lock() + existing := st.front + st.mu.Unlock() + if existing != nil && existing.alive() { + return existing, true, nil + } backend, err := frontBackendPort(rt.Front, public) if err != nil { - return nil, err + return nil, false, err } if err := e.reclaimFrontBackend(st, engine, backend); err != nil { - return nil, err + return nil, false, err } - g, err := startFrontGateway(host, public, backend) + g, err = startFrontGateway(host, public, backend) if err != nil { - return nil, err + return nil, false, err } st.mu.Lock() previous := st.front st.front = g st.mu.Unlock() previous.close() - return g, nil + go e.watchIdle(st, engine, g) + return g, false, nil +} + +// frontIdleTimeout is the engine's current idle limit; zero means never. +func frontIdleTimeout(st *engineState) time.Duration { + if front := st.plat.Runtime.Front; front != nil && front.IdleTimeoutS > 0 { + return time.Duration(front.IdleTimeoutS) * time.Second + } + return 0 +} + +// watchIdle unloads the engine once it has been idle for its limit. It runs for +// the life of the gateway, across unloads and wakes. +func (e *Executor) watchIdle(st *engineState, engine string, g *frontGateway) { + interval := time.Second + if timeout := frontIdleTimeout(st); timeout > 0 && timeout/4 < interval { + interval = max(timeout/4, 50*time.Millisecond) + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-g.closed: + return + case <-ticker.C: + if timeout := frontIdleTimeout(st); timeout > 0 && g.expired(timeout) { + e.unloadFront(st, engine, g, timeout, false) + } + } + } +} + +// unloadFront stops the engine's process and leaves the gateway, and the +// engine's ON state, in place. It never waits for another lifecycle operation: a +// start, stop or switch in progress wins, and the next tick tries again. force +// ejects regardless of the idle time or requests in flight. +func (e *Executor) unloadFront(st *engineState, engine string, g *frontGateway, timeout time.Duration, force bool) bool { + if force { + st.opMu.Lock() + } else if !st.opMu.TryLock() { + return false + } + defer st.opMu.Unlock() + st.mu.Lock() + proc := st.proc + ok := st.running && !st.stopping && !st.idle && st.front == g && proc != nil + st.mu.Unlock() + if !ok || !g.enterIdle(timeout, force) { + return false + } + st.mu.Lock() + // The process is released from the state before it is stopped, so its exit is + // not taken for a crash. + st.proc = nil + st.idle = true + st.mu.Unlock() + slog.Info("unloading an idle engine", "engine", engine, "forced", force) + e.emitState(engine) + proc.stop(e.stopGraceFor(st.plat.Runtime)) + return true +} + +// wakeIdle starts the engine again behind its gateway after an idle unload. A +// failed start leaves it idle, not stopped, so the next request can try again. +func (e *Executor) wakeIdle(ctx context.Context, engine string) error { + st, err := e.state(engine) + if err != nil { + return err + } + st.opMu.Lock() + defer st.opMu.Unlock() + st.mu.Lock() + idle, g := st.idle, st.front + if !idle || g == nil { + st.mu.Unlock() + return nil + } + // doStart does nothing for an engine marked running, so it is marked stopped + // for the duration of the start. + st.running, st.healthy, st.idle = false, false, false + st.mu.Unlock() + // The gateway forwards again from here, so that the start's readiness probes, + // made on the public port, reach the engine and not the idle answer. + g.mu.Lock() + g.idle, g.up = false, false + g.mu.Unlock() + err = e.doStart(ctx, st, engine, startOpts{}) + if err != nil { + st.mu.Lock() + if st.front != nil && st.front.alive() { + st.running, st.healthy, st.idle = true, true, true + st.front.mu.Lock() + st.front.up, st.front.idle = false, true + st.front.mu.Unlock() + } + st.mu.Unlock() + e.emitState(engine) + } + return err } // reclaimFrontBackend frees the backend port when it holds an engine that PAIR @@ -238,3 +463,16 @@ func applyFrontBackend(rt Runtime, command launchCommand, public int) (launchCom command.Args = args return command, nil } + +// presenceForStart is reconcilePresence for a start, except that a gateway PAIR +// itself left on the port, while the engine behind it is unloaded, is not another +// engine to adopt or to refuse. +func (e *Executor) presenceForStart(ctx context.Context, engine string, st *engineState, pathInstalled bool, port int) presenceResult { + st.mu.Lock() + ours := st.front != nil && st.front.alive() + st.mu.Unlock() + if ours { + return presenceResult{} + } + return e.reconcilePresence(ctx, engine, st, pathInstalled, port, true) +} diff --git a/services/nvpair-engine-manager/front_test.go b/services/nvpair-engine-manager/front_test.go index cb6d8679e..bed838d93 100644 --- a/services/nvpair-engine-manager/front_test.go +++ b/services/nvpair-engine-manager/front_test.go @@ -93,7 +93,7 @@ func TestOnlyVLLMHasAFront(t *testing.T) { } } vllm, _ := reg.Get("vllm") - if front := vllm.Platforms["linux/amd64"].Runtime.Front; front == nil || front.BackendPortOffset != 10000 { + if front := vllm.Platforms["linux/amd64"].Runtime.Front; front == nil || front.BackendPortOffset != 10000 || front.IdleTimeoutS != 600 { t.Errorf("vllm front = %+v", front) } } @@ -271,7 +271,7 @@ func TestFrontGatewayCloseFreesThePortAndIsIdempotent(t *testing.T) { // frontExecutor builds an engine whose process listens where the launch flag // says, so the gateway in front of it can be exercised end to end. -func frontExecutor(t *testing.T) (e *Executor, public, backend int) { +func frontExecutor(t *testing.T, idleSeconds int) (e *Executor, public, backend int) { t.Helper() if runtime.GOOS == "windows" { t.Skip("uses the fake engine's --listen flag") @@ -294,12 +294,20 @@ func frontExecutor(t *testing.T) (e *Executor, public, backend int) { p := m.Platforms[key] p.Runtime.Port = public p.Runtime.Env = nil - p.Runtime.Args = []string{"serve", "--listen", "{host}:{port}"} + p.Runtime.Args = []string{"serve", "--model", "org/m", "--listen", "{host}:{port}"} p.Runtime.EditableLaunch = &EditableLaunch{FixedArgs: []string{"serve"}, Controls: []LaunchControl{ {Flags: []string{"--listen"}, Value: "{server.host}:{server.port}"}, }} - p.Runtime.Front = &Front{BackendPortOffset: 1} + p.Runtime.Front = &Front{BackendPortOffset: 1, IdleTimeoutS: idleSeconds} m.Platforms[key] = p + // The engine advertises only what it has loaded, as vLLM does; Eject and Load + // are the same actions vLLM declares. + m.AdvertisedModels = advertisedModelsLoaded + m.Actions = map[string]Action{ + "loaded_models": {HTTP: &ActionHTTP{Method: "GET", Path: "/api/ps"}, Result: &ActionResult{Array: "models", Field: "name"}}, + "unload_model": {StopEngine: true}, + "load_model": {LaunchModel: &ActionLaunchModel{Flag: "--model"}}, + } if err := m.Validate(); err != nil { t.Fatal(err) } @@ -322,7 +330,7 @@ func statusCode(url string) int { } func TestFrontedEngineIsReachedThroughItsPublicPort(t *testing.T) { - e, public, backend := frontExecutor(t) + e, public, backend := frontExecutor(t, 0) if err := e.Start(context.Background(), "fronted"); err != nil { t.Fatalf("start: %v", err) } @@ -356,7 +364,7 @@ func TestFrontedEngineIsReachedThroughItsPublicPort(t *testing.T) { } func TestFrontedEngineGatewayGoesWithACrashedEngine(t *testing.T) { - e, public, _ := frontExecutor(t) + e, public, _ := frontExecutor(t, 0) if err := e.Start(context.Background(), "fronted"); err != nil { t.Fatal(err) } @@ -374,7 +382,7 @@ func TestFrontedEngineGatewayGoesWithACrashedEngine(t *testing.T) { } func TestFrontedEngineRefusesABackendPortSomethingElseHolds(t *testing.T) { - e, public, backend := frontExecutor(t) + e, public, backend := frontExecutor(t, 0) squatter, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", backend)) if err != nil { t.Skipf("backend port taken: %v", err) @@ -389,3 +397,265 @@ func TestFrontedEngineRefusesABackendPortSomethingElseHolds(t *testing.T) { t.Fatalf("a failed start left a gateway on the public port (%d)", got) } } + +func post(url string) int { + resp, err := http.Post(url, "application/json", strings.NewReader("{}")) + if err != nil { + return 0 + } + defer resp.Body.Close() + return resp.StatusCode +} + +func eventually(t *testing.T, what string, within time.Duration, cond func() bool) { + t.Helper() + deadline := time.Now().Add(within) + for !cond() { + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %s", what) + } + time.Sleep(25 * time.Millisecond) + } +} + +func up(port int) bool { + return statusCode(fmt.Sprintf("http://127.0.0.1:%d/api/tags", port)) == http.StatusOK +} + +func TestIsActivity(t *testing.T) { + for method, want := range map[string]bool{"GET": false, "HEAD": false, "OPTIONS": false, "POST": true, "PUT": true, "DELETE": true} { + if got := isActivity(&http.Request{Method: method}); got != want { + t.Errorf("%s: isActivity = %v, want %v", method, got, want) + } + } +} + +func TestGatewayIdleAccounting(t *testing.T) { + g := &frontGateway{closed: make(chan struct{})} + if g.expired(0) || g.enterIdle(0, false) { + t.Fatal("a gateway whose engine is not up cannot go idle") + } + g.markUp() + if g.expired(time.Hour) { + t.Fatal("a fresh engine is not idle") + } + g.mu.Lock() + g.lastActive = time.Now().Add(-time.Minute) + g.inflight = 1 + g.mu.Unlock() + if g.expired(time.Second) || g.enterIdle(time.Second, false) { + t.Fatal("a request in flight keeps the engine loaded") + } + if !g.enterIdle(time.Second, true) { + t.Fatal("an explicit eject ignores in-flight work") + } + if g.enterIdle(time.Second, true) { + t.Fatal("an engine already idle cannot be unloaded twice") + } +} + +func TestIdleEngineIsUnloadedAfterTheTimeout(t *testing.T) { + e, public, backend := frontExecutor(t, 1) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + if !up(public) { + t.Fatal("not serving") + } + eventually(t, "the idle unload", 10*time.Second, func() bool { return !up(backend) }) + + st, _ := e.Status("fronted") + if !st.Running || !st.Idle || st.Port != public { + t.Fatalf("status = %+v, want ON, idle, on the public port", st) + } + if got := statusCode(fmt.Sprintf("http://127.0.0.1:%d/health", public)); got != http.StatusOK { + t.Fatalf("an idle engine must still pass health checks: %d", got) + } + if got := post(fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", public)); got != http.StatusServiceUnavailable { + t.Fatalf("inference while unloaded answered %d, want 503", got) + } +} + +func TestInferenceKeepsAnEngineLoadedButMonitoringDoesNot(t *testing.T) { + e, public, backend := frontExecutor(t, 1) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + // Requests every 300 ms against a 1 s limit: never idle. + end := time.Now().Add(2500 * time.Millisecond) + for time.Now().Before(end) { + post(fmt.Sprintf("http://127.0.0.1:%d/api/generate", public)) + time.Sleep(300 * time.Millisecond) + } + if !up(backend) { + t.Fatal("the engine was unloaded while it was being used") + } + // Polling health and the model list, as the proxy and monitors do, does not count. + stop := make(chan struct{}) + go func() { + for { + select { + case <-stop: + return + default: + statusCode(fmt.Sprintf("http://127.0.0.1:%d/health", public)) + statusCode(fmt.Sprintf("http://127.0.0.1:%d/api/tags", public)) + time.Sleep(100 * time.Millisecond) + } + } + }() + defer close(stop) + eventually(t, "unload despite monitoring traffic", 10*time.Second, func() bool { return !up(backend) }) +} + +func TestARequestInFlightKeepsTheEngineLoaded(t *testing.T) { + e, public, backend := frontExecutor(t, 1) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + done := make(chan int, 1) + go func() { done <- post(fmt.Sprintf("http://127.0.0.1:%d/slow?ms=2500", public)) }() + time.Sleep(1800 * time.Millisecond) // well past the 1 s limit + if !up(backend) { + t.Fatal("the engine was unloaded under a request that was still running") + } + if got := <-done; got != http.StatusOK { + t.Fatalf("the long request answered %d", got) + } +} + +func TestZeroTimeoutNeverUnloads(t *testing.T) { + e, _, backend := frontExecutor(t, 0) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + time.Sleep(1500 * time.Millisecond) + if !up(backend) { + t.Fatal("an engine with no idle timeout was unloaded") + } +} + +func TestStoppingAnIdleEngineStopsEverything(t *testing.T) { + e, public, backend := frontExecutor(t, 1) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + eventually(t, "the idle unload", 10*time.Second, func() bool { return !up(backend) }) + if err := e.Stop("fronted"); err != nil { + t.Fatalf("stop while idle: %v", err) + } + if st, _ := e.Status("fronted"); st.Running || st.Idle { + t.Fatalf("status = %+v, want stopped", st) + } + if got := statusCode(fmt.Sprintf("http://127.0.0.1:%d/health", public)); got != 0 { + t.Fatalf("the gateway outlived the stop (%d)", got) + } + if err := e.Start(context.Background(), "fronted"); err != nil || !up(public) { + t.Fatalf("starting after a stop from idle: %v", err) + } +} + +func TestWakeLoadsTheEngineBackBehindTheSameGateway(t *testing.T) { + e, public, backend := frontExecutor(t, 1) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + for cycle := 0; cycle < 2; cycle++ { + eventually(t, "the idle unload", 10*time.Second, func() bool { return !up(backend) }) + if err := e.wakeIdle(context.Background(), "fronted"); err != nil { + t.Fatalf("wake %d: %v", cycle, err) + } + if !up(public) || !up(backend) { + t.Fatalf("wake %d: the engine is not serving", cycle) + } + if st, _ := e.Status("fronted"); !st.Running || st.Idle || !st.Healthy { + t.Fatalf("wake %d: status = %+v", cycle, st) + } + } + // A crash after a wake still takes the gateway with it. + _ = statusCode(fmt.Sprintf("http://127.0.0.1:%d/exit", backend)) + eventually(t, "the gateway to close", 10*time.Second, func() bool { + return statusCode(fmt.Sprintf("http://127.0.0.1:%d/health", public)) == 0 + }) +} + +func TestFailedWakeLeavesTheEngineIdle(t *testing.T) { + e, public, backend := frontExecutor(t, 1) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + eventually(t, "the idle unload", 10*time.Second, func() bool { return !up(backend) }) + squatter, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", backend)) + if err != nil { + t.Skipf("backend port taken: %v", err) + } + if err := e.wakeIdle(context.Background(), "fronted"); err == nil { + t.Fatal("a wake onto an occupied port succeeded") + } + st, _ := e.Status("fronted") + if !st.Running || !st.Idle { + t.Fatalf("status = %+v, want still ON and idle after a failed wake", st) + } + if got := statusCode(fmt.Sprintf("http://127.0.0.1:%d/health", public)); got != http.StatusOK { + t.Fatalf("the idle gateway stopped answering health checks (%d)", got) + } + squatter.Close() + if err := e.wakeIdle(context.Background(), "fronted"); err != nil || !up(public) { + t.Fatalf("a second wake after the port was freed: %v", err) + } +} + +func TestEjectUnloadsButKeepsTheEngineOn(t *testing.T) { + e, public, backend := frontExecutor(t, 0) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + raw, err := e.Action(context.Background(), "fronted", "unload_model", []byte(`{"model":"org/m"}`)) + if err != nil || !strings.Contains(string(raw), `"stopped":true`) { + t.Fatalf("eject: %s %v", raw, err) + } + if up(backend) { + t.Fatal("eject must free the engine's process") + } + if st, _ := e.Status("fronted"); !st.Running || !st.Idle { + t.Fatalf("status = %+v, want the engine still ON, idle", st) + } + if got := statusCode(fmt.Sprintf("http://127.0.0.1:%d/health", public)); got != http.StatusOK { + t.Fatalf("health after eject: %d", got) + } + raw, err = e.Action(context.Background(), "fronted", "unload_model", []byte(`{"model":"org/m"}`)) + if err != nil || !strings.Contains(string(raw), `"stopped":false`) { + t.Fatalf("ejecting an unloaded engine is a no-op: %s %v", raw, err) + } + // Load of the model it is set to serve loads it again. + if _, err := e.Action(context.Background(), "fronted", "load_model", []byte(`{"model":"org/m"}`)); err != nil { + t.Fatalf("load: %v", err) + } + if !up(public) || !up(backend) { + t.Fatal("load did not bring the model back") + } +} + +func TestIdleEngineStillAdvertisesItsModel(t *testing.T) { + e, _, backend := frontExecutor(t, 0) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + res := e.ModelsResult(context.Background()) + if len(res.LoadedByEngine["fronted"]) == 0 { + t.Fatalf("a loaded engine reports what is loaded: %+v", res) + } + if _, err := e.Action(context.Background(), "fronted", "unload_model", []byte(`{}`)); err != nil { + t.Fatal(err) + } + if up(backend) { + t.Fatal("not unloaded") + } + res = e.ModelsResult(context.Background()) + if got := res.ByEngine["fronted"]; len(got) != 1 || got[0] != "org/m" { + t.Fatalf("advertised = %v, want the model it is set to serve, so requests reach it", got) + } + if loaded, ok := res.LoadedByEngine["fronted"]; !ok || len(loaded) != 0 { + t.Fatalf("loaded = %v (%v), want queried and empty", loaded, ok) + } +} diff --git a/services/nvpair-engine-manager/hfcache.go b/services/nvpair-engine-manager/hfcache.go index 748b3d6da..8ae940b39 100644 --- a/services/nvpair-engine-manager/hfcache.go +++ b/services/nvpair-engine-manager/hfcache.go @@ -353,3 +353,18 @@ func (e *Executor) servedModel(engine string) (string, error) { } return model, nil } + +// servedModelNoWait is servedModel for a reader that must not wait on a +// lifecycle operation, such as the model inventory: while one is running it +// reports an error and the caller tries again at its next poll. +func (e *Executor) servedModelNoWait(engine string) (string, error) { + st, err := e.state(engine) + if err != nil { + return "", err + } + if !st.opMu.TryLock() { + return "", fmt.Errorf("a lifecycle operation is in progress") + } + st.opMu.Unlock() + return e.servedModel(engine) +} diff --git a/services/nvpair-engine-manager/launchmodel.go b/services/nvpair-engine-manager/launchmodel.go index 54903d083..36917b674 100644 --- a/services/nvpair-engine-manager/launchmodel.go +++ b/services/nvpair-engine-manager/launchmodel.go @@ -60,6 +60,14 @@ func (e *Executor) switchLaunchModel(ctx context.Context, engine string, spec *A }{model, restarted}) } if !changed && current.Running { + // An engine unloaded for being idle serves nothing, so asking for the model + // it is set to serve is asking to load it. + if status, _ := e.Status(engine); status.Idle { + if err := e.wakeIdle(ctx, engine); err != nil { + return nil, fmt.Errorf("loading %s failed: %w", model, err) + } + return result(true) + } return result(false) } if !current.Running { @@ -177,11 +185,26 @@ func (e *Executor) ejectByStopping(ctx context.Context, engine string, params js if !state.Running { return stopped(false) } + status, _ := e.Status(engine) + idle := status.Idle if model := strings.TrimSpace(request.Model); model != "" { if served, err := e.servedModel(engine); err == nil && served != model { return nil, fmt.Errorf("%s is not the model %s is serving (%s)", model, engine, served) } } + // An engine behind a gateway is ejected the way the other engines unload a + // model: its process goes and the GPU is freed, but the engine stays ON. + if st, err := e.state(engine); err == nil { + st.mu.Lock() + g := st.front + st.mu.Unlock() + if g != nil { + if idle { + return stopped(false) + } + return stopped(e.unloadFront(st, engine, g, 0, true)) + } + } if err := e.Stop(engine); err != nil { return nil, err } diff --git a/services/nvpair-engine-manager/lifecycle.go b/services/nvpair-engine-manager/lifecycle.go index 09aea8310..a1738c78f 100644 --- a/services/nvpair-engine-manager/lifecycle.go +++ b/services/nvpair-engine-manager/lifecycle.go @@ -144,7 +144,7 @@ func (e *Executor) doStart(ctx context.Context, st *engineState, engine string, if err := e.reservedPortError(port); err != nil { return err } - presence := e.reconcilePresence(ctx, engine, st, pathInstalled, port, true) + presence := e.presenceForStart(ctx, engine, st, pathInstalled, port) if presence.Identified { if opts.RequireOwned { return fmt.Errorf("cannot apply launch settings: another process already owns the server port") @@ -192,15 +192,18 @@ func (e *Executor) doStart(ctx context.Context, st *engineState, engine string, // process so that readiness probes on the public port see it; it answers 503 // until the engine is ready. var gateway *frontGateway + reusedGateway := false if rt.Front != nil { - g, err := e.startFront(st, engine, rt, vars["host"], port) + g, reused, err := e.startFront(st, engine, rt, vars["host"], port) if err != nil { return err } - gateway = g + gateway, reusedGateway = g, reused } if err := e.bringUp(ctx, st, engine, rt, port, vars); err != nil { - gateway.close() + if !reusedGateway { + gateway.close() + } return err } @@ -223,6 +226,7 @@ func (e *Executor) doStart(ctx context.Context, st *engineState, engine string, e.watch(st, engine, proc) if gateway != nil { gateway.closeWhen(proc.done) + gateway.markUp() } } @@ -455,6 +459,12 @@ func (e *Executor) doStop(st *engineState, engine string) error { st.mu.Unlock() return nil } + if st.idle { + // Unloaded for being idle: there is no process, only the gateway. + st.mu.Unlock() + e.markStopped(st, engine) + return nil + } proc := st.proc port := st.port adopted := st.adopted @@ -626,6 +636,7 @@ func (e *Executor) markStopped(st *engineState, engine string) { st.stopping = true st.adopted = false st.proc = nil + st.idle = false front := st.front st.front = nil st.mu.Unlock() diff --git a/services/nvpair-engine-manager/manifests/vllm.json b/services/nvpair-engine-manager/manifests/vllm.json index 4443ff028..d87b5993a 100644 --- a/services/nvpair-engine-manager/manifests/vllm.json +++ b/services/nvpair-engine-manager/manifests/vllm.json @@ -17,7 +17,7 @@ ] }, "args": ["serve", "--model", "Qwen/Qwen2.5-0.5B-Instruct", "--host", "{host}", "--port", "{port}"], - "front": { "backend_port_offset": 10000 }, + "front": { "backend_port_offset": 10000, "idle_timeout_s": 600 }, "auto_launch": { "kind": "hf_context", "context_tokens": 65536 }, "env": { "HF_HOME": "{models_dir}", "VLLM_USE_FLASHINFER_SAMPLER": "0" }, "bind": "127.0.0.1", diff --git a/services/nvpair-engine-manager/models.go b/services/nvpair-engine-manager/models.go index 42551a7ad..ccaccfcb9 100644 --- a/services/nvpair-engine-manager/models.go +++ b/services/nvpair-engine-manager/models.go @@ -81,6 +81,7 @@ func (e *Executor) ModelsResult(ctx context.Context) ModelsResult { loaded := make([][]string, len(engineNames)) loadedOK := make([]bool, len(engineNames)) advertiseLoaded := make([]bool, len(engineNames)) + idleModel := make([]string, len(engineNames)) var wg sync.WaitGroup for i, name := range engineNames { mf, ok := e.reg.Get(name) @@ -111,6 +112,15 @@ func (e *Executor) ModelsResult(ctx context.Context) ModelsResult { if err != nil || !st.Running { return } + if st.Idle && loadedSpec != nil { + // Unloaded for being idle: nothing is resident, but the engine still + // answers for the model it is set to serve, by loading it on demand. + if model, err := e.servedModelNoWait(name); err == nil { + idleModel[i] = model + loaded[i], loadedOK[i] = []string{}, true + } + return + } if listSpec != nil { if raw, err := e.Action(ctx, name, "list_models", nil); err != nil { slog.Debug("engine:models list_models failed", "engine", name, "err", err) @@ -143,6 +153,9 @@ func (e *Executor) ModelsResult(ctx context.Context) ModelsResult { for i, models := range perEngine { if advertiseLoaded[i] { models, listOK[i] = loaded[i], loadedOK[i] + if idleModel[i] != "" { + models, listOK[i] = []string{idleModel[i]}, true + } } if listOK[i] { if res.ByEngine == nil { diff --git a/services/nvpair-engine-manager/registry.go b/services/nvpair-engine-manager/registry.go index f08661584..4df3835de 100644 --- a/services/nvpair-engine-manager/registry.go +++ b/services/nvpair-engine-manager/registry.go @@ -825,6 +825,9 @@ const ( func (p *Platform) validate(key string) error { if front := p.Runtime.Front; front != nil { + if front.IdleTimeoutS < 0 || front.IdleTimeoutS > 86400 { + return fmt.Errorf("platform %q: front.idle_timeout_s must be between 0 and 86400", key) + } if front.BackendPortOffset < 1 || front.BackendPortOffset > 60000 { return fmt.Errorf("platform %q: front.backend_port_offset must be between 1 and 60000", key) } diff --git a/services/nvpair-engine-manager/status.go b/services/nvpair-engine-manager/status.go index 9935c930a..9aeffb955 100644 --- a/services/nvpair-engine-manager/status.go +++ b/services/nvpair-engine-manager/status.go @@ -143,6 +143,7 @@ func (e *Executor) snapshot(engine string, st *engineState) EngineStatus { Running: st.running, Healthy: st.healthy, Port: st.port, + Idle: st.idle, } } diff --git a/services/nvpair-engine-manager/testdata/fakeengine/main.go b/services/nvpair-engine-manager/testdata/fakeengine/main.go index 6657a674a..e6e39c68f 100644 --- a/services/nvpair-engine-manager/testdata/fakeengine/main.go +++ b/services/nvpair-engine-manager/testdata/fakeengine/main.go @@ -334,6 +334,12 @@ func main() { mux.HandleFunc("/api/error", func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", http.StatusInternalServerError) }) + // /slow?ms=N answers after N milliseconds, as a long generation does. + mux.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) { + ms, _ := strconv.Atoi(r.URL.Query().Get("ms")) + time.Sleep(time.Duration(ms) * time.Millisecond) + w.WriteHeader(http.StatusOK) + }) mux.HandleFunc("/exit", func(w http.ResponseWriter, r *http.Request) { os.Exit(1) // simulate an unexpected engine crash }) From 76c31238a100462080a788c5d39e9106ff47943d Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 21 Oct 2026 19:03:30 +0530 Subject: [PATCH 16/35] feat(nvpair-engine-manager): load an unloaded vLLM on the first request A request that needs the model and arrives while the engine is unloaded now loads it behind the gateway and is answered once it is ready, as Ollama and llama.cpp do, instead of getting a 503. Requests that arrive together share one start, a client that leaves does not abandon the load, and Eject followed by a request reloads it. A failed load answers 503 with the reason and leaves the engine idle so the next request retries. Health checks, the model list and other reads never load the model. Signed-off-by: Tushar Khinvasara --- services/nvpair-engine-manager/MANIFEST.md | 6 +- services/nvpair-engine-manager/README.md | 14 +- services/nvpair-engine-manager/front.go | 176 ++++++++++++++++--- services/nvpair-engine-manager/front_test.go | 146 ++++++++++++++- 4 files changed, 307 insertions(+), 35 deletions(-) diff --git a/services/nvpair-engine-manager/MANIFEST.md b/services/nvpair-engine-manager/MANIFEST.md index a1d1d56fd..def427521 100644 --- a/services/nvpair-engine-manager/MANIFEST.md +++ b/services/nvpair-engine-manager/MANIFEST.md @@ -359,8 +359,10 @@ its process is stopped and its GPU memory freed, while the gateway, and the engine's ON state, stay. Only requests that change something count as activity (not GET, HEAD or OPTIONS, which is what health checks, monitors and the model list use), and a request still running keeps the engine loaded. While unloaded -the gateway answers `/health` with 200, refuses inference with 503, and -`engine:status` reports `idle: true`. The fixed offset lets a stale engine +the gateway answers `/health` with 200 and `engine:status` reports `idle: true`. +A request that needs the model (POST under `/v1/` and a few other inference paths) +loads it again and is answered after the load; other requests get 503. Requests +that arrive together share one load. The fixed offset lets a stale engine left by a crashed run be found and reclaimed. It requires a process-mode runtime whose `editable_launch` has a flag control for `{server.port}`, and only `vllm.json` declares one. diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index 92d42e174..508a822e0 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -103,8 +103,18 @@ answers 200, and `engine:models` still advertises the model it is set to serve with nothing loaded. Health checks, monitors and the model list do not count as activity, and a request still running keeps the engine loaded. Eject (`unload_model`) does the same on demand. `load_model` of the model it is set to -serve loads it again; a request while unloaded is answered 503 until it loads on -demand arrives. A failed load leaves the engine idle, not stopped. +serve loads it again. + +A request that needs the model (a POST under `/v1/`, or to `/tokenize`, +`/detokenize`, `/pooling`, `/score`, `/rerank` or `/classify`) and arrives while it +is unloaded loads it and is then answered, as Ollama and llama.cpp do: the gateway +holds the connection, starts the engine behind itself, and forwards the request +once it is ready, so the first request waits for the load (about 25 s for an 8B +model). Requests that arrive together share one start, a client that gives up +does not abandon the load, and the proxy's 120 s limit is the ceiling for a very +large model. A load that fails answers 503 saying so, and leaves the engine idle, +not stopped, so the next request tries again. Reads such as `/health` and +`/v1/models` never load the model. `unload_model` is a `stop_engine` action: vLLM cannot drop its model while running, so ejecting stops its process, which frees all of its GPU memory, while the gateway and the engine stay ON (an engine with no gateway is stopped) and the saved model choice is kept; a model other than the served one is refused. `load_model` on a stopped engine saves the choice and starts it, restoring the previous choice if it does not start. diff --git a/services/nvpair-engine-manager/front.go b/services/nvpair-engine-manager/front.go index 96b44ca24..a30608440 100644 --- a/services/nvpair-engine-manager/front.go +++ b/services/nvpair-engine-manager/front.go @@ -5,6 +5,7 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -80,6 +81,75 @@ type frontGateway struct { // epoch identifies the engine process the gateway currently belongs to, so // the exit of an earlier process, stopped on purpose, does not close it. epoch int + // wakeFn loads the engine again, and wakeSt is the load in progress, if any. + // Requests that arrive meanwhile wait on it. + wakeFn func(context.Context) error + wakeSt *wakeState +} + +// wakeState is one load of an unloaded engine. Every request that needs the +// engine waits on done, so any number of them share a single start. +type wakeState struct { + done chan struct{} + err error +} + +// wakePaths are the non-/v1 paths that run inference. +var wakePaths = map[string]bool{"/tokenize": true, "/detokenize": true, "/pooling": true, "/score": true, "/rerank": true, "/classify": true} + +// wakeable reports whether a request needs the model and so should load it. +// Health checks, metrics and reads must not: they are made on a timer, and would +// keep the model loaded or load it for nothing. +func wakeable(r *http.Request) bool { + return r.Method == http.MethodPost && (strings.HasPrefix(r.URL.Path, "/v1/") || wakePaths[r.URL.Path]) +} + +// requestWake returns the load in progress, starting one if the engine is +// unloaded. It returns nil when the engine is neither, so the caller can carry +// on. The gateway forwards again from the moment a load begins, so that the +// start's own readiness probes, made on the public port, reach the engine and not +// the idle answer. +func (g *frontGateway) requestWake() *wakeState { + g.mu.Lock() + defer g.mu.Unlock() + if g.wakeSt != nil { + return g.wakeSt + } + if !g.idle || g.wakeFn == nil { + return nil + } + ws := &wakeState{done: make(chan struct{})} + g.wakeSt = ws + g.idle, g.up = false, false + go func() { g.finishWake(ws, g.wakeFn(context.Background())) }() + return ws +} + +// finishWake ends a load. A failed one puts the gateway back to idle, so the next +// request tries again. +func (g *frontGateway) finishWake(ws *wakeState, err error) { + g.mu.Lock() + ws.err = err + if err != nil { + g.idle, g.up = true, false + } + if g.wakeSt == ws { + g.wakeSt = nil + } + g.mu.Unlock() + close(ws.done) +} + +// loadFailed answers a request whose model could not be loaded. +func loadFailed(w http.ResponseWriter, err error) { + reason := strings.Join(strings.Fields(err.Error()), " ") + if len(reason) > 400 { + reason = reason[:400] + "..." + } + body, _ := json.Marshal(map[string]string{"error": "the model could not be loaded: " + reason}) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write(body) } // isActivity reports whether a request is inference work. Reads such as health @@ -95,24 +165,46 @@ func isActivity(r *http.Request) bool { func (g *frontGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { activity := isActivity(r) - g.mu.Lock() - if g.idle { - g.mu.Unlock() - if r.URL.Path == "/health" && (r.Method == http.MethodGet || r.Method == http.MethodHead) { + for { + g.mu.Lock() + if (g.idle || g.wakeSt != nil) && wakeable(r) { + g.mu.Unlock() + ws := g.requestWake() + if ws == nil { + continue // it loaded between the check and now + } + select { + case <-ws.done: + if ws.err != nil { + loadFailed(w, ws.err) + return + } + continue + case <-r.Context().Done(): + return // the client left; the load carries on for the next one + case <-g.closed: + return + } + } + if g.idle { + g.mu.Unlock() + if r.URL.Path == "/health" && (r.Method == http.MethodGet || r.Method == http.MethodHead) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"idle"}`)) + return + } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"status":"idle"}`)) + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error":"the model is unloaded because the engine was idle"}`)) return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusServiceUnavailable) - _, _ = w.Write([]byte(`{"error":"the model is unloaded because the engine was idle"}`)) - return - } - if activity { - g.inflight++ - g.lastActive = time.Now() + if activity { + g.inflight++ + g.lastActive = time.Now() + } + g.mu.Unlock() + break } - g.mu.Unlock() if activity { defer func() { g.mu.Lock() @@ -268,6 +360,7 @@ func (e *Executor) startFront(st *engineState, engine string, rt Runtime, host s if err != nil { return nil, false, err } + g.wakeFn = func(ctx context.Context) error { return e.wakeEngine(ctx, engine) } st.mu.Lock() previous := st.front st.front = g @@ -336,9 +429,37 @@ func (e *Executor) unloadFront(st *engineState, engine string, g *frontGateway, return true } -// wakeIdle starts the engine again behind its gateway after an idle unload. A -// failed start leaves it idle, not stopped, so the next request can try again. +// wakeIdle loads an engine that was unloaded for being idle, and returns once it +// is serving. It shares the load with any request already waiting for it. func (e *Executor) wakeIdle(ctx context.Context, engine string) error { + st, err := e.state(engine) + if err != nil { + return err + } + st.mu.Lock() + g := st.front + st.mu.Unlock() + if g == nil || !g.alive() { + return nil + } + ws := g.requestWake() + if ws == nil { + return nil + } + select { + case <-ws.done: + return ws.err + case <-ctx.Done(): + return ctx.Err() + } +} + +// wakeEngine is the load itself: it starts the engine's process behind the +// gateway that is already in place. A failed start leaves the engine idle, not +// stopped, so the next request can try again. The load is bounded by the engine's +// readiness limit, and not by the request that asked for it, so a client leaving +// does not abandon it. +func (e *Executor) wakeEngine(ctx context.Context, engine string) error { st, err := e.state(engine) if err != nil { return err @@ -346,8 +467,7 @@ func (e *Executor) wakeIdle(ctx context.Context, engine string) error { st.opMu.Lock() defer st.opMu.Unlock() st.mu.Lock() - idle, g := st.idle, st.front - if !idle || g == nil { + if !st.idle || st.front == nil { st.mu.Unlock() return nil } @@ -355,24 +475,22 @@ func (e *Executor) wakeIdle(ctx context.Context, engine string) error { // for the duration of the start. st.running, st.healthy, st.idle = false, false, false st.mu.Unlock() - // The gateway forwards again from here, so that the start's readiness probes, - // made on the public port, reach the engine and not the idle answer. - g.mu.Lock() - g.idle, g.up = false, false - g.mu.Unlock() - err = e.doStart(ctx, st, engine, startOpts{}) - if err != nil { + limit := 10 * time.Minute + if ready := st.plat.Runtime.Ready; ready != nil && ready.TimeoutS > 0 { + limit = time.Duration(ready.TimeoutS+60) * time.Second + } + ctx, cancel := context.WithTimeout(ctx, limit) + defer cancel() + if err := e.doStart(ctx, st, engine, startOpts{}); err != nil { st.mu.Lock() if st.front != nil && st.front.alive() { st.running, st.healthy, st.idle = true, true, true - st.front.mu.Lock() - st.front.up, st.front.idle = false, true - st.front.mu.Unlock() } st.mu.Unlock() e.emitState(engine) + return err } - return err + return nil } // reclaimFrontBackend frees the backend port when it holds an engine that PAIR diff --git a/services/nvpair-engine-manager/front_test.go b/services/nvpair-engine-manager/front_test.go index bed838d93..ef624de9b 100644 --- a/services/nvpair-engine-manager/front_test.go +++ b/services/nvpair-engine-manager/front_test.go @@ -11,10 +11,13 @@ import ( "net" "net/http" "net/http/httptest" + "os" + "path/filepath" "runtime" "slices" "strconv" "strings" + "sync" "sync/atomic" "testing" "time" @@ -471,8 +474,147 @@ func TestIdleEngineIsUnloadedAfterTheTimeout(t *testing.T) { if got := statusCode(fmt.Sprintf("http://127.0.0.1:%d/health", public)); got != http.StatusOK { t.Fatalf("an idle engine must still pass health checks: %d", got) } - if got := post(fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", public)); got != http.StatusServiceUnavailable { - t.Fatalf("inference while unloaded answered %d, want 503", got) + // Only a request that needs the model loads it. A read, or a post to something + // that is not inference, is refused and leaves it unloaded. + if got := statusCode(fmt.Sprintf("http://127.0.0.1:%d/api/tags", public)); got != http.StatusServiceUnavailable { + t.Fatalf("a read while unloaded answered %d, want 503", got) + } + if got := post(fmt.Sprintf("http://127.0.0.1:%d/admin/reset", public)); got != http.StatusServiceUnavailable { + t.Fatalf("a non-inference post answered %d, want 503", got) + } + if up(backend) { + t.Fatal("something that is not inference loaded the model") + } +} + +// The request that arrives while the model is unloaded loads it and is answered, +// as Ollama and llama.cpp do. +func TestARequestLoadsAnUnloadedEngineAndIsAnswered(t *testing.T) { + e, public, backend := frontExecutor(t, 1) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + for cycle := 0; cycle < 2; cycle++ { + eventually(t, "the idle unload", 10*time.Second, func() bool { return !up(backend) }) + if got := post(fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", public)); got != http.StatusOK { + t.Fatalf("cycle %d: the request that woke the engine answered %d", cycle, got) + } + if !up(backend) { + t.Fatalf("cycle %d: the engine is not loaded", cycle) + } + if st, _ := e.Status("fronted"); !st.Running || st.Idle || !st.Healthy { + t.Fatalf("cycle %d: status = %+v", cycle, st) + } + } +} + +func startCount(t *testing.T, path string) int { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + return 0 + } + return strings.Count(string(data), "\n") +} + +func TestRequestsThatArriveTogetherShareOneLoad(t *testing.T) { + log := filepath.Join(t.TempDir(), "starts.log") + t.Setenv("FAKE_START_LOG", log) + t.Setenv("FAKE_START_DELAY", "700ms") + e, public, backend := frontExecutor(t, 1) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + eventually(t, "the idle unload", 10*time.Second, func() bool { return !up(backend) }) + before := startCount(t, log) + + var wg sync.WaitGroup + results := make([]int, 8) + for i := range results { + wg.Add(1) + go func() { + defer wg.Done() + results[i] = post(fmt.Sprintf("http://127.0.0.1:%d/v1/completions", public)) + }() + } + wg.Wait() + for i, code := range results { + if code != http.StatusOK { + t.Fatalf("request %d answered %d", i, code) + } + } + if got := startCount(t, log) - before; got != 1 { + t.Fatalf("the engine was started %d times for 8 simultaneous requests, want 1", got) + } +} + +func TestAClientLeavingDoesNotAbandonTheLoad(t *testing.T) { + t.Setenv("FAKE_START_DELAY", "1500ms") + e, public, backend := frontExecutor(t, 1) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + eventually(t, "the idle unload", 15*time.Second, func() bool { return !up(backend) }) + + client := &http.Client{Timeout: 300 * time.Millisecond} + if resp, err := client.Post(fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", public), "application/json", strings.NewReader("{}")); err == nil { + resp.Body.Close() + t.Fatal("expected the impatient client to give up") + } + eventually(t, "the load to finish without the client", 15*time.Second, func() bool { return up(backend) }) + if got := post(fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", public)); got != http.StatusOK { + t.Fatalf("the next request answered %d", got) + } +} + +func TestARequestDuringALoadFromEjectWaitsForIt(t *testing.T) { + t.Setenv("FAKE_START_DELAY", "1s") + e, public, backend := frontExecutor(t, 0) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + if _, err := e.Action(context.Background(), "fronted", "unload_model", []byte(`{}`)); err != nil { + t.Fatal(err) + } + loaded := make(chan error, 1) + go func() { + _, err := e.Action(context.Background(), "fronted", "load_model", []byte(`{"model":"org/m"}`)) + loaded <- err + }() + time.Sleep(300 * time.Millisecond) // the load is under way + if got := post(fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", public)); got != http.StatusOK { + t.Fatalf("a request during the load answered %d", got) + } + if err := <-loaded; err != nil || !up(backend) { + t.Fatalf("load: %v", err) + } +} + +func TestAFailedLoadIsReportedToTheRequestAndRetriedByTheNext(t *testing.T) { + e, public, backend := frontExecutor(t, 1) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + eventually(t, "the idle unload", 10*time.Second, func() bool { return !up(backend) }) + squatter, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", backend)) + if err != nil { + t.Skipf("backend port taken: %v", err) + } + resp, err := http.Post(fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", public), "application/json", strings.NewReader("{}")) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusServiceUnavailable || !strings.Contains(string(body), "could not be loaded") { + t.Fatalf("got %d %s, want 503 saying the model could not be loaded", resp.StatusCode, body) + } + if st, _ := e.Status("fronted"); !st.Running || !st.Idle { + t.Fatalf("status = %+v, want ON and still idle", st) + } + squatter.Close() + if got := post(fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", public)); got != http.StatusOK { + t.Fatalf("the next request answered %d, want it to load", got) } } From 35eef76c16a1cee42eaccd4f038ed4d47fa735d4 Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 21 Oct 2026 19:23:16 +0530 Subject: [PATCH 17/35] fix(nvpair-engine-manager): keep an unloaded vLLM routable through the proxy The proxy routes by each node's advertised models and builds its own model list from the engine's /v1/models. While the engine was unloading or loading, the inventory briefly lost the model, and the idle gateway refused the model list, so a request arriving then was answered 'no node advertises the requested model' and never reached the wake. Record the served model when the engine goes idle, mark the state idle before the gateway so no reader sees an engine that is neither loaded nor idle, keep the engine marked running and idle while it loads (no stopped flicker), and answer GET /v1/models from the gateway while unloaded or loading. Signed-off-by: Tushar Khinvasara --- services/nvpair-engine-manager/README.md | 6 +- services/nvpair-engine-manager/executor.go | 4 + services/nvpair-engine-manager/front.go | 80 +++++++++++++++++--- services/nvpair-engine-manager/front_test.go | 80 ++++++++++++++++++++ services/nvpair-engine-manager/hfcache.go | 15 ---- services/nvpair-engine-manager/lifecycle.go | 5 +- services/nvpair-engine-manager/models.go | 12 ++- 7 files changed, 174 insertions(+), 28 deletions(-) diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index 508a822e0..6708feb9f 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -112,7 +112,11 @@ holds the connection, starts the engine behind itself, and forwards the request once it is ready, so the first request waits for the load (about 25 s for an 8B model). Requests that arrive together share one start, a client that gives up does not abandon the load, and the proxy's 120 s limit is the ceiling for a very -large model. A load that fails answers 503 saying so, and leaves the engine idle, +large model. While it is unloaded and while it loads, the engine stays marked +running and idle, goes on advertising the model it is set to serve, and the +gateway answers `GET /v1/models` with that model, so the proxy, which routes by +the advertised models and builds its own list from that call, keeps sending the +request here instead of refusing it. A load that fails answers 503 saying so, and leaves the engine idle, not stopped, so the next request tries again. Reads such as `/health` and `/v1/models` never load the model. diff --git a/services/nvpair-engine-manager/executor.go b/services/nvpair-engine-manager/executor.go index 4b60b6b54..f8283cfc3 100644 --- a/services/nvpair-engine-manager/executor.go +++ b/services/nvpair-engine-manager/executor.go @@ -82,6 +82,10 @@ type engineState struct { // idle is set while front's engine is unloaded for being idle: the engine is // ON (running is true) but has no process. idle bool + // idleModel is the model the engine was set to serve when it was unloaded. It + // is kept so the engine goes on advertising it, without reading the launch + // settings while a lifecycle operation holds them. + idleModel string } // Executor owns engine lifecycle for every engine known on this host. diff --git a/services/nvpair-engine-manager/front.go b/services/nvpair-engine-manager/front.go index a30608440..badb740c9 100644 --- a/services/nvpair-engine-manager/front.go +++ b/services/nvpair-engine-manager/front.go @@ -85,6 +85,9 @@ type frontGateway struct { // Requests that arrive meanwhile wait on it. wakeFn func(context.Context) error wakeSt *wakeState + // model is the model the engine serves, so that the model list can be + // answered while the engine is unloaded. + model string } // wakeState is one load of an unloaded engine. Every request that needs the @@ -186,8 +189,19 @@ func (g *frontGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } } - if g.idle { + if g.idle || (g.wakeSt != nil && r.Method == http.MethodGet && r.URL.Path == "/v1/models") { + model := g.model g.mu.Unlock() + if r.Method == http.MethodGet && r.URL.Path == "/v1/models" && model != "" { + // The proxy builds its model list from this call, and an engine that is + // only unloaded still answers for its model. + body, _ := json.Marshal(map[string]any{"object": "list", "data": []map[string]any{ + {"id": model, "object": "model", "created": 0, "owned_by": "vllm"}, + }}) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + return + } if r.URL.Path == "/health" && (r.Method == http.MethodGet || r.Method == http.MethodHead) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status":"idle"}`)) @@ -414,14 +428,32 @@ func (e *Executor) unloadFront(st *engineState, engine string, g *frontGateway, proc := st.proc ok := st.running && !st.stopping && !st.idle && st.front == g && proc != nil st.mu.Unlock() - if !ok || !g.enterIdle(timeout, force) { + if !ok { + return false + } + // Read while this holds the lifecycle lock: the engine goes on advertising + // this model, and nothing may read the launch settings during the unload. + model, _ := e.servedModelLocked(engine, st) + g.mu.Lock() + g.model = model + g.mu.Unlock() + // The state says idle before the gateway does, so a reader of the inventory + // never sees an engine that is neither loaded nor idle and drops its model. + st.mu.Lock() + st.idle = true + st.idleModel = model + st.mu.Unlock() + if !g.enterIdle(timeout, force) { + st.mu.Lock() + st.idle = false + st.idleModel = "" + st.mu.Unlock() return false } st.mu.Lock() // The process is released from the state before it is stopped, so its exit is // not taken for a crash. st.proc = nil - st.idle = true st.mu.Unlock() slog.Info("unloading an idle engine", "engine", engine, "forced", force) e.emitState(engine) @@ -471,9 +503,6 @@ func (e *Executor) wakeEngine(ctx context.Context, engine string) error { st.mu.Unlock() return nil } - // doStart does nothing for an engine marked running, so it is marked stopped - // for the duration of the start. - st.running, st.healthy, st.idle = false, false, false st.mu.Unlock() limit := 10 * time.Minute if ready := st.plat.Runtime.Ready; ready != nil && ready.TimeoutS > 0 { @@ -481,15 +510,20 @@ func (e *Executor) wakeEngine(ctx context.Context, engine string) error { } ctx, cancel := context.WithTimeout(ctx, limit) defer cancel() - if err := e.doStart(ctx, st, engine, startOpts{}); err != nil { + // The engine stays marked running and idle throughout, so the status and the + // model inventory do not flicker while it loads. + if err := e.doStart(ctx, st, engine, startOpts{Wake: true}); err != nil { st.mu.Lock() - if st.front != nil && st.front.alive() { - st.running, st.healthy, st.idle = true, true, true - } + st.stopping = false st.mu.Unlock() e.emitState(engine) return err } + st.mu.Lock() + st.idle = false + st.idleModel = "" + st.mu.Unlock() + e.emitState(engine) return nil } @@ -594,3 +628,29 @@ func (e *Executor) presenceForStart(ctx context.Context, engine string, st *engi } return e.reconcilePresence(ctx, engine, st, pathInstalled, port, true) } + +// servedModelLocked is the model the engine's launch names. The caller holds the +// lifecycle lock, which the launch settings are read under. +func (e *Executor) servedModelLocked(engine string, st *engineState) (string, error) { + state := e.launchStateLocked(engine, st) + tokens, err := parseLaunchText(state.LaunchText) + if err != nil { + return "", err + } + model, ok := launchFlagValue(tokens, "--model") + if !ok || model == "" { + return "", fmt.Errorf("the launch arguments name no --model") + } + return model, nil +} + +// idleModelOf is the model an unloaded engine goes on advertising. +func (e *Executor) idleModelOf(engine string) string { + st, err := e.state(engine) + if err != nil { + return "" + } + st.mu.Lock() + defer st.mu.Unlock() + return st.idleModel +} diff --git a/services/nvpair-engine-manager/front_test.go b/services/nvpair-engine-manager/front_test.go index ef624de9b..98313b77b 100644 --- a/services/nvpair-engine-manager/front_test.go +++ b/services/nvpair-engine-manager/front_test.go @@ -801,3 +801,83 @@ func TestIdleEngineStillAdvertisesItsModel(t *testing.T) { t.Fatalf("loaded = %v (%v), want queried and empty", loaded, ok) } } + +// A request through the proxy is routed by the node's advertised models, so the +// engine must never drop its model from the inventory while it unloads or loads: +// in that gap the proxy has no node for the model and refuses the request. +func TestTheModelStaysAdvertisedThroughAnUnloadAndALoad(t *testing.T) { + t.Setenv("FAKE_START_DELAY", "800ms") + e, public, backend := frontExecutor(t, 0) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + + var missing atomic.Int32 + var samples atomic.Int32 + stop := make(chan struct{}) + var watchers sync.WaitGroup + watchers.Add(1) + go func() { + defer watchers.Done() + for { + select { + case <-stop: + return + default: + } + res := e.ModelsResult(context.Background()) + samples.Add(1) + if got := res.ByEngine["fronted"]; len(got) == 0 { + missing.Add(1) + } + } + }() + + if _, err := e.Action(context.Background(), "fronted", "unload_model", []byte(`{}`)); err != nil { + t.Fatal(err) + } + if got := post(fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", public)); got != http.StatusOK { + t.Fatalf("the request that loads the engine answered %d", got) + } + close(stop) + watchers.Wait() + if !up(backend) { + t.Fatal("not loaded") + } + if missing.Load() != 0 { + t.Fatalf("the model was missing from the inventory in %d of %d samples during the unload and load", missing.Load(), samples.Load()) + } +} + +func TestTheModelListIsAnsweredWhileUnloadedAndWhileLoading(t *testing.T) { + t.Setenv("FAKE_START_DELAY", "1s") + e, public, backend := frontExecutor(t, 0) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + if _, err := e.Action(context.Background(), "fronted", "unload_model", []byte(`{}`)); err != nil { + t.Fatal(err) + } + list := func() (int, string) { + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/v1/models", public)) + if err != nil { + return 0, "" + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(body) + } + if code, body := list(); code != http.StatusOK || !strings.Contains(body, `"id":"org/m"`) { + t.Fatalf("while unloaded: %d %s, want the model it serves", code, body) + } + + go post(fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", public)) // loads it + time.Sleep(400 * time.Millisecond) + if st, _ := e.Status("fronted"); !st.Running { + t.Fatalf("status = %+v: an engine that is loading must not look stopped", st) + } + if code, body := list(); code != http.StatusOK || !strings.Contains(body, `"id":"org/m"`) { + t.Fatalf("while loading: %d %s, want the model it serves", code, body) + } + eventually(t, "the load", 10*time.Second, func() bool { return up(backend) }) +} diff --git a/services/nvpair-engine-manager/hfcache.go b/services/nvpair-engine-manager/hfcache.go index 8ae940b39..748b3d6da 100644 --- a/services/nvpair-engine-manager/hfcache.go +++ b/services/nvpair-engine-manager/hfcache.go @@ -353,18 +353,3 @@ func (e *Executor) servedModel(engine string) (string, error) { } return model, nil } - -// servedModelNoWait is servedModel for a reader that must not wait on a -// lifecycle operation, such as the model inventory: while one is running it -// reports an error and the caller tries again at its next poll. -func (e *Executor) servedModelNoWait(engine string) (string, error) { - st, err := e.state(engine) - if err != nil { - return "", err - } - if !st.opMu.TryLock() { - return "", fmt.Errorf("a lifecycle operation is in progress") - } - st.opMu.Unlock() - return e.servedModel(engine) -} diff --git a/services/nvpair-engine-manager/lifecycle.go b/services/nvpair-engine-manager/lifecycle.go index a1738c78f..a4ca4f7c1 100644 --- a/services/nvpair-engine-manager/lifecycle.go +++ b/services/nvpair-engine-manager/lifecycle.go @@ -64,6 +64,9 @@ type startOpts struct { RequireOwned bool Port int Bind string + // Wake starts an engine that is marked running but has no process, one that + // was unloaded for being idle, instead of treating it as already started. + Wake bool } // effectiveBind picks the listen address substituted as {host}: a per-call @@ -122,7 +125,7 @@ func (e *Executor) doStart(ctx context.Context, st *engineState, engine string, st.mu.Lock() running := st.running adopted := st.adopted - if running && !adopted { + if running && !adopted && !opts.Wake { st.mu.Unlock() return nil } diff --git a/services/nvpair-engine-manager/models.go b/services/nvpair-engine-manager/models.go index ccaccfcb9..746d493dd 100644 --- a/services/nvpair-engine-manager/models.go +++ b/services/nvpair-engine-manager/models.go @@ -115,7 +115,7 @@ func (e *Executor) ModelsResult(ctx context.Context) ModelsResult { if st.Idle && loadedSpec != nil { // Unloaded for being idle: nothing is resident, but the engine still // answers for the model it is set to serve, by loading it on demand. - if model, err := e.servedModelNoWait(name); err == nil { + if model := e.idleModelOf(name); model != "" { idleModel[i] = model loaded[i], loadedOK[i] = []string{}, true } @@ -133,6 +133,16 @@ func (e *Executor) ModelsResult(ctx context.Context) ModelsResult { } if loadedSpec != nil { if raw, err := e.Action(ctx, name, "loaded_models", nil); err != nil { + // The engine may have been unloaded between the status read and this + // query, which then fails. It is idle, not missing: it still answers + // for its model. + if fresh, _ := e.Status(name); fresh.Idle { + if model := e.idleModelOf(name); model != "" { + idleModel[i] = model + loaded[i], loadedOK[i] = []string{}, true + return + } + } slog.Debug("engine:models loaded_models failed", "engine", name, "err", err) } else if models, ok := extractStringsResult(raw, loadedSpec); ok { // A successful query — even an empty result — records the key From a335b38d11c2df8b622f8af693f0cb2fbac5bf5d Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 21 Oct 2026 19:37:11 +0530 Subject: [PATCH 18/35] fix(nvpair-engine-manager): no health warning while vLLM is unloaded or loading The health check kept probing /health while the engine loaded again after an idle unload. The gateway forwards that probe to the engine for the start's readiness check, so it failed for the whole load and raised 'failed its health probe', which cleared itself once the load finished. Skip the probe while the engine is idle or loading, and test that no warning is raised while one stands. Signed-off-by: Tushar Khinvasara --- services/nvpair-engine-manager/front_test.go | 51 ++++++++++++++++++++ services/nvpair-engine-manager/lifecycle.go | 13 +++++ 2 files changed, 64 insertions(+) diff --git a/services/nvpair-engine-manager/front_test.go b/services/nvpair-engine-manager/front_test.go index 98313b77b..35f00debb 100644 --- a/services/nvpair-engine-manager/front_test.go +++ b/services/nvpair-engine-manager/front_test.go @@ -881,3 +881,54 @@ func TestTheModelListIsAnsweredWhileUnloadedAndWhileLoading(t *testing.T) { } eventually(t, "the load", 10*time.Second, func() bool { return up(backend) }) } + +// The engine is not serving while it is unloaded or loading, and that is not a +// failure: the health check must not raise a warning for it. A warning is raised +// and then cleared when the load finishes, so it has to be caught while it stands. +func TestNoHealthWarningWhileUnloadedOrLoading(t *testing.T) { + t.Setenv("FAKE_START_DELAY", "2500ms") // several health ticks long + e, public, backend := frontExecutor(t, 0) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + + var raised atomic.Value + stop := make(chan struct{}) + var watcher sync.WaitGroup + watcher.Add(1) + go func() { + defer watcher.Done() + for { + select { + case <-stop: + return + default: + } + for _, problem := range e.Errors() { + if problem.ID == unhealthyID("fronted") || problem.ID == exitedID("fronted") { + raised.Store(problem.Message) + } + } + time.Sleep(10 * time.Millisecond) + } + }() + + if _, err := e.Action(context.Background(), "fronted", "unload_model", []byte(`{}`)); err != nil { + t.Fatal(err) + } + time.Sleep(2200 * time.Millisecond) // unloaded across health ticks + if got := post(fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", public)); got != http.StatusOK { + t.Fatalf("the request that loads the engine answered %d", got) + } + close(stop) + watcher.Wait() + if !up(backend) { + t.Fatal("not loaded") + } + if msg := raised.Load(); msg != nil { + t.Fatalf("a warning was raised for an engine that was only unloaded or loading: %v", msg) + } + if st, _ := e.Status("fronted"); !st.Healthy { + t.Fatalf("status = %+v, want healthy once loaded", st) + } +} diff --git a/services/nvpair-engine-manager/lifecycle.go b/services/nvpair-engine-manager/lifecycle.go index a4ca4f7c1..ce8e615fe 100644 --- a/services/nvpair-engine-manager/lifecycle.go +++ b/services/nvpair-engine-manager/lifecycle.go @@ -863,6 +863,14 @@ func (e *Executor) runHealth(ctx context.Context, st *engineState, engine string case <-ctx.Done(): return case <-t.C: + // An engine unloaded for being idle, or loading again, is not serving + // and is not failing: there is nothing to probe. + st.mu.Lock() + skip := st.idle + st.mu.Unlock() + if skip { + continue + } ok := e.probe(ctx, h, port) st.mu.Lock() // Bail if stopped or if a newer start superseded this loop — @@ -871,6 +879,11 @@ func (e *Executor) runHealth(ctx context.Context, st *engineState, engine string st.mu.Unlock() return } + if st.idle { + // It was unloaded while the probe ran, so the result is no answer. + st.mu.Unlock() + continue + } was := st.healthy st.healthy = ok st.mu.Unlock() From 44a6c22962094b57be863e2344c708ee91826b69 Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 21 Oct 2026 20:08:02 +0530 Subject: [PATCH 19/35] feat(nvpair-engine-manager): start vLLM lazily and make its idle timeout settable Start, restart and the restore after PAIR restarts now bring an engine with a front up idle, with nothing loaded, as Ollama and llama.cpp start a server: about a second, the engine ON and healthy, the model loaded by the first request or a Load. The launch is still validated and the ports checked, so a launch that cannot work is refused at Start. Applying launch settings and switching or loading the model still load it at once, so a bad setting is found and rolled back. Add get_idle_timeout and set_idle_timeout (front_idle) actions: the limit is read and changed in seconds, 0 for never, applies at once and is saved in the engine's override file. Signed-off-by: Tushar Khinvasara --- services/nvpair-engine-manager/MANIFEST.md | 6 + services/nvpair-engine-manager/README.md | 13 +- services/nvpair-engine-manager/actions.go | 3 + services/nvpair-engine-manager/executor.go | 4 + services/nvpair-engine-manager/front.go | 126 +++++++++++- services/nvpair-engine-manager/front_test.go | 191 ++++++++++++++++-- services/nvpair-engine-manager/launchmodel.go | 2 +- services/nvpair-engine-manager/lifecycle.go | 9 + .../nvpair-engine-manager/manifests/vllm.json | 8 + services/nvpair-engine-manager/registry.go | 11 +- .../nvpair-engine-manager/registry_test.go | 2 +- services/nvpair-engine-manager/settings.go | 2 +- 12 files changed, 349 insertions(+), 28 deletions(-) diff --git a/services/nvpair-engine-manager/MANIFEST.md b/services/nvpair-engine-manager/MANIFEST.md index def427521..e99500687 100644 --- a/services/nvpair-engine-manager/MANIFEST.md +++ b/services/nvpair-engine-manager/MANIFEST.md @@ -367,6 +367,12 @@ left by a crashed run be found and reclaimed. It requires a process-mode runtime whose `editable_launch` has a flag control for `{server.port}`, and only `vllm.json` declares one. +A **`front_idle`** action (`"get"` or `"set"`) reads or changes the idle limit of +an engine with a front, in seconds with 0 for never; a change applies at once and +is saved in the override file. With a front, Start and a restore bring the +engine up idle, with nothing loaded, and applying launch settings or loading a +model loads it at once. + A **`stop_engine`** action (only as `unload_model`) stops the engine, for one that serves a single model per process and so cannot unload it while running. A `launch_model` action on a stopped engine saves the choice and starts it. diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index 6708feb9f..ffb6332ae 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -96,12 +96,23 @@ clients see no difference; the cost measured on Qwen2.5-0.5B was about 2 ms per request. If the engine is not up, the gateway answers 503. A vLLM left running on the internal port by a crashed run is stopped and replaced on the next start. +Start brings vLLM up idle, with nothing loaded, like Ollama and llama.cpp start a +server: it takes about a second, the engine is ON and answers health checks, and +the model loads on the first request or a Load. The same is true after PAIR +restarts, when an engine that was ON is restored. A launch that cannot work, such +as a port already in use, is still refused at Start. Applying launch settings and +switching the model load it straight away, so a bad setting is found then and +rolled back. + After 10 minutes with no inference request (`front.idle_timeout_s`, 0 to never) the manager stops the `vllm serve` process, which frees the GPU, but keeps the gateway and the engine ON: `engine:status` reports `idle: true`, `/health` still answers 200, and `engine:models` still advertises the model it is set to serve with nothing loaded. Health checks, monitors and the model list do not count as -activity, and a request still running keeps the engine loaded. Eject +activity, and a request still running keeps the engine loaded. The limit is read +and changed with the `get_idle_timeout` and `set_idle_timeout` actions (seconds, +0 never); a change applies at once and is saved in the engine's override file. +Eject (`unload_model`) does the same on demand. `load_model` of the model it is set to serve loads it again. diff --git a/services/nvpair-engine-manager/actions.go b/services/nvpair-engine-manager/actions.go index 5734ada9d..90010c9bd 100644 --- a/services/nvpair-engine-manager/actions.go +++ b/services/nvpair-engine-manager/actions.go @@ -39,6 +39,9 @@ func (e *Executor) Action(ctx context.Context, engine, action string, params jso if act.StopEngine { return e.ejectByStopping(ctx, engine, params) } + if act.FrontIdle != "" { + return e.frontIdleAction(engine, act.FrontIdle, params) + } res, err := e.dispatchAction(ctx, st, engine, action, act, params) if err != nil { return nil, err diff --git a/services/nvpair-engine-manager/executor.go b/services/nvpair-engine-manager/executor.go index f8283cfc3..8f30c7bea 100644 --- a/services/nvpair-engine-manager/executor.go +++ b/services/nvpair-engine-manager/executor.go @@ -86,6 +86,10 @@ type engineState struct { // is kept so the engine goes on advertising it, without reading the launch // settings while a lifecycle operation holds them. idleModel string + // idleSecs is the idle limit chosen at run time, when idleSet; otherwise the + // manifest's applies. Atomic because the idle watcher reads it on every tick. + idleSet atomic.Bool + idleSecs atomic.Int64 } // Executor owns engine lifecycle for every engine known on this host. diff --git a/services/nvpair-engine-manager/front.go b/services/nvpair-engine-manager/front.go index badb740c9..ab87ad4d2 100644 --- a/services/nvpair-engine-manager/front.go +++ b/services/nvpair-engine-manager/front.go @@ -4,6 +4,7 @@ package main import ( + "bytes" "context" "encoding/json" "errors" @@ -15,6 +16,9 @@ import ( "net/http" "net/http/httputil" "net/url" + "os" + "path/filepath" + "runtime" "strconv" "strings" "sync" @@ -384,8 +388,12 @@ func (e *Executor) startFront(st *engineState, engine string, rt Runtime, host s return g, false, nil } -// frontIdleTimeout is the engine's current idle limit; zero means never. +// frontIdleTimeout is the engine's current idle limit; zero means never. A limit +// chosen at run time wins over the manifest's. func frontIdleTimeout(st *engineState) time.Duration { + if st.idleSet.Load() { + return time.Duration(st.idleSecs.Load()) * time.Second + } if front := st.plat.Runtime.Front; front != nil && front.IdleTimeoutS > 0 { return time.Duration(front.IdleTimeoutS) * time.Second } @@ -654,3 +662,119 @@ func (e *Executor) idleModelOf(engine string) string { defer st.mu.Unlock() return st.idleModel } + +// enterLazy brings an engine with a front up idle: the gateway is listening and +// answering, the engine is ON, and nothing is loaded until a request or a Load +// asks for the model. Only the cheap checks a start can make without loading +// run: the launch must resolve and pass its validation, and the ports must be +// free. +func (e *Executor) enterLazy(st *engineState, engine string, rt Runtime, vars map[string]string, port int) error { + st.mu.Lock() + binPath := st.binPath + st.mu.Unlock() + launch, err := resolveProcessLaunch(rt, binPath, vars) + if err != nil { + return err + } + if err := validateEffectiveLaunch(rt, launch, vars["host"], vars["port"]); err != nil { + return err + } + model, _ := e.servedModelLocked(engine, st) + g, _, err := e.startFront(st, engine, rt, vars["host"], port) + if err != nil { + return err + } + g.mu.Lock() + g.idle, g.up, g.model = true, false, model + g.mu.Unlock() + st.mu.Lock() + st.running, st.healthy, st.adopted = true, true, false + st.idle, st.idleModel = true, model + st.proc = nil + st.gen++ + st.mu.Unlock() + e.reporter.clear(startFailedID(engine)) + e.reporter.clear(exitedID(engine)) + e.reporter.clear(unhealthyID(engine)) + e.emitState(engine) + return nil +} + +// frontIdleAction reads or changes how long the engine may sit idle before it is +// unloaded. A change applies at once, is saved so it survives a restart, and 0 +// means never. +func (e *Executor) frontIdleAction(engine, mode string, params json.RawMessage) (json.RawMessage, error) { + st, err := e.state(engine) + if err != nil { + return nil, err + } + if st.plat.Runtime.Front == nil { + return nil, fmt.Errorf("engine %q has no idle timeout", engine) + } + if mode == "set" { + var request struct { + Seconds *int `json:"seconds"` + } + if err := json.Unmarshal(params, &request); err != nil || request.Seconds == nil { + return nil, errors.New(`params must be {"seconds": <0 to 86400>}`) + } + if *request.Seconds < 0 || *request.Seconds > 86400 { + return nil, fmt.Errorf("seconds must be between 0 and 86400, got %d", *request.Seconds) + } + if err := e.persistFrontIdle(engine, *request.Seconds); err != nil { + return nil, err + } + st.idleSecs.Store(int64(*request.Seconds)) + st.idleSet.Store(true) + } + return json.Marshal(map[string]any{"seconds": int(frontIdleTimeout(st) / time.Second)}) +} + +// persistFrontIdle saves the idle limit in the engine's override file, next to +// the port and launch the user has set there. +func (e *Executor) persistFrontIdle(engine string, seconds int) error { + if e.overrideDir == "" { + return errors.New("no config directory is available to save the idle timeout") + } + if err := os.MkdirAll(e.overrideDir, 0o700); err != nil { + return fmt.Errorf("create override dir: %w", err) + } + path := filepath.Join(e.overrideDir, engine+".json") + m := map[string]any{"engine": engine} + if existing, err := os.ReadFile(path); err == nil { + decoder := json.NewDecoder(bytes.NewReader(existing)) + decoder.UseNumber() // unrelated manifest numbers must not lose precision + m = nil + if err := decoder.Decode(&m); err != nil || m == nil { + return errors.New("override must be a JSON object") + } + if name, ok := m["engine"].(string); !ok || name != engine { + return fmt.Errorf("override engine does not match %q", engine) + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("read override: %w", err) + } + platforms, err := overrideObject(m, "platforms") + if err != nil { + return err + } + host := runtime.GOOS + "/" + runtime.GOARCH + platform, err := overrideObject(platforms, host) + if err != nil { + return err + } + hostRuntime, err := overrideObject(platform, "runtime") + if err != nil { + return err + } + front, err := overrideObject(hostRuntime, "front") + if err != nil { + return err + } + front["idle_timeout_s"] = seconds + hostRuntime["front"] = front + setOverrideObject(platform, "runtime", hostRuntime) + setOverrideObject(platforms, host, platform) + setOverrideObject(m, "platforms", platforms) + return writeJSONAtomic(path, m) +} diff --git a/services/nvpair-engine-manager/front_test.go b/services/nvpair-engine-manager/front_test.go index 35f00debb..ff271ce88 100644 --- a/services/nvpair-engine-manager/front_test.go +++ b/services/nvpair-engine-manager/front_test.go @@ -334,7 +334,7 @@ func statusCode(url string) int { func TestFrontedEngineIsReachedThroughItsPublicPort(t *testing.T) { e, public, backend := frontExecutor(t, 0) - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatalf("start: %v", err) } if got := statusCode(fmt.Sprintf("http://127.0.0.1:%d/api/tags", public)); got != http.StatusOK { @@ -358,7 +358,7 @@ func TestFrontedEngineIsReachedThroughItsPublicPort(t *testing.T) { } // A restart takes the public port straight back. - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatalf("restart: %v", err) } if got := statusCode(fmt.Sprintf("http://127.0.0.1:%d/api/tags", public)); got != http.StatusOK { @@ -368,7 +368,7 @@ func TestFrontedEngineIsReachedThroughItsPublicPort(t *testing.T) { func TestFrontedEngineGatewayGoesWithACrashedEngine(t *testing.T) { e, public, _ := frontExecutor(t, 0) - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatal(err) } _ = statusCode(fmt.Sprintf("http://127.0.0.1:%d/exit", public)) // the engine dies @@ -392,7 +392,7 @@ func TestFrontedEngineRefusesABackendPortSomethingElseHolds(t *testing.T) { } defer squatter.Close() - err = e.Start(context.Background(), "fronted") + err = e.StartWith(context.Background(), "fronted", startOpts{Eager: true}) if err == nil || !strings.Contains(err.Error(), "in use by another program") { t.Fatalf("err = %v, want the occupied internal port reported", err) } @@ -459,7 +459,7 @@ func TestGatewayIdleAccounting(t *testing.T) { func TestIdleEngineIsUnloadedAfterTheTimeout(t *testing.T) { e, public, backend := frontExecutor(t, 1) - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatal(err) } if !up(public) { @@ -491,7 +491,7 @@ func TestIdleEngineIsUnloadedAfterTheTimeout(t *testing.T) { // as Ollama and llama.cpp do. func TestARequestLoadsAnUnloadedEngineAndIsAnswered(t *testing.T) { e, public, backend := frontExecutor(t, 1) - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatal(err) } for cycle := 0; cycle < 2; cycle++ { @@ -522,7 +522,7 @@ func TestRequestsThatArriveTogetherShareOneLoad(t *testing.T) { t.Setenv("FAKE_START_LOG", log) t.Setenv("FAKE_START_DELAY", "700ms") e, public, backend := frontExecutor(t, 1) - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatal(err) } eventually(t, "the idle unload", 10*time.Second, func() bool { return !up(backend) }) @@ -551,7 +551,7 @@ func TestRequestsThatArriveTogetherShareOneLoad(t *testing.T) { func TestAClientLeavingDoesNotAbandonTheLoad(t *testing.T) { t.Setenv("FAKE_START_DELAY", "1500ms") e, public, backend := frontExecutor(t, 1) - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatal(err) } eventually(t, "the idle unload", 15*time.Second, func() bool { return !up(backend) }) @@ -570,7 +570,7 @@ func TestAClientLeavingDoesNotAbandonTheLoad(t *testing.T) { func TestARequestDuringALoadFromEjectWaitsForIt(t *testing.T) { t.Setenv("FAKE_START_DELAY", "1s") e, public, backend := frontExecutor(t, 0) - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatal(err) } if _, err := e.Action(context.Background(), "fronted", "unload_model", []byte(`{}`)); err != nil { @@ -592,7 +592,7 @@ func TestARequestDuringALoadFromEjectWaitsForIt(t *testing.T) { func TestAFailedLoadIsReportedToTheRequestAndRetriedByTheNext(t *testing.T) { e, public, backend := frontExecutor(t, 1) - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatal(err) } eventually(t, "the idle unload", 10*time.Second, func() bool { return !up(backend) }) @@ -620,7 +620,7 @@ func TestAFailedLoadIsReportedToTheRequestAndRetriedByTheNext(t *testing.T) { func TestInferenceKeepsAnEngineLoadedButMonitoringDoesNot(t *testing.T) { e, public, backend := frontExecutor(t, 1) - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatal(err) } // Requests every 300 ms against a 1 s limit: never idle. @@ -652,7 +652,7 @@ func TestInferenceKeepsAnEngineLoadedButMonitoringDoesNot(t *testing.T) { func TestARequestInFlightKeepsTheEngineLoaded(t *testing.T) { e, public, backend := frontExecutor(t, 1) - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatal(err) } done := make(chan int, 1) @@ -668,7 +668,7 @@ func TestARequestInFlightKeepsTheEngineLoaded(t *testing.T) { func TestZeroTimeoutNeverUnloads(t *testing.T) { e, _, backend := frontExecutor(t, 0) - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatal(err) } time.Sleep(1500 * time.Millisecond) @@ -679,7 +679,7 @@ func TestZeroTimeoutNeverUnloads(t *testing.T) { func TestStoppingAnIdleEngineStopsEverything(t *testing.T) { e, public, backend := frontExecutor(t, 1) - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatal(err) } eventually(t, "the idle unload", 10*time.Second, func() bool { return !up(backend) }) @@ -692,14 +692,14 @@ func TestStoppingAnIdleEngineStopsEverything(t *testing.T) { if got := statusCode(fmt.Sprintf("http://127.0.0.1:%d/health", public)); got != 0 { t.Fatalf("the gateway outlived the stop (%d)", got) } - if err := e.Start(context.Background(), "fronted"); err != nil || !up(public) { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil || !up(public) { t.Fatalf("starting after a stop from idle: %v", err) } } func TestWakeLoadsTheEngineBackBehindTheSameGateway(t *testing.T) { e, public, backend := frontExecutor(t, 1) - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatal(err) } for cycle := 0; cycle < 2; cycle++ { @@ -723,7 +723,7 @@ func TestWakeLoadsTheEngineBackBehindTheSameGateway(t *testing.T) { func TestFailedWakeLeavesTheEngineIdle(t *testing.T) { e, public, backend := frontExecutor(t, 1) - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatal(err) } eventually(t, "the idle unload", 10*time.Second, func() bool { return !up(backend) }) @@ -749,7 +749,7 @@ func TestFailedWakeLeavesTheEngineIdle(t *testing.T) { func TestEjectUnloadsButKeepsTheEngineOn(t *testing.T) { e, public, backend := frontExecutor(t, 0) - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatal(err) } raw, err := e.Action(context.Background(), "fronted", "unload_model", []byte(`{"model":"org/m"}`)) @@ -780,7 +780,7 @@ func TestEjectUnloadsButKeepsTheEngineOn(t *testing.T) { func TestIdleEngineStillAdvertisesItsModel(t *testing.T) { e, _, backend := frontExecutor(t, 0) - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatal(err) } res := e.ModelsResult(context.Background()) @@ -808,7 +808,7 @@ func TestIdleEngineStillAdvertisesItsModel(t *testing.T) { func TestTheModelStaysAdvertisedThroughAnUnloadAndALoad(t *testing.T) { t.Setenv("FAKE_START_DELAY", "800ms") e, public, backend := frontExecutor(t, 0) - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatal(err) } @@ -852,7 +852,7 @@ func TestTheModelStaysAdvertisedThroughAnUnloadAndALoad(t *testing.T) { func TestTheModelListIsAnsweredWhileUnloadedAndWhileLoading(t *testing.T) { t.Setenv("FAKE_START_DELAY", "1s") e, public, backend := frontExecutor(t, 0) - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatal(err) } if _, err := e.Action(context.Background(), "fronted", "unload_model", []byte(`{}`)); err != nil { @@ -888,7 +888,7 @@ func TestTheModelListIsAnsweredWhileUnloadedAndWhileLoading(t *testing.T) { func TestNoHealthWarningWhileUnloadedOrLoading(t *testing.T) { t.Setenv("FAKE_START_DELAY", "2500ms") // several health ticks long e, public, backend := frontExecutor(t, 0) - if err := e.Start(context.Background(), "fronted"); err != nil { + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { t.Fatal(err) } @@ -932,3 +932,150 @@ func TestNoHealthWarningWhileUnloadedOrLoading(t *testing.T) { t.Fatalf("status = %+v, want healthy once loaded", st) } } + +// Start on an engine with a front brings it up with nothing loaded, as Ollama and +// llama.cpp start a server: the engine is ON and answers health checks, and the +// model loads when a request or a Load asks for it. +func TestStartIsLazy(t *testing.T) { + log := filepath.Join(t.TempDir(), "starts.log") + t.Setenv("FAKE_START_LOG", log) + e, public, backend := frontExecutor(t, 0) + start := time.Now() + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + if time.Since(start) > 3*time.Second { + t.Fatalf("a lazy start took %s", time.Since(start)) + } + if startCount(t, log) != 0 || up(backend) { + t.Fatal("a lazy start spawned the engine") + } + st, _ := e.Status("fronted") + if !st.Running || !st.Healthy || !st.Idle || st.Port != public { + t.Fatalf("status = %+v, want ON, healthy and idle on the public port", st) + } + if got := statusCode(fmt.Sprintf("http://127.0.0.1:%d/health", public)); got != http.StatusOK { + t.Fatalf("health = %d", got) + } + if res := e.ModelsResult(context.Background()); len(res.ByEngine["fronted"]) != 1 || res.ByEngine["fronted"][0] != "org/m" { + t.Fatalf("an idle engine advertises the model it serves: %+v", res) + } + if got := post(fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", public)); got != http.StatusOK || !up(backend) { + t.Fatalf("the first request must load the engine: %d", got) + } + if got := startCount(t, log); got != 1 { + t.Fatalf("starts = %d, want 1", got) + } +} + +func TestStopAfterALazyStartStopsAndStaysOff(t *testing.T) { + e, public, backend := frontExecutor(t, 0) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + if err := e.Stop("fronted"); err != nil { + t.Fatal(err) + } + if got := statusCode(fmt.Sprintf("http://127.0.0.1:%d/health", public)); got != 0 { + t.Fatalf("the gateway outlived Stop (%d)", got) + } + if st, _ := e.Status("fronted"); st.Running || st.Idle { + t.Fatalf("status = %+v", st) + } + if enabled, known, _ := e.desired.get("fronted"); !known || enabled { + t.Fatalf("Stop must save OFF (enabled=%v known=%v)", enabled, known) + } + if up(backend) { + t.Fatal("the engine is running") + } +} + +// After a restart of PAIR the engine comes back ON but idle, like the others, +// which start a server with nothing loaded. +func TestRestoreBringsAnEngineBackIdle(t *testing.T) { + log := filepath.Join(t.TempDir(), "starts.log") + t.Setenv("FAKE_START_LOG", log) + e, public, backend := frontExecutor(t, 0) + if err := e.setDesiredEnabled("fronted", true); err != nil { + t.Fatal(err) + } + if err := e.RestoreEnabled(context.Background()); err != nil { + t.Fatal(err) + } + st, _ := e.Status("fronted") + if !st.Running || !st.Idle || up(backend) || startCount(t, log) != 0 { + t.Fatalf("status = %+v: a restore must not load the model", st) + } + if got := post(fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", public)); got != http.StatusOK { + t.Fatalf("the first request after a restore answered %d", got) + } +} + +func TestLoadOnALazilyStartedEngineLoadsIt(t *testing.T) { + e, _, backend := frontExecutor(t, 0) + if err := e.Start(context.Background(), "fronted"); err != nil { + t.Fatal(err) + } + if _, err := e.Action(context.Background(), "fronted", "load_model", []byte(`{"model":"org/m"}`)); err != nil { + t.Fatal(err) + } + if !up(backend) { + t.Fatal("Load did not load the model") + } + if st, _ := e.Status("fronted"); st.Idle { + t.Fatalf("status = %+v, want loaded", st) + } +} + +// A launch that cannot work is reported at Start, even though nothing is loaded. +func TestLazyStartStillChecksTheLaunch(t *testing.T) { + e, public, _ := frontExecutor(t, 0) + squatter, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", public)) + if err != nil { + t.Skipf("port taken: %v", err) + } + defer squatter.Close() + if err := e.Start(context.Background(), "fronted"); err == nil { + t.Fatal("a start onto an occupied port succeeded") + } +} + +func TestIdleTimeoutCanBeReadAndChangedAtRunTime(t *testing.T) { + e, _, backend := frontExecutor(t, 0) + m, _ := e.reg.Get("fronted") + m.Actions["get_idle_timeout"] = Action{FrontIdle: "get"} + m.Actions["set_idle_timeout"] = Action{FrontIdle: "set"} + if err := m.Validate(); err != nil { + t.Fatal(err) + } + call := func(action, params string) (string, error) { + raw, err := e.Action(context.Background(), "fronted", action, []byte(params)) + return string(raw), err + } + if raw, err := call("get_idle_timeout", `{}`); err != nil || raw != `{"seconds":0}` { + t.Fatalf("get = %s %v, want the manifest's never", raw, err) + } + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { + t.Fatal(err) + } + // Setting it unloads an engine that has already been idle that long, and + // applies without a restart. + if raw, err := call("set_idle_timeout", `{"seconds":1}`); err != nil || raw != `{"seconds":1}` { + t.Fatalf("set = %s %v", raw, err) + } + eventually(t, "the unload under the new limit", 10*time.Second, func() bool { return !up(backend) }) + + for _, bad := range []string{`{}`, `{"seconds":-1}`, `{"seconds":86401}`, `{"seconds":"soon"}`, `null`} { + if _, err := call("set_idle_timeout", bad); err == nil { + t.Errorf("%s was accepted", bad) + } + } + // It was saved where the override file keeps the rest of the user's choices. + data, err := os.ReadFile(filepath.Join(e.overrideDir, "fronted.json")) + if err != nil || !strings.Contains(string(data), `"idle_timeout_s": 1`) { + t.Fatalf("override = %s %v", data, err) + } + if raw, _ := call("set_idle_timeout", `{"seconds":0}`); raw != `{"seconds":0}` { + t.Fatalf("never = %s", raw) + } +} diff --git a/services/nvpair-engine-manager/launchmodel.go b/services/nvpair-engine-manager/launchmodel.go index 36917b674..d29f2de96 100644 --- a/services/nvpair-engine-manager/launchmodel.go +++ b/services/nvpair-engine-manager/launchmodel.go @@ -81,7 +81,7 @@ func (e *Executor) switchLaunchModel(ctx context.Context, engine string, spec *A return nil, err } } - if err := e.Start(ctx, engine); err != nil { + if err := e.StartWith(ctx, engine, startOpts{Eager: true}); err != nil { if changed { _, _ = e.ConfigureLaunch(ctx, settings.Configure{ Engine: engine, diff --git a/services/nvpair-engine-manager/lifecycle.go b/services/nvpair-engine-manager/lifecycle.go index ce8e615fe..071ba3e84 100644 --- a/services/nvpair-engine-manager/lifecycle.go +++ b/services/nvpair-engine-manager/lifecycle.go @@ -67,6 +67,11 @@ type startOpts struct { // Wake starts an engine that is marked running but has no process, one that // was unloaded for being idle, instead of treating it as already started. Wake bool + // Eager makes an engine that loads its model lazily load it now. A start + // brings such an engine up idle, like Ollama and llama.cpp start a server with + // nothing loaded; a caller that has to prove the launch works, or is asking for + // the model, sets this. + Eager bool } // effectiveBind picks the listen address substituted as {host}: a per-call @@ -190,6 +195,10 @@ func (e *Executor) doStart(ctx context.Context, st *engineState, engine string, st.stopping = false st.mu.Unlock() + if rt.Front != nil && !opts.Wake && !opts.Eager { + return e.enterLazy(st, engine, rt, vars, port) + } + // An engine with a front is reached through a PAIR gateway on its public port // while the process itself listens elsewhere. The gateway is up before the // process so that readiness probes on the public port see it; it answers 503 diff --git a/services/nvpair-engine-manager/manifests/vllm.json b/services/nvpair-engine-manager/manifests/vllm.json index d87b5993a..a67fbb45d 100644 --- a/services/nvpair-engine-manager/manifests/vllm.json +++ b/services/nvpair-engine-manager/manifests/vllm.json @@ -66,6 +66,14 @@ "description": "Serve a different model (params: {\"model\": \"\"}). vLLM serves one model per process, so this saves the model into the launch arguments and restarts the engine on it; if the new model does not start, the previous one is restored.", "launch_model": { "flag": "--model" } }, + "get_idle_timeout": { + "description": "Read how many seconds vLLM may sit idle before its model is unloaded (0 means never).", + "front_idle": "get" + }, + "set_idle_timeout": { + "description": "Set how many seconds vLLM may sit idle before its model is unloaded (params: {\"seconds\": 0 to 86400}, 0 means never). Applies at once and is kept across restarts.", + "front_idle": "set" + }, "unload_model": { "description": "Eject the model vLLM is serving (params: {\"model\": \"\"}). vLLM cannot drop its model and keep running, so this stops the engine, which frees all of its GPU memory. Load starts it again.", "stop_engine": true diff --git a/services/nvpair-engine-manager/registry.go b/services/nvpair-engine-manager/registry.go index 4df3835de..9a3ac820a 100644 --- a/services/nvpair-engine-manager/registry.go +++ b/services/nvpair-engine-manager/registry.go @@ -298,6 +298,9 @@ type Action struct { // and keep running, but stopping it frees all of its GPU memory. Only // unload_model may set it. StopEngine bool `json:"stop_engine,omitempty"` + // FrontIdle reads ("get") or sets ("set") how long an engine behind a gateway + // may sit idle before it is unloaded. + FrontIdle string `json:"front_idle,omitempty"` // ProgressProtocol selects a narrowly defined streaming adapter for an // asynchronous pull HTTP action. Empty keeps the ordinary response-stream // behavior; named protocols are validated so a typo cannot silently fall @@ -1032,8 +1035,14 @@ func (a *Action) validate(name string) error { if a.StopEngine { kinds++ } + if a.FrontIdle != "" { + kinds++ + if a.FrontIdle != "get" && a.FrontIdle != "set" { + return fmt.Errorf("action %q: front_idle must be \"get\" or \"set\"", name) + } + } if kinds != 1 { - return fmt.Errorf("action %q: exactly one of http, cmd, remove_path, hf_cache, launch_model, hf_pull, or stop_engine is required", name) + return fmt.Errorf("action %q: exactly one of http, cmd, remove_path, hf_cache, launch_model, hf_pull, stop_engine, or front_idle is required", name) } if a.StopEngine && name != "unload_model" { return fmt.Errorf("action %q: stop_engine belongs to unload_model", name) diff --git a/services/nvpair-engine-manager/registry_test.go b/services/nvpair-engine-manager/registry_test.go index 37973b21c..1ec94435c 100644 --- a/services/nvpair-engine-manager/registry_test.go +++ b/services/nvpair-engine-manager/registry_test.go @@ -317,7 +317,7 @@ func TestValidate_RejectsInvalidManifests(t *testing.T) { }, "json_match.value is required"}, {"action without http or cmd", func(m *Manifest) { m.Actions = map[string]Action{"x": {Description: "neither"}} - }, "exactly one of http, cmd, remove_path, hf_cache, launch_model, hf_pull, or stop_engine"}, + }, "exactly one of http, cmd, remove_path, hf_cache, launch_model, hf_pull, stop_engine, or front_idle"}, {"action missing method", func(m *Manifest) { m.Actions = map[string]Action{"x": {HTTP: &ActionHTTP{Path: "/p"}}} }, "http.method and http.path"}, diff --git a/services/nvpair-engine-manager/settings.go b/services/nvpair-engine-manager/settings.go index 568915107..66b75587b 100644 --- a/services/nvpair-engine-manager/settings.go +++ b/services/nvpair-engine-manager/settings.go @@ -441,7 +441,7 @@ func (e *Executor) ConfigureLaunch(ctx context.Context, p settings.Configure, re resume = false } if (changed && old.Running) || resume { - if err := e.doStart(ctx, st, p.Engine, startOpts{RequireOwned: true}); err != nil { + if err := e.doStart(ctx, st, p.Engine, startOpts{RequireOwned: true, Eager: true}); err != nil { e.emitState(p.Engine) return e.launchStateLocked(p.Engine, st), err } From 6373a807368dac9338092f67bd95e354516337a9 Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 21 Oct 2026 20:12:46 +0530 Subject: [PATCH 20/35] feat(desktop): show an idle vLLM and let its idle limit be chosen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status of an engine that is on with nothing loaded now carries idle, and the card reads 'Idle · loads on next request'. For vLLM the model row's expiry control becomes the idle limit (1 minute to 1 hour, or stays loaded), read from and sent to the engine manager as seconds through get_idle_timeout and set_idle_timeout; a limit that is not offered, such as one set by editing the override file, shows as the nearest one. A new engine-level list marks which engines use the expiry this way, and a test keeps it and the capability in step. Update the docs. Signed-off-by: Tushar Khinvasara --- desktop/docs/frontend-api.md | 7 +- desktop/docs/services-parity.md | 9 +- .../electron/service-bridge/empty-handlers.ts | 20 ++- .../electron/service-bridge/modular-state.ts | 45 +++++- .../service-bridge/modular-supervisor.ts | 49 +++++- desktop/src/shared/constants/engines.ts | 15 +- desktop/src/shared/types/engines.ts | 5 + desktop/src/shared/utils/engines.ts | 31 ++++ .../components/BackendRow/BackendHeader.tsx | 6 + .../ui/components/ModelManager/ModelRow.tsx | 2 + .../ModelManager/ModelRowSecondaryRow.tsx | 28 ++-- .../NodeList/NodeEngineSettings.tsx | 1 + .../src/ui/constants/engine-capabilities.ts | 10 +- desktop/src/ui/constants/model-row.ts | 3 + desktop/src/ui/types/engine-info.ts | 2 + desktop/src/ui/types/engine-manifest.ts | 8 + .../tests/modular/engine-command-load.test.ts | 28 +++- desktop/tests/modular/engine-identity.test.ts | 15 +- desktop/tests/modular/idle-timeout.test.ts | 148 ++++++++++++++++++ docs/engine-lifecycle.mdx | 12 +- 20 files changed, 418 insertions(+), 26 deletions(-) create mode 100644 desktop/tests/modular/idle-timeout.test.ts diff --git a/desktop/docs/frontend-api.md b/desktop/docs/frontend-api.md index 401d92bae..ca3f031e4 100644 --- a/desktop/docs/frontend-api.md +++ b/desktop/docs/frontend-api.md @@ -100,8 +100,11 @@ They do not imply a WebSocket connection. Browser clients are not supported. became unreachable, so its cached snapshot is stale. `EngineType` currently includes `ollama`, `lm-studio`, `llama-cpp`, and `vllm`. -vLLM serves one model per process, so `unloadModel` (Eject) stops the engine, -which frees its GPU memory, and `loadModel` on a stopped engine starts it. Its model +vLLM serves one model per process, so `unloadModel` (Eject) stops its process, +which frees its GPU memory while the engine stays on, and `loadModel` loads it +again. An engine status with `idle: true` is on with nothing loaded, and loads on +the next request; `setModelExpiry` on vLLM sets how long it may sit idle before it +unloads (1 minute to 1 hour, or never). Its model list is the Hugging Face cache, which stays available while the engine is stopped, `deleteModel` removes a cached model (not the one it is set to serve), `pullModel` downloads a Hugging Face repo into that cache (no running engine needed), and `loadModel` on a cached model switches diff --git a/desktop/docs/services-parity.md b/desktop/docs/services-parity.md index 1f209db42..19dc023a4 100644 --- a/desktop/docs/services-parity.md +++ b/desktop/docs/services-parity.md @@ -189,8 +189,13 @@ The bundled `vllm` manifest is Linux only. It installs a pinned `vllm` wheel into a private virtualenv through a checksum-pinned `uv` bootstrap, or adopts a `vllm` already installed at a detected path, then runs `vllm serve` on `8001` behind the proxy facade on `8000`. vLLM serves one model per process and has no -load or unload API, so Eject (`unload_model`, a `stop_engine` action) stops the -engine to free the GPU and Load on a stopped engine starts it; its model list +load or unload API, so the engine manager puts a gateway on its port (`front`): +Start and the restore after a restart bring it up idle with nothing loaded, the +first request or a Load loads the model, it unloads after an idle limit (ten +minutes by default, `get_idle_timeout` and `set_idle_timeout`, shown as the +model row's expiry control), and Eject (`unload_model`, a `stop_engine` action) +unloads it on demand while the engine stays on; the card shows an idle engine +as "Idle · loads on next request" (`idle` on the engine status). Its model list (`list_models` on disk) is kept while stopped. It downloads and deletes through the shared Hugging Face cache (`pull_model` is an `hf_pull` action, `delete_model` an `hf_cache` delete that refuses the model it is set to serve), diff --git a/desktop/src/electron/service-bridge/empty-handlers.ts b/desktop/src/electron/service-bridge/empty-handlers.ts index 9a59edfa1..1cef8fb07 100644 --- a/desktop/src/electron/service-bridge/empty-handlers.ts +++ b/desktop/src/electron/service-bridge/empty-handlers.ts @@ -12,7 +12,8 @@ import { MODULAR_ENGINE_LIFECYCLE_CALL_TIMEOUT_MS } from '@/shared/constants/modular-runtime' import getErrorString from '@/shared/utils/get-error-string' -import { engineManagerName } from '@/shared/utils/engines' +import { engineManagerName, expiryToIdleSeconds, isModelExpiry } from '@/shared/utils/engines' +import { EnginesWithIdleTimeout } from '@/shared/constants/engines' import { getEngineHubModels } from '@/electron/service-bridge/model-catalog' import { getModularSupervisor } from './modular-supervisor' import { @@ -322,6 +323,23 @@ function routeEngineManagerCommand(payload: WsInvokeRequest<'engine:command'>): } } break + case 'setModelExpiry': { + // For an engine whose expiry is an idle limit, the choice is how long it + // may sit idle before it unloads its model, and the engine manager keeps it. + const seconds = isModelExpiry(payload.expiry) + ? expiryToIdleSeconds(payload.expiry) + : undefined + if (EnginesWithIdleTimeout.includes(payload.engineType) && seconds !== undefined) { + void supervisor.setIdleTimeout(engine, payload.engineType, seconds) + } else { + supervisor.reportError( + `${payload.command} is not supported for this engine.`, + 'warning', + `engine-cmd:${payload.command}` + ) + } + break + } case 'unloadModel': if (payload.model) { const unloadParams: JsonObject = diff --git a/desktop/src/electron/service-bridge/modular-state.ts b/desktop/src/electron/service-bridge/modular-state.ts index db48f564c..89ba2e526 100644 --- a/desktop/src/electron/service-bridge/modular-state.ts +++ b/desktop/src/electron/service-bridge/modular-state.ts @@ -8,6 +8,7 @@ import type { EngineProgress, EngineStatusData, EngineType, + ModelExpiry, ModelItem } from '@/shared/types/engines' import type { LogEntry, LogPage } from '@/shared/types/log' @@ -27,6 +28,7 @@ import { emptyEngineStatus, engineManagerName, engineTypeFromManagerName, + idleSecondsToExpiry, isEngineType } from '@/shared/utils/engines' import { engineProgressKey } from '@/shared/utils/engine-progress' @@ -578,7 +580,7 @@ function sameTelemetry( ) } -function modelItem(name: string, loaded = false): ModelItem { +function modelItem(name: string, loaded = false, expiry: ModelExpiry = '10m'): ModelItem { return { name, size: 0, @@ -590,7 +592,7 @@ function modelItem(name: string, loaded = false): ModelItem { digest: '', sizeVram: null, expiresAt: null, - expiry: '10m', + expiry, capabilities: [] } } @@ -904,8 +906,13 @@ class ModularBridgeState { */ private engineManagerFacts = new Map< EngineType, - { installed: boolean; running: boolean; port: number } + { installed: boolean; running: boolean; port: number; idle: boolean } >() + /** + * How long an engine whose expiry is an idle limit may sit idle before it + * unloads its model, in seconds (0 is never), as the engine manager reports it. + */ + private idleSeconds = new Map() /** * Local model lists pulled from `nvpair-engine-manager`'s `list_models` action by * the supervisor. Cached so `engines:get-initial` can include them and survive @@ -1353,7 +1360,8 @@ class ModularBridgeState { this.engineManagerFacts.set(engineType, { installed: booleanValue(obj.installed), running: booleanValue(obj.running), - port: numberValue(obj.port) + port: numberValue(obj.port), + idle: booleanValue(obj.idle) }) // A fresh authoritative state is the resolution of whatever op was in // flight (start/stop done, install `done`+installed, uninstall removed). @@ -1363,6 +1371,31 @@ class ModularBridgeState { this.emitLocalEngineStatus(engineType) } + /** The expiry the models of an engine show: its idle limit, or the default. */ + private expiryFor(engineType: EngineType): ModelExpiry { + const seconds = this.idleSeconds.get(engineType) + return seconds === undefined ? '10m' : idleSecondsToExpiry(seconds) + } + + /** + * Record how long an engine may sit idle before it unloads its model, and show + * it on the engine's models. + */ + setEngineIdleSeconds(engineType: EngineType, seconds: number): void { + this.idleSeconds.set(engineType, seconds) + const nodeId = this.selfId + const current = this.localManagerModels.get(engineType) + if (!nodeId || !current) return + const expiry = this.expiryFor(engineType) + const items = current.map(item => ({ ...item, expiry })) + this.localManagerModels.set(engineType, items) + emitBridgePush('engines:state-changed', { + nodeId, + engineType, + models: { engineType, nodeId, models: items } + }) + } + /** * Mark an engine as switching model. While it is down for the restart the UI * shows it as starting, not stopped, and its model list is kept. The caller @@ -1881,7 +1914,8 @@ class ModularBridgeState { // row to idle. The loaded set is seeded by discovery self-enrichment and // kept fresh by {@link applyLocalLoadedModels}. const loaded = loadedNamesForEngine(nodeId ? this.nodes.get(nodeId) : undefined, engineType) - const items = modelNames.map(name => modelItem(name, loaded.has(name))) + const expiry = this.expiryFor(engineType) + const items = modelNames.map(name => modelItem(name, loaded.has(name), expiry)) this.localManagerModels.set(engineType, items) if (!nodeId) return emitBridgePush('engines:state-changed', { @@ -2096,6 +2130,7 @@ class ModularBridgeState { return { engineType, nodeId, + ...(facts.running && facts.idle ? { idle: true } : {}), processStatus: facts.running ? 'running' : this.modelSwitches.has(engineType) diff --git a/desktop/src/electron/service-bridge/modular-supervisor.ts b/desktop/src/electron/service-bridge/modular-supervisor.ts index e559fe704..208c14d0b 100644 --- a/desktop/src/electron/service-bridge/modular-supervisor.ts +++ b/desktop/src/electron/service-bridge/modular-supervisor.ts @@ -12,7 +12,7 @@ import { type JsonRpcNotification, type JsonValue } from './json-rpc-subprocess' -import { EnginesListingModelsWhenStopped } from '@/shared/constants/engines' +import { EnginesListingModelsWhenStopped, EnginesWithIdleTimeout } from '@/shared/constants/engines' import { createStructuredLogger } from '@/shared/utils/log' import getErrorString from '@/shared/utils/get-error-string' import { currentPlatform } from '@/shared/utils/platform' @@ -2014,6 +2014,9 @@ class ModularSupervisor { // broker call in that window. if (!this.hasProcess('broker')) return this.stoppedModelEngines.delete(engineType) + if (EnginesWithIdleTimeout.includes(engineType)) { + void this.refreshIdleTimeout(engine, engineType) + } const generation = this.beginModelRefresh(engineType) this.callProcess('broker', 'engine:action', { engine, @@ -2032,6 +2035,50 @@ class ModularSupervisor { }) } + /** + * Read how long an engine whose expiry is an idle limit may sit idle before it + * unloads its model, and show it on the engine's models. + */ + private async refreshIdleTimeout(engine: string, engineType: EngineType): Promise { + try { + const result = await this.callProcess('broker', 'engine:action', { + engine, + action: 'get_idle_timeout' + }) + const seconds = objectValue(result)?.seconds + if (typeof seconds === 'number') { + getModularBridgeState().setEngineIdleSeconds(engineType, seconds) + } + } catch (err) { + log.verbose({ + sublevel: 'engine-manager', + message: `get_idle_timeout for ${engine} failed: ${getErrorString(err)}` + }) + } + } + + /** Change how long an engine may sit idle before it unloads its model; 0 is never. */ + async setIdleTimeout(engine: string, engineType: EngineType, seconds: number): Promise { + try { + const result = await this.callProcess('broker', 'engine:action', { + engine, + action: 'set_idle_timeout', + params: { seconds } + }) + const applied = objectValue(result)?.seconds + getModularBridgeState().setEngineIdleSeconds( + engineType, + typeof applied === 'number' ? applied : seconds + ) + } catch (err) { + this.reportError( + `Failed to set the ${engine} idle timeout: ${getErrorString(err)}`, + 'error', + `engine-idle:${engine}` + ) + } + } + /** * Re-pull the local node's model list for a proxy engine when discovery * reports it changed (a pull/delete on an already-running engine emits no diff --git a/desktop/src/shared/constants/engines.ts b/desktop/src/shared/constants/engines.ts index 2c6973b29..5f0d05b94 100644 --- a/desktop/src/shared/constants/engines.ts +++ b/desktop/src/shared/constants/engines.ts @@ -18,6 +18,16 @@ export const EnabledEngineTypes: EngineType[] = ['ollama', 'lm-studio', 'llama-c */ export const EnginesListingModelsWhenStopped: readonly EngineType[] = ['vllm'] +/** + * Engines whose expiry setting is how long the engine may sit idle before it + * unloads its model, kept by the engine manager and not sent with each load. The + * `hasExpiry` capability must agree. + */ +export const EnginesWithIdleTimeout: readonly EngineType[] = ['vllm'] + +/** The idle limits offered, as expiry values. Idle limits are never shorter than a minute: reloading takes longer than that to pay off. */ +export const IdleTimeoutExpiries: readonly ModelExpiry[] = ['1m', '5m', '10m', '30m', '1h', '-1'] + /** * How `nvpair-engine-manager` spells each engine on the wire. This is the * single translation table — use `engineManagerName()` and @@ -55,14 +65,17 @@ export const EngineDefaultLinks: Record = { '0': 'Immediately', '1s': '1 second', '10s': '10 seconds', '1m': '1 minute', + '5m': '5 minutes', '10m': '10 minutes', + '30m': '30 minutes', + '1h': '1 hour', '-1': 'Never' } as const diff --git a/desktop/src/shared/types/engines.ts b/desktop/src/shared/types/engines.ts index 863c415c4..671ec734e 100644 --- a/desktop/src/shared/types/engines.ts +++ b/desktop/src/shared/types/engines.ts @@ -36,6 +36,11 @@ export interface EngineStatusData { engineType: EngineType nodeId: string processStatus: EngineProcessStatus + /** + * True for an engine that is on but has no model loaded, and loads it on the + * next request (vLLM, after it starts or has sat idle). Omitted otherwise. + */ + idle?: boolean /** * Configured bind port for the engine HTTP server when installed (same value when stopped vs running). * Null when not installed, or for an engine with no server port. diff --git a/desktop/src/shared/utils/engines.ts b/desktop/src/shared/utils/engines.ts index e8a6bd053..0bae364c8 100644 --- a/desktop/src/shared/utils/engines.ts +++ b/desktop/src/shared/utils/engines.ts @@ -43,6 +43,37 @@ export function isModelExpiry(v: string | undefined): v is ModelExpiry { return v !== undefined && MODEL_EXPIRY_SET.has(v) } +const IDLE_SECONDS: Partial> = { + '1m': 60, + '5m': 300, + '10m': 600, + '30m': 1800, + '1h': 3600 +} + +/** The idle limit in seconds an expiry value stands for; 0 is never. Undefined for a value that is not an idle limit. */ +export function expiryToIdleSeconds(expiry: ModelExpiry): number | undefined { + return expiry === '-1' ? 0 : IDLE_SECONDS[expiry] +} + +/** + * The offered expiry value closest to an idle limit in seconds, for display. A + * limit the settings never offer, such as one set by editing a file, shows as the + * nearest one. 0 is never. + */ +export function idleSecondsToExpiry(seconds: number): ModelExpiry { + if (seconds <= 0) return '-1' + let best: ModelExpiry = '10m' + let distance = Infinity + for (const [expiry, value] of Object.entries(IDLE_SECONDS)) { + if (isModelExpiry(expiry) && Math.abs(value - seconds) < distance) { + best = expiry + distance = Math.abs(value - seconds) + } + } + return best +} + /** Shallow placeholder when the service has not yet reported this node/engine pair. */ export function emptyEngineStatus(nodeId: string, engineType: EngineType): EngineStatusData { return { diff --git a/desktop/src/ui/components/BackendRow/BackendHeader.tsx b/desktop/src/ui/components/BackendRow/BackendHeader.tsx index d54bd6291..674427be9 100644 --- a/desktop/src/ui/components/BackendRow/BackendHeader.tsx +++ b/desktop/src/ui/components/BackendRow/BackendHeader.tsx @@ -101,6 +101,12 @@ export function BackendHeader({ {backend.displayName} + {backend.processStatus === 'running' && backend.idle && ( + + Idle · loads on next request + + )} + {isLocalNode && backend.proxyPort && diff --git a/desktop/src/ui/components/ModelManager/ModelRow.tsx b/desktop/src/ui/components/ModelManager/ModelRow.tsx index 80d77a7c9..29e374e86 100644 --- a/desktop/src/ui/components/ModelManager/ModelRow.tsx +++ b/desktop/src/ui/components/ModelManager/ModelRow.tsx @@ -108,6 +108,8 @@ function ModelRowInner({ progress={progress} busyLabel={busyLabel} hasExpiry={capabilities.hasExpiry} + expiryOptions={capabilities.expiryOptions} + expiryText={capabilities.expiryText} expiryDd={expiryDd} onExpiryChange={onExpiryChange} /> diff --git a/desktop/src/ui/components/ModelManager/ModelRowSecondaryRow.tsx b/desktop/src/ui/components/ModelManager/ModelRowSecondaryRow.tsx index 7bb20b14c..f15fd877d 100644 --- a/desktop/src/ui/components/ModelManager/ModelRowSecondaryRow.tsx +++ b/desktop/src/ui/components/ModelManager/ModelRowSecondaryRow.tsx @@ -17,6 +17,8 @@ export function ModelRowSecondaryRow({ progress, busyLabel, hasExpiry, + expiryOptions, + expiryText, expiryDd, onExpiryChange }: { @@ -25,18 +27,24 @@ export function ModelRowSecondaryRow({ /** Optimistic "working" label (load/eject/delete in flight) shown with a spinner. */ busyLabel?: string | null hasExpiry: boolean + /** The expiry values on offer; all of them when absent. */ + expiryOptions?: readonly ModelExpiry[] + /** How the current expiry reads; "Expires …" when absent. */ + expiryText?: (expiry: ModelExpiry) => string expiryDd: ModelRowDropdownControl onExpiryChange: (modelName: string, value: ModelExpiry) => void }) { const expiryItems: DropdownEntry[] = useMemo( () => - EXPIRY_OPTIONS.map(item => ({ - children: item.children, - onSelect: () => { - if (isModelExpiry(item.id)) onExpiryChange(model.name, item.id) - } - })), - [model.name, onExpiryChange] + EXPIRY_OPTIONS.filter(item => !expiryOptions || expiryOptions.includes(item.id)).map( + item => ({ + children: item.children, + onSelect: () => { + if (isModelExpiry(item.id)) onExpiryChange(model.name, item.id) + } + }) + ), + [model.name, onExpiryChange, expiryOptions] ) return ( @@ -94,9 +102,11 @@ export function ModelRowSecondaryRow({ > - {`Expires ${EXPIRY_SHORT_LABELS[model.expiry ?? '10m']}`} + {expiryText + ? expiryText(model.expiry ?? '10m') + : `Expires ${EXPIRY_SHORT_LABELS[model.expiry ?? '10m']}`} )} diff --git a/desktop/src/ui/components/NodeList/NodeEngineSettings.tsx b/desktop/src/ui/components/NodeList/NodeEngineSettings.tsx index 7581be681..10f81f3cf 100644 --- a/desktop/src/ui/components/NodeList/NodeEngineSettings.tsx +++ b/desktop/src/ui/components/NodeList/NodeEngineSettings.tsx @@ -92,6 +92,7 @@ export default function NodeEngineSettings({ nodeId }: { nodeId: string }) { displayName: EngineDisplayNames[type], source: 'detected', processStatus: status.processStatus, + idle: status.idle, port: status.enginePort, proxyPort: status.proxyPort, models: models.models, diff --git a/desktop/src/ui/constants/engine-capabilities.ts b/desktop/src/ui/constants/engine-capabilities.ts index cf92288f0..566d28438 100644 --- a/desktop/src/ui/constants/engine-capabilities.ts +++ b/desktop/src/ui/constants/engine-capabilities.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { EngineType } from '@/shared/types/engines' +import { IdleTimeoutExpiries } from '@/shared/constants/engines' +import { EXPIRY_SHORT_LABELS } from '@/ui/constants/model-row' import type { EngineCaps } from '@/ui/types/engine-manifest' export const EngineCapabilities: Record = { @@ -67,7 +69,13 @@ export const EngineCapabilities: Record = { } }, vllm: { - hasExpiry: false, + // The expiry is how long vLLM may sit idle before it unloads its model and + // frees the GPU. The engine manager keeps it; the next request loads the + // model again. + hasExpiry: true, + expiryOptions: IdleTimeoutExpiries, + expiryText: expiry => + expiry === '-1' ? 'Stays loaded' : `Unloads after ${EXPIRY_SHORT_LABELS[expiry]} idle`, // vLLM serves one model per process and cannot drop it while running, so // Eject stops the engine, which frees all of its GPU memory. Load starts // it again on the chosen model. diff --git a/desktop/src/ui/constants/model-row.ts b/desktop/src/ui/constants/model-row.ts index 54a2e339a..3b0561579 100644 --- a/desktop/src/ui/constants/model-row.ts +++ b/desktop/src/ui/constants/model-row.ts @@ -17,6 +17,9 @@ export const EXPIRY_SHORT_LABELS: Record = { '1s': '1s', '10s': '10s', '1m': '1m', + '5m': '5m', '10m': '10m', + '30m': '30m', + '1h': '1h', '-1': 'never' } diff --git a/desktop/src/ui/types/engine-info.ts b/desktop/src/ui/types/engine-info.ts index 3c4cb7807..62567116b 100644 --- a/desktop/src/ui/types/engine-info.ts +++ b/desktop/src/ui/types/engine-info.ts @@ -89,6 +89,8 @@ export interface BackendInfo { source: EngineSource /** Whether the inference process (e.g. ollama serve) is running */ processStatus: EngineProcessStatus + /** On with no model loaded; the model loads on the next request. */ + idle?: boolean /** Server port the inference process listens on (null if process is stopped) */ port: number | null /** PAIR proxy port -- always running when the backend is installed */ diff --git a/desktop/src/ui/types/engine-manifest.ts b/desktop/src/ui/types/engine-manifest.ts index 84cc201e0..d926f1dcf 100644 --- a/desktop/src/ui/types/engine-manifest.ts +++ b/desktop/src/ui/types/engine-manifest.ts @@ -7,6 +7,7 @@ * Replaces BackendManifest, BackendCapabilities, BackendDisplayNames, BackendDefaultLinks. */ import type { SupportedPlatform } from '@/shared/types/platform' +import type { ModelExpiry } from '@/shared/types/engines' export interface EngineHubConfig { /** Display label shown in the hub source selector (e.g. 'Ollama'). */ @@ -59,6 +60,13 @@ export interface EngineCaps { * model files go, so the backend refuses it and the UI does not offer it. */ deleteBlockedWhileLoaded?: boolean + /** + * The expiry values the engine offers, when not all of them. An engine whose + * expiry is an idle limit offers only limits that make sense for it. + */ + expiryOptions?: readonly ModelExpiry[] + /** How the engine's expiry reads next to a model, when "Expires …" does not. */ + expiryText?: (expiry: ModelExpiry) => string /** * If the engine has its own public model catalog (e.g. ollama.com), provide the * hub config here. Presence doubles as the truthy flag for the hub source selector. diff --git a/desktop/tests/modular/engine-command-load.test.ts b/desktop/tests/modular/engine-command-load.test.ts index 39aa4b242..4731770f8 100644 --- a/desktop/tests/modular/engine-command-load.test.ts +++ b/desktop/tests/modular/engine-command-load.test.ts @@ -13,7 +13,8 @@ const mocks = vi.hoisted(() => ({ hasProcess: vi.fn(() => true), sendProcess: vi.fn(), callProcess: vi.fn(), - reportError: vi.fn() + reportError: vi.fn(), + setIdleTimeout: vi.fn() } })) @@ -203,4 +204,29 @@ describe('local model load command', () => { expect.any(Function) ) }) + + describe('idle limit', () => { + const choose = (engineType: 'vllm' | 'ollama', expiry: string) => + handleServiceBridgeInvoke('engine:command', { + command: 'setModelExpiry', + engineType, + nodeId: 'local-node', + model: 'Qwen/Qwen3-8B', + expiry + }) + + it('sends the chosen limit for vLLM as seconds', async () => { + await choose('vllm', '30m') + expect(mocks.supervisor.setIdleTimeout).toHaveBeenCalledWith('vllm', 'vllm', 1800) + await choose('vllm', '-1') + expect(mocks.supervisor.setIdleTimeout).toHaveBeenLastCalledWith('vllm', 'vllm', 0) + }) + + it('refuses a value that is not an idle limit, and an engine without one', async () => { + await choose('vllm', '10s') + await choose('ollama', '10m') + expect(mocks.supervisor.setIdleTimeout).not.toHaveBeenCalled() + expect(mocks.supervisor.reportError).toHaveBeenCalledTimes(2) + }) + }) }) diff --git a/desktop/tests/modular/engine-identity.test.ts b/desktop/tests/modular/engine-identity.test.ts index ed758282e..277827cf9 100644 --- a/desktop/tests/modular/engine-identity.test.ts +++ b/desktop/tests/modular/engine-identity.test.ts @@ -8,7 +8,9 @@ import { EngineDisplayNames, EngineManagerNames, EnginesListingModelsWhenStopped, - EngineTypes + EnginesWithIdleTimeout, + EngineTypes, + IdleTimeoutExpiries } from '@/shared/constants/engines' import { engineManagerName, engineTypeFromManagerName, isEngineType } from '@/shared/utils/engines' import { EngineCapabilities } from '@/ui/constants/engine-capabilities' @@ -70,6 +72,17 @@ describe('engine identity', () => { } }) + it('keeps the expiry capability and the idle-limit list agreeing', () => { + for (const engineType of EngineTypes) { + expect(EngineCapabilities[engineType].hasExpiry).toBe( + EnginesWithIdleTimeout.includes(engineType) + ) + } + expect(EngineCapabilities.vllm.expiryOptions).toEqual(IdleTimeoutExpiries) + expect(EngineCapabilities.vllm.expiryText?.('-1')).toBe('Stays loaded') + expect(EngineCapabilities.vllm.expiryText?.('10m')).toBe('Unloads after 10m idle') + }) + it('offers Add model to every engine, vLLM included', () => { for (const engineType of EngineTypes) { expect(EngineCapabilities[engineType].hasPullModel).toBe(true) diff --git a/desktop/tests/modular/idle-timeout.test.ts b/desktop/tests/modular/idle-timeout.test.ts new file mode 100644 index 000000000..0acd25ca1 --- /dev/null +++ b/desktop/tests/modular/idle-timeout.test.ts @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { isPackaged: false, getAppPath: () => process.cwd() }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('@/electron/window', () => ({ createOverviewWindow: vi.fn() })) + +import { getModularBridgeState } from '@/electron/service-bridge/modular-state' +import { getModularSupervisor } from '@/electron/service-bridge/modular-supervisor' +import type { JsonValue } from '@/electron/service-bridge/json-rpc-subprocess' +import { IdleTimeoutExpiries } from '@/shared/constants/engines' +import { expiryToIdleSeconds, idleSecondsToExpiry } from '@/shared/utils/engines' + +const NODE = 'idle-timeout-self' + +function statusOf(engine: string, idle?: boolean, running = true) { + return { + engine, + installed: true, + running, + healthy: running, + port: 8001, + ...(idle ? { idle } : {}) + } +} + +function vllmStatus() { + return getModularBridgeState() + .getEngineInitialState() + .statuses.find(status => status.nodeId === NODE && status.engineType === 'vllm') +} + +function vllmExpiries() { + return getModularBridgeState() + .getEngineInitialState() + .models.find(models => models.nodeId === NODE && models.engineType === 'vllm') + ?.models.map(model => model.expiry) +} + +describe('idle limit values', () => { + it('turns every offered limit into seconds and back', () => { + for (const expiry of IdleTimeoutExpiries) { + const seconds = expiryToIdleSeconds(expiry) + expect(seconds).toBeDefined() + expect(idleSecondsToExpiry(seconds ?? -1)).toBe(expiry) + } + expect(expiryToIdleSeconds('10m')).toBe(600) + expect(expiryToIdleSeconds('-1')).toBe(0) + }) + + it('never turns a limit reloading cannot pay off into seconds', () => { + for (const expiry of ['0', '1s', '10s'] as const) { + expect(expiryToIdleSeconds(expiry)).toBeUndefined() + } + }) + + it('shows a limit that is not offered as the nearest one', () => { + expect(idleSecondsToExpiry(120)).toBe('1m') // set by editing a file + expect(idleSecondsToExpiry(1500)).toBe('30m') + expect(idleSecondsToExpiry(0)).toBe('-1') + }) +}) + +describe('an engine that is on with nothing loaded', () => { + beforeEach(() => { + const state = getModularBridgeState() + state.setSelfId(NODE) + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { + nodes: [{ hostUuid: NODE, name: 'idle-host', ipAddress: '127.0.0.1', port: 14318 }] + } + }) + }) + + it('is reported idle while it is running, and not otherwise', () => { + const state = getModularBridgeState() + state.applyEngineManagerStatus(statusOf('vllm', true)) + expect(vllmStatus()).toMatchObject({ processStatus: 'running', idle: true }) + + state.applyEngineManagerStatus(statusOf('vllm')) + expect(vllmStatus()?.processStatus).toBe('running') + expect(vllmStatus()).not.toHaveProperty('idle') + + // An engine that is not running is not idle, whatever it last reported. + state.applyEngineManagerStatus(statusOf('vllm', true, false)) + expect(vllmStatus()?.processStatus).toBe('stopped') + expect(vllmStatus()).not.toHaveProperty('idle') + }) + + it('never marks another engine idle', () => { + const state = getModularBridgeState() + state.applyEngineManagerStatus(statusOf('ollama')) + const ollama = state + .getEngineInitialState() + .statuses.find(status => status.nodeId === NODE && status.engineType === 'ollama') + expect(ollama).not.toHaveProperty('idle') + }) + + it('shows the idle limit on the models, before and after the list changes', () => { + const state = getModularBridgeState() + state.applyEngineManagerStatus(statusOf('vllm')) + state.setLocalEngineModels('vllm', ['Qwen/Qwen3-8B', 'Qwen/Qwen3-32B']) + expect(vllmExpiries()).toEqual(['10m', '10m']) + + state.setEngineIdleSeconds('vllm', 300) + expect(vllmExpiries()).toEqual(['5m', '5m']) + + state.setLocalEngineModels('vllm', ['Qwen/Qwen3-8B']) + expect(vllmExpiries()).toEqual(['5m']) + + state.setEngineIdleSeconds('vllm', 0) + expect(vllmExpiries()).toEqual(['-1']) + }) + + it('reads the idle limit and the model list from the engine manager', async () => { + const supervisor = getModularSupervisor() + const hasProcess = vi.spyOn(supervisor, 'hasProcess').mockReturnValue(true) + const call = vi + .spyOn(supervisor, 'callProcess') + .mockImplementation( + async (_name, _method, params): Promise => + typeof params === 'object' && + params && + 'action' in params && + params.action === 'get_idle_timeout' + ? { seconds: 1800 } + : { data: [{ id: 'Qwen/Qwen3-8B' }] } + ) + getModularBridgeState().applyEngineManagerStatus(statusOf('vllm', true)) + + supervisor.refreshManagedEngineModels({ engine: 'vllm', running: true }) + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(call).toHaveBeenCalledWith('broker', 'engine:action', { + engine: 'vllm', + action: 'get_idle_timeout' + }) + expect(vllmExpiries()).toEqual(['30m']) + call.mockRestore() + hasProcess.mockRestore() + }) +}) diff --git a/docs/engine-lifecycle.mdx b/docs/engine-lifecycle.mdx index 4a887d62d..d713463ba 100644 --- a/docs/engine-lifecycle.mdx +++ b/docs/engine-lifecycle.mdx @@ -102,8 +102,16 @@ When you install an engine, consider the following: new model does not start, the previous one is restored. **Add model** browses and searches Hugging Face (open safetensors chat models) and downloads the one you pick into the same cache, with a progress bar, even while vLLM is stopped; - it then appears in the list. **Delete** removes a cached model from disk, except the one vLLM is set to serve (switch first). **Eject** stops vLLM, which frees all of its GPU memory (vLLM cannot drop its model and keep running); the model list stays visible and **Load** starts it again on the model you pick. Other - nodes and the proxy see only the model vLLM is serving, so a request for a + it then appears in the list. **Delete** removes a cached model from disk, except the one vLLM is set to serve (switch first). **Start** brings vLLM up idle, with nothing loaded, as Ollama and llama.cpp + start a server: it takes about a second and the card reads "Idle · loads on next + request". The model loads when the first request arrives (the request waits for + it, about 25 seconds for an 8B model) or when you press **Load**, and the same + happens after PAIR restarts. After ten minutes without a request vLLM unloads + the model and frees the GPU, but stays on, and the next request loads it again; + the "Unloads after … idle" control on the model row changes the time, or keeps + it loaded. **Eject** does the same unload on demand, and **Stop** turns the + engine off. Other nodes and the proxy are told about the model vLLM is set to + serve, even while it is unloaded, so requests reach it; a request for a different cached model is refused until you switch to it. PAIR-managed llama.cpp models sleep after five minutes without inference work. From 056456dae67de3ed0120d10359479eebfc831ae8 Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 21 Oct 2026 21:22:27 +0530 Subject: [PATCH 21/35] feat: default the vLLM idle limit to five minutes Unload an idle vLLM after five minutes, not ten, and show five minutes as the limit until the engine manager reports the real one. Update the docs. Signed-off-by: Tushar Khinvasara --- desktop/docs/services-parity.md | 2 +- desktop/src/electron/service-bridge/modular-state.ts | 4 +++- desktop/src/shared/constants/engines.ts | 3 +++ desktop/tests/modular/idle-timeout.test.ts | 8 ++++---- docs/engine-lifecycle.mdx | 2 +- services/nvpair-engine-manager/MANIFEST.md | 2 +- services/nvpair-engine-manager/README.md | 2 +- services/nvpair-engine-manager/front_test.go | 2 +- services/nvpair-engine-manager/manifests/vllm.json | 2 +- 9 files changed, 16 insertions(+), 11 deletions(-) diff --git a/desktop/docs/services-parity.md b/desktop/docs/services-parity.md index 19dc023a4..41609b1bd 100644 --- a/desktop/docs/services-parity.md +++ b/desktop/docs/services-parity.md @@ -191,7 +191,7 @@ into a private virtualenv through a checksum-pinned `uv` bootstrap, or adopts a behind the proxy facade on `8000`. vLLM serves one model per process and has no load or unload API, so the engine manager puts a gateway on its port (`front`): Start and the restore after a restart bring it up idle with nothing loaded, the -first request or a Load loads the model, it unloads after an idle limit (ten +first request or a Load loads the model, it unloads after an idle limit (five minutes by default, `get_idle_timeout` and `set_idle_timeout`, shown as the model row's expiry control), and Eject (`unload_model`, a `stop_engine` action) unloads it on demand while the engine stays on; the card shows an idle engine diff --git a/desktop/src/electron/service-bridge/modular-state.ts b/desktop/src/electron/service-bridge/modular-state.ts index 89ba2e526..10c6d6414 100644 --- a/desktop/src/electron/service-bridge/modular-state.ts +++ b/desktop/src/electron/service-bridge/modular-state.ts @@ -24,6 +24,7 @@ import { MODULAR_SUPERSEDE_MIN_AGE_MS } from '@/shared/constants/modular-runtime' import { WorkloadStates } from '@/shared/constants/workloads' +import { DefaultIdleExpiry, EnginesWithIdleTimeout } from '@/shared/constants/engines' import { emptyEngineStatus, engineManagerName, @@ -1374,7 +1375,8 @@ class ModularBridgeState { /** The expiry the models of an engine show: its idle limit, or the default. */ private expiryFor(engineType: EngineType): ModelExpiry { const seconds = this.idleSeconds.get(engineType) - return seconds === undefined ? '10m' : idleSecondsToExpiry(seconds) + if (seconds !== undefined) return idleSecondsToExpiry(seconds) + return EnginesWithIdleTimeout.includes(engineType) ? DefaultIdleExpiry : '10m' } /** diff --git a/desktop/src/shared/constants/engines.ts b/desktop/src/shared/constants/engines.ts index 5f0d05b94..86fe3e552 100644 --- a/desktop/src/shared/constants/engines.ts +++ b/desktop/src/shared/constants/engines.ts @@ -28,6 +28,9 @@ export const EnginesWithIdleTimeout: readonly EngineType[] = ['vllm'] /** The idle limits offered, as expiry values. Idle limits are never shorter than a minute: reloading takes longer than that to pay off. */ export const IdleTimeoutExpiries: readonly ModelExpiry[] = ['1m', '5m', '10m', '30m', '1h', '-1'] +/** The idle limit shown until the engine manager reports the real one; it is the manifest's default. */ +export const DefaultIdleExpiry: ModelExpiry = '5m' + /** * How `nvpair-engine-manager` spells each engine on the wire. This is the * single translation table — use `engineManagerName()` and diff --git a/desktop/tests/modular/idle-timeout.test.ts b/desktop/tests/modular/idle-timeout.test.ts index 0acd25ca1..63139fc85 100644 --- a/desktop/tests/modular/idle-timeout.test.ts +++ b/desktop/tests/modular/idle-timeout.test.ts @@ -106,13 +106,13 @@ describe('an engine that is on with nothing loaded', () => { const state = getModularBridgeState() state.applyEngineManagerStatus(statusOf('vllm')) state.setLocalEngineModels('vllm', ['Qwen/Qwen3-8B', 'Qwen/Qwen3-32B']) - expect(vllmExpiries()).toEqual(['10m', '10m']) + expect(vllmExpiries()).toEqual(['5m', '5m']) // the default, until the limit is read - state.setEngineIdleSeconds('vllm', 300) - expect(vllmExpiries()).toEqual(['5m', '5m']) + state.setEngineIdleSeconds('vllm', 1800) + expect(vllmExpiries()).toEqual(['30m', '30m']) state.setLocalEngineModels('vllm', ['Qwen/Qwen3-8B']) - expect(vllmExpiries()).toEqual(['5m']) + expect(vllmExpiries()).toEqual(['30m']) state.setEngineIdleSeconds('vllm', 0) expect(vllmExpiries()).toEqual(['-1']) diff --git a/docs/engine-lifecycle.mdx b/docs/engine-lifecycle.mdx index d713463ba..50cf23aff 100644 --- a/docs/engine-lifecycle.mdx +++ b/docs/engine-lifecycle.mdx @@ -106,7 +106,7 @@ When you install an engine, consider the following: start a server: it takes about a second and the card reads "Idle · loads on next request". The model loads when the first request arrives (the request waits for it, about 25 seconds for an 8B model) or when you press **Load**, and the same - happens after PAIR restarts. After ten minutes without a request vLLM unloads + happens after PAIR restarts. After five minutes without a request vLLM unloads the model and frees the GPU, but stays on, and the next request loads it again; the "Unloads after … idle" control on the model row changes the time, or keeps it loaded. **Eject** does the same unload on demand, and **Stop** turns the diff --git a/services/nvpair-engine-manager/MANIFEST.md b/services/nvpair-engine-manager/MANIFEST.md index e99500687..eade45bad 100644 --- a/services/nvpair-engine-manager/MANIFEST.md +++ b/services/nvpair-engine-manager/MANIFEST.md @@ -353,7 +353,7 @@ port at that offset (8001 becomes 18001). Clients, the proxy and probes keep usi the public port; the gateway forwards every request unchanged, streams responses as they arrive, stops the engine's work when the client leaves, and answers 503 until the engine is ready. The launch a user sees and edits keeps the public -port: only the spawned process is repointed. `idle_timeout_s` (default 600 for +port: only the spawned process is repointed. `idle_timeout_s` (default 300 for vLLM, 0 never) unloads the engine after that long with no inference request: its process is stopped and its GPU memory freed, while the gateway, and the engine's ON state, stay. Only requests that change something count as activity diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index ffb6332ae..1ea956da1 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -104,7 +104,7 @@ as a port already in use, is still refused at Start. Applying launch settings an switching the model load it straight away, so a bad setting is found then and rolled back. -After 10 minutes with no inference request (`front.idle_timeout_s`, 0 to never) +After 5 minutes with no inference request (`front.idle_timeout_s`, 0 to never) the manager stops the `vllm serve` process, which frees the GPU, but keeps the gateway and the engine ON: `engine:status` reports `idle: true`, `/health` still answers 200, and `engine:models` still advertises the model it is set to serve diff --git a/services/nvpair-engine-manager/front_test.go b/services/nvpair-engine-manager/front_test.go index ff271ce88..840aa14d6 100644 --- a/services/nvpair-engine-manager/front_test.go +++ b/services/nvpair-engine-manager/front_test.go @@ -96,7 +96,7 @@ func TestOnlyVLLMHasAFront(t *testing.T) { } } vllm, _ := reg.Get("vllm") - if front := vllm.Platforms["linux/amd64"].Runtime.Front; front == nil || front.BackendPortOffset != 10000 || front.IdleTimeoutS != 600 { + if front := vllm.Platforms["linux/amd64"].Runtime.Front; front == nil || front.BackendPortOffset != 10000 || front.IdleTimeoutS != 300 { t.Errorf("vllm front = %+v", front) } } diff --git a/services/nvpair-engine-manager/manifests/vllm.json b/services/nvpair-engine-manager/manifests/vllm.json index a67fbb45d..dfcf0b34a 100644 --- a/services/nvpair-engine-manager/manifests/vllm.json +++ b/services/nvpair-engine-manager/manifests/vllm.json @@ -17,7 +17,7 @@ ] }, "args": ["serve", "--model", "Qwen/Qwen2.5-0.5B-Instruct", "--host", "{host}", "--port", "{port}"], - "front": { "backend_port_offset": 10000, "idle_timeout_s": 600 }, + "front": { "backend_port_offset": 10000, "idle_timeout_s": 300 }, "auto_launch": { "kind": "hf_context", "context_tokens": 65536 }, "env": { "HF_HOME": "{models_dir}", "VLLM_USE_FLASHINFER_SAMPLER": "0" }, "bind": "127.0.0.1", From d562baf6e89a5f792226cfa518a660842e9f62fb Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Fri, 23 Oct 2026 18:13:39 +0530 Subject: [PATCH 22/35] feat(nvpair-engine-manager): install vLLM on Apple Silicon macOS Add a darwin/arm64 platform to the vLLM manifest. The install fetches the sha256-pinned uv, creates a managed Python 3.12 venv and installs the pinned vLLM 0.32.0 core and vLLM-Metal wheels, each checked against a digest. It refuses early on non-arm64, macOS older than 15, or under 10 GB free. Allow the install on macOS in the desktop engine capabilities, update the tests that assumed Linux only, and document the new platform. Signed-off-by: Tushar Khinvasara --- .../src/ui/constants/engine-capabilities.ts | 5 +- desktop/tests/modular/engine-identity.test.ts | 6 +-- services/nvpair-engine-manager/MANIFEST.md | 4 +- services/nvpair-engine-manager/README.md | 8 ++- .../nvpair-engine-manager/manifests/vllm.json | 14 ++++++ .../vllm_manifest_test.go | 50 ++++++++++++++++--- services/readme.md | 4 +- 7 files changed, 73 insertions(+), 18 deletions(-) diff --git a/desktop/src/ui/constants/engine-capabilities.ts b/desktop/src/ui/constants/engine-capabilities.ts index 566d28438..57fb1a1b4 100644 --- a/desktop/src/ui/constants/engine-capabilities.ts +++ b/desktop/src/ui/constants/engine-capabilities.ts @@ -80,8 +80,9 @@ export const EngineCapabilities: Record = { // Eject stops the engine, which frees all of its GPU memory. Load starts // it again on the chosen model. hasEject: true, - // Linux only: vLLM has no native Windows or macOS GPU path. - hasInstall: ['linux'], + // Linux (NVIDIA) and Apple Silicon macOS (vLLM-Metal). vLLM has no + // native Windows path. + hasInstall: ['linux', 'darwin'], hasEnginePort: true, hasInstallPath: false, hasProxyWebUI: false, diff --git a/desktop/tests/modular/engine-identity.test.ts b/desktop/tests/modular/engine-identity.test.ts index 277827cf9..c5e7d2da1 100644 --- a/desktop/tests/modular/engine-identity.test.ts +++ b/desktop/tests/modular/engine-identity.test.ts @@ -35,10 +35,10 @@ describe('engine identity', () => { }) }) - it('offers vLLM only where it can be installed', () => { + it('offers vLLM on Linux and macOS, not Windows', () => { expect(getWelcomeEngineCandidates('Linux')).toContain('vllm') expect(getWelcomeEngineCandidates('Windows')).not.toContain('vllm') - expect(getWelcomeEngineCandidates('MacOS')).not.toContain('vllm') + expect(getWelcomeEngineCandidates('MacOS')).toContain('vllm') }) it('maps vLLM to its engine-manager wire id and one-model-per-process capabilities', () => { @@ -58,7 +58,7 @@ describe('engine identity', () => { deleteBlockedWhileLoaded: true, hasPullModel: true, hubSearch: true, - hasInstall: ['linux'], + hasInstall: ['linux', 'darwin'], hasEnginePort: true, engineHub: { label: 'vLLM' } }) diff --git a/services/nvpair-engine-manager/MANIFEST.md b/services/nvpair-engine-manager/MANIFEST.md index eade45bad..1c62da69f 100644 --- a/services/nvpair-engine-manager/MANIFEST.md +++ b/services/nvpair-engine-manager/MANIFEST.md @@ -599,7 +599,7 @@ GPU selection, and auth. | **Ollama** | strong (env-first) | `ollama serve` (foreground) | env: `OLLAMA_HOST`, `OLLAMA_MODELS`, `OLLAMA_KEEP_ALIVE`, `OLLAMA_NUM_PARALLEL`, `OLLAMA_MAX_LOADED_MODELS`, `OLLAMA_MAX_QUEUE`, `OLLAMA_CONTEXT_LENGTH`, `OLLAMA_FLASH_ATTENTION` | HTTP `/api/tags`, `/api/pull`; CLI `ollama pull/ls/ps/stop` | | **llama.cpp** | strong (env+flags) — **reference design** | `llama-server --host 127.0.0.1 --port {port}` (foreground) | flags + `LLAMA_ARG_*` (host/port, ctx-size, n-parallel, cont-batching, flash-attn, device, n-gpu-layers, tensor-split, main-gpu, api-key, models-dir/max) | HTTP `/v1/models`, `/models` download/delete, `/models/load`, `/models/unload`, `/health`, `/slots` | | **LM Studio** | command / daemon | `lms daemon up` → `lms server start --port {port}` | small env (`LMS_SERVER_HOST`, `LM_API_TOKEN`); most config is flags/API/settings (`lms load --context-length/--gpu/--ttl`) | HTTP `/api/v1/models[/download\|load\|unload]`; CLI `lms ls/get/load/unload/ps` | -| **vLLM** | flags-first — **Linux/WSL only** | `vllm serve --host 127.0.0.1 --port {port}` | flags (host/port/api-key); `HF_HOME` for cache. `VLLM_PORT`/`VLLM_HOST_IP` are **not** the API bind | OpenAI `/v1/models`; one model per process (unload = restart) | +| **vLLM** | flags-first — **Linux/WSL and Apple Silicon macOS** | `vllm serve --host 127.0.0.1 --port {port}` | flags (host/port/api-key); `HF_HOME` for cache. `VLLM_PORT`/`VLLM_HOST_IP` are **not** the API bind | OpenAI `/v1/models`; one model per process (unload = restart) | | **Jan** | hybrid (on llama.cpp router) | `jan serve --port {port}` (CLI) | forwards `LLAMA_ARG_*`; perf settings are router-preset-driven | `jan serve` auto-downloads HF repos | | **GPT4All** | weak (GUI app) | none (Local API Server toggled in the GUI, `:4891`) | desktop settings / Python SDK — not env | OpenAI `/v1` once enabled in-app | @@ -607,5 +607,5 @@ Caveats worth encoding when authoring these: - **Model store location**: leave each engine's model cache at the engine's own default — `~/.ollama`, `~/.llamacpp`, `~/.lmstudio/models` — and declare that path as `models_dir`. Never point one inside `{install_dir}` or anywhere else under the app data root: `uninstall` skips `models_dir`, but the app-level "remove all data" uninstall deletes the entire app data root and nothing can exempt a subdirectory of it. See Model stores. - **llama.cpp** ships prebuilt archives with **published SHA-256s**, so it's an ideal checksum-pinned `fetch`+`run` target (no placeholder SHA needed). - **LM Studio** model dir is settings-controlled (no documented relocation env); surface its config as flags/API, not env. -- **vLLM**: native Windows is unsupported (WSL only); the bind is a flag, not env. The bundled `vllm.json` is the reference: it passes the model as `--model`, since manifest `runtime.args` have no `{model}` token, and installs through a sha256-pinned `uv` bootstrap with a detect-first path. +- **vLLM**: native Windows is unsupported (WSL only), and macOS is Apple Silicon only through the vLLM-Metal plugin (`darwin/arm64`); the bind is a flag, not env. The bundled `vllm.json` is the reference: it passes the model as `--model`, since manifest `runtime.args` have no `{model}` token, and installs through a sha256-pinned `uv` bootstrap with a detect-first path. - **GPT4All**: no first-class headless CLI today — out of scope for a config-only manager unless targeting its Python SDK. diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index 1ea956da1..f9746697e 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -41,8 +41,12 @@ claim GPU activation. ### vLLM -The `vllm` manifest is Linux only (`linux/amd64` and `linux/arm64`; vLLM has no -native Windows or macOS GPU path). It runs `vllm serve` on loopback port `8001`, +The `vllm` manifest covers Linux (`linux/amd64` and `linux/arm64`, NVIDIA GPU +with a CUDA 13 driver) and Apple Silicon macOS (`darwin/arm64`, macOS 15 or newer, +through the vLLM-Metal plugin). There is no native Windows or Intel Mac path. The +macOS install builds a managed Python 3.12 venv with a sha256-pinned `uv` and +installs the pinned vLLM core and vLLM-Metal wheels, each checked against a +digest, so no compiler is needed. It runs `vllm serve` on loopback port `8001`, and the proxy facade owns `8000`. vLLM serves **one model per process** and has no load, unload, pull or delete API, so the manifest declares only `list_models` and `loaded_models` (both `GET /v1/models`). Changing the model means editing the diff --git a/services/nvpair-engine-manager/manifests/vllm.json b/services/nvpair-engine-manager/manifests/vllm.json index dfcf0b34a..e3151bfad 100644 --- a/services/nvpair-engine-manager/manifests/vllm.json +++ b/services/nvpair-engine-manager/manifests/vllm.json @@ -54,6 +54,20 @@ "vllm-install", "{download}", "{install_dir}" ] } + }, + "darwin/arm64": { + "detect": ["{install_dir}/venv/bin/vllm", "~/.venv-vllm-metal/bin/vllm"], + "install": { + "fetch": { + "url": "https://github.com/astral-sh/uv/releases/download/0.13.1/uv-aarch64-apple-darwin.tar.gz", + "sha256": "cfe454573412ff7380d15fc7dae5997ab53ffce72fce0a78e91ddbc84001acd2" + }, + "run": [ + "sh", "-c", + "set -eu; [ \"$(uname -m)\" = arm64 ] || { echo 'vLLM on macOS needs an Apple Silicon Mac (arm64)' >&2; exit 1; }; macos_major=$(sw_vers -productVersion | cut -d. -f1); [ \"${macos_major:-0}\" -ge 15 ] 2>/dev/null || { echo \"vLLM needs macOS 15 or newer (found: ${macos_major:-unknown}). Update macOS and try again.\" >&2; exit 1; }; free_kb=$(df -Pk \"$2\" | awk 'NR==2 {print $4}'); [ \"${free_kb:-0}\" -ge 10485760 ] || { echo 'vLLM needs at least 10 GB of free disk space' >&2; exit 1; }; mkdir -p \"$2/uv\" \"$2/wheels\"; tar -xzf \"$1\" -C \"$2/uv\" --strip-components=1; export UV_PYTHON_INSTALL_DIR=\"$2/python\" UV_PYTHON_PREFERENCE=only-managed; \"$2/uv/uv\" venv --no-cache --python 3.12 \"$2/venv\"; core_whl=\"$2/wheels/vllm-0.32.0+cpu-cp38-abi3-macosx_11_0_arm64.whl\"; metal_whl=\"$2/wheels/vllm_metal-0.32.0-cp312-cp312-macosx_15_0_arm64.whl\"; curl -fsSL --retry 3 -o \"${core_whl:-}\" 'https://github.com/vllm-project/vllm/releases/download/v0.32.0/vllm-0.32.0%2Bcpu-cp38-abi3-macosx_11_0_arm64.whl'; echo \"c28cf0fede58f02c4886d2794217ec3c7e4bbd22dfeb16b4b32586a8f230bb75 ${core_whl:-}\" | shasum -a 256 -c -; curl -fsSL --retry 3 -o \"${metal_whl:-}\" 'https://github.com/vllm-project/vllm-metal/releases/download/v0.32.0/vllm_metal-0.32.0-cp312-cp312-macosx_15_0_arm64.whl'; echo \"458d34157eb02a6bfb0e293c70485df18f72b7eb1d1ce8f1eefea95ba47ab63c ${metal_whl:-}\" | shasum -a 256 -c -; \"$2/uv/uv\" pip install --no-cache --python \"$2/venv/bin/python\" \"${core_whl:-}\"; \"$2/uv/uv\" pip install --no-cache --python \"$2/venv/bin/python\" \"${metal_whl:-}\"; rm -rf \"$2/wheels\"", + "vllm-install", "{download}", "{install_dir}" + ] + } } }, "actions": { diff --git a/services/nvpair-engine-manager/vllm_manifest_test.go b/services/nvpair-engine-manager/vllm_manifest_test.go index a6fa5418f..ca3777ccb 100644 --- a/services/nvpair-engine-manager/vllm_manifest_test.go +++ b/services/nvpair-engine-manager/vllm_manifest_test.go @@ -13,8 +13,8 @@ import ( "testing" ) -// vLLM is Linux-only, installs through a pinned uv bootstrap, serves exactly -// one model per process and has no load, unload, pull or delete API. These +// vLLM is Linux (NVIDIA) and Apple Silicon only, installs through a pinned uv +// bootstrap, serves exactly one model per process and has no load, unload, pull or delete API. These // are the properties the rest of the stack (UI capabilities, the model wire, // the install gate) relies on, so a manifest edit that drops one fails here. func TestBundledVLLMManifest(t *testing.T) { @@ -24,10 +24,13 @@ func TestBundledVLLMManifest(t *testing.T) { t.Fatal("vllm manifest not loaded") } - if got := len(manifest.Platforms); got != 2 { - t.Fatalf("vllm declares %d platforms, want linux/amd64 and linux/arm64 only", got) + if got := len(manifest.Platforms); got != 3 { + t.Fatalf("vllm declares %d platforms, want linux/amd64, linux/arm64 and darwin/arm64 only", got) } - for _, key := range []string{"linux/amd64", "linux/arm64"} { + // The version each platform's install script must pin: Linux installs vLLM + // itself from PyPI, macOS installs the vLLM core and vLLM-Metal wheels. + pins := map[string]string{"linux/amd64": "vllm==", "linux/arm64": "vllm==", "darwin/arm64": "vllm-0.32.0"} + for _, key := range []string{"linux/amd64", "linux/arm64", "darwin/arm64"} { platform, ok := manifest.Platforms[key] if !ok { t.Fatalf("vllm has no %s platform block", key) @@ -43,8 +46,16 @@ func TestBundledVLLMManifest(t *testing.T) { if len(platform.Install.Script) != 0 { t.Errorf("%s: install must not use the unpinned script escape hatch", key) } - if !strings.Contains(strings.Join(platform.Install.Run, " "), "vllm==") { - t.Errorf("%s: the vLLM version must be pinned", key) + script := strings.Join(platform.Install.Run, " ") + if !strings.Contains(script, pins[key]) { + t.Errorf("%s: the vLLM install must be pinned (want %q in the script)", key, pins[key]) + } + if strings.HasPrefix(key, "darwin/") { + // The macOS wheels are downloaded by the script itself, so each one + // has to be checked against a digest before it is installed. + if got := strings.Count(script, "shasum -a 256 -c"); got != 2 { + t.Errorf("%s: %d wheel digest checks, want one for the vLLM core and one for vLLM-Metal", key, got) + } } rt := platform.Runtime if rt.EditableLaunch == nil || !slices.Equal(rt.EditableLaunch.FixedArgs, []string{"serve"}) { @@ -206,4 +217,29 @@ func TestVLLMInstallRefusesDriversWithoutCUDA13(t *testing.T) { } } +// The executor expands OS environment references in every install argument +// after resolving placeholders, so a "$name" in a script would be replaced +// before the shell ran it. Each platform's script must survive that untouched. +func TestVLLMInstallScriptsSurviveArgumentExpansion(t *testing.T) { + reg := loadWithOverrides(t, t.TempDir()) + manifest, ok := reg.Get("vllm") + if !ok { + t.Fatal("vllm manifest not loaded") + } + for key, platform := range manifest.Platforms { + argv, err := resolveArgs(platform.Install.Run, map[string]string{ + "download": "/tmp/uv.tar.gz", + "install_dir": "/tmp/vllm", + }) + if err != nil { + t.Fatalf("%s: %v", key, err) + } + for i := range argv { + if expanded := expandPathForOS(argv[i], "linux"); expanded != argv[i] { + t.Errorf("%s: the executor rewrites install argument %d, which breaks the script:\n%s\n-> %s", key, i, argv[i], expanded) + } + } + } +} + func ptr(s string) *string { return &s } diff --git a/services/readme.md b/services/readme.md index b37ed04e4..61595db4c 100644 --- a/services/readme.md +++ b/services/readme.md @@ -30,8 +30,8 @@ never intercepted. The llama.cpp and vLLM facades are enabled by default alongside Ollama and LM Studio. The llama.cpp facade exposes OpenAI-compatible traffic on `http://localhost:8080` -while the managed router runs on `8081`. The vLLM facade (the engine itself is -Linux only) uses `http://localhost:8000` with its managed engine on `8001`. A safe fallback is reported when a facade port is +while the managed router runs on `8081`. The vLLM facade (the engine itself runs on +Linux and Apple Silicon macOS) uses `http://localhost:8000` with its managed engine on `8001`. A safe fallback is reported when a facade port is occupied. The desktop and TUI expose each engine's managed lifecycle and inventory, plus llama.cpp's downloads and load/unload actions; vLLM serves one model per process, so changing its model means restarting it. `--proxy-engines` can still restrict the From 0a94a2c21fa002ce44ad80ce1cf190883f6614bd Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 28 Oct 2026 10:38:34 +0530 Subject: [PATCH 23/35] feat(nvpair-engine-manager): switch vLLM models on request vLLM serves one model per process, so a request naming another downloaded model used to be refused: only the loaded model was advertised. The gateway in front of vLLM now reads the request's JSON "model" field and, for a different downloaded model, lets the requests already running finish, unloads the model, saves --model, loads the new one behind the same gateway, and answers the held request. Requests that arrive together share one switch. A model that is not downloaded gets a 404 and nothing changes. A switch that fails is not rolled back: the request gets a 503 naming the model and the reason, and vLLM is left unloaded and ON, so the next request loads again. vLLM now advertises every cached model, including while idle. The gateway's /v1/models names them all, and loaded_models asks for /v1/models?resident=1 so only the loaded one is reported as resident. Signed-off-by: Tushar Khinvasara --- services/nvpair-engine-manager/MANIFEST.md | 6 +- services/nvpair-engine-manager/README.md | 30 +- services/nvpair-engine-manager/front.go | 350 +++++++++++++++++- .../front_switch_test.go | 301 +++++++++++++++ services/nvpair-engine-manager/front_test.go | 17 +- services/nvpair-engine-manager/lifecycle.go | 3 + .../nvpair-engine-manager/manifests/vllm.json | 5 +- services/nvpair-engine-manager/models.go | 15 +- services/nvpair-engine-manager/settings.go | 30 +- .../testdata/fakeengine/main.go | 18 +- .../vllm_manifest_test.go | 12 +- 11 files changed, 747 insertions(+), 40 deletions(-) create mode 100644 services/nvpair-engine-manager/front_switch_test.go diff --git a/services/nvpair-engine-manager/MANIFEST.md b/services/nvpair-engine-manager/MANIFEST.md index 1c62da69f..50ef2ccb9 100644 --- a/services/nvpair-engine-manager/MANIFEST.md +++ b/services/nvpair-engine-manager/MANIFEST.md @@ -337,8 +337,10 @@ placeholder before the action runs: A manifest-level **`advertised_models`** (`"list"`, the default, or `"loaded"`) chooses what the node tells its peers and its own proxy it can serve. The proxy sends a request to any node advertising the model, so an engine that serves one -model per process sets `"loaded"` and advertises only `loaded_models`, even when -its `list_models` offers many models it could be switched to. With `"loaded"`, +model per process and cannot switch on request sets `"loaded"` and advertises only +`loaded_models`, even when its `list_models` offers many models it could be +switched to. An engine behind a `front` switches models on request, so it +advertises its whole `list_models`. With `"loaded"`, `list_models` is not queried for `engine:models`, and a `loaded_models` action is required. diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index f9746697e..f6b02be69 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -86,12 +86,13 @@ Readiness polls `/health` for up to eight minutes while weights load. The model list is the Hugging Face cache (`~/.cache/huggingface/hub`), not what vLLM happens to be serving: `list_models` is an `hf_cache` action listing the complete, text-model-shaped downloads (see `MANIFEST.md`), and it works while -vLLM is stopped. Peers and the proxy, though, are only told about the model being -served (`advertised_models: loaded`), because the proxy routes a request to any -node advertising its model and vLLM cannot answer for the others. `load_model` -switches the served model: it writes `--model ` into the saved launch -arguments and restarts vLLM on it, restoring the previous model if the new one -does not start (for example it does not fit in GPU memory). +vLLM is stopped. Peers and the proxy are told about all of them, and a request +for a model that is not loaded switches vLLM to it (see below), as Ollama and +llama.cpp load a model on demand. `engine:models` still reports which one is +resident in `loadedByEngine`. `load_model` switches the served model on demand +too: it writes `--model ` into the saved launch arguments and restarts vLLM +on it, restoring the previous model if the new one does not start (for example it +does not fit in GPU memory). vLLM is reached through a PAIR gateway (`front`): the public port (8001) is the gateway, and the `vllm serve` process listens on a loopback port 10000 higher @@ -120,6 +121,23 @@ Eject (`unload_model`) does the same on demand. `load_model` of the model it is set to serve loads it again. +A request that names a different downloaded model than the one vLLM serves +(the JSON `model` field of a POST under `/v1/` or the other inference paths +below) switches vLLM to it and is then answered by it. The gateway reads only as +far as that field and forwards the body unchanged. Requests already running on +the current model finish first, and nothing new is started on it meanwhile, so +none is cut off. The gateway then unloads the model, saves `--model ` into the +launch arguments (the settings show what is running), loads the new model behind +the same gateway, and forwards the held request. Requests that arrive together +share one switch, and a request for yet another model waits for it. A model that +is not downloaded is answered 404 and nothing changes. A switch that fails is not +rolled back: the request gets a 503 naming the model and the reason, and vLLM is +left unloaded but ON, so the next request loads whichever model it names. A switch +is a full restart, so the proxy's 120 s limit is the ceiling for a very large model. +The gateway's `GET /v1/models`, which the proxy builds its own model list from, +names every downloaded model, not just the loaded one: it adds them to the +engine's answer while vLLM is up and answers with them while it is unloaded. + A request that needs the model (a POST under `/v1/`, or to `/tokenize`, `/detokenize`, `/pooling`, `/score`, `/rerank` or `/classify`) and arrives while it is unloaded loads it and is then answered, as Ollama and llama.cpp do: the gateway diff --git a/services/nvpair-engine-manager/front.go b/services/nvpair-engine-manager/front.go index ab87ad4d2..4d8b5fd5c 100644 --- a/services/nvpair-engine-manager/front.go +++ b/services/nvpair-engine-manager/front.go @@ -19,10 +19,13 @@ import ( "os" "path/filepath" "runtime" + "slices" "strconv" "strings" "sync" "time" + + settings "nvpair-shared/enginesettings" ) // Front puts a PAIR-owned HTTP gateway on an engine's public port and runs the @@ -90,10 +93,34 @@ type frontGateway struct { wakeFn func(context.Context) error wakeSt *wakeState // model is the model the engine serves, so that the model list can be - // answered while the engine is unloaded. + // answered while the engine is unloaded, and so that a request naming another + // model is known to need a switch. model string + // switchFn changes the served model, and swSt is the switch in progress, if + // any. Every request that needs the model waits on it. + switchFn func(ctx context.Context, model string) error + swSt *switchState + // modelsFn lists the models the engine can be switched to. The gateway adds + // them to its model list, since the engine itself only names the one it serves. + modelsFn func(ctx context.Context) []string } +// switchState is one change of the served model. Every request that needs the +// engine while it runs waits on done, so any number of them share a single +// switch. +type switchState struct { + model string + done chan struct{} + err error +} + +// errModelNotDownloaded marks a request for a model the engine cannot serve +// because it is not in the cache. Nothing is switched or unloaded for it. +var errModelNotDownloaded = errors.New("the model is not downloaded on this node") + +// frontPeekLimit bounds how much of a request body is read to find its model. +const frontPeekLimit = 32 << 20 + // wakeState is one load of an unloaded engine. Every request that needs the // engine waits on done, so any number of them share a single start. type wakeState struct { @@ -147,18 +174,192 @@ func (g *frontGateway) finishWake(ws *wakeState, err error) { close(ws.done) } -// loadFailed answers a request whose model could not be loaded. -func loadFailed(w http.ResponseWriter, err error) { +// requestSwitch returns the switch in progress, starting one if the request names +// a model other than the one served. It returns nil when no switch is needed, so +// the caller can carry on. The switch drains the requests already running on the +// current model before it unloads it, and a request that arrives meanwhile waits +// for it, so no request is cut off and none is answered by the wrong model. +func (g *frontGateway) requestSwitch(model string) *switchState { + g.mu.Lock() + defer g.mu.Unlock() + if g.swSt != nil { + return g.swSt + } + if model == "" || g.model == "" || model == g.model || g.switchFn == nil { + return nil + } + ss := &switchState{model: model, done: make(chan struct{})} + g.swSt = ss + go func() { g.finishSwitch(ss, g.switchFn(context.Background(), model)) }() + return ss +} + +// finishSwitch ends a switch. Whatever its outcome, the next request that needs +// the engine decides what happens next: a failed switch leaves the engine +// unloaded, it does not put the previous model back. +func (g *frontGateway) finishSwitch(ss *switchState, err error) { + g.mu.Lock() + ss.err = err + if g.swSt == ss { + g.swSt = nil + } + g.mu.Unlock() + close(ss.done) +} + +// listedModels is what the gateway adds to the engine's model list: the models +// a request can switch to. +func (g *frontGateway) listedModels(ctx context.Context) []string { + if g.modelsFn == nil { + return nil + } + return g.modelsFn(ctx) +} + +// residentOnly reports whether a model list request asks for only the model the +// engine has loaded (?resident=1), which is how the manager reads what is +// resident, as against the list that clients and the proxy read. +func residentOnly(r *http.Request) bool { + return r.URL.Query().Has("resident") +} + +// withListedModels adds to an OpenAI-style model list body any of ids it does not +// already name. A body that is not such a list is returned as it is. +func withListedModels(body []byte, ids []string) []byte { + var list map[string]json.RawMessage + var data []json.RawMessage + if json.Unmarshal(body, &list) != nil || json.Unmarshal(list["data"], &data) != nil { + return body + } + named := map[string]bool{} + for _, entry := range data { + var model struct { + ID string `json:"id"` + } + if json.Unmarshal(entry, &model) == nil { + named[model.ID] = true + } + } + for _, id := range ids { + if named[id] { + continue + } + named[id] = true + entry, _ := json.Marshal(map[string]any{"id": id, "object": "model", "created": 0, "owned_by": "vllm"}) + data = append(data, entry) + } + list["data"], _ = json.Marshal(data) + merged, err := json.Marshal(list) + if err != nil { + return body + } + return merged +} + +// setModel records the model the engine serves. +func (g *frontGateway) setModel(model string) { + g.mu.Lock() + g.model = model + g.mu.Unlock() +} + +// drain returns once no inference request is running, which is when the model +// can be unloaded without cutting one off. Requests that need the model are held +// while a switch is in progress, so nothing new starts meanwhile. +func (g *frontGateway) drain(ctx context.Context) error { + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + for { + g.mu.Lock() + running := g.inflight + g.mu.Unlock() + if running == 0 { + return nil + } + select { + case <-ctx.Done(): + return fmt.Errorf("requests on the current model did not finish: %w", ctx.Err()) + case <-g.closed: + return errors.New("the engine was stopped") + case <-ticker.C: + } + } +} + +// requestedModel returns the model a JSON inference request names, or "" when it +// names none or is not JSON. It reads only as far as the model field and puts the +// bytes back, so the engine receives the body unchanged. +func requestedModel(r *http.Request) string { + if !wakeable(r) || r.Body == nil || !strings.Contains(r.Header.Get("Content-Type"), "json") { + return "" + } + var seen bytes.Buffer + model := peekModel(json.NewDecoder(io.TeeReader(io.LimitReader(r.Body, frontPeekLimit), &seen))) + r.Body = struct { + io.Reader + io.Closer + }{io.MultiReader(&seen, r.Body), r.Body} + return model +} + +// peekModel reads a JSON object up to its top-level "model" string. +func peekModel(dec *json.Decoder) string { + if tok, err := dec.Token(); err != nil || tok != json.Delim('{') { + return "" + } + for dec.More() { + tok, err := dec.Token() + key, isString := tok.(string) + if err != nil || !isString { + return "" + } + if key == "model" { + var model string + if dec.Decode(&model) != nil { + return "" + } + return model + } + var skipped json.RawMessage + if dec.Decode(&skipped) != nil { + return "" + } + } + return "" +} + +// shortReason flattens an error to one line short enough for a response. +func shortReason(err error) string { reason := strings.Join(strings.Fields(err.Error()), " ") if len(reason) > 400 { reason = reason[:400] + "..." } - body, _ := json.Marshal(map[string]string{"error": "the model could not be loaded: " + reason}) + return reason +} + +// unavailable answers a request the engine cannot serve right now. +func unavailable(w http.ResponseWriter, status int, message string) { + body, _ := json.Marshal(map[string]string{"error": message}) w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusServiceUnavailable) + w.WriteHeader(status) _, _ = w.Write(body) } +// loadFailed answers a request whose model could not be loaded. +func loadFailed(w http.ResponseWriter, err error) { + unavailable(w, http.StatusServiceUnavailable, "the model could not be loaded: "+shortReason(err)) +} + +// switchFailed answers a request whose model the engine could not be switched to. +// The engine is left unloaded, and the next request tries again. +func switchFailed(w http.ResponseWriter, model string, err error) { + if errors.Is(err, errModelNotDownloaded) { + unavailable(w, http.StatusNotFound, fmt.Sprintf("the model %q is not downloaded on this node", model)) + return + } + unavailable(w, http.StatusServiceUnavailable, fmt.Sprintf("could not switch to model %q: %s. The engine is unloaded and will try again on the next request.", model, shortReason(err))) +} + // isActivity reports whether a request is inference work. Reads such as health // checks, metrics and the model list are made by the proxy and by monitors on a // timer, and must not keep an engine loaded. @@ -172,7 +373,24 @@ func isActivity(r *http.Request) bool { func (g *frontGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { activity := isActivity(r) + requested := requestedModel(r) for { + if wakeable(r) { + if ss := g.requestSwitch(requested); ss != nil { + select { + case <-ss.done: + if ss.err != nil && ss.model == requested { + switchFailed(w, requested, ss.err) + return + } + continue + case <-r.Context().Done(): + return // the client left; the switch carries on for the next one + case <-g.closed: + return + } + } + } g.mu.Lock() if (g.idle || g.wakeSt != nil) && wakeable(r) { g.mu.Unlock() @@ -198,10 +416,13 @@ func (g *frontGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { g.mu.Unlock() if r.Method == http.MethodGet && r.URL.Path == "/v1/models" && model != "" { // The proxy builds its model list from this call, and an engine that is - // only unloaded still answers for its model. + // only unloaded still answers for its model and every one it can load. body, _ := json.Marshal(map[string]any{"object": "list", "data": []map[string]any{ {"id": model, "object": "model", "created": 0, "owned_by": "vllm"}, }}) + if !residentOnly(r) { + body = withListedModels(body, g.listedModels(r.Context())) + } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(body) return @@ -276,7 +497,7 @@ func (g *frontGateway) alive() bool { } } -func newFrontHandler(backendPort int) http.Handler { +func newFrontHandler(backendPort int, listed func(context.Context) []string) http.Handler { target := &url.URL{Scheme: "http", Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(backendPort))} return &httputil.ReverseProxy{ Rewrite: func(r *httputil.ProxyRequest) { @@ -292,6 +513,23 @@ func newFrontHandler(backendPort int) http.Handler { MaxIdleConnsPerHost: 16, IdleConnTimeout: frontConnIdleTimeout, }, + // The engine's model list names only the model it serves; the gateway adds + // the ones a request can switch to. + ModifyResponse: func(resp *http.Response) error { + if listed == nil || resp.StatusCode != http.StatusOK || resp.Request.Method != http.MethodGet || resp.Request.URL.Path != "/v1/models" || residentOnly(resp.Request) { + return nil + } + body, err := io.ReadAll(io.LimitReader(resp.Body, frontPeekLimit)) + _ = resp.Body.Close() + if err != nil { + return err + } + body = withListedModels(body, listed(resp.Request.Context())) + resp.Body = io.NopCloser(bytes.NewReader(body)) + resp.ContentLength = int64(len(body)) + resp.Header.Set("Content-Length", strconv.Itoa(len(body))) + return nil + }, ErrorLog: log.New(io.Discard, "", 0), ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { if errors.Is(err, context.Canceled) { @@ -309,7 +547,8 @@ func startFrontGateway(host string, public, backend int) (*frontGateway, error) if err != nil { return nil, fmt.Errorf("listen on port %d: %w", public, err) } - g := &frontGateway{ln: ln, proxy: newFrontHandler(backend), closed: make(chan struct{})} + g := &frontGateway{ln: ln, closed: make(chan struct{})} + g.proxy = newFrontHandler(backend, g.listedModels) g.srv = &http.Server{ Handler: g, ReadHeaderTimeout: frontHeaderTimeout, @@ -379,6 +618,11 @@ func (e *Executor) startFront(st *engineState, engine string, rt Runtime, host s return nil, false, err } g.wakeFn = func(ctx context.Context) error { return e.wakeEngine(ctx, engine) } + g.switchFn = func(ctx context.Context, model string) error { return e.switchFrontModel(ctx, st, engine, g, model) } + g.modelsFn = func(ctx context.Context) []string { + models, _ := e.cachedModels(ctx, engine) + return models + } st.mu.Lock() previous := st.front st.front = g @@ -535,6 +779,96 @@ func (e *Executor) wakeEngine(ctx context.Context, engine string) error { return nil } +// switchFrontModel makes the engine serve another model, the way Ollama and +// llama.cpp swap one in for a request: requests already running on the current +// model finish, the model is unloaded, and the new one is loaded behind the same +// gateway, which holds the requests that need it meanwhile. A model that is not +// downloaded is refused before anything changes. A load that fails is not rolled +// back: the engine is left unloaded, still on, and the next request loads again. +func (e *Executor) switchFrontModel(ctx context.Context, st *engineState, engine string, g *frontGateway, model string) error { + if err := e.requireCachedModel(ctx, engine, model); err != nil { + return err + } + limit := 10 * time.Minute + if ready := st.plat.Runtime.Ready; ready != nil && ready.TimeoutS > 0 { + limit = time.Duration(ready.TimeoutS+60) * time.Second + } + ctx, cancel := context.WithTimeout(ctx, limit) + defer cancel() + if err := g.drain(ctx); err != nil { + return err + } + e.unloadFront(st, engine, g, 0, true) + if err := e.setIdleModel(st, engine, g, model); err != nil { + return err + } + slog.Info("switching the served model", "engine", engine, "model", model) + return e.wakeIdle(ctx, engine) +} + +// cachedModels lists the models the engine has a complete download of. +func (e *Executor) cachedModels(ctx context.Context, engine string) ([]string, error) { + st, err := e.state(engine) + if err != nil { + return nil, err + } + act, ok := st.manifest.Actions["list_models"] + if !ok || act.Result == nil { + return nil, fmt.Errorf("engine %q cannot list its models", engine) + } + raw, err := e.Action(ctx, engine, "list_models", nil) + if err != nil { + return nil, err + } + models, _ := extractStringsResult(raw, act.Result) + return models, nil +} + +// requireCachedModel refuses a model the engine has no complete download of. +func (e *Executor) requireCachedModel(ctx context.Context, engine, model string) error { + if !hfModelIDRe.MatchString(model) { + return fmt.Errorf("%w: %q is not a model id", errModelNotDownloaded, model) + } + models, err := e.cachedModels(ctx, engine) + if err != nil { + return err + } + if !slices.Contains(models, model) { + return fmt.Errorf("%w: %s", errModelNotDownloaded, model) + } + return nil +} + +// setIdleModel saves model as the one the unloaded engine serves next, and +// advertises it, without starting the engine. +func (e *Executor) setIdleModel(st *engineState, engine string, g *frontGateway, model string) error { + st.opMu.Lock() + defer st.opMu.Unlock() + current := e.launchStateLocked(engine, st) + text, changed, err := withLaunchFlag(current.LaunchText, "--model", model) + if err != nil { + return err + } + if changed { + preview := e.previewLaunchLocked(st, settings.Request{ + Engine: engine, + Settings: settings.Config{ServerPort: current.ServerPort, LaunchText: text}, + }) + if len(preview.Errors) > 0 || preview.Conflict != nil { + return fmt.Errorf("the launch arguments do not accept model %s", model) + } + if err := e.commitLaunchLocked(st, engine, preview, true); err != nil { + return err + } + } + st.mu.Lock() + st.idleModel = model + st.mu.Unlock() + g.setModel(model) + e.emitState(engine) + return nil +} + // reclaimFrontBackend frees the backend port when it holds an engine that PAIR // started in an earlier run and lost, as doStop does for the public port. Anything // else on the port is not ours to touch, and is reported. diff --git a/services/nvpair-engine-manager/front_switch_test.go b/services/nvpair-engine-manager/front_switch_test.go new file mode 100644 index 000000000..2dc2522b5 --- /dev/null +++ b/services/nvpair-engine-manager/front_switch_test.go @@ -0,0 +1,301 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// askFor posts a chat request naming model, and returns the status and body. +func askFor(t *testing.T, public int, path, model string) (int, string) { + t.Helper() + body := fmt.Sprintf(`{"model":%q,"messages":[]}`, model) + resp, err := http.Post(fmt.Sprintf("http://127.0.0.1:%d%s", public, path), "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + answer, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return resp.StatusCode, string(answer) +} + +func startSwitchableEngine(t *testing.T) (e *Executor, public, backend int) { + t.Helper() + e, public, backend = frontExecutorAdvertising(t, 0, "") + require.NoError(t, e.StartWith(context.Background(), "fronted", startOpts{Eager: true})) + return e, public, backend +} + +func servedModelOf(t *testing.T, e *Executor) string { + t.Helper() + state, err := e.LaunchSettings("fronted") + require.NoError(t, err) + tokens, err := parseLaunchText(state.LaunchText) + require.NoError(t, err) + model, _ := launchFlagValue(tokens, "--model") + return model +} + +func TestFrontSwitch_RequestForAnotherCachedModelSwitchesAndIsAnswered(t *testing.T) { + e, public, _ := startSwitchableEngine(t) + + status, body := askFor(t, public, "/v1/chat/completions", "org/m") + require.Equal(t, http.StatusOK, status) + require.Contains(t, body, `"served_by":"org/m"`) + + status, body = askFor(t, public, "/v1/chat/completions", "org/other") + require.Equal(t, http.StatusOK, status, body) + require.Contains(t, body, `"served_by":"org/other"`, "the request is answered by the model it asked for") + require.Equal(t, "org/other", servedModelOf(t, e), "the switch is saved, so the launch shows what is running") + state, err := e.Status("fronted") + require.NoError(t, err) + require.True(t, state.Running && !state.Idle && state.Healthy, "status = %+v", state) +} + +func TestFrontSwitch_RunningRequestFinishesBeforeTheModelIsUnloaded(t *testing.T) { + _, public, _ := startSwitchableEngine(t) + + type answer struct { + status int + body string + } + slow := make(chan answer, 1) + go func() { + status, body := askFor(t, public, "/v1/slow", "org/m") + slow <- answer{status, body} + }() + time.Sleep(200 * time.Millisecond) // let it reach the engine + + status, body := askFor(t, public, "/v1/chat/completions", "org/other") + require.Equal(t, http.StatusOK, status, body) + require.Contains(t, body, `"served_by":"org/other"`) + + select { + case got := <-slow: + require.Equal(t, http.StatusOK, got.status, "the request already running was cut off") + require.Contains(t, got.body, `"served_by":"org/m"`, "it was answered by the model it started on") + case <-time.After(5 * time.Second): + t.Fatal("the running request never finished") + } +} + +func TestFrontSwitch_SimultaneousRequestsForOneNewModelShareOneSwitch(t *testing.T) { + log := filepath.Join(t.TempDir(), "starts.log") + t.Setenv("FAKE_START_LOG", log) + t.Setenv("FAKE_START_DELAY", "500ms") + _, public, _ := startSwitchableEngine(t) + before := startCount(t, log) + + var wg sync.WaitGroup + results := make([]string, 6) + for i := range results { + wg.Add(1) + go func() { + defer wg.Done() + status, body := askFor(t, public, "/v1/chat/completions", "org/other") + results[i] = fmt.Sprintf("%d %s", status, body) + }() + } + wg.Wait() + for i, got := range results { + require.Contains(t, got, `200`, "request %d", i) + require.Contains(t, got, `"served_by":"org/other"`, "request %d", i) + } + require.Equal(t, 1, startCount(t, log)-before, "one start for six simultaneous requests") +} + +func TestFrontSwitch_ModelThatIsNotDownloadedIsRefusedAndNothingChanges(t *testing.T) { + e, public, _ := startSwitchableEngine(t) + + status, body := askFor(t, public, "/v1/chat/completions", "org/missing") + require.Equal(t, http.StatusNotFound, status) + require.Contains(t, body, "not downloaded") + + status, body = askFor(t, public, "/v1/chat/completions", "org/m") + require.Equal(t, http.StatusOK, status) + require.Contains(t, body, `"served_by":"org/m"`, "the engine kept serving the model it had") + require.Equal(t, "org/m", servedModelOf(t, e)) +} + +func TestFrontSwitch_FailedLoadLeavesTheEngineUnloadedAndNextRequestLoads(t *testing.T) { + t.Setenv("FAKE_FAIL_MODEL", "org/bad") + e, public, backend := startSwitchableEngine(t) + + status, body := askFor(t, public, "/v1/chat/completions", "org/bad") + require.Equal(t, http.StatusServiceUnavailable, status) + require.Contains(t, body, `could not switch to model \"org/bad\"`) + require.Contains(t, body, "unloaded", "the message says the engine is left unloaded") + + state, err := e.Status("fronted") + require.NoError(t, err) + require.True(t, state.Running && state.Idle, "status = %+v, want ON and unloaded", state) + require.False(t, up(backend), "nothing is loaded after a failed switch") + require.Equal(t, "org/bad", servedModelOf(t, e), "the previous model is not put back") + + status, body = askFor(t, public, "/v1/chat/completions", "org/m") + require.Equal(t, http.StatusOK, status, body) + require.Contains(t, body, `"served_by":"org/m"`, "the next request loads the model it names") +} + +func TestFrontSwitch_UnloadedEngineLoadsTheRequestedModelDirectly(t *testing.T) { + log := filepath.Join(t.TempDir(), "starts.log") + t.Setenv("FAKE_START_LOG", log) + e, public, backend := startSwitchableEngine(t) + _, err := e.Action(context.Background(), "fronted", "unload_model", []byte(`{}`)) + require.NoError(t, err) + require.False(t, up(backend)) + before := startCount(t, log) + + status, body := askFor(t, public, "/v1/chat/completions", "org/other") + require.Equal(t, http.StatusOK, status, body) + require.Contains(t, body, `"served_by":"org/other"`) + require.Equal(t, 1, startCount(t, log)-before, "one load, not the old model and then the new one") +} + +func TestFrontSwitch_UnloadedEngineAdvertisesEveryCachedModel(t *testing.T) { + e, _, backend := startSwitchableEngine(t) + _, err := e.Action(context.Background(), "fronted", "unload_model", []byte(`{}`)) + require.NoError(t, err) + require.False(t, up(backend)) + + res := e.ModelsResult(context.Background()) + require.ElementsMatch(t, []string{"org/m", "org/other", "org/bad"}, res.ByEngine["fronted"]) + loaded, known := res.LoadedByEngine["fronted"] + require.True(t, known) + require.Empty(t, loaded, "the cached models are offered, none is resident") +} + +func TestRequestedModel_ReadsTheModelAndLeavesTheBodyIntact(t *testing.T) { + tests := []struct { + name string + contentType string + body string + want string + }{ + {"model first", "application/json", `{"model":"org/a","messages":[{"role":"user","content":"hi"}]}`, "org/a"}, + {"model after other fields", "application/json; charset=utf-8", `{"messages":[{"role":"user","content":"hi"}],"stream":true,"model":"org/b"}`, "org/b"}, + {"no model field", "application/json", `{"messages":[]}`, ""}, + {"model is not a string", "application/json", `{"model":7}`, ""}, + {"not JSON content type", "text/plain", `{"model":"org/a"}`, ""}, + {"not an object", "application/json", `["org/a"]`, ""}, + {"truncated body", "application/json", `{"messages":[{"role":`, ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(tc.body)) + r.Header.Set("Content-Type", tc.contentType) + + require.Equal(t, tc.want, requestedModel(r)) + + forwarded, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.Equal(t, tc.body, string(forwarded), "the engine must receive the body unchanged") + }) + } +} + +func TestRequestedModel_IgnoresRequestsThatDoNotNeedTheModel(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + r.Header.Set("Content-Type", "application/json") + require.Empty(t, requestedModel(r)) +} + +// listedIDs returns the model ids a gateway's /v1/models names. +func listedIDs(t *testing.T, public int) []string { + t.Helper() + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/v1/models", public)) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + var list struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&list)) + ids := make([]string, 0, len(list.Data)) + for _, entry := range list.Data { + ids = append(ids, entry.ID) + } + return ids +} + +func TestFrontSwitch_ModelListNamesEveryCachedModelWhileLoadedAndWhileUnloaded(t *testing.T) { + e, public, backend := startSwitchableEngine(t) + + loaded := listedIDs(t, public) + require.Subset(t, loaded, []string{"org/m", "org/other", "org/bad"}, "the engine's own list is extended") + unique := map[string]bool{} + for _, id := range loaded { + unique[id] = true + } + require.Len(t, unique, len(loaded), "no model is listed twice") + + _, err := e.Action(context.Background(), "fronted", "unload_model", []byte(`{}`)) + require.NoError(t, err) + require.False(t, up(backend)) + + require.ElementsMatch(t, []string{"org/m", "org/other", "org/bad"}, listedIDs(t, public)) +} + +func TestFrontSwitch_ResidentModelListNamesOnlyTheLoadedModel(t *testing.T) { + e, public, backend := startSwitchableEngine(t) + + resident := func() []string { + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/v1/models?resident=1", public)) + require.NoError(t, err) + defer resp.Body.Close() + var list struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&list)) + ids := []string{} + for _, entry := range list.Data { + ids = append(ids, entry.ID) + } + return ids + } + require.NotContains(t, resident(), "org/other", "a model that is only downloaded is not resident") + require.NotContains(t, resident(), "org/bad") + + _, err := e.Action(context.Background(), "fronted", "unload_model", []byte(`{}`)) + require.NoError(t, err) + require.False(t, up(backend)) + require.Equal(t, []string{"org/m"}, resident(), "an unloaded engine still names the model it will load") +} + +func TestWithListedModels_AddsOnlyTheModelsTheListLacks(t *testing.T) { + body := []byte(`{"object":"list","data":[{"id":"org/a","object":"model","max_model_len":65536}]}`) + + merged := withListedModels(body, []string{"org/a", "org/b", "org/b"}) + + var list struct { + Object string `json:"object"` + Data []struct { + ID string `json:"id"` + MaxModelLen int `json:"max_model_len"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(merged, &list)) + require.Equal(t, "list", list.Object) + require.Len(t, list.Data, 2) + require.Equal(t, "org/a", list.Data[0].ID) + require.Equal(t, 65536, list.Data[0].MaxModelLen, "the engine's own entry is kept as it was") + require.Equal(t, "org/b", list.Data[1].ID) + + require.Equal(t, `not json`, string(withListedModels([]byte(`not json`), []string{"org/b"})), "anything else passes through") +} diff --git a/services/nvpair-engine-manager/front_test.go b/services/nvpair-engine-manager/front_test.go index 840aa14d6..cb0a64cc7 100644 --- a/services/nvpair-engine-manager/front_test.go +++ b/services/nvpair-engine-manager/front_test.go @@ -275,6 +275,14 @@ func TestFrontGatewayCloseFreesThePortAndIsIdempotent(t *testing.T) { // frontExecutor builds an engine whose process listens where the launch flag // says, so the gateway in front of it can be exercised end to end. func frontExecutor(t *testing.T, idleSeconds int) (e *Executor, public, backend int) { + t.Helper() + return frontExecutorAdvertising(t, idleSeconds, advertisedModelsLoaded) +} + +// frontExecutorAdvertising is frontExecutor for an engine that advertises what +// the manifest says. Its cache holds org/m, which it serves, and org/other and +// org/bad, which it can be switched to. +func frontExecutorAdvertising(t *testing.T, idleSeconds int, advertised string) (e *Executor, public, backend int) { t.Helper() if runtime.GOOS == "windows" { t.Skip("uses the fake engine's --listen flag") @@ -303,10 +311,13 @@ func frontExecutor(t *testing.T, idleSeconds int) (e *Executor, public, backend }} p.Runtime.Front = &Front{BackendPortOffset: 1, IdleTimeoutS: idleSeconds} m.Platforms[key] = p - // The engine advertises only what it has loaded, as vLLM does; Eject and Load - // are the same actions vLLM declares. - m.AdvertisedModels = advertisedModelsLoaded + cache := t.TempDir() + for _, id := range []string{"org/m", "org/other", "org/bad"} { + writeHFRepo(t, cache, id, llmFiles(map[string]string{"model.safetensors": "weights"})) + } + m.AdvertisedModels = advertised m.Actions = map[string]Action{ + "list_models": {HFCache: &ActionHFCache{Dir: cache}, Result: &ActionResult{Array: "data", Field: "id"}}, "loaded_models": {HTTP: &ActionHTTP{Method: "GET", Path: "/api/ps"}, Result: &ActionResult{Array: "models", Field: "name"}}, "unload_model": {StopEngine: true}, "load_model": {LaunchModel: &ActionLaunchModel{Flag: "--model"}}, diff --git a/services/nvpair-engine-manager/lifecycle.go b/services/nvpair-engine-manager/lifecycle.go index 071ba3e84..4884bff06 100644 --- a/services/nvpair-engine-manager/lifecycle.go +++ b/services/nvpair-engine-manager/lifecycle.go @@ -238,6 +238,9 @@ func (e *Executor) doStart(ctx context.Context, st *engineState, engine string, e.watch(st, engine, proc) if gateway != nil { gateway.closeWhen(proc.done) + if model, err := e.servedModelLocked(engine, st); err == nil { + gateway.setModel(model) + } gateway.markUp() } } diff --git a/services/nvpair-engine-manager/manifests/vllm.json b/services/nvpair-engine-manager/manifests/vllm.json index e3151bfad..79da1f004 100644 --- a/services/nvpair-engine-manager/manifests/vllm.json +++ b/services/nvpair-engine-manager/manifests/vllm.json @@ -3,7 +3,6 @@ "engine": "vllm", "display_name": "vLLM", "manifest_version": 1, - "advertised_models": "loaded", "install": { "mode": "user" }, "models_dir": "~/.cache/huggingface", "uninstall": { "remove": ["{install_dir}"] }, @@ -101,8 +100,8 @@ "hf_pull": { "tool": "hf", "dir": "{models_dir}/hub" } }, "loaded_models": { - "description": "List the model this vLLM server is serving (it serves exactly one). This is what peers and the proxy are told it can answer for.", - "http": { "method": "GET", "path": "/v1/models" }, + "description": "List the model this vLLM server has loaded (it serves exactly one). The gateway's plain model list also names every downloaded model, so this asks for only the resident one.", + "http": { "method": "GET", "path": "/v1/models?resident=1" }, "result": { "array": "data", "field": "id" } } } diff --git a/services/nvpair-engine-manager/models.go b/services/nvpair-engine-manager/models.go index 746d493dd..9d4781a9b 100644 --- a/services/nvpair-engine-manager/models.go +++ b/services/nvpair-engine-manager/models.go @@ -115,11 +115,18 @@ func (e *Executor) ModelsResult(ctx context.Context) ModelsResult { if st.Idle && loadedSpec != nil { // Unloaded for being idle: nothing is resident, but the engine still // answers for the model it is set to serve, by loading it on demand. - if model := e.idleModelOf(name); model != "" { - idleModel[i] = model - loaded[i], loadedOK[i] = []string{}, true + model := e.idleModelOf(name) + if model == "" { + return } - return + idleModel[i] = model + loaded[i], loadedOK[i] = []string{}, true + if listSpec == nil { + return + } + // An engine that advertises its inventory goes on doing so while it + // is unloaded: it loads whichever of those models a request names. + loadedSpec = nil } if listSpec != nil { if raw, err := e.Action(ctx, name, "list_models", nil); err != nil { diff --git a/services/nvpair-engine-manager/settings.go b/services/nvpair-engine-manager/settings.go index 66b75587b..921a4d8cf 100644 --- a/services/nvpair-engine-manager/settings.go +++ b/services/nvpair-engine-manager/settings.go @@ -421,17 +421,9 @@ func (e *Executor) ConfigureLaunch(ctx context.Context, p settings.Configure, re return e.launchStateLocked(p.Engine, st), err } } - if changed || !reflect.DeepEqual(st.plat.Runtime.LaunchArgs, preview.Args) || !reflect.DeepEqual(st.plat.Runtime.LaunchEnv, preview.Env) { - if err := e.persistRuntimeConfig(p.Engine, preview.Settings.ServerPort, &preview.Args, &preview.Env); err != nil { - return e.launchStateLocked(p.Engine, st), err - } + if err := e.commitLaunchLocked(st, p.Engine, preview, changed); err != nil { + return e.launchStateLocked(p.Engine, st), err } - st.mu.Lock() - st.plat.Runtime.Port = preview.Settings.ServerPort - st.plat.Runtime.LaunchArgs = preview.Args - st.plat.Runtime.LaunchEnv = preview.Env - st.port = preview.Settings.ServerPort - st.mu.Unlock() if err := rebind(); err != nil { e.emitState(p.Engine) return e.launchStateLocked(p.Engine, st), err @@ -450,4 +442,22 @@ func (e *Executor) ConfigureLaunch(ctx context.Context, p settings.Configure, re return e.launchStateLocked(p.Engine, st), nil } +// commitLaunchLocked saves a previewed launch and makes it the one the engine's +// next start uses. The caller holds the lifecycle lock and has stopped the engine +// if it was running on the old launch. +func (e *Executor) commitLaunchLocked(st *engineState, engine string, preview settings.Preview, changed bool) error { + if changed || !reflect.DeepEqual(st.plat.Runtime.LaunchArgs, preview.Args) || !reflect.DeepEqual(st.plat.Runtime.LaunchEnv, preview.Env) { + if err := e.persistRuntimeConfig(engine, preview.Settings.ServerPort, &preview.Args, &preview.Env); err != nil { + return err + } + } + st.mu.Lock() + st.plat.Runtime.Port = preview.Settings.ServerPort + st.plat.Runtime.LaunchArgs = preview.Args + st.plat.Runtime.LaunchEnv = preview.Env + st.port = preview.Settings.ServerPort + st.mu.Unlock() + return nil +} + type settingsApplyContextKey struct{} diff --git a/services/nvpair-engine-manager/testdata/fakeengine/main.go b/services/nvpair-engine-manager/testdata/fakeengine/main.go index e6e39c68f..1446bfa15 100644 --- a/services/nvpair-engine-manager/testdata/fakeengine/main.go +++ b/services/nvpair-engine-manager/testdata/fakeengine/main.go @@ -194,10 +194,26 @@ func main() { time.Sleep(delay) } + served := "" + for i := 1; i+1 < len(os.Args); i++ { + if os.Args[i] == "--model" { + served = os.Args[i+1] + } + } + if failModel := os.Getenv("FAKE_FAIL_MODEL"); failModel != "" && failModel == served { + log.Fatalf("cannot load model %s", served) + } + mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + // Inference answers carry served_by, the model the process was launched with, + // so a test can tell which model served a request. /v1/slow takes a while. + mux.HandleFunc("/v1/slow", func(w http.ResponseWriter, r *http.Request) { + time.Sleep(700 * time.Millisecond) + _ = json.NewEncoder(w).Encode(map[string]string{"served_by": served}) + }) // Ollama-shape model API (stateful). mux.HandleFunc("/api/tags", func(w http.ResponseWriter, r *http.Request) { ms := make([]map[string]string, 0) @@ -328,7 +344,7 @@ func main() { mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) { b := decodeBody(r) w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"model": bstr(b, "model"), "object": "chat.completion", + _ = json.NewEncoder(w).Encode(map[string]any{"model": bstr(b, "model"), "served_by": served, "object": "chat.completion", "choices": []map[string]any{{"index": 0, "message": map[string]string{"role": "assistant", "content": "ok"}, "finish_reason": "stop"}}}) }) mux.HandleFunc("/api/error", func(w http.ResponseWriter, r *http.Request) { diff --git a/services/nvpair-engine-manager/vllm_manifest_test.go b/services/nvpair-engine-manager/vllm_manifest_test.go index ca3777ccb..af0993a47 100644 --- a/services/nvpair-engine-manager/vllm_manifest_test.go +++ b/services/nvpair-engine-manager/vllm_manifest_test.go @@ -92,12 +92,18 @@ func TestBundledVLLMManifest(t *testing.T) { t.Errorf("vllm must declare %s", name) } } - // list_models is what is on disk; only the model being served is advertised. + // list_models is what is on disk, and all of it is advertised: a request for + // a model that is not loaded switches the engine to it. if list := manifest.Actions["list_models"]; list.HFCache == nil || list.HFCache.Dir != "{models_dir}/hub" { t.Errorf("list_models must list the Hugging Face cache, got %+v", list) } - if manifest.AdvertisedModels != advertisedModelsLoaded { - t.Errorf("advertised_models = %q, want %q: peers and the proxy must only be offered the served model", manifest.AdvertisedModels, advertisedModelsLoaded) + if manifest.AdvertisedModels == advertisedModelsLoaded { + t.Errorf("advertised_models = %q: the gateway switches models on request, so peers and the proxy must be offered every cached model", manifest.AdvertisedModels) + } + // The gateway's plain /v1/models names every downloaded model, so reading what + // is resident has to ask for the resident one only, or every model shows as loaded. + if loaded := manifest.Actions["loaded_models"]; loaded.HTTP == nil || loaded.HTTP.Path != "/v1/models?resident=1" { + t.Errorf("loaded_models must read /v1/models?resident=1, got %+v", loaded.HTTP) } // Switching the model is a launch-argument change plus a restart, since the // engine has no load call. From 7c0f178f9d5504744861383ffaaad6303379e0b7 Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 28 Oct 2026 11:34:01 +0530 Subject: [PATCH 24/35] fix(nvpair-engine-manager): harden vLLM model switching after review - Re-check for a switch under the gateway lock before admitting a request, so a request cannot start on a model that is being unloaded or load the old one while the switch runs. - Verify the current model was really unloaded before saving the new one, and wait out a load that was only just starting. - Leave a name that is not a downloaded model (such as a --served-model-name alias) to the engine instead of answering 404. - Read the downloaded-model list once per interval for all requests, keep the last good list when a read fails, and bound the model-list body separately from the request peek. Signed-off-by: Tushar Khinvasara --- services/nvpair-engine-manager/README.md | 8 +- services/nvpair-engine-manager/front.go | 144 +++++++++++++++++- .../front_switch_test.go | 88 ++++++++++- 3 files changed, 225 insertions(+), 15 deletions(-) diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index f6b02be69..311504db0 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -124,13 +124,15 @@ serve loads it again. A request that names a different downloaded model than the one vLLM serves (the JSON `model` field of a POST under `/v1/` or the other inference paths below) switches vLLM to it and is then answered by it. The gateway reads only as -far as that field and forwards the body unchanged. Requests already running on +far as that field, at most the first 32 MiB of the body, and forwards the body +unchanged. Requests already running on the current model finish first, and nothing new is started on it meanwhile, so none is cut off. The gateway then unloads the model, saves `--model ` into the launch arguments (the settings show what is running), loads the new model behind the same gateway, and forwards the held request. Requests that arrive together -share one switch, and a request for yet another model waits for it. A model that -is not downloaded is answered 404 and nothing changes. A switch that fails is not +share one switch, and a request for yet another model waits for it. A name that +is not a downloaded model, such as an alias given with `--served-model-name`, is +left to vLLM to answer, and nothing changes. A switch that fails is not rolled back: the request gets a 503 naming the model and the reason, and vLLM is left unloaded but ON, so the next request loads whichever model it names. A switch is a full restart, so the proxy's 120 s limit is the ceiling for a very large model. diff --git a/services/nvpair-engine-manager/front.go b/services/nvpair-engine-manager/front.go index 4d8b5fd5c..21b732c48 100644 --- a/services/nvpair-engine-manager/front.go +++ b/services/nvpair-engine-manager/front.go @@ -103,6 +103,10 @@ type frontGateway struct { // modelsFn lists the models the engine can be switched to. The gateway adds // them to its model list, since the engine itself only names the one it serves. modelsFn func(ctx context.Context) []string + list modelList + // readMu lets one read of the downloaded models serve every request waiting + // for it. + readMu sync.Mutex } // switchState is one change of the served model. Every request that needs the @@ -118,9 +122,29 @@ type switchState struct { // because it is not in the cache. Nothing is switched or unloaded for it. var errModelNotDownloaded = errors.New("the model is not downloaded on this node") -// frontPeekLimit bounds how much of a request body is read to find its model. +// frontPeekLimit bounds how much of a request body is read to find its model. A +// request whose model comes after more than this (a chat with large inline +// images, say) is forwarded as it is, and the engine answers for its own model. const frontPeekLimit = 32 << 20 +// frontModelListLimit bounds the engine's own model list that the gateway extends. +const frontModelListLimit = 4 << 20 + +// frontListTTL is how long a read of the downloaded models is reused, and +// frontMissTTL how long before a model missing from it makes the cache be read +// again, so a model pulled a moment ago is found without every request that +// names an unknown model (an alias, say) scanning the disk. +const ( + frontListTTL = 5 * time.Second + frontMissTTL = time.Second +) + +// modelList is the downloaded models and when they were read. +type modelList struct { + at time.Time + models []string +} + // wakeState is one load of an unloaded engine. Every request that needs the // engine waits on done, so any number of them share a single start. type wakeState struct { @@ -207,13 +231,89 @@ func (g *frontGateway) finishSwitch(ss *switchState, err error) { close(ss.done) } +// downloaded returns the models the engine can be switched to. The cache is read +// again only when the last read is older than maxAge, and one read serves every +// request that asks meanwhile. A read that fails keeps the last list that worked +// and is not retried until maxAge has passed, so a failing disk is not scanned on +// every request. +func (g *frontGateway) downloaded(ctx context.Context, maxAge time.Duration) []string { + g.mu.Lock() + read, last := g.modelsFn, g.list + g.mu.Unlock() + if read == nil { + return nil + } + if !last.at.IsZero() && time.Since(last.at) < maxAge { + return last.models + } + g.readMu.Lock() + defer g.readMu.Unlock() + g.mu.Lock() + last = g.list + g.mu.Unlock() + if !last.at.IsZero() && time.Since(last.at) < maxAge { + return last.models // another request read it while this one waited + } + models := read(ctx) + g.mu.Lock() + defer g.mu.Unlock() + if models != nil { + g.list.models = models + } + g.list.at = time.Now() + return g.list.models +} + // listedModels is what the gateway adds to the engine's model list: the models // a request can switch to. func (g *frontGateway) listedModels(ctx context.Context) []string { - if g.modelsFn == nil { + return g.downloaded(ctx, frontListTTL) +} + +// canSwitchTo reports whether model is one the engine can be switched to: a +// downloaded model other than the one it serves. A name that is neither, such as +// an alias the engine was told to answer to, is left for the engine itself. +func (g *frontGateway) canSwitchTo(ctx context.Context, model string) bool { + g.mu.Lock() + served := g.model + g.mu.Unlock() + if model == "" || model == served { + return false + } + return slices.Contains(g.downloaded(ctx, frontListTTL), model) || slices.Contains(g.downloaded(ctx, frontMissTTL), model) +} + +// waitForWake returns once the load in progress, if any, has ended; with none in +// progress it waits a moment, so a caller that polls does not spin. +func (g *frontGateway) waitForWake(ctx context.Context) error { + g.mu.Lock() + ws := g.wakeSt + g.mu.Unlock() + if ws == nil { + select { + case <-time.After(50 * time.Millisecond): + return nil + case <-ctx.Done(): + return ctx.Err() + case <-g.closed: + return errors.New("the engine was stopped") + } + } + select { + case <-ws.done: return nil + case <-ctx.Done(): + return ctx.Err() + case <-g.closed: + return errors.New("the engine was stopped") } - return g.modelsFn(ctx) +} + +// isIdle reports whether the engine behind the gateway is unloaded. +func (g *frontGateway) isIdle() bool { + g.mu.Lock() + defer g.mu.Unlock() + return g.idle } // residentOnly reports whether a model list request asks for only the model the @@ -376,7 +476,11 @@ func (g *frontGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { requested := requestedModel(r) for { if wakeable(r) { - if ss := g.requestSwitch(requested); ss != nil { + want := "" + if g.canSwitchTo(r.Context(), requested) { + want = requested + } + if ss := g.requestSwitch(want); ss != nil { select { case <-ss.done: if ss.err != nil && ss.model == requested { @@ -392,6 +496,13 @@ func (g *frontGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } g.mu.Lock() + if g.swSt != nil && wakeable(r) { + // A switch began since the check above. A request admitted now would + // run on a model that is about to be unloaded, or start loading the old + // one, so it goes back to wait for the switch. + g.mu.Unlock() + continue + } if (g.idle || g.wakeSt != nil) && wakeable(r) { g.mu.Unlock() ws := g.requestWake() @@ -519,7 +630,7 @@ func newFrontHandler(backendPort int, listed func(context.Context) []string) htt if listed == nil || resp.StatusCode != http.StatusOK || resp.Request.Method != http.MethodGet || resp.Request.URL.Path != "/v1/models" || residentOnly(resp.Request) { return nil } - body, err := io.ReadAll(io.LimitReader(resp.Body, frontPeekLimit)) + body, err := io.ReadAll(io.LimitReader(resp.Body, frontModelListLimit)) _ = resp.Body.Close() if err != nil { return err @@ -617,12 +728,15 @@ func (e *Executor) startFront(st *engineState, engine string, rt Runtime, host s if err != nil { return nil, false, err } + // The gateway is already listening, so the callbacks are set under its lock. + g.mu.Lock() g.wakeFn = func(ctx context.Context) error { return e.wakeEngine(ctx, engine) } g.switchFn = func(ctx context.Context, model string) error { return e.switchFrontModel(ctx, st, engine, g, model) } g.modelsFn = func(ctx context.Context) []string { models, _ := e.cachedModels(ctx, engine) return models } + g.mu.Unlock() st.mu.Lock() previous := st.front st.front = g @@ -798,7 +912,25 @@ func (e *Executor) switchFrontModel(ctx context.Context, st *engineState, engine if err := g.drain(ctx); err != nil { return err } - e.unloadFront(st, engine, g, 0, true) + // Nothing is saved or loaded unless the current model is really gone: an + // engine that could not be unloaded would go on answering as the old model. + // A load that was only just starting when the unload ran is waited for, and + // then the unload is made again. + unloaded := false + for attempt := 0; attempt < 5 && !unloaded; attempt++ { + e.unloadFront(st, engine, g, 0, true) + if !g.alive() { + return errors.New("the engine was stopped") + } + if unloaded = g.isIdle(); !unloaded { + if err := g.waitForWake(ctx); err != nil { + return err + } + } + } + if !unloaded { + return errors.New("the model being served could not be unloaded") + } if err := e.setIdleModel(st, engine, g, model); err != nil { return err } diff --git a/services/nvpair-engine-manager/front_switch_test.go b/services/nvpair-engine-manager/front_switch_test.go index 2dc2522b5..9921d9db8 100644 --- a/services/nvpair-engine-manager/front_switch_test.go +++ b/services/nvpair-engine-manager/front_switch_test.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "sync" @@ -116,17 +117,15 @@ func TestFrontSwitch_SimultaneousRequestsForOneNewModelShareOneSwitch(t *testing require.Equal(t, 1, startCount(t, log)-before, "one start for six simultaneous requests") } -func TestFrontSwitch_ModelThatIsNotDownloadedIsRefusedAndNothingChanges(t *testing.T) { +func TestFrontSwitch_ModelThatIsNotDownloadedIsLeftToTheEngine(t *testing.T) { e, public, _ := startSwitchableEngine(t) + // An alias the engine was told to answer to, or a model nobody has: the + // gateway cannot switch to it, so the engine decides. The fake engine answers. status, body := askFor(t, public, "/v1/chat/completions", "org/missing") - require.Equal(t, http.StatusNotFound, status) - require.Contains(t, body, "not downloaded") - - status, body = askFor(t, public, "/v1/chat/completions", "org/m") require.Equal(t, http.StatusOK, status) require.Contains(t, body, `"served_by":"org/m"`, "the engine kept serving the model it had") - require.Equal(t, "org/m", servedModelOf(t, e)) + require.Equal(t, "org/m", servedModelOf(t, e), "nothing was switched or saved") } func TestFrontSwitch_FailedLoadLeavesTheEngineUnloadedAndNextRequestLoads(t *testing.T) { @@ -299,3 +298,80 @@ func TestWithListedModels_AddsOnlyTheModelsTheListLacks(t *testing.T) { require.Equal(t, `not json`, string(withListedModels([]byte(`not json`), []string{"org/b"})), "anything else passes through") } + +func TestFrontGateway_ReadsTheDownloadedModelsAgainOnlyWhenTheyAreStale(t *testing.T) { + reads := 0 + g := &frontGateway{model: "org/m", modelsFn: func(context.Context) []string { + reads++ + return []string{"org/m", "org/other"} + }} + + for range 5 { + require.True(t, g.canSwitchTo(context.Background(), "org/other")) + } + require.Equal(t, 1, reads, "repeated requests reuse one read of the cache") + + require.False(t, g.canSwitchTo(context.Background(), "org/m"), "the served model needs no switch") + require.Equal(t, 1, reads) + + require.False(t, g.canSwitchTo(context.Background(), "alias")) + require.False(t, g.canSwitchTo(context.Background(), "alias")) + require.LessOrEqual(t, reads, 2, "an unknown name does not scan the cache on every request") +} + +func TestFrontGateway_FailedReadKeepsTheLastListAndIsNotRetriedAtOnce(t *testing.T) { + reads, fail := 0, false + g := &frontGateway{model: "org/m", modelsFn: func(context.Context) []string { + reads++ + if fail { + return nil + } + return []string{"org/m", "org/other"} + }} + require.True(t, g.canSwitchTo(context.Background(), "org/other")) + + fail = true + g.mu.Lock() + g.list.at = time.Now().Add(-time.Minute) // the list has gone stale + g.mu.Unlock() + for range 4 { + require.True(t, g.canSwitchTo(context.Background(), "org/other"), "a failed read does not forget the models") + } + require.Equal(t, 2, reads, "one failed read, then none until the next interval") +} + +func TestFrontSwitch_ModelDeletedAfterTheListWasReadIsRefused(t *testing.T) { + e, public, _ := startSwitchableEngine(t) + require.Contains(t, listedIDs(t, public), "org/other") // the list is now cached + + st, err := e.state("fronted") + require.NoError(t, err) + cache := st.manifest.Actions["list_models"].HFCache.Dir + require.NoError(t, os.RemoveAll(filepath.Join(cache, "models--org--other"))) + + status, body := askFor(t, public, "/v1/chat/completions", "org/other") + require.Equal(t, http.StatusNotFound, status, "the switch checks the cache itself, not the cached list") + require.Contains(t, body, "not downloaded") + require.Equal(t, "org/m", servedModelOf(t, e)) +} + +func TestFrontSwitch_DifferentModelsRequestedTogetherFromAnUnloadedEngineEachGetTheirOwn(t *testing.T) { + e, public, backend := startSwitchableEngine(t) + _, err := e.Action(context.Background(), "fronted", "unload_model", []byte(`{}`)) + require.NoError(t, err) + require.False(t, up(backend)) + + var wg sync.WaitGroup + answers := make([]string, 2) + for i, model := range []string{"org/m", "org/other"} { + wg.Add(1) + go func() { + defer wg.Done() + status, body := askFor(t, public, "/v1/chat/completions", model) + answers[i] = fmt.Sprintf("%d %s", status, body) + }() + } + wg.Wait() + require.Contains(t, answers[0], `"served_by":"org/m"`) + require.Contains(t, answers[1], `"served_by":"org/other"`) +} From 70abdbc5c3f596b1a9e16480e037a4f9b1f3420d Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Wed, 28 Oct 2026 23:33:36 +0530 Subject: [PATCH 25/35] docs: bring the vLLM docs up to date with the branch Several docs described vLLM as it was before later commits in this series: - vLLM also runs on Apple Silicon macOS, not only Linux. - Every cached model is advertised, and a request for one that is not loaded switches vLLM to it; the docs still said only the served model was advertised and other requests were refused. - The PAIR gateway listens on 8001 with vllm serve behind it on loopback 18001; the docs still put vllm serve on 8001 and said vLLM had no load, unload, pull or delete actions. Also document GET /v1/models?resident=1, and say "vLLM engine" rather than "vLLM backend" in the services parity table. Signed-off-by: Tushar Khinvasara --- README.md | 4 ++-- desktop/docs/services-parity.md | 22 +++++++++---------- docs/engine-lifecycle.mdx | 28 +++++++++++++++--------- docs/getting-started.mdx | 2 +- services/nvpair-engine-manager/README.md | 21 ++++++++++-------- services/nvpair-proxy/README.md | 2 +- services/nvpair-ui-broker/README.md | 3 ++- services/readme.md | 4 ++-- 8 files changed, 49 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index df7dc5c48..eb3957e61 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ one, and both report live GPU and memory use throughout. | **Architectures** | x64 and arm64 on all three. Windows on ARM is experimental. | | **Installers** | Windows `.exe`; Linux `.deb`; macOS `.dmg`. On other Linux distributions, [build from source](docs/building.mdx). | | **Mixing nodes** | Windows, Linux, and macOS nodes can all be paired with each other | -| **Inference engines** | Ollama, LM Studio, llama.cpp, and vLLM (Linux only) | +| **Inference engines** | Ollama, LM Studio, llama.cpp, and vLLM (Linux with an NVIDIA GPU, or Apple Silicon macOS) | **PAIR running on a machine does not mean an engine will.** PAIR itself runs on any supported Windows, Linux, or macOS machine. Each engine sets its own requirements @@ -100,7 +100,7 @@ you want by its full filename instead. **Settings → Service** and read the status there. - **Get an engine running.** On the node's card, open **Engine settings** and - select **Install** next to Ollama, LM Studio, llama.cpp, or (on Linux) vLLM. PAIR downloads and + select **Install** next to Ollama, LM Studio, llama.cpp, or (on Linux or Apple Silicon macOS) vLLM. PAIR downloads and sets the engine up for you, so nothing needs to be in place beforehand. ![The Install engines dialog with Ollama downloading, reporting progress as it installs.](docs/assets/onboarding/engine-lifecycle/01-engine-installing.png) diff --git a/desktop/docs/services-parity.md b/desktop/docs/services-parity.md index 41609b1bd..33597c30d 100644 --- a/desktop/docs/services-parity.md +++ b/desktop/docs/services-parity.md @@ -25,7 +25,7 @@ history. | Ollama routing | Complete | Broker relay and backend scheduler drive proxy routing | | LM Studio routing | Complete | Parallel broker relay and scheduler path | | llama.cpp backend | Integrated | Desktop and TUI expose install, lifecycle, catalog pull, inventory, load/unload, endpoints, routing, and demo traffic | -| vLLM backend | Integrated (Linux) | Desktop and TUI expose install, lifecycle, the cached-model list, switching the served model (Load), Hugging Face catalog search and download, endpoints and routing; eject (stops the engine), delete of cached models; no demo traffic | +| vLLM engine | Integrated (Linux, macOS arm64) | Desktop and TUI expose install, lifecycle, the cached-model list, switching the served model (Load), Hugging Face catalog search and download, endpoints and routing; eject (stops the engine), delete of cached models; no demo traffic | | Local engine lifecycle | Complete | Install, start, stop, uninstall, update, and port configuration | | Remote engine lifecycle | Partial | Remote install, start, stop, status, and model pull are supported | | Engine models | Partial | Core list, pull, load, unload, and supported delete actions are wired | @@ -185,10 +185,11 @@ rather than driver-aware or offline-repacked. ### vLLM support -The bundled `vllm` manifest is Linux only. It installs a pinned `vllm` wheel -into a private virtualenv through a checksum-pinned `uv` bootstrap, or adopts a -`vllm` already installed at a detected path, then runs `vllm serve` on `8001` -behind the proxy facade on `8000`. vLLM serves one model per process and has no +The bundled `vllm` manifest covers Linux (NVIDIA GPU) and Apple Silicon macOS +(vLLM-Metal). It installs a pinned vLLM release into a private virtualenv +through a checksum-pinned `uv` bootstrap, or adopts a `vllm` already installed +at a detected path. The engine's PAIR gateway listens on `8001`, with the +`vllm serve` process on loopback `18001`, behind the proxy facade on `8000`. vLLM serves one model per process and has no load or unload API, so the engine manager puts a gateway on its port (`front`): Start and the restore after a restart bring it up idle with nothing loaded, the first request or a Load loads the model, it unloads after an idle limit (five @@ -199,14 +200,13 @@ as "Idle · loads on next request" (`idle` on the engine status). Its model list (`list_models` on disk) is kept while stopped. It downloads and deletes through the shared Hugging Face cache (`pull_model` is an `hf_pull` action, `delete_model` an `hf_cache` delete that refuses the model it is set to serve), -and has a Hugging Face catalog. Its model list is the Hugging Face cache -(`list_models` is an `hf_cache` action, so it works while the engine is -stopped), and Load switches the served model through `load_model` (a +and has a Hugging Face catalog. Load switches the served model through `load_model` (a `launch_model` action): it saves `--model ` into the launch arguments and restarts the engine, restoring the previous model if the new one does not start. -Only the served model is advertised to peers and the proxy -(`advertised_models: loaded`), since a request for any other cached model would -fail at the engine. A switch passes through a stopped engine for the length of the +Every cached model is advertised to peers and the proxy. A request that names +one that is not loaded switches vLLM to it through the gateway; if that switch +fails, the request gets a 503 and vLLM is left unloaded, with nothing rolled +back. A Load switch passes through a stopped engine for the length of the restart, so the desktop marks the engine as switching: its card shows starting rather than stopped, and its model list is kept, until the load request finishes (success, or failure with the previous model restored). Weights live in the shared diff --git a/docs/engine-lifecycle.mdx b/docs/engine-lifecycle.mdx index 50cf23aff..3b3e2757e 100644 --- a/docs/engine-lifecycle.mdx +++ b/docs/engine-lifecycle.mdx @@ -88,13 +88,17 @@ When you install an engine, consider the following: demand and is not part of the PAIR installer. Package choice is not driver-aware; llama.cpp retains CPU fallback when GPU acceleration is unavailable. -- vLLM is available on Linux with an NVIDIA GPU. If a `vllm` is already - installed at a known location (`~/.local/bin/vllm`), PAIR uses it and - downloads nothing. Otherwise PAIR downloads a checksum-pinned `uv`, creates a - private Python environment and installs a pinned vLLM release into it; this - is several GiB because it includes PyTorch and the CUDA libraries, and PAIR - first checks for an NVIDIA driver that supports CUDA 13 or newer and about - 15 GB of free disk, and stops with a clear message if either is missing. +- vLLM is available on Linux with an NVIDIA GPU and on Apple Silicon Macs + running macOS 15 or newer (through vLLM-Metal). If a `vllm` is already + installed at a known location (`~/.local/bin/vllm` on Linux, + `~/.venv-vllm-metal/bin/vllm` on macOS), PAIR uses it and downloads nothing. + Otherwise PAIR downloads a checksum-pinned `uv`, creates a private Python + environment and installs a pinned vLLM release into it. On Linux this is + several GiB because it includes PyTorch and the CUDA libraries, and PAIR first + checks for an NVIDIA driver that supports CUDA 13 or newer and about 15 GB of + free disk. On macOS it installs checksum-pinned vLLM and vLLM-Metal wheels and + needs about 10 GB of free disk. Either way PAIR stops with a clear message if a + requirement is missing. Nothing is compiled. vLLM serves a single model per process. Its model list shows the complete text models already in your Hugging Face cache (`~/.cache/huggingface`), and **Load** on one of them switches vLLM to it by @@ -110,9 +114,13 @@ When you install an engine, consider the following: the model and frees the GPU, but stays on, and the next request loads it again; the "Unloads after … idle" control on the model row changes the time, or keeps it loaded. **Eject** does the same unload on demand, and **Stop** turns the - engine off. Other nodes and the proxy are told about the model vLLM is set to - serve, even while it is unloaded, so requests reach it; a request for a - different cached model is refused until you switch to it. + engine off. Other nodes and the proxy are told about every model in the cache, + even while vLLM is unloaded, so requests reach it. A request for a different + cached model waits for the requests already running to finish, then switches + vLLM to that model and is answered once it has loaded. Unlike **Load**, a + switch made by a request does not restore the previous model if the new one + fails: the request gets an error saying why, and vLLM stays on with nothing + loaded until the next request. PAIR-managed llama.cpp models sleep after five minutes without inference work. This releases model and KV-cache memory, and the next request wakes the model diff --git a/docs/getting-started.mdx b/docs/getting-started.mdx index f37faa1ff..967df7507 100644 --- a/docs/getting-started.mdx +++ b/docs/getting-started.mdx @@ -35,7 +35,7 @@ inference; two or more on the same local network let you try pairing and routing Nothing else has to be in place first: -- **Engines.** PAIR installs and starts Ollama, LM Studio, llama.cpp, or (on Linux) vLLM for you +- **Engines.** PAIR installs and starts Ollama, LM Studio, llama.cpp, or (on Linux or Apple Silicon macOS) vLLM for you in step 4. Existing Ollama and LM Studio installations can also be detected and used. - **Models.** PAIR downloads models for you in step 4. A request needs only one diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index 311504db0..4374738dc 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -46,11 +46,12 @@ with a CUDA 13 driver) and Apple Silicon macOS (`darwin/arm64`, macOS 15 or newe through the vLLM-Metal plugin). There is no native Windows or Intel Mac path. The macOS install builds a managed Python 3.12 venv with a sha256-pinned `uv` and installs the pinned vLLM core and vLLM-Metal wheels, each checked against a -digest, so no compiler is needed. It runs `vllm serve` on loopback port `8001`, -and the proxy facade owns `8000`. vLLM serves **one model per process** and has -no load, unload, pull or delete API, so the manifest declares only `list_models` -and `loaded_models` (both `GET /v1/models`). Changing the model means editing the -launch arguments and restarting the engine. +digest, so no compiler is needed. The engine's PAIR gateway listens on `8001` +and runs `vllm serve` behind it on loopback `18001` (see below); the proxy facade +owns `8000`. vLLM serves **one model per process** and has no load, unload, pull +or delete API of its own, so the manifest provides them as manager actions: +`load_model`, `unload_model`, `pull_model`, `delete_model` and the idle-limit +actions, described below. The default launch serves `Qwen/Qwen2.5-0.5B-Instruct` through `--model `; edit that argument to serve another model. Only @@ -112,8 +113,8 @@ rolled back. After 5 minutes with no inference request (`front.idle_timeout_s`, 0 to never) the manager stops the `vllm serve` process, which frees the GPU, but keeps the gateway and the engine ON: `engine:status` reports `idle: true`, `/health` still -answers 200, and `engine:models` still advertises the model it is set to serve -with nothing loaded. Health checks, monitors and the model list do not count as +answers 200, and `engine:models` still advertises every cached model with +nothing loaded. Health checks, monitors and the model list do not count as activity, and a request still running keeps the engine loaded. The limit is read and changed with the `get_idle_timeout` and `set_idle_timeout` actions (seconds, 0 never); a change applies at once and is saved in the engine's override file. @@ -139,6 +140,8 @@ is a full restart, so the proxy's 120 s limit is the ceiling for a very large mo The gateway's `GET /v1/models`, which the proxy builds its own model list from, names every downloaded model, not just the loaded one: it adds them to the engine's answer while vLLM is up and answers with them while it is unloaded. +`GET /v1/models?resident=1` returns only the model actually resident, which is +what the `loaded_models` action uses. A request that needs the model (a POST under `/v1/`, or to `/tokenize`, `/detokenize`, `/pooling`, `/score`, `/rerank` or `/classify`) and arrives while it @@ -148,8 +151,8 @@ once it is ready, so the first request waits for the load (about 25 s for an 8B model). Requests that arrive together share one start, a client that gives up does not abandon the load, and the proxy's 120 s limit is the ceiling for a very large model. While it is unloaded and while it loads, the engine stays marked -running and idle, goes on advertising the model it is set to serve, and the -gateway answers `GET /v1/models` with that model, so the proxy, which routes by +running and idle, goes on advertising every cached model, and the gateway +answers `GET /v1/models` with them, so the proxy, which routes by the advertised models and builds its own list from that call, keeps sending the request here instead of refusing it. A load that fails answers 503 saying so, and leaves the engine idle, not stopped, so the next request tries again. Reads such as `/health` and diff --git a/services/nvpair-proxy/README.md b/services/nvpair-proxy/README.md index 059268303..b89b5e430 100644 --- a/services/nvpair-proxy/README.md +++ b/services/nvpair-proxy/README.md @@ -120,7 +120,7 @@ forwarded verbatim, which is how `/api/show`, `/api/pull`, `/api/ps`, `/api/version` and `OPTIONS` preflights keep working. The broker enables all four facades by default. Local llama.cpp-compatible -clients use `8080`, while the managed `llama-server` stays on `8081`. vLLM clients use `8000`, while the managed `vllm serve` stays on `8001`. +clients use `8080`, while the managed `llama-server` stays on `8081`. vLLM clients use `8000`, while vLLM's PAIR gateway stays on `8001` (the `vllm serve` process runs behind it on loopback `18001`). `--proxy-engines` can restrict a standalone broker or TUI to a subset. ### HTTP Reverse Proxy diff --git a/services/nvpair-ui-broker/README.md b/services/nvpair-ui-broker/README.md index 10b0af5c5..85e9170f5 100644 --- a/services/nvpair-ui-broker/README.md +++ b/services/nvpair-ui-broker/README.md @@ -123,7 +123,8 @@ withdrawn or answered with an error on its own, leaving the others serving. Ollama's standalone default is `:11435`; with managed port ownership enabled (the default), the broker starts settings and engine-manager first, claims `:11434` with that facade, and only then moves a stopped default-port Ollama backend to a free port. Custom backend ports are preserved. When the inherited `OLLAMA_HOST` names a distinct local plaintext port, the broker also gives the facade that normalized loopback-only alias so clients already using the variable enter the same routing path; `localhost` reserves both canonical loopback families atomically, while remote and HTTPS targets are ignored. The alias port is reserved against every configured engine, local or remote engine start override, every facade's control plane, and every managed engine's backend port plan, so a backend that has to move can never land on the alias. A running Ollama or unknown owner on either requested port is never stopped or moved: the primary uses a safe fallback when needed, and an occupied alias remains with its owner while the broker reports a warning. vLLM is a managed engine handled exactly like llama.cpp below, with its -engine on `:8001` and its default-enabled facade on `:8000` or a safe fallback. +engine's PAIR gateway on `:8001` (the `vllm serve` process runs behind it on +loopback `:18001`) and its default-enabled facade on `:8000` or a safe fallback. llama.cpp is a managed engine: engine-manager owns its lifecycle and starts it on its configured loopback port, `:8081` by default. The broker places its diff --git a/services/readme.md b/services/readme.md index 61595db4c..4266b383b 100644 --- a/services/readme.md +++ b/services/readme.md @@ -31,10 +31,10 @@ never intercepted. The llama.cpp and vLLM facades are enabled by default alongside Ollama and LM Studio. The llama.cpp facade exposes OpenAI-compatible traffic on `http://localhost:8080` while the managed router runs on `8081`. The vLLM facade (the engine itself runs on -Linux and Apple Silicon macOS) uses `http://localhost:8000` with its managed engine on `8001`. A safe fallback is reported when a facade port is +Linux and Apple Silicon macOS) uses `http://localhost:8000` with its managed engine's gateway on `8001`. A safe fallback is reported when a facade port is occupied. The desktop and TUI expose each engine's managed lifecycle and inventory, plus llama.cpp's downloads and load/unload actions; vLLM serves one -model per process, so changing its model means restarting it. `--proxy-engines` can still restrict the +model per process, so a request for another cached model switches it to that model. `--proxy-engines` can still restrict the facades a standalone broker or TUI starts. PAIR ships a graphical UI alongside these services. The UI launches From 86e94a42be223bd16c8d5216312cc496b35c5ed8 Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Thu, 29 Oct 2026 10:12:32 +0530 Subject: [PATCH 26/35] feat(nvpair-engine-manager): find a model's vLLM tool-call parser Add a probe that asks the installed vLLM which of its tool-call parsers reads a model's tool-call format. It runs with vLLM's own Python, renders the model's chat template with one example tool call, and offers that text to every registered parser; a parser matches when it extracts exactly that call. A tokenizer that ships response_template metadata gets vLLM's hf parser. Nothing is downloaded and no code from the model repository runs. Among parsers that read a format equally, the general-purpose ones are preferred. Results are kept per vLLM binary and snapshot for the life of the service; a failed probe is tried again on the next start. Signed-off-by: Tushar Khinvasara --- services/nvpair-engine-manager/launchauto.go | 12 +- services/nvpair-engine-manager/toolparser.go | 126 ++++++++++++++++++ .../nvpair-engine-manager/toolparser_probe.py | 117 ++++++++++++++++ .../nvpair-engine-manager/toolparser_test.go | 112 ++++++++++++++++ 4 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 services/nvpair-engine-manager/toolparser.go create mode 100644 services/nvpair-engine-manager/toolparser_probe.py create mode 100644 services/nvpair-engine-manager/toolparser_test.go diff --git a/services/nvpair-engine-manager/launchauto.go b/services/nvpair-engine-manager/launchauto.go index 9d9f00fe0..c6d46ccfd 100644 --- a/services/nvpair-engine-manager/launchauto.go +++ b/services/nvpair-engine-manager/launchauto.go @@ -146,10 +146,16 @@ func (c hfModelConfig) dtypeBytes() int64 { // readHFModel finds the model's complete snapshot in the Hugging Face hub cache // and returns its parsed config and size on disk. func readHFModel(hubDir, id string) (cfg hfModelConfig, size int64, ok bool) { + _, cfg, size, ok = findHFSnapshot(hubDir, id) + return cfg, size, ok +} + +// findHFSnapshot is readHFModel that also returns the snapshot's directory. +func findHFSnapshot(hubDir, id string) (dir string, cfg hfModelConfig, size int64, ok bool) { repo := filepath.Join(hubDir, "models--"+strings.ReplaceAll(id, "/", "--")) snapshots, err := os.ReadDir(filepath.Join(repo, "snapshots")) if err != nil { - return cfg, 0, false + return "", cfg, 0, false } for _, snapshot := range snapshots { dir := filepath.Join(repo, "snapshots", snapshot.Name()) @@ -161,9 +167,9 @@ func readHFModel(hubDir, id string) (cfg hfModelConfig, size int64, ok bool) { if err != nil || json.Unmarshal(raw, &cfg) != nil { continue } - return cfg, bytes, true + return dir, cfg, bytes, true } - return cfg, 0, false + return "", cfg, 0, false } // planHFContext decides the extra vLLM arguments for the model named in args: diff --git a/services/nvpair-engine-manager/toolparser.go b/services/nvpair-engine-manager/toolparser.go new file mode 100644 index 000000000..901e56a7b --- /dev/null +++ b/services/nvpair-engine-manager/toolparser.go @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "sync" + "time" +) + +// toolParserProbeScript asks the installed vLLM which of its tool-call parsers +// read a model's tool-call format. See the script for how. +// +//go:embed toolparser_probe.py +var toolParserProbeScript string + +// toolParserProbeTimeout bounds one probe. It imports transformers and vLLM's +// parsers, which takes a few seconds once their files are in the page cache. +const toolParserProbeTimeout = 2 * time.Minute + +// toolParserProbe is what the probe found for one model. +type toolParserProbe struct { + // HF is true when the tokenizer ships response_template metadata, which + // vLLM's hf parser reads. + HF bool `json:"hf"` + // Matches are the parsers that extracted the model's example tool call. + Matches []string `json:"matches"` +} + +// probeToolParsers runs the probe with the Python next to the vLLM binary. It +// is a variable so tests can supply results without a vLLM install. +var probeToolParsers = runToolParserProbe + +// toolParserCache keeps each probe's result for the life of the service, keyed +// by vLLM binary and snapshot directory, so a model is probed once rather than +// on every start, wake and switch. A different vLLM or model revision is a +// different key. +var toolParserCache sync.Map + +// preferredToolParsers breaks ties when several parsers read a format equally +// well: Qwen's JSON is read alike by hermes, ernie45, granite4 and +// qwen3_json, and its XML form by qwen3_coder, qwen3_xml, seed_oss and step3p5. +// These are vLLM's general-purpose parsers for the common formats, tried in +// this order; any other match is taken in name order. +var preferredToolParsers = []string{"hermes", "qwen3_coder", "llama3_json", "mistral", "pythonic"} + +// chooseToolParser picks the parser to launch with, or "" when none fits. +func chooseToolParser(p toolParserProbe) string { + if p.HF { + return "hf" + } + for _, name := range preferredToolParsers { + if slices.Contains(p.Matches, name) { + return name + } + } + if len(p.Matches) == 0 { + return "" + } + return slices.Min(p.Matches) +} + +// vllmPython is the interpreter of the environment vLLM is installed in. +func vllmPython(bin string) (string, error) { + if resolved, err := filepath.EvalSymlinks(bin); err == nil { + bin = resolved + } + name := "python" + if runtime.GOOS == "windows" { + name = "python.exe" + } + python := filepath.Join(filepath.Dir(bin), name) + if _, err := os.Stat(python); err != nil { + return "", fmt.Errorf("no Python next to %s", bin) + } + return python, nil +} + +func runToolParserProbe(bin, snapshot string) (toolParserProbe, error) { + python, err := vllmPython(bin) + if err != nil { + return toolParserProbe{}, err + } + ctx, cancel := context.WithTimeout(context.Background(), toolParserProbeTimeout) + defer cancel() + // -I keeps the working directory and user site off the import path. + cmd := exec.CommandContext(ctx, python, "-I", "-c", toolParserProbeScript, snapshot) + cmd.Dir = os.TempDir() + out, err := cmd.Output() + if err != nil { + return toolParserProbe{}, fmt.Errorf("tool-call parser probe: %w", err) + } + lines := bytes.Split(bytes.TrimSpace(out), []byte("\n")) + var probe toolParserProbe + if err := json.Unmarshal(lines[len(lines)-1], &probe); err != nil { + return toolParserProbe{}, errors.New("tool-call parser probe printed no result") + } + return probe, nil +} + +// detectToolParser returns the parser for the model in snapshot, probing it the +// first time. A failed probe is not cached, so the next start tries again. +func detectToolParser(bin, snapshot string) (string, []string, error) { + key := bin + "\x00" + snapshot + if cached, ok := toolParserCache.Load(key); ok { + probe := cached.(toolParserProbe) + return chooseToolParser(probe), probe.Matches, nil + } + probe, err := probeToolParsers(bin, snapshot) + if err != nil { + return "", nil, err + } + toolParserCache.Store(key, probe) + return chooseToolParser(probe), probe.Matches, nil +} diff --git a/services/nvpair-engine-manager/toolparser_probe.py b/services/nvpair-engine-manager/toolparser_probe.py new file mode 100644 index 000000000..b63371137 --- /dev/null +++ b/services/nvpair-engine-manager/toolparser_probe.py @@ -0,0 +1,117 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Find the vLLM tool-call parsers that read a model's tool-call format. + +nvpair-engine-manager runs this with the Python of the vLLM it is about to +launch, passing the model's snapshot directory: + + python -I -c + +A model's chat template writes an earlier tool call back into the conversation +in the same format the model uses when it calls a tool. So the template is +rendered with one example call, and that text is offered to every parser the +installed vLLM provides; a parser matches when it extracts exactly that call. + +The last line printed is JSON: {"hf": bool, "matches": [parser names]}, where +hf means the tokenizer ships response_template metadata for vLLM's hf parser. +Nothing is downloaded, and no code from the model repository runs. +""" + +import json +import os +import sys + +os.environ["HF_HUB_OFFLINE"] = "1" +os.environ["TRANSFORMERS_OFFLINE"] = "1" + +ARGS = {"city": "Paris"} +TOOL = { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather in a city", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + }, +} +USER = {"role": "user", "content": "What is the weather in Paris?"} +RESULT = {"role": "tool", "tool_call_id": "call_1", "content": "sunny"} + + +def rendered_call(tok): + """Return the example call as the template writes it, or "" if it does not.""" + try: + prompt = tok.apply_chat_template([USER], tools=[TOOL], add_generation_prompt=True, tokenize=False) + base = tok.apply_chat_template([USER], tools=[TOOL], tokenize=False) + except Exception: + return "" + # Templates differ on whether arguments are an object or a JSON string. + for arguments in (ARGS, json.dumps(ARGS)): + call = { + "role": "assistant", + "content": "", + "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": arguments}}], + } + # Some templates render a final assistant turn as the generation prompt + # and drop its calls; a tool result after it makes them write the call. + for conversation in ([USER, call], [USER, call, RESULT]): + try: + full = tok.apply_chat_template(conversation, tools=[TOOL], tokenize=False) + except Exception: + continue + for prefix in (prompt, base, os.path.commonprefix([prompt, full])): + if full.startswith(prefix): + text = full[len(prefix):] + break + if "get_weather" in text and "Paris" in text: + return text + return "" + + +def main(): + from transformers import AutoTokenizer + + tok = AutoTokenizer.from_pretrained(sys.argv[1], trust_remote_code=False) + + hf = False + try: + from vllm.parser.response_template import validate_tokenizer_response_template + + validate_tokenizer_response_template(tok, reasoning=False, tools=True) + hf = True + except Exception: + pass + + matches = [] + text = rendered_call(tok) + if text: + try: + from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest + except ImportError: + from vllm.entrypoints.openai.protocol import ChatCompletionRequest + from vllm.tool_parsers import ToolParserManager + + request = ChatCompletionRequest(model="probe", messages=[USER], tools=[TOOL]) + for name in sorted(ToolParserManager.list_registered()): + if name == "hf": + continue + try: + parser = ToolParserManager.get_tool_parser(name)(tok, tools=request.tools) + result = parser.extract_tool_calls(text, request) + calls = result.tool_calls if result.tools_called else [] + if ( + len(calls) == 1 + and calls[0].function.name == "get_weather" + and json.loads(calls[0].function.arguments) == ARGS + ): + matches.append(name) + except Exception: + continue + + print(json.dumps({"hf": hf, "matches": matches})) + + +main() diff --git a/services/nvpair-engine-manager/toolparser_test.go b/services/nvpair-engine-manager/toolparser_test.go new file mode 100644 index 000000000..06d99ce7f --- /dev/null +++ b/services/nvpair-engine-manager/toolparser_test.go @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// withToolParserProbe replaces the probe of the installed vLLM, and empties the +// cache of earlier results, for one test. +func withToolParserProbe(t *testing.T, probe func(bin, snapshot string) (toolParserProbe, error)) { + t.Helper() + old := probeToolParsers + probeToolParsers = probe + toolParserCache.Clear() + t.Cleanup(func() { + probeToolParsers = old + toolParserCache.Clear() + }) +} + +func TestChooseToolParser_PrefersGeneralPurposeParsersAmongEqualMatches(t *testing.T) { + cases := []struct { + name string + probe toolParserProbe + want string + }{ + {"Qwen JSON in tool_call tags", toolParserProbe{Matches: []string{"ernie45", "granite4", "hermes", "qwen3_json"}}, "hermes"}, + {"Qwen XML function tags", toolParserProbe{Matches: []string{"qwen3_coder", "qwen3_xml", "seed_oss", "step3p5"}}, "qwen3_coder"}, + {"only uncommon parsers", toolParserProbe{Matches: []string{"step3p5", "seed_oss"}}, "seed_oss"}, + {"response template wins", toolParserProbe{HF: true, Matches: []string{"hermes"}}, "hf"}, + {"nothing matched", toolParserProbe{}, ""}, + } + for _, c := range cases { + if got := chooseToolParser(c.probe); got != c.want { + t.Errorf("%s: got %q, want %q", c.name, got, c.want) + } + } +} + +func TestDetectToolParser_ProbesEachModelOnce(t *testing.T) { + probes := 0 + withToolParserProbe(t, func(bin, snapshot string) (toolParserProbe, error) { + probes++ + return toolParserProbe{Matches: []string{"hermes"}}, nil + }) + + for range 3 { + if parser, _, err := detectToolParser("vllm", "/hub/a"); err != nil || parser != "hermes" { + t.Fatalf("got %q, %v", parser, err) + } + } + if probes != 1 { + t.Fatalf("probed %d times, want once for the same model", probes) + } + if _, _, err := detectToolParser("vllm", "/hub/c"); err != nil || probes != 2 { + t.Fatalf("another model must be probed on its own: %d probes, %v", probes, err) + } +} + +func TestDetectToolParser_RetriesAFailedProbe(t *testing.T) { + probes := 0 + withToolParserProbe(t, func(bin, snapshot string) (toolParserProbe, error) { + probes++ + if probes == 1 { + return toolParserProbe{}, errors.New("timed out") + } + return toolParserProbe{Matches: []string{"hermes"}}, nil + }) + + if _, _, err := detectToolParser("vllm", "/hub/a"); err == nil { + t.Fatal("the first probe's failure was not reported") + } + if parser, _, err := detectToolParser("vllm", "/hub/a"); err != nil || parser != "hermes" { + t.Fatalf("the next start must probe again: %q, %v", parser, err) + } +} + +// The probe run against the real vLLM install and the models in the local +// Hugging Face cache. NVPAIR_LIVE_VLLM_BIN names the vllm binary, and +// NVPAIR_LIVE_VLLM_TOOL_MODELS lists model=parser pairs to check, with an empty +// parser for a model no parser reads, for example +// "Qwen/Qwen3-8B=hermes,nvidia/NVIDIA-Nemotron-Nano-9B-v2=". +func TestToolParserProbe_FindsTheParserWithTheInstalledVLLM(t *testing.T) { + bin := os.Getenv("NVPAIR_LIVE_VLLM_BIN") + if bin == "" { + t.Skip("set NVPAIR_LIVE_VLLM_BIN and NVPAIR_LIVE_VLLM_TOOL_MODELS to probe real models with the installed vLLM") + } + home, _ := os.UserHomeDir() + hub := filepath.Join(home, ".cache", "huggingface", "hub") + for _, pair := range strings.Split(os.Getenv("NVPAIR_LIVE_VLLM_TOOL_MODELS"), ",") { + model, want, _ := strings.Cut(pair, "=") + snapshot, _, _, ok := findHFSnapshot(hub, model) + if !ok { + t.Errorf("%s is not in %s", model, hub) + continue + } + probe, err := runToolParserProbe(bin, snapshot) + if err != nil { + t.Errorf("%s: %v", model, err) + continue + } + if got := chooseToolParser(probe); got != want { + t.Errorf("%s: chose %q from %+v, want %q", model, got, probe, want) + } + } +} From 8c6f7737d16e45fc405472bab67af30b96bde9b7 Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Thu, 29 Oct 2026 10:12:48 +0530 Subject: [PATCH 27/35] feat(nvpair-engine-manager): choose vLLM's tool-call parser per model Tool calling was enabled only for the Qwen families, with the hermes parser, so every other model answered HTTP 400 to agent clients, which send tools with tool_choice "auto" on every request. Replace the family table with the probe: a vLLM launch now gets --enable-auto-tool-choice and the parser that reads the model's own tool-call format, or no tool flags when none does. A parser named in the launch arguments is still used as given. Checked against vLLM 0.32.0: Qwen2.5 and Qwen3 get hermes as before; Qwen3.5 and Granite 4.2 now get qwen3_coder; Nemotron Nano v2 and Qwen2.5-VL get none. Signed-off-by: Tushar Khinvasara --- services/nvpair-engine-manager/MANIFEST.md | 5 +- services/nvpair-engine-manager/launchauto.go | 73 +++++++------- .../nvpair-engine-manager/launchauto_test.go | 98 +++++++++++++++---- 3 files changed, 119 insertions(+), 57 deletions(-) diff --git a/services/nvpair-engine-manager/MANIFEST.md b/services/nvpair-engine-manager/MANIFEST.md index 50ef2ccb9..60e9585c9 100644 --- a/services/nvpair-engine-manager/MANIFEST.md +++ b/services/nvpair-engine-manager/MANIFEST.md @@ -388,8 +388,9 @@ Hugging Face cache and the free GPU memory, then appends `--max-model-len` native window, and `--gpu-memory-utilization` sized to weights plus KV cache. Arguments the user saved always win and nothing added is written back to saved settings, so a model switch is re-sized for the new model. It also adds -`--enable-auto-tool-choice` and the matching `--tool-call-parser` for model -families known to work (Qwen: `hermes`). Tensor- or +`--enable-auto-tool-choice` and a `--tool-call-parser` found from the model's own +chat template with the installed vLLM (see the README's vLLM section), or nothing +when no parser reads the model's tool calls. Tensor- or pipeline-parallel launches are left alone. It requires a process-mode runtime with `editable_launch`. diff --git a/services/nvpair-engine-manager/launchauto.go b/services/nvpair-engine-manager/launchauto.go index c6d46ccfd..5b1b8e20e 100644 --- a/services/nvpair-engine-manager/launchauto.go +++ b/services/nvpair-engine-manager/launchauto.go @@ -66,9 +66,9 @@ func applyAutoLaunch(engine string, rt Runtime, command launchCommand, modelsDir } hub := filepath.Join(expandPath(modelsDir), "hub") extra, note := planHFContext(command.Args, hub, int64(rt.AutoLaunch.ContextTokens)) - if tools := planToolCalling(command.Args, hub); len(tools) > 0 { + if tools, parser := planToolCalling(command.Args, hub, command.Bin); len(tools) > 0 { extra = append(extra, tools...) - note += " tool calling enabled" + note = strings.TrimPrefix(note+"; tool calls parsed with "+parser, "; ") } if len(extra) == 0 { return command @@ -309,47 +309,50 @@ func planHFContext(args []string, hubDir string, target int64) (extra []string, return extra, fmt.Sprintf("native window %d, target %d, weights %.1f GiB, GPU free %.1f GiB", native, target, float64(weights)/float64(gib), float64(free)/float64(gib)) } -// toolCallParsers maps a model's config.json model_type to the vLLM tool-call -// parser that understands its tool-call format. Only families whose parser has -// been run against a real model here are listed; others keep vLLM's default, -// where a request that sets tool_choice "auto" is rejected, and can name a -// parser in the launch arguments. -var toolCallParsers = map[string]string{ - "qwen2": "hermes", - "qwen2_moe": "hermes", - "qwen3": "hermes", - "qwen3_moe": "hermes", - "qwen2_vl": "hermes", - "qwen2_5_vl": "hermes", - "qwen3_vl": "hermes", - "qwen3_vl_moe": "hermes", -} - // planToolCalling returns the arguments that let clients send tool_choice -// "auto", which agents such as Hermes do on every request. vLLM answers HTTP 400 -// unless both --enable-auto-tool-choice and --tool-call-parser are set. A flag -// the user already supplied is left as given. -func planToolCalling(args []string, hubDir string) []string { +// "auto", which agent clients do on every request. vLLM answers HTTP 400 +// unless both --enable-auto-tool-choice and --tool-call-parser are set. The +// parser is the one the installed vLLM provides for the model's own tool-call +// format (see detectToolParser), so no list of model families is kept here. A +// flag the user already supplied is left as given. When no parser fits, nothing +// is added, and the gateway takes tools out of the requests vLLM would reject. +func planToolCalling(args []string, hubDir, bin string) (extra []string, parser string) { model, ok := launchFlagValue(args, "--model") if !ok || model == "" { - return nil + return nil, "" } - _, hasParser := launchFlagValue(args, "--tool-call-parser") + userParser, hasParser := launchFlagValue(args, "--tool-call-parser") _, hasEnable := launchFlagValue(args, "--enable-auto-tool-choice") if hasParser && hasEnable { - return nil - } - var extra []string - if !hasParser { - cfg, _, cached := readHFModel(hubDir, model) - parser := toolCallParsers[cfg.ModelType] - if !cached || parser == "" { - return nil - } - extra = append(extra, "--tool-call-parser", parser) + return nil, userParser + } + if hasParser { + return []string{"--enable-auto-tool-choice"}, userParser + } + snapshot, _, _, cached := findHFSnapshot(hubDir, model) + if !cached { + return nil, "" } + parser, matched, err := detectToolParser(bin, snapshot) + if err != nil { + slog.Warn("could not work out the model's tool-call format; requests with tools are answered without them", "model", model, "err", err) + return nil, "" + } + if parser == "" { + slog.Info("no vLLM tool-call parser reads this model's tool calls; requests with tools are answered without them", "model", model) + return nil, "" + } + slog.Info("vLLM tool-call parser chosen from the model's chat template", "model", model, "parser", parser, "matched", matched) + extra = []string{"--tool-call-parser", parser} if !hasEnable { extra = append(extra, "--enable-auto-tool-choice") } - return extra + return extra, parser +} + +// parsesToolCalls reports whether a vLLM launch can answer tool_choice "auto". +func parsesToolCalls(args []string) bool { + _, hasParser := launchFlagValue(args, "--tool-call-parser") + _, hasEnable := launchFlagValue(args, "--enable-auto-tool-choice") + return hasParser && hasEnable } diff --git a/services/nvpair-engine-manager/launchauto_test.go b/services/nvpair-engine-manager/launchauto_test.go index 84869c964..46b310071 100644 --- a/services/nvpair-engine-manager/launchauto_test.go +++ b/services/nvpair-engine-manager/launchauto_test.go @@ -5,6 +5,7 @@ package main import ( "encoding/json" + "errors" "os" "path/filepath" "slices" @@ -226,32 +227,89 @@ func TestApplyAutoLaunchAppendsAfterTheUsersArguments(t *testing.T) { } } -func TestToolCallingIsEnabledForKnownFamilies(t *testing.T) { - cases := map[string]string{"qwen3": "hermes", "qwen2": "hermes", "qwen3_vl": "hermes"} - for modelType, parser := range cases { - cfg := strings.Replace(qwen8BConfig, `{`, `{"model_type":"`+modelType+`",`, 1) - cache := cachedModel(t, "org/m", cfg, 1_000_000) - extra := planToolCalling([]string{"--model", "org/m"}, cache) - if flagOf(t, extra, "--tool-call-parser") != parser || !slices.Contains(extra, "--enable-auto-tool-choice") { - t.Errorf("%s: got %v, want parser %s and auto tool choice", modelType, extra, parser) - } +func TestPlanToolCalling_UsesTheParserThatReadsTheModelsToolCalls(t *testing.T) { + cache := cachedModel(t, "org/m", qwen8BConfig, 1_000_000) + var probedSnapshot string + withToolParserProbe(t, func(bin, snapshot string) (toolParserProbe, error) { + probedSnapshot = snapshot + return toolParserProbe{Matches: []string{"ernie45", "granite4", "hermes", "qwen3_json"}}, nil + }) + + extra, parser := planToolCalling([]string{"--model", "org/m"}, cache, "/opt/vllm/venv/bin/vllm") + + if parser != "hermes" || flagOf(t, extra, "--tool-call-parser") != "hermes" || !slices.Contains(extra, "--enable-auto-tool-choice") { + t.Fatalf("got %v (%q), want the hermes parser and auto tool choice", extra, parser) + } + if !strings.Contains(probedSnapshot, filepath.Join("models--org--m", "snapshots")) { + t.Fatalf("probed %q, want the model's snapshot directory", probedSnapshot) + } +} + +func TestPlanToolCalling_PrefersTheModelsOwnResponseTemplate(t *testing.T) { + cache := cachedModel(t, "org/m", qwen8BConfig, 1_000_000) + withToolParserProbe(t, func(bin, snapshot string) (toolParserProbe, error) { + return toolParserProbe{HF: true, Matches: []string{"hermes"}}, nil + }) + + extra, _ := planToolCalling([]string{"--model", "org/m"}, cache, "vllm") + + if flagOf(t, extra, "--tool-call-parser") != "hf" { + t.Fatalf("got %v, want the hf parser for a model that ships response_template", extra) } } -func TestToolCallingLeavesUnknownFamiliesAndUserChoicesAlone(t *testing.T) { - unknown := cachedModel(t, "org/m", strings.Replace(qwen8BConfig, `{`, `{"model_type":"someother",`, 1), 1_000_000) - if extra := planToolCalling([]string{"--model", "org/m"}, unknown); len(extra) != 0 { - t.Fatalf("a family with no verified parser must be left as vLLM defaults: %v", extra) +func TestPlanToolCalling_AddsNothingWhenNoParserCanBeChosen(t *testing.T) { + cache := cachedModel(t, "org/m", qwen8BConfig, 1_000_000) + cases := map[string]func(bin, snapshot string) (toolParserProbe, error){ + "no parser reads the format": func(bin, snapshot string) (toolParserProbe, error) { return toolParserProbe{}, nil }, + "the probe fails": func(bin, snapshot string) (toolParserProbe, error) { return toolParserProbe{}, errors.New("no python") }, + } + for name, probe := range cases { + withToolParserProbe(t, probe) + if extra, parser := planToolCalling([]string{"--model", "org/m"}, cache, "vllm"); len(extra) != 0 || parser != "" { + t.Errorf("%s: got %v (%q), want vLLM's defaults", name, extra, parser) + } } - if extra := planToolCalling([]string{"--model", "org/not-cached"}, t.TempDir()); len(extra) != 0 { - t.Fatalf("an uncached model has no known family: %v", extra) + + withToolParserProbe(t, func(bin, snapshot string) (toolParserProbe, error) { + t.Fatal("a model that is not downloaded has no template to probe") + return toolParserProbe{}, nil + }) + if extra, _ := planToolCalling([]string{"--model", "org/not-cached"}, t.TempDir(), "vllm"); len(extra) != 0 { + t.Fatalf("an uncached model got %v", extra) } - qwen := cachedModel(t, "org/q", strings.Replace(qwen8BConfig, `{`, `{"model_type":"qwen3",`, 1), 1_000_000) - if extra := planToolCalling([]string{"--model", "org/q", "--tool-call-parser", "qwen3_xml", "--enable-auto-tool-choice"}, qwen); len(extra) != 0 { +} + +func TestPlanToolCalling_KeepsTheParserTheUserChose(t *testing.T) { + cache := cachedModel(t, "org/q", qwen8BConfig, 1_000_000) + withToolParserProbe(t, func(bin, snapshot string) (toolParserProbe, error) { + t.Fatal("a parser the user named needs no probe") + return toolParserProbe{}, nil + }) + + if extra, _ := planToolCalling([]string{"--model", "org/q", "--tool-call-parser", "qwen3_xml", "--enable-auto-tool-choice"}, cache, "vllm"); len(extra) != 0 { t.Fatalf("the user set both: %v", extra) } - extra := planToolCalling([]string{"--model", "org/q", "--tool-call-parser", "qwen3_xml"}, qwen) - if slices.Contains(extra, "--tool-call-parser") || !slices.Contains(extra, "--enable-auto-tool-choice") { - t.Fatalf("the user's parser must stand and only the enable flag be added: %v", extra) + extra, parser := planToolCalling([]string{"--model", "org/q", "--tool-call-parser", "qwen3_xml"}, cache, "vllm") + if parser != "qwen3_xml" || !slices.Equal(extra, []string{"--enable-auto-tool-choice"}) { + t.Fatalf("got %v (%q), want only the enable flag beside the user's parser", extra, parser) + } +} + +func TestParsesToolCalls_NeedsBothFlags(t *testing.T) { + cases := map[string]struct { + args []string + want bool + }{ + "both flags": {[]string{"--model", "m", "--tool-call-parser", "hermes", "--enable-auto-tool-choice"}, true}, + "equals form": {[]string{"--tool-call-parser=hermes", "--enable-auto-tool-choice"}, true}, + "parser only": {[]string{"--tool-call-parser", "hermes"}, false}, + "enable flag only": {[]string{"--enable-auto-tool-choice"}, false}, + "neither": {[]string{"--model", "m"}, false}, + } + for name, c := range cases { + if got := parsesToolCalls(c.args); got != c.want { + t.Errorf("%s: got %v, want %v", name, got, c.want) + } } } From b4447432bd0b9f571efc4049768727dd9ff25b3f Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Thu, 29 Oct 2026 10:13:04 +0530 Subject: [PATCH 28/35] feat(nvpair-engine-manager): answer tool requests to a vLLM without a parser A model that no vLLM parser reads (Nemotron Nano v2, or a template with no tool support) still failed every chat from an agent client: vLLM rejects tool_choice "auto" without a parser, and such clients offer their tools on every request. The gateway now knows whether the running launch parses tool calls. When it does not, it removes tools, tool_choice and parallel_tool_calls from chat requests that leave tool_choice unset or "auto", so the model answers in plain text, and marks the response with X-PAIR-Tool-Calls. A request with tool_choice "required" or a named function is forwarded unchanged and gets vLLM's own error. Bodies are still never logged. Update the engine-manager README and the engine settings guide for the parser choice and this fallback. Signed-off-by: Tushar Khinvasara --- docs/engine-settings.mdx | 11 +- services/nvpair-engine-manager/README.md | 24 ++- services/nvpair-engine-manager/front.go | 77 ++++++++- .../nvpair-engine-manager/front_tools_test.go | 147 ++++++++++++++++++ services/nvpair-engine-manager/lifecycle.go | 6 + 5 files changed, 255 insertions(+), 10 deletions(-) create mode 100644 services/nvpair-engine-manager/front_tools_test.go diff --git a/docs/engine-settings.mdx b/docs/engine-settings.mdx index 1742eb5f4..8974d54f4 100644 --- a/docs/engine-settings.mdx +++ b/docs/engine-settings.mdx @@ -103,9 +103,14 @@ is smaller (for example Qwen3-8B's 40,960 tokens), and a GPU memory share just l enough for the weights and that context, so other engines can run alongside it. Arguments you add to the launch text, such as your own `--max-model-len` or `--gpu-memory-utilization`, take precedence, and a launch with -`--tensor-parallel-size` or `--pipeline-parallel-size` is left as written. For -Qwen models PAIR also turns on tool calling (`--enable-auto-tool-choice` with the -`hermes` parser), which agent clients require. +`--tensor-parallel-size` or `--pipeline-parallel-size` is left as written. PAIR +also turns on tool calling, which agent clients require: it checks how the +model's chat template writes a tool call and picks the vLLM parser that reads +that format (for example `hermes` for Qwen3, `qwen3_coder` for Qwen3.5 and +Granite 4.2). For a model no vLLM parser can read, such as Nemotron Nano v2, chat +requests that offer tools are answered in plain text, without the tools, instead +of failing. To choose a parser yourself, add `--tool-call-parser ` to the +launch text. Changing the server-port field updates the port in the command. Editing the command's port updates the numeric field. If both are edited before validation diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index 4374738dc..7ceed8564 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -70,11 +70,25 @@ launch is left untouched. A model that is not yet downloaded gets only a capped memory share. The added arguments appear in the engine-manager log (`engine launch adjusted for the model and GPU`) and not in the saved settings, so switching models re-sizes for the new one. -It also enables tool calling, which agents such as Hermes need on every request -(vLLM answers HTTP 400 to `tool_choice: "auto"` otherwise): for the Qwen families -it adds `--enable-auto-tool-choice --tool-call-parser hermes`. Other families are -left at vLLM's default until their parser has been tried; name one in the launch -arguments to enable it. +It also enables tool calling, which agent clients need on every request +(vLLM answers HTTP 400 to `tool_choice: "auto"` otherwise). The parser is found +from the model, not from a list of families: `hf` when the tokenizer ships +`response_template` metadata, otherwise the parser of the installed vLLM that +reads the model's own tool-call format. To find it, `toolparser_probe.py` runs +with vLLM's Python, renders the model's chat template with one example tool call, +and offers that text to every parser vLLM provides; among parsers that read it +equally, `hermes`, `qwen3_coder`, `llama3_json`, `mistral` and `pythonic` are +preferred in that order. The probe downloads nothing, runs no code from the model +repository, takes a few seconds, and is done once per model and vLLM while the +service runs. The chosen parser appears in the log +(`vLLM tool-call parser chosen from the model's chat template`). +When no parser reads the model's format (Nemotron Nano v2's ``, or a +template with no tool support), nothing is added, and the gateway removes `tools` +from chat requests that leave `tool_choice` unset or `"auto"`, so the model +answers in plain text instead of failing with HTTP 400. The response carries +`X-PAIR-Tool-Calls: removed: …`. A request with `tool_choice: "required"` or a +named function is forwarded unchanged and gets vLLM's own error. A parser named +in the launch arguments is always used as given. The environment sets `VLLM_USE_FLASHINFER_SAMPLER=0`. vLLM's FlashInfer sampler compiles GPU kernels on first start, and that build cannot handle the spaces in PAIR's app-data folder (`Nvidia Corporation/Personal AI Router`), so the diff --git a/services/nvpair-engine-manager/front.go b/services/nvpair-engine-manager/front.go index 21b732c48..49ffb212b 100644 --- a/services/nvpair-engine-manager/front.go +++ b/services/nvpair-engine-manager/front.go @@ -65,8 +65,10 @@ func frontBackendPort(f *Front, public int) (int, error) { } // frontGateway forwards every request to the engine on its backend port and -// streams the response back. It adds, removes and logs nothing: it is a -// transparent hop, and request or response bodies never pass through a log. +// streams the response back. It is a transparent hop, with one exception: a +// chat request that offers tools to an engine started without a tool-call +// parser has them removed (see dropUnparsableTools). Request and response +// bodies never pass through a log. // // It also keeps the one fact idle unload needs: when the engine last did // inference work, and whether any is in flight. @@ -104,6 +106,9 @@ type frontGateway struct { // them to its model list, since the engine itself only names the one it serves. modelsFn func(ctx context.Context) []string list modelList + // toolCalls is true when the engine was launched with a tool-call parser, + // so it can answer requests that offer tools. + toolCalls bool // readMu lets one read of the downloaded models serve every request waiting // for it. readMu sync.Mutex @@ -363,6 +368,70 @@ func (g *frontGateway) setModel(model string) { g.mu.Unlock() } +// setToolCalls records whether the engine being launched parses tool calls. +func (g *frontGateway) setToolCalls(on bool) { + g.mu.Lock() + g.toolCalls = on + g.mu.Unlock() +} + +// toolsUnavailableHeader is set on a response whose request had its tools +// removed, so a client or a person debugging one can tell why no tool was called. +const toolsUnavailableHeader = "X-PAIR-Tool-Calls" + +// dropUnparsableTools removes the tools from a chat request that an engine +// without a tool-call parser would reject, and reports whether it did. vLLM +// answers HTTP 400 to tool_choice "auto", which is what a request that offers +// tools and names no choice means, and agent clients offer their tools on +// every request. Without them the model answers in plain text, +// as it would have to anyway. A request that demands a tool, with "required" +// or a named function, is left alone: answering it without one would break +// what the client asked for, and vLLM's own error explains the refusal. +func dropUnparsableTools(r *http.Request) bool { + if r.URL.Path != "/v1/chat/completions" || r.Body == nil || !strings.Contains(r.Header.Get("Content-Type"), "json") { + return false + } + body, err := io.ReadAll(io.LimitReader(r.Body, frontPeekLimit+1)) + keep := func() bool { + r.Body = struct { + io.Reader + io.Closer + }{io.MultiReader(bytes.NewReader(body), r.Body), r.Body} + return false + } + if err != nil || len(body) > frontPeekLimit { + return keep() + } + var fields map[string]json.RawMessage + var tools []json.RawMessage + if json.Unmarshal(body, &fields) != nil || json.Unmarshal(fields["tools"], &tools) != nil || len(tools) == 0 { + return keep() + } + if choice, ok := fields["tool_choice"]; ok { + var mode string + if json.Unmarshal(choice, &mode) != nil || mode != "auto" { + return keep() + } + } + delete(fields, "tools") + delete(fields, "tool_choice") + delete(fields, "parallel_tool_calls") + var out bytes.Buffer + enc := json.NewEncoder(&out) + enc.SetEscapeHTML(false) + if enc.Encode(fields) != nil { + return keep() + } + r.Body = struct { + io.Reader + io.Closer + }{bytes.NewReader(out.Bytes()), r.Body} + r.ContentLength = int64(out.Len()) + r.Header.Del("Content-Length") + r.TransferEncoding = nil + return true +} + // drain returns once no inference request is running, which is when the model // can be unloaded without cutting one off. Requests that need the model are held // while a switch is in progress, so nothing new starts meanwhile. @@ -552,7 +621,11 @@ func (g *frontGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { g.inflight++ g.lastActive = time.Now() } + parsesTools := g.toolCalls g.mu.Unlock() + if !parsesTools && wakeable(r) && dropUnparsableTools(r) { + w.Header().Set(toolsUnavailableHeader, "removed: the model has no tool-call parser") + } break } if activity { diff --git a/services/nvpair-engine-manager/front_tools_test.go b/services/nvpair-engine-manager/front_tools_test.go new file mode 100644 index 000000000..e8d716424 --- /dev/null +++ b/services/nvpair-engine-manager/front_tools_test.go @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" +) + +// toolGateway puts a gateway in front of an engine that records the body of +// each request, with or without a tool-call parser. +func toolGateway(t *testing.T, parsesTools bool) (url string, received func() map[string]json.RawMessage) { + t.Helper() + var last []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + last, _ = io.ReadAll(r.Body) + if r.ContentLength != int64(len(last)) { + t.Errorf("Content-Length %d does not match the %d bytes sent", r.ContentLength, len(last)) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[]}`)) + })) + t.Cleanup(srv.Close) + _, backendPort, _ := net.SplitHostPort(strings.TrimPrefix(srv.URL, "http://")) + bp, _ := strconv.Atoi(backendPort) + public, err := freePort() + if err != nil { + t.Fatal(err) + } + g, err := startFrontGateway("127.0.0.1", public, bp) + if err != nil { + t.Fatal(err) + } + t.Cleanup(g.close) + g.setToolCalls(parsesTools) + return fmt.Sprintf("http://127.0.0.1:%d", public), func() map[string]json.RawMessage { + var fields map[string]json.RawMessage + if err := json.Unmarshal(last, &fields); err != nil { + t.Fatalf("the engine received invalid JSON %q: %v", last, err) + } + return fields + } +} + +const weatherTools = `[{"type":"function","function":{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}}]` + +func postChat(t *testing.T, url, path, body string) *http.Response { + t.Helper() + resp, err := http.Post(url+path, "application/json", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + return resp +} + +func TestDropUnparsableTools_RemovesToolsAnEngineWithoutAParserWouldReject(t *testing.T) { + cases := map[string]string{ + "no tool_choice": `{"model":"m","messages":[{"role":"user","content":"Is it & sunny?"}],"tools":` + weatherTools + `,"parallel_tool_calls":true}`, + "tool_choice auto": `{"model":"m","messages":[{"role":"user","content":"Is it & sunny?"}],"tools":` + weatherTools + `,"tool_choice":"auto"}`, + } + for name, body := range cases { + url, received := toolGateway(t, false) + + resp := postChat(t, url, "/v1/chat/completions", body) + + fields := received() + for _, removed := range []string{"tools", "tool_choice", "parallel_tool_calls"} { + if _, ok := fields[removed]; ok { + t.Errorf("%s: %s reached the engine", name, removed) + } + } + if string(fields["messages"]) != `[{"role":"user","content":"Is it & sunny?"}]` || string(fields["model"]) != `"m"` { + t.Errorf("%s: the rest of the request changed: %s %s", name, fields["model"], fields["messages"]) + } + if resp.StatusCode != http.StatusOK || resp.Header.Get(toolsUnavailableHeader) == "" { + t.Errorf("%s: status %d, %s header %q; want 200 saying the tools were removed", name, resp.StatusCode, toolsUnavailableHeader, resp.Header.Get(toolsUnavailableHeader)) + } + } +} + +func TestDropUnparsableTools_LeavesRequestsThatDemandATool(t *testing.T) { + cases := map[string]string{ + "required": `"required"`, + "named function": `{"type":"function","function":{"name":"get_weather"}}`, + "none": `"none"`, + } + for name, choice := range cases { + url, received := toolGateway(t, false) + body := `{"model":"m","messages":[],"tools":` + weatherTools + `,"tool_choice":` + choice + `}` + + resp := postChat(t, url, "/v1/chat/completions", body) + + fields := received() + if _, ok := fields["tools"]; !ok || string(fields["tool_choice"]) != choice { + t.Errorf("%s: the engine received %v; want the request unchanged", name, fields) + } + if resp.Header.Get(toolsUnavailableHeader) != "" { + t.Errorf("%s: reported removed tools", name) + } + } +} + +func TestDropUnparsableTools_ForwardsToolsToAnEngineWithAParser(t *testing.T) { + url, received := toolGateway(t, true) + body := `{"model":"m","messages":[],"tools":` + weatherTools + `}` + + resp := postChat(t, url, "/v1/chat/completions", body) + + if fields := received(); string(fields["tools"]) != weatherTools { + t.Fatalf("tools = %s, want them forwarded unchanged", fields["tools"]) + } + if resp.Header.Get(toolsUnavailableHeader) != "" { + t.Fatal("reported removed tools") + } +} + +func TestDropUnparsableTools_LeavesOtherRequestsByteForByte(t *testing.T) { + cases := map[string]struct{ path, body string }{ + "chat without tools": {"/v1/chat/completions", `{"model":"m", "messages":[]}`}, + "chat with an empty list": {"/v1/chat/completions", `{"model":"m","messages":[],"tools":[]}`}, + "completions": {"/v1/completions", `{"model":"m","prompt":"hi","tools":` + weatherTools + `}`}, + "not JSON": {"/v1/chat/completions", `tools`}, + } + for name, c := range cases { + req := httptest.NewRequest(http.MethodPost, c.path, strings.NewReader(c.body)) + req.Header.Set("Content-Type", "application/json") + + if dropUnparsableTools(req) { + t.Errorf("%s: reported removed tools", name) + } + body, _ := io.ReadAll(req.Body) + if !bytes.Equal(body, []byte(c.body)) { + t.Errorf("%s: body = %q, want %q", name, body, c.body) + } + } +} diff --git a/services/nvpair-engine-manager/lifecycle.go b/services/nvpair-engine-manager/lifecycle.go index 4884bff06..b338d49bd 100644 --- a/services/nvpair-engine-manager/lifecycle.go +++ b/services/nvpair-engine-manager/lifecycle.go @@ -279,6 +279,12 @@ func (e *Executor) bringUpProcess(ctx context.Context, st *engineState, engine s if launch, err = applyFrontBackend(rt, launch, port); err != nil { return err } + st.mu.Lock() + front := st.front + st.mu.Unlock() + if front != nil { + front.setToolCalls(parsesToolCalls(launch.Args)) + } diagnostics := newStartupOutput(launchDiagnosticArgs(rt), launch.Env) defer diagnostics.close() From ad32c11b5816304d52e6ab162d08b3bf103e85a9 Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Thu, 29 Oct 2026 17:24:04 +0530 Subject: [PATCH 29/35] fix(nvpair-engine-manager): leave out engines this host cannot run engine:get-installed listed every manifest, and reported an engine with no block for the host's OS and CPU, such as vLLM on Windows, as an empty not-installed entry. The desktop and TUI then offered an install that could only fail. Leave such an engine out of the listing, and make engine:status return an error for it. Signed-off-by: Tushar Khinvasara --- services/nvpair-engine-manager/README.md | 7 +++--- services/nvpair-engine-manager/executor.go | 2 +- .../nvpair-engine-manager/executor_test.go | 23 +++++++++++++++++++ services/nvpair-engine-manager/spec.md | 4 ++-- services/nvpair-engine-manager/status.go | 17 +++----------- 5 files changed, 33 insertions(+), 20 deletions(-) diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index 7ceed8564..bdfd4948d 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -43,7 +43,8 @@ claim GPU activation. The `vllm` manifest covers Linux (`linux/amd64` and `linux/arm64`, NVIDIA GPU with a CUDA 13 driver) and Apple Silicon macOS (`darwin/arm64`, macOS 15 or newer, -through the vLLM-Metal plugin). There is no native Windows or Intel Mac path. The +through the vLLM-Metal plugin). There is no native Windows path, so on Windows +`engine:get-installed` leaves vLLM out and PAIR does not offer it. The macOS install builds a managed Python 3.12 venv with a sha256-pinned `uv` and installs the pinned vLLM core and vLLM-Metal wheels, each checked against a digest, so no compiler is needed. The engine's PAIR gateway listens on `8001` @@ -216,9 +217,9 @@ Requests (caller → service): | Method | Params | Result | |---|---|---| -| `engine:get-installed` | — | `{ engines: [EngineStatus] }` | +| `engine:get-installed` | — | `{ engines: [EngineStatus] }` for every engine this host can run; an engine whose manifest has no block for the host's OS and CPU is left out | | `engine:describe` | `{ engine }` | the engine's manifest | -| `engine:status` | `{ engine }` | `EngineStatus` | +| `engine:status` | `{ engine }` | `EngineStatus`; an error for an engine this host cannot run | | `engine:install` | `{ engine, start?, port?, bind? }` | `EngineStatus` (after install; also starts it if `start:true`) | | `engine:uninstall` | `{ engine }` | `EngineStatus` (after removal) | | `engine:uninstall-managed` | — | `{ engines: [{ engine, removed, error? }] }` — removes every engine this service installed, selected by its install marker, each through the `engine:uninstall` path. Backs "reset all data" in both clients; downloaded models are kept. Neither `removed` nor `error` means the engine was left alone as not ours | diff --git a/services/nvpair-engine-manager/executor.go b/services/nvpair-engine-manager/executor.go index 8f30c7bea..1196c5e37 100644 --- a/services/nvpair-engine-manager/executor.go +++ b/services/nvpair-engine-manager/executor.go @@ -241,7 +241,7 @@ func (e *Executor) state(engine string) (*engineState, error) { } plat, ok := m.HostPlatform() if !ok { - return nil, fmt.Errorf("engine %q has no platform block for %s/%s", engine, runtime.GOOS, runtime.GOARCH) + return nil, fmt.Errorf("%s is not supported on %s/%s", m.DisplayName, runtime.GOOS, runtime.GOARCH) } st := &engineState{ manifest: m, diff --git a/services/nvpair-engine-manager/executor_test.go b/services/nvpair-engine-manager/executor_test.go index c8dda23c7..97d4a2e4d 100644 --- a/services/nvpair-engine-manager/executor_test.go +++ b/services/nvpair-engine-manager/executor_test.go @@ -408,6 +408,29 @@ func TestGetInstalled_AdoptsExternallyRunningEngineWithoutDetectPath(t *testing. require.True(t, statuses[0].Healthy, "expected get-installed to adopt the external engine (%v)", statuses) } +// An engine whose manifest has no block for this OS and CPU, the way vLLM has +// none for Windows, can never be installed here, so it is not offered. +func TestGetInstalled_OmitsEnginesThisHostCannotRun(t *testing.T) { + runsHere := testEngineManifest(fakeEngineBin) + elsewhere := &Manifest{ + Engine: "elsewhere", DisplayName: "Elsewhere", ManifestVersion: 1, + Platforms: map[string]Platform{"plan9/mips": {Runtime: Runtime{Bin: "elsewhere", Port: 1}}}, + } + reg := NewRegistry() + reg.engines[runsHere.Engine] = runsHere + reg.engines[elsewhere.Engine] = elsewhere + ex := NewExecutor(reg, NewReporter(nil), func(string, any) {}, t.TempDir()) + + var listed []string + for _, st := range ex.GetInstalled() { + listed = append(listed, st.Engine) + } + assert.Equal(t, []string{runsHere.Engine}, listed) + + _, err := ex.Status("elsewhere") + require.ErrorContains(t, err, "Elsewhere is not supported on "+runtime.GOOS+"/"+runtime.GOARCH) +} + func TestInstall_AdoptsExternalServiceWithoutDownloading(t *testing.T) { engineSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/version" { diff --git a/services/nvpair-engine-manager/spec.md b/services/nvpair-engine-manager/spec.md index 50b2d9a04..8b5669928 100644 --- a/services/nvpair-engine-manager/spec.md +++ b/services/nvpair-engine-manager/spec.md @@ -136,9 +136,9 @@ Requests (caller → service): | Method | Params | Result | |--------|--------|--------| -| `engine:get-installed` | — | `{ engines: [EngineStatus] }` | +| `engine:get-installed` | — | `{ engines: [EngineStatus] }` for every engine this host can run; an engine whose manifest has no block for the host's OS and CPU is left out | | `engine:describe` | `{ engine }` | the engine's manifest | -| `engine:status` | `{ engine }` | `EngineStatus` | +| `engine:status` | `{ engine }` | `EngineStatus`; an error for an engine this host cannot run | | `engine:install` | `{ engine }` | `EngineStatus` (after install) | | `engine:uninstall` | `{ engine }` | `EngineStatus` (after removal) | | `engine:uninstall-managed` | — | `{ engines: [{ engine, removed, error? }] }` — removes the engines this service installed, selected by their install marker, each through the `engine:uninstall` path. Backs both clients' "reset all data"; the platform uninstallers reach the same code through `--uninstall-managed` because they have no broker. Neither `removed` nor `error` means the engine was left alone as not ours. | diff --git a/services/nvpair-engine-manager/status.go b/services/nvpair-engine-manager/status.go index 9aeffb955..4784bbc37 100644 --- a/services/nvpair-engine-manager/status.go +++ b/services/nvpair-engine-manager/status.go @@ -59,12 +59,6 @@ func (e *Executor) Status(engine string) (EngineStatus, error) { func (e *Executor) StatusAtPort(engine string, probePort int) (EngineStatus, error) { st, err := e.state(engine) if err != nil { - // Known engine with no host-platform block: return a shell status - // (matching get-installed) rather than erroring, so status is - // consistent for the same engine across methods. - if m, ok := e.reg.Get(engine); ok { - return EngineStatus{Engine: engine, DisplayName: m.DisplayName}, nil - } return EngineStatus{}, err } // A start, stop, install or model switch holds opMu for as long as it takes, @@ -87,19 +81,14 @@ func (e *Executor) StatusAtPort(engine string, probePort int) (EngineStatus, err return e.snapshot(engine, st), nil } -// GetInstalled lists every known engine with its current status. +// GetInstalled lists every engine this host can run with its current status. func (e *Executor) GetInstalled() []EngineStatus { var out []EngineStatus for _, name := range e.reg.Names() { st, err := e.state(name) if err != nil { - // Known engine with no block for this host: surface a shell - // status so the UI can still list it as unavailable here. - dn := name - if m, ok := e.reg.Get(name); ok { - dn = m.DisplayName - } - out = append(out, EngineStatus{Engine: name, DisplayName: dn}) + // No block for this host, such as vLLM on Windows: the engine + // cannot be installed or run here, so it is not offered at all. continue } // Same reasoning as StatusAtPort: one engine mid-start must not hold up From a0eff8fabe0e5462c65d463e90be9a9eae65faa3 Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Thu, 29 Oct 2026 17:24:04 +0530 Subject: [PATCH 30/35] fix(desktop): hide engines a node cannot run A node's engine manager now leaves out an engine the node cannot run. Once a node has listed its engines, mark the missing ones unsupported and give them no row in the engine list, tray, endpoints or first-run setup. This hides vLLM on Windows, while a Linux node viewed from Windows still shows it. Signed-off-by: Tushar Khinvasara --- .../electron/service-bridge/modular-state.ts | 50 ++++++- .../service-bridge/modular-supervisor.ts | 2 +- desktop/src/shared/constants/engines.ts | 5 +- desktop/src/shared/utils/engines.ts | 11 ++ .../NodeList/NodeEngineSettings.tsx | 59 ++++---- .../ui/components/Welcome/WelcomeModal.tsx | 10 +- desktop/src/ui/utils/get-engines-for-node.ts | 12 +- desktop/src/ui/utils/status.ts | 3 +- .../modular/proxy-engine-identity.test.ts | 9 +- .../tests/modular/unsupported-engine.test.ts | 132 ++++++++++++++++++ 10 files changed, 250 insertions(+), 43 deletions(-) create mode 100644 desktop/tests/modular/unsupported-engine.test.ts diff --git a/desktop/src/electron/service-bridge/modular-state.ts b/desktop/src/electron/service-bridge/modular-state.ts index 10c6d6414..f7c6ec310 100644 --- a/desktop/src/electron/service-bridge/modular-state.ts +++ b/desktop/src/electron/service-bridge/modular-state.ts @@ -24,13 +24,14 @@ import { MODULAR_SUPERSEDE_MIN_AGE_MS } from '@/shared/constants/modular-runtime' import { WorkloadStates } from '@/shared/constants/workloads' -import { DefaultIdleExpiry, EnginesWithIdleTimeout } from '@/shared/constants/engines' +import { DefaultIdleExpiry, EnginesWithIdleTimeout, EngineTypes } from '@/shared/constants/engines' import { emptyEngineStatus, engineManagerName, engineTypeFromManagerName, idleSecondsToExpiry, - isEngineType + isEngineType, + unsupportedEngineStatus } from '@/shared/utils/engines' import { engineProgressKey } from '@/shared/utils/engine-progress' import { workloadKey } from '@/shared/utils/workloads' @@ -909,6 +910,12 @@ class ModularBridgeState { EngineType, { installed: boolean; running: boolean; port: number; idle: boolean } >() + /** + * The engines `nvpair-engine-manager` lists for this host. It leaves out an + * engine the host cannot run, such as vLLM on Windows, which is then + * reported as `unsupported`. Null until the first full listing. + */ + private localEngineList: ReadonlySet | null = null /** * How long an engine whose expiry is an idle limit may sit idle before it * unloads its model, in seconds (0 is never), as the engine manager reports it. @@ -978,6 +985,8 @@ class ModularBridgeState { * evicted. */ private remoteEngineFacts = new Map() + /** The engines each peer's engine manager lists, as {@link localEngineList} is for this host. */ + private remoteEngineLists = new Map>() /** * The model of an in-flight remote pull keyed by {@link remoteOpKey}. The * backend's `engine:remote-progress` frames carry **no** `model` field @@ -1126,6 +1135,14 @@ class ModularBridgeState { } } + if (selfId && this.localEngineList) { + for (const engineType of EngineTypes) { + if (!this.localEngineList.has(engineType)) { + statuses.push(unsupportedEngineStatus(selfId, engineType)) + } + } + } + // Remote nodes: per proxy-engine status + models from discovery. Status // prefers the authoritative `engine:remote-get-installed` facts (mTLS) // over the inferred presence, and is omitted entirely when neither is @@ -1347,6 +1364,27 @@ class ModularBridgeState { } } + /** + * Record a full `engine:get-installed` listing: each engine's status, and + * which engines the host can run at all. One left out is `unsupported`. + */ + applyEngineManagerList(result: JsonValue | undefined): void { + const list = objectValue(result)?.engines + if (!Array.isArray(list)) return + const listed = new Set() + for (const entry of list) { + this.applyEngineManagerStatus(entry) + const engineType = engineTypeFromManagerName(stringValue(objectValue(entry)?.engine)) + if (engineType) listed.add(engineType) + } + // An empty listing says nothing about what the host can run. + if (listed.size === 0) return + this.localEngineList = listed + for (const engineType of EngineTypes) { + if (!listed.has(engineType)) this.emitLocalEngineStatus(engineType) + } + } + /** * Record a `nvpair-engine-manager` `engine:state-changed` (or hydrated * `engine:get-installed` entry). Facts are kept regardless of whether the @@ -1770,6 +1808,8 @@ class ModularBridgeState { nodeId: string, engine: ProxyEngine ): EngineStatusData | null { + const listed = this.remoteEngineLists.get(nodeId) + if (listed && !listed.has(engine)) return unsupportedEngineStatus(nodeId, engine) const node = this.nodes.get(nodeId) const presence = node?.engines[engine] const facts = this.remoteEngineFacts.get(this.remoteOpKey(nodeId, engine)) @@ -1841,6 +1881,8 @@ class ModularBridgeState { }) seen.add(engineType) } + // A peer leaves out an engine it cannot run. An empty listing says nothing. + if (seen.size > 0) this.remoteEngineLists.set(nodeId, seen) for (const engine of PROXY_ENGINES) { if (!seen.has(engine)) { this.remoteEngineFacts.delete(this.remoteOpKey(nodeId, engine)) @@ -1873,6 +1915,7 @@ class ModularBridgeState { /** Forget a node's authoritative remote-engine facts (on eviction/leave). */ private dropRemoteEngineFacts(nodeId: string): void { + this.remoteEngineLists.delete(nodeId) for (const engine of PROXY_ENGINES) { this.remoteEngineFacts.delete(this.remoteOpKey(nodeId, engine)) this.remotePullModels.delete(this.remoteOpKey(nodeId, engine)) @@ -2112,6 +2155,9 @@ class ModularBridgeState { ): EngineStatusData | undefined { const nodeId = this.selfId if (!nodeId) return undefined + if (this.localEngineList && !this.localEngineList.has(engineType)) { + return unsupportedEngineStatus(nodeId, engineType) + } const facts = this.engineManagerFacts.get(engineType) // An in-flight lifecycle op outranks the fact-derived status so the UI diff --git a/desktop/src/electron/service-bridge/modular-supervisor.ts b/desktop/src/electron/service-bridge/modular-supervisor.ts index 208c14d0b..8facc4f03 100644 --- a/desktop/src/electron/service-bridge/modular-supervisor.ts +++ b/desktop/src/electron/service-bridge/modular-supervisor.ts @@ -1081,8 +1081,8 @@ class ModularSupervisor { const result = await this.callProcess('broker', 'engine:get-installed') const obj = objectValue(result) if (!obj || !Array.isArray(obj.engines)) return + getModularBridgeState().applyEngineManagerList(result) for (const engine of obj.engines) { - getModularBridgeState().applyEngineManagerStatus(engine) this.refreshManagedEngineModels(engine) } } catch (err) { diff --git a/desktop/src/shared/constants/engines.ts b/desktop/src/shared/constants/engines.ts index 86fe3e552..0f14eb1db 100644 --- a/desktop/src/shared/constants/engines.ts +++ b/desktop/src/shared/constants/engines.ts @@ -90,7 +90,10 @@ export const EngineProcessStatuses = [ 'uninstalling', 'starting', 'stopping', - 'initializing' + 'initializing', + // The node's engine manager does not list the engine because the node + // cannot run it (vLLM on Windows), so it is not shown for that node. + 'unsupported' ] as const /** diff --git a/desktop/src/shared/utils/engines.ts b/desktop/src/shared/utils/engines.ts index 0bae364c8..7498401d9 100644 --- a/desktop/src/shared/utils/engines.ts +++ b/desktop/src/shared/utils/engines.ts @@ -85,6 +85,17 @@ export function emptyEngineStatus(nodeId: string, engineType: EngineType): Engin } } +/** An engine the node's engine manager does not list, because the node cannot run it. */ +export function unsupportedEngineStatus(nodeId: string, engineType: EngineType): EngineStatusData { + return { + engineType, + nodeId, + processStatus: 'unsupported', + enginePort: null, + proxyPort: null + } +} + /** Shallow placeholder when no model list has been pushed for this node/engine yet. */ export function emptyEngineModels(nodeId: string, engineType: EngineType): EngineModels { return { nodeId, engineType, models: [] } diff --git a/desktop/src/ui/components/NodeList/NodeEngineSettings.tsx b/desktop/src/ui/components/NodeList/NodeEngineSettings.tsx index 10f81f3cf..f48bf8c39 100644 --- a/desktop/src/ui/components/NodeList/NodeEngineSettings.tsx +++ b/desktop/src/ui/components/NodeList/NodeEngineSettings.tsx @@ -75,36 +75,39 @@ export default function NodeEngineSettings({ nodeId }: { nodeId: string }) { void engineFingerprint void modelsFingerprint - const allBackends: { backend: BackendInfo; statusKnown: boolean }[] = EnabledEngineTypes.map( - type => { - const statusStore = useEngineStatusStore.getState() - // Distinguish "the service reported this engine" from "we synthesized - // a placeholder". The placeholder's processStatus is 'initializing'; - // without this flag a never-reported remote engine spins forever. - const statusKnown = statusStore.statusByNode.get(nodeId)?.has(type) ?? false - const status = statusStore.getStatus(nodeId, type) - const models = useEngineModelsStore.getState().getModels(nodeId, type) - const updateAvailable = useEngineUpdateAvailableStore.getState().getInfo(nodeId, type) + // An engine the node cannot run, such as vLLM on Windows, gets no row. + const shownTypes = EnabledEngineTypes.filter( + type => + useEngineStatusStore.getState().getStatus(nodeId, type).processStatus !== 'unsupported' + ) + const allBackends: { backend: BackendInfo; statusKnown: boolean }[] = shownTypes.map(type => { + const statusStore = useEngineStatusStore.getState() + // Distinguish "the service reported this engine" from "we synthesized + // a placeholder". The placeholder's processStatus is 'initializing'; + // without this flag a never-reported remote engine spins forever. + const statusKnown = statusStore.statusByNode.get(nodeId)?.has(type) ?? false + const status = statusStore.getStatus(nodeId, type) + const models = useEngineModelsStore.getState().getModels(nodeId, type) + const updateAvailable = useEngineUpdateAvailableStore.getState().getInfo(nodeId, type) - return { - backend: { - type, - displayName: EngineDisplayNames[type], - source: 'detected', - processStatus: status.processStatus, - idle: status.idle, - port: status.enginePort, - proxyPort: status.proxyPort, - models: models.models, - docsUrl: EngineDefaultLinks[type]?.docsUrl, - installUrl: EngineDefaultLinks[type]?.installUrl, - installedVersion: status.installedVersion, - updateAvailable - }, - statusKnown - } + return { + backend: { + type, + displayName: EngineDisplayNames[type], + source: 'detected', + processStatus: status.processStatus, + idle: status.idle, + port: status.enginePort, + proxyPort: status.proxyPort, + models: models.models, + docsUrl: EngineDefaultLinks[type]?.docsUrl, + installUrl: EngineDefaultLinks[type]?.installUrl, + installedVersion: status.installedVersion, + updateAvailable + }, + statusKnown } - ) + }) return ( diff --git a/desktop/src/ui/components/Welcome/WelcomeModal.tsx b/desktop/src/ui/components/Welcome/WelcomeModal.tsx index 101da2471..4d394e6d7 100644 --- a/desktop/src/ui/components/Welcome/WelcomeModal.tsx +++ b/desktop/src/ui/components/Welcome/WelcomeModal.tsx @@ -51,7 +51,15 @@ export function WelcomeModal({ open, onOpenChange, selfId }: WelcomeModalProps) const failedTargets = useRef(new Set()) const knownErrors = useRef(new Map()) - const candidates = useMemo(() => getWelcomeEngineCandidates(window.windowApi.platform), []) + // The engine manager has the last word on what this machine can run, so an + // engine it leaves out is not offered here. + const candidates = useMemo( + () => + getWelcomeEngineCandidates(window.windowApi.platform).filter( + t => statusByNode.get(selfId)?.get(t)?.processStatus !== 'unsupported' + ), + [statusByNode, selfId] + ) const allEnginesInstalled = useMemo( () => areWelcomeEnginesInstalled(statusByNode, selfId, candidates), [candidates, selfId, statusByNode] diff --git a/desktop/src/ui/utils/get-engines-for-node.ts b/desktop/src/ui/utils/get-engines-for-node.ts index 8d46d1894..7d7c9a748 100644 --- a/desktop/src/ui/utils/get-engines-for-node.ts +++ b/desktop/src/ui/utils/get-engines-for-node.ts @@ -19,8 +19,8 @@ interface EngineListRow { /** * One row per enabled engine type for this node. Types not yet reported by the * service appear as `initializing` placeholders ({@link emptyEngineStatus}) so - * compact rows can hide them until evidence arrives. When `engineFilter` is - * provided, only those types are included. + * compact rows can hide them until evidence arrives. Types the node cannot run + * are left out. When `engineFilter` is provided, only those types are included. */ export function getEnginesForNode( nodeId: string, @@ -29,11 +29,9 @@ export function getEnginesForNode( ): EngineStatusData[] { const inner = statusByNode.get(nodeId) const types = engineFilter ?? EnabledEngineTypes - return types.map(type => { - const s = inner?.get(type) - if (s) return s - return emptyEngineStatus(nodeId, type) - }) + return types + .map(type => inner?.get(type) ?? emptyEngineStatus(nodeId, type)) + .filter(s => s.processStatus !== 'unsupported') } function engineDisplayName(engineType: string): string { diff --git a/desktop/src/ui/utils/status.ts b/desktop/src/ui/utils/status.ts index 0357e45b7..05ec43695 100644 --- a/desktop/src/ui/utils/status.ts +++ b/desktop/src/ui/utils/status.ts @@ -11,5 +11,6 @@ export const statusLabel: Record = { uninstalling: 'Uninstalling...', starting: 'Starting...', stopping: 'Stopping...', - initializing: 'Initializing...' + initializing: 'Initializing...', + unsupported: 'Not Supported' } diff --git a/desktop/tests/modular/proxy-engine-identity.test.ts b/desktop/tests/modular/proxy-engine-identity.test.ts index 8fe3f0a91..b52cbbec0 100644 --- a/desktop/tests/modular/proxy-engine-identity.test.ts +++ b/desktop/tests/modular/proxy-engine-identity.test.ts @@ -106,15 +106,19 @@ describe('proxy engine identity', () => { ] } }) + // A peer lists every engine it can run; one it leaves out is unsupported. state.applyRemoteEngineFacts(nodeId, { engines: [ + { engine: 'ollama', installed: true, running: true, healthy: true, port: 11435 }, + { engine: 'lmstudio', installed: true, running: true, healthy: true, port: 1235 }, { engine: 'llamacpp', installed: true, running: true, healthy: true, port: 8081 - } + }, + { engine: 'vllm', installed: false, running: false, healthy: false, port: 0 } ] }) @@ -137,7 +141,8 @@ describe('proxy engine identity', () => { processStatus: 'running', enginePort: 8081, proxyPort: 8080 - }) + }), + expect.objectContaining({ engineType: 'vllm', processStatus: 'not-installed' }) ]) const llamaModels = state diff --git a/desktop/tests/modular/unsupported-engine.test.ts b/desktop/tests/modular/unsupported-engine.test.ts new file mode 100644 index 000000000..b60ea23fe --- /dev/null +++ b/desktop/tests/modular/unsupported-engine.test.ts @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) +vi.mock('@/electron/window', () => ({ createOverviewWindow: vi.fn() })) + +import type { EngineStatusByNode, EngineStatusData } from '@/shared/types/engines' +import { getModularBridgeState } from '@/electron/service-bridge/modular-state' +import { getEnginesForNode } from '@/ui/utils/get-engines-for-node' + +// An engine manager lists only the engines its host can run. On Windows the +// listing has no vLLM, the way these do. +const windowsListing = { + engines: [ + { engine: 'ollama', installed: true, running: true, healthy: true, port: 11434 }, + { engine: 'lmstudio', installed: false, running: false, healthy: false, port: 0 }, + { engine: 'llamacpp', installed: false, running: false, healthy: false, port: 0 } + ] +} + +function statusOf(nodeId: string, engineType: string): EngineStatusData | undefined { + return getModularBridgeState() + .getEngineInitialState() + .statuses.find(status => status.nodeId === nodeId && status.engineType === engineType) +} + +function byNode(statuses: EngineStatusData[]): EngineStatusByNode { + const out: EngineStatusByNode = new Map() + for (const status of statuses) { + const inner = out.get(status.nodeId) ?? new Map() + inner.set(status.engineType, status) + out.set(status.nodeId, inner) + } + return out +} + +describe('engines a node cannot run', () => { + it('marks an engine the local engine manager leaves out as unsupported', () => { + const state = getModularBridgeState() + state.setSelfId('windows-self') + + state.applyEngineManagerList(windowsListing) + + expect(statusOf('windows-self', 'vllm')).toMatchObject({ processStatus: 'unsupported' }) + expect(statusOf('windows-self', 'lm-studio')).toMatchObject({ + processStatus: 'not-installed' + }) + }) + + it('marks an engine a peer leaves out as unsupported', () => { + const state = getModularBridgeState() + const peerId = 'windows-peer' + state.setSelfId('linux-self') + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { + nodes: [ + { + hostUuid: peerId, + name: 'windows-peer', + ipAddress: '192.0.2.70', + port: 14318, + trusted: true, + clustered: true + } + ] + } + }) + + state.applyRemoteEngineFacts(peerId, windowsListing) + + expect(statusOf(peerId, 'vllm')).toMatchObject({ processStatus: 'unsupported' }) + expect(statusOf(peerId, 'lm-studio')).toMatchObject({ processStatus: 'not-installed' }) + }) + + it('keeps an engine a peer has not listed yet as unknown, not unsupported', () => { + const state = getModularBridgeState() + const peerId = 'unpolled-peer' + state.setSelfId('linux-self') + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { + nodes: [ + { + hostUuid: peerId, + name: 'unpolled-peer', + ipAddress: '192.0.2.71', + port: 14318, + trusted: true, + clustered: true + } + ] + } + }) + + state.applyRemoteEngineFacts(peerId, { engines: [] }) + + expect(statusOf(peerId, 'vllm')).toBeUndefined() + }) + + it('gives an unsupported engine no row, and keeps one for an unreported engine', () => { + const nodeId = 'windows-node' + const statuses = byNode([ + { + engineType: 'ollama', + nodeId, + processStatus: 'running', + enginePort: 11434, + proxyPort: null + }, + { + engineType: 'vllm', + nodeId, + processStatus: 'unsupported', + enginePort: null, + proxyPort: null + } + ]) + + const rows = getEnginesForNode(nodeId, statuses).map(s => [s.engineType, s.processStatus]) + + expect(rows).toEqual([ + ['ollama', 'running'], + ['lm-studio', 'initializing'], + ['llama-cpp', 'initializing'] + ]) + }) +}) From 5c27d65afbb7b62acc3d7a6cfa4892d7f494a598 Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Fri, 30 Oct 2026 10:42:01 +0530 Subject: [PATCH 31/35] fix(nvpair-engine-manager): wait for remote vLLM loads and always restore the previous model A remote load used the ordinary 30 s response-header budget for every engine except Ollama. Loading a vLLM model restarts vLLM on it, which can take minutes, so the caller gave up, the peer's handler was cancelled mid start, and the rollback to the previous model ran under the same cancelled context and failed at once, leaving vLLM down with the new model saved. Remote vLLM loads now use the readiness-sized client, and the previous model is restored under a context the caller cannot cancel. A test cancels a slow switch and checks that the previous model is back and running. Signed-off-by: Tushar Khinvasara --- desktop/docs/services-parity.md | 20 ++++++------ services/nvpair-engine-manager/launchmodel.go | 12 ++++--- .../nvpair-engine-manager/launchmodel_test.go | 32 +++++++++++++++++++ .../nvpair-engine-manager/remoteclient.go | 8 +++-- .../remoteclient_test.go | 1 + services/nvpair-engine-manager/spec.md | 2 +- 6 files changed, 57 insertions(+), 18 deletions(-) diff --git a/desktop/docs/services-parity.md b/desktop/docs/services-parity.md index 33597c30d..5c0d3ba21 100644 --- a/desktop/docs/services-parity.md +++ b/desktop/docs/services-parity.md @@ -316,16 +316,16 @@ restart-backed delete, the reply is withheld until the engine is ready again `MODULAR_MODEL_ACTION_TIMEOUT_MS` anchors the delete-specific observers. Ollama Load uses a separate 10/11-minute response-header budget: -| Budget | Value | Why | -| ------------------------------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MODULAR_MODEL_ACTION_TIMEOUT_MS` (`modular-runtime.ts`) | 120s | The RPC must outlast a full stop + readiness-probed start. | -| `RESTART_DELETE_TIMEOUT_MS` (`pending-actions.store.ts`) | 180s | The optimistic-spinner safety net must outlast the RPC, or it expires mid-flight and drops the spinner while the delete is still running. | -| `engineResponseHeaderTimeout` (`executor.go`) | 30s | Downloads and ordinary local HTTP actions retain a prompt response-header bound; probe contexts stay shorter. | -| `pullProgressTimeout` (`executor.go`) | 30min | Streaming Ollama and llama.cpp pulls may run indefinitely while layer/file completed bytes advance; 30 minutes without advancing bytes cancels the stalled pull. | -| `ollamaLoadResponseHeaderTimeout` (`executor.go`) | 10min | Only Ollama's local `run_model` action gets the cold-load allowance. | -| `OLLAMA_LOAD_PENDING_TIMEOUT_MS` (`pending-actions.store.ts`) | 12min | Ollama's Load control stays locked beyond the remote path's 11-minute header budget, while backend success or failure still clears it immediately. | -| `PENDING_TIMEOUT_MS` | 60s | Unchanged for every other command, including an Ollama delete. | -| `remoteReadyResponseHeaderTimeout` (`remoteclient.go`) | 11min | Remote start/delete responses can wait on engine readiness, and a cold Ollama model load can withhold headers for minutes, so those calls use the readiness-sized client. Other model actions retain the ordinary 30s budget. | +| Budget | Value | Why | +| ------------------------------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MODULAR_MODEL_ACTION_TIMEOUT_MS` (`modular-runtime.ts`) | 120s | The RPC must outlast a full stop + readiness-probed start. | +| `RESTART_DELETE_TIMEOUT_MS` (`pending-actions.store.ts`) | 180s | The optimistic-spinner safety net must outlast the RPC, or it expires mid-flight and drops the spinner while the delete is still running. | +| `engineResponseHeaderTimeout` (`executor.go`) | 30s | Downloads and ordinary local HTTP actions retain a prompt response-header bound; probe contexts stay shorter. | +| `pullProgressTimeout` (`executor.go`) | 30min | Streaming Ollama and llama.cpp pulls may run indefinitely while layer/file completed bytes advance; 30 minutes without advancing bytes cancels the stalled pull. | +| `ollamaLoadResponseHeaderTimeout` (`executor.go`) | 10min | Only Ollama's local `run_model` action gets the cold-load allowance. | +| `OLLAMA_LOAD_PENDING_TIMEOUT_MS` (`pending-actions.store.ts`) | 12min | Ollama's Load control stays locked beyond the remote path's 11-minute header budget, while backend success or failure still clears it immediately. | +| `PENDING_TIMEOUT_MS` | 60s | Unchanged for every other command, including an Ollama delete. | +| `remoteReadyResponseHeaderTimeout` (`remoteclient.go`) | 11min | Remote start/delete responses can wait on engine readiness, and a cold Ollama model load or a vLLM load (which restarts vLLM on the new model) can withhold headers for minutes, so those calls use the readiness-sized client. Other model actions retain the ordinary 30s budget. | The net is a backstop, not the mechanism: a delete now ends the spinner on real backend truth either way. On success the refreshed `list_models` drops the model; diff --git a/services/nvpair-engine-manager/launchmodel.go b/services/nvpair-engine-manager/launchmodel.go index d29f2de96..35a9202da 100644 --- a/services/nvpair-engine-manager/launchmodel.go +++ b/services/nvpair-engine-manager/launchmodel.go @@ -53,6 +53,10 @@ func (e *Executor) switchLaunchModel(ctx context.Context, engine string, spec *A if err != nil { return nil, err } + // Putting the previous model back must not depend on the caller still + // waiting: a remote caller that gives up cancels ctx mid-start, and a restore + // under that ctx would fail at once and leave the engine down. + restoreCtx := context.WithoutCancel(ctx) result := func(restarted bool) (json.RawMessage, error) { return json.Marshal(struct { Model string `json:"model"` @@ -83,7 +87,7 @@ func (e *Executor) switchLaunchModel(ctx context.Context, engine string, spec *A } if err := e.StartWith(ctx, engine, startOpts{Eager: true}); err != nil { if changed { - _, _ = e.ConfigureLaunch(ctx, settings.Configure{ + _, _ = e.ConfigureLaunch(restoreCtx, settings.Configure{ Engine: engine, Settings: settings.Config{ServerPort: current.ServerPort, LaunchText: current.LaunchText}, }, func() error { return nil }) @@ -93,7 +97,7 @@ func (e *Executor) switchLaunchModel(ctx context.Context, engine string, spec *A return result(true) } - apply := func(launchText string, resume bool) error { + apply := func(ctx context.Context, launchText string, resume bool) error { _, err := e.ConfigureLaunch(ctx, settings.Configure{ Engine: engine, Settings: settings.Config{ServerPort: current.ServerPort, LaunchText: launchText}, @@ -101,12 +105,12 @@ func (e *Executor) switchLaunchModel(ctx context.Context, engine string, spec *A }, func() error { return nil }) return err } - if err := apply(text, false); err != nil { + if err := apply(ctx, text, false); err != nil { if !current.Running { return nil, err } // The engine's own error carries a long log tail, so say what happened first. - if restoreErr := apply(current.LaunchText, true); restoreErr != nil { + if restoreErr := apply(restoreCtx, current.LaunchText, true); restoreErr != nil { return nil, fmt.Errorf("switching to %s failed and restoring the previous model also failed (%v): %w", model, restoreErr, err) } return nil, fmt.Errorf("switching to %s failed; the previous model was restored: %w", model, err) diff --git a/services/nvpair-engine-manager/launchmodel_test.go b/services/nvpair-engine-manager/launchmodel_test.go index eea0cb292..d0c283e6b 100644 --- a/services/nvpair-engine-manager/launchmodel_test.go +++ b/services/nvpair-engine-manager/launchmodel_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "strings" "testing" + "time" settings "nvpair-shared/enginesettings" ) @@ -280,6 +281,37 @@ func TestLaunchModelRestoresThePreviousModelWhenTheNewOneFails(t *testing.T) { } } +// A remote caller that stops waiting cancels the switch while the new model is +// still starting. The previous model must still come back: restoring it under +// the cancelled context would fail at once and leave the engine down. +func TestLaunchModel_RestoresThePreviousModelWhenTheCallerGivesUp(t *testing.T) { + e := switchModelExecutor(t, "--model") + // Every launch from here on takes a second to become ready, so the switch + // below is still starting when its caller gives up. + if _, err := e.ConfigureLaunch(context.Background(), settings.Configure{Engine: "fake", Settings: settings.Config{ + ServerPort: mustStatus(t, e).Port, LaunchText: "FAKE_START_DELAY=1s " + launchTextOf(t, e), + }}, func() error { return nil }); err != nil { + t.Fatal(err) + } + before := launchTextOf(t, e) + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + _, err := e.Action(ctx, "fake", "load_model", json.RawMessage(`{"model":"org/slow"}`)) + if err == nil { + t.Fatal("a switch whose caller gave up was reported as done") + } + if !strings.Contains(err.Error(), "the previous model was restored") { + t.Fatalf("err = %v, want the previous model restored", err) + } + if got := launchTextOf(t, e); got != before { + t.Fatalf("launch text = %q, want the previous %q", got, before) + } + if !mustStatus(t, e).Running { + t.Fatal("the engine was left down after the caller gave up") + } +} + func TestLaunchModelValidation(t *testing.T) { for _, flag := range []string{"model", "--mod el", "--model=x", ""} { if err := (&Action{LaunchModel: &ActionLaunchModel{Flag: flag}}).validate("load_model"); err == nil { diff --git a/services/nvpair-engine-manager/remoteclient.go b/services/nvpair-engine-manager/remoteclient.go index 3a19bdf07..e54cdd33a 100644 --- a/services/nvpair-engine-manager/remoteclient.go +++ b/services/nvpair-engine-manager/remoteclient.go @@ -38,8 +38,8 @@ type remoteClient struct { } // waitsForEngineReadiness reports whether a peer may only answer after an -// engine is healthy or an Ollama model is loaded, which the ordinary 30s -// response-header budget cannot cover. +// engine is healthy or an Ollama or vLLM model is loaded, which the ordinary +// 30s response-header budget cannot cover. // // Cutting such a call off is worse than slow. The initiator's cancellation // propagates into the peer's in-flight handler, so bringUpCommand tears the @@ -51,8 +51,10 @@ func waitsForEngineReadiness(path, engine string) bool { } // controlDeletePath: LM Studio's delete_model declares restart_after, so the // peer replies only after the post-delete restart is ready. + // vLLM's load_model restarts the engine on the new model and replies once + // it is ready. if path == controlLoadPath { - return engine == "ollama" + return engine == "ollama" || engine == "vllm" } return path == controlStartPath || path == controlDeletePath } diff --git a/services/nvpair-engine-manager/remoteclient_test.go b/services/nvpair-engine-manager/remoteclient_test.go index 36f8939b7..d685e0502 100644 --- a/services/nvpair-engine-manager/remoteclient_test.go +++ b/services/nvpair-engine-manager/remoteclient_test.go @@ -74,6 +74,7 @@ func TestRemoteReadinessBudget_CoversEngineStartupAllowance(t *testing.T) { test("start waits", controlStartPath, "ollama", true) test("delete waits", controlDeletePath, "lmstudio", true) test("Ollama load waits", controlLoadPath, "ollama", true) + test("vLLM load waits", controlLoadPath, "vllm", true) test("LM Studio load does not wait", controlLoadPath, "lmstudio", false) test("stop does not wait", controlStopPath, "ollama", false) test("unload does not wait", controlUnloadPath, "ollama", false) diff --git a/services/nvpair-engine-manager/spec.md b/services/nvpair-engine-manager/spec.md index 8b5669928..99117816d 100644 --- a/services/nvpair-engine-manager/spec.md +++ b/services/nvpair-engine-manager/spec.md @@ -182,7 +182,7 @@ Example `engine:pull-progress` (stdout): ### 7.2 Remote engine management (the `ec` surface) With `--control-port` and a clustered `--cluster-dir`, engine-manager serves a cluster-scoped remote-control surface over pin-based mTLS (`nvpair-shared/clustertrust`): it presents this node's cluster leaf, requires a client cert, and `403`s any caller that isn't a byte-for-byte pinned cluster peer. The listener is bound whenever `--control-port` is set and admits callers by live membership — its leaf is resolved per handshake, so an unclustered node presents none and every handshake is refused — so no restart is needed on `cluster:identity-changed`; the broker registers `ec` whenever a cluster dir is configured. Routes under `/v1`: `GET /engines`, streaming `POST /engines/install` and `/models/pull` (chunked NDJSON — zero+ `{"type":"progress"}` frames then one terminal `{"type":"result"}`/`{"type":"error"}` frame), non-streaming `POST /models/{load,unload,delete}`, and non-streaming `POST /engines/{start,stop}` returning `EngineStatus`. -The `engine:remote-*` methods are the client half: engine-manager resolves the target `node` in an `ec` peer directory (fed by its own `discovery:subscribe{services:[ec]}` to the broker relay), dials the peer's `ec` surface with the same pinned identity, relays each streamed progress frame up as `engine:remote-progress` (keyed by a minted `opId`), and settles the request on the terminal frame. Remote install/pull run for the operation's full duration (no broker-imposed timeout); remote stop and model unload/delete are fast request/response, remote Ollama model load uses the readiness-sized response budget, and remote start waits for the engine's bounded readiness result. They error if this node isn't clustered or the target isn't a pinned peer. +The `engine:remote-*` methods are the client half: engine-manager resolves the target `node` in an `ec` peer directory (fed by its own `discovery:subscribe{services:[ec]}` to the broker relay), dials the peer's `ec` surface with the same pinned identity, relays each streamed progress frame up as `engine:remote-progress` (keyed by a minted `opId`), and settles the request on the terminal frame. Remote install/pull run for the operation's full duration (no broker-imposed timeout); remote stop and model unload/delete are fast request/response, remote Ollama and vLLM model loads use the readiness-sized response budget (a vLLM load restarts the engine on the new model), and remote start waits for the engine's bounded readiness result. They error if this node isn't clustered or the target isn't a pinned peer. ## 8. Dependencies - **Upstream**: the orchestrator that spawns it and forwards `engine:*` (`nvpair-ui-broker`). From 74f8e14e087c61ac7b9b4b1a20bb29e9d4eb670e Mon Sep 17 00:00:00 2001 From: Tushar Khinvasara Date: Fri, 30 Oct 2026 12:34:39 +0530 Subject: [PATCH 32/35] fix(nvpair-engine-manager): address review comments on vLLM support - Match Hugging Face shard file names without a leading ^.+ in hfShardRe. - Drop the redundant shard-count comparison; the loop over shard numbers is the real completeness check, and a test now covers shards numbered from zero. - Allow deleting the model a stopped vLLM is set to serve. The delete takes --model out of the saved launch arguments, and an engine with a launch_model action refuses to start while its launch names no model, rather than letting vLLM download a default of its own. Loading a model selects one again. The model a vLLM process serves is still refused; an engine ejected behind its gateway is stopped first, so Eject then Delete works. - Say in hfpull.go that the download is done by Hugging Face's hf CLI, and state in the pull_model description and README that it fetches the files vLLM loads rather than the whole repository. - Fold setting and removing a launch flag into one helper, dropLaunchFlag. - Make Qwen/Qwen3.5-4B the default vLLM model in place of Qwen2.5-0.5B, state that it needs a GPU with at least 16 GB, and raise the install's free-disk check to cover the engine and that model (25 GB on Linux, 20 GB on macOS). - Size vLLM launches on unified-memory machines such as DGX Spark, where nvidia-smi reports [N/A] for GPU memory, from /proc/meminfo, as nvpair-node-info already does. Signed-off-by: Tushar Khinvasara --- desktop/docs/frontend-api.md | 3 +- desktop/docs/services-parity.md | 4 +- .../src/ui/constants/engine-capabilities.ts | 4 +- docs/engine-lifecycle.mdx | 13 +-- services/nvpair-engine-manager/MANIFEST.md | 9 +- services/nvpair-engine-manager/README.md | 26 ++++-- services/nvpair-engine-manager/front_test.go | 36 ++++++++ services/nvpair-engine-manager/hfcache.go | 86 +++++++++++++++---- .../nvpair-engine-manager/hfcache_test.go | 52 +++++++++-- services/nvpair-engine-manager/hfpull.go | 5 ++ services/nvpair-engine-manager/launchauto.go | 55 +++++++++++- .../nvpair-engine-manager/launchauto_test.go | 35 ++++++++ services/nvpair-engine-manager/launchmodel.go | 72 +++++++++++----- .../nvpair-engine-manager/launchmodel_test.go | 51 +++++++++-- services/nvpair-engine-manager/lifecycle.go | 8 ++ .../nvpair-engine-manager/manifests/vllm.json | 12 +-- .../testdata/fakeengine/main.go | 4 +- services/nvpair-tui/ui/engineswire.go | 2 +- 18 files changed, 400 insertions(+), 77 deletions(-) diff --git a/desktop/docs/frontend-api.md b/desktop/docs/frontend-api.md index ca3f031e4..c905fc5d8 100644 --- a/desktop/docs/frontend-api.md +++ b/desktop/docs/frontend-api.md @@ -106,7 +106,8 @@ again. An engine status with `idle: true` is on with nothing loaded, and loads o the next request; `setModelExpiry` on vLLM sets how long it may sit idle before it unloads (1 minute to 1 hour, or never). Its model list is the Hugging Face cache, which stays available while the engine is stopped, `deleteModel` removes a cached model (not the one -it is set to serve), `pullModel` downloads a Hugging Face repo into +loaded in vLLM; deleting an ejected or stopped vLLM's model leaves it with no model +selected until `loadModel` picks one), `pullModel` downloads a Hugging Face repo into that cache (no running engine needed), and `loadModel` on a cached model switches the served model by restarting vLLM on it. `searchHub(engineType, query?)` serves Ollama's locked local catalog and cached diff --git a/desktop/docs/services-parity.md b/desktop/docs/services-parity.md index 5c0d3ba21..907f30a18 100644 --- a/desktop/docs/services-parity.md +++ b/desktop/docs/services-parity.md @@ -199,7 +199,9 @@ unloads it on demand while the engine stays on; the card shows an idle engine as "Idle · loads on next request" (`idle` on the engine status). Its model list (`list_models` on disk) is kept while stopped. It downloads and deletes through the shared Hugging Face cache (`pull_model` is an `hf_pull` action, -`delete_model` an `hf_cache` delete that refuses the model it is set to serve), +`delete_model` an `hf_cache` delete that refuses the model an engine process +serves and, on an ejected or stopped engine, also clears it from the launch +arguments), and has a Hugging Face catalog. Load switches the served model through `load_model` (a `launch_model` action): it saves `--model ` into the launch arguments and restarts the engine, restoring the previous model if the new one does not start. diff --git a/desktop/src/ui/constants/engine-capabilities.ts b/desktop/src/ui/constants/engine-capabilities.ts index 57fb1a1b4..f9891a548 100644 --- a/desktop/src/ui/constants/engine-capabilities.ts +++ b/desktop/src/ui/constants/engine-capabilities.ts @@ -93,7 +93,9 @@ export const EngineCapabilities: Record = { hasModelSearchOnlyWhenRunning: false, modelOpsWhenStopped: true, // delete_model removes a model from the shared Hugging Face cache. The - // model vLLM is serving cannot be deleted: the engine manager refuses it. + // loaded model is refused, so Delete is disabled on it until Eject. + // Deleting the model an ejected or stopped vLLM is set to serve leaves it + // with no model selected until Load picks one. hasDeleteModel: true, deleteBlockedWhileLoaded: true, // The engine manager downloads a Hugging Face repo into the shared cache diff --git a/docs/engine-lifecycle.mdx b/docs/engine-lifecycle.mdx index 3b3e2757e..5dd3e02d0 100644 --- a/docs/engine-lifecycle.mdx +++ b/docs/engine-lifecycle.mdx @@ -95,18 +95,21 @@ When you install an engine, consider the following: Otherwise PAIR downloads a checksum-pinned `uv`, creates a private Python environment and installs a pinned vLLM release into it. On Linux this is several GiB because it includes PyTorch and the CUDA libraries, and PAIR first - checks for an NVIDIA driver that supports CUDA 13 or newer and about 15 GB of - free disk. On macOS it installs checksum-pinned vLLM and vLLM-Metal wheels and - needs about 10 GB of free disk. Either way PAIR stops with a clear message if a + checks for an NVIDIA driver that supports CUDA 13 or newer and about 25 GB of + free disk, enough for the engine and its default model. On macOS it installs + checksum-pinned vLLM and vLLM-Metal wheels and needs about 20 GB of free disk. Either way PAIR stops with a clear message if a requirement is missing. - Nothing is compiled. vLLM serves a single model per process. Its model list + Nothing is compiled. vLLM serves a single model per process. Until you choose + one it serves Qwen3.5-4B, downloaded on the first start (about 9.3 GB, and a GPU + with at least 16 GB of memory); on a smaller GPU, add and load a smaller model + first. Its model list shows the complete text models already in your Hugging Face cache (`~/.cache/huggingface`), and **Load** on one of them switches vLLM to it by restarting the engine, which takes as long as the model needs to load; if the new model does not start, the previous one is restored. **Add model** browses and searches Hugging Face (open safetensors chat models) and downloads the one you pick into the same cache, with a progress bar, even while vLLM is stopped; - it then appears in the list. **Delete** removes a cached model from disk, except the one vLLM is set to serve (switch first). **Start** brings vLLM up idle, with nothing loaded, as Ollama and llama.cpp + it then appears in the list. **Delete** removes a cached model from disk, except the one vLLM has loaded (eject it first). Deleting the model a stopped or ejected vLLM is set to serve leaves it with no model selected, and **Start** refuses with an error until you **Load** another model. **Start** brings vLLM up idle, with nothing loaded, as Ollama and llama.cpp start a server: it takes about a second and the card reads "Idle · loads on next request". The model loads when the first request arrives (the request waits for it, about 25 seconds for an 8B model) or when you press **Load**, and the same diff --git a/services/nvpair-engine-manager/MANIFEST.md b/services/nvpair-engine-manager/MANIFEST.md index 60e9585c9..808febd11 100644 --- a/services/nvpair-engine-manager/MANIFEST.md +++ b/services/nvpair-engine-manager/MANIFEST.md @@ -272,9 +272,12 @@ Exactly one of `http`, `cmd`, `remove_path`, `hf_cache`, or `launch_model`: path or a shell fragment is refused), writes it into the saved launch settings exactly as the settings editor would, and restarts a running engine on it. If the new model does not come up, the previous one is restored and the - error says so; a stopped engine only has its saved launch changed, and asking - for the model already in the launch arguments restarts nothing. Requires - `runtime.editable_launch` and `runtime.ready` on every platform. + error says so; a stopped engine is started on it, and asking a running engine + for the model already in the launch arguments restarts nothing. An engine + with a `launch_model` action will not start while its launch arguments lack + the flag: it has no model selected, and refusing beats letting the engine + pick and download a default of its own. Requires `runtime.editable_launch` + and `runtime.ready` on every platform. An optional **`result`** declares how `engine:models` extracts a normalized name list from the action's JSON response: `array` is the top-level array diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index bdfd4948d..41b0fb974 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -54,13 +54,20 @@ or delete API of its own, so the manifest provides them as manager actions: `load_model`, `unload_model`, `pull_model`, `delete_model` and the idle-limit actions, described below. -The default launch serves `Qwen/Qwen2.5-0.5B-Instruct` through -`--model `; edit that argument to serve another model. Only +The default launch serves `Qwen/Qwen3.5-4B` through `--model `. +The model thinks before it answers, and with no `--reasoning-parser` in the launch +that reasoning arrives in the reply ahead of the answer; adding +`--reasoning-parser qwen3` in the launch settings moves it to +`reasoning_content`. The model is about 9.3 GB of weights, so the default needs a GPU with at least 16 GB of +memory; on a smaller GPU, download and load a smaller model before starting. +Edit the `--model` argument, or use `load_model`, to serve another model. Only `--host` and `--port` are managed controls, and there is no CORS control. The context window and GPU share are chosen automatically at every start (`auto_launch`, kind `hf_context`), not pinned in the saved launch. The manager reads the model's `config.json` from the Hugging Face cache and the free GPU -memory, then adds: `--max-model-len` of 64K (PAIR's agent-ready default; a +memory (`nvidia-smi`; on a unified-memory machine such as DGX Spark, where it +reports `[N/A]`, the total and available system memory from `/proc/meminfo`), +then adds: `--max-model-len` of 64K (PAIR's agent-ready default; a model with a larger window is capped there, and a smaller one is shrunk to what fits), a YaRN `--hf-overrides` when the model's own window is below 64K (for example Qwen3-8B, which stops at 40,960), and a `--gpu-memory-utilization` just @@ -175,13 +182,18 @@ not stopped, so the next request tries again. Reads such as `/health` and `unload_model` is a `stop_engine` action: vLLM cannot drop its model while running, so ejecting stops its process, which frees all of its GPU memory, while the gateway and the engine stay ON (an engine with no gateway is stopped) and the saved model choice is kept; a model other than the served one is refused. `load_model` on a stopped engine saves the choice and starts it, restoring the previous choice if it does not start. -`delete_model` removes a model's folder from the hub cache, and the large files it links to in the cache's shared `blobs/` store when no other model uses them (an `hf_cache` action with `delete`); the model vLLM is set to serve, running or not, is refused so a switch has to come first. +`delete_model` removes a model's folder from the hub cache, and the large files it links to in the cache's shared `blobs/` store when no other model uses them (an `hf_cache` action with `delete`); the model a vLLM process is serving is refused. Deleting the model an ejected or stopped vLLM is set to serve also takes `--model` out of its launch arguments (an ejected engine is stopped first), and a start with no `--model` is refused with an error rather than letting vLLM download a default model, until `load_model` selects one. `pull_model` is an `hf_pull` action: it asks Hugging Face for the repo's file list and sizes, picks the files vLLM reads (safetensors, config and tokenizer; not GGUF, ONNX, pickle copies of weights that also exist as safetensors, or a -duplicate consolidated file), and runs the `hf` tool that ships beside `vllm` in -the engine's virtualenv to download them into the hub cache. Progress is the +duplicate consolidated file, or `original/` checkpoints), and runs the `hf` tool +that ships beside `vllm` in the engine's virtualenv to download them into the hub +cache. Like the other engines' pulls (one Ollama tag, one llama.cpp +quantization), it downloads what the engine loads, not the whole repository: the +same set vLLM fetches when it downloads a model itself. Other formats in the +repository are not fetched; a full `hf download` adds them to the same cache +without downloading the rest again. Progress is the share of those bytes that has landed in the cache, a download that makes no progress for the pull timeout is abandoned, and a cancelled one is stopped. It needs no running engine. A gated repo needs its terms accepted and `HF_TOKEN` @@ -195,7 +207,7 @@ Python 3.12, creates `{install_dir}/venv` and installs a pinned `vllm` wheel fro PyPI. No source build happens. The pinned wheel is a CUDA 13 build, so the install first checks that `nvidia-smi` exists, that the driver supports CUDA 13 or newer (it reads either the `CUDA UMD Version` or the older `CUDA Version` field), and -that at least 15 GB of disk is free. It fails with a clear message otherwise, +that at least 25 GB of disk is free, enough for the engine and its default model. It fails with a clear message otherwise, before downloading anything. The download is several GB, because `torch` and the CUDA libraries come with it. Model weights download from Hugging Face on first start into `~/.cache/huggingface` diff --git a/services/nvpair-engine-manager/front_test.go b/services/nvpair-engine-manager/front_test.go index cb0a64cc7..db102a9ca 100644 --- a/services/nvpair-engine-manager/front_test.go +++ b/services/nvpair-engine-manager/front_test.go @@ -789,6 +789,42 @@ func TestEjectUnloadsButKeepsTheEngineOn(t *testing.T) { } } +// Eject leaves an engine behind its gateway on with nothing loaded. Deleting the +// model it is set to serve then stops it and leaves it with no model selected, +// rather than being refused as if the model were still loaded. +func TestDeleteAfterEject_StopsTheEngineAndUnselectsTheModel(t *testing.T) { + e, _, _ := frontExecutor(t, 0) + m, _ := e.reg.Get("fronted") + cache := m.Actions["list_models"].HFCache.Dir + m.Actions["delete_model"] = Action{HFCache: &ActionHFCache{Dir: cache, Delete: true}} + if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { + t.Fatal(err) + } + del := func() error { + _, err := e.Action(context.Background(), "fronted", "delete_model", []byte(`{"model":"org/m"}`)) + return err + } + if err := del(); err == nil || !strings.Contains(err.Error(), "eject it or load another model first") { + t.Fatalf("deleting the loaded model = %v, want it refused", err) + } + + if _, err := e.Action(context.Background(), "fronted", "unload_model", []byte(`{"model":"org/m"}`)); err != nil { + t.Fatalf("eject: %v", err) + } + if err := del(); err != nil { + t.Fatalf("deleting the ejected model: %v", err) + } + if st, _ := e.Status("fronted"); st.Running { + t.Fatalf("status = %+v, want the engine stopped once its model is gone", st) + } + if text := launchTextOfEngine(t, e, "fronted"); strings.Contains(text, "--model") { + t.Fatalf("launch text still names a model: %q", text) + } + if ids := scannedIDs(t, cache); slices.Contains(ids, "org/m") { + t.Fatalf("org/m is still in the cache: %v", ids) + } +} + func TestIdleEngineStillAdvertisesItsModel(t *testing.T) { e, _, backend := frontExecutor(t, 0) if err := e.StartWith(context.Background(), "fronted", startOpts{Eager: true}); err != nil { diff --git a/services/nvpair-engine-manager/hfcache.go b/services/nvpair-engine-manager/hfcache.go index 748b3d6da..89c2d2f47 100644 --- a/services/nvpair-engine-manager/hfcache.go +++ b/services/nvpair-engine-manager/hfcache.go @@ -4,6 +4,7 @@ package main import ( + "context" "encoding/json" "errors" "fmt" @@ -14,6 +15,8 @@ import ( "sort" "strconv" "strings" + + settings "nvpair-shared/enginesettings" ) // hfCacheModel is one usable model found in a Hugging Face hub cache. Size is @@ -33,7 +36,7 @@ var hfTokenizerFiles = []string{"tokenizer.json", "tokenizer_config.json", "toke // hfShardRe matches the shard names Hugging Face repos use when they carry no // index file, such as model-00003-of-00004.safetensors. -var hfShardRe = regexp.MustCompile(`^.+-(\d+)-of-(\d+)\.(?:safetensors|bin)$`) +var hfShardRe = regexp.MustCompile(`-(\d+)-of-(\d+)\.(?:safetensors|bin)$`) // runHFCacheAction lists the models in act.HFCache.Dir. It needs no running // engine, so the list is available while the engine is stopped. @@ -201,9 +204,11 @@ func hfWeightsComplete(snapshot string, files map[string]int64, weights []string total = count seen[n] = true } - if total == 0 || len(seen) != total { + if total == 0 { return false } + // Shard numbers come from file names, so count alone is not enough: + // -00000-of-00002 and -00001-of-00002 are two files with shard 2 missing. for n := 1; n <= total; n++ { if !seen[n] { return false @@ -222,9 +227,11 @@ func hasAny(files map[string]int64, names []string) bool { } // deleteHFModel removes one model's repository folder from the hub cache, and -// its download locks. The model the engine is serving, or will serve at its next -// start, is refused: deleting it would break the running engine, or make the next -// start download the model again without being asked. +// its download locks. The model an engine process is serving is refused, since +// the engine would download it again on its next start. The model a stopped or +// ejected engine is set to serve is deleted and taken out of its launch +// arguments, so the engine has no model selected and will not start until one +// is loaded. func (e *Executor) deleteHFModel(engine, hubDir string, params json.RawMessage) (json.RawMessage, error) { var p struct { Model string `json:"model"` @@ -237,9 +244,6 @@ func (e *Executor) deleteHFModel(engine, hubDir string, params json.RawMessage) if err != nil { return nil, fmt.Errorf("delete: cannot tell which model %s is set to serve, so nothing was deleted: %w", engine, err) } - if served == id { - return nil, fmt.Errorf("delete: %s is the model %s is set to serve; switch to another model first", id, engine) - } name := "models--" + strings.ReplaceAll(id, "/", "--") repo := filepath.Join(hubDir, name) if rel, err := filepath.Rel(hubDir, repo); err != nil || rel != name { @@ -248,6 +252,12 @@ func (e *Executor) deleteHFModel(engine, hubDir string, params json.RawMessage) if _, err := os.Stat(repo); err != nil { return nil, fmt.Errorf("delete: %s is not in the model cache", id) } + reselect := func() {} + if served == id { + if reselect, err = e.unselectModel(engine, id); err != nil { + return nil, err + } + } // Recent Hugging Face tooling keeps large files in one store shared by every // repo, hub/blobs//, and leaves symlinks in each repo's own blobs // directory. Removing the repo alone would leave the weights behind, using the @@ -255,6 +265,7 @@ func (e *Executor) deleteHFModel(engine, hubDir string, params json.RawMessage) shared := sharedHFBlobs(hubDir, repo) freed := hfBlobBytes(repo) if err := os.RemoveAll(repo); err != nil { + reselect() return nil, fmt.Errorf("delete %s: %w", id, err) } _ = os.RemoveAll(filepath.Join(hubDir, ".locks", name)) @@ -326,10 +337,58 @@ func removeUnreferencedHFBlobs(hubDir string, candidates []string) int64 { return freed } +// unselectModel takes the model flag out of a stopped engine's saved launch +// arguments, and returns a function that puts it back. An engine whose process +// is serving the model is refused. One ejected behind its gateway is on with no +// process and nothing loaded, so it is stopped first: with no model selected it +// could not load anything again. +func (e *Executor) unselectModel(engine, id string) (reselect func(), err error) { + st, err := e.state(engine) + if err != nil { + return nil, err + } + refused := fmt.Errorf("delete: %s is the model %s is serving; eject it or load another model first", id, engine) + state, err := e.LaunchSettings(engine) + if err != nil { + return nil, err + } + if state.Running { + if status, _ := e.Status(engine); !status.Idle { + return nil, refused + } + if err := e.Stop(engine); err != nil { + return nil, fmt.Errorf("delete: stop %s before removing its model: %w", engine, err) + } + if state, err = e.LaunchSettings(engine); err != nil { + return nil, err + } + } + flag := modelLaunchFlag(st.manifest) + if state.Running || flag == "" || !state.Editable { + return nil, refused + } + text, _, err := withoutLaunchFlag(state.LaunchText, flag) + if err != nil { + return nil, err + } + save := func(launchText string) error { + _, err := e.ConfigureLaunch(context.Background(), settings.Configure{ + Engine: engine, + Settings: settings.Config{ServerPort: state.ServerPort, LaunchText: launchText}, + }, func() error { return nil }) + return err + } + if err := save(text); err != nil { + return nil, fmt.Errorf("delete: could not clear %s from the launch settings of %s: %w", id, engine, err) + } + return func() { _ = save(state.LaunchText) }, nil +} + // servedModel returns the model the engine is set to serve, from its effective -// launch arguments. It fails when that cannot be determined, so a caller that -// must not touch the served model fails closed. The engine's binary is detected -// first, because the launch cannot be resolved before that. +// launch arguments, or "" when they name none. It fails when that cannot be +// determined, so a caller that must not touch the served model fails closed. +// The engine's binary is detected first, because the launch cannot be resolved +// before that. func (e *Executor) servedModel(engine string) (string, error) { state, err := e.LaunchSettings(engine) if err != nil { @@ -347,9 +406,6 @@ func (e *Executor) servedModel(engine string) (string, error) { if err != nil { return "", err } - model, ok := launchFlagValue(tokens, "--model") - if !ok || model == "" { - return "", fmt.Errorf("the launch arguments name no --model") - } + model, _ := launchFlagValue(tokens, "--model") return model, nil } diff --git a/services/nvpair-engine-manager/hfcache_test.go b/services/nvpair-engine-manager/hfcache_test.go index a46520b70..8e16667fd 100644 --- a/services/nvpair-engine-manager/hfcache_test.go +++ b/services/nvpair-engine-manager/hfcache_test.go @@ -93,6 +93,10 @@ func TestScanHFCacheListsCompleteModelsOnly(t *testing.T) { writeHFRepo(t, cache, "Qwen/partial-numbered", llmFiles(map[string]string{ "model-00001-of-00004.safetensors": "a", "model-00002-of-00004.safetensors": "b", "model-00004-of-00004.safetensors": "d", })) + // Two files for a two-shard model, but numbered from zero, so shard 2 is missing. + writeHFRepo(t, cache, "Qwen/misnumbered-shards", llmFiles(map[string]string{ + "model-00000-of-00002.safetensors": "a", "model-00001-of-00002.safetensors": "b", + })) writeHFRepo(t, cache, "Qwen/unnumbered-shards-no-index", llmFiles(map[string]string{ "layers-0.safetensors": "a", "layers-1.safetensors": "b", })) @@ -210,7 +214,7 @@ func TestDeleteHFModel(t *testing.T) { return e.Action(context.Background(), "fake", "delete_model", json.RawMessage(`{"model":`+quoteJSON(model)+`}`)) } - if _, err := del("org/served"); err == nil || !strings.Contains(err.Error(), "switch to another model first") { + if _, err := del("org/served"); err == nil || !strings.Contains(err.Error(), "or load another model first") { t.Fatalf("the model being served must be refused: %v", err) } if _, err := os.Stat(filepath.Join(cache, "models--org--served")); err != nil { @@ -237,6 +241,44 @@ func TestDeleteHFModel(t *testing.T) { } } +// A stopped engine's model can be deleted. The engine is then left with no model +// selected, so it refuses to start rather than download one, until a model is +// loaded. +func TestDeleteHFModel_LeavesAStoppedEngineWithNoModelSelected(t *testing.T) { + cache := t.TempDir() + e := switchModelExecutor(t, "--model") + if _, err := loadModel(t, e, "org/served"); err != nil { + t.Fatalf("load: %v", err) + } + m, _ := e.reg.Get("fake") + m.Actions["delete_model"] = Action{HFCache: &ActionHFCache{Dir: cache, Delete: true}} + writeHFRepo(t, cache, "org/served", llmFiles(map[string]string{"model.safetensors": "w"})) + writeHFRepo(t, cache, "org/other", llmFiles(map[string]string{"model.safetensors": "w"})) + if err := e.Stop("fake"); err != nil { + t.Fatalf("stop: %v", err) + } + + if _, err := e.Action(context.Background(), "fake", "delete_model", json.RawMessage(`{"model":"org/served"}`)); err != nil { + t.Fatalf("deleting the model of a stopped engine: %v", err) + } + if ids := scannedIDs(t, cache); !slices.Equal(ids, []string{"org/other"}) { + t.Fatalf("remaining models = %v", ids) + } + if text := launchTextOf(t, e); strings.Contains(text, "--model") { + t.Fatalf("launch text still names a model: %q", text) + } + + if err := e.Start(context.Background(), "fake"); err == nil || !strings.Contains(err.Error(), "no model selected") { + t.Fatalf("start with no model selected = %v, want a no-model error", err) + } + if _, err := loadModel(t, e, "org/other"); err != nil { + t.Fatalf("loading another model: %v", err) + } + if state, err := e.LaunchSettings("fake"); err != nil || !state.Running { + t.Fatalf("after load: running=%v err=%v", state.Running, err) + } +} + // With the bundled manifest and no saved settings the served model is the // default launch's, even in a manager that has not been asked about the engine // yet. Deleting also fails closed, rather than assuming nothing is served, when @@ -249,7 +291,7 @@ func TestDeleteHFModelRefusesTheBundledDefaultModel(t *testing.T) { t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) cache := filepath.Join(home, ".cache", "huggingface", "hub") - writeHFRepo(t, cache, "Qwen/Qwen2.5-0.5B-Instruct", llmFiles(map[string]string{"model.safetensors": "w"})) + writeHFRepo(t, cache, "Qwen/Qwen3.5-4B", llmFiles(map[string]string{"model.safetensors": "w"})) writeHFRepo(t, cache, "org/other", llmFiles(map[string]string{"model.safetensors": "w"})) reg := loadWithOverrides(t, t.TempDir()) @@ -259,11 +301,11 @@ func TestDeleteHFModelRefusesTheBundledDefaultModel(t *testing.T) { return err } - // The bundled default launch serves Qwen/Qwen2.5-0.5B-Instruct. - if err := act("Qwen/Qwen2.5-0.5B-Instruct"); err == nil { + // The bundled default launch serves Qwen/Qwen3.5-4B. + if err := act("Qwen/Qwen3.5-4B"); err == nil { t.Fatal("the default served model was deleted by a manager that had not detected the engine") } - if _, err := os.Stat(filepath.Join(cache, "models--Qwen--Qwen2.5-0.5B-Instruct")); err != nil { + if _, err := os.Stat(filepath.Join(cache, "models--Qwen--Qwen3.5-4B")); err != nil { t.Fatal("the served model is gone from the cache") } if err := act("org/other"); err != nil { diff --git a/services/nvpair-engine-manager/hfpull.go b/services/nvpair-engine-manager/hfpull.go index e75f91fe8..a3fa05f4c 100644 --- a/services/nvpair-engine-manager/hfpull.go +++ b/services/nvpair-engine-manager/hfpull.go @@ -18,6 +18,11 @@ import ( "time" ) +// The download itself is done by Hugging Face's own `hf` CLI, which ships with +// the huggingface_hub package vLLM depends on, so it writes the cache exactly as +// vLLM reads it. This file only chooses the files to fetch, reports progress and +// stops a download that stalls. + // hfAPIBase is the Hugging Face model API. A variable so tests can point it at // a local server. var hfAPIBase = hfModelsAPI diff --git a/services/nvpair-engine-manager/launchauto.go b/services/nvpair-engine-manager/launchauto.go index 5b1b8e20e..2f5bcdba6 100644 --- a/services/nvpair-engine-manager/launchauto.go +++ b/services/nvpair-engine-manager/launchauto.go @@ -44,11 +44,22 @@ func nvidiaSMIMemory() (total, free int64, ok bool) { if err != nil { return 0, 0, false } - line, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n") + return parseGPUMemory(string(out), systemMemory) +} + +// parseGPUMemory reads the first row of nvidia-smi's memory.total,memory.free +// query, in MiB. On a unified-memory machine such as DGX Spark the GPU shares +// system RAM and nvidia-smi reports [N/A]; system memory then stands in, as it +// does in nvpair-node-info, and as vLLM itself sees it there. +func parseGPUMemory(out string, system func() (total, available int64, ok bool)) (total, free int64, ok bool) { + line, _, _ := strings.Cut(strings.TrimSpace(out), "\n") parts := strings.Split(line, ",") if len(parts) != 2 { return 0, 0, false } + if nvidiaSMINotApplicable(parts[0]) { + return system() + } t, errT := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64) f, errF := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) if errT != nil || errF != nil || t <= 0 { @@ -57,6 +68,48 @@ func nvidiaSMIMemory() (total, free int64, ok bool) { return t * 1024 * 1024, f * 1024 * 1024, true } +// nvidiaSMINotApplicable reports an nvidia-smi field that holds no number, such +// as the [N/A] or [Not Supported] a unified-memory GPU gives for its memory. +func nvidiaSMINotApplicable(field string) bool { + value := strings.ToLower(strings.Trim(strings.TrimSpace(field), "[]")) + return value == "n/a" || value == "not supported" +} + +// systemMemory reports total and available system memory from /proc/meminfo. +func systemMemory() (total, available int64, ok bool) { + raw, err := os.ReadFile("/proc/meminfo") + if err != nil { + return 0, 0, false + } + return parseMeminfo(string(raw)) +} + +// parseMeminfo reads MemTotal and MemAvailable, which /proc/meminfo gives in kB. +// Available rather than free counts the page cache the kernel can reclaim. +func parseMeminfo(text string) (total, available int64, ok bool) { + for _, line := range strings.Split(text, "\n") { + key, rest, found := strings.Cut(line, ":") + if !found { + continue + } + fields := strings.Fields(rest) + if len(fields) == 0 { + continue + } + kb, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + continue + } + switch key { + case "MemTotal": + total = kb * 1024 + case "MemAvailable": + available = kb * 1024 + } + } + return total, available, total > 0 && available > 0 +} + // applyAutoLaunch appends the arguments the manifest's auto_launch asks for. // It runs after the user's launch has been validated and never changes an // argument the user supplied, so anything set in the engine settings wins. diff --git a/services/nvpair-engine-manager/launchauto_test.go b/services/nvpair-engine-manager/launchauto_test.go index 46b310071..e2cfbdceb 100644 --- a/services/nvpair-engine-manager/launchauto_test.go +++ b/services/nvpair-engine-manager/launchauto_test.go @@ -313,3 +313,38 @@ func TestParsesToolCalls_NeedsBothFlags(t *testing.T) { } } } + +func TestParseGPUMemory_ReadsADiscreteGPUAndFallsBackOnUnifiedMemory(t *testing.T) { + system := func() (int64, int64, bool) { return 128 * gib, 100 * gib, true } + for _, tc := range []struct { + name string + out string + total, free int64 + ok bool + }{ + {"discrete GPU, in MiB", "49140, 48600\n", 49140 << 20, 48600 << 20, true}, + {"first GPU of several", "24576, 20000\n49140, 48600\n", 24576 << 20, 20000 << 20, true}, + {"DGX Spark reports N/A", "[N/A], [N/A]\n", 128 * gib, 100 * gib, true}, + {"not supported", "[Not Supported], [Not Supported]\n", 128 * gib, 100 * gib, true}, + {"malformed", "error\n", 0, 0, false}, + {"empty", "", 0, 0, false}, + } { + t.Run(tc.name, func(t *testing.T) { + total, free, ok := parseGPUMemory(tc.out, system) + if total != tc.total || free != tc.free || ok != tc.ok { + t.Fatalf("parseGPUMemory(%q) = %d, %d, %v; want %d, %d, %v", tc.out, total, free, ok, tc.total, tc.free, tc.ok) + } + }) + } +} + +func TestParseMeminfo_ReadsTotalAndAvailable(t *testing.T) { + text := "MemTotal: 130567000 kB\nMemFree: 2000000 kB\nMemAvailable: 104000000 kB\nBuffers: 100 kB\n" + total, available, ok := parseMeminfo(text) + if !ok || total != 130567000*1024 || available != 104000000*1024 { + t.Fatalf("parseMeminfo = %d, %d, %v", total, available, ok) + } + if _, _, ok := parseMeminfo("MemTotal: 100 kB\n"); ok { + t.Fatal("a reading without MemAvailable must not count as known") + } +} diff --git a/services/nvpair-engine-manager/launchmodel.go b/services/nvpair-engine-manager/launchmodel.go index 35a9202da..4dd570977 100644 --- a/services/nvpair-engine-manager/launchmodel.go +++ b/services/nvpair-engine-manager/launchmodel.go @@ -130,40 +130,66 @@ func withLaunchFlag(text, flag, value string) (result string, changed bool, err if err != nil { return "", false, err } - out := make([]string, 0, len(tokens)+2) - found := false + rest, at := dropLaunchFlag(tokens, flag) + out := slices.Insert(rest, at, flag, value) + result, err = formatLaunchText(out) + return result, !slices.Equal(tokens, out), err +} + +// withoutLaunchFlag returns launch text with every "flag value" and +// "flag=value" before any "--" terminator removed, and whether any was. +func withoutLaunchFlag(text, flag string) (result string, changed bool, err error) { + tokens, err := parseLaunchText(text) + if err != nil { + return "", false, err + } + rest, _ := dropLaunchFlag(tokens, flag) + result, err = formatLaunchText(rest) + return result, len(rest) != len(tokens), err +} + +// dropLaunchFlag returns the arguments without any "flag value" or "flag=value" +// before a "--" terminator, and the index in that result where the flag first +// stood. Without the flag, the index is the terminator's, or the end. +// Arguments are a list rather than key/value pairs: flags repeat, may take no +// value, and keep their order, so the list is walked rather than looked up. +func dropLaunchFlag(tokens []string, flag string) (rest []string, at int) { + rest = make([]string, 0, len(tokens)+2) + at = -1 for i := 0; i < len(tokens); i++ { token := tokens[i] switch { case token == "--": - if !found { - out = append(out, flag, value) - found = true - } - out = append(out, tokens[i:]...) - i = len(tokens) - case token == flag: - if !found { - out = append(out, flag, value) - found = true + if at < 0 { + at = len(rest) } - if i+1 < len(tokens) { - i++ // the old value + return append(rest, tokens[i:]...), at + case token == flag || strings.HasPrefix(token, flag+"="): + if at < 0 { + at = len(rest) } - case strings.HasPrefix(token, flag+"="): - if !found { - out = append(out, flag, value) - found = true + if token == flag { + i++ // its value } default: - out = append(out, token) + rest = append(rest, token) } } - if !found { - out = append(out, flag, value) + if at < 0 { + at = len(rest) } - result, err = formatLaunchText(out) - return result, !slices.Equal(tokens, out), err + return rest, at +} + +// modelLaunchFlag is the flag an engine's launch_model action writes, or "" for +// an engine that does not choose its model by launch argument. +func modelLaunchFlag(m *Manifest) string { + for _, act := range m.Actions { + if act.LaunchModel != nil { + return act.LaunchModel.Flag + } + } + return "" } // ejectByStopping ejects the served model by stopping the engine, which is the diff --git a/services/nvpair-engine-manager/launchmodel_test.go b/services/nvpair-engine-manager/launchmodel_test.go index d0c283e6b..a14e6a712 100644 --- a/services/nvpair-engine-manager/launchmodel_test.go +++ b/services/nvpair-engine-manager/launchmodel_test.go @@ -60,6 +60,21 @@ func switchModelExecutor(t *testing.T, flag string) *Executor { if err := m.Validate(); err != nil { t.Fatal(err) } + // An engine that chooses its model by launch argument needs one to start. + state, err := e.LaunchSettings("fake") + if err != nil { + t.Fatal(err) + } + text, _, err := withLaunchFlag(state.LaunchText, flag, "org/initial") + if err != nil { + t.Fatal(err) + } + if _, err := e.ConfigureLaunch(context.Background(), settings.Configure{ + Engine: "fake", + Settings: settings.Config{ServerPort: state.ServerPort, LaunchText: text}, + }, func() error { return nil }); err != nil { + t.Fatal(err) + } if err := e.Start(context.Background(), "fake"); err != nil { t.Fatalf("start: %v", err) } @@ -75,13 +90,35 @@ func quoteJSON(s string) string { b, _ := json.Marshal(s); return string(b) } func launchTextOf(t *testing.T, e *Executor) string { t.Helper() - state, err := e.LaunchSettings("fake") + return launchTextOfEngine(t, e, "fake") +} + +func launchTextOfEngine(t *testing.T, e *Executor, engine string) string { + t.Helper() + state, err := e.LaunchSettings(engine) if err != nil { t.Fatal(err) } return state.LaunchText } +func TestWithoutLaunchFlag_RemovesEveryFormOfTheFlag(t *testing.T) { + for _, tc := range []struct{ name, in, want string }{ + {"separate value", "--model org/a --port 8001", "--port 8001"}, + {"equals form", "--model=org/a --port 8001", "--port 8001"}, + {"repeated", "--model org/a --port 8001 --model=org/b", "--port 8001"}, + {"after the terminator is kept", "--port 8001 -- --model org/a", "--port 8001 -- --model org/a"}, + {"absent", "--port 8001", "--port 8001"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, changed, err := withoutLaunchFlag(tc.in, "--model") + if err != nil || got != tc.want || changed != (tc.in != tc.want) { + t.Fatalf("withoutLaunchFlag(%q) = %q, %v, %v; want %q", tc.in, got, changed, err, tc.want) + } + }) + } +} + func TestLaunchModelSwitchesAndRestartsARunningEngine(t *testing.T) { e := switchModelExecutor(t, "--model") gen := startGeneration(t, e) @@ -179,12 +216,12 @@ func TestLaunchModelOnAStoppedEngineStartsIt(t *testing.T) { } func TestLaunchModelOnAStoppedEngineRestoresTheChoiceWhenItWillNotStart(t *testing.T) { - e := switchModelExecutor(t, "--fail-launch") + e := switchModelExecutor(t, "--model") before := launchTextOf(t, e) if err := e.Stop("fake"); err != nil { t.Fatal(err) } - if _, err := loadModel(t, e, "org/broken"); err == nil || !strings.Contains(err.Error(), "previous model was restored") { + if _, err := loadModel(t, e, "org/fails-to-load"); err == nil || !strings.Contains(err.Error(), "previous model was restored") { t.Fatalf("err = %v, want the failed start reported with the restore", err) } if mustStatus(t, e).Running { @@ -260,17 +297,17 @@ func TestLaunchModelRefusesAnythingThatIsNotAModelID(t *testing.T) { // If the new model does not come up, the engine goes back to the one it was // serving instead of staying down. The fake engine exits at once when it is -// given "--fail-launch", which here stands in for a model that fails to load. +// given the model org/fails-to-load, which stands in for a model that does not fit. func TestLaunchModelRestoresThePreviousModelWhenTheNewOneFails(t *testing.T) { - e := switchModelExecutor(t, "--fail-launch") + e := switchModelExecutor(t, "--model") before := launchTextOf(t, e) - _, err := loadModel(t, e, "org/too-big") + _, err := loadModel(t, e, "org/fails-to-load") if err == nil { t.Fatal("a model that failed to start was reported as loaded") } // The note comes first: the engine's own error that follows it is a long log tail. - if !strings.HasPrefix(err.Error(), "switching to org/too-big failed; the previous model was restored: ") { + if !strings.HasPrefix(err.Error(), "switching to org/fails-to-load failed; the previous model was restored: ") { t.Fatalf("error does not lead with the restore note: %v", err) } if got := launchTextOf(t, e); got != before { diff --git a/services/nvpair-engine-manager/lifecycle.go b/services/nvpair-engine-manager/lifecycle.go index b338d49bd..af972ca9f 100644 --- a/services/nvpair-engine-manager/lifecycle.go +++ b/services/nvpair-engine-manager/lifecycle.go @@ -183,6 +183,14 @@ func (e *Executor) doStart(ctx context.Context, st *engineState, engine string, if !pathInstalled { return fmt.Errorf("engine %q is not installed", engine) } + // An engine whose model is a launch argument cannot start without one. vLLM + // would serve, and first download, a default model of its own instead. + if flag := modelLaunchFlag(st.manifest); flag != "" { + tokens, err := parseLaunchText(e.launchStateLocked(engine, st).LaunchText) + if model, _ := launchFlagValue(tokens, flag); err == nil && model == "" { + return fmt.Errorf("%s has no model selected; load a model to start it", st.manifest.DisplayName) + } + } vars := st.pathVars() vars["host"] = effectiveBind(rt.Bind, opts.Bind) vars["port"] = strconv.Itoa(port) diff --git a/services/nvpair-engine-manager/manifests/vllm.json b/services/nvpair-engine-manager/manifests/vllm.json index 79da1f004..4b8b19d34 100644 --- a/services/nvpair-engine-manager/manifests/vllm.json +++ b/services/nvpair-engine-manager/manifests/vllm.json @@ -15,7 +15,7 @@ { "flags": ["--port"], "value": "{server.port}" } ] }, - "args": ["serve", "--model", "Qwen/Qwen2.5-0.5B-Instruct", "--host", "{host}", "--port", "{port}"], + "args": ["serve", "--model", "Qwen/Qwen3.5-4B", "--host", "{host}", "--port", "{port}"], "front": { "backend_port_offset": 10000, "idle_timeout_s": 300 }, "auto_launch": { "kind": "hf_context", "context_tokens": 65536 }, "env": { "HF_HOME": "{models_dir}", "VLLM_USE_FLASHINFER_SAMPLER": "0" }, @@ -35,7 +35,7 @@ }, "run": [ "sh", "-c", - "set -eu; command -v nvidia-smi >/dev/null 2>&1 || { echo 'vLLM needs an NVIDIA GPU and driver (nvidia-smi not found)' >&2; exit 1; }; cuda_major=$(nvidia-smi -q 2>/dev/null | awk -F' *: *' '/^CUDA (UMD )?Version/ {split($2, v, \".\"); print v[1]; exit}'); [ \"${cuda_major:-0}\" -ge 13 ] 2>/dev/null || { echo \"vLLM needs an NVIDIA driver that supports CUDA 13 or newer (found: ${cuda_major:-none}). Update your NVIDIA driver and try again.\" >&2; exit 1; }; free_kb=$(df -Pk \"$2\" | awk 'NR==2 {print $4}'); [ \"${free_kb:-0}\" -ge 15728640 ] || { echo 'vLLM needs at least 15 GB of free disk space' >&2; exit 1; }; mkdir -p \"$2/uv\"; tar -xzf \"$1\" -C \"$2/uv\" --strip-components=1; export UV_PYTHON_INSTALL_DIR=\"$2/python\" UV_PYTHON_PREFERENCE=only-managed; \"$2/uv/uv\" venv --no-cache --python 3.12 \"$2/venv\"; \"$2/uv/uv\" pip install --no-cache --python \"$2/venv/bin/python\" \"vllm==0.32.0\"", + "set -eu; command -v nvidia-smi >/dev/null 2>&1 || { echo 'vLLM needs an NVIDIA GPU and driver (nvidia-smi not found)' >&2; exit 1; }; cuda_major=$(nvidia-smi -q 2>/dev/null | awk -F' *: *' '/^CUDA (UMD )?Version/ {split($2, v, \".\"); print v[1]; exit}'); [ \"${cuda_major:-0}\" -ge 13 ] 2>/dev/null || { echo \"vLLM needs an NVIDIA driver that supports CUDA 13 or newer (found: ${cuda_major:-none}). Update your NVIDIA driver and try again.\" >&2; exit 1; }; free_kb=$(df -Pk \"$2\" | awk 'NR==2 {print $4}'); [ \"${free_kb:-0}\" -ge 26214400 ] || { echo 'vLLM needs at least 25 GB of free disk space (the engine and its default model)' >&2; exit 1; }; mkdir -p \"$2/uv\"; tar -xzf \"$1\" -C \"$2/uv\" --strip-components=1; export UV_PYTHON_INSTALL_DIR=\"$2/python\" UV_PYTHON_PREFERENCE=only-managed; \"$2/uv/uv\" venv --no-cache --python 3.12 \"$2/venv\"; \"$2/uv/uv\" pip install --no-cache --python \"$2/venv/bin/python\" \"vllm==0.32.0\"", "vllm-install", "{download}", "{install_dir}" ] } @@ -49,7 +49,7 @@ }, "run": [ "sh", "-c", - "set -eu; command -v nvidia-smi >/dev/null 2>&1 || { echo 'vLLM needs an NVIDIA GPU and driver (nvidia-smi not found)' >&2; exit 1; }; cuda_major=$(nvidia-smi -q 2>/dev/null | awk -F' *: *' '/^CUDA (UMD )?Version/ {split($2, v, \".\"); print v[1]; exit}'); [ \"${cuda_major:-0}\" -ge 13 ] 2>/dev/null || { echo \"vLLM needs an NVIDIA driver that supports CUDA 13 or newer (found: ${cuda_major:-none}). Update your NVIDIA driver and try again.\" >&2; exit 1; }; free_kb=$(df -Pk \"$2\" | awk 'NR==2 {print $4}'); [ \"${free_kb:-0}\" -ge 15728640 ] || { echo 'vLLM needs at least 15 GB of free disk space' >&2; exit 1; }; mkdir -p \"$2/uv\"; tar -xzf \"$1\" -C \"$2/uv\" --strip-components=1; export UV_PYTHON_INSTALL_DIR=\"$2/python\" UV_PYTHON_PREFERENCE=only-managed; \"$2/uv/uv\" venv --no-cache --python 3.12 \"$2/venv\"; \"$2/uv/uv\" pip install --no-cache --python \"$2/venv/bin/python\" \"vllm==0.32.0\"", + "set -eu; command -v nvidia-smi >/dev/null 2>&1 || { echo 'vLLM needs an NVIDIA GPU and driver (nvidia-smi not found)' >&2; exit 1; }; cuda_major=$(nvidia-smi -q 2>/dev/null | awk -F' *: *' '/^CUDA (UMD )?Version/ {split($2, v, \".\"); print v[1]; exit}'); [ \"${cuda_major:-0}\" -ge 13 ] 2>/dev/null || { echo \"vLLM needs an NVIDIA driver that supports CUDA 13 or newer (found: ${cuda_major:-none}). Update your NVIDIA driver and try again.\" >&2; exit 1; }; free_kb=$(df -Pk \"$2\" | awk 'NR==2 {print $4}'); [ \"${free_kb:-0}\" -ge 26214400 ] || { echo 'vLLM needs at least 25 GB of free disk space (the engine and its default model)' >&2; exit 1; }; mkdir -p \"$2/uv\"; tar -xzf \"$1\" -C \"$2/uv\" --strip-components=1; export UV_PYTHON_INSTALL_DIR=\"$2/python\" UV_PYTHON_PREFERENCE=only-managed; \"$2/uv/uv\" venv --no-cache --python 3.12 \"$2/venv\"; \"$2/uv/uv\" pip install --no-cache --python \"$2/venv/bin/python\" \"vllm==0.32.0\"", "vllm-install", "{download}", "{install_dir}" ] } @@ -63,7 +63,7 @@ }, "run": [ "sh", "-c", - "set -eu; [ \"$(uname -m)\" = arm64 ] || { echo 'vLLM on macOS needs an Apple Silicon Mac (arm64)' >&2; exit 1; }; macos_major=$(sw_vers -productVersion | cut -d. -f1); [ \"${macos_major:-0}\" -ge 15 ] 2>/dev/null || { echo \"vLLM needs macOS 15 or newer (found: ${macos_major:-unknown}). Update macOS and try again.\" >&2; exit 1; }; free_kb=$(df -Pk \"$2\" | awk 'NR==2 {print $4}'); [ \"${free_kb:-0}\" -ge 10485760 ] || { echo 'vLLM needs at least 10 GB of free disk space' >&2; exit 1; }; mkdir -p \"$2/uv\" \"$2/wheels\"; tar -xzf \"$1\" -C \"$2/uv\" --strip-components=1; export UV_PYTHON_INSTALL_DIR=\"$2/python\" UV_PYTHON_PREFERENCE=only-managed; \"$2/uv/uv\" venv --no-cache --python 3.12 \"$2/venv\"; core_whl=\"$2/wheels/vllm-0.32.0+cpu-cp38-abi3-macosx_11_0_arm64.whl\"; metal_whl=\"$2/wheels/vllm_metal-0.32.0-cp312-cp312-macosx_15_0_arm64.whl\"; curl -fsSL --retry 3 -o \"${core_whl:-}\" 'https://github.com/vllm-project/vllm/releases/download/v0.32.0/vllm-0.32.0%2Bcpu-cp38-abi3-macosx_11_0_arm64.whl'; echo \"c28cf0fede58f02c4886d2794217ec3c7e4bbd22dfeb16b4b32586a8f230bb75 ${core_whl:-}\" | shasum -a 256 -c -; curl -fsSL --retry 3 -o \"${metal_whl:-}\" 'https://github.com/vllm-project/vllm-metal/releases/download/v0.32.0/vllm_metal-0.32.0-cp312-cp312-macosx_15_0_arm64.whl'; echo \"458d34157eb02a6bfb0e293c70485df18f72b7eb1d1ce8f1eefea95ba47ab63c ${metal_whl:-}\" | shasum -a 256 -c -; \"$2/uv/uv\" pip install --no-cache --python \"$2/venv/bin/python\" \"${core_whl:-}\"; \"$2/uv/uv\" pip install --no-cache --python \"$2/venv/bin/python\" \"${metal_whl:-}\"; rm -rf \"$2/wheels\"", + "set -eu; [ \"$(uname -m)\" = arm64 ] || { echo 'vLLM on macOS needs an Apple Silicon Mac (arm64)' >&2; exit 1; }; macos_major=$(sw_vers -productVersion | cut -d. -f1); [ \"${macos_major:-0}\" -ge 15 ] 2>/dev/null || { echo \"vLLM needs macOS 15 or newer (found: ${macos_major:-unknown}). Update macOS and try again.\" >&2; exit 1; }; free_kb=$(df -Pk \"$2\" | awk 'NR==2 {print $4}'); [ \"${free_kb:-0}\" -ge 20971520 ] || { echo 'vLLM needs at least 20 GB of free disk space (the engine and its default model)' >&2; exit 1; }; mkdir -p \"$2/uv\" \"$2/wheels\"; tar -xzf \"$1\" -C \"$2/uv\" --strip-components=1; export UV_PYTHON_INSTALL_DIR=\"$2/python\" UV_PYTHON_PREFERENCE=only-managed; \"$2/uv/uv\" venv --no-cache --python 3.12 \"$2/venv\"; core_whl=\"$2/wheels/vllm-0.32.0+cpu-cp38-abi3-macosx_11_0_arm64.whl\"; metal_whl=\"$2/wheels/vllm_metal-0.32.0-cp312-cp312-macosx_15_0_arm64.whl\"; curl -fsSL --retry 3 -o \"${core_whl:-}\" 'https://github.com/vllm-project/vllm/releases/download/v0.32.0/vllm-0.32.0%2Bcpu-cp38-abi3-macosx_11_0_arm64.whl'; echo \"c28cf0fede58f02c4886d2794217ec3c7e4bbd22dfeb16b4b32586a8f230bb75 ${core_whl:-}\" | shasum -a 256 -c -; curl -fsSL --retry 3 -o \"${metal_whl:-}\" 'https://github.com/vllm-project/vllm-metal/releases/download/v0.32.0/vllm_metal-0.32.0-cp312-cp312-macosx_15_0_arm64.whl'; echo \"458d34157eb02a6bfb0e293c70485df18f72b7eb1d1ce8f1eefea95ba47ab63c ${metal_whl:-}\" | shasum -a 256 -c -; \"$2/uv/uv\" pip install --no-cache --python \"$2/venv/bin/python\" \"${core_whl:-}\"; \"$2/uv/uv\" pip install --no-cache --python \"$2/venv/bin/python\" \"${metal_whl:-}\"; rm -rf \"$2/wheels\"", "vllm-install", "{download}", "{install_dir}" ] } @@ -92,11 +92,11 @@ "stop_engine": true }, "delete_model": { - "description": "Delete a downloaded model from the Hugging Face cache (params: {\"model\": \"\"}). The model vLLM is set to serve is refused: switch to another model first.", + "description": "Delete a downloaded model from the Hugging Face cache (params: {\"model\": \"\"}). The model vLLM has loaded is refused (eject it first). Deleting the model an ejected or stopped vLLM is set to serve leaves it with no model selected, and it will not start until a model is loaded.", "hf_cache": { "dir": "{models_dir}/hub", "delete": true } }, "pull_model": { - "description": "Download a model from Hugging Face into the shared cache (params: {\"model\": \"\"}). Needs no running engine. Once downloaded it appears in list_models and can be served with load_model.", + "description": "Download the files vLLM loads from a Hugging Face model into the shared cache (params: {\"model\": \"\"}): weights in one format, config and tokenizer. Other formats in the repository, such as GGUF or original/ checkpoints, are not fetched. Needs no running engine. Once downloaded it appears in list_models and can be served with load_model.", "hf_pull": { "tool": "hf", "dir": "{models_dir}/hub" } }, "loaded_models": { diff --git a/services/nvpair-engine-manager/testdata/fakeengine/main.go b/services/nvpair-engine-manager/testdata/fakeengine/main.go index 1446bfa15..cc2b851ff 100644 --- a/services/nvpair-engine-manager/testdata/fakeengine/main.go +++ b/services/nvpair-engine-manager/testdata/fakeengine/main.go @@ -81,8 +81,10 @@ func main() { os.Exit(2) } } + // "--fail-launch", or a model named org/fails-to-load, stands in for a launch + // the engine rejects, such as a model that does not fit in GPU memory. for _, arg := range os.Args[1:] { - if arg == "--fail-launch" { + if arg == "--fail-launch" || arg == "org/fails-to-load" { fmt.Fprintln(os.Stderr, "invalid launch", os.Args[1:]) os.Exit(2) } diff --git a/services/nvpair-tui/ui/engineswire.go b/services/nvpair-tui/ui/engineswire.go index b9a2b547e..1c7bf311e 100644 --- a/services/nvpair-tui/ui/engineswire.go +++ b/services/nvpair-tui/ui/engineswire.go @@ -152,7 +152,7 @@ var downloadExamples = map[string]string{ engines.NameOllama: "llama3.2", engines.NameLMStudio: "lmstudio-community/Qwen3-8B-GGUF", engines.NameLlamaCPP: "ggml-org/gemma-3-1b-it-GGUF:Q4_K_M", - engines.NameVLLM: "Qwen/Qwen2.5-0.5B-Instruct", + engines.NameVLLM: "Qwen/Qwen3.5-4B", } // downloadPrompt is the placeholder for the download-by-name field, with an From 8a72b821a961aa9cdb25dd2d14a6a292f6dfc26e Mon Sep 17 00:00:00 2001 From: nvrithik Date: Fri, 30 Oct 2026 08:15:59 -0700 Subject: [PATCH 33/35] fix(nvpair-engine-manager): install vLLM on a space-free path Let manifests declare a managed install directory and place vLLM under ~/.nvpair so FlashInfer and DeepGEMM can compile kernels without unescaped spaces. Keep the venv first on PATH for helper tools and extend reset and uninstall inventories across Linux, macOS, and Windows while preserving the Hugging Face model cache. Signed-off-by: nvrithik --- desktop/docs/architecture.md | 7 + desktop/scripts/build/installer.nsh | 14 +- desktop/scripts/build/linux/after-remove.sh | 6 +- desktop/scripts/build/macos/uninstall.sh | 3 + desktop/tests/modular/app-data-wipe.test.ts | 13 ++ docs/building.mdx | 5 +- scripts/wipe-app-data.ps1 | 39 ++++-- scripts/wipe-app-data.sh | 20 ++- services/nvpair-engine-manager/MANIFEST.md | 18 +-- services/nvpair-engine-manager/executor.go | 11 +- .../nvpair-engine-manager/manifests/vllm.json | 1 + .../nvpair-engine-manager/modelsdir_test.go | 14 +- services/nvpair-engine-manager/proc.go | 50 ++++++- .../nvpair-engine-manager/proc_venv_test.go | 124 ++++++++++++++++++ services/nvpair-engine-manager/provenance.go | 8 +- .../nvpair-engine-manager/provenance_test.go | 21 +++ services/nvpair-engine-manager/registry.go | 49 +++++-- .../nvpair-engine-manager/registry_test.go | 35 ++++- services/nvpair-engine-manager/spec.md | 7 +- .../vllm_manifest_test.go | 16 ++- 20 files changed, 395 insertions(+), 66 deletions(-) create mode 100644 services/nvpair-engine-manager/proc_venv_test.go diff --git a/desktop/docs/architecture.md b/desktop/docs/architecture.md index 94928ec35..c4307ffaa 100644 --- a/desktop/docs/architecture.md +++ b/desktop/docs/architecture.md @@ -373,6 +373,13 @@ Personal AI Router and the Go services share one current per-user root: - `Nvidia Corporation/Personal AI Router`. +Managed engine payloads normally live under that root. vLLM is the deliberate +exception: its load-time GPU build tools cannot parse spaces in source/include +paths, so PAIR installs its private environment at +`~/.nvpair/engine-bin/vllm`. The append-only wipe inventory and every platform +uninstaller include that exact PAIR-owned path; model weights remain in the +separate Hugging Face cache. + On first launch after the rename, Electron data under `NVIDIA Corporation/PAIR` is merged into the shared directory without overwriting backend files already present there. diff --git a/desktop/scripts/build/installer.nsh b/desktop/scripts/build/installer.nsh index bb2df03de..1c3fb92ca 100644 --- a/desktop/scripts/build/installer.nsh +++ b/desktop/scripts/build/installer.nsh @@ -47,7 +47,8 @@ ; model store as models_dir, and a test in services/nvpair-engine-manager fails ; if one of them resolves inside the app data root. ; -; UNINSTALLER SAFETY: only ever delete the three per-user AppData roots below. +; UNINSTALLER SAFETY: only ever delete the three per-user AppData roots and the +; exact PAIR-managed vLLM payload below. ; Never touch $INSTDIR (C:\Program Files\...). The uninstaller itself lives at ; $INSTDIR\Uninstall ${PRODUCT_FILENAME}.exe and is removed by the template's ; own RMDir /r $INSTDIR + DeleteRegKey; deleting it here would orphan the @@ -66,6 +67,7 @@ ; - Shared app data: %LOCALAPPDATA%\Nvidia Corporation\Personal AI Router ; - Previous app data: %LOCALAPPDATA%\NVIDIA Corporation\PAIR ; - electron-updater: %LOCALAPPDATA%\nvpair-updater +; - Managed vLLM: %USERPROFILE%\.nvpair\engine-bin\vllm ; On Windows the "NVIDIA Corporation" / "Nvidia Corporation" casing resolves to ; the same case-insensitive folder. ; Per-user data roots. Keep in sync with src/shared/constants/app.ts and the @@ -74,10 +76,14 @@ DetailPrint "Removing NVIDIA PAIR user data..." ClearErrors ReadEnvStr $0 LOCALAPPDATA + ReadEnvStr $1 USERPROFILE RMDir /r "$0\Nvidia Corporation\Personal AI Router" RMDir /r "$0\NVIDIA Corporation\PAIR" RMDir "$0\NVIDIA Corporation" RMDir /r "$0\nvpair-updater" + RMDir /r "$1\.nvpair\engine-bin\vllm" + RMDir "$1\.nvpair\engine-bin" + RMDir "$1\.nvpair" ClearErrors !macroend @@ -109,6 +115,7 @@ ; Best-effort: when the user opts to remove data, stop any process whose ; executable lives UNDER one of the data roots (e.g. an engine like Ollama ; running from %LOCALAPPDATA%\Nvidia Corporation\Personal AI Router\engine-bin\) +; or the PAIR-managed vLLM directory ; so RMDir /r can then delete it. Scoped by ExecutablePath, so an external ; Ollama installed elsewhere is never touched. nsExec::ExecToLog never aborts the (un)installer, ; PowerShell swallows its own errors (-ErrorAction SilentlyContinue), and a @@ -117,7 +124,7 @@ ; containing spaces need no extra quoting. !macro pairKillProcessesInDataDirs DetailPrint "Stopping any processes running from NVIDIA PAIR data folders..." - nsExec::ExecToLog 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_Process | Where-Object { $$_.ExecutablePath -and ($$_.ExecutablePath -like \"$$env:LOCALAPPDATA\NVIDIA Corporation\PAIR\*\" -or $$_.ExecutablePath -like \"$$env:LOCALAPPDATA\Nvidia Corporation\Personal AI Router\*\") } | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }"' + nsExec::ExecToLog 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_Process | Where-Object { $$_.ExecutablePath -and ($$_.ExecutablePath -like \"$$env:LOCALAPPDATA\NVIDIA Corporation\PAIR\*\" -or $$_.ExecutablePath -like \"$$env:LOCALAPPDATA\Nvidia Corporation\Personal AI Router\*\" -or $$_.ExecutablePath -like \"$$env:USERPROFILE\.nvpair\engine-bin\vllm\*\") } | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }"' ; Give the OS a moment to release the file handles before RMDir /r. Sleep 1000 !macroend @@ -128,6 +135,7 @@ ; continue — the uninstall must never abort over locked user data. !macro pairWarnIfDataRemains ReadEnvStr $0 LOCALAPPDATA + ReadEnvStr $1 USERPROFILE StrCpy $9 "" IfFileExists "$0\Nvidia Corporation\Personal AI Router\*.*" 0 +2 StrCpy $9 "$9$\n$0\Nvidia Corporation\Personal AI Router" @@ -135,6 +143,8 @@ StrCpy $9 "$9$\n$0\NVIDIA Corporation\PAIR" IfFileExists "$0\nvpair-updater\*.*" 0 +2 StrCpy $9 "$9$\n$0\nvpair-updater" + IfFileExists "$1\.nvpair\engine-bin\vllm\*.*" 0 +2 + StrCpy $9 "$9$\n$1\.nvpair\engine-bin\vllm" StrCmp $9 "" pairNoLeftover MessageBox MB_OK|MB_ICONEXCLAMATION "Some NVIDIA PAIR data could not be removed because files were still in use (for example, a running engine such as Ollama).$\n$\nClose those programs, then delete these folders manually:$9" pairNoLeftover: diff --git a/desktop/scripts/build/linux/after-remove.sh b/desktop/scripts/build/linux/after-remove.sh index 5257a8e71..0db0bc354 100755 --- a/desktop/scripts/build/linux/after-remove.sh +++ b/desktop/scripts/build/linux/after-remove.sh @@ -44,7 +44,8 @@ fi # 4. Remove per-user data only on `purge`, never on a plain `remove`. Per-user # data here means settings, logs, cluster identity and certificates, and the -# engines NVIDIA PAIR installed under those roots (Ollama and llama.cpp); +# engines NVIDIA PAIR installed under those roots (Ollama and llama.cpp) or in +# its managed space-free vLLM directory; # downloaded model weights live outside them (~/.ollama, ~/.llamacpp, # ~/.lmstudio/models) and are never touched. # @@ -81,10 +82,13 @@ case "${1:-}" in # Every delete is best-effort so a locked or missing path never aborts removal. rm -rf "$user_home/.config/Nvidia Corporation/Personal AI Router" 2>/dev/null || true rm -rf "$user_home/.config/NVIDIA Corporation/PAIR" 2>/dev/null || true + rm -rf "$user_home/.nvpair/engine-bin/vllm" 2>/dev/null || true # Remove the current and previous parents only when empty so other NVIDIA # applications survive. rmdir "$user_home/.config/Nvidia Corporation" 2>/dev/null || true rmdir "$user_home/.config/NVIDIA Corporation" 2>/dev/null || true + rmdir "$user_home/.nvpair/engine-bin" 2>/dev/null || true + rmdir "$user_home/.nvpair" 2>/dev/null || true fi ;; esac diff --git a/desktop/scripts/build/macos/uninstall.sh b/desktop/scripts/build/macos/uninstall.sh index 15f195a06..2d876840a 100755 --- a/desktop/scripts/build/macos/uninstall.sh +++ b/desktop/scripts/build/macos/uninstall.sh @@ -156,10 +156,13 @@ echo "Removing user data..." # scripts/wipe-app-data.sh — do not silently diverge. rm -rf "$APP_SUPPORT/Nvidia Corporation/Personal AI Router" 2>/dev/null || true rm -rf "$APP_SUPPORT/NVIDIA Corporation/PAIR" 2>/dev/null || true +rm -rf "$target_home/.nvpair/engine-bin/vllm" 2>/dev/null || true # Remove the current and previous parents only when empty so other NVIDIA # applications survive. rmdir "$APP_SUPPORT/Nvidia Corporation" 2>/dev/null || true rmdir "$APP_SUPPORT/NVIDIA Corporation" 2>/dev/null || true +rmdir "$target_home/.nvpair/engine-bin" 2>/dev/null || true +rmdir "$target_home/.nvpair" 2>/dev/null || true # Best-effort removal of macOS framework state keyed on the bundle id. rm -rf "$target_home/Library/Preferences/$PACKAGE_ID.plist" 2>/dev/null || true diff --git a/desktop/tests/modular/app-data-wipe.test.ts b/desktop/tests/modular/app-data-wipe.test.ts index 12336080a..6f1ec6f9e 100644 --- a/desktop/tests/modular/app-data-wipe.test.ts +++ b/desktop/tests/modular/app-data-wipe.test.ts @@ -40,6 +40,19 @@ describe('repo-root wipe scripts', () => { } }) + it('lists the managed space-free vLLM directory in both inventories', () => { + const shText = fs.readFileSync(sh, 'utf8') + const psText = fs.readFileSync(ps1, 'utf8') + expect(shText).toContain('VLLM_INSTALL_ROOT="$HOME_DIR/.nvpair/engine-bin/vllm"') + expect(shText).toContain('TARGETS+=("$VLLM_INSTALL_ROOT|PAIR-managed vLLM payload")') + expect(psText).toContain( + "$VLLMInstallRoot = Join-Path $HomeDir (Join-Path '.nvpair' (Join-Path 'engine-bin' 'vllm'))" + ) + expect(psText).toContain( + "@{ Path = $VLLMInstallRoot; Reason = 'PAIR-managed vLLM payload' }" + ) + }) + it('accepts the app-invoked wait-pid and relaunch options in both twins', () => { const shText = fs.readFileSync(sh, 'utf8') const psText = fs.readFileSync(ps1, 'utf8') diff --git a/docs/building.mdx b/docs/building.mdx index 7527385f3..f0f3d2eb8 100644 --- a/docs/building.mdx +++ b/docs/building.mdx @@ -255,8 +255,9 @@ application running: On Windows use `scripts\wipe-app-data.cmd` with the same flags. Neither script needs Node, and both exclude engine model libraries such as `~/.ollama`, -`~/.lmstudio/models`, and `~/.llamacpp` by design, so a reset does not cost you -re-downloading models. +`~/.lmstudio/models`, `~/.llamacpp`, and `~/.cache/huggingface` by design, so a +reset does not cost you re-downloading models. They do remove PAIR's managed +space-free vLLM environment at `~/.nvpair/engine-bin/vllm`. Start with `--dry-run`; it prints the exact paths and deletes nothing. If the machine belongs to a cluster, leave the cluster as well, or the other diff --git a/scripts/wipe-app-data.ps1 b/scripts/wipe-app-data.ps1 index fed5459f8..be4b42312 100644 --- a/scripts/wipe-app-data.ps1 +++ b/scripts/wipe-app-data.ps1 @@ -24,9 +24,10 @@ # - scripts/wipe-app-data.sh (Unix twin — update both in the same change) # # Explicit exclusions (never add): %USERPROFILE%\.ollama, %USERPROFILE%\.llamacpp, -# %USERPROFILE%\.lmstudio, external engine installs, and the application install -# tree (Program Files\PAIR). Those three are the engines' model stores; each -# engine declares its own as models_dir, and nothing PAIR removes may delete one. +# %USERPROFILE%\.lmstudio, %USERPROFILE%\.cache\huggingface, engine installs PAIR +# did not create, and the application install tree (Program Files\PAIR). Those +# home directories are model stores; each engine declares its own as models_dir, +# and nothing PAIR removes may delete one. PAIR's managed vLLM payload is included. # --------------------------------------------------------------------------- [CmdletBinding()] @@ -51,9 +52,10 @@ Usage: scripts\wipe-app-data.cmd [options] scripts\wipe-app-data.ps1 [options] Delete all NVIDIA PAIR-owned application data (settings, logs, cluster -identity, chat history, PAIR-managed engines under the app data root). +identity, chat history, and PAIR-managed engines). -Does NOT delete model libraries (%USERPROFILE%\.ollama, .llamacpp, .lmstudio\models). +Does NOT delete model libraries (%USERPROFILE%\.ollama, .llamacpp, +.lmstudio\models, .cache\huggingface). Does NOT uninstall the application binary. Options: @@ -106,9 +108,14 @@ if (-not $LocalAppData) { $HomeDir = $env:USERPROFILE if (-not $HomeDir) { $HomeDir = $HOME } +if (-not $HomeDir) { + Write-Error 'USERPROFILE is unset' + exit 1 +} $CurrentRoot = Join-Path $LocalAppData (Join-Path $AppOrg $AppDataDirName) $LegacyRoot = Join-Path $LocalAppData (Join-Path $PreviousOrg $PreviousDataDirName) +$VLLMInstallRoot = Join-Path $HomeDir (Join-Path '.nvpair' (Join-Path 'engine-bin' 'vllm')) $Username = $env:USERNAME if (-not $Username) { $Username = 'user' } @@ -128,6 +135,7 @@ $ControlDir = Join-Path $Tmp "nvpair-$Scope" $Targets = @( @{ Path = $CurrentRoot; Reason = 'Current shared Electron + backend app data root' }, @{ Path = $LegacyRoot; Reason = 'Legacy pre-rename app data root (NVIDIA Corporation/PAIR)' }, + @{ Path = $VLLMInstallRoot; Reason = 'PAIR-managed vLLM payload' }, @{ Path = (Join-Path $LocalAppData 'nvpair-updater'); Reason = 'electron-updater download cache (Windows)' }, @{ Path = (Join-Path $CurrentRoot (Join-Path 'bin' 'nvpair.cmd')); Reason = 'Generated Windows nvpair launcher under userData' }, @{ Path = $ControlDir; Reason = 'Removed CLI control token directory' } @@ -204,9 +212,10 @@ if ($running.Count -gt 0 -and $ForceKill) { Write-Host 'Stopping PAIR processes (--force-kill)...' $killScript = @' Get-CimInstance Win32_Process | Where-Object { - $_.ExecutablePath -and ( - $_.ExecutablePath -like "$env:LOCALAPPDATA\NVIDIA Corporation\PAIR\*" -or - $_.ExecutablePath -like "$env:LOCALAPPDATA\Nvidia Corporation\Personal AI Router\*" + $_.ExecutablePath -and ( + $_.ExecutablePath -like "$env:LOCALAPPDATA\NVIDIA Corporation\PAIR\*" -or + $_.ExecutablePath -like "$env:LOCALAPPDATA\Nvidia Corporation\Personal AI Router\*" -or + $_.ExecutablePath -like "$env:USERPROFILE\.nvpair\engine-bin\vllm\*" ) } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } '@ @@ -227,7 +236,7 @@ if (-not $Confirmed) { } Write-Host '' Write-Host 'WARNING: This permanently deletes all NVIDIA PAIR app data.' - Write-Host 'Model libraries (%USERPROFILE%\.ollama, .llamacpp, .lmstudio\models) are NOT removed.' + Write-Host 'Model libraries (%USERPROFILE%\.ollama, .llamacpp, .lmstudio\models, .cache\huggingface) are NOT removed.' Write-Host '' Write-Host 'Paths to remove:' foreach ($t in $Targets) { Write-Host (" - {0}" -f $t.Path) } @@ -266,6 +275,18 @@ foreach ($org in @($AppOrg, $PreviousOrg)) { } } +foreach ($parent in @( + (Join-Path $HomeDir (Join-Path '.nvpair' 'engine-bin')) + (Join-Path $HomeDir '.nvpair') +)) { + if (Test-Path -LiteralPath $parent) { + $kids = @(Get-ChildItem -LiteralPath $parent -Force -ErrorAction SilentlyContinue) + if ($kids.Count -eq 0) { + try { Remove-Item -LiteralPath $parent -Force -ErrorAction SilentlyContinue } catch { } + } + } +} + Write-Host ("Removed {0} path(s)." -f $removed) if ($failures -gt 0) { Write-Error ("{0} path(s) could not be removed." -f $failures) diff --git a/scripts/wipe-app-data.sh b/scripts/wipe-app-data.sh index 7a8cfbfc8..83905fa18 100755 --- a/scripts/wipe-app-data.sh +++ b/scripts/wipe-app-data.sh @@ -24,10 +24,11 @@ # - desktop/scripts/build/{installer.nsh,linux/after-remove.sh,macos/uninstall.sh} # - scripts/wipe-app-data.ps1 (Windows twin — update both in the same change) # -# Explicit exclusions (never add): ~/.ollama, ~/.llamacpp, ~/.lmstudio, external -# engine installs, and the application install tree (Program Files / /opt/PAIR / -# the macOS app bundle). Those three are the engines' model stores; each engine -# declares its own as models_dir, and nothing PAIR removes may delete one. +# Explicit exclusions (never add): ~/.ollama, ~/.llamacpp, ~/.lmstudio, +# ~/.cache/huggingface, engine installs PAIR did not create, and the application +# install tree (Program Files / /opt/PAIR / the macOS app bundle). Those home +# directories are model stores; each engine declares its own as models_dir, and +# nothing PAIR removes may delete one. PAIR's managed vLLM payload is included. # --------------------------------------------------------------------------- set -uo pipefail @@ -44,9 +45,10 @@ usage() { Usage: scripts/wipe-app-data.sh [options] Delete all NVIDIA PAIR-owned application data (settings, logs, cluster -identity, chat history, PAIR-managed engines under the app data root). +identity, chat history, and PAIR-managed engines). -Does NOT delete model libraries (~/.ollama, ~/.llamacpp, ~/.lmstudio/models). +Does NOT delete model libraries (~/.ollama, ~/.llamacpp, ~/.lmstudio/models, +~/.cache/huggingface). Does NOT uninstall the application binary. Options: @@ -113,6 +115,7 @@ fi CURRENT_ROOT="$CONFIG_BASE/$APP_ORG/$APP_DATA_DIR_NAME" LEGACY_ROOT="$CONFIG_BASE/$APP_PREVIOUS_ORG/$APP_PREVIOUS_DATA_DIR_NAME" +VLLM_INSTALL_ROOT="$HOME_DIR/.nvpair/engine-bin/vllm" USERNAME="${USER:-user}" if command -v sha256sum >/dev/null 2>&1; then @@ -130,6 +133,7 @@ CONTROL_DIR="$RUNTIME_BASE/nvpair-$SCOPE" TARGETS=() TARGETS+=("$CURRENT_ROOT|Current shared Electron + backend app data root") TARGETS+=("$LEGACY_ROOT|Legacy pre-rename app data root (NVIDIA Corporation/PAIR)") +TARGETS+=("$VLLM_INSTALL_ROOT|PAIR-managed vLLM payload") TARGETS+=("$CACHE_HOME/nvpair-updater|electron-updater download cache") TARGETS+=("$CONTROL_DIR|Removed CLI control socket directory and auth token") TARGETS+=("$HOME_DIR/.local/bin/nvpair|Generated Linux nvpair launcher") @@ -216,7 +220,7 @@ if [[ "$CONFIRM" -eq 0 ]]; then fi echo "" echo "WARNING: This permanently deletes all NVIDIA PAIR app data." - echo "Model libraries (~/.ollama, ~/.llamacpp, ~/.lmstudio/models) are NOT removed." + echo "Model libraries (~/.ollama, ~/.llamacpp, ~/.lmstudio/models, ~/.cache/huggingface) are NOT removed." echo "" echo "Paths to remove:" for entry in "${TARGETS[@]}"; do @@ -251,6 +255,8 @@ done # Remove org parents only when empty (other NVIDIA apps may share them). rmdir "$CONFIG_BASE/$APP_ORG" 2>/dev/null || true rmdir "$CONFIG_BASE/$APP_PREVIOUS_ORG" 2>/dev/null || true +rmdir "$HOME_DIR/.nvpair/engine-bin" 2>/dev/null || true +rmdir "$HOME_DIR/.nvpair" 2>/dev/null || true echo "Removed $removed path(s)." if [[ "$failures" -gt 0 ]]; then diff --git a/services/nvpair-engine-manager/MANIFEST.md b/services/nvpair-engine-manager/MANIFEST.md index 808febd11..ac9c73865 100644 --- a/services/nvpair-engine-manager/MANIFEST.md +++ b/services/nvpair-engine-manager/MANIFEST.md @@ -122,14 +122,15 @@ and recovery. Editing `args`/`start` directly remains trusted manifest authoring | `manifest_version` | int | yes | Must be `1`. A higher value is rejected (asks for behavior this binary lacks). Unknown optional fields within a supported version are ignored, so the schema can grow additively. | | `platforms` | object | yes | Map of `"/"` → platform block (e.g. `"windows/amd64"`, `"darwin/arm64"`, `"linux/amd64"`). At least one entry. The runner selects the block matching the host. | | `actions` | object | no | Map of action name → action (see below). | -| `detect` / `install` / `uninstall` / `models_dir` / `runtime` | — | no | Optional **shared defaults** inherited by every platform (see below). | +| `install_dir` / `detect` / `install` / `uninstall` / `models_dir` / `runtime` | — | no | Optional **shared defaults** inherited by every platform (see below). | -**Shared defaults & per-platform overrides.** The platform-level fields `detect`, `install`, `uninstall`, `models_dir`, and `runtime` may also be given once at the top level as shared defaults; each `platforms` entry is then merged onto them. Nested objects (e.g. `runtime`, `runtime.env`) merge key-by-key with the platform value winning, while arrays and scalars are replaced wholesale. So a runtime that's identical across platforms except `cli` is declared once at the top level, and each platform sets only `"runtime": { "cli": "…" }`. Omitting a key inherits the default; setting it (even to a zero value like `"port": 0`) overrides it. A manifest that fully specifies each platform with no top-level defaults behaves exactly as before. +**Shared defaults & per-platform overrides.** The platform-level fields `install_dir`, `detect`, `install`, `uninstall`, `models_dir`, and `runtime` may also be given once at the top level as shared defaults; each `platforms` entry is then merged onto them. Nested objects (e.g. `runtime`, `runtime.env`) merge key-by-key with the platform value winning, while arrays and scalars are replaced wholesale. So a runtime that's identical across platforms except `cli` is declared once at the top level, and each platform sets only `"runtime": { "cli": "…" }`. Omitting a key inherits the default; setting it (even to a zero value like `"port": 0`) overrides it. A manifest that fully specifies each platform with no top-level defaults behaves exactly as before. ## Platform block | Field | Type | Required | Notes | |---|---|---|---| +| `install_dir` | string | no | Literal managed payload directory, with OS env refs and a leading `~` expanded. Defaults to the app-data root's `engine-bin/`. Use an external location only when the engine requires it, and add that path to every app-data wipe/uninstall inventory. Available to the rest of the platform block as `{install_dir}`. | | `detect` | string[] | no | Paths that, if any exists, mean the engine is already installed. Supports OS env refs (`%VAR%`, `$VAR`), a leading `~`, and the `{install_dir}` placeholder. | | `install` | object | no | How to obtain the engine (see Install). Omit for engines that are only ever detected/launched. | | `uninstall` | object | no | How to remove a user-mode install (see Uninstall). Backs `engine:uninstall`. | @@ -179,10 +180,11 @@ PAIR cannot replace for them. Two separate removals have to leave them alone: data directory. Nothing can exempt a subdirectory of it, so the only defense is for `models_dir` to resolve somewhere else entirely. -That second one is why `models_dir` must be a literal path: `{install_dir}` is -inside the app data root, so any store derived from it is destroyed when a user -uninstalls the app — even though the prompt promises their models are kept, and -even though no engine uninstall ever touched them. +That second one is why `models_dir` must be a literal path. By default, +`{install_dir}` is inside the app data root, so any store derived from it is +destroyed when a user uninstalls the app. A platform may declare an external +`install_dir`, but that PAIR-owned path must also be in the app wipe inventory, +so it is no safer for models. Keep the store separate from both locations. Declare the engine's own default where it has one, so PAIR and a user's own copy of that engine share a library rather than downloading the same weights twice. @@ -459,7 +461,7 @@ validation at load: | `{cli}` | The platform's `runtime.cli` path | runtime start/stop, action `cmd` | | `{download}` | Path of the verified download | `install.run` | | `{download_}` | Path of a verified member of `install.artifacts` | `install.run` | -| `{install_dir}` | Per-engine user-scoped install dir | `detect`, `install`, runtime | +| `{install_dir}` | Per-engine managed payload directory: the platform's expanded `install_dir`, or the app-data root's `engine-bin/` by default | `detect`, `install`, uninstall, runtime | | `{models_dir}` | The platform's `models_dir`, expanded | `detect`, runtime args/env, `uninstall.run`, actions. **Not** `install`, which resolves only `{install_dir}` and the download paths — and not `uninstall.remove`, where naming the store is rejected at load. Unset when the platform declares no store, so a template referencing it then fails to resolve rather than silently becoming a path under `/` | A `cmd` action additionally templates the action's own `params` as @@ -610,7 +612,7 @@ GPU selection, and auth. | **GPT4All** | weak (GUI app) | none (Local API Server toggled in the GUI, `:4891`) | desktop settings / Python SDK — not env | OpenAI `/v1` once enabled in-app | Caveats worth encoding when authoring these: -- **Model store location**: leave each engine's model cache at the engine's own default — `~/.ollama`, `~/.llamacpp`, `~/.lmstudio/models` — and declare that path as `models_dir`. Never point one inside `{install_dir}` or anywhere else under the app data root: `uninstall` skips `models_dir`, but the app-level "remove all data" uninstall deletes the entire app data root and nothing can exempt a subdirectory of it. See Model stores. +- **Model store location**: leave each engine's model cache at the engine's own default — `~/.ollama`, `~/.llamacpp`, `~/.lmstudio/models` — and declare that path as `models_dir`. Never point one inside `{install_dir}` or anywhere else under the app data root: `uninstall` skips `models_dir`, but app reset removes both the data root and PAIR-owned external payload directories. See Model stores. - **llama.cpp** ships prebuilt archives with **published SHA-256s**, so it's an ideal checksum-pinned `fetch`+`run` target (no placeholder SHA needed). - **LM Studio** model dir is settings-controlled (no documented relocation env); surface its config as flags/API, not env. - **vLLM**: native Windows is unsupported (WSL only), and macOS is Apple Silicon only through the vLLM-Metal plugin (`darwin/arm64`); the bind is a flag, not env. The bundled `vllm.json` is the reference: it passes the model as `--model`, since manifest `runtime.args` have no `{model}` token, and installs through a sha256-pinned `uv` bootstrap with a detect-first path. diff --git a/services/nvpair-engine-manager/executor.go b/services/nvpair-engine-manager/executor.go index 1196c5e37..55fbbb00a 100644 --- a/services/nvpair-engine-manager/executor.go +++ b/services/nvpair-engine-manager/executor.go @@ -108,7 +108,7 @@ type Executor struct { // streaming handlers) in addition to the local engine:install-progress // notification path. See progress.go. progress *progressHub - baseDir string // user-scoped install base + baseDir string // default user-scoped install base // desired persists explicit per-engine ON/OFF intent. Runtime state remains // in-memory; shutdown cleanup must not rewrite this store. desired *desiredStateStore @@ -248,7 +248,7 @@ func (e *Executor) state(engine string) (*engineState, error) { plat: plat, logs: newLogBuffer(), port: plat.Runtime.Port, - installDir: filepath.Join(e.baseDir, engine), + installDir: engineInstallDir(e.baseDir, engine, plat.InstallDir), } if plat.ModelsDir != "" { st.modelsDir = expandPath(plat.ModelsDir) @@ -257,6 +257,13 @@ func (e *Executor) state(engine string) (*engineState, error) { return st, nil } +func engineInstallDir(defaultBase, engine, configured string) string { + if configured != "" { + return filepath.Clean(expandPath(configured)) + } + return filepath.Join(defaultBase, engine) +} + // pathVars are the directory placeholders every templated manifest string can // use. Both are absolute by the time they get here, so a manifest never has to // expand "~" itself — which it could not do, since only argv and a handful of diff --git a/services/nvpair-engine-manager/manifests/vllm.json b/services/nvpair-engine-manager/manifests/vllm.json index 4b8b19d34..627beb533 100644 --- a/services/nvpair-engine-manager/manifests/vllm.json +++ b/services/nvpair-engine-manager/manifests/vllm.json @@ -4,6 +4,7 @@ "display_name": "vLLM", "manifest_version": 1, "install": { "mode": "user" }, + "install_dir": "~/.nvpair/engine-bin/vllm", "models_dir": "~/.cache/huggingface", "uninstall": { "remove": ["{install_dir}"] }, "runtime": { diff --git a/services/nvpair-engine-manager/modelsdir_test.go b/services/nvpair-engine-manager/modelsdir_test.go index db2f1dff4..0c122bb73 100644 --- a/services/nvpair-engine-manager/modelsdir_test.go +++ b/services/nvpair-engine-manager/modelsdir_test.go @@ -16,15 +16,14 @@ import ( "nvpair-shared/appdir" ) -// TestBundledModelStoresOutliveTheAppDataRoot is the regression guard for the -// uninstall that deleted downloaded models. The app-level "remove all data" -// uninstall deletes the whole app data root, so a model store anywhere inside it -// is destroyed even though no engine uninstall touched it — which is exactly -// what llama.cpp's cache did when it sat beside the install directory. +// TestBundledModelStores_RemainOutsideManagedApplicationPaths is the regression +// guard for the uninstall that deleted downloaded models. App reset deletes the +// whole app data root and every declared external install directory, so a model +// store inside either is destroyed even though no engine uninstall touched it. // // The app data root always ends in the vendor/product segments, so this holds // the line for every target platform rather than only the test host's. -func TestBundledModelStores_RemainOutsideApplicationDataRoot(t *testing.T) { +func TestBundledModelStores_RemainOutsideManagedApplicationPaths(t *testing.T) { forbidden := []string{"nvidia corporation", "personal ai router"} for name, manifest := range bundledManifestSet(t) { for key, platform := range manifest.Platforms { @@ -37,6 +36,9 @@ func TestBundledModelStores_RemainOutsideApplicationDataRoot(t *testing.T) { break } } + if platform.InstallDir != "" { + assert.False(t, pathWithinRoot(expandPath(platform.InstallDir), expandPath(platform.ModelsDir)), "%s/%s: models_dir %q resolves under managed install_dir %q", name, key, platform.ModelsDir, platform.InstallDir) + } } platform, ok := manifest.HostPlatform() if !ok { diff --git a/services/nvpair-engine-manager/proc.go b/services/nvpair-engine-manager/proc.go index 894fe274b..75589a19e 100644 --- a/services/nvpair-engine-manager/proc.go +++ b/services/nvpair-engine-manager/proc.go @@ -14,6 +14,49 @@ import ( "time" ) +// processEnvironment is the environment an engine process starts with: the +// current one with env merged over it, and the engine's own Python virtual +// environment first on PATH. An engine installed in a virtual environment runs +// its helper tools by name (vLLM's FlashInfer compiles kernels with ninja), and +// those are in the environment's bin directory, which the service's own PATH +// does not include. It returns nil when there is nothing to add, so the process +// inherits the environment unchanged. +func processEnvironment(bin string, env map[string]string) []string { + venvBin := venvBinDir(bin) + if len(env) == 0 && venvBin == "" { + return nil + } + merged := os.Environ() + for k, v := range env { + merged = append(merged, k+"="+v) + } + if venvBin != "" { + path, ok := env["PATH"] + if !ok { + path = os.Getenv("PATH") + } + merged = append(merged, "PATH="+venvBin+string(os.PathListSeparator)+path) + } + return merged +} + +// venvBinDir returns the directory holding bin when it belongs to a Python +// virtual environment, which has a pyvenv.cfg beside its bin directory, and "" +// when it does not. A symbolic link such as ~/.local/bin/vllm is followed. +func venvBinDir(bin string) string { + candidates := []string{bin} + if resolved, err := filepath.EvalSymlinks(bin); err == nil && resolved != bin { + candidates = append(candidates, resolved) + } + for _, candidate := range candidates { + dir := filepath.Dir(candidate) + if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "pyvenv.cfg")); err == nil { + return dir + } + } + return "" +} + // managedProc is a spawned engine process with its stdout/stderr // captured line-by-line and a `done` channel that closes when it // exits. The OS-specific primitives it relies on (console hiding, @@ -30,12 +73,7 @@ type managedProc struct { // closes once the process exits. func startManagedProc(bin string, args []string, env map[string]string, onLine func(stream, line string)) (*managedProc, error) { cmd := exec.Command(bin, args...) - if len(env) > 0 { - cmd.Env = os.Environ() - for k, v := range env { - cmd.Env = append(cmd.Env, k+"="+v) - } - } + cmd.Env = processEnvironment(bin, env) configureSysProcAttr(cmd) stdout, err := cmd.StdoutPipe() diff --git a/services/nvpair-engine-manager/proc_venv_test.go b/services/nvpair-engine-manager/proc_venv_test.go new file mode 100644 index 000000000..45131415f --- /dev/null +++ b/services/nvpair-engine-manager/proc_venv_test.go @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +package main + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// fakeVenv lays out a Python virtual environment: a pyvenv.cfg beside a bin +// directory holding the given executable scripts. It returns the bin directory. +func fakeVenv(t *testing.T, scripts map[string]string) string { + t.Helper() + root := t.TempDir() + bin := filepath.Join(root, "venv", "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "venv", "pyvenv.cfg"), []byte("home = /usr/bin\n"), 0o644); err != nil { + t.Fatal(err) + } + for name, body := range scripts { + if err := os.WriteFile(filepath.Join(bin, name), []byte(body), 0o755); err != nil { + t.Fatal(err) + } + } + return bin +} + +// lastValue is the value the process will see for key, which is the last entry +// naming it. +func lastValue(environment []string, key string) (string, bool) { + value, found := "", false + for _, entry := range environment { + if k, v, ok := strings.Cut(entry, "="); ok && k == key { + value, found = v, true + } + } + return value, found +} + +func TestProcessEnvironment_PutsTheVirtualEnvironmentFirstOnPath(t *testing.T) { + t.Setenv("PATH", "/usr/bin:/bin") + bin := fakeVenv(t, map[string]string{"vllm": "#!/bin/sh\n"}) + + got, _ := lastValue(processEnvironment(filepath.Join(bin, "vllm"), nil), "PATH") + + if want := bin + ":/usr/bin:/bin"; got != want { + t.Fatalf("PATH = %q, want the venv's bin directory ahead of the existing PATH (%q)", got, want) + } +} + +func TestProcessEnvironment_InheritsTheEnvironmentForAnEngineOutsideAVirtualEnvironment(t *testing.T) { + dir := t.TempDir() + if got := processEnvironment(filepath.Join(dir, "ollama"), nil); got != nil { + t.Fatalf("environment = %v, want nil so the process inherits it unchanged", got) + } +} + +func TestProcessEnvironment_KeepsTheManifestVariablesAndTheirPath(t *testing.T) { + bin := fakeVenv(t, map[string]string{"vllm": "#!/bin/sh\n"}) + + environment := processEnvironment(filepath.Join(bin, "vllm"), map[string]string{"HF_HOME": "/models", "PATH": "/custom"}) + + if got, _ := lastValue(environment, "HF_HOME"); got != "/models" { + t.Fatalf("HF_HOME = %q, want /models", got) + } + if got, _ := lastValue(environment, "PATH"); got != bin+":/custom" { + t.Fatalf("PATH = %q, want the venv's bin directory ahead of the PATH the manifest set", got) + } +} + +func TestProcessEnvironment_FollowsALinkIntoTheVirtualEnvironment(t *testing.T) { + bin := fakeVenv(t, map[string]string{"vllm": "#!/bin/sh\n"}) + shim := filepath.Join(t.TempDir(), "vllm") + if err := os.Symlink(filepath.Join(bin, "vllm"), shim); err != nil { + t.Fatal(err) + } + + if got := venvBinDir(shim); got != bin { + t.Fatalf("venvBinDir = %q, want %q: a link such as ~/.local/bin/vllm belongs to the venv it points into", got, bin) + } +} + +// vLLM's FlashInfer compiles kernels by running ninja by name, and ninja is in +// the virtual environment's bin directory. Before the engine was started with +// that directory on PATH, loading a model that needs a kernel stopped with +// "No such file or directory: 'ninja'". +func TestStartManagedProc_FindsToolsInTheEnginesVirtualEnvironment(t *testing.T) { + t.Setenv("PATH", "/usr/bin:/bin") + bin := fakeVenv(t, map[string]string{ + "engine": "#!/bin/sh\nexec ninja\n", + "ninja": "#!/bin/sh\necho ninja-was-found\n", + }) + + var mu sync.Mutex + var lines []string + proc, err := startManagedProc(filepath.Join(bin, "engine"), nil, nil, func(_, line string) { + mu.Lock() + defer mu.Unlock() + lines = append(lines, line) + }) + if err != nil { + t.Fatal(err) + } + select { + case <-proc.done: + case <-time.After(10 * time.Second): + t.Fatal("the engine did not exit") + } + + mu.Lock() + defer mu.Unlock() + if got := strings.Join(lines, "\n"); !strings.Contains(got, "ninja-was-found") { + t.Fatalf("engine output = %q, want it to have run the venv's ninja", got) + } +} diff --git a/services/nvpair-engine-manager/provenance.go b/services/nvpair-engine-manager/provenance.go index 023421e72..04c8d665b 100644 --- a/services/nvpair-engine-manager/provenance.go +++ b/services/nvpair-engine-manager/provenance.go @@ -125,7 +125,13 @@ func (e *Executor) UninstallManaged(ctx context.Context) []ManagedUninstall { slog.Warn("stopped removing engines before finishing", "err", err, "remaining", len(names)-len(results)) return results } - installDir := filepath.Join(e.baseDir, engine) + st, err := e.state(engine) + if err != nil { + slog.Info("leaving engine in place; it is not supported on this host", "engine", engine, "err", err) + results = append(results, ManagedUninstall{Engine: engine}) + continue + } + installDir := st.installDir if !installedByPAIR(installDir) { slog.Info("leaving engine in place; PAIR did not install it", "engine", engine) results = append(results, ManagedUninstall{Engine: engine}) diff --git a/services/nvpair-engine-manager/provenance_test.go b/services/nvpair-engine-manager/provenance_test.go index ecf8d2c31..67032a52a 100644 --- a/services/nvpair-engine-manager/provenance_test.go +++ b/services/nvpair-engine-manager/provenance_test.go @@ -105,6 +105,27 @@ func TestUninstallManaged_RemovesOnlyWhatPAIRInstalled(t *testing.T) { assert.False(t, installedByPAIR(installDir), "install marker not cleared after a successful removal") } +func TestUninstallManaged_RemovesEngineMarkedInDeclaredInstallDirectory(t *testing.T) { + vendorRoot := t.TempDir() + binary, weights := vendorEngineOnDisk(t, vendorRoot) + manifest := vendorEngineManifest(vendorRoot, filepath.Join(vendorRoot, "models"), []string{vendorRoot}) + platformKey := runtime.GOOS + "/" + runtime.GOARCH + platform := manifest.Platforms[platformKey] + platform.InstallDir = filepath.Join(t.TempDir(), "managed", "vendor") + manifest.Platforms[platformKey] = platform + ex := managedExecutor(t, manifest) + require.NoError(t, writeInstallMarker(platform.InstallDir, "vendor")) + + results := ex.UninstallManaged(context.Background()) + + require.Len(t, results, 1, "declared install directory outcome") + assert.True(t, results[0].Removed, "marked engine was not removed") + assert.Empty(t, results[0].Error, "marked engine removal") + assert.NoFileExists(t, binary, "engine binary survived") + assert.FileExists(t, weights, "removal deleted downloaded models") + assert.False(t, installedByPAIR(platform.InstallDir), "install marker survived removal") +} + // TestUninstallManagedKeepsTheMarkerWhenRemovalFails is the regression guard for // losing ownership. The marker is the only record that an engine is PAIR's, so // clearing it after a failure would leave the engine installed and every future diff --git a/services/nvpair-engine-manager/registry.go b/services/nvpair-engine-manager/registry.go index 9a3ac820a..206ccb443 100644 --- a/services/nvpair-engine-manager/registry.go +++ b/services/nvpair-engine-manager/registry.go @@ -75,15 +75,19 @@ type Manifest struct { // first; OS-specific Go code is the fallback only for primitives a // manifest cannot express (process termination, console hiding). type Platform struct { - Detect []string `json:"detect,omitempty"` - Install *Install `json:"install,omitempty"` - Uninstall *Uninstall `json:"uninstall,omitempty"` - Runtime Runtime `json:"runtime"` + // InstallDir overrides the default app-data-root engine directory. It is a + // literal user path with the same ~ and OS environment expansion as detect + // paths; its resolved value is exposed to the manifest as {install_dir}. + InstallDir string `json:"install_dir,omitempty"` + Detect []string `json:"detect,omitempty"` + Install *Install `json:"install,omitempty"` + Uninstall *Uninstall `json:"uninstall,omitempty"` + Runtime Runtime `json:"runtime"` // ModelsDir is the engine's on-disk model store, referenced elsewhere as // {models_dir}. Nothing this service removes may delete it: Uninstall skips - // it, and the app-level data purge never reaches it because validation - // rejects a models_dir that is a placeholder (and therefore cannot be - // derived from the install directory, which lives under the app data root). + // it, validation keeps it outside an explicit managed install directory, and + // the bundled-manifest tests keep it outside the app data root. App reset + // removes both kinds of managed application path. // Declare the engine's real default — the path it would use on its own — // so PAIR and a user's own copy of the engine share one library. ModelsDir string `json:"models_dir,omitempty"` @@ -670,9 +674,9 @@ func (r *Registry) mergeOntoBundled(override []byte) ([]byte, error) { } // applyPlatformDefaults implements manifest-level shared defaults: a -// top-level "detect" / "install" / "uninstall" / "runtime" / "models_dir" -// block is merged into every entry under "platforms", with the per-platform block -// winning key-by-key. Nested objects (runtime, runtime.env, …) merge +// top-level "install_dir" / "detect" / "install" / "uninstall" / "runtime" / +// "models_dir" block is merged into every entry under "platforms", with the +// per-platform block winning key-by-key. Nested objects (runtime, runtime.env, …) merge // recursively; arrays and scalars are replaced wholesale. The merge runs // at the JSON level before unmarshalling, so an omitted key truly // inherits the default while an explicit zero value (e.g. "port": 0) @@ -685,7 +689,7 @@ func applyPlatformDefaults(data []byte) ([]byte, error) { return nil, fmt.Errorf("invalid JSON: %w", err) } base := map[string]any{} - for _, k := range []string{"detect", "install", "uninstall", "runtime", "models_dir"} { + for _, k := range []string{"install_dir", "detect", "install", "uninstall", "runtime", "models_dir"} { raw, ok := top[k] if !ok { continue @@ -827,6 +831,24 @@ const ( ) func (p *Platform) validate(key string) error { + if p.InstallDir != "" { + if strings.TrimSpace(p.InstallDir) == "" { + return fmt.Errorf("platform %q: install_dir cannot be blank", key) + } + literalPath := p.InstallDir + if !strings.HasPrefix(key, "windows/") { + literalPath = unixEnvRe.ReplaceAllString(literalPath, "") + } + if strings.ContainsAny(literalPath, "{}") { + return fmt.Errorf("platform %q: install_dir %q must be a literal path, not a template", key, p.InstallDir) + } + if p.ModelsDir != "" && pathWithinRoot( + strings.ToLower(filepath.Clean(expandPath(p.InstallDir))), + strings.ToLower(filepath.Clean(expandPath(p.ModelsDir))), + ) { + return fmt.Errorf("platform %q: models_dir %q cannot be inside install_dir %q — app reset removes managed engine payloads", key, p.ModelsDir, p.InstallDir) + } + } if front := p.Runtime.Front; front != nil { if front.IdleTimeoutS < 0 || front.IdleTimeoutS > 86400 { return fmt.Errorf("platform %q: front.idle_timeout_s must be between 0 and 86400", key) @@ -934,9 +956,8 @@ func (p *Platform) validate(key string) error { } } // A models_dir derived from {install_dir} is the mistake this field exists to - // prevent: the install directory lives under the app data root, so the - // app-level "remove all data" uninstall would take the weights with it even - // though no engine uninstall ever touched them. Keeping it literal means the + // prevent: app reset removes every managed install directory, so it would take + // the weights too even though no engine uninstall ever touched them. Keeping it literal means the // only way to point the model store at the data root is to type that path // out, which review will catch. if strings.Contains(p.ModelsDir, "{") { diff --git a/services/nvpair-engine-manager/registry_test.go b/services/nvpair-engine-manager/registry_test.go index 1ec94435c..cff34c153 100644 --- a/services/nvpair-engine-manager/registry_test.go +++ b/services/nvpair-engine-manager/registry_test.go @@ -54,6 +54,35 @@ func TestValidate_AcceptsValidManifest(t *testing.T) { require.NoError(t, m.Validate(), "valid manifest rejected") } +func TestValidate_RejectsTemplatedInstallDirectory(t *testing.T) { + m := validManifest() + platform := m.Platforms["linux/amd64"] + platform.InstallDir = "{models_dir}/engine" + m.Platforms["linux/amd64"] = platform + + require.ErrorContains(t, m.Validate(), "install_dir", "templated install directory accepted") +} + +func TestValidate_AcceptsEnvironmentReferenceInInstallDirectory(t *testing.T) { + t.Setenv("NVPAIR_INSTALL_ROOT", t.TempDir()) + m := validManifest() + platform := m.Platforms["linux/amd64"] + platform.InstallDir = "${NVPAIR_INSTALL_ROOT}/engine" + m.Platforms["linux/amd64"] = platform + + require.NoError(t, m.Validate(), "supported braced environment reference rejected") +} + +func TestValidate_RejectsModelStoreInsideDeclaredInstallDirectory(t *testing.T) { + m := validManifest() + platform := m.Platforms["linux/amd64"] + platform.InstallDir = "~/.nvpair/engine-bin/demo" + platform.ModelsDir = "~/.nvpair/engine-bin/demo/models" + m.Platforms["linux/amd64"] = platform + + require.ErrorContains(t, m.Validate(), "models_dir", "model store inside managed install directory accepted") +} + func TestValidate_AcceptsCommandModeAndCmdAction(t *testing.T) { m := validManifest() p := m.Platforms["linux/amd64"] @@ -440,7 +469,7 @@ func TestLoadRegistryMissingDir_IsSkipped(t *testing.T) { } // TestApplyPlatformDefaults covers the shared-base merge: a top-level -// runtime/install block is inherited by each platform, per-platform keys +// runtime/install block and install directory are inherited by each platform, per-platform keys // override, nested objects (env) deep-merge, and a platform can override // a shared scalar. func TestApplyPlatformDefaults_InheritsBaseFieldsAndKeepsPlatformOverrides(t *testing.T) { @@ -449,6 +478,7 @@ func TestApplyPlatformDefaults_InheritsBaseFieldsAndKeepsPlatformOverrides(t *te "engine": "demo", "display_name": "Demo", "manifest_version": 1, + "install_dir": "~/.nvpair/engine-bin/demo", "install": { "mode": "user" }, "runtime": { "args": ["serve"], @@ -464,6 +494,7 @@ func TestApplyPlatformDefaults_InheritsBaseFieldsAndKeepsPlatformOverrides(t *te "runtime": { "bin": "{install_dir}/bin/demo", "env": { "EXTRA": "2" } } }, "linux/arm64": { + "install_dir": "/opt/nvpair/demo", "detect": ["{install_dir}/bin/demo"], "install": { "fetch": { "url": "https://x/demo.tgz" }, "run": ["tar", "xf", "{download}", "-C", "{install_dir}"] }, "uninstall": { "run": ["rm", "-rf", "{install_dir}"] }, @@ -489,6 +520,7 @@ func TestApplyPlatformDefaults_InheritsBaseFieldsAndKeepsPlatformOverrides(t *te assert.Equal(t, "user", amd.Install.ModeOrDefault(), "amd64 install: expected base mode + platform fetch") assert.NotNil(t, amd.Install.Fetch, "amd64 install: expected base mode + platform fetch") } + assert.Equal(t, "~/.nvpair/engine-bin/demo", amd.InstallDir, "amd64 install_dir: expected inherited path") // per-platform override applied: assert.Equal(t, "{install_dir}/bin/demo", amd.Runtime.Bin, "amd64 bin override missing") // nested object (env) deep-merges base + platform keys: @@ -500,6 +532,7 @@ func TestApplyPlatformDefaults_InheritsBaseFieldsAndKeepsPlatformOverrides(t *te require.True(t, ok, "linux/arm64 missing") assert.Equal(t, 5678, arm.Runtime.Port, "arm64 port override expected 5678") assert.Equal(t, "1", arm.Runtime.Env["SHARED"], "arm64 should still inherit base env SHARED") + assert.Equal(t, "/opt/nvpair/demo", arm.InstallDir, "arm64 install_dir override missing") } // TestBundledManifestsMerge guards the actual shipped manifests: they must diff --git a/services/nvpair-engine-manager/spec.md b/services/nvpair-engine-manager/spec.md index 99117816d..c8ad6492c 100644 --- a/services/nvpair-engine-manager/spec.md +++ b/services/nvpair-engine-manager/spec.md @@ -198,7 +198,7 @@ The `engine:remote-*` methods are the client half: engine-manager resolves the t `~/Library/Application Support/Nvidia Corporation/Personal AI Router` on macOS) plus bundled `manifests/*.json`. Each engine's model store is declared as `models_dir` outside that data dir (`~/.ollama`, `~/.llamacpp`, - `~/.lmstudio/models`) so neither an engine uninstall nor the app-level data + `~/.lmstudio/models`, `~/.cache/huggingface`) so neither an engine uninstall nor the app-level data purge removes downloaded models; clearing one is the user's own choice, made per model through `delete_model`. @@ -209,8 +209,9 @@ The `engine:remote-*` methods are the client half: engine-manager resolves the t wherever the filesystem says so. Removal unlinks a symlink or junction rather than following it, and refuses a target that is the store or inside it. - One persisted file: `engine-bin//installed-by-pair.json`, written after - an install this service performed. It is the only record distinguishing an + One persisted file: `installed-by-pair.json` in the engine's managed install + directory (normally `engine-bin/` under app data, or the platform's + explicit `install_dir`), written after an install this service performed. It is the only record distinguishing an install PAIR made from one the user made themselves, which an engine whose vendor script chooses its own destination cannot be told apart any other way, and it gates both `engine:uninstall` for such an engine and diff --git a/services/nvpair-engine-manager/vllm_manifest_test.go b/services/nvpair-engine-manager/vllm_manifest_test.go index af0993a47..43c2a6ff5 100644 --- a/services/nvpair-engine-manager/vllm_manifest_test.go +++ b/services/nvpair-engine-manager/vllm_manifest_test.go @@ -39,6 +39,12 @@ func TestBundledVLLMManifest(t *testing.T) { if len(platform.Detect) < 2 || !strings.HasPrefix(platform.Detect[0], "{install_dir}/") { t.Errorf("%s detect order = %v, want the managed install first", key, platform.Detect) } + if platform.InstallDir != "~/.nvpair/engine-bin/vllm" { + t.Errorf("%s install_dir = %q, want the space-free managed vLLM directory", key, platform.InstallDir) + } + if strings.ContainsAny(platform.InstallDir, " \t\r\n") { + t.Errorf("%s install_dir = %q, which still contains whitespace", key, platform.InstallDir) + } if platform.Install == nil || platform.Install.Fetch == nil || platform.Install.Fetch.SHA256 == "" { t.Errorf("%s: the uv bootstrap must be a sha256-pinned fetch", key) continue @@ -64,13 +70,15 @@ func TestBundledVLLMManifest(t *testing.T) { if platform.ModelsDir != "~/.cache/huggingface" { t.Errorf("%s: models_dir = %q, want the Hugging Face cache", key, platform.ModelsDir) } - // vLLM compiles FlashInfer sampling kernels at first start and that build - // cannot cope with the spaces in PAIR's app-data path - // ("Nvidia Corporation/Personal AI Router"), so it must stay off. It also - // keeps a system CUDA toolkit and C++ compiler from being required. + // Keep the optional FlashInfer sampler off so an ordinary model does not + // need a CUDA toolkit solely for sampling. Other FlashInfer and DeepGEMM + // kernels remain enabled and can build from the space-free install_dir. if rt.Env["VLLM_USE_FLASHINFER_SAMPLER"] != "0" { t.Errorf("%s: the FlashInfer sampler must be disabled", key) } + if _, disabled := rt.Env["VLLM_DISABLED_KERNELS"]; disabled { + t.Errorf("%s: vLLM kernels must stay enabled with the space-free install", key) + } // vLLM reserves 92% of GPU memory by default and refuses a context above // the model's own limit. Both are decided per model at start, so the // manifest must not pin them in the saved launch. From 6c1e452f8a04f41f48ef5b50ae683c8a9185bc4c Mon Sep 17 00:00:00 2001 From: nvrithik Date: Fri, 30 Oct 2026 08:16:42 -0700 Subject: [PATCH 34/35] fix(nvpair-engine-manager): size vLLM across device backends Keep the DGX Spark unified-memory guard, reserve startup headroom, and fall back to the installed vLLM platform when nvidia-smi cannot report memory. The probe covers Metal, ROCm/CUDA, XPU, CPU, and compatible out-of-tree platforms while leaving sizing to vLLM when no trustworthy reading exists. Signed-off-by: nvrithik --- .../nvpair-engine-manager/devicememory.go | 74 ++++++++++ .../devicememory_probe.py | 46 +++++++ .../devicememory_test.go | 93 +++++++++++++ services/nvpair-engine-manager/launchauto.go | 97 +++++++------ .../nvpair-engine-manager/launchauto_test.go | 69 +++------- .../launchauto_unified_test.go | 128 ++++++++++++++++++ 6 files changed, 410 insertions(+), 97 deletions(-) create mode 100644 services/nvpair-engine-manager/devicememory.go create mode 100644 services/nvpair-engine-manager/devicememory_probe.py create mode 100644 services/nvpair-engine-manager/devicememory_test.go create mode 100644 services/nvpair-engine-manager/launchauto_unified_test.go diff --git a/services/nvpair-engine-manager/devicememory.go b/services/nvpair-engine-manager/devicememory.go new file mode 100644 index 000000000..d4642c2c5 --- /dev/null +++ b/services/nvpair-engine-manager/devicememory.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "os/exec" + "time" +) + +//go:embed devicememory_probe.py +var deviceMemoryProbeScript string + +const deviceMemoryProbeTimeout = time.Minute + +type deviceMemoryProbe struct { + Total int64 `json:"total"` + Free int64 `json:"free"` + Shared bool `json:"shared"` +} + +var nativeDeviceMemory = nvidiaSMIMemory +var probeVLLMDeviceMemory = runVLLMDeviceMemoryProbe + +func detectDeviceMemory(bin string) (total, free int64, ok bool) { + if total, free, ok := nativeDeviceMemory(); ok { + return total, free, true + } + reading, err := probeVLLMDeviceMemory(bin) + if err != nil { + slog.Warn("could not measure free device memory; leaving memory sizing to vLLM", "err", err) + return 0, 0, false + } + if reading.Shared { + reading.Free = max(reading.Free-unifiedMemoryHeadroom, 0) + } + return reading.Total, reading.Free, true +} + +func runVLLMDeviceMemoryProbe(bin string) (deviceMemoryProbe, error) { + python, err := vllmPython(bin) + if err != nil { + return deviceMemoryProbe{}, err + } + ctx, cancel := context.WithTimeout(context.Background(), deviceMemoryProbeTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, python, "-I", "-c", deviceMemoryProbeScript) + cmd.Dir = os.TempDir() + out, err := cmd.Output() + if err != nil { + return deviceMemoryProbe{}, fmt.Errorf("device memory probe: %w", err) + } + return parseDeviceMemoryProbe(out) +} + +func parseDeviceMemoryProbe(out []byte) (deviceMemoryProbe, error) { + lines := bytes.Split(bytes.TrimSpace(out), []byte("\n")) + var reading deviceMemoryProbe + if len(lines) == 0 || json.Unmarshal(lines[len(lines)-1], &reading) != nil { + return deviceMemoryProbe{}, errors.New("device memory probe printed no result") + } + if reading.Total <= 0 || reading.Free < 0 || reading.Free > reading.Total { + return deviceMemoryProbe{}, errors.New("device memory probe printed an invalid result") + } + return reading, nil +} diff --git a/services/nvpair-engine-manager/devicememory_probe.py b/services/nvpair-engine-manager/devicememory_probe.py new file mode 100644 index 000000000..468c9e2ad --- /dev/null +++ b/services/nvpair-engine-manager/devicememory_probe.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import json + +import psutil +import torch +from vllm.platforms import current_platform + + +def system_reading(total): + memory = psutil.virtual_memory() + return { + "total": total, + "free": min(int(memory.available), total), + "shared": True, + } + + +def shares_system_memory(total): + system_total = int(psutil.virtual_memory().total) + return abs(total - system_total) <= max(1 << 30, system_total // 20) + + +if torch.cuda.is_available(): + free, total = torch.cuda.mem_get_info(0) + total = int(total) + reading = {"total": total, "free": int(free), "shared": shares_system_memory(total)} +elif hasattr(torch, "xpu") and torch.xpu.is_available(): + free, total = torch.xpu.mem_get_info(0) + total = int(total) + reading = {"total": total, "free": int(free), "shared": shares_system_memory(total)} +else: + total = int(current_platform.get_device_total_memory(0)) + available = getattr(current_platform, "get_device_available_memory", None) + if callable(available): + free = int(available(0)) + reading = {"total": total, "free": free, "shared": shares_system_memory(total)} + elif current_platform.is_cpu(): + reading = system_reading(total) + else: + if not shares_system_memory(total): + raise RuntimeError("the selected vLLM backend does not report free memory") + reading = system_reading(total) + +print(json.dumps(reading)) diff --git a/services/nvpair-engine-manager/devicememory_test.go b/services/nvpair-engine-manager/devicememory_test.go new file mode 100644 index 000000000..2de010e65 --- /dev/null +++ b/services/nvpair-engine-manager/devicememory_test.go @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "testing" +) + +func withDeviceMemoryProviders(t *testing.T, native func() (int64, int64, bool), probe func(string) (deviceMemoryProbe, error)) { + t.Helper() + oldNative := nativeDeviceMemory + oldProbe := probeVLLMDeviceMemory + nativeDeviceMemory = native + probeVLLMDeviceMemory = probe + t.Cleanup(func() { + nativeDeviceMemory = oldNative + probeVLLMDeviceMemory = oldProbe + }) +} + +func TestDetectDeviceMemory_PrefersNativeTelemetry(t *testing.T) { + withDeviceMemoryProviders(t, + func() (int64, int64, bool) { return 48 * gib, 32 * gib, true }, + func(string) (deviceMemoryProbe, error) { + t.Fatal("the vLLM probe must not run when native telemetry works") + return deviceMemoryProbe{}, nil + }, + ) + + total, free, ok := detectDeviceMemory("/opt/vllm/bin/vllm") + if !ok || total != 48*gib || free != 32*gib { + t.Fatalf("got total=%d free=%d ok=%v, want native telemetry", total, free, ok) + } +} + +func TestDetectDeviceMemory_FallsBackToTheSelectedVLLMBackend(t *testing.T) { + withDeviceMemoryProviders(t, + func() (int64, int64, bool) { return 0, 0, false }, + func(bin string) (deviceMemoryProbe, error) { + if bin != "/opt/vllm/bin/vllm" { + t.Fatalf("probe bin = %q", bin) + } + return deviceMemoryProbe{Total: 64 * gib, Free: 40 * gib}, nil + }, + ) + + total, free, ok := detectDeviceMemory("/opt/vllm/bin/vllm") + if !ok || total != 64*gib || free != 40*gib { + t.Fatalf("got total=%d free=%d ok=%v, want the vLLM backend reading", total, free, ok) + } +} + +func TestDetectDeviceMemory_ReservesStartupHeadroomOnSharedMemory(t *testing.T) { + withDeviceMemoryProviders(t, + func() (int64, int64, bool) { return 0, 0, false }, + func(string) (deviceMemoryProbe, error) { + return deviceMemoryProbe{Total: 64 * gib, Free: 20 * gib, Shared: true}, nil + }, + ) + + _, free, ok := detectDeviceMemory("vllm") + if !ok || free != 20*gib-unifiedMemoryHeadroom { + t.Fatalf("free = %d ok=%v, want shared memory less startup headroom", free, ok) + } +} + +func TestDetectDeviceMemory_LeavesSizingToVLLMWhenEveryProviderFails(t *testing.T) { + withDeviceMemoryProviders(t, + func() (int64, int64, bool) { return 0, 0, false }, + func(string) (deviceMemoryProbe, error) { return deviceMemoryProbe{}, errors.New("unsupported backend") }, + ) + + if _, _, ok := detectDeviceMemory("vllm"); ok { + t.Fatal("a made-up memory limit is less safe than leaving sizing to vLLM") + } +} + +func TestParseDeviceMemoryProbe_ReadsTheLastOutputLine(t *testing.T) { + reading, err := parseDeviceMemoryProbe([]byte("vLLM startup log\n{\"total\":68719476736,\"free\":42949672960,\"shared\":true}\n")) + if err != nil || reading.Total != 64*gib || reading.Free != 40*gib || !reading.Shared { + t.Fatalf("reading = %+v err=%v", reading, err) + } +} + +func TestParseDeviceMemoryProbe_RejectsImpossibleReadings(t *testing.T) { + for _, output := range []string{"", "not json", `{"total":0,"free":0}`, `{"total":10,"free":11}`} { + if _, err := parseDeviceMemoryProbe([]byte(output)); err == nil { + t.Errorf("accepted %q", output) + } + } +} diff --git a/services/nvpair-engine-manager/launchauto.go b/services/nvpair-engine-manager/launchauto.go index 2f5bcdba6..de7f15c9a 100644 --- a/services/nvpair-engine-manager/launchauto.go +++ b/services/nvpair-engine-manager/launchauto.go @@ -33,9 +33,19 @@ const ( minAutoUtil = 0.15 ) -// gpuMemory reports total and free bytes on GPU 0. It is a variable so tests -// can supply a machine. -var gpuMemory = nvidiaSMIMemory +// unifiedMemoryHeadroom is taken off the system's available memory when the +// device shares it, because vLLM measures free memory after its own runtime and +// execution context have been set up. On a DGX Spark that costs about 3 GiB, so +// a share sized from the reading before the launch would be refused at startup. +const unifiedMemoryHeadroom = 3 * gib + +// meminfoPath is where the system memory figures come from. It is a variable so +// tests can supply a machine. +var meminfoPath = "/proc/meminfo" + +// deviceMemory reports total and free bytes on device 0. It is a variable so +// tests can supply a machine. +var deviceMemory = detectDeviceMemory func nvidiaSMIMemory() (total, free int64, ok bool) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -44,21 +54,21 @@ func nvidiaSMIMemory() (total, free int64, ok bool) { if err != nil { return 0, 0, false } - return parseGPUMemory(string(out), systemMemory) + return gpuMemoryFromQuery(string(out)) } -// parseGPUMemory reads the first row of nvidia-smi's memory.total,memory.free -// query, in MiB. On a unified-memory machine such as DGX Spark the GPU shares -// system RAM and nvidia-smi reports [N/A]; system memory then stands in, as it -// does in nvpair-node-info, and as vLLM itself sees it there. -func parseGPUMemory(out string, system func() (total, available int64, ok bool)) (total, free int64, ok bool) { +// gpuMemoryFromQuery reads the output of the nvidia-smi memory query. A GPU +// with unified memory, such as the GB10 in a DGX Spark, has no memory of its +// own and reports "[N/A]" for both figures; its memory is the system's, so the +// figures come from there instead. +func gpuMemoryFromQuery(out string) (total, free int64, ok bool) { line, _, _ := strings.Cut(strings.TrimSpace(out), "\n") parts := strings.Split(line, ",") if len(parts) != 2 { return 0, 0, false } - if nvidiaSMINotApplicable(parts[0]) { - return system() + if memoryNotReported(parts[0]) && memoryNotReported(parts[1]) { + return unifiedMemory() } t, errT := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64) f, errF := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) @@ -68,27 +78,21 @@ func parseGPUMemory(out string, system func() (total, available int64, ok bool)) return t * 1024 * 1024, f * 1024 * 1024, true } -// nvidiaSMINotApplicable reports an nvidia-smi field that holds no number, such -// as the [N/A] or [Not Supported] a unified-memory GPU gives for its memory. -func nvidiaSMINotApplicable(field string) bool { - value := strings.ToLower(strings.Trim(strings.TrimSpace(field), "[]")) - return value == "n/a" || value == "not supported" +func memoryNotReported(field string) bool { + v := strings.ToLower(strings.Trim(strings.TrimSpace(field), "[]")) + return v == "n/a" || v == "not supported" } -// systemMemory reports total and available system memory from /proc/meminfo. -func systemMemory() (total, available int64, ok bool) { - raw, err := os.ReadFile("/proc/meminfo") +// unifiedMemory reports the system's total memory and what is left of it for a +// GPU that shares it, less the headroom vLLM's own startup uses. +func unifiedMemory() (total, free int64, ok bool) { + raw, err := os.ReadFile(meminfoPath) if err != nil { return 0, 0, false } - return parseMeminfo(string(raw)) -} - -// parseMeminfo reads MemTotal and MemAvailable, which /proc/meminfo gives in kB. -// Available rather than free counts the page cache the kernel can reclaim. -func parseMeminfo(text string) (total, available int64, ok bool) { - for _, line := range strings.Split(text, "\n") { - key, rest, found := strings.Cut(line, ":") + var available int64 + for _, line := range strings.Split(string(raw), "\n") { + name, rest, found := strings.Cut(line, ":") if !found { continue } @@ -96,18 +100,21 @@ func parseMeminfo(text string) (total, available int64, ok bool) { if len(fields) == 0 { continue } - kb, err := strconv.ParseInt(fields[0], 10, 64) + kib, err := strconv.ParseInt(fields[0], 10, 64) if err != nil { continue } - switch key { + switch name { case "MemTotal": - total = kb * 1024 + total = kib * 1024 case "MemAvailable": - available = kb * 1024 + available = kib * 1024 } } - return total, available, total > 0 && available > 0 + if total <= 0 || available <= 0 { + return 0, 0, false + } + return total, max(available-unifiedMemoryHeadroom, 0), true } // applyAutoLaunch appends the arguments the manifest's auto_launch asks for. @@ -118,7 +125,7 @@ func applyAutoLaunch(engine string, rt Runtime, command launchCommand, modelsDir return command } hub := filepath.Join(expandPath(modelsDir), "hub") - extra, note := planHFContext(command.Args, hub, int64(rt.AutoLaunch.ContextTokens)) + extra, note := planHFContext(command.Args, hub, int64(rt.AutoLaunch.ContextTokens), command.Bin) if tools, parser := planToolCalling(command.Args, hub, command.Bin); len(tools) > 0 { extra = append(extra, tools...) note = strings.TrimPrefix(note+"; tool calls parsed with "+parser, "; ") @@ -126,7 +133,7 @@ func applyAutoLaunch(engine string, rt Runtime, command launchCommand, modelsDir if len(extra) == 0 { return command } - slog.Info("engine launch adjusted for the model and GPU", "engine", engine, "added_args", extra, "reason", note) + slog.Info("engine launch adjusted for the model and device memory", "engine", engine, "added_args", extra, "reason", note) command.Args = append(append([]string{}, command.Args...), extra...) return command } @@ -228,15 +235,15 @@ func findHFSnapshot(hubDir, id string) (dir string, cfg hfModelConfig, size int6 // planHFContext decides the extra vLLM arguments for the model named in args: // // - --max-model-len: the target window, or the model's own limit when that is -// lower and cannot be extended, or what fits in GPU memory when less. +// lower and cannot be extended, or what fits in device memory when less. // - --hf-overrides: YaRN rope scaling when the target exceeds the model's // native window, so a 40K model can serve 64K. // - --gpu-memory-utilization: just enough for weights, KV cache and overhead, // not vLLM's 92% default that would starve other engines. // // A flag the user already set is never touched. Tensor or pipeline parallel -// launches are left alone entirely: sizing them needs more than GPU 0. -func planHFContext(args []string, hubDir string, target int64) (extra []string, note string) { +// launches are left alone entirely: sizing them needs more than device 0. +func planHFContext(args []string, hubDir string, target int64, bin string) (extra []string, note string) { if _, ok := launchFlagValue(args, "--tensor-parallel-size"); ok { return nil, "" } @@ -255,12 +262,12 @@ func planHFContext(args []string, hubDir string, target int64) (extra []string, return nil, "" } - total, free, haveGPU := gpuMemory() + total, free, haveDevice := deviceMemory(bin) cfg, weights, cached := readHFModel(hubDir, model) if !cached { // Not downloaded yet, so its size and limits are unknown. Only the memory // share can be bounded sensibly. - if haveGPU && !userUtil && !userKV { + if haveDevice && !userUtil && !userKV { util := math.Floor(math.Min(0.5, float64(free-freeMargin)/float64(total))*100) / 100 if util >= minAutoUtil { return []string{"--gpu-memory-utilization", fmt.Sprintf("%.2f", util)}, "model not in cache" @@ -295,19 +302,19 @@ func planHFContext(args []string, hubDir string, target int64) (extra []string, ctx = native } - // The GPU share and window must fit together. + // The device share and window must fit together. var budget int64 switch { case userUtil: util, err := strconv.ParseFloat(userUtilText, 64) - if err != nil || !haveGPU { + if err != nil || !haveDevice { return nil, "" } budget = int64(util * float64(total)) - case haveGPU: + case haveDevice: budget = free - freeMargin } - if haveGPU && budget > 0 && fixed+perToken*ctx > budget { + if haveDevice && budget > 0 && fixed+perToken*ctx > budget { fit := (budget - fixed) / perToken fit -= fit % 1024 if fit >= minAutoContext { @@ -338,7 +345,7 @@ func planHFContext(args []string, hubDir string, target int64) (extra []string, extra = append(extra, "--hf-overrides", overrides) } } - if !userUtil && !userKV && haveGPU { + if !userUtil && !userKV && haveDevice { utilCtx := ctx if userLen { if v, _ := launchFlagValue(args, "--max-model-len"); v != "" { @@ -359,7 +366,7 @@ func planHFContext(args []string, hubDir string, target int64) (extra []string, if len(extra) == 0 { return nil, "" } - return extra, fmt.Sprintf("native window %d, target %d, weights %.1f GiB, GPU free %.1f GiB", native, target, float64(weights)/float64(gib), float64(free)/float64(gib)) + return extra, fmt.Sprintf("native window %d, target %d, weights %.1f GiB, device free %.1f GiB", native, target, float64(weights)/float64(gib), float64(free)/float64(gib)) } // planToolCalling returns the arguments that let clients send tool_choice diff --git a/services/nvpair-engine-manager/launchauto_test.go b/services/nvpair-engine-manager/launchauto_test.go index e2cfbdceb..988954d36 100644 --- a/services/nvpair-engine-manager/launchauto_test.go +++ b/services/nvpair-engine-manager/launchauto_test.go @@ -35,16 +35,16 @@ func cachedModelAt(t *testing.T, cache, id, config string, weightBytes int64) { func withGPU(t *testing.T, totalGiB, freeGiB int64) { t.Helper() - old := gpuMemory - gpuMemory = func() (int64, int64, bool) { return totalGiB * gib, freeGiB * gib, true } - t.Cleanup(func() { gpuMemory = old }) + old := deviceMemory + deviceMemory = func(string) (int64, int64, bool) { return totalGiB * gib, freeGiB * gib, true } + t.Cleanup(func() { deviceMemory = old }) } func withoutGPU(t *testing.T) { t.Helper() - old := gpuMemory - gpuMemory = func() (int64, int64, bool) { return 0, 0, false } - t.Cleanup(func() { gpuMemory = old }) + old := deviceMemory + deviceMemory = func(string) (int64, int64, bool) { return 0, 0, false } + t.Cleanup(func() { deviceMemory = old }) } func flagOf(t *testing.T, args []string, flag string) string { @@ -65,7 +65,7 @@ func utilOf(t *testing.T, args []string) float64 { func TestAutoContextExtendsAShortModelToTheTarget(t *testing.T) { withGPU(t, 48, 48) cache := cachedModel(t, "Qwen/Qwen3-8B", qwen8BConfig, 16_400_000_000) - extra, _ := planHFContext([]string{"serve", "--model", "Qwen/Qwen3-8B"}, cache, 65536) + extra, _ := planHFContext([]string{"serve", "--model", "Qwen/Qwen3-8B"}, cache, 65536, "") if got := flagOf(t, extra, "--max-model-len"); got != "65536" { t.Fatalf("max-model-len = %q, want 65536 (the model alone stops at 40960): %v", got, extra) @@ -95,7 +95,7 @@ func TestAutoContextCapsALongModelAtTheTarget(t *testing.T) { withGPU(t, 48, 48) cfg := strings.Replace(qwen8BConfig, "40960", "262144", 1) cache := cachedModel(t, "Qwen/Long", cfg, 16_400_000_000) - extra, _ := planHFContext([]string{"--model", "Qwen/Long"}, cache, 65536) + extra, _ := planHFContext([]string{"--model", "Qwen/Long"}, cache, 65536, "") if got := flagOf(t, extra, "--max-model-len"); got != "65536" { t.Fatalf("max-model-len = %q, want the 65536 target, not the 262144 native window", got) } @@ -108,7 +108,7 @@ func TestAutoContextLeavesModelsWithRopeScalingAlone(t *testing.T) { withGPU(t, 48, 48) cfg := strings.Replace(qwen8BConfig, `"torch_dtype"`, `"rope_scaling":{"rope_type":"yarn","factor":4.0},"torch_dtype"`, 1) cache := cachedModel(t, "Qwen/Scaled", cfg, 16_400_000_000) - extra, _ := planHFContext([]string{"--model", "Qwen/Scaled"}, cache, 131072) + extra, _ := planHFContext([]string{"--model", "Qwen/Scaled"}, cache, 131072, "") if slices.Contains(extra, "--hf-overrides") { t.Fatalf("must not override a model's own rope scaling: %v", extra) } @@ -120,7 +120,7 @@ func TestAutoContextLeavesModelsWithRopeScalingAlone(t *testing.T) { func TestAutoContextShrinksToWhatFitsInFreeMemory(t *testing.T) { withGPU(t, 48, 22) cache := cachedModel(t, "Qwen/Qwen3-8B", qwen8BConfig, 16_400_000_000) - extra, _ := planHFContext([]string{"--model", "Qwen/Qwen3-8B"}, cache, 65536) + extra, _ := planHFContext([]string{"--model", "Qwen/Qwen3-8B"}, cache, 65536, "") n, err := strconv.ParseInt(flagOf(t, extra, "--max-model-len"), 10, 64) if err != nil || n < 4096 || n >= 65536 || n%1024 != 0 { @@ -138,7 +138,7 @@ func TestAutoContextNeverOverridesWhatTheUserSet(t *testing.T) { withGPU(t, 48, 48) cache := cachedModel(t, "Qwen/Qwen3-8B", qwen8BConfig, 16_400_000_000) - extra, _ := planHFContext([]string{"--model", "Qwen/Qwen3-8B", "--max-model-len=8192"}, cache, 65536) + extra, _ := planHFContext([]string{"--model", "Qwen/Qwen3-8B", "--max-model-len=8192"}, cache, 65536, "") if slices.Contains(extra, "--max-model-len") || slices.Contains(extra, "--hf-overrides") { t.Fatalf("the user's context must stand untouched: %v", extra) } @@ -146,7 +146,7 @@ func TestAutoContextNeverOverridesWhatTheUserSet(t *testing.T) { t.Fatalf("an 8K window needs far less than %.2f of the GPU", util) } - extra, _ = planHFContext([]string{"--model", "Qwen/Qwen3-8B", "--gpu-memory-utilization", "0.5"}, cache, 65536) + extra, _ = planHFContext([]string{"--model", "Qwen/Qwen3-8B", "--gpu-memory-utilization", "0.5"}, cache, 65536, "") if slices.Contains(extra, "--gpu-memory-utilization") { t.Fatalf("the user's GPU share must stand untouched: %v", extra) } @@ -155,7 +155,7 @@ func TestAutoContextNeverOverridesWhatTheUserSet(t *testing.T) { t.Fatalf("a 0.5 share (24 GiB) cannot hold 64K of Qwen3-8B; max-model-len = %d", n) } - extra, _ = planHFContext([]string{"--model", "Qwen/Qwen3-8B", "--max-model-len", "4096", "--gpu-memory-utilization", "0.3"}, cache, 65536) + extra, _ = planHFContext([]string{"--model", "Qwen/Qwen3-8B", "--max-model-len", "4096", "--gpu-memory-utilization", "0.3"}, cache, 65536, "") if len(extra) != 0 { t.Fatalf("with both set there is nothing to add: %v", extra) } @@ -165,7 +165,7 @@ func TestAutoContextSkipsShardedLaunches(t *testing.T) { withGPU(t, 48, 48) cache := cachedModel(t, "Qwen/Qwen3-8B", qwen8BConfig, 16_400_000_000) for _, flag := range []string{"--tensor-parallel-size", "--pipeline-parallel-size"} { - if extra, _ := planHFContext([]string{"--model", "Qwen/Qwen3-8B", flag, "2"}, cache, 65536); len(extra) != 0 { + if extra, _ := planHFContext([]string{"--model", "Qwen/Qwen3-8B", flag, "2"}, cache, 65536, ""); len(extra) != 0 { t.Fatalf("%s: sizing a sharded launch needs more than GPU 0; got %v", flag, extra) } } @@ -173,7 +173,7 @@ func TestAutoContextSkipsShardedLaunches(t *testing.T) { func TestAutoContextForAModelNotInTheCache(t *testing.T) { withGPU(t, 48, 20) - extra, _ := planHFContext([]string{"--model", "org/not-downloaded"}, t.TempDir(), 65536) + extra, _ := planHFContext([]string{"--model", "org/not-downloaded"}, t.TempDir(), 65536, "") if slices.Contains(extra, "--max-model-len") { t.Fatalf("an unknown model's limits are unknown: %v", extra) } @@ -185,7 +185,7 @@ func TestAutoContextForAModelNotInTheCache(t *testing.T) { func TestAutoContextWithoutAGPUReading(t *testing.T) { withoutGPU(t) cache := cachedModel(t, "Qwen/Qwen3-8B", qwen8BConfig, 16_400_000_000) - extra, _ := planHFContext([]string{"--model", "Qwen/Qwen3-8B"}, cache, 65536) + extra, _ := planHFContext([]string{"--model", "Qwen/Qwen3-8B"}, cache, 65536, "") if flagOf(t, extra, "--max-model-len") != "65536" { t.Fatalf("the window does not depend on the GPU reading: %v", extra) } @@ -198,7 +198,7 @@ func TestAutoContextWrapsOverridesForAMultimodalConfig(t *testing.T) { withGPU(t, 48, 48) cfg := `{"architectures":["Qwen3VLForConditionalGeneration"],"text_config":` + qwen8BConfig + `}` cache := cachedModel(t, "Qwen/VL", cfg, 16_400_000_000) - extra, _ := planHFContext([]string{"--model", "Qwen/VL"}, cache, 65536) + extra, _ := planHFContext([]string{"--model", "Qwen/VL"}, cache, 65536, "") var overrides map[string]json.RawMessage if err := json.Unmarshal([]byte(flagOf(t, extra, "--hf-overrides")), &overrides); err != nil { t.Fatal(err) @@ -313,38 +313,3 @@ func TestParsesToolCalls_NeedsBothFlags(t *testing.T) { } } } - -func TestParseGPUMemory_ReadsADiscreteGPUAndFallsBackOnUnifiedMemory(t *testing.T) { - system := func() (int64, int64, bool) { return 128 * gib, 100 * gib, true } - for _, tc := range []struct { - name string - out string - total, free int64 - ok bool - }{ - {"discrete GPU, in MiB", "49140, 48600\n", 49140 << 20, 48600 << 20, true}, - {"first GPU of several", "24576, 20000\n49140, 48600\n", 24576 << 20, 20000 << 20, true}, - {"DGX Spark reports N/A", "[N/A], [N/A]\n", 128 * gib, 100 * gib, true}, - {"not supported", "[Not Supported], [Not Supported]\n", 128 * gib, 100 * gib, true}, - {"malformed", "error\n", 0, 0, false}, - {"empty", "", 0, 0, false}, - } { - t.Run(tc.name, func(t *testing.T) { - total, free, ok := parseGPUMemory(tc.out, system) - if total != tc.total || free != tc.free || ok != tc.ok { - t.Fatalf("parseGPUMemory(%q) = %d, %d, %v; want %d, %d, %v", tc.out, total, free, ok, tc.total, tc.free, tc.ok) - } - }) - } -} - -func TestParseMeminfo_ReadsTotalAndAvailable(t *testing.T) { - text := "MemTotal: 130567000 kB\nMemFree: 2000000 kB\nMemAvailable: 104000000 kB\nBuffers: 100 kB\n" - total, available, ok := parseMeminfo(text) - if !ok || total != 130567000*1024 || available != 104000000*1024 { - t.Fatalf("parseMeminfo = %d, %d, %v", total, available, ok) - } - if _, _, ok := parseMeminfo("MemTotal: 100 kB\n"); ok { - t.Fatal("a reading without MemAvailable must not count as known") - } -} diff --git a/services/nvpair-engine-manager/launchauto_unified_test.go b/services/nvpair-engine-manager/launchauto_unified_test.go new file mode 100644 index 000000000..4ff076518 --- /dev/null +++ b/services/nvpair-engine-manager/launchauto_unified_test.go @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// A DGX Spark (GB10) as seen while a model load failed: 121.69 GiB of unified +// memory of which about 41.7 GiB was available. The GPU reports "[N/A]" for its +// own memory, so the system's figures are the only ones there are. +const ( + sparkMemInfo = "MemTotal: 127600524 kB\nMemFree: 43590000 kB\nMemAvailable: 43684000 kB\nBuffers: 30000 kB\n" + sparkTotal = int64(127600524) * 1024 + sparkAvail = int64(43684000) * 1024 +) + +const llama8BFP8Config = `{"max_position_embeddings":131072,"num_hidden_layers":32,"num_attention_heads":32,"num_key_value_heads":8,"head_dim":128,"torch_dtype":"bfloat16","rope_scaling":{"rope_type":"llama3","factor":8.0}}` + +func withSystemMemory(t *testing.T, meminfo string) { + t.Helper() + old := meminfoPath + meminfoPath = filepath.Join(t.TempDir(), "meminfo") + if err := os.WriteFile(meminfoPath, []byte(meminfo), 0o600); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { meminfoPath = old }) +} + +// withUnifiedMemoryGPU makes nvidia-smi answer the way it does on a GPU that +// shares the system's memory, so the whole path from its output to the planned +// launch arguments runs. +func withUnifiedMemoryGPU(t *testing.T, meminfo string) { + t.Helper() + withSystemMemory(t, meminfo) + old := deviceMemory + deviceMemory = func(string) (int64, int64, bool) { return gpuMemoryFromQuery("[N/A], [N/A]\n") } + t.Cleanup(func() { deviceMemory = old }) +} + +func TestGPUMemoryFromQuery_ReadsADiscreteGPU(t *testing.T) { + total, free, ok := gpuMemoryFromQuery("49140, 40000\n") + if !ok || total != 49140*1024*1024 || free != 40000*1024*1024 { + t.Fatalf("got total=%d free=%d ok=%v, want the two MiB figures in bytes", total, free, ok) + } +} + +func TestGPUMemoryFromQuery_UsesSystemMemoryWhenTheGPUReportsNA(t *testing.T) { + for _, tc := range []struct{ name, output string }{ + {"bracketed N/A, as nvidia-smi prints with nounits", "[N/A], [N/A]\n"}, + {"bare N/A", "N/A, N/A\n"}, + {"Not Supported", "[Not Supported], [Not Supported]\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + withSystemMemory(t, sparkMemInfo) + total, free, ok := gpuMemoryFromQuery(tc.output) + if !ok || total != sparkTotal { + t.Fatalf("got total=%d ok=%v, want the system's %d", total, ok, sparkTotal) + } + if want := sparkAvail - unifiedMemoryHeadroom; free != want { + t.Fatalf("free = %d, want what is available less vLLM's startup headroom (%d)", free, want) + } + }) + } +} + +func TestGPUMemoryFromQuery_IsUnavailableWhenOnlyOneFigureIsMissing(t *testing.T) { + withSystemMemory(t, sparkMemInfo) + if _, _, ok := gpuMemoryFromQuery("[N/A], 1000\n"); ok { + t.Fatal("one missing figure is not a unified-memory GPU; the reading is unusable") + } +} + +func TestGPUMemoryFromQuery_IsUnavailableWhenSystemMemoryCannotBeRead(t *testing.T) { + old := meminfoPath + meminfoPath = filepath.Join(t.TempDir(), "absent") + t.Cleanup(func() { meminfoPath = old }) + if _, _, ok := gpuMemoryFromQuery("[N/A], [N/A]\n"); ok { + t.Fatal("with no system figures there is no reading to size a share from") + } +} + +func TestGPUMemoryFromQuery_ReportsNoFreeMemoryWhenLessThanTheHeadroomIsAvailable(t *testing.T) { + withSystemMemory(t, "MemTotal: 127600524 kB\nMemAvailable: 1000000 kB\n") + _, free, ok := gpuMemoryFromQuery("[N/A], [N/A]\n") + if !ok || free != 0 { + t.Fatalf("free = %d ok=%v, want 0 rather than a negative figure", free, ok) + } +} + +// The failure behind the report: with about 41 GiB available, the launch asked +// vLLM for nothing, so it wanted its default 0.92 of 121.69 GiB (111.95 GiB) and +// stopped before readiness. +func TestPlanHFContext_CapsTheGPUShareOnAUnifiedMemoryMachine(t *testing.T) { + withUnifiedMemoryGPU(t, sparkMemInfo) + cache := cachedModel(t, "nvidia/Llama-3.1-8B-Instruct-FP8", llama8BFP8Config, 9_100_000_000) + extra, _ := planHFContext([]string{"serve", "--model", "nvidia/Llama-3.1-8B-Instruct-FP8"}, cache, 65536, "") + + util := utilOf(t, extra) + request := util * float64(sparkTotal) + if limit := float64(sparkAvail - unifiedMemoryHeadroom - freeMargin); request > limit { + t.Fatalf("--gpu-memory-utilization %.2f asks for %.1f GiB but only %.1f GiB can be claimed", util, request/float64(gib), limit/float64(gib)) + } + if got := flagOf(t, extra, "--max-model-len"); got != "65536" { + t.Fatalf("max-model-len = %q, want 65536 (it fits in the memory that is free): %v", got, extra) + } +} + +func TestPlanHFContext_CapsTheGPUShareOfAModelNotYetDownloadedOnAUnifiedMemoryMachine(t *testing.T) { + withUnifiedMemoryGPU(t, sparkMemInfo) + extra, _ := planHFContext([]string{"serve", "--model", "nvidia/Llama-3.1-8B-Instruct-FP8"}, t.TempDir(), 65536, "") + + util := utilOf(t, extra) + if limit := float64(sparkAvail-unifiedMemoryHeadroom-freeMargin) / float64(sparkTotal); util > limit { + t.Fatalf("--gpu-memory-utilization %.2f is above the %.2f that is free: %v", util, limit, extra) + } +} + +func TestPlanHFContext_LeavesTheShareToVLLMWhenAUnifiedMemoryMachineHasNothingFree(t *testing.T) { + withUnifiedMemoryGPU(t, "MemTotal: 127600524 kB\nMemAvailable: 1000000 kB\n") + extra, _ := planHFContext([]string{"serve", "--model", "nvidia/Llama-3.1-8B-Instruct-FP8"}, t.TempDir(), 65536, "") + if v, _ := launchFlagValue(extra, "--gpu-memory-utilization"); v != "" { + t.Fatalf("no share can be sized from nothing free; got %q in %v", v, extra) + } +} From 75be10ad25652e23e45d9c439547bc5aa3bf81e0 Mon Sep 17 00:00:00 2001 From: nvrithik Date: Fri, 30 Oct 2026 08:17:18 -0700 Subject: [PATCH 35/35] fix(nvpair-engine-manager): handle FP8 models safely on macOS Use the model dtype for a quantized KV cache when vLLM-Metal can load the checkpoint, while preserving explicit user launch options. Reject NVIDIA ModelOpt FP8 weight checkpoints before process startup with an actionable BF16, FP16, or MLX-compatible alternative because vLLM-Metal cannot load that tensor layout. Signed-off-by: nvrithik --- desktop/docs/services-parity.md | 11 +- docs/engine-lifecycle.mdx | 13 +- services/nvpair-engine-manager/README.md | 35 ++++- services/nvpair-engine-manager/launchauto.go | 13 ++ .../nvpair-engine-manager/launchauto_metal.go | 111 ++++++++++++++++ .../launchauto_metal_test.go | 124 ++++++++++++++++++ services/nvpair-engine-manager/lifecycle.go | 3 + 7 files changed, 297 insertions(+), 13 deletions(-) create mode 100644 services/nvpair-engine-manager/launchauto_metal.go create mode 100644 services/nvpair-engine-manager/launchauto_metal_test.go diff --git a/desktop/docs/services-parity.md b/desktop/docs/services-parity.md index 907f30a18..6914ad492 100644 --- a/desktop/docs/services-parity.md +++ b/desktop/docs/services-parity.md @@ -187,8 +187,11 @@ rather than driver-aware or offline-repacked. The bundled `vllm` manifest covers Linux (NVIDIA GPU) and Apple Silicon macOS (vLLM-Metal). It installs a pinned vLLM release into a private virtualenv -through a checksum-pinned `uv` bootstrap, or adopts a `vllm` already installed -at a detected path. The engine's PAIR gateway listens on `8001`, with the +at the space-free managed path `~/.nvpair/engine-bin/vllm` through a +checksum-pinned `uv` bootstrap, or adopts a `vllm` already installed at a +detected path. The space-free physical path lets FlashInfer and DeepGEMM compile +load-time GPU kernels; reset and uninstall include the path while preserving the +model cache. The engine's PAIR gateway listens on `8001`, with the `vllm serve` process on loopback `18001`, behind the proxy facade on `8000`. vLLM serves one model per process and has no load or unload API, so the engine manager puts a gateway on its port (`front`): Start and the restore after a restart bring it up idle with nothing loaded, the @@ -213,7 +216,9 @@ restart, so the desktop marks the engine as switching: its card shows starting rather than stopped, and its model list is kept, until the load request finishes (success, or failure with the previous model restored). Weights live in the shared Hugging Face cache (`~/.cache/huggingface`), which uninstall and app-data reset -never remove. +never remove. On macOS the manager rejects NVIDIA ModelOpt FP8 weight +checkpoints before launch because vLLM-Metal cannot load that tensor layout; +the NVIDIA Linux path remains unchanged. Current limits are explicit: the manual-node worker does not probe vLLM, the Inference Demo does not use it, and the icon is a placeholder lettermark rather diff --git a/docs/engine-lifecycle.mdx b/docs/engine-lifecycle.mdx index 5dd3e02d0..0c98cdb59 100644 --- a/docs/engine-lifecycle.mdx +++ b/docs/engine-lifecycle.mdx @@ -97,9 +97,16 @@ When you install an engine, consider the following: several GiB because it includes PyTorch and the CUDA libraries, and PAIR first checks for an NVIDIA driver that supports CUDA 13 or newer and about 25 GB of free disk, enough for the engine and its default model. On macOS it installs - checksum-pinned vLLM and vLLM-Metal wheels and needs about 20 GB of free disk. Either way PAIR stops with a clear message if a - requirement is missing. - Nothing is compiled. vLLM serves a single model per process. Until you choose + checksum-pinned vLLM and vLLM-Metal wheels and needs about 20 GB of free disk. + Either way PAIR stops with a clear message if a requirement is missing. + NVIDIA ModelOpt FP8 weight checkpoints run on the NVIDIA path but are not + loadable by vLLM-Metal; on macOS PAIR rejects them before launch and asks for + a BF16, FP16, or MLX-compatible quantized checkpoint. + PAIR's private environment lives at + `~/.nvpair/engine-bin/vllm`, outside the app-data path whose spaces break the + load-time GPU builds used by some models. Reset and uninstall still remove it. + Installation uses prebuilt wheels; loading a model may compile FlashInfer or + DeepGEMM GPU kernels. vLLM serves a single model per process. Until you choose one it serves Qwen3.5-4B, downloaded on the first start (about 9.3 GB, and a GPU with at least 16 GB of memory); on a smaller GPU, add and load a smaller model first. Its model list diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index 41b0fb974..be26f2683 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -97,13 +97,34 @@ answers in plain text instead of failing with HTTP 400. The response carries `X-PAIR-Tool-Calls: removed: …`. A request with `tool_choice: "required"` or a named function is forwarded unchanged and gets vLLM's own error. A parser named in the launch arguments is always used as given. -The environment sets `VLLM_USE_FLASHINFER_SAMPLER=0`. vLLM's FlashInfer sampler -compiles GPU kernels on first start, and that build cannot handle the spaces in -PAIR's app-data folder (`Nvidia Corporation/Personal AI Router`), so the -built-in sampler is used instead. As a side effect no CUDA toolkit or C++ -compiler is needed. The same limit means anything that forces a FlashInfer -kernel build (for example choosing a FlashInfer attention backend) fails until -the install can live on a path without spaces. +PAIR installs its managed vLLM payload under `~/.nvpair/engine-bin/vllm`, rather +than under the app-data folder whose `Nvidia Corporation/Personal AI Router` +segments contain spaces. FlashInfer and DeepGEMM write unescaped source and +include paths into load-time GPU builds, so the space-free physical location lets +their attention and FP8/FP4 kernels compile normally. The engine's virtualenv is +put first on `PATH` so those builds find its bundled `ninja`. Engine uninstall, +Reset app data, and the platform uninstallers all remove this managed directory; +downloaded Hugging Face models remain in `~/.cache/huggingface`. + +The environment still sets `VLLM_USE_FLASHINFER_SAMPLER=0`, so an ordinary model +does not need a CUDA toolkit solely for the optional FlashInfer sampler. Other +FlashInfer and DeepGEMM kernels are not disabled, and vLLM chooses their attention +backend normally. + +Before choosing a context window and `--gpu-memory-utilization`, PAIR reads +device memory from `nvidia-smi` when available and otherwise asks the installed +vLLM platform. This covers CUDA/ROCm, Intel XPU, Apple Metal, CPU, and +out-of-tree backends that expose vLLM's memory API. Shared-memory platforms keep +3 GiB for runtime startup in addition to the normal 1 GiB free margin. If neither +provider can measure free memory, PAIR does not invent a limit and leaves memory +sizing to vLLM. On Apple Metal, a checkpoint whose weights are Metal-compatible +but which declares an FP8 KV cache is launched with a dense KV cache matching its +`bfloat16` or `float16` model dtype, because vLLM Metal stores paged KV data in +the model dtype and rejects FP8 KV cache storage. An explicit launch option +always wins. NVIDIA ModelOpt FP8 weight checkpoints are rejected before launch: +vLLM Metal cannot load that tensor layout, so macOS needs a BF16, FP16, or +MLX-compatible quantized checkpoint instead. + Readiness polls `/health` for up to eight minutes while weights load. The model list is the Hugging Face cache (`~/.cache/huggingface/hub`), not what diff --git a/services/nvpair-engine-manager/launchauto.go b/services/nvpair-engine-manager/launchauto.go index de7f15c9a..bedd64aaf 100644 --- a/services/nvpair-engine-manager/launchauto.go +++ b/services/nvpair-engine-manager/launchauto.go @@ -12,6 +12,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strconv" "strings" "time" @@ -126,6 +127,10 @@ func applyAutoLaunch(engine string, rt Runtime, command launchCommand, modelsDir } hub := filepath.Join(expandPath(modelsDir), "hub") extra, note := planHFContext(command.Args, hub, int64(rt.AutoLaunch.ContextTokens), command.Bin) + if kvCache, reason := planMetalKVCache(command.Args, hub, runtime.GOOS); len(kvCache) > 0 { + extra = append(extra, kvCache...) + note = strings.TrimPrefix(note+"; "+reason, "; ") + } if tools, parser := planToolCalling(command.Args, hub, command.Bin); len(tools) > 0 { extra = append(extra, tools...) note = strings.TrimPrefix(note+"; tool calls parsed with "+parser, "; ") @@ -138,6 +143,14 @@ func applyAutoLaunch(engine string, rt Runtime, command launchCommand, modelsDir return command } +func validateAutoLaunch(rt Runtime, command launchCommand, modelsDir string) error { + if rt.AutoLaunch == nil || rt.AutoLaunch.Kind != autoLaunchHFContext { + return nil + } + hub := filepath.Join(expandPath(modelsDir), "hub") + return validateMetalModel(command.Args, hub, runtime.GOOS) +} + // launchFlagValue returns the value of a flag in either "--flag value" or // "--flag=value" form, and whether the flag is present at all. func launchFlagValue(args []string, flag string) (string, bool) { diff --git a/services/nvpair-engine-manager/launchauto_metal.go b/services/nvpair-engine-manager/launchauto_metal.go new file mode 100644 index 000000000..ffdc778cd --- /dev/null +++ b/services/nvpair-engine-manager/launchauto_metal.go @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type modelOptConfig struct { + Producer struct { + Name string `json:"name"` + } `json:"producer"` + Quantization struct { + QuantAlgo string `json:"quant_algo"` + KVCacheQuantAlgo string `json:"kv_cache_quant_algo"` + } `json:"quantization"` +} + +func planMetalKVCache(args []string, hubDir, goos string) (extra []string, note string) { + if goos != "darwin" { + return nil, "" + } + if _, set := launchFlagValue(args, "--kv-cache-dtype"); set { + return nil, "" + } + model, ok := launchFlagValue(args, "--model") + if !ok || model == "" { + return nil, "" + } + snapshot, cfg, _, cached := findHFSnapshot(hubDir, model) + if !cached || modelOptFP8Weights(snapshot) || !quantizedKVCache(snapshot) { + return nil, "" + } + dtype := metalDenseKVCacheDtype(cfg) + if dtype == "" { + return nil, "" + } + return []string{"--kv-cache-dtype", dtype}, "Metal requires the KV cache to use the model dtype " + dtype +} + +func validateMetalModel(args []string, hubDir, goos string) error { + if goos != "darwin" { + return nil + } + model, ok := launchFlagValue(args, "--model") + if !ok || model == "" { + return nil + } + snapshot, _, _, cached := findHFSnapshot(hubDir, model) + if !cached || !modelOptFP8Weights(snapshot) { + return nil + } + return fmt.Errorf("model %q uses NVIDIA ModelOpt FP8 weights, which vLLM Metal cannot load on macOS; use a BF16, FP16, or MLX-compatible quantized checkpoint", model) +} + +func metalDenseKVCacheDtype(cfg hfModelConfig) string { + text := cfg + if cfg.TextConfig != nil { + text = *cfg.TextConfig + } + for _, value := range []string{text.Dtype, text.TorchDtype, cfg.Dtype, cfg.TorchDtype} { + switch strings.TrimPrefix(strings.ToLower(value), "torch.") { + case "bfloat16", "bf16": + return "bfloat16" + case "float16", "fp16", "half": + return "float16" + } + } + return "" +} + +func quantizedKVCache(snapshot string) bool { + if modelOpt, ok := readModelOptConfig(snapshot); ok && kvAlgorithmSet(modelOpt.Quantization.KVCacheQuantAlgo) { + return true + } + var config struct { + Quantization struct { + KVCacheQuantAlgo string `json:"kv_cache_quant_algo"` + KVCacheScheme json.RawMessage `json:"kv_cache_scheme"` + } `json:"quantization_config"` + } + if !readJSONFile(filepath.Join(snapshot, "config.json"), &config) { + return false + } + scheme := strings.TrimSpace(string(config.Quantization.KVCacheScheme)) + return kvAlgorithmSet(config.Quantization.KVCacheQuantAlgo) || (scheme != "" && scheme != "null") +} + +func modelOptFP8Weights(snapshot string) bool { + modelOpt, ok := readModelOptConfig(snapshot) + return ok && strings.EqualFold(modelOpt.Producer.Name, "modelopt") && strings.EqualFold(modelOpt.Quantization.QuantAlgo, "fp8") +} + +func readModelOptConfig(snapshot string) (modelOptConfig, bool) { + var config modelOptConfig + return config, readJSONFile(filepath.Join(snapshot, "hf_quant_config.json"), &config) +} + +func kvAlgorithmSet(algorithm string) bool { + return algorithm != "" && !strings.EqualFold(algorithm, "none") +} + +func readJSONFile(path string, into any) bool { + raw, err := os.ReadFile(path) + return err == nil && json.Unmarshal(raw, into) == nil +} diff --git a/services/nvpair-engine-manager/launchauto_metal_test.go b/services/nvpair-engine-manager/launchauto_metal_test.go new file mode 100644 index 000000000..ea6a199c6 --- /dev/null +++ b/services/nvpair-engine-manager/launchauto_metal_test.go @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const ( + modelOptFP8KVOnly = `{"producer":{"name":"modelopt"},"quantization":{"kv_cache_quant_algo":"FP8"}}` + modelOptFP8WeightsJSON = `{"producer":{"name":"modelopt"},"quantization":{"quant_algo":"FP8","kv_cache_quant_algo":"FP8"}}` +) + +func cachedModelWithFile(t *testing.T, id, config, name, contents string) string { + t.Helper() + cache := cachedModel(t, id, config, 1_000_000) + snapshot, _, _, ok := findHFSnapshot(cache, id) + if !ok { + t.Fatal("cached model has no complete snapshot") + } + if err := os.WriteFile(filepath.Join(snapshot, name), []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + return cache +} + +func TestPlanMetalKVCache_UsesBFloat16ForModelOptFP8KVCache(t *testing.T) { + cache := cachedModelWithFile(t, "org/Llama-KV-FP8", llama8BFP8Config, "hf_quant_config.json", modelOptFP8KVOnly) + + extra, note := planMetalKVCache([]string{"serve", "--model", "org/Llama-KV-FP8"}, cache, "darwin") + + if got := flagOf(t, extra, "--kv-cache-dtype"); got != "bfloat16" { + t.Fatalf("kv-cache-dtype = %q, want bfloat16: %v", got, extra) + } + if !strings.Contains(note, "model dtype bfloat16") { + t.Fatalf("note = %q", note) + } +} + +func TestPlanMetalKVCache_UsesFloat16ForCompressedKVCache(t *testing.T) { + config := `{"max_position_embeddings":40960,"num_hidden_layers":32,"num_attention_heads":32,"num_key_value_heads":8,"head_dim":128,"torch_dtype":"float16","quantization_config":{"quant_method":"compressed-tensors","kv_cache_scheme":{"type":"float","num_bits":8}}}` + cache := cachedModel(t, "org/compressed", config, 1_000_000) + + extra, _ := planMetalKVCache([]string{"--model", "org/compressed"}, cache, "darwin") + + if got := flagOf(t, extra, "--kv-cache-dtype"); got != "float16" { + t.Fatalf("kv-cache-dtype = %q, want float16: %v", got, extra) + } +} + +func TestPlanMetalKVCache_PreservesAnExplicitUserChoice(t *testing.T) { + cache := cachedModelWithFile(t, "org/Llama-KV-FP8", llama8BFP8Config, "hf_quant_config.json", modelOptFP8KVOnly) + args := []string{"--model", "org/Llama-KV-FP8", "--kv-cache-dtype=auto"} + + extra, _ := planMetalKVCache(args, cache, "darwin") + + if len(extra) != 0 { + t.Fatalf("the user's KV cache choice must stand untouched: %v", extra) + } +} + +func TestPlanMetalKVCache_AddsNothingWithoutAPlatformConflict(t *testing.T) { + quantized := cachedModelWithFile(t, "org/Llama-KV-FP8", llama8BFP8Config, "hf_quant_config.json", modelOptFP8KVOnly) + plain := cachedModel(t, "org/plain", llama8BFP8Config, 1_000_000) + unsupportedDtype := cachedModelWithFile(t, "org/fp32-kv", strings.Replace(llama8BFP8Config, "bfloat16", "float32", 1), "hf_quant_config.json", modelOptFP8KVOnly) + + cases := []struct { + name string + args []string + cache string + goos string + }{ + {"non-Metal platform", []string{"--model", "org/Llama-KV-FP8"}, quantized, "linux"}, + {"dense checkpoint", []string{"--model", "org/plain"}, plain, "darwin"}, + {"dtype unsupported by Metal", []string{"--model", "org/fp32-kv"}, unsupportedDtype, "darwin"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + extra, _ := planMetalKVCache(tc.args, tc.cache, tc.goos) + if len(extra) != 0 { + t.Fatalf("got %v, want no automatic KV cache override", extra) + } + }) + } +} + +func TestPlanMetalKVCache_SkipsModelOptFP8Weights(t *testing.T) { + cache := cachedModelWithFile(t, "nvidia/Llama-FP8", llama8BFP8Config, "hf_quant_config.json", modelOptFP8WeightsJSON) + + extra, _ := planMetalKVCache([]string{"--model", "nvidia/Llama-FP8"}, cache, "darwin") + + if len(extra) != 0 { + t.Fatalf("an unsupported checkpoint must not receive a misleading KV-cache fallback: %v", extra) + } +} + +func TestValidateMetalModel_RejectsModelOptFP8WeightsOnMacOS(t *testing.T) { + cache := cachedModelWithFile(t, "nvidia/Llama-FP8", llama8BFP8Config, "hf_quant_config.json", modelOptFP8WeightsJSON) + + err := validateMetalModel([]string{"--model", "nvidia/Llama-FP8"}, cache, "darwin") + + if err == nil { + t.Fatal("ModelOpt FP8 weights must be rejected before vLLM Metal starts") + } + for _, want := range []string{"nvidia/Llama-FP8", "ModelOpt FP8", "BF16", "MLX-compatible"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %q, want %q", err, want) + } + } +} + +func TestValidateMetalModel_AllowsModelOptFP8WeightsOutsideMacOS(t *testing.T) { + cache := cachedModelWithFile(t, "nvidia/Llama-FP8", llama8BFP8Config, "hf_quant_config.json", modelOptFP8WeightsJSON) + + err := validateMetalModel([]string{"--model", "nvidia/Llama-FP8"}, cache, "linux") + + if err != nil { + t.Fatalf("non-Metal platforms keep their native FP8 support: %v", err) + } +} diff --git a/services/nvpair-engine-manager/lifecycle.go b/services/nvpair-engine-manager/lifecycle.go index af972ca9f..64a98f1b2 100644 --- a/services/nvpair-engine-manager/lifecycle.go +++ b/services/nvpair-engine-manager/lifecycle.go @@ -283,6 +283,9 @@ func (e *Executor) bringUpProcess(ctx context.Context, st *engineState, engine s if err := validateEffectiveLaunch(rt, launch, vars["host"], vars["port"]); err != nil { return err } + if err := validateAutoLaunch(rt, launch, vars["models_dir"]); err != nil { + return err + } launch = applyAutoLaunch(engine, rt, launch, vars["models_dir"]) if launch, err = applyFrontBackend(rt, launch, port); err != nil { return err