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 81f6861df8 feat: eigene Cookbook-Setups erstellen & loeschen
- Nutzer koennen im Cookbook eigene Use-Case-Setups anlegen (Titel, Beschreibung,
  beliebige Modelle: Repo + Rolle + Quant). Groesse wird aus dem Repo-Namen
  abgeleitet; Fit-Ampel + optimaler Kontext kommen wie bei kuratierten Setups
  zur Laufzeit aus hw_math.
- Persistenz: JSON-Datei (MC_USER_RECIPES, Default /srv/models/...) -> ueberlebt
  Deploys (liegt bewusst NICHT im rsync-Ziel).
- /api/cookbook/recipes merged eingebaute + eigene Setups; install-recipe findet
  beide. Neue Endpunkte POST/DELETE /api/cookbook/user-recipe.
- UI: "+ Eigenes Setup", Modal mit dynamischen Modell-Zeilen; eigene Karten mit
  "Dein Setup"-Tag + Loeschen. "Beste Wahl" bleibt auf kuratierte beschraenkt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:13:55 +02:00

343 lines
13 KiB
Python

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