"""Lucys Hirn gezielt prüfen (Wächter, 24.09.2026) und „Jetzt laden“ mit echtem Ergebnis. Bis 24.09. galt das Hirn als bereit, sobald irgendein Modell lief, und POST /api/models/{id}/load meldete „ok“, egal was die Engine sagte. llama-swap wird hier durch httpx.MockTransport ersetzt (Format von /running wie in llama-swap v257: jedes Modell, das nicht „stopped“ ist, mit seinem Zustand).""" import httpx import pytest from services import llamaswap, waechter _ECHTER_CLIENT = httpx.Client def _engine(monkeypatch, handler) -> None: """llama-swap durch eine Attrappe ersetzen (alle httpx.Client in services.llamaswap).""" monkeypatch.setattr(llamaswap.httpx, "Client", lambda *a, **kw: _ECHTER_CLIENT( transport=httpx.MockTransport(handler), timeout=kw.get("timeout"))) def _running(*eintraege) -> httpx.Response: return httpx.Response(200, json={"running": list(eintraege)}) # --- Zustände aus /running ------------------------------------------------------------------------ def test_modell_zustaende_liest_objekte_und_alte_namenslisten(monkeypatch): _engine(monkeypatch, lambda req: _running({"model": "Hirn", "state": "starting"}, {"model": "Coder", "state": "ready"}, "Alt")) assert llamaswap.modell_zustaende() == {"Hirn": "starting", "Coder": "ready", "Alt": "ready"} _engine(monkeypatch, lambda req: httpx.Response(500)) assert llamaswap.modell_zustaende() is None @pytest.mark.parametrize("running, zustand", [ ([{"model": "Hirn", "state": "ready"}], "bereit"), ([{"model": "Hirn", "state": "starting"}], "laedt"), ([{"model": "Hirn", "state": "stopping"}], "fehlt"), ([{"model": "Coder", "state": "ready"}], "fehlt"), # der alte Fehler: Coder läuft, Hirn ist weg (None, "unbekannt"), ]) def test_hirn_zustand_prueft_gezielt_das_hirn(monkeypatch, running, zustand): monkeypatch.setattr(llamaswap, "brain_model_name", lambda: "Hirn") monkeypatch.setattr(llamaswap, "modell_zustaende", lambda: None if running is None else {e["model"]: e["state"] for e in running}) assert llamaswap.hirn_zustand() == {"modell": "Hirn", "zustand": zustand} # --- Wächter ------------------------------------------------------------------------------------------ @pytest.fixture def hirn(monkeypatch): """Der Wächter sieht den Zustand, den der Test vorgibt; seine Frist beginnt frisch.""" zustand = {"modell": "Qwen3.6-35B-A3B", "zustand": "bereit"} monkeypatch.setattr(waechter, "_hirn", {"fehlt_seit": None, "geprueft": None}) monkeypatch.setattr(waechter.llamaswap, "hirn_zustand", lambda: dict(zustand)) return zustand def test_abgestuerztes_hirn_neben_geladenem_coder_ist_ein_befund(monkeypatch, hirn): monkeypatch.setattr(waechter.llamaswap, "engine_reachable", lambda: True) monkeypatch.setattr(waechter.llamaswap, "get_running_models", lambda: ["Qwen3.8-27B"]) # Coder läuft monkeypatch.setattr(waechter, "_reach", lambda url, pfad, kopf=None: True) monkeypatch.setattr(waechter, "_systemctl_show", lambda name, system: {"ActiveState": "active"}) hirn["zustand"] = "fehlt" befunde = {b.id: b for b in waechter.pruefe_kern()} assert befunde["kern:hirn"].titel == "Lucys Hirn ist nicht geladen" assert "Qwen3.6-35B-A3B läuft nicht" in befunde["kern:hirn"].text assert befunde["kern:hirn"].aktionen == [{"id": "protokoll", "label": "Protokoll des Motors", "dienst": "llama-swap"}] assert not befunde["kern:hirn"].sofort # zählt erst nach den üblichen Takten def test_laden_ist_kein_ausfall_bis_zur_frist(monkeypatch, hirn): monkeypatch.setattr(waechter, "HIRN_LADEN_MAX_S", 600) hirn["zustand"] = "laedt" assert waechter._hirn_befund(jetzt=1000.0) is None assert waechter._hirn_befund(jetzt=1060.0) is None # Startet es immer wieder neu, wechseln sich „fehlt“ und „lädt“ ab: „fehlt“ ist ein Befund, und nach der Frist # ist es auch „lädt“ — sonst käme nie ein Hinweis zustande. hirn["zustand"] = "fehlt" assert waechter._hirn_befund(jetzt=1120.0).titel == "Lucys Hirn ist nicht geladen" hirn["zustand"] = "laedt" for jetzt in range(1180, 1600, 60): # Takt wie im Betrieb: jede Minute assert waechter._hirn_befund(jetzt=float(jetzt)) is None b = waechter._hirn_befund(jetzt=1600.0) assert b.titel == "Lucys Hirn lädt nicht fertig" and "seit über 10 Minuten" in b.text hirn["zustand"] = "bereit" assert waechter._hirn_befund(jetzt=1660.0) is None hirn["zustand"] = "laedt" # nach „bereit“ beginnt die Frist neu assert waechter._hirn_befund(jetzt=1720.0) is None def test_lange_pause_setzt_die_frist_zurueck(hirn): """Während eines Updates prüft der Wächter den Kern nicht. Danach darf das Hirn wieder in Ruhe laden.""" hirn["zustand"] = "laedt" assert waechter._hirn_befund(jetzt=0.0) is None assert waechter._hirn_befund(jetzt=5000.0) is None def test_ohne_hirn_rolle_ist_es_ein_befund(hirn): hirn.update(modell=None, zustand="fehlt") assert "Kein Modell trägt die Rolle „hermes“" in waechter._hirn_befund(jetzt=0.0).text # --- Jetzt laden ----------------------------------------------------------------------------------------- def _laden(monkeypatch, antwort, running=()) -> dict: def handler(req: httpx.Request) -> httpx.Response: if req.url.path == "/running": return _running(*running) if isinstance(antwort, Exception): raise antwort return antwort _engine(monkeypatch, handler) return llamaswap.lade_modell("Qwen3.8-27B", warte_s=5) def test_laden_meldet_den_fehler_der_engine(monkeypatch): ergebnis = _laden(monkeypatch, httpx.Response(502, text="upstream command exited prematurely but successfully")) assert ergebnis["ok"] is False assert ergebnis["detail"] == ("Qwen3.8-27B ließ sich nicht laden. Die Engine meldet (HTTP 502): " "upstream command exited prematurely but successfully") ergebnis = _laden(monkeypatch, httpx.Response(404, json={"error": {"message": "model not found"}})) assert ergebnis["ok"] is False and ergebnis["detail"].endswith("(HTTP 404): model not found") def test_laden_gelingt(monkeypatch): assert _laden(monkeypatch, httpx.Response(200, json={"choices": []})) == {"ok": True, "text": "Qwen3.8-27B ist geladen."} # Ein Modell, das nicht chattet (embed), antwortet mit Fehler — geladen ist es trotzdem. ergebnis = _laden(monkeypatch, httpx.Response(501, text="no chat"), running=[{"model": "Qwen3.8-27B", "state": "ready"}]) assert ergebnis["ok"] is True def test_laden_nach_der_wartezeit(monkeypatch): zeitueber = httpx.ReadTimeout("zu langsam") ergebnis = _laden(monkeypatch, zeitueber, running=[{"model": "Qwen3.8-27B", "state": "starting"}]) assert ergebnis["ok"] is True and ergebnis["laedt"] is True ergebnis = _laden(monkeypatch, zeitueber) assert ergebnis["ok"] is False and "nach 5 Sekunden nicht geantwortet" in ergebnis["detail"] ergebnis = _laden(monkeypatch, httpx.ConnectError("refused")) assert ergebnis["ok"] is False and "nicht erreichbar (ConnectError)" in ergebnis["detail"] # Unter Windows scheitert ein Verbindungsaufbau zu einem toten Port als Zeitüberschreitung — das ist # „nicht erreichbar“, nicht „lädt noch“. ergebnis = _laden(monkeypatch, httpx.ConnectTimeout("timed out")) assert ergebnis["ok"] is False and "nicht erreichbar (ConnectTimeout)" in ergebnis["detail"] def test_route_reicht_das_ergebnis_durch(monkeypatch): from fastapi import FastAPI from fastapi.testclient import TestClient from routers import models monkeypatch.setattr(models.llamaswap, "lade_modell", lambda mid: {"ok": False, "detail": f"{mid}: kaputt"}) app = FastAPI() app.include_router(models.router) antwort = TestClient(app).post("/api/models/Qwen3.8-27B/load") assert antwort.status_code == 200 and antwort.json() == {"ok": False, "detail": "Qwen3.8-27B: kaputt"}