ALPHA: a Free VRAM killswitch, not just an automatic unload
Browse files`unload` only ever targets the model this pack configured. That is right for
the automatic path -- it hands back exactly what writing a plan caused to be
loaded -- but it is not enough, because the three situations that actually OOM
a render are the ones where the configured model is not what is resident:
* the "Unload after writing" checkbox was off
* the write failed before it reached the unload
* LM Studio's JIT loaded something other than what was asked for
PromptMasterLD covers all three with `lms unload --all`. That is a subprocess,
which is the rung that got 0.4.1-0.4.3 registry-Flagged under
`python_command_injection_risk`, so `unload_all` lists loaded models over HTTP
and walks each through the same four-rung ladder instead. Same effect, nothing
spawned. The rungs move into `_unload_one` so both paths share them.
Two behaviours worth stating. `shares_this_gpu` still guards it, so the
killswitch never reaches across a network to evict someone else's model --
freeing VRAM on a laptop does not help a desktop render. And a server that
does not report load state (no `/api/v0/models`) falls back to the configured
model rather than evicting nothing, saying so in the note.
The button sits in the main WRITE row, not in Settings: it is what you reach
for when a render just OOMed, and a killswitch behind a disclosure is not one.
The row now wraps rather than squeezing the brief input on a narrow node.
check_planner grows six assertions for the parts that need no server -- the
local-only guard, and that a remote or unconfigured writer is a clean no-op
rather than an error. Verified live against LM Studio with both models
unloaded: lists 2, evicts 0, reports "nothing is loaded". The eviction rung
itself still wants a loaded model to prove.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015MGjcAV8bDy93qJfmLi9kw
- CLAUDE.md +6 -1
- js/editor/writer_bar.js +35 -0
- js/h3_ref_chain.css +4 -0
- llm.py +103 -31
- prompt_pack/README.md +4 -0
- routes.py +22 -0
- tools/check_planner.py +23 -0
|
@@ -193,7 +193,12 @@ Four rules, each of which cost something to learn:
|
|
| 193 |
- **No subprocess, ever.** 0.4.1–0.4.3 were registry-Flagged under
|
| 194 |
`python_command_injection_risk` until `store.py` moved off `subprocess.Popen`.
|
| 195 |
PromptMasterLD's unload ladder ends in `lms unload --all`; that rung is
|
| 196 |
-
deliberately absent here and the four HTTP ones are enough.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
can afford it because it has no `pyproject.toml` and is never scanned.
|
| 198 |
- **No blocking I/O.** These functions are awaited inside aiohttp handlers, so
|
| 199 |
`urllib.request` — which is what PromptMasterLD uses from its worker thread —
|
|
|
|
| 193 |
- **No subprocess, ever.** 0.4.1–0.4.3 were registry-Flagged under
|
| 194 |
`python_command_injection_risk` until `store.py` moved off `subprocess.Popen`.
|
| 195 |
PromptMasterLD's unload ladder ends in `lms unload --all`; that rung is
|
| 196 |
+
deliberately absent here and the four HTTP ones are enough. `unload_all` is
|
| 197 |
+
the killswitch that rung existed for: it lists loaded models over HTTP and
|
| 198 |
+
walks each through the same ladder. `unload` alone was not enough, because
|
| 199 |
+
it only ever targets the configured model -- and the three cases that
|
| 200 |
+
actually OOM a render are the ones where that is not what is resident (the
|
| 201 |
+
checkbox was off, the write failed early, or JIT loaded something else). The sibling pack
|
| 202 |
can afford it because it has no `pyproject.toml` and is never scanned.
|
| 203 |
- **No blocking I/O.** These functions are awaited inside aiohttp handlers, so
|
| 204 |
`urllib.request` — which is what PromptMasterLD uses from its worker thread —
|
|
@@ -19,6 +19,7 @@ import { el, button } from "./widget_utils.js";
|
|
| 19 |
|
| 20 |
const LLM_URL = "/h3_ref_chain/llm";
|
| 21 |
const PLAN_URL = "/h3_ref_chain/plan";
|
|
|
|
| 22 |
|
| 23 |
export function createWriterBar(node, { onWritten, hopCount } = {}) {
|
| 24 |
const root = el("details", "h3e-section h3e-writer");
|
|
@@ -52,6 +53,13 @@ export function createWriterBar(node, { onWritten, hopCount } = {}) {
|
|
| 52 |
const go = button("Write plan", "Generate a plan and repair it until the "
|
| 53 |
+ "node accepts it", () => run());
|
| 54 |
row.appendChild(go);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
body.appendChild(row);
|
| 56 |
|
| 57 |
const status = el("div", "h3e-note h3e-writer-status");
|
|
@@ -118,9 +126,36 @@ export function createWriterBar(node, { onWritten, hopCount } = {}) {
|
|
| 118 |
function setBusy(on) {
|
| 119 |
busy = on;
|
| 120 |
go.disabled = on;
|
|
|
|
|
|
|
| 121 |
go.textContent = on ? "Writing…" : "Write plan";
|
| 122 |
}
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
async function loadConn(announce) {
|
| 125 |
try {
|
| 126 |
const r = await fetch(LLM_URL);
|
|
|
|
| 19 |
|
| 20 |
const LLM_URL = "/h3_ref_chain/llm";
|
| 21 |
const PLAN_URL = "/h3_ref_chain/plan";
|
| 22 |
+
const UNLOAD_URL = "/h3_ref_chain/llm/unload";
|
| 23 |
|
| 24 |
export function createWriterBar(node, { onWritten, hopCount } = {}) {
|
| 25 |
const root = el("details", "h3e-section h3e-writer");
|
|
|
|
| 53 |
const go = button("Write plan", "Generate a plan and repair it until the "
|
| 54 |
+ "node accepts it", () => run());
|
| 55 |
row.appendChild(go);
|
| 56 |
+
|
| 57 |
+
// In the main row on purpose. This is the button you reach for when a
|
| 58 |
+
// render just OOMed, and a killswitch behind a disclosure is not one.
|
| 59 |
+
const freeBtn = button("Free VRAM", "Unload whatever the writer is holding "
|
| 60 |
+
+ "in memory, right now -- not just the model configured here. Press "
|
| 61 |
+
+ "it before queueing if the card is full.", () => freeVram());
|
| 62 |
+
row.appendChild(freeBtn);
|
| 63 |
body.appendChild(row);
|
| 64 |
|
| 65 |
const status = el("div", "h3e-note h3e-writer-status");
|
|
|
|
| 126 |
function setBusy(on) {
|
| 127 |
busy = on;
|
| 128 |
go.disabled = on;
|
| 129 |
+
// Unloading mid-generation would evict the model answering the prompt.
|
| 130 |
+
freeBtn.disabled = on;
|
| 131 |
go.textContent = on ? "Writing…" : "Write plan";
|
| 132 |
}
|
| 133 |
|
| 134 |
+
async function freeVram() {
|
| 135 |
+
if (busy) return;
|
| 136 |
+
freeBtn.disabled = true;
|
| 137 |
+
freeBtn.textContent = "Freeing…";
|
| 138 |
+
try {
|
| 139 |
+
const r = await fetch(UNLOAD_URL, { method: "POST" });
|
| 140 |
+
const j = await r.json();
|
| 141 |
+
// "Nothing was loaded" is a success with nothing to do, and saying
|
| 142 |
+
// so beats a bare "done" that leaves the user wondering whether
|
| 143 |
+
// the card is actually free.
|
| 144 |
+
say(!j.ok
|
| 145 |
+
? (j.error || "could not reach the writer")
|
| 146 |
+
: j.unloaded
|
| 147 |
+
? `Freed ${j.unloaded} model(s): ${j.note}`
|
| 148 |
+
: `Nothing to free — ${j.note}`,
|
| 149 |
+
j.ok ? "hint" : "error");
|
| 150 |
+
if (j.ok && j.unloaded) await loadConn(false);
|
| 151 |
+
} catch (e) {
|
| 152 |
+
say(String(e), "error");
|
| 153 |
+
} finally {
|
| 154 |
+
freeBtn.disabled = false;
|
| 155 |
+
freeBtn.textContent = "Free VRAM";
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
async function loadConn(announce) {
|
| 160 |
try {
|
| 161 |
const r = await fetch(LLM_URL);
|
|
@@ -903,7 +903,11 @@
|
|
| 903 |
align-items: center;
|
| 904 |
gap: var(--h3-gap-sm);
|
| 905 |
margin-bottom: var(--h3-gap-sm);
|
|
|
|
|
|
|
|
|
|
| 906 |
}
|
|
|
|
| 907 |
.h3e-writer-brief { flex: 1 1 auto; min-width: 0; }
|
| 908 |
.h3e-writer-hops { flex: 0 0 56px; text-align: center; }
|
| 909 |
.h3e-writer-url { flex: 1 1 auto; min-width: 0; }
|
|
|
|
| 903 |
align-items: center;
|
| 904 |
gap: var(--h3-gap-sm);
|
| 905 |
margin-bottom: var(--h3-gap-sm);
|
| 906 |
+
/* The brief, the hop count and two buttons do not fit a narrow node at
|
| 907 |
+
one line. Wrap rather than squeezing the brief down to nothing. */
|
| 908 |
+
flex-wrap: wrap;
|
| 909 |
}
|
| 910 |
+
.h3e-writer-row button { flex: 0 0 auto; }
|
| 911 |
.h3e-writer-brief { flex: 1 1 auto; min-width: 0; }
|
| 912 |
.h3e-writer-hops { flex: 0 0 56px; text-align: center; }
|
| 913 |
.h3e-writer-url { flex: 1 1 auto; min-width: 0; }
|
|
@@ -415,6 +415,46 @@ def shares_this_gpu(base_url):
|
|
| 415 |
return bool(here & there)
|
| 416 |
|
| 417 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
async def unload(base_url, model):
|
| 419 |
"""Hand the VRAM back, so the render that follows has somewhere to live.
|
| 420 |
|
|
@@ -437,39 +477,10 @@ async def unload(base_url, model):
|
|
| 437 |
flush=True)
|
| 438 |
return False
|
| 439 |
|
| 440 |
-
attempts = [
|
| 441 |
-
("/api/v1/models/unload", {"instance_id": model}),
|
| 442 |
-
("/api/v1/models/unload", {"identifier": model}),
|
| 443 |
-
("/api/v0/models/unload", {"instance_id": model}),
|
| 444 |
-
("/api/v0/models/unload", {"identifier": model}),
|
| 445 |
-
]
|
| 446 |
try:
|
| 447 |
async with aiohttp.ClientSession() as session:
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
async with session.post(
|
| 451 |
-
base + path, json=body,
|
| 452 |
-
timeout=aiohttp.ClientTimeout(total=UNLOAD_TIMEOUT),
|
| 453 |
-
) as resp:
|
| 454 |
-
if resp.status < 400:
|
| 455 |
-
print(f"[{TAG}] unloaded {model} from the writer",
|
| 456 |
-
flush=True)
|
| 457 |
-
return True
|
| 458 |
-
except Exception:
|
| 459 |
-
continue
|
| 460 |
-
# Ollama keeps its own eviction verb; harmless against LM Studio.
|
| 461 |
-
try:
|
| 462 |
-
async with session.post(
|
| 463 |
-
base + "/api/generate",
|
| 464 |
-
json={"model": model, "keep_alive": 0},
|
| 465 |
-
timeout=aiohttp.ClientTimeout(total=UNLOAD_TIMEOUT),
|
| 466 |
-
) as resp:
|
| 467 |
-
if resp.status < 400:
|
| 468 |
-
print(f"[{TAG}] evicted {model} (keep_alive 0)",
|
| 469 |
-
flush=True)
|
| 470 |
-
return True
|
| 471 |
-
except Exception:
|
| 472 |
-
pass
|
| 473 |
except Exception as exc:
|
| 474 |
print(f"[{TAG}] unload failed: {exc}", flush=True)
|
| 475 |
return False
|
|
@@ -477,3 +488,64 @@ async def unload(base_url, model):
|
|
| 477 |
print(f"[{TAG}] no unload endpoint answered -- free the VRAM in LM Studio "
|
| 478 |
f"if the render runs short", flush=True)
|
| 479 |
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
return bool(here & there)
|
| 416 |
|
| 417 |
|
| 418 |
+
async def _unload_one(session, base, model):
|
| 419 |
+
"""One model, every known eviction verb. -> bool. Never raises.
|
| 420 |
+
|
| 421 |
+
LM Studio's unload route changed shape between versions, so the bodies are
|
| 422 |
+
tried in order; a backend with no unload endpoint at all is ordinary.
|
| 423 |
+
"""
|
| 424 |
+
import aiohttp
|
| 425 |
+
|
| 426 |
+
for path, body in (
|
| 427 |
+
("/api/v1/models/unload", {"instance_id": model}),
|
| 428 |
+
("/api/v1/models/unload", {"identifier": model}),
|
| 429 |
+
("/api/v0/models/unload", {"instance_id": model}),
|
| 430 |
+
("/api/v0/models/unload", {"identifier": model}),
|
| 431 |
+
):
|
| 432 |
+
try:
|
| 433 |
+
async with session.post(
|
| 434 |
+
base + path, json=body,
|
| 435 |
+
timeout=aiohttp.ClientTimeout(total=UNLOAD_TIMEOUT),
|
| 436 |
+
) as resp:
|
| 437 |
+
if resp.status < 400:
|
| 438 |
+
print(f"[{TAG}] unloaded {model} from the writer",
|
| 439 |
+
flush=True)
|
| 440 |
+
return True
|
| 441 |
+
except Exception:
|
| 442 |
+
continue
|
| 443 |
+
# Ollama keeps its own eviction verb; harmless against LM Studio.
|
| 444 |
+
try:
|
| 445 |
+
async with session.post(
|
| 446 |
+
base + "/api/generate",
|
| 447 |
+
json={"model": model, "keep_alive": 0},
|
| 448 |
+
timeout=aiohttp.ClientTimeout(total=UNLOAD_TIMEOUT),
|
| 449 |
+
) as resp:
|
| 450 |
+
if resp.status < 400:
|
| 451 |
+
print(f"[{TAG}] evicted {model} (keep_alive 0)", flush=True)
|
| 452 |
+
return True
|
| 453 |
+
except Exception:
|
| 454 |
+
pass
|
| 455 |
+
return False
|
| 456 |
+
|
| 457 |
+
|
| 458 |
async def unload(base_url, model):
|
| 459 |
"""Hand the VRAM back, so the render that follows has somewhere to live.
|
| 460 |
|
|
|
|
| 477 |
flush=True)
|
| 478 |
return False
|
| 479 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
try:
|
| 481 |
async with aiohttp.ClientSession() as session:
|
| 482 |
+
if await _unload_one(session, base, model):
|
| 483 |
+
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
except Exception as exc:
|
| 485 |
print(f"[{TAG}] unload failed: {exc}", flush=True)
|
| 486 |
return False
|
|
|
|
| 488 |
print(f"[{TAG}] no unload endpoint answered -- free the VRAM in LM Studio "
|
| 489 |
f"if the render runs short", flush=True)
|
| 490 |
return False
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
async def unload_all(base_url, fallback_model=""):
|
| 494 |
+
"""The killswitch: evict everything the writer has in VRAM. -> (n, note).
|
| 495 |
+
|
| 496 |
+
`unload` only ever targets the model this pack configured, which is right
|
| 497 |
+
for the automatic path -- it hands back exactly what writing a plan caused
|
| 498 |
+
to be loaded. It is not enough for a button whose whole job is "give me the
|
| 499 |
+
card back now", because the three situations that actually OOM a render are
|
| 500 |
+
the ones where the configured model is not what is resident: the unload
|
| 501 |
+
checkbox was off, the write failed before it ran, or LM Studio's JIT loaded
|
| 502 |
+
something other than what was asked for.
|
| 503 |
+
|
| 504 |
+
PromptMasterLD covers this with `lms unload --all`. That rung is a
|
| 505 |
+
subprocess, which is what got 0.4.1-0.4.3 registry-Flagged under
|
| 506 |
+
`python_command_injection_risk`, so this lists loaded models over HTTP and
|
| 507 |
+
walks them through the same ladder instead. Same effect, nothing spawned.
|
| 508 |
+
"""
|
| 509 |
+
import aiohttp
|
| 510 |
+
|
| 511 |
+
base = normalise_base(base_url)
|
| 512 |
+
if not base:
|
| 513 |
+
return 0, "no server configured"
|
| 514 |
+
if not shares_this_gpu(base):
|
| 515 |
+
# The laptop-and-desktop bug: never reach across a network to evict
|
| 516 |
+
# someone else's model. Freeing VRAM here would free the wrong VRAM.
|
| 517 |
+
return 0, "the writer is on another machine -- nothing to free here"
|
| 518 |
+
|
| 519 |
+
try:
|
| 520 |
+
listed = await models(base)
|
| 521 |
+
except Exception as exc:
|
| 522 |
+
listed = []
|
| 523 |
+
print(f"[{TAG}] could not list models to unload: {exc}", flush=True)
|
| 524 |
+
|
| 525 |
+
# `loaded` is None on servers with no /api/v0/models -- unknown, not false.
|
| 526 |
+
# Falling back to the configured model beats evicting nothing at all.
|
| 527 |
+
targets = [m["id"] for m in listed if m.get("loaded") is True]
|
| 528 |
+
guessing = False
|
| 529 |
+
if not targets:
|
| 530 |
+
if any(m.get("loaded") is None for m in listed) and fallback_model:
|
| 531 |
+
targets, guessing = [fallback_model], True
|
| 532 |
+
else:
|
| 533 |
+
return 0, "nothing is loaded"
|
| 534 |
+
|
| 535 |
+
done = []
|
| 536 |
+
try:
|
| 537 |
+
async with aiohttp.ClientSession() as session:
|
| 538 |
+
for name in targets:
|
| 539 |
+
if await _unload_one(session, base, name):
|
| 540 |
+
done.append(name)
|
| 541 |
+
except Exception as exc:
|
| 542 |
+
print(f"[{TAG}] unload_all failed: {exc}", flush=True)
|
| 543 |
+
return len(done), f"stopped after {len(done)}: {exc}"
|
| 544 |
+
|
| 545 |
+
if not done:
|
| 546 |
+
return 0, ("no unload endpoint answered -- free the VRAM in LM Studio "
|
| 547 |
+
"directly")
|
| 548 |
+
note = ", ".join(done)
|
| 549 |
+
if guessing:
|
| 550 |
+
note += " (this server does not report what is loaded)"
|
| 551 |
+
return len(done), note
|
|
@@ -78,6 +78,10 @@ Notes and limits:
|
|
| 78 |
node, never in the workflow. A shared `.json` cannot point at your server.
|
| 79 |
* **Unload after writing** is on by default. A 27B and an H3 render do not fit
|
| 80 |
on one card. It is skipped when the server is on another machine.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
* No API keys, no cloud providers, no model downloading — local servers only.
|
| 82 |
* This is **alpha**. The manual recipe above is the one to fall back on.
|
| 83 |
|
|
|
|
| 78 |
node, never in the workflow. A shared `.json` cannot point at your server.
|
| 79 |
* **Unload after writing** is on by default. A 27B and an H3 render do not fit
|
| 80 |
on one card. It is skipped when the server is on another machine.
|
| 81 |
+
* **Free VRAM** unloads everything the writer is holding, on demand. Reach for
|
| 82 |
+
it before queueing if a render just ran out of memory: the automatic unload
|
| 83 |
+
only hands back the model configured here, and LM Studio may have loaded a
|
| 84 |
+
different one on its own.
|
| 85 |
* No API keys, no cloud providers, no model downloading — local servers only.
|
| 86 |
* This is **alpha**. The manual recipe above is the one to fall back on.
|
| 87 |
|
|
@@ -40,6 +40,7 @@ UPLOAD_ROUTE = "/h3_ref_chain/upload"
|
|
| 40 |
FILES_ROUTE = "/h3_ref_chain/files"
|
| 41 |
LLM_ROUTE = "/h3_ref_chain/llm"
|
| 42 |
PLAN_ROUTE = "/h3_ref_chain/plan"
|
|
|
|
| 43 |
|
| 44 |
# A batch of stills is a handful; this is a guard against a runaway multipart
|
| 45 |
# body, not a considered product limit.
|
|
@@ -241,6 +242,27 @@ def register():
|
|
| 241 |
print(f"[{TAG}] llm save failed: {exc!r}", flush=True)
|
| 242 |
return web.json_response({"ok": False, "error": str(exc)}, status=500)
|
| 243 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
@instance.routes.post(PLAN_ROUTE)
|
| 245 |
async def _plan(request):
|
| 246 |
"""Write a plan, and make the model repair it until the node accepts it.
|
|
|
|
| 40 |
FILES_ROUTE = "/h3_ref_chain/files"
|
| 41 |
LLM_ROUTE = "/h3_ref_chain/llm"
|
| 42 |
PLAN_ROUTE = "/h3_ref_chain/plan"
|
| 43 |
+
UNLOAD_ROUTE = "/h3_ref_chain/llm/unload"
|
| 44 |
|
| 45 |
# A batch of stills is a handful; this is a guard against a runaway multipart
|
| 46 |
# body, not a considered product limit.
|
|
|
|
| 242 |
print(f"[{TAG}] llm save failed: {exc!r}", flush=True)
|
| 243 |
return web.json_response({"ok": False, "error": str(exc)}, status=500)
|
| 244 |
|
| 245 |
+
@instance.routes.post(UNLOAD_ROUTE)
|
| 246 |
+
async def _llm_unload(request):
|
| 247 |
+
"""Free the writer's VRAM on demand. Never a 500, never a hard error.
|
| 248 |
+
|
| 249 |
+
The automatic unload after writing covers the ordinary case. This is
|
| 250 |
+
for the ones it cannot: the checkbox was off, the write failed before
|
| 251 |
+
it ran, or LM Studio's JIT put a different model in memory than the one
|
| 252 |
+
configured. Pressing it when nothing is loaded is a no-op that says so.
|
| 253 |
+
"""
|
| 254 |
+
try:
|
| 255 |
+
from . import llm as _llm
|
| 256 |
+
except Exception as exc:
|
| 257 |
+
return web.json_response({"ok": False, "error": str(exc)})
|
| 258 |
+
try:
|
| 259 |
+
conn = _llm.load_conn()
|
| 260 |
+
n, note = await _llm.unload_all(conn["server_url"], conn["model"])
|
| 261 |
+
return web.json_response({"ok": True, "unloaded": n, "note": note})
|
| 262 |
+
except Exception as exc:
|
| 263 |
+
print(f"[{TAG}] unload failed: {exc!r}", flush=True)
|
| 264 |
+
return web.json_response({"ok": False, "error": str(exc)})
|
| 265 |
+
|
| 266 |
@instance.routes.post(PLAN_ROUTE)
|
| 267 |
async def _plan(request):
|
| 268 |
"""Write a plan, and make the model repair it until the node accepts it.
|
|
@@ -20,6 +20,8 @@ Covers:
|
|
| 20 |
duration label, an invented filename, a hop-count miss
|
| 21 |
* write_plan -- repairs on attempt 2, reports warnings, and never
|
| 22 |
returns an unvalidated plan when it cannot converge
|
|
|
|
|
|
|
| 23 |
"""
|
| 24 |
from __future__ import annotations
|
| 25 |
|
|
@@ -223,6 +225,27 @@ def main():
|
|
| 223 |
out["ok"] is False and any("no JSON" in e for e in out["errors"]),
|
| 224 |
"; ".join(out["errors"][:1]))
|
| 225 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
print()
|
| 227 |
if FAIL:
|
| 228 |
print("%d FAILURE(S): %s" % (len(FAIL), ", ".join(FAIL)))
|
|
|
|
| 20 |
duration label, an invented filename, a hop-count miss
|
| 21 |
* write_plan -- repairs on attempt 2, reports warnings, and never
|
| 22 |
returns an unvalidated plan when it cannot converge
|
| 23 |
+
* unload_all -- the killswitch refuses to evict a writer that is not on
|
| 24 |
+
this machine, without opening a connection to find out
|
| 25 |
"""
|
| 26 |
from __future__ import annotations
|
| 27 |
|
|
|
|
| 225 |
out["ok"] is False and any("no JSON" in e for e in out["errors"]),
|
| 226 |
"; ".join(out["errors"][:1]))
|
| 227 |
|
| 228 |
+
# ------------------------------------------------------- the killswitch
|
| 229 |
+
# Only the parts that need no server. `unload_all` short-circuits on a
|
| 230 |
+
# non-local host BEFORE it opens a session, so this asserts the guard
|
| 231 |
+
# without touching the network.
|
| 232 |
+
print("\nllm.unload_all -- the local-only guard")
|
| 233 |
+
LM = importlib.import_module("htcpack.llm")
|
| 234 |
+
|
| 235 |
+
ck("localhost shares this GPU", LM.shares_this_gpu("http://127.0.0.1:1234"))
|
| 236 |
+
ck("a bare host with no scheme still resolves",
|
| 237 |
+
LM.shares_this_gpu("localhost:1234"))
|
| 238 |
+
# TEST-NET-1, guaranteed unroutable and never this machine.
|
| 239 |
+
ck("a remote host does not",
|
| 240 |
+
not LM.shares_this_gpu("http://192.0.2.1:1234"))
|
| 241 |
+
|
| 242 |
+
n, note = asyncio.run(LM.unload_all("http://192.0.2.1:1234", "some-model"))
|
| 243 |
+
ck("a remote writer is never evicted", n == 0, note)
|
| 244 |
+
ck("and it says why", "another machine" in note, note)
|
| 245 |
+
|
| 246 |
+
n, note = asyncio.run(LM.unload_all("", "some-model"))
|
| 247 |
+
ck("no server configured is not an error", n == 0, note)
|
| 248 |
+
|
| 249 |
print()
|
| 250 |
if FAIL:
|
| 251 |
print("%d FAILURE(S): %s" % (len(FAIL), ", ".join(FAIL)))
|