feat(admin): Backend-Wechsel + Gateway-Neustart aus dem Admin-Panel — Plan-Schritt 3
- admin_llm: switch_backend() mit strikter Allowlist (backend ∈ {ollama,llamacpp},
Modell gegen 'ollama list' + Format-Regex), detached (Self-Restart-sicher),
niemals shell=True. restart_gateway_detached() für systemd-User-Dienst.
- switch-llm.sh: flock-Lock gegen parallele Backend-Wechsel (Exit 75).
- Endpunkte POST /api/admin/llm/backend (422 bei ungültig) und
POST /api/admin/gateway/restart (require_admin).
- Status-Tab: Steuerung (Backend-Dropdown + Modell, Wechseln/Neustart) mit
Poll bis das Gateway wieder antwortet; Hinweis auf systemd-Voraussetzung.
- Tests: Auth + Allowlist (Shell-Metazeichen/unbekanntes Modell -> 422). 165 grün.
- Doku §7.5.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
97ae0a5d34
commit
5b6f0f8ef2
7 changed files with 219 additions and 6 deletions
|
|
@ -8,10 +8,18 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
|
||||
ALLOWED_BACKENDS = {"ollama", "llamacpp"}
|
||||
_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "llm-server" / "switch-llm.sh"
|
||||
# Erlaubtes Format fuer Ollama-Modellnamen (zusaetzlich zur Pruefung gegen 'ollama list').
|
||||
_MODEL_RE = re.compile(r"^[A-Za-z0-9._:/-]{1,100}$")
|
||||
|
||||
|
||||
async def _run(cmd: list[str], timeout: float = 6.0) -> str | None:
|
||||
"""Fuehrt ein Kommando aus (shell=False) und liefert stdout, oder None bei Fehler."""
|
||||
|
|
@ -113,3 +121,69 @@ async def llm_status() -> dict:
|
|||
"gpus": gpus,
|
||||
"gateway_service_active": gw,
|
||||
}
|
||||
|
||||
|
||||
# ── Schreibende Steuerung (Backend-Wechsel / Gateway-Neustart) ───────────────
|
||||
|
||||
class LlmControlError(ValueError):
|
||||
"""Validierungs-/Steuerfehler -> wird vom Endpoint als 400/422 gemeldet."""
|
||||
|
||||
|
||||
async def available_ollama_models() -> list[str]:
|
||||
"""Modellnamen aus `ollama list` (erste Spalte), oder []."""
|
||||
out = await _run(["ollama", "list"])
|
||||
if out is None:
|
||||
return []
|
||||
names: list[str] = []
|
||||
for ln in out.splitlines()[1:]:
|
||||
ln = ln.strip()
|
||||
if ln:
|
||||
names.append(ln.split()[0])
|
||||
return names
|
||||
|
||||
|
||||
async def switch_backend(backend: str, model: str | None = None) -> dict:
|
||||
"""Validiert streng und startet den Backend-Wechsel als losgelösten Prozess.
|
||||
|
||||
Allowlist: backend ∈ {ollama, llamacpp}. Bei ollama muss `model` (falls gesetzt)
|
||||
exakt in `ollama list` vorkommen. Nie freie Strings an die Shell — Aufruf mit
|
||||
fester Argumentliste (shell=False); das Modell geht ausschließlich als Env-Var.
|
||||
Der Wechsel läuft detached weiter (er startet ggf. das Gateway neu).
|
||||
"""
|
||||
if backend not in ALLOWED_BACKENDS:
|
||||
raise LlmControlError(f"Unbekanntes Backend: {backend!r}")
|
||||
if not _SCRIPT.exists():
|
||||
raise LlmControlError("switch-llm.sh nicht gefunden")
|
||||
|
||||
env = {**os.environ}
|
||||
if backend == "ollama" and model:
|
||||
if not _MODEL_RE.match(model):
|
||||
raise LlmControlError("Ungültiger Modellname")
|
||||
available = await available_ollama_models()
|
||||
if available and model not in available:
|
||||
raise LlmControlError(f"Modell nicht in 'ollama list': {model!r}")
|
||||
env["OLLAMA_MODEL"] = model
|
||||
|
||||
# Detached starten: der Wechsel kann das Gateway neu starten -> wir würden uns
|
||||
# sonst selbst killen, bevor die HTTP-Antwort raus ist.
|
||||
subprocess.Popen(
|
||||
["bash", str(_SCRIPT), backend],
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
return {"status": "switching", "backend": backend, "model": model}
|
||||
|
||||
|
||||
def restart_gateway_detached() -> dict:
|
||||
"""Startet das Gateway als systemd-User-Dienst neu (losgelöst, Self-Restart-sicher)."""
|
||||
xdg = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
|
||||
subprocess.Popen(
|
||||
["bash", "-c",
|
||||
f"sleep 1; XDG_RUNTIME_DIR={xdg} systemctl --user restart voice-assistant.service"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
return {"status": "restarting"}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue