""" 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 functools import logging import os import re import threading from contextlib import contextmanager from pathlib import Path import httpx from config import ( CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, DRAFTS_DIR, LLAMA_SWAP_URL, MODELS_DIR, SPEC_DRAFT_MODEL_PATH, SPEC_DRAFT_N_MAX, SPEC_TYPE, ) from ruamel.yaml.scalarstring import LiteralScalarString try: import fcntl except ImportError: # Windows (Entwicklung): nur die Prozess-Sperre fcntl = None 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 # --- Sperre ------------------------------------------------------------------ # Die llama-swap-Config hat mehrere Schreiber: die Oberfläche, das Radar, das Aufräumen und die # Hirn-Umstellung. Ohne Sperre gingen gleichzeitige Änderungen verloren (lesen, ändern, schreiben # überlappten). Seit 24.09.2026 läuft jedes Lesen-Ändern-Schreiben unter dieser Sperre: im # Prozess per RLock, zwischen Prozessen per flock auf einer Datei neben der Config. _SPERRE = threading.RLock() _SPERR_TIEFE = threading.local() @contextmanager def config_sperre(): with _SPERRE: tiefe = getattr(_SPERR_TIEFE, "n", 0) _SPERR_TIEFE.n = tiefe + 1 datei = None try: if tiefe == 0 and fcntl is not None: try: datei = open(CONFIG_PATH.with_name(".mc2-config.lock"), "a+") fcntl.flock(datei, fcntl.LOCK_EX) except OSError: datei = None # ohne Sperrdatei (z. B. fehlende Rechte) bleibt die Prozess-Sperre yield finally: _SPERR_TIEFE.n = tiefe if datei is not None: fcntl.flock(datei, fcntl.LOCK_UN) datei.close() # Sammel-Schreiben (24.09.2026): Jeder Schreibvorgang lässt llama-swap neu laden und ALLE Modelle # entladen. Ein Modelltausch bestand aus bis zu sieben Schreibvorgängen (Eintragen, Befehl, Zwilling, # Alias, Gruppe, ttl …). Innerhalb von sammeln() lesen alle Änderungen dieselbe Config aus dem # Speicher, und geschrieben wird einmal am Ende — oder gar nicht, wenn etwas scheitert. _SAMMEL = threading.local() @contextmanager def sammeln(): with config_sperre(): if getattr(_SAMMEL, "aktiv", False): # verschachtelt: der äußere schreibt yield return _SAMMEL.aktiv, _SAMMEL.cfg = True, None try: yield cfg = _SAMMEL.cfg finally: _SAMMEL.aktiv, _SAMMEL.cfg = False, None if cfg is not None: _schreiben(cfg) def _mit_sperre(fn): @functools.wraps(fn) def huelle(*args, **kwargs): with config_sperre(): return fn(*args, **kwargs) return huelle # --- Lesen ------------------------------------------------------------------- def read_config() -> dict: if getattr(_SAMMEL, "aktiv", False): if _SAMMEL.cfg is None: _SAMMEL.cfg = _lesen() return _SAMMEL.cfg return _lesen() def _lesen() -> 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.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 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. Innerhalb von sammeln() wird nur vorgemerkt; geschrieben wird am Ende einmal.""" if getattr(_SAMMEL, "aktiv", False): _SAMMEL.cfg = cfg return _schreiben(cfg) def _schreiben(cfg: dict) -> None: 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 @_mit_sperre 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/archiv/2026-06-30-optimierungsplan.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 "" # --- Groups (Ko-Residenz) ---------------------------------------------------- @_mit_sperre 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, # Eine immer-warme Gruppe darf nichts verdrängen. llama-swap macht Gruppen sonst # `exclusive`, und JEDE Anfrage ans Hirn entlud den Coder samt Prompt-Cache # (live gefunden 24.09.2026: Lucy-Anfrage → OpenChamber-Coder weg). **({"exclusive": False} if persist else {}), "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 {} @_mit_sperre 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 @_mit_sperre 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 @_mit_sperre 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 @_mit_sperre def im_modellordner(pfad: str) -> bool: """Liegt der Pfad (aufgelöst) unter MODELS_DIR? Grenze für Löschen und Eintragen (24.09.2026).""" try: return Path(pfad).resolve().is_relative_to(MODELS_DIR.resolve()) except (OSError, ValueError): return False 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() # Seit 24.09.2026 teilen sich die Bild-Zwillinge ihre Gewichte (und ggf. Projektoren) mit Hirn und Coder. # Dateien, die ein anderer Eintrag noch nennt, bleiben liegen — sonst risse das Löschen eines Zwillings # den Coder mit. Dann wird nur der Eintrag entfernt. andere = " ".join(str((s or {}).get("cmd", "")) for mid, s in models.items() if mid != model_id) if (m := _PATH_RE.search(cmd)) and m.group(1).replace("'", "").replace('"', "") in andere: m = None if m: path = m.group(1).replace("'", "").replace('"', "") if path and not im_modellordner(path): log.warning("delete_model: %s liegt außerhalb von %s — nur der Eintrag wird entfernt", path, MODELS_DIR) path = "" 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) and m_path not in andere and im_modellordner(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 Path(dirname).resolve() != MODELS_DIR.resolve(): 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 []