""" Hält das ganze WARM-SET (Hirn + Gedächtnis/embed) dauerhaft warm. Die Augen (vision/VL-30B) sind seit 07.07. ON-DEMAND (ttl 900, User-Entscheid) und gehören NICHT mehr zum Warm-Set. Hintergrund: llama-swap ist EIN-Gruppen-resident — lädt ein on-demand-Modell außerhalb der `brains`-Gruppe, wird die ganze Gruppe verdrängt. `persist: true` verhindert nur Idle-Unload, NICHT die Gruppen-Verdrängung; auch ein `-watch-config`-Reload (jede Config- Änderung/Deploy) verwirft das Set, ohne llama-swaps ExecStartPost-Warmup neu auszulösen. Dieser Wächter schließt die Lücke: ist die Box idle (nichts geladen), lädt er das ganze Set über deploy/warmup.sh nach — Hirn UND embed (sonst bliebe embed kalt, live vom Review-Wächter beobachtet). Während aktiver Last (irgendetwas geladen) hält er sich raus, verdrängt also nie ein gerade genutztes Modell. warmup.sh deckt korrekt ab: fast über /v1/chat/completions, embed über /v1/embeddings (anderer Endpunkt!) und das Vorkauen des ~70-KB-Agent-Prompts. Es ist selbst-detachend. Abschaltbar/justierbar via Env: MC_REWARM_ENABLED=0, MC_REWARM_INTERVAL. Welche Modelle warmup.sh lädt: MC_WARMUP_MODELS (Default 'fast') + MC_WARMUP_EMBED (Default 'embed'). """ import asyncio import logging import os import subprocess import httpx from config import LLAMA_SWAP_URL log = logging.getLogger(__name__) ENABLED = os.environ.get("MC_REWARM_ENABLED", "1") != "0" INTERVAL = int(os.environ.get("MC_REWARM_INTERVAL", "90")) # Sekunden zwischen Checks START_DELAY = int(os.environ.get("MC_REWARM_START_DELAY", "25")) NUDGE_GRACE = int(os.environ.get("MC_REWARM_NUDGE_GRACE", "3")) # llama-swap den Reload abschließen lassen # Das geteilte Warm-Skript (auch llama-swaps ExecStartPost) — EINE Quelle für „was ist das # Warm-Set und wie wärmt man es korrekt", statt hier eine zweite, ärmere Logik zu pflegen. _WARMUP_SH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "deploy", "warmup.sh")) # Wecksignal für einen sofortigen Vorwärm-Check (statt bis zum nächsten INTERVAL-Tick zu warten). # Wird von write_config() nach einer Config-Änderung gesetzt: llama-swap (-watch-config) lädt die # neue Config und verwirft dabei ALLE Modelle inkl. Hirn — ohne Nudge bliebe es bis zu INTERVAL # Sekunden kalt liegen, bis der nächste Tick oder eine Anfrage es wieder lädt. _loop: asyncio.AbstractEventLoop | None = None _wake: asyncio.Event | None = None def nudge() -> None: """Threadsicher: bittet den Wächter, nach einem Config-Reload bald vorzuwärmen. No-op, solange der Wächter (noch) nicht läuft.""" if _loop is not None and _wake is not None and not _loop.is_closed(): try: _loop.call_soon_threadsafe(_wake.set) except RuntimeError: pass def _run_warmup() -> bool: """Volles Warm-Set via deploy/warmup.sh nachladen (fast+vision über /v1/chat/completions, embed über /v1/embeddings, plus Agent-Prompt-Prefill). Selbst-detachend, blockiert nicht. False, wenn das Skript fehlt.""" if not os.path.exists(_WARMUP_SH): log.warning("rewarm: warmup.sh nicht gefunden (%s) — kein Nachwärmen möglich", _WARMUP_SH) return False try: subprocess.Popen(["bash", _WARMUP_SH], env={**os.environ, "MC_LLAMA_SWAP_URL": LLAMA_SWAP_URL}, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return True except Exception: log.debug("rewarm: warmup.sh-Start fehlgeschlagen", exc_info=True) return False def _warmset_members() -> set[str]: """Soll-Warm-Set = Mitglieder aller ko-residenten Gruppen (swap:false bzw. persist/persistent).""" try: from services import llamaswap groups = llamaswap.list_groups() or {} except Exception: return set() want: set[str] = set() for g in groups.values(): if isinstance(g, dict) and (g.get("swap") is False or g.get("persist") or g.get("persistent")): want.update(g.get("members") or []) return want async def _warmset_missing() -> list[str] | None: """Warm-Set-Mitglieder, die NICHT 'ready' in /running sind. [] = alles warm, None = /running nicht lesbar. Erkennt auch TEIL-Kälte (nur Hirn warm, Augen/embed rausgefallen) — genau der Fall, der nach einem watch-config-Reload/Deploy auftritt.""" try: async with httpx.AsyncClient(timeout=8.0) as c: r = await c.get(f"{LLAMA_SWAP_URL}/running") data = r.json() or {} except Exception: return None ready = {str(x.get("model")) for x in (data.get("running") or []) if x.get("state") == "ready"} want = _warmset_members() if not want: # Set unbekannt → alte Heuristik: nur bei ganz leer return [] if ready else [""] return [m for m in want if m not in ready] async def rewarm_loop() -> None: """Endlos-Schleife (Hintergrund-Task): hält das ganze Warm-Set warm. Prüft periodisch, ob ein Mitglied fehlt (Teil-Kälte!), und sofort nach einem Config-Reload-Nudge — lädt via warmup.sh nach.""" global _loop, _wake _loop = asyncio.get_running_loop() _wake = asyncio.Event() await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen while True: try: missing = await _warmset_missing() if missing: log.info("rewarm: Warm-Set unvollständig (%s) → warmup.sh", ", ".join(missing)) _run_warmup() except Exception: log.debug("rewarm: Tick fehlgeschlagen", exc_info=True) # Bis zum nächsten Tick warten ODER sofort auf einen Config-Reload-Nudge reagieren. try: await asyncio.wait_for(_wake.wait(), timeout=INTERVAL) _wake.clear() await asyncio.sleep(NUDGE_GRACE) # llama-swap den Reload abschließen lassen log.info("rewarm: Config-Reload → warmup.sh (volles Warm-Set nachladen)") _run_warmup() except asyncio.TimeoutError: pass