- Waechter (pruefe_kern): prueft jetzt gezielt das Modell mit der Rolle hermes und seinen
Zustand in llama-swap (/running, v257 listet auch ladende Modelle), statt das Hirn fuer
bereit zu halten, sobald irgendein Modell laeuft. Ein Ladevorgang ist kein Ausfall, aber
nur bis 10 Minuten (MC_WAECHTER_HIRN_LADEN_S), sonst bliebe ein immer neu startendes Hirn
unbemerkt. Sonst die ueblichen Takte (FAIL_AFTER); Knopf "Protokoll des Motors".
- llamaswap: modell_zustaende() und hirn_zustand(); lade_modell() meldet ok false mit
deutschem Grund und der Meldung der Engine, "laedt noch" nach der Wartezeit ist kein Fehler,
ein geladenes Modell ohne Chat (embed) zaehlt als geladen. POST /api/models/{id}/load
reicht das Ergebnis durch (Router duenn).
- Radar "Jetzt suchen": startet die Suche als Auftrag (Job-Engine, Gruppe radar, hoechstens
einer, radar_lauf.py --nur-suche) und antwortet sofort mit der Auftrags-ID. Die Ansicht
zeigt "Sucht ..." bis der Auftrag fertig ist, liest danach das Radar neu und nennt eine
gescheiterte Suche; die Auftragskarte zeigt den Stand.
- Tests: Hirn-Zustaende und Frist, Laden gegen eine llama-swap-Attrappe, Radar-Auftrag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
157 lines
8.0 KiB
Python
157 lines
8.0 KiB
Python
"""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"}
|