diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 599eedb..5a613e6 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -255,6 +255,25 @@ curl -X PUT http://localhost:8080/api/me/prefs \ Diese Vorlieben gelten automatisch für alle Aufrufe dieses Nutzers (Ebene zwischen Profil und Session). Eine fremde Session zu nutzen, wird mit `403` abgelehnt. +**Langzeit-Erinnerungen** (dauerhafte Fakten/Vorlieben, gelten über alle Gespräche): + +```bash +# anlegen +curl -X POST http://localhost:8080/api/me/memories \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' -d '{"content":"Mag morgens Kamillentee."}' +# auflisten / löschen +curl http://localhost:8080/api/me/memories -H "Authorization: Bearer $TOKEN" +curl -X DELETE http://localhost:8080/api/me/memories/1 -H "Authorization: Bearer $TOKEN" +``` + +Diese Erinnerungen gibt der Assistent bei jedem Chat als Kontext mit — auch ohne +`session_id`. + +**Echtzeit-Chat über WebSocket** (`/ws/chat`): dauerhafter Kanal, pro Nachricht +`{"text": "..."}`; Antwort kommt als Event-Folge (`ack`, `semantic`, Audio, `done`). +Token per Query (`?token=…`), Gedächtnis per `?session_id=…`. + > **Für lokale Entwicklung** ist in der mitgelieferten `.env` `AUTH_ENABLED=false` > gesetzt — dann ist kein Token nötig (anonymer Nutzer). diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index ab153a2..a13744d 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -170,6 +170,8 @@ ein No-op; `LoopbackOutput` sammelt die Chunks (testbar ohne Hardware). | `GET /api/config` | aktives Profil + aufgelöste Route (ohne Secrets) | | `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig | | `GET /api/me` · `PUT /api/me/prefs` | aktueller Nutzer + dauerhafte Präferenzen | +| `GET/POST/DELETE /api/me/memories` | Langzeit-Erinnerungen des Nutzers | +| `WS /ws/chat` | Echtzeit-Chat (Streaming-Events über WebSocket) | Endpunkt-/Provider-Auswahl ist über **Request-Body** (pro Aufruf), **Session** (`?session_id=…`) und **Defaults/Profil** steuerbar. Verwendete Route erscheint als @@ -183,7 +185,8 @@ Pipeline; geschichtete Config + Profile; Registry + einheitliche Route-Auflösun Device Router (strikt, Singleton); Output-Lifecycle; **Authentifizierung (Bearer-Token) + persistenter SQLite-Store für Nutzer/Sessions + Mandanten-Trennung + dauerhafte Nutzer-Präferenzen**; **Gesprächsgedächtnis pro Session (Verlauf im -Store, fließt ins LLM)**; automatisierte Tests. +Store, fließt ins LLM)**; **Langzeit-Erinnerungen pro Nutzer (als LLM-Kontext)**; +**WebSocket-Streaming-Chat (`/ws/chat`)**; automatisierte Tests. **Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`, `mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind @@ -197,8 +200,8 @@ Reihenfolge der Weiterentwicklung: 1. **(erledigt)** Konfig- & Routing-Fundament: Profile, Device Router, Registry, Pro-Request-Override. 2. **(erledigt)** Cloud-Fundament: Bearer-Token-Auth, Mehrbenutzer, persistenter SQLite-Store, Mandanten-Trennung, dauerhafte Nutzer-Präferenzen. Offen: Skalierung auf gemeinsamen Store (Postgres/Redis) für mehrere Instanzen. -3. **(teilweise erledigt)** Konversationsgedächtnis: Kurzzeit-Gesprächsverlauf pro Session ist umgesetzt (Store + ans LLM). Offen: **Langzeit-Erinnerungen** über Sessions hinweg (Fakten/Vorlieben, ggf. LLM-gestützt extrahiert und zusammengefasst). -4. **Echtzeit:** Streaming-STT/TTS, WebSocket/WebRTC-Endpunkte real, Barge-in, Turn-Manager. +3. **(erledigt)** Konversationsgedächtnis: Kurzzeit-Gesprächsverlauf pro Session + Langzeit-Erinnerungen pro Nutzer (manuell gepflegt, als LLM-Kontext). Offen: **automatische** Extraktion/Zusammenfassung von Erinnerungen aus Gesprächen. +4. **(teilweise erledigt)** Echtzeit: WebSocket-Streaming-Chat (`/ws/chat`) mit Event-Folge (ack/semantic/audio/done) ist umgesetzt. Offen: **Token-Level-LLM-Streaming**, **Audio-Eingang/Streaming-STT**, **Barge-in/Turn-Manager**, **WebRTC**. 5. **Resilienz:** Fallback-Policy (remote KI fällt aus → lokaler/alternativer Provider), Metriken/Tracing. 6. **Betrieb:** Kosten-/Quota-Kontrolle pro Nutzer; Notfall-/Eskalationskonzept (Senioren-Kontext). 7. **TransportRouter** als eigene lokal/remote-Achse aktivieren. @@ -219,7 +222,7 @@ voice-assistant-scaffold/ │ ├── auth.py # Bearer-Token-Auth (require_user) + Admin-Schutz │ ├── errors.py # RoutingError -> HTTP 422 │ ├── schemas.py # Pydantic-Modelle -│ ├── api/ # health, chat, speak, transcribe, devices, sessions, config, admin, me +│ ├── api/ # health, chat, speak, transcribe, devices, sessions, config, admin, me, ws │ ├── core/ # orchestrator │ ├── audio/ # router, transport_router, endpoints/input|output/* │ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer diff --git a/README.md b/README.md index 26ad6ec..4ca0bcc 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md). - **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request - **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite) - **Gesprächsgedächtnis pro Session:** Verlauf wird gespeichert und fließt ins LLM +- **Langzeit-Erinnerungen pro Nutzer:** dauerhafte Fakten/Vorlieben als LLM-Kontext +- **WebSocket-Streaming-Chat** (`/ws/chat`) als Echtzeit-Transport - **REST-API** für Chat, Transkription, Sprachausgabe, Geräte, Sessions, Config - **Ohne Secrets im Code** — API-Keys nur über die Umgebung @@ -85,6 +87,8 @@ Aktive Konfiguration prüfen: `curl http://localhost:8080/api/config`. | `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig | | `GET /api/me` | aktueller Nutzer + Präferenzen | | `PUT /api/me/prefs` | dauerhafte Routing-Präferenzen des Nutzers setzen | +| `GET/POST/DELETE /api/me/memories` | Langzeit-Erinnerungen des Nutzers verwalten | +| `WS /ws/chat` | Echtzeit-Chat über WebSocket (Streaming-Events) | Beispiel (Sprachausgabe an den Test-Loopback, lokaler TTS-Stub): @@ -110,6 +114,27 @@ curl -X POST "http://localhost:8080/api/chat?session_id=oma-anna&debug=true" \ -H 'Content-Type: application/json' -d '{"text":"Wie war noch mein Name?"}' ``` +**Langzeit-Erinnerungen** (über Sessions hinweg, pro Nutzer) werden über +`/api/me/memories` gepflegt und bei jedem Chat als Kontext ans LLM gegeben — auch +ohne `session_id`: + +```bash +curl -X POST http://localhost:8080/api/me/memories \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' -d '{"content":"Mag morgens Kamillentee."}' +``` + +## Echtzeit-Chat (WebSocket) + +`/ws/chat` bietet einen dauerhaften, bidirektionalen Kanal. Der Client sendet pro +Turn eine JSON-Nachricht (`{"text": "..."}`, optional Provider/Endpunkt-Overrides), +der Server streamt strukturierte Events zurück: `ack` → `semantic` → Audio (binär) +→ `done`. Auth (Token-Query `?token=…`), Session-Gedächtnis (`?session_id=…`) und +Erinnerungen gelten wie bei `POST /api/chat`. + +> Token-Level-LLM-Streaming, Audio-Eingang/Streaming-STT, Barge-in und WebRTC sind +> als nächste Increments vorgesehen (siehe Architektur-Dokument). + ## Authentifizierung Standardmäßig (`AUTH_ENABLED=true`) sind `chat`/`speak`/`transcribe`/`sessions`/`me` diff --git a/app/api/chat.py b/app/api/chat.py index 38009ea..25fd247 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -58,23 +58,32 @@ async def chat( orchestrator = build_orchestrator(route) output = await resolve_output_endpoint(route) # Gespraechsverlauf laden (nur bei gesetzter session_id -> sonst zustandslos). - history = ( + conversation = ( store.get_recent_messages(session_id, settings.history_max_messages) if session_id else [] ) + # Langzeit-Erinnerungen sind nutzerbezogen und gelten auch ohne Session. + memories = store.get_memories(user.id) except SessionOwnershipError as exc: raise HTTPException(status_code=403, detail=str(exc)) except RoutingError as exc: raise HTTPException(status_code=422, detail=str(exc)) + llm_context = list(conversation) + if memories: + memory_text = "Was du ueber den Nutzer weisst:\n" + "\n".join( + f"- {m.content}" for m in memories + ) + llm_context = [{"role": "system", "content": memory_text}] + llm_context + try: trace, audio = await orchestrator.chat_text( payload.text, language=route.language, voice=voice, output=output, - history=history, + history=llm_context, ) except Exception as exc: raise HTTPException(status_code=502, detail=str(exc)) @@ -90,7 +99,8 @@ async def chat( "ok": True, "voice": voice, "route": route.as_dict(), - "history_len": len(history), + "history_len": len(conversation), + "memories_len": len(memories), "trace": { "raw_transcript": trace.raw_transcript, "cleaned_transcript": trace.cleaned_transcript, diff --git a/app/api/me.py b/app/api/me.py index 816a606..95855e8 100644 --- a/app/api/me.py +++ b/app/api/me.py @@ -1,8 +1,8 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from app.auth import require_user from app.dependencies import get_store -from app.schemas import UserPrefs +from app.schemas import UserPrefs, MemoryCreate, MemoryOut from app.store import User router = APIRouter() @@ -19,3 +19,21 @@ async def set_my_prefs(payload: UserPrefs, user: User = Depends(require_user)): prefs = {k: v for k, v in payload.model_dump().items() if v is not None} updated = get_store().set_user_prefs(user.id, prefs) return {"user_id": updated.id, "prefs": updated.prefs} + + +@router.get("/me/memories", response_model=list[MemoryOut]) +async def list_memories(user: User = Depends(require_user)): + return get_store().get_memories(user.id) + + +@router.post("/me/memories", response_model=MemoryOut) +async def add_memory(payload: MemoryCreate, user: User = Depends(require_user)): + """Speichert einen dauerhaften Fakt/eine Vorliebe ueber den Nutzer.""" + return get_store().add_memory(user.id, payload.content.strip()) + + +@router.delete("/me/memories/{memory_id}") +async def delete_memory(memory_id: int, user: User = Depends(require_user)): + if not get_store().delete_memory(user.id, memory_id): + raise HTTPException(status_code=404, detail="Memory not found") + return {"ok": True, "deleted": memory_id} diff --git a/app/api/ws.py b/app/api/ws.py new file mode 100644 index 0000000..6c505bc --- /dev/null +++ b/app/api/ws.py @@ -0,0 +1,126 @@ +"""WebSocket-Streaming-Chat (Echtzeit-Transport, erster Increment). + +Etabliert einen dauerhaften, bidirektionalen Kanal: der Client schickt pro Turn +eine JSON-Nachricht, der Server streamt strukturierte Events zurueck +(ack -> semantic -> audio (binaer) -> done). Auth, Session-Gedaechtnis und +Langzeit-Erinnerungen gelten wie bei POST /api/chat. + +Bewusst spaeter (eigene Increments): Token-Level-LLM-Streaming, Audio-Eingang/ +Streaming-STT, Barge-in/Interrupt und WebRTC. +""" + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from app.config import settings +from app.errors import RoutingError +from app.dependencies import ( + get_store, + resolve_route, + build_orchestrator, + resolve_output_endpoint, +) +from app.store import SessionOwnershipError + +router = APIRouter() + + +def _authenticate(token: str | None): + store = get_store() + if not settings.auth_enabled: + return store.ensure_anonymous_user() + if not token: + return None + return store.get_user_by_token(token) + + +@router.websocket("/ws/chat") +async def ws_chat( + websocket: WebSocket, + session_id: str | None = None, + token: str | None = None, +): + user = _authenticate(token) + if user is None: + # Vor accept() schliessen -> Handshake wird mit 403 abgelehnt. + await websocket.close(code=1008) + return + + await websocket.accept() + store = get_store() + + try: + while True: + msg = await websocket.receive_json() + text = (msg.get("text") or "").strip() + if not text: + await websocket.send_json({"type": "error", "detail": "empty text"}) + continue + + overrides = { + key: msg.get(key) + for key in ( + "input_endpoint", + "output_endpoint", + "language", + "stt_provider", + "llm_provider", + "tts_provider", + ) + } + + try: + route = resolve_route(user, session_id, overrides) + orchestrator = build_orchestrator(route) + output = await resolve_output_endpoint(route) + conversation = ( + store.get_recent_messages(session_id, settings.history_max_messages) + if session_id + else [] + ) + memories = store.get_memories(user.id) + except SessionOwnershipError as exc: + await websocket.send_json({"type": "error", "status": 403, "detail": str(exc)}) + continue + except RoutingError as exc: + await websocket.send_json({"type": "error", "status": 422, "detail": str(exc)}) + continue + + llm_context = list(conversation) + if memories: + memory_text = "Was du ueber den Nutzer weisst:\n" + "\n".join( + f"- {m.content}" for m in memories + ) + llm_context = [{"role": "system", "content": memory_text}] + llm_context + + await websocket.send_json({"type": "ack", "route": route.as_dict()}) + + voice = msg.get("voice") or settings.openrouter_tts_voice + try: + trace, audio = await orchestrator.chat_text( + text, + language=route.language, + voice=voice, + output=output, + history=llm_context, + ) + except Exception as exc: + await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)}) + continue + + if session_id: + store.append_message(session_id, user.id, "user", text) + store.append_message(session_id, user.id, "assistant", trace.semantic_response) + + await websocket.send_json( + { + "type": "semantic", + "text": trace.semantic_response, + "spoken": trace.spoken_response, + } + ) + await websocket.send_bytes(audio) + await websocket.send_json( + {"type": "done", "audio_format": "pcm", "sample_rate": 24000} + ) + except WebSocketDisconnect: + return diff --git a/app/main.py b/app/main.py index adcc928..961b113 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,7 @@ from app.api.sessions import router as sessions_router from app.api.config import router as config_router from app.api.admin import router as admin_router from app.api.me import router as me_router +from app.api.ws import router as ws_router app = FastAPI(title="Voice Assistant Gateway") app.include_router(health_router) @@ -19,3 +20,4 @@ app.include_router(sessions_router, prefix="/api") app.include_router(config_router, prefix="/api") app.include_router(admin_router, prefix="/api") app.include_router(me_router, prefix="/api") +app.include_router(ws_router) diff --git a/app/schemas.py b/app/schemas.py index c8df4ad..15c193a 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -88,3 +88,13 @@ class UserPrefs(BaseModel): tts_provider: str | None = None language: str | None = None + +class MemoryCreate(BaseModel): + content: str = Field(min_length=1) + + +class MemoryOut(BaseModel): + id: int + content: str + created_at: str + diff --git a/app/store.py b/app/store.py index 15421d9..b939323 100644 --- a/app/store.py +++ b/app/store.py @@ -42,6 +42,13 @@ class Session: data: dict = field(default_factory=dict) +@dataclass +class Memory: + id: int + content: str + created_at: str = "" + + class SessionOwnershipError(Exception): """Eine Session gehoert einem anderen Nutzer (-> HTTP 403).""" @@ -78,6 +85,18 @@ class Store(ABC): def get_recent_messages(self, session_id: str, limit: int) -> list[dict]: """Liefert die letzten `limit` Nachrichten chronologisch ([{'role','content'}, ...]).""" + @abstractmethod + def add_memory(self, user_id: str, content: str) -> Memory: + """Speichert eine dauerhafte Erinnerung (Fakt/Vorliebe) zum Nutzer.""" + + @abstractmethod + def get_memories(self, user_id: str) -> list[Memory]: + """Liefert alle Erinnerungen des Nutzers (chronologisch).""" + + @abstractmethod + def delete_memory(self, user_id: str, memory_id: int) -> bool: + """Loescht eine Erinnerung des Nutzers. True, wenn etwas geloescht wurde.""" + class SQLiteStore(Store): def __init__(self, db_path: str): @@ -119,6 +138,14 @@ class SQLiteStore(Store): ); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, id); + CREATE TABLE IF NOT EXISTS memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_memories_user + ON memories(user_id, id); """ ) @@ -244,3 +271,33 @@ class SQLiteStore(Store): (session_id, limit), ).fetchall() return [{"role": row["role"], "content": row["content"]} for row in reversed(rows)] + + # ----- Langzeit-Erinnerungen -------------------------------------------- + def add_memory(self, user_id: str, content: str) -> Memory: + now = _now() + with self._connect() as conn: + cur = conn.execute( + "INSERT INTO memories (user_id, content, created_at) VALUES (?, ?, ?)", + (user_id, content, now), + ) + memory_id = cur.lastrowid + return Memory(id=memory_id, content=content, created_at=now) + + def get_memories(self, user_id: str) -> list[Memory]: + with self._connect() as conn: + rows = conn.execute( + "SELECT id, content, created_at FROM memories WHERE user_id = ? ORDER BY id", + (user_id,), + ).fetchall() + return [ + Memory(id=row["id"], content=row["content"], created_at=row["created_at"]) + for row in rows + ] + + def delete_memory(self, user_id: str, memory_id: int) -> bool: + with self._connect() as conn: + cur = conn.execute( + "DELETE FROM memories WHERE id = ? AND user_id = ?", + (memory_id, user_id), + ) + return cur.rowcount > 0 diff --git a/tests/test_auth.py b/tests/test_auth.py index 4442b96..ba3959b 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -81,6 +81,32 @@ def test_user_prefs_applied_to_route(monkeypatch): assert resp.headers["X-Output-Endpoint"] == "loopback" +def test_memories_crud(monkeypatch): + _enable_auth(monkeypatch) + auth = {"Authorization": f"Bearer {_create_user('Mem')}"} + + assert client.get("/api/me/memories", headers=auth).json() == [] + + created = client.post("/api/me/memories", headers=auth, json={"content": "mag Tee"}) + assert created.status_code == 200 + mid = created.json()["id"] + + listed = client.get("/api/me/memories", headers=auth).json() + assert len(listed) == 1 and listed[0]["content"] == "mag Tee" + + assert client.delete(f"/api/me/memories/{mid}", headers=auth).status_code == 200 + assert client.get("/api/me/memories", headers=auth).json() == [] + assert client.delete("/api/me/memories/9999", headers=auth).status_code == 404 + + +def test_memories_are_per_user(monkeypatch): + _enable_auth(monkeypatch) + auth_a = {"Authorization": f"Bearer {_create_user('A')}"} + auth_b = {"Authorization": f"Bearer {_create_user('B')}"} + client.post("/api/me/memories", headers=auth_a, json={"content": "geheim A"}) + assert client.get("/api/me/memories", headers=auth_b).json() == [] + + def test_admin_unconfigured_returns_503(monkeypatch): # Auth an, aber kein Admin-Key gesetzt. monkeypatch.setattr(settings, "auth_enabled", True) diff --git a/tests/test_memory.py b/tests/test_memory.py index 73a824b..d29d260 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -50,6 +50,22 @@ def test_no_session_id_is_stateless(monkeypatch): assert captured["history"] == [] # ohne session_id kein Gedaechtnis +def test_memories_injected_as_context(monkeypatch): + captured = {} + base = _install_stubs(monkeypatch, captured) + + # Erinnerung fuer den (anonymen) Nutzer ablegen. + deps.get_store().ensure_anonymous_user() + deps.get_store().add_memory("anonymous", "heisst Anna") + + # Ohne session_id -> kein Verlauf, aber Erinnerung wird trotzdem injiziert. + r = client.post("/api/chat?debug=true", json={"text": "Hallo", **base}) + assert r.status_code == 200 + assert r.json()["memories_len"] == 1 + assert captured["history"][0]["role"] == "system" + assert "heisst Anna" in captured["history"][0]["content"] + + def test_history_limited_by_setting(monkeypatch): from app.config import settings diff --git a/tests/test_ws.py b/tests/test_ws.py new file mode 100644 index 0000000..082e0fa --- /dev/null +++ b/tests/test_ws.py @@ -0,0 +1,68 @@ +import pytest +from fastapi.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + +import app.dependencies as deps +from app.main import app +from app.config import settings + +client = TestClient(app) + + +def _install_stubs(monkeypatch, captured): + class MemLLM: + async def complete(self, text, history=None, session_id=None): + captured["history"] = list(history or []) + return f"Echo: {text}" + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm"): + return b"WSAUDIO" + + monkeypatch.setitem(deps.LLM_REGISTRY, "mem", lambda s: MemLLM()) + monkeypatch.setitem(deps.TTS_REGISTRY, "stub", lambda s: StubTTS()) + return {"llm_provider": "mem", "tts_provider": "stub", "output_endpoint": "loopback"} + + +def _drain_turn(ws): + assert ws.receive_json()["type"] == "ack" + sem = ws.receive_json() + assert ws.receive_bytes() == b"WSAUDIO" + assert ws.receive_json()["type"] == "done" + return sem + + +def test_ws_streams_events_and_remembers(monkeypatch): + captured = {} + base = _install_stubs(monkeypatch, captured) + + with client.websocket_connect("/ws/chat?session_id=wsconv") as ws: + ws.send_json({"text": "Hallo", **base}) + sem1 = _drain_turn(ws) + assert sem1["type"] == "semantic" and sem1["text"] == "Echo: Hallo" + + ws.send_json({"text": "Weiter", **base}) + _drain_turn(ws) + + # Zweiter Turn hat den Verlauf des ersten erhalten. + assert captured["history"] == [ + {"role": "user", "content": "Hallo"}, + {"role": "assistant", "content": "Echo: Hallo"}, + ] + + +def test_ws_unknown_provider_sends_error_event(monkeypatch): + captured = {} + _install_stubs(monkeypatch, captured) + with client.websocket_connect("/ws/chat") as ws: + ws.send_json({"text": "x", "llm_provider": "gibtsnicht"}) + err = ws.receive_json() + assert err["type"] == "error" and err["status"] == 422 + + +def test_ws_requires_token_when_auth_enabled(monkeypatch): + monkeypatch.setattr(settings, "auth_enabled", True) + monkeypatch.setattr(settings, "admin_api_key", "k") + with pytest.raises(WebSocketDisconnect): + with client.websocket_connect("/ws/chat"): + pass