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:
@@ -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
@@ -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)})
|
||||
|
||||
Reference in New Issue
Block a user