"""Modelle-Endpoints: Liste, Discover, Suche und Installation bei Hugging Face, Jobs, Rollen, Laden/Entladen/Löschen. Fit-, Kontext-, Draft- und Gruppen-Routen sind am 24.09.2026 entfallen (ohne Nutzer seit dem Box-Wart-Umbau).""" import psutil from config import HF_DOWNLOAD_ENV, MODELS_DIR from fastapi import APIRouter, HTTPException from pydantic import BaseModel from services import budget, discover, geheimnisse, hf, jobengine, llamaswap router = APIRouter(prefix="/api") def _ram_gb() -> float: return psutil.virtual_memory().total / (1024 ** 3) @router.get("/models") def models() -> dict: items = llamaswap.list_models() return {"models": items, "count": len(items), "running": llamaswap.get_running_models()} @router.get("/discover") def discover_models(force: bool = False) -> dict: ram = _ram_gb() data = discover.refresh_discover(ram) if force else discover.safe_discover(ram) if not data: raise HTTPException(502, "Modell-Quellen gerade nicht erreichbar — später erneut.") return {**data, "sys_ram_gb": round(ram, 1)} class RegisterReq(BaseModel): model_path: str role: str | None = None ctx: int = 8192 ttl: int | None = None mmproj_path: str | None = None jinja: bool = False @router.post("/models/register") def register(req: RegisterReq) -> dict: # Nur Dateien aus dem Modellordner (24.09.2026): der Pfad landet im Startbefehl der Engine. for pfad in (req.model_path, req.mmproj_path): if pfad and not llamaswap.im_modellordner(pfad): raise HTTPException(400, f"Pfad liegt nicht im Modellordner ({MODELS_DIR}): {pfad}") try: model_id = llamaswap.register_model( req.model_path, role=req.role, ctx=req.ctx, ttl=req.ttl, mmproj_path=req.mmproj_path, jinja=req.jinja, ) except PermissionError as exc: raise HTTPException(500, str(exc)) return {"ok": True, "model_id": model_id} @jobengine.nacharbeit("modell:rolle") def _rolle_uebernehmen(model_id: str, role: str) -> None: """Nach dem Download die gewünschte Rolle setzen. hermes geht dabei durch den warm-bewussten Hirn-Flow (brains-Gruppe, ttl 0, Hermes-Config, Gateway-Restart) — der rohe Alias-Move hatte diesen Flow bisher umgangen.""" if role == "hermes": from services.agent import set_agent_brain res = set_agent_brain(model_id) if not res.get("ok"): raise RuntimeError(res.get("reason", "Hirn-Wechsel fehlgeschlagen")) else: llamaswap.set_role(model_id, role) class InstallReq(BaseModel): repo: str role: str | None = None quant: str = "Q4_K_M" ctx: int | None = None jinja: bool = False # Kein hf_token mehr im Request (v3-Umbau P1): der Token lag frueher im localStorage # des Browsers und reiste hier mit. Jetzt liegt er auf der Box (services.geheimnisse) # und wird unten von dort gelesen — die Oberflaeche sieht ihn nie wieder. @router.get("/hf/search") def hf_search(q: str = "") -> dict: return {"results": hf.search(q)} @router.get("/hf/quants") def hf_quants(repo: str) -> dict: repo = hf.normalize_repo(repo) return {"repo": repo, "quants": hf.list_quants(repo)} @router.post("/models/install") def install(req: InstallReq) -> dict: """Lädt ein Modell von HuggingFace (Hintergrund-Job) UND trägt es sofort in llama-swap ein (cmd + Rolle-Alias). llama-swap (-watch-config) lädt es, sobald die Datei da ist. Split-GGUFs werden komplett geladen, registriert wird der erste Teil (-00001-of-…). Akzeptiert volle HF-URL ODER org/repo.""" repo = hf.normalize_repo(req.repo) info = hf.resolve_gguf(repo, req.quant) if not info["first"]: raise HTTPException(404, f"Keine GGUF-Datei für Quant '{req.quant}' in {repo} gefunden.") subdir = repo.split("/")[-1] target = MODELS_DIR / subdir target.mkdir(parents=True, exist_ok=True) model_path = str(target / info["first"]) mmproj_path = str(target / info["mmproj"]) if info["mmproj"] else None ctx = req.ctx if ctx is None: # SETUP-BEWUSST: größter ctx, der neben Hirn/warmem Set passt (nicht nur Modell allein). ctx = budget.setup_aware_ctx(budget.params_b_for(repo), req.quant, role=req.role)["ctx"] # Sofort registrieren (robust gegen -watch-config) — aber OHNE den Rollen-Alias # umzuhängen: der zeigte sonst minutenlang auf eine noch ladende Datei (Lane kalt; # bei role=hermes wäre Lucy bis Download-Ende tot gewesen — Review 16.07.). try: model_id = llamaswap.register_model( model_path, role=req.role, ctx=ctx, mmproj_path=mmproj_path, jinja=req.jinja, set_alias=False) except PermissionError as exc: raise HTTPException(500, str(exc)) # Rolle erst NACH erfolgreichem Download übernehmen (_rolle_uebernehmen). role = (req.role or "").strip().lower() # Download-Job: alle GGUF-Teile (+ mmproj) per --include holen. args = [hf.hf_bin(), "download", repo] args.extend(info["files"]) if info["mmproj"]: args.append(info["mmproj"]) args += ["--local-dir", str(target)] env = dict(HF_DOWNLOAD_ENV) if token := geheimnisse.hf_token(): env["HF_TOKEN"] = token # Gruppe „download“: so taucht der Download in der Oberfläche mit Fortschritt und Abbrechen auf. # Der Download läuft als eigener Auftrag weiter, auch wenn MC2 zwischendurch neu startet. job_id = jobengine.start_job(args, f"Download {repo}", env=env, group="download", zeitlimit_s=6 * 3600, nacharbeit="modell:rolle" if role else None, nacharbeit_daten={"model_id": model_id, "role": role} if role else None) jobengine.attach_download_progress(job_id, str(target), info["total_bytes"]) return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path, "total_bytes": info["total_bytes"], "files": len(info["files"])} @router.get("/jobs") def jobs() -> dict: return {"jobs": jobengine.public_jobs()} @router.post("/jobs/{job_id}/cancel") def cancel(job_id: str) -> dict: return {"ok": jobengine.cancel_job(job_id)} class RoleReq(BaseModel): role: str | None = None @router.post("/models/{model_id}/role") def set_model_role(model_id: str, body: RoleReq) -> dict: new_role = (body.role or "").strip().lower() # Guard: Hält das Modell einen LEBENSWICHTIGEN Alias (hermes/embed), darf die Rolle # hier nicht weggeklickt werden — sonst verliert Lucy Hirn/Gedächtnis mit einem Klick. # Weg: die geschützte Rolle zuerst einem ANDEREN Modell zuweisen (Alias zieht um). prot = llamaswap.protected_alias_of(model_id) if prot and new_role != prot: raise HTTPException(400, f"Dieses Modell hält die lebenswichtige Rolle '{prot}'. Weise '{prot}' zuerst " f"einem anderen Modell zu — danach lässt sich die Rolle hier ändern.") # Das Agent-Hirn (Rolle 'hermes') braucht den warm-bewussten Flow (Alias + brains-Gruppe + # ttl 0 + Hermes config.default + Gateway-Restart) — Single Source of Truth UI ↔ Hermes. if new_role == "hermes": from services.agent import set_agent_brain res = set_agent_brain(model_id) if not res.get("ok"): raise HTTPException(400, res.get("reason", "Fehler beim Setzen des Agent-Hirns")) return res if not llamaswap.set_role(model_id, body.role): raise HTTPException(404, "Modell nicht gefunden") return {"ok": True} @router.post("/models/unload") def unload_all_models() -> dict: import httpx from config import LLAMA_SWAP_URL try: with httpx.Client(timeout=10.0) as c: r = c.post(f"{LLAMA_SWAP_URL}/api/models/unload") return {"ok": r.status_code == 200} except Exception as exc: raise HTTPException(500, str(exc)) @router.post("/models/{model_id}/unload") def unload_model(model_id: str) -> dict: import httpx from config import LLAMA_SWAP_URL try: with httpx.Client(timeout=10.0) as c: r = c.post(f"{LLAMA_SWAP_URL}/api/models/unload/{model_id}") return {"ok": r.status_code == 200} except Exception as exc: raise HTTPException(500, str(exc)) @router.post("/models/{model_id}/load") def load_model(model_id: str) -> dict: import httpx from config import LLAMA_SWAP_URL try: # Trigger load by sending a lightweight completion request. body = { "model": model_id, "messages": [{"role": "user", "content": "ping"}], "max_tokens": 1 } # High timeout because model loading might take time with httpx.Client(timeout=60.0) as c: c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=body) return {"ok": True} except Exception as exc: raise HTTPException(500, str(exc)) @router.delete("/models/{model_id}") def delete(model_id: str) -> dict: if not llamaswap.delete_model(model_id): raise HTTPException(404, "Modell nicht gefunden") return {"ok": True}