Feat: kuratierter Modell-Katalog (Cookbook) - korrekte Metadaten statt Namens-Raterei

Inspiriert von Odysseus' Cookbook: backend/models_catalog.json mit echten Metadaten
(total/active params, moe, generation) je Rolle. services/catalog.py: Laden, Name-Match,
MoE-bewusstes Scoring (Wissen + Tempo via tps -> MoE-first auf der bandbreiten-Box), Fit.
- discover.py: Empfehlung jetzt KATALOG-FIRST (kuratiert, korrekt), HF-Dynamik als Ergaenzung/Fallback.
- maintenance.model_upgrades: Metadaten aus Katalog -> praezise Familie/Generation/Groesse + MoE-first
  (dense ersetzt MoE nur bei grossem Wissens-Sprung). Behebt Coder-Next=7B-Fehlschaetzung,
  Qwen2.5-VL-Generations-Downgrade, falsches dense-scout-Upgrade.
- fit.py: MXFP4/FP8/AWQ in der Quant-Tabelle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-27 13:04:21 +02:00
parent 123303240d
commit c074d977ce
5 changed files with 237 additions and 26 deletions
+60
View File
@@ -0,0 +1,60 @@
{
"_comment": "Kuratierter Modell-Katalog (Cookbook) für Strix Halo / Ryzen AI MAX+ 395 — 128GB unified, bandbreiten-limitiert (256 GB/s). MoE-first. EINE Quelle der Wahrheit für KORREKTE Metadaten (total/active params, moe, generation) → präzise Empfehlungen ohne Namens-Raterei. Inspiriert vom Odysseus-Cookbook (statischer, validierter Katalog statt Live-Scraping). Erweiterbar: neue Modelle hier eintragen. Felder: name (Match-Identifier), repo (HF org/name für Install), family (+Subtyp), generation (numerisch, für Upgrade-Vergleich), total_params_b, active_params_b (=total bei dense), moe, quant, ctx (empfohlen), tools, vision.",
"version": "2026-06-27",
"models": [
{
"role": "fast", "name": "Qwen3.6-35B-A3B", "repo": "Qwen/Qwen3.6-35B-A3B-GGUF",
"family": "qwen", "generation": 3.6, "total_params_b": 35, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
},
{
"role": "fast", "name": "Qwen3-30B-A3B-Instruct", "repo": "unsloth/Qwen3-30B-A3B-Instruct-2507-GGUF",
"family": "qwen", "generation": 3.0, "total_params_b": 30, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": false
},
{
"role": "heavy", "name": "Qwen3.5-122B-A10B", "repo": "Qwen/Qwen3.5-122B-A10B-GGUF",
"family": "qwen", "generation": 3.5, "total_params_b": 122, "active_params_b": 10,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": false
},
{
"role": "heavy", "name": "gpt-oss-120b", "repo": "ggml-org/gpt-oss-120b-GGUF",
"family": "gpt-oss", "generation": 1.0, "total_params_b": 120, "active_params_b": 5,
"moe": true, "quant": "MXFP4", "ctx": 32768, "tools": true, "vision": false
},
{
"role": "coder", "name": "Qwen3-Coder-Next", "repo": "Qwen/Qwen3-Coder-Next-GGUF",
"family": "qwen-coder", "generation": 3.0, "total_params_b": 84, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
},
{
"role": "coder", "name": "Qwen3-Coder-30B-A3B-Instruct", "repo": "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF",
"family": "qwen-coder", "generation": 3.0, "total_params_b": 30, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
},
{
"role": "vision", "name": "Qwen3-VL-8B-Instruct", "repo": "Qwen/Qwen3-VL-8B-Instruct-GGUF",
"family": "qwen-vl", "generation": 3.0, "total_params_b": 8, "active_params_b": 8,
"moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
},
{
"role": "vision", "name": "Qwen3-VL-2B-Instruct", "repo": "Qwen/Qwen3-VL-2B-Instruct-GGUF",
"family": "qwen-vl", "generation": 3.0, "total_params_b": 2, "active_params_b": 2,
"moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
},
{
"role": "scout", "name": "gemma-4-26B-A4B-it", "repo": "google/gemma-4-26B-A4B-it-GGUF",
"family": "gemma", "generation": 4.0, "total_params_b": 26, "active_params_b": 4,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
},
{
"role": "scout", "name": "gemma-4-31B-it", "repo": "google/gemma-4-31B-it-GGUF",
"family": "gemma", "generation": 4.0, "total_params_b": 31, "active_params_b": 31,
"moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
}
]
}
+115
View File
@@ -0,0 +1,115 @@
"""
Kuratierter Modell-Katalog ("Cookbook", inspiriert von Odysseus): EINE Quelle der
Wahrheit für KORREKTE Metadaten (total/active params, moe, generation) statt
Namens-Raterei. Macht Empfehlung + Upgrade-Erkennung präzise und MoE-bewusst
für die bandbreiten-limitierte Strix-Halo-Box.
Daten: backend/models_catalog.json. Fällt sanft aus (leerer Katalog), wenn die
Datei fehlt → discover nutzt dann nur die HF-Dynamik.
"""
import json
import math
import os
import re
from services.fit import estimate_memory_gb, estimate_speed
_CATALOG_PATH = os.path.join(os.path.dirname(__file__), "..", "models_catalog.json")
_cache: dict = {"mtime": 0.0, "models": []}
def _load() -> list[dict]:
try:
mt = os.path.getmtime(_CATALOG_PATH)
if mt != _cache["mtime"]:
with open(_CATALOG_PATH, encoding="utf-8") as f:
data = json.load(f) or {}
_cache.update(mtime=mt, models=[m for m in data.get("models", []) if m.get("name")])
except (OSError, ValueError):
_cache.update(mtime=0.0, models=[])
return _cache["models"]
def _norm(name: str) -> str:
"""Vergleichs-Stamm: kleingeschrieben, Org-Prefix/Quant/GGUF/Split entfernt."""
s = (name or "").lower().split("/")[-1]
s = re.sub(r"\.gguf$", "", s)
s = re.sub(r"-\d+-of-\d+$", "", s)
s = re.sub(r"[-_](ud-)?(i?q\d[\w]*|f16|bf16|fp16|f32|mxfp4)$", "", s)
return s.strip("-_ ")
def entries() -> list[dict]:
return list(_load())
def entries_for_role(role: str) -> list[dict]:
return [e for e in _load() if e.get("role") == role]
def meta_for_name(name: str) -> dict | None:
"""Katalog-Metadaten zu einem Modell(namen) — matcht lokalen Namen ODER HF-Repo."""
n = _norm(name)
if not n:
return None
for e in _load():
cand = {_norm(e.get("name", "")), _norm(e.get("repo", ""))}
if n in cand or any(c and (c in n or n in c) for c in cand):
return e
return None
def fit_of(e: dict, ram_gb: float) -> dict:
"""Hardware-Fit eines Katalog-Eintrags (MoE-bewusst über active_params_b)."""
total = float(e.get("total_params_b") or 7)
active = float(e.get("active_params_b") or total)
quant = e.get("quant") or "Q4_K_M"
ctx = int(e.get("ctx") or 32768)
req_gb = estimate_memory_gb(total, quant, ctx)
tps = estimate_speed(req_gb, ram_gb, (active / total) if total else 1.0)
usable = max(ram_gb - 4.0, 0)
if req_gb > usable:
level, text = "too_tight", "Zu groß (OOM)"
elif req_gb > usable * 0.8:
level, text = "marginal", "Könnte knapp werden"
else:
level, text = "perfect", "Passt perfekt"
return {"level": level, "text": text, "req_gb": round(req_gb, 1), "tps": round(tps, 0)}
def stack_score(e: dict, ram_gb: float) -> float:
"""Score für DIESE Hardware: muss passen, dann Wissen (total params) + Tempo
(tps — belohnt MoE durch niedrige aktive Params automatisch). Bandbreiten-Box
→ MoE gewinnt bei vergleichbarem Wissen gegen dense."""
fit = fit_of(e, ram_gb)
if fit["level"] == "too_tight":
return -100.0
total = float(e.get("total_params_b") or 7)
fit_bonus = 3.0 if fit["level"] == "perfect" else 1.0
knowledge = math.log2(total + 1) / 8.0 # ~0..1 (bis ~256B)
speed = min((fit["tps"] or 0) / 80.0, 1.0) # normalisiert; MoE = hohe tps
return fit_bonus + 1.2 * knowledge + 1.0 * speed
def to_model_dict(e: dict, ram_gb: float) -> dict:
"""Katalog-Eintrag → discover-kompatibles Modell-Dict (echte Metadaten)."""
total = float(e.get("total_params_b") or 7)
active = e.get("active_params_b")
repo = e.get("repo") or e.get("name")
role = e.get("role")
caps = {
"moe": bool(e.get("moe")), "active_b": active,
"tools": "yes" if e.get("tools") else "no",
"vision": bool(e.get("vision")), "coder": role == "coder",
"reasoning": role == "heavy", "embedding": False,
"ctx": e.get("ctx"), "params_b": total, "arch": e.get("family"),
}
return {
"name": e.get("name"), "author": repo.split("/")[0] if "/" in repo else "catalog",
"repo": repo, "role": role, "params_b": total, "active_b": active,
"moe": bool(e.get("moe")), "generation": e.get("generation"),
"family": e.get("family"), "quant": e.get("quant") or "Q4_K_M",
"tags": ["catalog"], "downloads": 0, "fit": fit_of(e, ram_gb),
"optimal_ctx": int(e.get("ctx") or 32768), "caps": caps, "curated": True,
}
+20 -9
View File
@@ -19,6 +19,7 @@ import httpx
import logging import logging
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
from services import catalog
from services.caps import capabilities from services.caps import capabilities
from services.fit import evaluate_fit, extract_params_b, max_ctx_for from services.fit import evaluate_fit, extract_params_b, max_ctx_for
from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS
@@ -100,7 +101,7 @@ def refresh_discover(ram_gb: float) -> dict:
continue continue
seen.add(rid) seen.add(rid)
raw.append(m) raw.append(m)
if ok == 0: if ok == 0 and not catalog.entries():
raise RuntimeError("Keine Quelle erreichbar.") raise RuntimeError("Keine Quelle erreichbar.")
by_cat: dict[str, list] = {c["role"]: [] for c in CATEGORIES} by_cat: dict[str, list] = {c["role"]: [] for c in CATEGORIES}
@@ -125,15 +126,25 @@ def refresh_discover(ram_gb: float) -> dict:
cats = [] cats = []
for c in CATEGORIES: for c in CATEGORIES:
items = by_cat[c["role"]] role = c["role"]
ranked = rank_runnable(items) # 1) KATALOG zuerst (kuratierte, korrekte Metadaten, MoE-bewusst gerankt) —
# Top 4 je Kategorie = die best-gerankten (fähigstes-was-passt zuerst). # macht die Empfehlung präzise statt Namens-Raterei.
top = ranked[:4] cat_entries = sorted(catalog.entries_for_role(role),
if top: key=lambda e: -catalog.stack_score(e, ram_gb))
cat_models = [m for m in (catalog.to_model_dict(e, ram_gb) for e in cat_entries)
if m["fit"]["level"] != "too_tight"]
# 2) HF-Dynamik als Ergänzung (nicht-kuratierte Funde), dedupliziert.
hf_ranked = rank_runnable(by_cat[role])
seen = {catalog._norm(m["repo"]) for m in cat_models}
extra = [h for h in hf_ranked if catalog._norm(h["repo"]) not in seen]
combined = cat_models + extra
if combined:
cats.append({ cats.append({
"role": c["role"], "title": c["title"], "icon": c["icon"], "role": role, "title": c["title"], "icon": c["icon"],
"models": top, "models": combined[:6],
"recommended": ranked[0]["repo"] if ranked else None, # Empfehlung = bester KURATIERTER Eintrag, sonst beste HF-Fundstelle.
"recommended": (cat_models[0]["repo"] if cat_models
else (hf_ranked[0]["repo"] if hf_ranked else None)),
}) })
data = {"updated": time.time(), "categories": cats} data = {"updated": time.time(), "categories": cats}
+1
View File
@@ -11,6 +11,7 @@ QUANT_BYTES_PER_PARAM = {
"Q4_0": 0.50, "Q4_1": 0.55, "Q4_K_S": 0.50, "Q4_K_M": 0.55, "Q4_0": 0.50, "Q4_1": 0.55, "Q4_K_S": 0.50, "Q4_K_M": 0.55,
"Q5_0": 0.62, "Q5_1": 0.68, "Q5_K_S": 0.62, "Q5_K_M": 0.65, "Q5_0": 0.62, "Q5_1": 0.68, "Q5_K_S": 0.62, "Q5_K_M": 0.65,
"Q6_K": 0.75, "Q8_0": 1.00, "F16": 2.00, "BF16": 2.00, "Q6_K": 0.75, "Q8_0": 1.00, "F16": 2.00, "BF16": 2.00,
"MXFP4": 0.55, "FP8": 1.05, "AWQ": 0.55,
} }
+39 -15
View File
@@ -16,7 +16,7 @@ import httpx
import psutil import psutil
from config import ANYTHINGLLM_REPO, ANYTHINGLLM_URL, HERMES_AGENT_REPO from config import ANYTHINGLLM_REPO, ANYTHINGLLM_URL, HERMES_AGENT_REPO
from services import discover, jobengine, llamaswap, system from services import catalog, discover, jobengine, llamaswap, system
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user). # System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
SYSTEM_SERVICES = {"llama-swap"} SYSTEM_SERVICES = {"llama-swap"}
@@ -185,11 +185,27 @@ def _gen_key(name: str):
return None return None
def _meta(name: str, model_dict: dict | None = None, im: dict | None = None) -> dict:
"""Metadaten (family, gen, total, active, moe) — bevorzugt den kuratierten Katalog,
sonst die Felder eines Discover-/Modell-Dicts, sonst Namens-/Größen-Heuristik."""
cm = catalog.meta_for_name(name)
if cm:
return {"family": cm.get("family"), "gen": cm.get("generation"),
"total": float(cm.get("total_params_b") or 0),
"active": cm.get("active_params_b"), "moe": bool(cm.get("moe"))}
d = model_dict or {}
g = _gen_key(name)
total = float(d.get("params_b") or 0) or (_params_of(im) if im else 0.0)
return {"family": (d.get("family") or (g[0] if g else None)),
"gen": (d.get("generation") if d.get("generation") is not None else (g[1] if g else None)),
"total": total, "active": d.get("active_b"), "moe": bool(d.get("moe"))}
def model_upgrades() -> list[dict]: def model_upgrades() -> list[dict]:
"""Je Rolle ein ECHTES Upgrade-Vorschlag — nur wenn die Empfehlung wirklich besser ist: """Je Rolle ein ECHTES Upgrade — nur wenn die Empfehlung wirklich besser ist:
gleiche Familie UND (neuere Generation ODER größer in gleicher Generation). Verhindert gleiche Familie UND (neuere Generation ODER deutlich größer) UND kein Tempo-Downgrade
Generations-Downgrades (Qwen2.5-VL über Qwen3-VL), Größen-Downgrades (Coder-Next → 30B) (MoE-first für die bandbreiten-limitierte Box: dense ersetzt MoE nur bei großem Wissens-
und Fremd-Familien-Swaps (gpt-oss als „Upgrade" von Qwen).""" Sprung). Metadaten kommen aus dem kuratierten Katalog → keine Namens-Raterei."""
disc = discover.safe_discover(_ram_gb()) disc = discover.safe_discover(_ram_gb())
if not disc: if not disc:
return [] return []
@@ -203,23 +219,31 @@ def model_upgrades() -> list[dict]:
for c in disc.get("categories", []): for c in disc.get("categories", []):
role = c["role"] role = c["role"]
im = inst_by_role.get(role) im = inst_by_role.get(role)
if im is None: # nur Rollen mit bereits installiertem Modell if im is None:
continue continue
rec = c.get("recommended") rec = c.get("recommended")
if not rec: if not rec:
continue continue
rec_model = next((x for x in c.get("models", []) if x.get("repo") == rec), None) rec_model = next((x for x in c.get("models", []) if x.get("repo") == rec), None)
rec_params = float((rec_model or {}).get("params_b") or 0.0)
inst_params = _params_of(im)
ig, rg = _gen_key(im["name"]), _gen_key(rec) i = _meta(im["name"], im=im)
# Upgrade nur bei GLEICHER, erkennbarer Familie: r = _meta(rec, model_dict=rec_model)
if not (ig and rg) or ig[0] != rg[0]:
continue # Fremd-Familie / unbekannt → kein „Upgrade" if not i["family"] or not r["family"] or i["family"] != r["family"]:
if rg[1] < ig[1] - 1e-6: continue # andere/unbekannte Familie → kein Upgrade
if r["gen"] is not None and i["gen"] is not None and r["gen"] < i["gen"] - 1e-6:
continue # ältere Generation → niemals continue # ältere Generation → niemals
if abs(rg[1] - ig[1]) < 1e-6 and rec_params and rec_params < inst_params * 1.05: same_gen = (r["gen"] is None or i["gen"] is None or abs(r["gen"] - i["gen"]) < 1e-6)
continue # gleiche Gen, nicht (deutlich) größer if same_gen:
if r["total"] and i["total"] and r["total"] < i["total"] * 1.05:
continue # gleiche Gen, nicht größer → kein Upgrade
# MoE-first: ein MoE durch dense ersetzen nur bei deutlichem Wissens-Sprung
if i["moe"] and not r["moe"] and r["total"] < i["total"] * 1.5:
continue
# Tempo nicht verschlechtern (aktive Params), außer großer Wissens-Gewinn
ia, ra = (i["active"] or i["total"]), (r["active"] or r["total"])
if ia and ra > ia * 1.3 and r["total"] < i["total"] * 1.3:
continue
base = rec.split("/")[-1].lower() base = rec.split("/")[-1].lower()
stem = base[:-5] if base.endswith("-gguf") else base stem = base[:-5] if base.endswith("-gguf") else base