"""SSE-Eventstrom /api/events — Server-Sent Events für Frontend-Updates. EIN EventSource-Endpunkt, der Changes der heute gepollten Zustände pusht: - System-Status (SystemStatus) - Modelle/running (ModelsResp) - Agent-Status (AgentStatus) - Auftragsbuch/Ideen-Zaehler (IdeenResp) - Token-Stats (TokenStats) Event-Schema: type: "invalidate" data: {"keys": ["system-status", "models", "agent-status", "ideen", "token-stats"]} """ import asyncio import json import logging import time from typing import Any, AsyncGenerator from fastapi import APIRouter, Request from fastapi.responses import StreamingResponse from config import HERMES_API_URL, LLAMA_SWAP_URL, GATEWAY_URL import httpx from services import agent, llamaswap from services.system import system_status from services.token_stats import get_stats log = logging.getLogger(__name__) router = APIRouter(prefix="/api") # Aktueller Snapshot für Diff-Prüfung (kein Lock nötig: Python GIL, single writer) _last_snapshot: dict[str, dict[str, Any]] = { "system-status": {}, "models": {}, "agent-status": {}, "ideen": {}, "token-stats": {}, } # Heartbeat-Intervall (Sekunden) — EventSource reconnectet nach ~3s ohne Daten HEARTBEAT_INTERVAL = 25 async def _get_system_status() -> dict: try: return system_status() except Exception: return {} async def _get_models() -> dict: try: items = llamaswap.list_models() return {"models": items, "count": len(items), "running": llamaswap.get_running_models()} except Exception: return {"models": [], "count": 0, "running": []} async def _get_agent_status() -> dict: try: return agent.agent_status() except Exception: return {} async def _get_ideen_count() -> dict: """Ideen-Zaehler (nur count, nicht volle Liste — zu teuer).""" try: async with httpx.AsyncClient(timeout=5.0) as client: r = await client.get(f"{GATEWAY_URL}/v1/ideen", headers={"Authorization": "Bearer placeholder"}) if r.status_code == 200: data = r.json() return {"count": len(data.get("items", []))} except Exception: pass return {"count": 0} async def _get_token_stats() -> dict: try: return get_stats() except Exception: return {} def _snapshot_diff(key: str, new: dict) -> bool: """Gibt True zurück, wenn sich das Snapshot geändert hat.""" old = _last_snapshot.get(key, {}) changed = old != new if changed: _last_snapshot[key] = new return changed async def event_stream() -> AsyncGenerator[str, None]: """Generiert SSE-Events.""" # Initialer Heartbeat sofort yield f": heartbeat\n\n" while True: try: # Alle Status abrufen tasks = { "system-status": asyncio.create_task(_get_system_status()), "models": asyncio.create_task(_get_models()), "agent-status": asyncio.create_task(_get_agent_status()), "ideen": asyncio.create_task(_get_ideen_count()), "token-stats": asyncio.create_task(_get_token_stats()), } # Warten auf alle (mit Timeout) await asyncio.wait(tasks.values(), timeout=10) # Prüfen, ob sich etwas geändert hat keys_to_invalidate: list[str] = [] for key, task in tasks.items(): try: new = task.result() if _snapshot_diff(key, new): keys_to_invalidate.append(key) except Exception as exc: log.warning(f"Event {key} failed: {exc}") if keys_to_invalidate: event = json.dumps({"keys": keys_to_invalidate}) yield f"type: invalidate\n" yield f"data: {event}\n\n" log.info(f"SSE invalidate: {keys_to_invalidate}") except asyncio.CancelledError: log.info("SSE stream cancelled") break except Exception as exc: log.error(f"SSE stream error: {exc}") break # Heartbeat alle HEARTBEAT_INTERVAL Sekunden try: for _ in range(HEARTBEAT_INTERVAL * 10): # 100ms Intervall await asyncio.sleep(0.1) yield f": heartbeat\n\n" except asyncio.CancelledError: break @router.get("/events") async def events_endpoint(request: Request): """SSE-Endpunkt für EventSource.""" return StreamingResponse( event_stream(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", # Nginx-Buffering deaktivieren }, )