""" Cookbook Router: Verbindet die HuggingFace API mit der Odysseus-Hardware-Berechnung. """ import httpx import json import os import re import time 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, extract_params_b from config import (MODELS_DIR, CMD_TEMPLATE, DEFAULT_TTL, HF_DOWNLOAD_ENV, USER_RECIPES_PATH, DISCOVER_CACHE_PATH, DISCOVER_TTL, hf_bin) from llamaswap import read_config, write_config, model_id_from_path, set_role_alias from jobengine import start_job, JOBS, attach_download_progress from recipes import RECIPES, UPGRADES from sources import TRUSTED_AUTHORS, CATEGORIES, SKIP_TOKENS 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" file: str | None = None # konkrete GGUF-Datei; None → _pick_gguf wählt automatisch ctx: int | None = None # None → max_ctx_for (Hardware-Optimum) 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_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, name=req.repo_id) # 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, name=m.get("name", "")) 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.put("/user-recipe/{recipe_id}") def update_user_recipe(recipe_id: str, req: UserRecipeReq): """Bestehendes eigenes Setup aktualisieren (Titel, Beschreibung, Modelle).""" items = load_user_recipes() idx = next((i for i, r in enumerate(items) if r.get("id") == recipe_id), None) if idx is None: raise HTTPException(404, "Eigenes Setup nicht gefunden.") 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[idx] = {**items[idx], "title": req.title.strip(), "icon": (req.icon or "box").strip(), "desc": req.desc.strip(), "models": models} save_user_recipes(items) return {"ok": True, "id": recipe_id} @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) # Vision-Modelle: mmproj-Projektor mitholen (nötig für --mmproj in llama.cpp). mmproj = _pick_mmproj(m["repo"]) target = MODELS_DIR / m["repo"].split("/")[-1] target.mkdir(parents=True, exist_ok=True) args = [hf_bin(), "download", m["repo"], file] if mmproj: args.append(mmproj) # hf-cli unterstützt mehrere Dateien in einem Aufruf args += ["--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 für die Hardware. ctx = max_ctx_for(m["params_b"], m["quant"], ram_gb) path = str(target / file) cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx)) if mmproj: cmd = cmd.rstrip() + f" --mmproj {target / mmproj} --jinja" # Schluessel = sprechender Modellname; Rolle (aus dem Rezept) als Alias. mid = model_id_from_path(path) cfg["models"][mid] = {"cmd": LiteralScalarString(cmd + "\n"), "ttl": DEFAULT_TTL} set_role_alias(cfg, mid, m["role"]) 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 _pick_mmproj(repo: str) -> str | None: """Sucht den mmproj-Projektor (Vision-Modelle) im Repo — F16/BF16 bevorzugt. Gibt None zurueck, wenn kein Projektor vorhanden (= kein Vision-Modell).""" 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 candidates = [ f["path"] for f in tree if isinstance(f, dict) and "mmproj" in str(f.get("path", "")).lower() and str(f.get("path", "")).endswith(".gguf") ] if not candidates: return None # F16/BF16 hat die beste Projektor-Qualität; nimm das erste wenn keins passt. pref = [c for c in candidates if "f16" in c.lower() or "bf16" in c.lower()] return (pref or candidates)[0] # --------------------------------------------------------------------------- # Automatische Modell-Entdeckung ("aktuell beste Modelle", live von HF + Cache). # --------------------------------------------------------------------------- def _categorize(repo_id: str) -> str: low = repo_id.lower() for cat in CATEGORIES: if any(k in low for k in cat["kw"]): return cat["role"] return "scout" # Fallback: Allrounder def _installed_index() -> dict: """alias -> kleingeschriebene cmd-Zeile der installierten Modelle (fuer Bestandsabgleich).""" return {alias: str(spec.get("cmd", "")).lower() for alias, spec in (read_config().get("models") or {}).items()} def _model_installed(repo: str, idx: dict) -> str | None: """Liefert den Alias, unter dem dieses Repo schon installiert ist — sonst None.""" base = repo.split("/")[-1].lower() # GGUF-Repos heissen meist '-GGUF' -> auch ohne Suffix vergleichen stem = base[:-5] if base.endswith("-gguf") else base for alias, cmd in idx.items(): if base in cmd or (stem and stem in cmd): return alias return None def _model_requirements(role: str, quant: str, fit: dict) -> list[str]: """Ehrliche Voraussetzungen/Hinweise pro Modell (Klartext fuer Anfaenger).""" reqs = [] if role == "vision": reqs.append("Bild-Modell: Projektor (mmproj-F16) wird automatisch mitgeladen und --jinja gesetzt.") if quant.upper() in ("FP16", "BF16", "F16", "F32"): reqs.append("Unquantisiert — sehr groß. Eine Q4/Q5-Variante ist meist die bessere Wahl.") if fit["level"] == "too_tight": reqs.append("Größer als dein Speicher — nur mit kleinerem Quant realistisch.") elif fit["level"] == "marginal": reqs.append("Läuft, wird aber knapp — andere Programme währenddessen besser schließen.") return reqs def _fetch_author_models(author: str) -> list: url = (f"https://huggingface.co/api/models?author={author}" f"&filter=gguf&sort=downloads&direction=-1&limit=40") try: with httpx.Client(timeout=12.0) as c: data = c.get(url).json() return data if isinstance(data, list) else [] except Exception: # noqa: BLE001 return [] def refresh_discover(ram_gb: float) -> dict: """Quellen live abfragen, kategorisieren, nach Hardware-Fit + Beliebtheit ranken und cachen. Wirft nur, wenn KEINE einzige Quelle erreichbar war.""" raw, seen, ok = [], set(), 0 for author in TRUSTED_AUTHORS: models = _fetch_author_models(author) if models: ok += 1 for m in models: rid = m.get("id") if not rid or rid in seen: continue seen.add(rid) raw.append(m) if ok == 0: raise RuntimeError("Keine Quelle erreichbar.") by_cat = {c["role"]: [] for c in CATEGORIES} for m in raw: rid = m["id"] low = rid.lower() if any(tok in low for tok in SKIP_TOKENS): continue role = _categorize(rid) params_b = extract_params_b(rid) quant = "Q4_K_M" # Referenz-Quant fuer die Fit-Einschaetzung fit = evaluate_fit(params_b, quant, 8192, ram_gb, name=rid) by_cat[role].append({ "name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid, "role": role, "params_b": params_b, "quant": quant, "tags": [str(t) for t in (m.get("tags") or [])], # HF-Tags für Capability-Erkennung (Phase 10) "downloads": int(m.get("downloads") or 0), "likes": int(m.get("likes") or 0), "lastModified": m.get("lastModified"), "fit": fit, "optimal_ctx": max_ctx_for(params_b, quant, ram_gb), }) cats = [] for c in CATEGORIES: items = by_cat[c["role"]] # Passendes zuerst (perfect/marginal vor too_tight), dann nach Downloads. items.sort(key=lambda x: (x["fit"]["level"] != "too_tight", x["downloads"]), reverse=True) top = items[:4] if top: cats.append({"role": c["role"], "title": c["title"], "icon": c["icon"], "models": top}) data = {"updated": time.time(), "categories": cats} try: DISCOVER_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) tmp = DISCOVER_CACHE_PATH.with_name(DISCOVER_CACHE_PATH.name + ".tmp") tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") os.replace(tmp, DISCOVER_CACHE_PATH) except Exception: # noqa: BLE001 pass # Cache ist nur Beschleunigung — Entdeckung funktioniert auch ohne Schreibrecht return data def _load_discover() -> dict | None: try: if DISCOVER_CACHE_PATH.exists(): return json.loads(DISCOVER_CACHE_PATH.read_text(encoding="utf-8")) except Exception: # noqa: BLE001 pass return None @router.get("/discover") def discover(force: bool = False): """Aktuell beste Modelle je Kategorie — live aus den Quellen (gecacht, TTL). Markiert pro Modell, ob es schon installiert ist, und nennt Voraussetzungen.""" ram_gb = psutil.virtual_memory().total / (1024 ** 3) cached = _load_discover() fresh = bool(cached) and (time.time() - cached.get("updated", 0) < DISCOVER_TTL) if fresh and not force: data = cached else: try: data = refresh_discover(ram_gb) except Exception: # noqa: BLE001 if cached: data = cached # Quellen down -> alter Cache ist besser als nichts else: raise HTTPException(502, "Modell-Quellen gerade nicht erreichbar — später erneut versuchen.") # Bestand + Voraussetzungen frisch dazurechnen (aendern sich unabhaengig vom Cache). idx = _installed_index() out = [] for c in data["categories"]: ms = [{**m, "installed": _model_installed(m["repo"], idx), "requirements": _model_requirements(m["role"], m.get("quant", "Q4_K_M"), m["fit"])} for m in c["models"]] # „Beste Wahl" je Kategorie: das lauffähige Modell mit dem besten Fit (perfect vor # marginal), bei Gleichstand das meistgeladene. Zu große Modelle kommen nie in Frage. ranked = sorted([m for m in ms if m["fit"]["level"] != "too_tight"], key=lambda m: (_FIT_ORDER[m["fit"]["level"]], -int(m.get("downloads") or 0))) out.append({**c, "models": ms, "recommended": ranked[0]["repo"] if ranked else None}) return {"updated": data["updated"], "categories": out, "sys_ram_gb": round(ram_gb, 1), "stale": not fresh} 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, name=up.get("name", "")), "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). Wenn 'file' übergeben wird, wird genau diese GGUF-Datei geladen (Profi-Suche-Auswahl); andernfalls wählt _pick_gguf automatisch die beste Variante für den gewünschten Quant.""" file = req.file or _pick_gguf(req.repo, req.quant) if not file: raise HTTPException(404, "Keine GGUF-Datei im Repo gefunden.") mmproj = _pick_mmproj(req.repo) 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) args = [hf_bin(), "download", req.repo, file] if mmproj: args.append(mmproj) args += ["--local-dir", str(target)] jid = start_job(args, 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 = req.ctx if req.ctx else max_ctx_for(req.params_b, req.quant, ram_gb) path = str(target / file) cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx)) if mmproj: cmd = cmd.rstrip() + f" --mmproj {target / mmproj} --jinja" mid = model_id_from_path(path) cfg["models"][mid] = {"cmd": LiteralScalarString(cmd + "\n"), "ttl": DEFAULT_TTL} set_role_alias(cfg, mid, req.role) write_config(cfg) return {"job_id": jid, "model_id": mid}