""" 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 ruamel.yaml.scalarstring import LiteralScalarString from config import ( CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, DRAFTS_DIR, LLAMA_SWAP_URL, SPEC_DRAFT_MODEL_PATH, SPEC_DRAFT_N_MAX, SPEC_TYPE, ) 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"} _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"(? 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.I): d = os.path.basename(os.path.dirname(os.path.dirname(model_path))) name = re.sub(r"[-_]?GGUF$", "", d, flags=re.I).strip("-_") if not name: fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.I) 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.I) return name or "modell" def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None: """Rolle als eindeutigen llama-swap-`aliases`-Eintrag setzen (vorher bei allen anderen Modellen entfernen). role=None/leer entfernt den Alias.""" 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): if role and role != model_id.lower(): spec["aliases"] = [role] else: spec.pop("aliases", None) def _augment_vision(cmd: str, model_path: str, mmproj_path: str | None) -> str: """Vision-Modelle brauchen --mmproj 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: # noqa: BLE001 — Vorwärmen ist Komfort, nie ein Schreib-Blocker 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) -> 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).""" 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) cfg.setdefault("models", {})[model_id] = { "cmd": LiteralScalarString(cmd + "\n"), "ttl": ttl if ttl is not None else DEFAULT_TTL, } 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 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 []