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:
+89
-6
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user