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>
This commit is contained in:
Hitonabi
2026-06-22 16:13:55 +02:00
parent 5761556d84
commit 81f6861df8
3 changed files with 174 additions and 10 deletions
+89 -6
View File
@@ -3,6 +3,8 @@ Cookbook Router: Verbindet die HuggingFace API mit der Odysseus-Hardware-Berechn
"""
import httpx
import json
import os
import re
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
@@ -12,7 +14,7 @@ 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, hf_bin
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
@@ -21,6 +23,30 @@ 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
@@ -41,6 +67,21 @@ class InstallModelReq(BaseModel):
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
@@ -126,26 +167,68 @@ def recipes():
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 RECIPES:
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})
# „Beste Wahl": das reichste Setup, das komplett auf die Hardware passt.
fitting = [r for r in out if r["fit_level"] != "too_tight"]
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 RECIPES if r["id"] == req.recipe_id), None)
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)