feat(llm): Download-Fortschritt beim Modell-Laden — und "Laden" kommt endlich an

Eigentlicher Fehler zuerst: llm_provision_model/_result und llm_remove_model
standen nie in der RVS-Allow-List. Der RVS verwirft unbekannte Typen mit einem
stillen return — jeder Klick auf "Laden" im Modell-Katalog ist seit Stage D
(19.09.) an dieser Liste gestorben, die Box hat nie davon erfahren. Laden
liessen sich nur die Modelle aus der Basis-Config der Box.

Gefunden, weil grep rvs/server.js fuer eine Binaerdatei hielt und still
uebersprang: in Zeile 164 stand seit a7998bcd ein ECHTES Nullbyte im
Template-String statt eines Escapes. Node stoert das nicht, aber git zeigte
fuer jede Aenderung am RVS nur noch "Binary files differ". Jetzt \u0000 —
zur Laufzeit derselbe String.

Fortschritt: llama.cpp zieht das GGUF per -hf selbst und meldet nichts. Der
llm-adapter fragt vorab bei Hugging Face, welche Dateien (Groesse, sha256)
kommen, und misst genau diese im HF-Cache-Layout, das llama.cpp verwendet:
  /models/models--org--repo/blobs/<sha256>.downloadInProgress  (waechst)
  /models/models--org--repo/blobs/<sha256>                     (fertig)
Layout gegen den echten llama.cpp-Server ausprobiert, nicht angenommen. Geht
auch bei gesplitteten Modellen und zwei Downloads gleichzeitig; Fallback fuer
aeltere Layouts ueber alle *.downloadInProgress. Phasen: queued → start →
download (%, MB/s, Restzeit) → load ("laedt in die Karte") → ready ("in Karte
N geladen", Name + VRAM aus nvidia-smi) bzw. error.

Mitgefixt: der Warmup wartete LLM_TIMEOUT_SEC (Default 60 s). Ein Modell mit
mehreren GB meldete deshalb "Fehler", waehrend llama-swap weiterlud. Jetzt
LLM_WARMUP_TIMEOUT_S (1900 s, ueber llama-swaps healthCheckTimeout).

Diagnostic: Knopf "⬇ Downloads" neben "Von HuggingFace aktualisieren" mit
Zaehler fuer laufende; "Laden" oeffnet das Fenster direkt. Der Server merkt
sich den Stand pro (Box, Modell), ein spaeter geoeffneter Tab sieht laufende
Downloads. Antwortet die Box nicht, steht nach 30 s eine Warnung da statt
nichts.

Compose (KI-Box): llm-adapter bekommt LLM_GPU (Karte fuer die Meldung) und
HF_TOKEN (Dateiliste gesperrter Repos).

Test: Fortschritt gegen einen echten llama.cpp-Download (Qwen2.5-0.5B Q8,
676 MB): Prozent steigend 0 → 91.6, dann load; zweiter Lauf aus dem Cache
direkt load mit cached=true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 15:33:54 +02:00
co-authored by Claude Opus 5.5
parent d684bbb5f0
commit 864237ff2c
6 changed files with 342 additions and 7 deletions
+5
View File
@@ -178,6 +178,11 @@ services:
- LLM_REGISTRY=/models/aria_models.json
# Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig.
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
# Auf welche Karte llama-swap gepinnt ist — fuer die Meldung „in Karte N
# geladen" samt VRAM nach dem Laden. Dieselbe Variable wie bei llama-swap.
- LLM_GPU=${LLM_GPU:-0}
# Fuer gesperrte (gated) HF-Repos: die Dateiliste fuer den Fortschritt.
- HF_TOKEN=${HF_TOKEN:-}
restart: unless-stopped
# ─── OpenHands (optional, Standalone) ────────────────
+157 -5
View File
@@ -238,7 +238,7 @@ async def _send(ws, mtype: str, payload: dict) -> None:
async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
stop, tools=None, model=None) -> dict:
stop, tools=None, model=None, timeout: float | None = None) -> dict:
"""Ruft llama.cpp/llama-swap /v1/chat/completions (OpenAI-Format). Gibt
{ok, content, tool_calls, error} zurueck — wirft nie.
@@ -263,7 +263,7 @@ async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
# weiter. Qwen3 unterdrueckt damit den <think>-Block.
body["chat_template_kwargs"] = {"enable_thinking": False}
try:
async with httpx.AsyncClient(timeout=LLM_TIMEOUT_SEC) as client:
async with httpx.AsyncClient(timeout=timeout or LLM_TIMEOUT_SEC) as client:
r = await client.post(f"{LLAMA_URL}/v1/chat/completions", json=body)
r.raise_for_status()
data = r.json()
@@ -454,6 +454,141 @@ async def _handle_openai_config(ws, payload: dict) -> None:
await _announce(ws)
# ── Download-Fortschritt beim Modell-Laden ─────────────────────────────
# llama.cpp zieht das GGUF per -hf selbst und meldet keinen Fortschritt. Wir
# messen von aussen: Hugging Face sagt vorab, welche Dateien kommen (Name,
# Groesse, sha256). llama.cpp legt sie im HF-Cache-Layout ab —
# /models/models--org--repo/blobs/<sha256>.downloadInProgress (waechst)
# /models/models--org--repo/blobs/<sha256> (fertig)
# Genau diese Dateien werden gemessen. Das klappt auch bei gesplitteten
# Modellen und bei zwei Downloads gleichzeitig. Fallback fuer aeltere
# llama.cpp-Layouts: alle *.downloadInProgress unter /models.
MODELS_DIR = os.getenv("LLAMA_CACHE_DIR", "/models")
HF_TOKEN = os.getenv("HF_TOKEN", "").strip()
LLM_GPU = os.getenv("LLM_GPU", "").strip() # Karte, auf die llama-swap gepinnt ist
WARMUP_TIMEOUT_S = float(os.getenv("LLM_WARMUP_TIMEOUT_S", "1900")) # > healthCheckTimeout
_INPROGRESS = ".downloadInProgress"
_SPLIT_RE = re.compile(r"-(\d{5})-of-(\d{5})\.gguf$", re.IGNORECASE)
async def _hf_expected_files(repo: str, quant: str) -> list:
"""Welche GGUF-Dateien zieht 'repo:quant'? [{path, oid, size}]. Fail-soft: []."""
url = f"https://huggingface.co/api/models/{repo}/tree/main?recursive=1"
headers = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {}
try:
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
r = await c.get(url, headers=headers)
r.raise_for_status()
entries = r.json()
except Exception as e:
logger.warning("HF-Dateiliste fuer %s nicht abrufbar (%s) — Fortschritt ohne Gesamtgroesse", repo, e)
return []
q = (quant or "").lower()
files = []
for e in entries if isinstance(entries, list) else []:
path = e.get("path") or ""
low = path.lower()
if e.get("type") != "file" or not low.endswith(".gguf") or "mmproj" in low:
continue
if q and q not in low:
continue
lfs = e.get("lfs") or {}
files.append({"path": path, "oid": lfs.get("oid") or e.get("oid") or "",
"size": int(lfs.get("size") or e.get("size") or 0)})
if not files:
return []
# Gesplittetes Modell → alle Teile; sonst die eine passende Datei (bei
# mehreren Treffern die mit dem kuerzesten Namen, z.B. ohne "-imat").
split = [f for f in files if _SPLIT_RE.search(f["path"])]
if split:
return split
return [min(files, key=lambda f: len(f["path"]))]
def _scan_download(oids: set) -> tuple:
"""(bytes_da, laeuft_noch, gefunden) fuer die erwarteten Blobs."""
done, active, found = 0, False, 0
fallback, fallback_active = 0, False
for root, _dirs, fns in os.walk(MODELS_DIR):
for fn in fns:
inprog = fn.endswith(_INPROGRESS)
base = fn[:-len(_INPROGRESS)] if inprog else fn
try:
size = os.path.getsize(os.path.join(root, fn))
except OSError:
continue
if base in oids:
done += size
found += 1
active |= inprog
elif inprog:
fallback += size
fallback_active = True
if not found and fallback_active:
return fallback, True, 0
return done, active, found
async def _gpu_info() -> dict:
"""Name + VRAM der LLM-Karte nach dem Laden (fuer 'in Karte N geladen')."""
if not LLM_GPU:
return {}
try:
proc = await asyncio.create_subprocess_exec(
"nvidia-smi", f"--id={LLM_GPU}",
"--query-gpu=index,name,memory.used,memory.total", "--format=csv,noheader,nounits",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
out, _ = await asyncio.wait_for(proc.communicate(), 10)
idx, name, used, total = [x.strip() for x in out.decode().strip().split(",")[:4]]
return {"gpu": idx, "gpuName": name, "vramUsedMB": int(used), "vramTotalMB": int(total)}
except Exception as e:
logger.info("GPU-Info nicht verfuegbar: %s", e)
return {"gpu": LLM_GPU}
async def _emit_progress(ws, key: str, phase: str, **extra) -> None:
await _send(ws, "llm_provision_progress",
{"instanceId": INSTANCE_ID, "node": NODE_NAME, "key": key,
"phase": phase, "ts": int(time.time() * 1000), **extra})
async def _watch_download(ws, key: str, files: list, stop: asyncio.Event) -> None:
"""Meldet jede Sekunde den Download-Stand, bis der Warmup fertig ist.
Ist alles da, wechselt die Phase auf 'load' (llama.cpp laedt in die Karte)."""
oids = {f["oid"] for f in files if f.get("oid")}
total = sum(f["size"] for f in files)
phase = ""
downloaded = False # gab es einen echten Download? sonst: lag schon im Cache
last_b, last_t, speed = None, time.time(), 0.0
while not stop.is_set():
b, active, found = await asyncio.to_thread(_scan_download, oids)
now = time.time()
if last_b is not None and now > last_t and b >= last_b:
inst = (b - last_b) / (now - last_t)
speed = inst if speed == 0 else 0.7 * speed + 0.3 * inst
last_b, last_t = b, now
if active:
downloaded = True
phase = "download"
pct = round(min(100.0, 100.0 * b / total), 1) if total else None
eta = int(max(0, total - b) / speed) if total and speed > 1 else None
await _emit_progress(ws, key, "download", bytes=b, total=total, percent=pct,
speedBps=int(speed), etaSeconds=eta)
else:
# Nichts laeuft mehr: entweder fertig geladen (alle Blobs da) oder
# es lag schon im Cache. Im Notfallmodus (keine HF-Liste) zaehlt
# nur: es lief ein Download, und jetzt nicht mehr.
all_there = bool(oids) and found >= len(oids)
if phase != "load" and (all_there or downloaded):
phase = "load"
await _emit_progress(ws, key, "load", bytes=total or b, total=total,
percent=100, cached=not downloaded)
try:
await asyncio.wait_for(stop.wait(), 1.0)
except asyncio.TimeoutError:
pass
async def _handle_provision(ws, payload: dict) -> None:
"""Fuegt ein Modell hinzu: Registry+Config schreiben, reload, dann Warmup
(zieht das GGUF via -hf beim ersten Load). Meldet die neue Modell-Liste."""
@@ -475,19 +610,36 @@ async def _handle_provision(ws, payload: dict) -> None:
_generate_config()
await _reload_llama()
await _announce(ws) # Bridge/Diagnostic lernen das neue Modell
# Warmup: Mini-Request → llama-swap laedt/zieht das Modell (Fortschritt via
# service_status loading→ready, freshlyDownloaded).
# Warmup: Mini-Request → llama-swap zieht das GGUF (falls noch nicht da)
# und laedt es in die Karte. Daneben misst _watch_download den Download.
files = await _hf_expected_files(repo, entry["quant"])
await _emit_progress(ws, key, "start", total=sum(f["size"] for f in files),
files=len(files), gpu=LLM_GPU)
await _emit_llm_status(ws, "loading", key)
stop = asyncio.Event()
watcher = asyncio.create_task(_watch_download(ws, key, files, stop))
t0 = time.time()
# Langer Timeout: ein GGUF mit mehreren GB braucht Minuten. Mit dem normalen
# LLM_TIMEOUT_SEC (60 s) meldete das hier "Fehler", waehrend llama-swap im
# Hintergrund munter weiterlud.
res = await _call_llama([{"role": "user", "content": "hi"}],
max_tokens=1, temperature=0.0, stop=None, model=key)
max_tokens=1, temperature=0.0, stop=None, model=key,
timeout=WARMUP_TIMEOUT_S)
dt = time.time() - t0
stop.set()
try:
await watcher
except Exception:
pass
if res.get("ok"):
_ready_models.add(key)
await _emit_llm_status(ws, "ready", key, loadSeconds=round(dt, 1),
freshlyDownloaded=dt > 25)
await _emit_progress(ws, key, "ready", percent=100, loadSeconds=round(dt, 1),
**(await _gpu_info()))
else:
await _emit_llm_status(ws, "error", key, error=(res.get("error") or "")[:160])
await _emit_progress(ws, key, "error", error=(res.get("error") or "")[:200])
await _send(ws, "llm_provision_result",
{"instanceId": INSTANCE_ID, "key": key, "ok": res.get("ok", False),
"error": res.get("error"), "elapsedMs": int(dt * 1000)})