` : ''}
`;
}).join('');
}
diff --git a/diagnostic/server.js b/diagnostic/server.js
index 00225f3..d6bef07 100644
--- a/diagnostic/server.js
+++ b/diagnostic/server.js
@@ -473,8 +473,50 @@ function saveLlmCatalog(arr) {
function slugModelId(repo) {
return String(repo).toLowerCase().replace(/^.*\//, "").replace(/-gguf$/,"").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "model";
}
+// ── Quantisierung pro Repo ──────────────────────────────────────────
+// Nicht jedes Repo hat eine Q4_K_M-Datei (unsloth z.B. "UD-Q4_K_XL"). Steht
+// eine Quantisierung im Katalog, die es nicht gibt, beendet sich llama-server
+// sofort und die Box meldet nur "500". Darum fragen wir pro Repo die Dateiliste
+// ab und nehmen die beste vorhandene — dieselbe Reihenfolge wie im llm-adapter.
+const QUANT_PREFERENCE = ["Q4_K_M", "UD-Q4_K_XL", "Q4_K_XL", "Q4_K_S", "IQ4_XS", "IQ4_NL", "Q4_0",
+ "Q5_K_M", "UD-Q5_K_XL", "Q6_K", "Q8_0"];
+const QUANT_RE = /((?:UD-)?(?:IQ[1-4]_(?:XXS|XS|S|M|NL)|Q[2-8]_K(?:_(?:XL|L|M|S))?|Q[4-8]_[01]|BF16|F16|F32))/gi;
+const SPLIT_RE = /-(\d{5})-of-(\d{5})\.gguf$/i;
+function ggufQuant(p) {
+ const base = String(p).split("/").pop().replace(SPLIT_RE, ".gguf").replace(/\.gguf$/i, "");
+ const m = base.match(QUANT_RE);
+ return m ? m[m.length - 1].toUpperCase() : "";
+}
+function isMainGguf(p) {
+ const low = String(p).toLowerCase();
+ const base = low.split("/").pop();
+ return low.endsWith(".gguf") && !base.includes("mmproj") && !base.startsWith("mtp")
+ && !("/" + low).includes("/mtp/") && !base.includes("imatrix");
+}
+// → { quant, sizeGB, quants } oder null (Repo nicht lesbar / kein Modell darin)
+async function hfRepoQuant(repo, wanted = "Q4_K_M") {
+ try {
+ const r = await fetch(`https://huggingface.co/api/models/${repo}/tree/main?recursive=1`,
+ { headers: { "User-Agent": "aria-diagnostic" } });
+ if (!r.ok) return null;
+ const files = (await r.json()).filter(e => e.type === "file" && isMainGguf(e.path))
+ .map(e => ({ path: e.path, size: (e.lfs && e.lfs.size) || e.size || 0, quant: ggufQuant(e.path) }));
+ const have = new Set(files.map(f => f.quant).filter(Boolean));
+ const quant = have.has(String(wanted).toUpperCase()) ? String(wanted).toUpperCase()
+ : QUANT_PREFERENCE.find(q => have.has(q)) || "";
+ if (!quant) return null;
+ const match = files.filter(f => f.quant === quant);
+ const parts = match.some(f => SPLIT_RE.test(f.path)) ? match.filter(f => SPLIT_RE.test(f.path))
+ : [match.reduce((a, b) => (a.path.length <= b.path.length ? a : b))];
+ const bytes = parts.reduce((n, f) => n + f.size, 0);
+ return { quant, sizeGB: Math.round(bytes / 1e8) / 10, quants: [...have].sort() };
+ } catch { return null; }
+}
+
// Holt populaere GGUF-Modelle von der HuggingFace-API und merged sie in den
-// Katalog (kuratierte Eintraege + Beschreibungen bleiben erhalten).
+// Katalog (kuratierte Eintraege + Beschreibungen bleiben erhalten). Fuer jedes
+// HF-Repo wird die TATSAECHLICH vorhandene Quantisierung und Groesse
+// eingetragen — auch bei schon vorhandenen HF-Eintraegen.
async function refreshLlmCatalogFromHF() {
const url = "https://huggingface.co/api/models?search=GGUF&sort=downloads&direction=-1&limit=40";
const r = await fetch(url, { headers: { "User-Agent": "aria-diagnostic" } });
@@ -489,9 +531,24 @@ async function refreshLlmCatalogFromHF() {
const id = slugModelId(repo);
if (byId.has(id)) continue; // kuratierte/vorhandene nicht ueberschreiben
const entry = { id, hfRepo: repo, quant: "Q4_K_M", ctx: 8192, sizeGB: 0,
- description: `HuggingFace · ${(m.downloads || 0).toLocaleString("de")} Downloads`, source: "hf" };
- byId.set(id, entry); added++;
+ description: `HuggingFace · ${(m.downloads || 0).toLocaleString("de")} Downloads`, source: "hf", _new: true };
+ byId.set(id, entry);
}
+ // Quantisierung + Groesse pruefen: alle HF-Eintraege (kuratierte haben
+ // gepflegte Werte). Je 6 gleichzeitig, damit HF nicht drosselt.
+ const toCheck = [...byId.values()].filter(e => e.source === "hf");
+ for (let i = 0; i < toCheck.length; i += 6) {
+ await Promise.all(toCheck.slice(i, i + 6).map(async e => {
+ const q = await hfRepoQuant(e.hfRepo, e.quant || "Q4_K_M");
+ if (!q) {
+ // Kein ladbares Modell im Repo: neue nicht aufnehmen, alte markieren.
+ if (e._new) byId.delete(e.id); else e.unusable = true;
+ return;
+ }
+ e.quant = q.quant; e.sizeGB = q.sizeGB; e.quants = q.quants; delete e.unusable;
+ }));
+ }
+ for (const e of byId.values()) { if (e._new) { added++; delete e._new; } }
const merged = Array.from(byId.values());
saveLlmCatalog(merged);
return { models: merged, added };
diff --git a/xtts/llm-adapter/adapter.py b/xtts/llm-adapter/adapter.py
index 45dd5cb..dfe9a90 100644
--- a/xtts/llm-adapter/adapter.py
+++ b/xtts/llm-adapter/adapter.py
@@ -645,8 +645,33 @@ _INPROGRESS = ".downloadInProgress"
_SPLIT_RE = re.compile(r"-(\d{5})-of-(\d{5})\.gguf$", re.IGNORECASE)
-async def _hf_expected_files(repo: str, quant: str) -> list:
- """Welche GGUF-Dateien zieht 'repo:quant'? [{path, oid, size}]. Fail-soft: []."""
+# Quantisierung aus einem Dateinamen: "gemma-4-12B-it-qat-UD-Q4_K_XL.gguf" → "UD-Q4_K_XL"
+_QUANT_RE = re.compile(r"(?i)((?:UD-)?(?:IQ[1-4]_(?:XXS|XS|S|M|NL)|Q[2-8]_K(?:_(?:XL|L|M|S))?|Q[4-8]_[01]|BF16|F16|F32))")
+# Wenn die gewuenschte fehlt: die erste vorhandene aus dieser Liste. 4-Bit zuerst
+# (beste Groesse/Qualitaet fuer 12-GB-Karten), dann groessere.
+QUANT_PREFERENCE = ["Q4_K_M", "UD-Q4_K_XL", "Q4_K_XL", "Q4_K_S", "IQ4_XS", "IQ4_NL", "Q4_0",
+ "Q5_K_M", "UD-Q5_K_XL", "Q6_K", "Q8_0"]
+
+
+def _quant_of(path: str) -> str:
+ base = os.path.basename(path)
+ base = _SPLIT_RE.sub(".gguf", base)
+ m = _QUANT_RE.findall(base[:-5] if base.lower().endswith(".gguf") else base)
+ return m[-1].upper() if m else ""
+
+
+def _is_main_gguf(path: str) -> bool:
+ """Das eigentliche Modell — nicht Bild-Projektor (mmproj), nicht die
+ Zusatzdatei fuers spekulative Dekodieren (mtp), keine imatrix."""
+ low = path.lower()
+ base = os.path.basename(low)
+ return (low.endswith(".gguf") and "mmproj" not in base and not base.startswith("mtp")
+ and "/mtp/" not in ("/" + low) and "imatrix" not in base)
+
+
+async def _hf_repo_files(repo: str):
+ """Alle Modell-GGUFs eines Repos: [{path, oid, size, quant}] — oder None,
+ wenn Hugging Face nicht erreichbar ist (dann pruefen wir eben nicht)."""
url = f"https://huggingface.co/api/models/{repo}/tree/main?recursive=1"
headers = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {}
try:
@@ -655,28 +680,42 @@ async def _hf_expected_files(repo: str, quant: str) -> list:
r.raise_for_status()
entries = r.json()
except Exception as e:
- logger.warning("HF-Dateiliste fuer %s nicht abrufbar (%s) — Fortschritt ohne Gesamtgroesse", repo, e)
- return []
- q = (quant or "").lower()
- files = []
+ logger.warning("HF-Dateiliste fuer %s nicht abrufbar (%s)", repo, e)
+ return None
+ out = []
for e in entries if isinstance(entries, list) else []:
path = e.get("path") or ""
- low = path.lower()
- if e.get("type") != "file" or not low.endswith(".gguf") or "mmproj" in low:
- continue
- if q and q not in low:
+ if e.get("type") != "file" or not _is_main_gguf(path):
continue
lfs = e.get("lfs") or {}
- files.append({"path": path, "oid": lfs.get("oid") or e.get("oid") or "",
- "size": int(lfs.get("size") or e.get("size") or 0)})
- if not files:
- return []
- # Gesplittetes Modell → alle Teile; sonst die eine passende Datei (bei
- # mehreren Treffern die mit dem kuerzesten Namen, z.B. ohne "-imat").
- split = [f for f in files if _SPLIT_RE.search(f["path"])]
+ out.append({"path": path, "oid": lfs.get("oid") or e.get("oid") or "",
+ "size": int(lfs.get("size") or e.get("size") or 0), "quant": _quant_of(path)})
+ return out
+
+
+def _pick_quant(files: list, wanted: str) -> str:
+ """Die gewuenschte Quantisierung, wenn es sie gibt — sonst die erste aus
+ QUANT_PREFERENCE, die das Repo hat. "" wenn gar nichts Brauchbares da ist."""
+ have = {f["quant"] for f in files if f["quant"]}
+ if wanted and wanted.upper() in have:
+ return wanted.upper()
+ return next((q for q in QUANT_PREFERENCE if q in have), "")
+
+
+def _files_for_quant(files: list, quant: str) -> list:
+ """Die Datei(en), die llama.cpp fuer repo:quant zieht. Gesplittet → alle
+ Teile; sonst die eine (bei mehreren die mit dem kuerzesten Namen)."""
+ match = [f for f in files if f["quant"] == quant.upper()]
+ split = [f for f in match if _SPLIT_RE.search(f["path"])]
if split:
return split
- return [min(files, key=lambda f: len(f["path"]))]
+ return [min(match, key=lambda f: len(f["path"]))] if match else []
+
+
+async def _hf_expected_files(repo: str, quant: str) -> list:
+ """Welche GGUF-Dateien zieht 'repo:quant'? [{path, oid, size}]. Fail-soft: []."""
+ files = await _hf_repo_files(repo)
+ return _files_for_quant(files, quant) if files else []
def _scan_download(oids: set) -> tuple:
@@ -792,10 +831,32 @@ async def _handle_provision(ws, payload: dict) -> None:
return await _provision_result(
ws, key, False, gpu=card,
error=f"{key} liegt schon auf Karte {elsewhere} dieser Box — ein Modell pro Box nur einmal.")
+ quant_note = ""
+ repo_files = None
if repo:
+ wanted = (payload.get("quant") or "Q4_K_M").strip()
+ quant = wanted
+ # Gibt es die Datei ueberhaupt? Fehlt sie, beendet sich llama-server
+ # sofort und llama-swap meldet nur "500" — der Grund geht verloren.
+ # Lieber vorher pruefen und ggf. die naechstbeste Quantisierung nehmen
+ # (unsloth z.B. hat statt Q4_K_M ein UD-Q4_K_XL).
+ repo_files = await _hf_repo_files(repo)
+ if repo_files is not None:
+ if not repo_files:
+ return await _provision_result(
+ ws, key, False, gpu=card,
+ error=f"In {repo} gibt es keine GGUF-Modelldatei.")
+ quant = _pick_quant(repo_files, wanted)
+ if not quant:
+ have = sorted({f["quant"] for f in repo_files if f["quant"]}) or ["?"]
+ return await _provision_result(
+ ws, key, False, gpu=card,
+ error=f"{repo}: keine brauchbare Quantisierung. Vorhanden: {', '.join(have)}")
+ if quant != wanted.upper():
+ quant_note = f"{wanted} gibt es in {repo} nicht — nehme {quant}"
+ logger.info("Karte %s: %s", card, quant_note)
entry = {
- "key": key, "hfRepo": repo,
- "quant": (payload.get("quant") or "Q4_K_M").strip(),
+ "key": key, "hfRepo": repo, "quant": quant,
"ctx": int(payload.get("ctx") or 8192),
"ngl": int(payload.get("ngl") or 99),
}
@@ -809,9 +870,10 @@ async def _handle_provision(ws, payload: dict) -> None:
_generate_config()
quant = next((e.get("quant") for e in _load_registry() if e.get("key") == key), "") or "Q4_K_M"
- files = await _hf_expected_files(repo, quant) if repo else []
+ files = _files_for_quant(repo_files, quant) if repo_files else []
await _emit_progress(ws, key, "start", total=sum(f["size"] for f in files),
- files=len(files), gpu=card, replaces=previous)
+ files=len(files), gpu=card, replaces=previous, quant=quant,
+ note=quant_note)
await _reload_llama()
await _announce(ws)
stop = asyncio.Event()
@@ -842,6 +904,14 @@ async def _handle_provision(ws, payload: dict) -> None:
# ein kaputtes Modell in der Config, und jedes Neuladen von llama-swap
# risse die anderen Karten mit.
err = (res.get("error") or "Laden fehlgeschlagen")[:200]
+ if "500" in err and dt < 120:
+ # llama-swap reicht den Grund nicht weiter ("upstream command exited
+ # prematurely"). Kam der Abbruch schnell, lief kein Download — dann ist
+ # es fast immer eins von dreien.
+ err = ("llama.cpp hat sich beim Start sofort beendet. Haeufigste Gruende: "
+ "die Modell-Architektur ist neuer als die llama.cpp-Version der Box, "
+ "zu wenig (V)RAM, oder die Datei passt nicht. (llama-swap gibt den "
+ "genauen Grund nicht weiter.)")
slots = _load_slots()
if previous:
slots[card] = previous