Ampel / ampel (push) Failing after 21s
Suche aus Merkliste (deploy/radar-watchlist.json) und Hugging-Face-Entdeckung; nur Kandidaten mit Bild-Projektor, die neben das Warm-Set passen (<= 115 GB inkl. KV-Cache). Nachtlauf mc2-radar.timer 00:30, Tests nur bis 02:30 (um 03:00 kommt NerdQuiz), hoechstens ein neuer Kandidat pro Woche, Notbremse 02:35, RuntimeMaxSec als letzte Sicherung. Pruefstand als Modul (deploy/bench/pruefstand.py): Tempo, Werkzeuge, Deutsch/JSON bzw. Programmieraufgaben und Bild-Probe gegen das heutige Modell der Rolle. Durchgefallene werden geloescht, Bestandene gemeldet und erst nach "Uebernehmen" getauscht. Beim Uebernehmen wandern die Zweitrollen mit (fast beim Hirn, heavy beim Coder), und das Warm-Set des Stewards wird selbst umgestellt statt als Handgriff zu bleiben. Modell-Code im Pruefstand darf keine Prozesse starten (RLIMIT_NPROC=0). Radar steht im Flugplan und unter Waechter-Aufsicht; deploy.sh spielt seine Units ein. Oberflaeche: Vergleichswerte je Kandidat, Rueckfrage vor Uebernehmen und Verwerfen, Status auf Deutsch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
628 lines
27 KiB
Python
628 lines
27 KiB
Python
"""
|
|
Engine-Service: liest/schreibt die llama-swap config.yaml und spricht die
|
|
llama-swap-API. Portiert & erweitert aus Mission Control v1.
|
|
|
|
NEU in 2.0: `groups` für Ko-Residenz (schnell + schwer gleichzeitig geladen,
|
|
`swap:false`) → Multi-Model-Delegation ohne Nachlade-Latenz.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
|
|
import httpx
|
|
from config import (
|
|
CMD_TEMPLATE,
|
|
CONFIG_PATH,
|
|
DEFAULT_TTL,
|
|
DRAFTS_DIR,
|
|
LLAMA_SWAP_URL,
|
|
SPEC_DRAFT_MODEL_PATH,
|
|
SPEC_DRAFT_N_MAX,
|
|
SPEC_TYPE,
|
|
)
|
|
from ruamel.yaml.scalarstring import LiteralScalarString
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Kanonische Serving-Rollen — EINE Quelle der Wahrheit (identisch zu sources.ROLE_IDS,
|
|
# maintenance, frontend ModelBadges.ROLES). `hermes` = Lucys Agent-Hirn (warm + ko-resident
|
|
# in der `brains`-Gruppe); UI-Label „Hirn".
|
|
ROLE_IDS = {"fast", "heavy", "coder", "vision", "scout", "hermes", "kritiker", "reranker"}
|
|
|
|
_CTX_RE = re.compile(r"-(?:c|-ctx-size)\s+(\d+)")
|
|
_PATH_RE = re.compile(r"-(?:m|-model)\s+([^\s]+)")
|
|
_QUANT_RE = re.compile(r"(Q\d_[A-Z0-9_]+|IQ\d_[A-Z0-9_]+|fp16|bf16)\.gguf", re.IGNORECASE)
|
|
_SPLIT_RE = re.compile(r"-(\d+)-of-(\d+)\.gguf$", re.IGNORECASE)
|
|
|
|
|
|
def _gguf_total_size(path: str) -> int | None:
|
|
"""Gesamtgröße eines GGUF inkl. ALLER Split-Teile (…-00001-of-00003.gguf).
|
|
Die Größe nur des ersten Teils ist bei Splits irreführend (oft nur ein Header)."""
|
|
try:
|
|
base = os.path.basename(path)
|
|
m = _SPLIT_RE.search(base)
|
|
if not m:
|
|
return os.path.getsize(path)
|
|
prefix, dirn = base[:m.start()], os.path.dirname(path)
|
|
total = sum(os.path.getsize(os.path.join(dirn, f))
|
|
for f in os.listdir(dirn)
|
|
if f.startswith(prefix) and _SPLIT_RE.search(f))
|
|
return total or os.path.getsize(path)
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
# --- Lesen -------------------------------------------------------------------
|
|
def read_config() -> dict:
|
|
if not CONFIG_PATH.exists():
|
|
return {"models": {}}
|
|
from ruamel.yaml import YAML
|
|
r_yaml = YAML()
|
|
r_yaml.preserve_quotes = True
|
|
with CONFIG_PATH.open("r", encoding="utf-8") as f:
|
|
data = r_yaml.load(f) or {}
|
|
if not data.get("models"):
|
|
data["models"] = {}
|
|
return data
|
|
|
|
|
|
def _parse_model(name: str, spec: dict) -> dict:
|
|
spec = spec or {}
|
|
cmd = str(spec.get("cmd", "")).strip()
|
|
ctx = int(m.group(1)) if (m := _CTX_RE.search(cmd)) else None
|
|
|
|
path = filename = quant = ""
|
|
size_bytes = None
|
|
if (m := _PATH_RE.search(cmd)):
|
|
path = m.group(1).replace("'", "").replace('"', "")
|
|
filename = os.path.basename(path)
|
|
if os.path.exists(path):
|
|
size_bytes = _gguf_total_size(path)
|
|
if (q := _QUANT_RE.search(path)):
|
|
quant = q.group(1).upper()
|
|
|
|
aliases = spec.get("aliases") or []
|
|
if isinstance(aliases, str):
|
|
aliases = [aliases]
|
|
aliases = [str(a) for a in aliases]
|
|
role = aliases[0].lower() if aliases else (name.lower() if name.lower() in ROLE_IDS else None)
|
|
|
|
prompt_cache = "--prompt-cache " in cmd or cmd.endswith("--prompt-cache") or "--prompt-cache-all" in cmd
|
|
# Draft-Modell erkennen — klassisch (--spec-draft-model) ODER MTP (--model-draft / -md).
|
|
spec_draft = None
|
|
if (m_draft := re.search(r"--(?:spec-draft-model|model-draft)\s+([^\s]+)", cmd)) \
|
|
or (m_draft := re.search(r"(?<![\w-])-md\s+([^\s]+)", cmd)):
|
|
spec_draft = os.path.basename(m_draft.group(1).replace("'", "").replace('"', ""))
|
|
spec_type = None
|
|
if (m_st := re.search(r"--spec-type\s+([^\s]+)", cmd)):
|
|
spec_type = m_st.group(1)
|
|
# Spec ist nur AKTIV, wenn BEIDES gesetzt ist (Draft-Modell UND --spec-type).
|
|
spec_active = bool(spec_draft and spec_type)
|
|
parallel_match = re.search(r"--parallel\s+(\d+)", cmd)
|
|
parallel_slots = int(parallel_match.group(1)) if parallel_match else 1
|
|
|
|
from services.caps import capabilities
|
|
return {
|
|
"name": name,
|
|
"role": role,
|
|
"aliases": aliases,
|
|
"api_ids": [name] + aliases,
|
|
"ctx": ctx,
|
|
"ttl": spec.get("ttl"),
|
|
"cmd": cmd,
|
|
"gguf_path": path,
|
|
"filename": filename,
|
|
"quant": quant,
|
|
"size_bytes": size_bytes,
|
|
"incomplete": not path,
|
|
"prompt_cache": prompt_cache,
|
|
"spec_draft_model": spec_draft,
|
|
"spec_type": spec_type,
|
|
"spec_active": spec_active,
|
|
"parallel_slots": parallel_slots,
|
|
"capabilities": capabilities(
|
|
name=filename or name, cmd=cmd,
|
|
gguf_path=(path if (path and os.path.exists(path)) else ""),
|
|
),
|
|
}
|
|
|
|
|
|
def list_models() -> list[dict]:
|
|
cfg = read_config()
|
|
return [_parse_model(name, spec) for name, spec in (cfg.get("models") or {}).items()]
|
|
|
|
|
|
def engine_reachable() -> bool:
|
|
try:
|
|
with httpx.Client(timeout=3.0) as c:
|
|
return c.get(f"{LLAMA_SWAP_URL}/v1/models").status_code == 200
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
# --- Schreiben ---------------------------------------------------------------
|
|
def model_id_from_path(model_path: str) -> str:
|
|
"""Sprechende Modell-ID (= API-Name) aus dem GGUF-Pfad: Repo-Ordnername ohne
|
|
'-GGUF'. Fallback: Dateiname ohne Quant-Suffix.
|
|
Split-GGUFs liegen oft in einem Quant-Unterordner (…/Q4_K_M/file-00001-of-…) →
|
|
dann eine Ebene höher (Repo-Ordner) nehmen, sonst hieße das Modell 'Q4_K_M'."""
|
|
d = os.path.basename(os.path.dirname(model_path))
|
|
if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.IGNORECASE):
|
|
d = os.path.basename(os.path.dirname(os.path.dirname(model_path)))
|
|
name = re.sub(r"[-_]?GGUF$", "", d, flags=re.IGNORECASE).strip("-_")
|
|
if not name:
|
|
fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.IGNORECASE)
|
|
fn = re.sub(r"-\d+-of-\d+$", "", fn)
|
|
name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.IGNORECASE)
|
|
return name or "modell"
|
|
|
|
|
|
# Lebenswichtige Aliase: hängen direkt an Lucys Denk- und Gedächtnis-Pfad. Sie dürfen
|
|
# beim Rollen-Umhängen NIE stillschweigend verloren gehen (Review 16.07.: Qwen3.6 hält
|
|
# live `hermes` UND `fast` — ein Rollen-Klick hätte beide gelöscht → Lucy tot).
|
|
PROTECTED_ALIASES = {"hermes", "embed"}
|
|
|
|
|
|
def protected_alias_of(model_id: str) -> str | None:
|
|
"""Hält dieses Modell gerade einen lebenswichtigen Alias? (für Guards in der API)"""
|
|
spec = (read_config().get("models") or {}).get(model_id)
|
|
if isinstance(spec, dict):
|
|
for a in spec.get("aliases") or []:
|
|
if str(a).lower() in PROTECTED_ALIASES:
|
|
return str(a).lower()
|
|
return None
|
|
|
|
|
|
def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
|
|
"""Rolle als llama-swap-`aliases`-Eintrag setzen (vorher bei allen anderen Modellen
|
|
entfernen — deren übrige Aliase bleiben). role=None/leer entfernt die Rolle.
|
|
Lebenswichtige Aliase (PROTECTED_ALIASES) des Ziel-Modells bleiben IMMER erhalten."""
|
|
models = cfg.get("models") or {}
|
|
role = (role or "").strip().lower()
|
|
if role:
|
|
for mid, spec in models.items():
|
|
if mid == model_id or not isinstance(spec, dict):
|
|
continue
|
|
al = [a for a in (spec.get("aliases") or []) if str(a).lower() != role]
|
|
if al:
|
|
spec["aliases"] = al
|
|
else:
|
|
spec.pop("aliases", None)
|
|
spec = models.get(model_id)
|
|
if isinstance(spec, dict):
|
|
keep = [a for a in (spec.get("aliases") or [])
|
|
if str(a).lower() in PROTECTED_ALIASES and str(a).lower() != role]
|
|
new = keep + ([role] if role and role != model_id.lower() else [])
|
|
if new:
|
|
spec["aliases"] = new
|
|
else:
|
|
spec.pop("aliases", None)
|
|
|
|
|
|
def _augment_vision(cmd: str, model_path: str, mmproj_path: str | None) -> str:
|
|
"""Vision-Modelle brauchen --mmproj <projektor> und --jinja."""
|
|
if mmproj_path:
|
|
if "--mmproj" not in cmd:
|
|
cmd += f" --mmproj {mmproj_path}"
|
|
if "--jinja" not in cmd:
|
|
cmd += " --jinja"
|
|
return cmd
|
|
|
|
|
|
def write_config(cfg: dict) -> None:
|
|
"""Atomar schreiben (tmp + os.replace), damit llama-swap mit -watch-config nie
|
|
eine halbe Datei sieht. Fehlende Schreibrechte → klare Meldung."""
|
|
try:
|
|
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = CONFIG_PATH.with_name(CONFIG_PATH.name + ".tmp")
|
|
from ruamel.yaml import YAML
|
|
r_yaml = YAML()
|
|
r_yaml.preserve_quotes = True
|
|
with tmp.open("w", encoding="utf-8") as f:
|
|
r_yaml.dump(cfg, f)
|
|
os.replace(tmp, CONFIG_PATH)
|
|
# llama-swap (-watch-config) lädt jetzt neu und verwirft dabei alle Modelle inkl. Hirn.
|
|
# Den Re-Warm-Wächter anstoßen, damit das Hirn nicht bis zum nächsten Tick kalt liegt.
|
|
try:
|
|
from services import warmer
|
|
warmer.nudge()
|
|
except Exception:
|
|
pass
|
|
except PermissionError as exc:
|
|
raise PermissionError(
|
|
f"Mission Control darf '{CONFIG_PATH}' nicht schreiben. "
|
|
f"Einmalig: sudo chown -R hitonabi:hitonabi {CONFIG_PATH.parent}"
|
|
) from exc
|
|
|
|
|
|
|
|
def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
|
|
ttl: int | None = None, mmproj_path: str | None = None,
|
|
jinja: bool = False, set_alias: bool = True) -> str:
|
|
"""Ein GGUF als llama-swap-Modell eintragen (cmd + Rolle-Alias). Gibt die
|
|
Modell-ID zurück. jinja=True erzwingt --jinja (Tool-Calling, z.B. fürs Agent-Hirn).
|
|
set_alias=False: Rolle nur für die cmd-Flags nutzen, den Alias aber NICHT umhängen —
|
|
der Install-Flow setzt ihn erst NACH fertigem Download (sonst zeigt die Rolle
|
|
minutenlang auf eine Datei, die noch gar nicht existiert)."""
|
|
cfg = read_config()
|
|
model_id = model_id_from_path(model_path)
|
|
cmd = CMD_TEMPLATE.replace("{model}", model_path).replace("{ctx}", str(ctx))
|
|
cmd = _augment_vision(cmd, model_path, mmproj_path)
|
|
if jinja and "--jinja" not in cmd:
|
|
cmd += " --jinja"
|
|
|
|
role_lower = (role or "").strip().lower()
|
|
# KV-Cache-Reuse über Turns (Prompt-Cache wiederverwenden) — hilft allen Chat-Modellen
|
|
# (Agent-Hirn, Coding, Multi-Turn). Spiegelt die auf der Box bewährten Flags wider, damit
|
|
# neu installierte Modelle nicht hinter dem hand-getunten Stand zurückbleiben (Drift-Fix).
|
|
# NICHT bei Vision-Modellen: --cache-reuse + --mmproj bricht llama-server (live verifiziert,
|
|
# deshalb fahren vision/scout auf der Box ohne cache-reuse).
|
|
if "--cache-reuse" not in cmd and "--mmproj" not in cmd:
|
|
cmd += " --cache-reuse 256 -cram 16384"
|
|
# IDE-Coding profitiert von Nebenläufigkeit; sonst Default 1 Slot = voller Kontext/Anfrage
|
|
# (--parallel teilt den Kontext HART auf die Slots auf, s. docs/OPTIMIZATION_PLAN.md §9.4 V6).
|
|
if role_lower == "coder" and "--parallel" not in cmd:
|
|
cmd += " --parallel 2"
|
|
# Vocab-kompatiblen Draft automatisch anhängen — klassisch (DRAFTS_DIR) ODER MTP-Kopf neben
|
|
# dem Modell. Self-guarding: ohne kompatiblen/vorhandenen Draft passiert nichts (später im UI
|
|
# setzbar). Bei frischem Install existiert die Modell-GGUF noch nicht → ebenfalls kein Draft.
|
|
if "--spec-draft-model" not in cmd and "--model-draft" not in cmd:
|
|
cmd += spec_draft_flags(model_path)
|
|
|
|
# Bestehende Aliase des Eintrags erhalten (Re-Install/Upgrade desselben Repos):
|
|
# die Rolle soll während des Downloads beim ALTEN Stand bleiben.
|
|
old_aliases = (cfg.get("models") or {}).get(model_id, {})
|
|
old_aliases = old_aliases.get("aliases") if isinstance(old_aliases, dict) else None
|
|
entry: dict = {
|
|
"cmd": LiteralScalarString(cmd + "\n"),
|
|
"ttl": ttl if ttl is not None else DEFAULT_TTL,
|
|
}
|
|
if old_aliases:
|
|
entry["aliases"] = old_aliases
|
|
cfg.setdefault("models", {})[model_id] = entry
|
|
if set_alias:
|
|
set_role_alias(cfg, model_id, role)
|
|
write_config(cfg)
|
|
return model_id
|
|
|
|
|
|
# --- Speculative-Draft / Vocab-Kompatibilität --------------------------------
|
|
def list_drafts() -> list[dict]:
|
|
"""Alle Draft-GGUFs in DRAFTS_DIR mit Tokenizer-Fingerprint."""
|
|
from services import gguf_meta
|
|
out = []
|
|
if DRAFTS_DIR.is_dir():
|
|
for p in sorted(DRAFTS_DIR.glob("*.gguf")):
|
|
out.append({
|
|
"path": str(p), "filename": p.name,
|
|
"size_bytes": p.stat().st_size if p.exists() else None,
|
|
"vocab": gguf_meta.fingerprint(str(p)),
|
|
})
|
|
return out
|
|
|
|
|
|
def _is_mtp_draft(draft_path: str) -> bool:
|
|
"""Ist dieser Draft ein MTP-Kopf (Multi-Token-Prediction) statt eines klassischen
|
|
Draft-Modells? MTP-Köpfe (z.B. gemma-4) laden mit `--model-draft … --spec-type
|
|
draft-mtp` statt `--spec-draft-model … --spec-type draft-simple`. Erkennung am Arch
|
|
('…-assistant' / 'mtp') oder Dateinamen ('mtp-*', '*-MTP', '*-assistant')."""
|
|
base = os.path.basename(draft_path).lower()
|
|
if base.startswith("mtp-") or "-mtp" in base or "assistant" in base:
|
|
return True
|
|
from services import gguf_meta
|
|
arch = ((gguf_meta.fingerprint(draft_path) or {}).get("arch") or "").lower()
|
|
return arch.endswith("-assistant") or "mtp" in arch
|
|
|
|
|
|
def _spec_flags_for_draft(draft_path: str) -> str:
|
|
"""Korrekte llama-server-Spec-Flags für einen (vocab-kompatiblen) Draft. MTP-Kopf →
|
|
`--model-draft … --spec-type draft-mtp --spec-draft-n-max N`; klassischer Draft →
|
|
`--spec-draft-model … --spec-type draft-simple`. (Beides nötig, sonst Spec inaktiv.)"""
|
|
if _is_mtp_draft(draft_path):
|
|
return (f" --model-draft {draft_path} --spec-type draft-mtp"
|
|
f" --spec-draft-n-max {SPEC_DRAFT_N_MAX}")
|
|
return f" --spec-draft-model {draft_path} --spec-type {SPEC_TYPE}"
|
|
|
|
|
|
def _sibling_mtp_drafters(target_path: str) -> list[str]:
|
|
"""MTP-Kopf-GGUFs NEBEN dem Zielmodell (gleicher Ordner): 'mtp-*.gguf', '*-MTP.gguf',
|
|
'*-assistant*.gguf'. Per Konstruktion vocab-identisch zum Modell → idealer Draft."""
|
|
out: list[str] = []
|
|
d = os.path.dirname(target_path)
|
|
if os.path.isdir(d):
|
|
for f in sorted(os.listdir(d)):
|
|
fl = f.lower()
|
|
if fl.endswith(".gguf") and (fl.startswith("mtp-") or "-mtp" in fl or "assistant" in fl):
|
|
p = os.path.join(d, f)
|
|
if p != target_path:
|
|
out.append(p)
|
|
return out
|
|
|
|
|
|
def find_compatible_draft(target_path: str) -> str | None:
|
|
"""Pfad eines vocab-kompatiblen Drafts für target_path, oder None.
|
|
Bevorzugt einen MTP-Kopf NEBEN dem Modell (höchste Qualität, by-construction),
|
|
dann MC_SPEC_DRAFT_MODEL (falls gesetzt+kompatibel), sonst der erste kompatible
|
|
Draft in DRAFTS_DIR. None auch, wenn target (noch) fehlt (nicht verifizierbar →
|
|
bewusst KEIN Draft anhängen)."""
|
|
if not target_path or not os.path.exists(target_path):
|
|
return None
|
|
from services import gguf_meta
|
|
candidates: list[str] = list(_sibling_mtp_drafters(target_path))
|
|
if SPEC_DRAFT_MODEL_PATH and os.path.exists(SPEC_DRAFT_MODEL_PATH):
|
|
candidates.append(SPEC_DRAFT_MODEL_PATH)
|
|
for d in list_drafts():
|
|
if d["path"] not in candidates:
|
|
candidates.append(d["path"])
|
|
for c in candidates:
|
|
if gguf_meta.compatible(target_path, c) is True:
|
|
return c
|
|
return None
|
|
|
|
|
|
def spec_draft_flags(target_path: str) -> str:
|
|
"""llama-server-Flags für Speculative Decoding (Draft + --spec-type), oder ''
|
|
wenn kein kompatibler Draft existiert. MTP-bewusst (s. _spec_flags_for_draft)."""
|
|
d = find_compatible_draft(target_path)
|
|
return _spec_flags_for_draft(d) if d else ""
|
|
|
|
|
|
def drafts_for(target_path: str) -> dict:
|
|
"""Für die UI: alle Drafts + ihre Kompatibilität zum Ziel-Modell. Schließt MTP-Köpfe
|
|
NEBEN dem Zielmodell ein (DRAFTS_DIR kennt sie nicht). `mtp:true` markiert MTP-Drafts.
|
|
compatible=None heißt 'nicht prüfbar' (Ziel- oder Draft-GGUF fehlt)."""
|
|
from services import gguf_meta
|
|
exists = bool(target_path and os.path.exists(target_path))
|
|
drafts = list_drafts()
|
|
seen = {d["path"] for d in drafts}
|
|
for p in _sibling_mtp_drafters(target_path):
|
|
if p not in seen:
|
|
drafts.append({"path": p, "filename": os.path.basename(p),
|
|
"size_bytes": os.path.getsize(p) if os.path.exists(p) else None,
|
|
"vocab": gguf_meta.fingerprint(p)})
|
|
for d in drafts:
|
|
d["compatible"] = gguf_meta.compatible(target_path, d["path"]) if exists else None
|
|
d["mtp"] = _is_mtp_draft(d["path"])
|
|
return {
|
|
"target_path": target_path,
|
|
"target_exists": exists,
|
|
"target_vocab": gguf_meta.fingerprint(target_path) if exists else None,
|
|
"drafts": drafts,
|
|
}
|
|
|
|
|
|
def set_spec_draft(model_id: str, draft_path: str | None) -> dict:
|
|
"""Setzt (oder entfernt mit draft_path=None) den Spec-Draft eines Modells.
|
|
Validiert die Vocab-Kompatibilität — ein inkompatibler/unprüfbarer Draft wird
|
|
abgelehnt (idiotensicher). Returns {ok, reason}."""
|
|
cfg = read_config()
|
|
spec = (cfg.get("models") or {}).get(model_id)
|
|
if not isinstance(spec, dict):
|
|
return {"ok": False, "reason": "Modell nicht gefunden"}
|
|
cmd = str(spec.get("cmd", ""))
|
|
# vorhandene Spec-Flags entfernen (idempotent) — klassisch UND MTP.
|
|
cmd = re.sub(r"\s+--(?:spec-draft-model|model-draft)\s+\S+", "", cmd)
|
|
cmd = re.sub(r"\s+-md\s+\S+", "", cmd)
|
|
cmd = re.sub(r"\s+--spec-type\s+\S+", "", cmd)
|
|
cmd = re.sub(r"\s+--spec-draft-n-(?:max|min)\s+\S+", "", cmd)
|
|
|
|
if draft_path:
|
|
# relative Angabe (nur Dateiname) gegen DRAFTS_DIR auflösen
|
|
if not os.path.isabs(draft_path) and "/" not in draft_path:
|
|
draft_path = str(DRAFTS_DIR / draft_path)
|
|
if not os.path.exists(draft_path):
|
|
return {"ok": False, "reason": "Draft-Datei nicht gefunden"}
|
|
from services import gguf_meta
|
|
target = ""
|
|
if (mt := _PATH_RE.search(cmd)):
|
|
target = mt.group(1).replace("'", "").replace('"', "")
|
|
comp = gguf_meta.compatible(target, draft_path) if os.path.exists(target) else None
|
|
if comp is not True:
|
|
reason = ("Draft ist NICHT vocab-kompatibel zum Modell — Speculative Decoding "
|
|
"würde beim Laden scheitern."
|
|
if comp is False else
|
|
"Kompatibilität nicht prüfbar (Modell-GGUF fehlt) — Draft nicht gesetzt.")
|
|
return {"ok": False, "reason": reason}
|
|
cmd = cmd.rstrip() + _spec_flags_for_draft(draft_path)
|
|
|
|
spec["cmd"] = LiteralScalarString(cmd.rstrip() + "\n")
|
|
write_config(cfg)
|
|
return {"ok": True, "reason": ""}
|
|
|
|
|
|
# --- Groups (Ko-Residenz) ----------------------------------------------------
|
|
def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None:
|
|
"""llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen
|
|
GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True →
|
|
Mitglieder werden nie von anderen Gruppen verdrängt.
|
|
|
|
llama-swap kennt dafür AUSSCHLIESSLICH den Key `persistent` — `persist` wird von ihm
|
|
stillschweigend ignoriert (so verlor das Hirn seinen Verdrängungsschutz; live gefunden
|
|
03.07.2026: Coder-Last warf die brains-Gruppe raus). `persist` wird zusätzlich weiter
|
|
geschrieben, weil MC2-API/UI (routers/models.py, agent.py, Frontend) diesen Key lesen."""
|
|
cfg = read_config()
|
|
groups = cfg.setdefault("groups", {})
|
|
groups[group] = {"swap": swap, "persist": persist, "persistent": persist,
|
|
"members": list(members)}
|
|
# Härtung: eine persistente (immer-warme) Gruppe verlangt ttl:0 je Mitglied.
|
|
# `persistent` schützt NUR gegen Verdrängung durch andere Modelle, NICHT gegen
|
|
# ttl-Selbstentladen — ein Mitglied mit ttl>0 fällt trotz Gruppen-Mitgliedschaft
|
|
# nach Leerlauf aus dem Warm-Set (live gefunden 03.07.2026: VL-30B mit ttl 300
|
|
# entlud sich alle 5 Min). So kann dieser Fehler beim Warm-Set-Umbau nie wieder passieren.
|
|
if persist:
|
|
models = cfg.get("models") or {}
|
|
for mid in members:
|
|
spec = models.get(mid)
|
|
if isinstance(spec, dict):
|
|
spec["ttl"] = 0
|
|
write_config(cfg)
|
|
|
|
|
|
def list_groups() -> dict:
|
|
return read_config().get("groups") or {}
|
|
|
|
|
|
def set_role(model_id: str, role: str | None) -> bool:
|
|
"""Rolle (llama-swap-Alias) eines bestehenden Modells setzen/ändern. So tauscht man
|
|
z.B. das `fast`-Hirn: Rolle `fast` auf ein anderes Modell legen (Alias wandert)."""
|
|
cfg = read_config()
|
|
if model_id not in (cfg.get("models") or {}):
|
|
return False
|
|
set_role_alias(cfg, model_id, role)
|
|
write_config(cfg)
|
|
return True
|
|
|
|
|
|
def add_role(model_id: str, role: str) -> bool:
|
|
"""Wie set_role, aber die übrigen Aliase des Ziel-Modells bleiben — für Modelle mit zwei Rollen
|
|
(Coder: coder UND heavy). set_role behielte nur die geschützten Aliase und würfe coder wieder weg."""
|
|
cfg = read_config()
|
|
models = cfg.get("models") or {}
|
|
rolle = (role or "").strip().lower()
|
|
if model_id not in models or not rolle or not isinstance(models[model_id], dict):
|
|
return False
|
|
bisher = [a for a in (models[model_id].get("aliases") or []) if str(a).lower() != rolle]
|
|
set_role_alias(cfg, model_id, rolle) # nimmt die Rolle allen anderen Modellen weg
|
|
models[model_id]["aliases"] = bisher + [rolle]
|
|
write_config(cfg)
|
|
return True
|
|
|
|
|
|
def set_ctx(model_id: str, ctx: int) -> bool:
|
|
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
|
|
cfg = read_config()
|
|
spec = (cfg.get("models") or {}).get(model_id)
|
|
if not spec:
|
|
return False
|
|
cmd = str(spec.get("cmd", ""))
|
|
if _CTX_RE.search(cmd):
|
|
cmd = re.sub(r"-(?:c|-ctx-size)\s+\d+", f"-c {ctx}", cmd)
|
|
else:
|
|
cmd = cmd.rstrip() + f" -c {ctx}"
|
|
spec["cmd"] = LiteralScalarString(cmd if cmd.endswith("\n") else cmd + "\n")
|
|
write_config(cfg)
|
|
return True
|
|
|
|
|
|
def set_ttl(model_id: str, ttl: int) -> bool:
|
|
"""Idle-TTL (Sekunden) eines bestehenden Modells setzen. ttl=0 → nie automatisch
|
|
entladen (für das Agent-Hirn, das dauerhaft warm bleiben muss)."""
|
|
cfg = read_config()
|
|
spec = (cfg.get("models") or {}).get(model_id)
|
|
if not isinstance(spec, dict):
|
|
return False
|
|
spec["ttl"] = int(ttl)
|
|
write_config(cfg)
|
|
return True
|
|
|
|
|
|
def delete_model(model_id: str) -> bool:
|
|
"""Entfernt einen Modell-Eintrag aus der config.yaml, löscht die zugehörigen
|
|
GGUF-Dateien (auch Splits) vom Datenträger und bereinigt leere Ordner.
|
|
"""
|
|
cfg = read_config()
|
|
models = cfg.get("models") or {}
|
|
if model_id not in models:
|
|
return False
|
|
|
|
model_spec = models[model_id] or {}
|
|
cmd = str(model_spec.get("cmd", "")).strip()
|
|
if (m := _PATH_RE.search(cmd)):
|
|
path = m.group(1).replace("'", "").replace('"', "")
|
|
if path:
|
|
# 1. Haupt-GGUF-Datei löschen
|
|
if os.path.exists(path):
|
|
try:
|
|
os.remove(path)
|
|
except Exception:
|
|
pass
|
|
|
|
# 2. Split-GGUF-Teile löschen (z.B. dateiname-00001-of-00005.gguf etc.)
|
|
dirname = os.path.dirname(path)
|
|
basename = os.path.basename(path)
|
|
if os.path.isdir(dirname):
|
|
split_idx = basename.find("-00001-of-")
|
|
if split_idx != -1:
|
|
prefix = basename[:split_idx]
|
|
for f in os.listdir(dirname):
|
|
if f.startswith(prefix) and f.endswith(".gguf"):
|
|
try:
|
|
os.remove(os.path.join(dirname, f))
|
|
except Exception:
|
|
pass
|
|
|
|
# mmproj-Datei (Vision adapter) aus dem Befehl parsen & löschen
|
|
if "mmproj" in cmd:
|
|
mmproj_match = re.search(r'--mmproj\s+[\'"]?([^\s\'"]+)[\'"]?', cmd)
|
|
if mmproj_match:
|
|
m_path = mmproj_match.group(1)
|
|
if os.path.exists(m_path):
|
|
try:
|
|
os.remove(m_path)
|
|
except Exception:
|
|
pass
|
|
|
|
# 3. Eltern-Ordner löschen, falls er leer ist und nicht der Modelle-Wurzelordner selbst ist
|
|
try:
|
|
if not os.listdir(dirname) and os.path.basename(dirname) != "models":
|
|
os.rmdir(dirname)
|
|
except Exception:
|
|
pass
|
|
|
|
del models[model_id]
|
|
for g in (cfg.get("groups") or {}).values():
|
|
if isinstance(g, dict) and model_id in (g.get("members") or []):
|
|
g["members"] = [m for m in g["members"] if m != model_id]
|
|
write_config(cfg)
|
|
return True
|
|
|
|
|
|
def brain_model_name() -> str | None:
|
|
"""Modellname von Lucys Agent-Hirn. Bevorzugt das Modell mit dem 'hermes'-Alias/-Rolle;
|
|
fällt auf Hermes' aktives `model.default` zurück (deckt den Fall ab, dass die Config direkt
|
|
auf einen Modellnamen statt den Alias zeigt)."""
|
|
models = list_models()
|
|
for m in models:
|
|
names = {str(a).lower() for a in (m.get("aliases") or [])}
|
|
if m.get("role"):
|
|
names.add(str(m["role"]).lower())
|
|
if "hermes" in names:
|
|
return m["name"]
|
|
# Fallback: das real von Hermes genutzte Hirn (model.default), per Alias/Name auflösen.
|
|
try:
|
|
from services.agent import _active_brain_name
|
|
brain = (_active_brain_name() or "").lower()
|
|
if brain and brain != "auto":
|
|
cur = next((m for m in models if (m.get("role") or "").lower() == brain), None) \
|
|
or next((m for m in models if brain in (m["name"] or "").lower()), None)
|
|
if cur:
|
|
return cur["name"]
|
|
except Exception:
|
|
log.debug("brain_model_name: Hermes-Fallback fehlgeschlagen", exc_info=True)
|
|
return None
|
|
|
|
|
|
def brain_status() -> dict:
|
|
"""Ist Lucys Agent-Hirn (Rolle 'hermes') WIRKLICH geladen & bereit? Prüft /running — ein
|
|
abgestürztes Modell (z.B. OOM/Crash nach Engine-Update) erscheint dort NICHT als running.
|
|
Fängt damit den Fall 'Engine erreichbar, aber Hirn tot', den engine_reachable() nicht sieht."""
|
|
name = brain_model_name()
|
|
running = get_running_models()
|
|
return {"role": "hermes", "model": name, "ready": bool(name and name in running)}
|
|
|
|
|
|
def get_running_models() -> list[str]:
|
|
"""Fragt den /running Endpunkt von llama-swap ab. Gibt die Namen der geladenen
|
|
Modelle zurück. Neuere llama-swap-Versionen liefern Objekte ({model, state, ...})
|
|
statt Strings — beide Formen werden auf Namens-Strings normalisiert."""
|
|
try:
|
|
with httpx.Client(timeout=2.0) as c:
|
|
r = c.get(f"{LLAMA_SWAP_URL}/running")
|
|
if r.status_code == 200:
|
|
data = r.json().get("running") or []
|
|
return [x.get("model", "") if isinstance(x, dict) else x for x in data]
|
|
except Exception:
|
|
log.warning("get_running_models fehlgeschlagen", exc_info=True)
|
|
return []
|