feat(cookbook): autonome Modell-Entdeckung aus vertrauenswuerdigen Quellen

"Aktuell beste Modelle fuer dein System": fragt vertrauenswuerdige HF-Orgs
(unsloth/bartowski/ggml-org/lmstudio-community) LIVE ab, kategorisiert die
Treffer (vision/coder/reasoning/agent/scout), filtert per hw_math auf das,
was auf die Hardware passt, und cached das Ergebnis (TTL 12 h, lazy + Knopf
"Aktualisieren"). Damit bleibt das Cookbook von selbst aktuell, ohne dass
Modelle hartkodiert werden.

- sources.py: TRUSTED_AUTHORS + CATEGORIES + SKIP_TOKENS (reine Daten).
- config.py: DISCOVER_CACHE_PATH (persistent neben den Modellen, uebersteht
  Deploys) + DISCOVER_TTL.
- cookbook.py: /api/cookbook/discover (force-Param), refresh_discover,
  Bestandsabgleich (_model_installed -> "schon installiert als X") und
  ehrliche Voraussetzungen je Modell (Vision->mmproj/jinja, unquantisiert,
  zu gross/knapp).
- cookbook.js: Sektion mit Kategorien, Fit-Ampel, Downloads, Hinweisen,
  1-Klick-Installieren bzw. "installiert"-Markierung; "Aktualisieren"-Knopf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-22 16:47:37 +02:00
parent 0ed4dd84b8
commit 0c16fb28c2
4 changed files with 266 additions and 2 deletions
+150 -1
View File
@@ -6,6 +6,7 @@ import httpx
import json
import os
import re
import time
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
import psutil
@@ -14,10 +15,12 @@ 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 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
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)])
@@ -286,6 +289,152 @@ def _pick_gguf(repo: str, quant: str = "Q4_K_M") -> str | None:
return (pref or nosplit or ggufs)[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) + --jinja werden beim Einpflegen automatisch ergänzt.")
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)
by_cat[role].append({
"name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid,
"role": role, "params_b": params_b, "quant": quant,
"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"]]
out.append({**c, "models": ms})
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 {})