""" Cookbook Router: Verbindet die HuggingFace API mit der Odysseus-Hardware-Berechnung. """ import httpx import json import os import re from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel import psutil from ruamel.yaml.scalarstring import LiteralScalarString from auth import auth from hw_math import evaluate_fit, max_ctx_for from config import MODELS_DIR, CMD_TEMPLATE, DEFAULT_TTL, HF_DOWNLOAD_ENV, USER_RECIPES_PATH, hf_bin from llamaswap import read_config, write_config from jobengine import start_job, JOBS, attach_download_progress from recipes import RECIPES, UPGRADES router = APIRouter(prefix="/api/cookbook", dependencies=[Depends(auth)]) _FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2} # --------------------------------------------------------------------------- # Eigene Setups (vom Nutzer) — persistente JSON-Datei, gleiches Schema wie RECIPES. # --------------------------------------------------------------------------- def load_user_recipes() -> list: try: if USER_RECIPES_PATH.exists(): return json.loads(USER_RECIPES_PATH.read_text(encoding="utf-8")) or [] except Exception: # noqa: BLE001 pass return [] def save_user_recipes(items: list) -> None: USER_RECIPES_PATH.parent.mkdir(parents=True, exist_ok=True) tmp = USER_RECIPES_PATH.with_name(USER_RECIPES_PATH.name + ".tmp") tmp.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8") os.replace(tmp, USER_RECIPES_PATH) def all_recipes() -> list: """Eingebaute + eigene Setups (eigene tragen 'user': True).""" return list(RECIPES) + load_user_recipes() class AnalyzeRequest(BaseModel): repo_id: str ctx: int = 8192 class EvaluateRequest(BaseModel): params_b: float quant: str ctx: int class InstallRecipeReq(BaseModel): recipe_id: str hf_token: str | None = None class InstallModelReq(BaseModel): repo: str role: str params_b: float quant: str = "Q4_K_M" hf_token: str | None = None class UserRecipeModelReq(BaseModel): repo: str role: str quant: str = "Q4_K_M" name: str | None = None why: str | None = None class UserRecipeReq(BaseModel): title: str desc: str = "" icon: str = "box" models: list[UserRecipeModelReq] def extract_params_b(repo_id: str) -> float: """Extrahiert die Parametergröße (in Milliarden) aus dem Repo-Namen.""" # z.B. Qwen2.5-Coder-32B -> 32 # 8x7B -> 56 (MoE) moe = re.search(r"(\d+)x(\d+(?:\.\d+)?)[bB]", repo_id) if moe: return float(moe.group(1)) * float(moe.group(2)) m = re.search(r"(\d+(?:\.\d+)?)[bB](?![a-zA-Z])", repo_id) if m: return float(m.group(1)) return 7.0 # Fallback def extract_quant(filename: str) -> str: m = re.search(r"(Q\d_[A-Z0-9_]+|IQ\d_[A-Z0-9_]+|FP16|BF16)", filename, re.IGNORECASE) return m.group(1).upper() if m else "Q4_K_M" @router.post("/analyze") async def analyze_repo(req: AnalyzeRequest): """Holt die GGUF Dateien von HuggingFace und berechnet den Hardware-Fit.""" url = f"https://huggingface.co/api/models/{req.repo_id}/tree/main" async with httpx.AsyncClient() as client: try: resp = await client.get(url, timeout=10.0) resp.raise_for_status() tree = resp.json() except Exception as e: raise HTTPException(status_code=500, detail=f"HuggingFace Fehler: {str(e)}") gguf_files = [f["path"] for f in tree if f.get("path", "").endswith(".gguf")] if not gguf_files: return {"files": []} params_b = extract_params_b(req.repo_id) # Ermittle RAM des Systems (da APU = Shared Memory) ram_gb = psutil.virtual_memory().total / (1024**3) results = [] for f in gguf_files: quant = extract_quant(f) fit = evaluate_fit(params_b, quant, req.ctx, ram_gb) # Priority-Score, um den besten Fit an oberste Stelle zu setzen. # "Q4_K_M" ist oft der Sweetspot. priority = 0 if fit["level"] == "perfect": priority += 10 if quant == "Q4_K_M": priority += 5 elif quant.startswith("Q4"): priority += 4 elif quant.startswith("Q5"): priority += 3 results.append({ "filename": f, "quant": quant, "fit": fit, "optimal_ctx": max_ctx_for(params_b, quant, ram_gb), "priority": priority }) # Sortieren: Highest priority first, dann nach tps (schnellste zuerst) results.sort(key=lambda x: (x["priority"], x["fit"]["tps"]), reverse=True) return { "repo": req.repo_id, "params_b": params_b, "sys_ram_gb": round(ram_gb, 1), "files": results } @router.post("/evaluate") def evaluate_single(req: EvaluateRequest): ram_gb = psutil.virtual_memory().total / (1024**3) fit = evaluate_fit(req.params_b, req.quant, req.ctx, ram_gb) fit["optimal_ctx"] = max_ctx_for(req.params_b, req.quant, ram_gb) return fit @router.get("/recipes") def recipes(): """Use-Case-Setups mit Hardware-Fit pro Modell + Stack-Gesamturteil. Da llama-swap nur EIN Modell gleichzeitig lädt, ist das Stack-Urteil der schlechteste (= größte) Einzel-Fit.""" ram_gb = psutil.virtual_memory().total / (1024 ** 3) out = [] for r in all_recipes(): models, worst = [], "perfect" for m in r["models"]: fit = evaluate_fit(m["params_b"], m["quant"], 8192, ram_gb) models.append({**m, "fit": fit, "optimal_ctx": max_ctx_for(m["params_b"], m["quant"], ram_gb)}) if _FIT_ORDER[fit["level"]] > _FIT_ORDER[worst]: worst = fit["level"] out.append({**r, "models": models, "fit_level": worst, "user": bool(r.get("user"))}) # „Beste Wahl": das reichste KURATIERTE Setup, das komplett auf die Hardware passt. fitting = [r for r in out if not r["user"] and r["fit_level"] != "too_tight"] rec = max(fitting, key=lambda r: len(r["models"]), default=None) if fitting else None return {"recipes": out, "sys_ram_gb": round(ram_gb, 1), "recommended_id": rec["id"] if rec else None} @router.post("/user-recipe") def create_user_recipe(req: UserRecipeReq): """Eigenes Cookbook-Setup anlegen. params_b wird aus dem Repo-Namen abgeleitet, damit der Nutzer nur Repo + Rolle + Quant angeben muss; Fit/Kontext kommen wie immer zur Laufzeit.""" if not req.title.strip(): raise HTTPException(400, "Bitte einen Titel angeben.") models = [] for m in req.models: if not m.repo.strip(): continue models.append({ "role": (m.role or "modell").strip(), "name": (m.name or m.repo.split("/")[-1]).strip(), "repo": m.repo.strip(), "params_b": extract_params_b(m.repo), "quant": (m.quant or "Q4_K_M").strip(), "why": (m.why or "").strip(), }) if not models: raise HTTPException(400, "Mindestens ein Modell (Repo) angeben.") items = load_user_recipes() base = "user-" + (re.sub(r"[^a-z0-9]+", "-", req.title.lower()).strip("-") or "setup") rid, n = base, 2 existing = {r.get("id") for r in items} | {r["id"] for r in RECIPES} while rid in existing: rid, n = f"{base}-{n}", n + 1 items.append({"id": rid, "title": req.title.strip(), "icon": (req.icon or "box").strip(), "desc": req.desc.strip(), "models": models, "user": True}) save_user_recipes(items) return {"ok": True, "id": rid} @router.delete("/user-recipe/{recipe_id}") def delete_user_recipe(recipe_id: str): items = load_user_recipes() kept = [r for r in items if r.get("id") != recipe_id] if len(kept) == len(items): raise HTTPException(404, "Eigenes Setup nicht gefunden.") save_user_recipes(kept) return {"ok": True} @router.post("/install-recipe") def install_recipe(req: InstallRecipeReq): """Komplettes Setup installieren: jedes Modell als Download-Job starten UND sofort mit optimalem (gedeckeltem) Kontext in die config.yaml eintragen. llama-swap (-watch-config) übernimmt es, sobald die Datei da ist.""" recipe = next((r for r in all_recipes() if r["id"] == req.recipe_id), None) if not recipe: raise HTTPException(404, "Setup nicht gefunden.") ram_gb = psutil.virtual_memory().total / (1024 ** 3) env = dict(HF_DOWNLOAD_ENV) if req.hf_token: env["HF_TOKEN"] = req.hf_token cfg = read_config() job_ids = [] for m in recipe["models"]: file = _pick_gguf(m["repo"], m.get("quant", "Q4_K_M")) if not file: continue # kein GGUF im Repo gefunden -> Modell ueberspringen (Rest installiert trotzdem) target = MODELS_DIR / m["repo"].split("/")[-1] target.mkdir(parents=True, exist_ok=True) args = [hf_bin(), "download", m["repo"], file, "--local-dir", str(target)] jid = start_job(args, f"download {m['name']}", env=env) JOBS[jid]["result_path"] = str(target / file) attach_download_progress(jid, str(target), hf_file_size(m["repo"], file)) job_ids.append(jid) # Eintrag jetzt schon schreiben — optimaler Kontext, aber gedeckelt fuer schnellen Erststart. ctx = min(max_ctx_for(m["params_b"], m["quant"], ram_gb), 32768) path = str(target / file) cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx)) cfg["models"][m["role"]] = {"cmd": LiteralScalarString(cmd + "\n"), "ttl": DEFAULT_TTL} write_config(cfg) return {"job_ids": job_ids, "count": len(job_ids)} def hf_file_size(repo: str, file: str) -> int: """Groesse einer Datei im HF-Repo (Bytes) fuer die Fortschrittsanzeige. 0 wenn unbekannt. GGUFs sind LFS -> ggf. unter 'lfs.size'.""" try: with httpx.Client(timeout=10.0) as c: tree = c.get(f"https://huggingface.co/api/models/{repo}/tree/main").json() for f in tree: if isinstance(f, dict) and f.get("path") == file: return int(f.get("size") or (f.get("lfs") or {}).get("size") or 0) except Exception: # noqa: BLE001 return 0 return 0 def _pick_gguf(repo: str, quant: str = "Q4_K_M") -> str | None: """Beste GGUF-Datei eines Repos auflösen: bevorzugt gewünschten Quant, keine Split-Teile.""" try: with httpx.Client(timeout=10.0) as c: tree = c.get(f"https://huggingface.co/api/models/{repo}/tree/main").json() except Exception: # noqa: BLE001 return None ggufs = [f["path"] for f in tree if isinstance(f, dict) and str(f.get("path", "")).endswith(".gguf")] if not ggufs: return None pref = [g for g in ggufs if quant.lower() in g.lower() and "-of-" not in g] nosplit = [g for g in ggufs if "-of-" not in g] return (pref or nosplit or ggufs)[0] def compute_upgrades(ram_gb): """Liste relevanter Modell-Upgrades für die installierten Modelle (UPGRADES-Map).""" installed = (read_config().get("models") or {}) out, seen = [], set() for up in UPGRADES: new_base = up["repo"].split("/")[-1].lower() if any(new_base in str(s.get("cmd", "")).lower() for s in installed.values()): continue # neueres Modell schon installiert old = None for alias, spec in installed.items(): hay = (alias + " " + str(spec.get("cmd", ""))).lower() if any(k in hay for k in up["match"]): old = alias break if not old or up["repo"] in seen: continue seen.add(up["repo"]) out.append({ "name": up["name"], "repo": up["repo"], "params_b": up["params_b"], "quant": up["quant"], "why": up["why"], "old": old, "role": old, "fit": evaluate_fit(up["params_b"], up["quant"], 8192, ram_gb), "optimal_ctx": max_ctx_for(up["params_b"], up["quant"], ram_gb), }) return out @router.get("/upgrades") def upgrades(): return {"upgrades": compute_upgrades(psutil.virtual_memory().total / (1024 ** 3))} @router.post("/install-model") def install_model(req: InstallModelReq): """Ein einzelnes Modell installieren (Download + Einpflegen unter 'role', optimaler ctx).""" file = _pick_gguf(req.repo, req.quant) if not file: raise HTTPException(404, "Keine GGUF-Datei im Repo gefunden.") ram_gb = psutil.virtual_memory().total / (1024 ** 3) env = dict(HF_DOWNLOAD_ENV) if req.hf_token: env["HF_TOKEN"] = req.hf_token target = MODELS_DIR / req.repo.split("/")[-1] target.mkdir(parents=True, exist_ok=True) jid = start_job([hf_bin(), "download", req.repo, file, "--local-dir", str(target)], f"download {req.repo.split('/')[-1]}", env=env) JOBS[jid]["result_path"] = str(target / file) attach_download_progress(jid, str(target), hf_file_size(req.repo, file)) cfg = read_config() ctx = min(max_ctx_for(req.params_b, req.quant, ram_gb), 32768) cmd = CMD_TEMPLATE.replace("{model}", str(target / file)).replace("{ctx}", str(ctx)) cfg["models"][req.role] = {"cmd": LiteralScalarString(cmd + "\n"), "ttl": DEFAULT_TTL} write_config(cfg) return {"job_id": jid}