"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups.""" import psutil from fastapi import APIRouter, HTTPException from pydantic import BaseModel from config import HF_DOWNLOAD_ENV, MODELS_DIR from services import discover, hf, jobengine, llamaswap from services.fit import evaluate_fit, extract_params_b, max_ctx_for 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)} @router.get("/fit") def fit(params_b: float, quant: str = "Q4_K_M", ctx: int = 8192, name: str = "") -> dict: ram = _ram_gb() return { "fit": evaluate_fit(params_b, quant, ctx, ram, name=name), "optimal_ctx": max_ctx_for(params_b, quant, ram), "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: 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} class InstallReq(BaseModel): repo: str role: str | None = None quant: str = "Q4_K_M" ctx: int | None = None jinja: bool = False hf_token: str | None = None @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: ram = _ram_gb() ctx = max_ctx_for(extract_params_b(repo), req.quant, ram) # Sofort registrieren (Datei kommt gleich) — robust gegen -watch-config. try: model_id = llamaswap.register_model( model_path, role=req.role, ctx=ctx, mmproj_path=mmproj_path, jinja=req.jinja) except PermissionError as exc: raise HTTPException(500, str(exc)) # Download-Job: alle GGUF-Teile (+ mmproj) per --include holen. args = [hf.hf_bin(), "download", repo] for f in info["files"]: args.append(f) if info["mmproj"]: args.append(info["mmproj"]) args += ["--local-dir", str(target)] env = dict(HF_DOWNLOAD_ENV) if req.hf_token: env["HF_TOKEN"] = req.hf_token job_id = jobengine.start_job(args, f"download {req.repo}", env=env) 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: if not llamaswap.set_role(model_id, body.role): raise HTTPException(404, "Modell nicht gefunden") return {"ok": True} class CtxReq(BaseModel): ctx: int @router.post("/models/{model_id}/ctx") def set_model_ctx(model_id: str, body: CtxReq) -> dict: if not llamaswap.set_ctx(model_id, body.ctx): raise HTTPException(404, "Modell nicht gefunden") return {"ok": True} @router.get("/models/drafts") def list_drafts(target: str = "") -> dict: """Verfügbare Draft-Modelle + ihre Vocab-Kompatibilität zum Ziel-Modell (target = GGUF-Pfad). Basis für die idiotensichere Spec-Draft-Auswahl im UI.""" return llamaswap.drafts_for(target) class DraftReq(BaseModel): draft_path: str | None = None @router.post("/models/{model_id}/draft") def set_model_draft(model_id: str, body: DraftReq) -> dict: """Setzt/entfernt den Speculative-Decoding-Draft eines Modells. Inkompatible (oder nicht prüfbare) Drafts werden serverseitig abgelehnt.""" try: res = llamaswap.set_spec_draft(model_id, body.draft_path) except PermissionError as exc: raise HTTPException(500, str(exc)) if not res["ok"]: raise HTTPException(400 if "kompatib" in res["reason"].lower() else 404, res["reason"]) return res @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} @router.get("/groups") def groups() -> dict: return {"groups": llamaswap.list_groups()} class GroupReq(BaseModel): group: str members: list[str] swap: bool = False persist: bool = False @router.put("/groups") def set_group(req: GroupReq) -> dict: try: llamaswap.set_group(req.group, req.members, swap=req.swap, persist=req.persist) except PermissionError as exc: raise HTTPException(500, str(exc)) return {"ok": True}