Sandpies Claude Opus 5 commited on
Commit
fcbaf66
·
1 Parent(s): 2450d96

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 CHANGED
@@ -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. The sibling pack
 
 
 
 
 
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 —
js/editor/writer_bar.js CHANGED
@@ -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);
js/h3_ref_chain.css CHANGED
@@ -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; }
llm.py CHANGED
@@ -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
- for path, body in attempts:
449
- try:
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
prompt_pack/README.md CHANGED
@@ -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
 
routes.py CHANGED
@@ -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.
tools/check_planner.py CHANGED
@@ -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)))