From ee2bb9d03cccb6ad95f9fc7203db8049c29286ee Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 24 Sep 2026 17:52:37 +0200 Subject: [PATCH] wartung: Groesse je Quantisierung, GitHub-Antworten 15 min gemerkt, Hermes-Version auch bei haengendem Abruf Co-Authored-By: Claude Opus 5.5 --- backend/services/hf.py | 19 ++++++---- backend/services/maintenance.py | 35 ++++++++++++------ backend/tests/test_wartung.py | 63 +++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 16 deletions(-) create mode 100644 backend/tests/test_wartung.py diff --git a/backend/services/hf.py b/backend/services/hf.py index d4164cf..839bdbf 100644 --- a/backend/services/hf.py +++ b/backend/services/hf.py @@ -17,10 +17,12 @@ def normalize_repo(s: str) -> str: return s.strip("/") -def list_quants(repo: str) -> list[str]: - """Verfügbare Quant-Stufen eines Repos (aus den GGUF-Dateinamen, ohne mmproj).""" +def list_quants(repo: str) -> list[dict]: + """Verfügbare Quant-Stufen eines Repos mit Downloadgröße (alle Teile + mmproj), ohne mmproj + als eigene Stufe. Ein Aufruf der Hugging-Face-API für alle Stufen.""" + baum = _tree(repo) quants: set[str] = set() - for e in _tree(repo): + for e in baum: p = str(e.get("path", "")) if p.lower().endswith(".gguf") and "mmproj" not in p.lower(): m = re.search(r"(I?Q\d[\w]*|F16|BF16|FP16|F32)", p, re.IGNORECASE) @@ -28,7 +30,8 @@ def list_quants(repo: str) -> list[str]: quants.add(m.group(1).upper()) # gängige Reihenfolge zuerst order = {"Q4_K_M": 0, "Q4_K_S": 1, "Q5_K_M": 2, "Q6_K": 3, "Q8_0": 4, "Q3_K_M": 5, "Q2_K": 6} - return sorted(quants, key=lambda q: (order.get(q, 99), q)) + return [{"quant": q, "total_bytes": auswahl(baum, q)["total_bytes"]} + for q in sorted(quants, key=lambda q: (order.get(q, 99), q))] def search(q: str = "", limit: int = 24) -> list[dict]: @@ -68,12 +71,16 @@ def _size(entry: dict) -> int: def resolve_gguf(repo: str, quant: str = "Q4_K_M") -> dict: - """Beste GGUF-Auswahl eines Repos für einen Quant. Behandelt Split-GGUFs + """Beste GGUF-Auswahl eines Repos für einen Quant (siehe auswahl).""" + return auswahl(_tree(repo), quant) + + +def auswahl(tree: list[dict], quant: str = "Q4_K_M") -> dict: + """GGUF-Auswahl aus dem Dateibaum eines Repos für einen Quant. Behandelt Split-GGUFs (-00001-of-000NN) als Gruppe. Liefert die Datei-/Pattern-Infos für den Download. Rückgabe: {files:[paths], first:path, total_bytes:int, mmproj:path|None, split:bool} """ - tree = _tree(repo) ggufs = [e for e in tree if str(e.get("path", "")).lower().endswith(".gguf")] q = quant.lower() # mmproj separat (Vision-Projektor) diff --git a/backend/services/maintenance.py b/backend/services/maintenance.py index 4fe45a0..586b8e3 100644 --- a/backend/services/maintenance.py +++ b/backend/services/maintenance.py @@ -50,10 +50,26 @@ _engine_cache = {"ts": 0.0, "avail": False} _ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.IGNORECASE) +# GitHub erlaubt ohne Anmeldung 60 Anfragen pro Stunde. Jeder Blick auf die Update-Details fragte +# bisher neu; jetzt gilt eine Antwort 15 Minuten. Fehler (auch das Anfragelimit) werden nicht gemerkt. +GITHUB_CACHE_S = int(os.environ.get("MC_GITHUB_CACHE_S", "900")) +_github_cache: dict[str, tuple[float, object]] = {} + + +def _github_json(pfad: str, timeout: float = 8) -> object: + jetzt = time.time() + if (eintrag := _github_cache.get(pfad)) and jetzt - eintrag[0] < GITHUB_CACHE_S: + return eintrag[1] + r = httpx.get(f"https://api.github.com/{pfad}", timeout=timeout, headers={"User-Agent": "MissionControl2"}) + r.raise_for_status() + daten = r.json() + _github_cache[pfad] = (jetzt, daten) + return daten + + def _latest_engine_asset_release() -> dict | None: try: - rels = httpx.get(f"https://api.github.com/repos/{ENGINE_REPO}/releases?per_page=15", - timeout=8, headers={"User-Agent": "MissionControl2"}).json() + rels = _github_json(f"repos/{ENGINE_REPO}/releases?per_page=15") for rel in (rels if isinstance(rels, list) else []): if any(_ENGINE_ASSET_RX.search(a.get("name", "")) for a in (rel.get("assets") or [])): return rel @@ -160,9 +176,8 @@ def _swap_update_available() -> bool: avail = False fehler = None try: - rel = httpx.get(f"https://api.github.com/repos/{SWAP_REPO}/releases/latest", - timeout=6, headers={"User-Agent": "MissionControl2"}).json() - tag = str(rel.get("tag_name", "")) + rel = _github_json(f"repos/{SWAP_REPO}/releases/latest", timeout=6) + tag = str(rel.get("tag_name", "")) if isinstance(rel, dict) else "" latest = int(m.group(1)) if (m := re.search(r"(\d{2,})", tag)) else None installed = _installed_swap_version() if latest is not None and installed is not None: @@ -346,8 +361,7 @@ def swap_update_details() -> dict: "latest_build": None, "latest_tag": None, "name": None, "url": None, "body": None} try: - rel = httpx.get(f"https://api.github.com/repos/{SWAP_REPO}/releases/latest", - timeout=8, headers={"User-Agent": "MissionControl2"}).json() + rel = _github_json(f"repos/{SWAP_REPO}/releases/latest") tag = str(rel.get("tag_name", "")) info["latest_tag"] = tag info["latest_build"] = int(m.group(1)) if (m := re.search(r"(\d{2,})", tag)) else None @@ -446,8 +460,8 @@ def _summarize_release(kind: str, key: str, context: str, changes: str) -> dict: def _summarize_hermes_commits(commits: list[dict]) -> dict: subjects = "\n".join(f"- {c['subject']}" for c in commits[:100]) context = ("Es geht um ein Update des Hermes-Agenten (das Gehirn/Werkzeug-System der Box auf " - "Strix Halo; genutzt werden: api_server/Gateway, memory-provider-Plugin 'mc2-memory', " - "terminal-/web-Tools, approvals, cron).") + "Strix Halo; genutzt werden: api_server/Gateway, Telegram, eigenes Gedächtnis, " + "terminal-/web-Tools, Plugins, cron).") return _summarize_release("hermes", commits[0]["hash"] if commits else "", context, subjects) @@ -469,6 +483,8 @@ def hermes_update_details() -> dict: info["error"] = "Hermes-Agent-Repo nicht gefunden." return info path = git["path"] + # Vor dem Abruf: scheitert der (Zeitlimit, Netz), zeigt die Seite trotzdem die laufende Version. + info["installed_version"] = _hermes_version(path) try: branch = (subprocess.run(["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True, timeout=8).stdout.strip() or "main") @@ -484,7 +500,6 @@ def hermes_update_details() -> dict: commits.append({"hash": parts[0], "subject": parts[1], "when": parts[2]}) info["commits"] = commits info["behind"] = len(commits) - info["installed_version"] = _hermes_version(path) if commits: info.update(_summarize_hermes_commits(commits)) except Exception as exc: diff --git a/backend/tests/test_wartung.py b/backend/tests/test_wartung.py new file mode 100644 index 0000000..b67c923 --- /dev/null +++ b/backend/tests/test_wartung.py @@ -0,0 +1,63 @@ +"""Kleinigkeiten der Update-Seite (24.09.2026): Größe je Quantisierung, GitHub-Zwischenspeicher, +Hermes-Version auch dann, wenn der Abruf scheitert.""" + +import subprocess + +import httpx +import pytest +from services import hf, maintenance + + +def test_quants_mit_groesse(monkeypatch): + baum = [ + {"path": "Q8_0/M-Q8_0-00001-of-00002.gguf", "size": 50}, + {"path": "Q8_0/M-Q8_0-00002-of-00002.gguf", "size": 50}, + {"path": "M-Q4_K_M.gguf", "lfs": {"size": 30}}, + {"path": "mmproj-F16.gguf", "size": 5}, + {"path": "README.md", "size": 1}, + ] + aufrufe = [] + monkeypatch.setattr(hf, "_tree", lambda repo: aufrufe.append(repo) or baum) + assert hf.list_quants("x/y") == [{"quant": "Q4_K_M", "total_bytes": 35}, {"quant": "Q8_0", "total_bytes": 105}] + assert aufrufe == ["x/y"] # ein Abruf für alle Stufen + + +class _Antwort: + def __init__(self, status: int, daten: object): + self.status_code = status + self._daten = daten + + def raise_for_status(self): + if self.status_code >= 400: + raise httpx.HTTPStatusError("fehler", request=httpx.Request("GET", "https://x"), + response=httpx.Response(self.status_code)) + + def json(self): + return self._daten + + +def test_github_antworten_werden_gemerkt_fehler_nicht(monkeypatch): + monkeypatch.setattr(maintenance, "_github_cache", {}) + antworten = [_Antwort(403, {"message": "API rate limit exceeded"}), _Antwort(200, {"tag_name": "v258"})] + aufrufe = [] + monkeypatch.setattr(maintenance.httpx, "get", lambda url, **kw: aufrufe.append(url) or antworten.pop(0)) + with pytest.raises(httpx.HTTPStatusError): + maintenance._github_json("repos/a/b/releases/latest") + assert maintenance._github_json("repos/a/b/releases/latest") == {"tag_name": "v258"} + assert maintenance._github_json("repos/a/b/releases/latest") == {"tag_name": "v258"} + assert len(aufrufe) == 2 + + +def test_hermes_version_auch_wenn_der_abruf_haengt(monkeypatch, tmp_path): + (tmp_path / "pyproject.toml").write_text('[project]\nversion = "0.21.4"\n', encoding="utf-8") + monkeypatch.setattr(maintenance.system, "find_hermes_agent_git", lambda: {"path": str(tmp_path)}) + + def run(befehl, **kw): + if "fetch" in befehl: + raise subprocess.TimeoutExpired(befehl, 25) + return subprocess.CompletedProcess(befehl, 0, stdout="main\n", stderr="") + + monkeypatch.setattr(maintenance.subprocess, "run", run) + info = maintenance.hermes_update_details() + assert info["installed_version"] == "0.21.4" + assert "error" in info