diff --git a/backend/services/maintenance.py b/backend/services/maintenance.py index d8b74c7..e55b270 100644 --- a/backend/services/maintenance.py +++ b/backend/services/maintenance.py @@ -154,48 +154,77 @@ def _components_cached() -> list[dict]: return data +def _params_of(m: dict) -> float: + """Größen-bewusste Parameterzahl eines installierten Modells: max aus Namens-Schätzung + und Dateigröße (fängt namenlose wie 'Qwen3-Coder-Next' UND Split-GGUFs ab).""" + from services.fit import QUANT_BYTES_PER_PARAM + caps = m.get("capabilities") or {} + bpp = QUANT_BYTES_PER_PARAM.get((m.get("quant") or "Q4_K_M").upper(), 0.55) + size_gb = (m.get("size_bytes") or 0) / (1024 ** 3) + pb_size = (size_gb / bpp) if size_gb > 1.0 else 0.0 + return max(float(caps.get("params_b") or 0), pb_size, 0.0) + + +# Familien-Subtyp + Generations-Version aus dem Modellnamen (für „echtes Upgrade?"). +_FAM_PATS = (("qwen", r"qwen(\d+(?:\.\d+)?)"), ("gemma", r"gemma[-_ ]?(\d+(?:\.\d+)?)"), + ("llama", r"llama[-_ ]?(\d+(?:\.\d+)?)"), ("phi", r"phi[-_ ]?(\d+(?:\.\d+)?)"), + ("mistral", r"mistral"), ("hermes", r"hermes[-_ ]?(\d+(?:\.\d+)?)")) + + +def _gen_key(name: str): + """(Familie+Subtyp, Generations-Version) oder None. Z.B. 'Qwen3-VL-2B' → ('qwen-vl', 3.0), + 'Qwen2.5-VL-7B' → ('qwen-vl', 2.5). Nur gleiche Familie ist sinnvoll vergleichbar.""" + low = (name or "").lower() + sub = "-vl" if any(k in low for k in ("-vl", "vl-", "vision", "llava", "pixtral")) else \ + "-coder" if ("coder" in low or "-code" in low) else "" + for fam, pat in _FAM_PATS: + m = re.search(pat, low) + if m: + ver = float(m.group(1)) if (m.groups() and m.group(1)) else 0.0 + return (fam + sub, ver) + return None + + def model_upgrades() -> list[dict]: - """Dynamisch: je discover-Kategorie das empfohlene Modell, das NOCH NICHT installiert ist, - aber NUR wenn für diese Rolle bereits irgendein Modell konfiguriert ist. - → Upgrade-Vorschlag für diese Rolle. Self-updating (kein Hardcode wie v1).""" + """Je Rolle ein ECHTES Upgrade-Vorschlag — nur wenn die Empfehlung wirklich besser ist: + gleiche Familie UND (neuere Generation ODER größer in gleicher Generation). Verhindert + Generations-Downgrades (Qwen2.5-VL über Qwen3-VL), Größen-Downgrades (Coder-Next → 30B) + und Fremd-Familien-Swaps (gpt-oss als „Upgrade" von Qwen).""" disc = discover.safe_discover(_ram_gb()) if not disc: return [] installed = llamaswap.list_models() - active_roles = {m["role"] for m in installed if m.get("role")} - # Größtes installiertes Modell je Rolle (params_b) — Basis für „kein Downgrade". - inst_params: dict[str, float] = {} - for m in installed: - r, pb = m.get("role"), (m.get("capabilities") or {}).get("params_b") - if r and pb: - inst_params[r] = max(inst_params.get(r, 0.0), float(pb)) - + inst_by_role = {m["role"]: m for m in installed if m.get("role")} cmds = " ".join(str(s.get("cmd", "")).lower() for s in (llamaswap.read_config().get("models") or {}).values()) out = [] - # Discover-Rollen == Serving-Rollen (fast/heavy/coder/vision/scout) → kein Mapping mehr. for c in disc.get("categories", []): role = c["role"] - if role not in active_roles: # nur Rollen, die bereits ein Modell haben + im = inst_by_role.get(role) + if im is None: # nur Rollen mit bereits installiertem Modell continue - rec = c.get("recommended") if not rec: continue - # KEIN Downgrade: Empfehlung nur, wenn sie mind. so „groß"/fähig ist wie das - # installierte Modell der Rolle (verhindert z.B. fast 35B-A3B → 4B). 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) - cur = inst_params.get(role, 0.0) - if cur and rec_params and rec_params < cur * 0.95: - continue + inst_params = _params_of(im) + + ig, rg = _gen_key(im["name"]), _gen_key(rec) + # Upgrade nur bei GLEICHER, erkennbarer Familie: + if not (ig and rg) or ig[0] != rg[0]: + continue # Fremd-Familie / unbekannt → kein „Upgrade" + if rg[1] < ig[1] - 1e-6: + continue # ältere Generation → niemals + if abs(rg[1] - ig[1]) < 1e-6 and rec_params and rec_params < inst_params * 1.05: + continue # gleiche Gen, nicht (deutlich) größer base = rec.split("/")[-1].lower() stem = base[:-5] if base.endswith("-gguf") else base if base in cmds or (stem and stem in cmds): - continue + continue # schon installiert out.append({"role": role, "title": c["title"], "repo": rec}) return out