This repository has been archived on 2026-07-22. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
mission-control/routers/cookbook.py
T
Hitonabi 701476179e feat(v9): Phase 10 Schritt 2 — Capability-Chips + Facetten-Filter im Cookbook
- Profi-Suche: HF-Suche mit &full=true (liefert Tags); Multi-Capability-Chips
  (Code/Bild/Logik/MoE·aktiv/Tools(yes|likely)/Chat) aus Name + HF-Tags.
- Facetten-Filter (Mehrfachauswahl: Tools/MoE/Logik/Bild/Code) statt
  Einzel-Kategorie — clientseitig auf den Treffern (Index für Fit/Best bleibt).
- Discover: dieselben Chips; Backend liefert HF-Tags je Modell mit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:16:40 +02:00

557 lines
22 KiB
Python

"""
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 '<modell>-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}