feat(llm): Modell-Download + HuggingFace-Katalog (Stage D)
Neue lokale GGUF-Modelle per Knopf auf eine Box laden — ohne Image-Rebuild. - llm-adapter besitzt jetzt llama-swaps Config: generiert /models/llama-swap.config.yaml aus Basis-Template (xtts/llama-swap/config.yaml) + persistenter Registry /models/aria_models.json. Neue RVS-Handler llm_provision_model / llm_remove_model (targetInstance-gefiltert): Registry+ Config schreiben, llama-swap-Reload anstossen, neu announcen, Warmup (zieht das GGUF via -hf, Fortschritt via service_status loading→ready). pyyaml ergaenzt. - compose: llama-swap liest --config /models/llama-swap.config.yaml; llm-adapter mountet ./models (rw) + ./llama-swap (ro Template). - diagnostic/server.js: /shared/config/llm_catalog.json (kuratierte GGUF-Liste) + GET /api/llm-catalog + POST /api/llm-catalog/refresh (HuggingFace-API-Merge); Actions llm_provision_model / llm_remove_model / llm_test; llm_provision_result an Browser durchgereicht. - diagnostic/index.html: "Modell-Katalog"-Card (HF-Refresh, Ziel-Box waehlen, Laden, Verfuegbarkeit) + Test-Chat-Zeile ans lokale LLM (Antwort + Latenz). Download nutzt llama-swaps vorhandenen -hf-Pfad (kein neuer Download-Code). Reload ist der einzige Deploy-Verify-Punkt (llama-swap-Image); Fallback Box-up. Deploy: diagnostic neu bauen (VM) + llm-Boxen neu bauen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+14
-5
@@ -123,31 +123,37 @@ services:
|
||||
# bestimmt das `model`-Feld im Request (Brain schickt es aus local_llm.json).
|
||||
# Erster Load zieht das GGUF via -hf von HF (Cache unter /models, persistent).
|
||||
# OpenAI-kompatibel auf :8080, nur im Compose-Netz; die Bruecke macht der
|
||||
# llm-adapter. Modell-Liste: ./llama-swap/config.yaml.
|
||||
# llm-adapter. Die Modell-Liste erzeugt der llm-adapter dynamisch aus
|
||||
# ./llama-swap/config.yaml (Basis) + Registry → /models/llama-swap.config.yaml.
|
||||
llama-swap:
|
||||
image: ghcr.io/mostlygeek/llama-swap:unified-cuda
|
||||
container_name: aria-llama-swap
|
||||
profiles: ["llm"] # startet nur mit COMPOSE_PROFILES=…llm…
|
||||
runtime: nvidia
|
||||
volumes:
|
||||
- ./models:/models # HF-Download-Cache (persistent)
|
||||
- ./llama-swap/config.yaml:/app/config.yaml:ro # Modell-Liste
|
||||
- ./models:/models # HF-Cache + generierte Config
|
||||
environment:
|
||||
- NVIDIA_VISIBLE_DEVICES=${LLM_GPU:-0}
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
- LLAMA_CACHE=/models # llama-server legt -hf-Downloads hier ab
|
||||
command: ["--config", "/app/config.yaml", "--listen", "0.0.0.0:8080"]
|
||||
# Liest die vom llm-adapter generierte Config. Beim allerersten Boot faengt
|
||||
# restart: unless-stopped die Reihenfolge ab, bis der Adapter sie geschrieben hat.
|
||||
command: ["--config", "/models/llama-swap.config.yaml", "--listen", "0.0.0.0:8080"]
|
||||
restart: unless-stopped
|
||||
|
||||
# ─── Local-LLM-Adapter — RVS <-> llama.cpp ────
|
||||
# Verbindet sich per Token an den RVS (wie f5tts/whisper), nimmt llm_request
|
||||
# entgegen, ruft llama.cpp lokal, antwortet llm_response.
|
||||
# entgegen, ruft llama.cpp lokal, antwortet llm_response. Verwaltet ausserdem
|
||||
# llama-swaps Config (Modelle hinzufuegen/entfernen via llm_provision_model).
|
||||
llm-adapter:
|
||||
build: ./llm-adapter
|
||||
container_name: aria-llm-adapter
|
||||
profiles: ["llm"]
|
||||
depends_on:
|
||||
- llama-swap
|
||||
volumes:
|
||||
- ./models:/models # generierte Config + Registry + Cache
|
||||
- ./llama-swap:/llamaswap:ro # Basis-Template (config.yaml)
|
||||
environment:
|
||||
- NODE_NAME=${NODE_NAME:-node}
|
||||
- RVS_HOST=${RVS_HOST}
|
||||
@@ -157,6 +163,9 @@ services:
|
||||
- RVS_TOKEN=${RVS_TOKEN}
|
||||
- LLAMA_URL=http://llama-swap:8080
|
||||
- LLM_MODEL=${LLM_MODEL:-qwen3-8b}
|
||||
- LLAMA_BASE_CONFIG=/llamaswap/config.yaml
|
||||
- LLAMA_GEN_CONFIG=/models/llama-swap.config.yaml
|
||||
- LLM_REGISTRY=/models/aria_models.json
|
||||
# Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig.
|
||||
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
|
||||
restart: unless-stopped
|
||||
|
||||
+164
-11
@@ -65,6 +65,87 @@ _inflight = 0 # laufende llm_requests (busy-Report im ping)
|
||||
# empfindlich reagiert: LLM_DISABLE_THINKING=false setzen.
|
||||
LLM_DISABLE_THINKING = os.getenv("LLM_DISABLE_THINKING", "true").lower() == "true"
|
||||
|
||||
# ── Modell-Verwaltung (Stage D): Adapter besitzt llama-swaps Config ──
|
||||
# llama-swap liest die GENERIERTE Config (beschreibbar, im /models-Bind). Wir
|
||||
# erzeugen sie aus dem Basis-Template (kuratierte Defaults) + der persistenten
|
||||
# Box-Registry (per Diagnostic hinzugefuegte Modelle). So werden neue Modelle
|
||||
# ohne Image-Rebuild waehlbar.
|
||||
import yaml # pyyaml
|
||||
BASE_CONFIG_PATH = os.getenv("LLAMA_BASE_CONFIG", "/llamaswap/config.yaml")
|
||||
GEN_CONFIG_PATH = os.getenv("LLAMA_GEN_CONFIG", "/models/llama-swap.config.yaml")
|
||||
REGISTRY_PATH = os.getenv("LLM_REGISTRY", "/models/aria_models.json")
|
||||
|
||||
|
||||
def _load_registry() -> list:
|
||||
try:
|
||||
with open(REGISTRY_PATH) as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _save_registry(reg: list) -> None:
|
||||
try:
|
||||
tmp = REGISTRY_PATH + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(reg, f, indent=2)
|
||||
os.replace(tmp, REGISTRY_PATH)
|
||||
except Exception as e:
|
||||
logger.warning("Registry speichern fehlgeschlagen: %s", e)
|
||||
|
||||
|
||||
def _generate_config() -> int:
|
||||
"""Schreibt die llama-swap-Config aus Basis-Template + Registry. Gibt die
|
||||
Anzahl Modelle zurueck. Idempotent, bei jeder Aenderung + beim Start."""
|
||||
base = {}
|
||||
try:
|
||||
with open(BASE_CONFIG_PATH) as f:
|
||||
base = yaml.safe_load(f) or {}
|
||||
except Exception as e:
|
||||
logger.warning("Basis-Template %s nicht lesbar (%s)", BASE_CONFIG_PATH, e)
|
||||
models = dict(base.get("models") or {})
|
||||
for e in _load_registry():
|
||||
key = (e.get("key") or "").strip()
|
||||
repo = (e.get("hfRepo") or "").strip()
|
||||
if not key or not repo:
|
||||
continue
|
||||
quant = (e.get("quant") or "Q4_K_M").strip()
|
||||
ctx = int(e.get("ctx") or 8192)
|
||||
ngl = int(e.get("ngl") or 99)
|
||||
models[key] = {
|
||||
"cmd": (f"llama-server --port ${{PORT}} --host 127.0.0.1\n"
|
||||
f"-hf {repo}:{quant}\n-ngl {ngl} -c {ctx} --jinja"),
|
||||
"ttl": 3600,
|
||||
}
|
||||
out = dict(base)
|
||||
out["models"] = models
|
||||
try:
|
||||
os.makedirs(os.path.dirname(GEN_CONFIG_PATH), exist_ok=True)
|
||||
tmp = GEN_CONFIG_PATH + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
yaml.safe_dump(out, f, sort_keys=False, default_flow_style=False)
|
||||
os.replace(tmp, GEN_CONFIG_PATH)
|
||||
logger.info("llama-swap-Config generiert: %d Modelle → %s", len(models), GEN_CONFIG_PATH)
|
||||
except Exception as e:
|
||||
logger.error("Config schreiben fehlgeschlagen: %s", e)
|
||||
return len(models)
|
||||
|
||||
|
||||
async def _reload_llama() -> None:
|
||||
"""Stoesst llama-swap-Reload an. Viele Builds watchen die Config-Datei ohnehin;
|
||||
zusaetzlich versuchen wir bekannte Reload-Endpunkte (Fehler ignoriert)."""
|
||||
for path in ("/api/config/reload", "/reload"):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.post(f"{LLAMA_URL}{path}")
|
||||
if r.status_code < 400:
|
||||
logger.info("llama-swap reload via %s", path)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("llama-swap reload: kein Endpoint — verlasse mich auf File-Watch")
|
||||
|
||||
|
||||
async def _send(ws, mtype: str, payload: dict) -> None:
|
||||
try:
|
||||
@@ -149,17 +230,23 @@ async def _fetch_available_models() -> list:
|
||||
return [LLM_MODEL]
|
||||
|
||||
|
||||
async def _announce(ws) -> None:
|
||||
"""Sendet ein frisches worker_hello mit der aktuellen Modell-Liste (nach
|
||||
Provision/Remove aufrufen, damit Bridge+Diagnostic das neue Modell lernen)."""
|
||||
models = await _fetch_available_models()
|
||||
await _send(ws, "worker_hello", {
|
||||
"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
|
||||
"node": NODE_NAME, "gpus": GPU_IDS, "model": LLM_MODEL,
|
||||
"models": models, # welche Modelle diese Box fahren kann (llama-swap-Keys)
|
||||
})
|
||||
logger.info("worker_hello: models=%s", models)
|
||||
|
||||
|
||||
async def _worker_register(ws) -> None:
|
||||
"""Meldet diesen Worker bei der aria-bridge an (worker_hello) und haelt die
|
||||
Flotten-Registry per periodischem worker_ping (mit busy-Status) frisch."""
|
||||
try:
|
||||
models = await _fetch_available_models()
|
||||
await _send(ws, "worker_hello", {
|
||||
"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
|
||||
"node": NODE_NAME, "gpus": GPU_IDS, "model": LLM_MODEL,
|
||||
"models": models, # welche Modelle diese Box fahren kann (llama-swap-Keys)
|
||||
})
|
||||
logger.info("worker_hello: models=%s", models)
|
||||
await _announce(ws)
|
||||
while True:
|
||||
await asyncio.sleep(WORKER_PING_INTERVAL_S)
|
||||
await _send(ws, "worker_ping",
|
||||
@@ -232,6 +319,61 @@ async def _do_llm_request(ws, payload: dict) -> None:
|
||||
})
|
||||
|
||||
|
||||
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."""
|
||||
key = (payload.get("key") or "").strip()
|
||||
repo = (payload.get("hfRepo") or "").strip()
|
||||
if not key or not repo:
|
||||
await _send(ws, "llm_provision_result",
|
||||
{"instanceId": INSTANCE_ID, "key": key, "ok": False, "error": "key/hfRepo fehlt"})
|
||||
return
|
||||
entry = {
|
||||
"key": key, "hfRepo": repo,
|
||||
"quant": (payload.get("quant") or "Q4_K_M").strip(),
|
||||
"ctx": int(payload.get("ctx") or 8192),
|
||||
"ngl": int(payload.get("ngl") or 99),
|
||||
}
|
||||
reg = [e for e in _load_registry() if e.get("key") != key]
|
||||
reg.append(entry)
|
||||
_save_registry(reg)
|
||||
_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).
|
||||
await _emit_llm_status(ws, "loading", key)
|
||||
t0 = time.time()
|
||||
res = await _call_llama([{"role": "user", "content": "hi"}],
|
||||
max_tokens=1, temperature=0.0, stop=None, model=key)
|
||||
dt = time.time() - t0
|
||||
if res.get("ok"):
|
||||
_ready_models.add(key)
|
||||
await _emit_llm_status(ws, "ready", key, loadSeconds=round(dt, 1),
|
||||
freshlyDownloaded=dt > 25)
|
||||
else:
|
||||
await _emit_llm_status(ws, "error", key, error=(res.get("error") or "")[:160])
|
||||
await _send(ws, "llm_provision_result",
|
||||
{"instanceId": INSTANCE_ID, "key": key, "ok": res.get("ok", False),
|
||||
"error": res.get("error"), "elapsedMs": int(dt * 1000)})
|
||||
logger.info("provision %s (%s) → ok=%s %.1fs", key, repo, res.get("ok"), dt)
|
||||
|
||||
|
||||
async def _handle_remove(ws, payload: dict) -> None:
|
||||
"""Entfernt ein Modell aus Registry+Config (GGUF bleibt im Cache)."""
|
||||
key = (payload.get("key") or "").strip()
|
||||
if not key:
|
||||
return
|
||||
reg = [e for e in _load_registry() if e.get("key") != key]
|
||||
_save_registry(reg)
|
||||
_generate_config()
|
||||
await _reload_llama()
|
||||
await _announce(ws)
|
||||
await _send(ws, "llm_provision_result",
|
||||
{"instanceId": INSTANCE_ID, "key": key, "ok": True, "removed": True})
|
||||
logger.info("removed model %s", key)
|
||||
|
||||
|
||||
async def _run() -> None:
|
||||
if not RVS_HOST:
|
||||
logger.error("RVS_HOST nicht gesetzt — Abbruch")
|
||||
@@ -240,6 +382,11 @@ async def _run() -> None:
|
||||
logger.error("RVS_TOKEN nicht gesetzt — Abbruch")
|
||||
return
|
||||
|
||||
# llama-swap-Config aus Basis-Template + Registry erzeugen, BEVOR llama-swap
|
||||
# sie braucht (llama-swap restart: unless-stopped faengt die Erst-Boot-
|
||||
# Reihenfolge ab, falls es kurz vor uns startet).
|
||||
_generate_config()
|
||||
|
||||
use_tls = RVS_TLS
|
||||
retry_s = 2
|
||||
tls_fallback_tried = False
|
||||
@@ -262,7 +409,8 @@ async def _run() -> None:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
if msg.get("type") != "llm_request":
|
||||
mtype = msg.get("type")
|
||||
if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model"):
|
||||
continue
|
||||
payload = msg.get("payload", {}) or {}
|
||||
# Redundanz-Routing: gezielt an eine andere Instanz adressiert
|
||||
@@ -270,9 +418,14 @@ async def _run() -> None:
|
||||
tgt = payload.get("targetInstance")
|
||||
if tgt and tgt != INSTANCE_ID:
|
||||
continue
|
||||
# Jede Anfrage nebenlaeufig — llama.cpp serialisiert intern,
|
||||
# aber wir blockieren so nicht den Empfang weiterer Messages.
|
||||
asyncio.create_task(_handle_llm_request(ws, payload))
|
||||
if mtype == "llm_provision_model":
|
||||
asyncio.create_task(_handle_provision(ws, payload))
|
||||
elif mtype == "llm_remove_model":
|
||||
asyncio.create_task(_handle_remove(ws, payload))
|
||||
else:
|
||||
# Jede Anfrage nebenlaeufig — llama.cpp serialisiert intern,
|
||||
# aber wir blockieren so nicht den Empfang weiterer Messages.
|
||||
asyncio.create_task(_handle_llm_request(ws, payload))
|
||||
except Exception as e:
|
||||
logger.warning("RVS-Verbindung verloren/fehlgeschlagen: %s", e)
|
||||
try:
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
websockets>=12.0
|
||||
httpx>=0.27.0
|
||||
pyyaml>=6.0
|
||||
|
||||
Reference in New Issue
Block a user