#!/usr/bin/env python3
"""
Shared SSE streaming client for Klea frontends.
Provides both an async generator (for NiceGUI and TUI) and a synchronous
generator (for Streamlit) that consume the ``/query/stream`` SSE endpoint
and yield parsed event dicts.
File: klea_utils/api/sse.py
Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
"""
import json
import logging
from collections.abc import AsyncGenerator, Generator
import httpx
from ..llm import parse_model_name
logger = logging.getLogger(__name__)
[docs]
async def stream_events(
query: str,
chat_id: str,
server_url: str,
user_id: str = "",
) -> AsyncGenerator[dict, None]:
"""POST to ``/query/stream`` and yield parsed SSE event dicts.
Each yielded dict has at least a ``"type"`` key. Known types::
progress {"type": "progress", "node": "<label>"}
info {"type": "info", "node": "<label>", "data": {...}}
debug {"type": "debug", "node": "<label>", "data": {...}}
token {"type": "token", "content": "<chunk>", "node": "<label>"}
usage {"type": "usage", "node": "<label>", "data": {...}}
complete {"type": "complete", "message_for_user": "<text>"}
error {"type": "error", "message": "<text>", "error_type": "<class>", "node": "<label>"}
This async generator is intended for NiceGUI and TUI frontends.
:param query: User's query string.
:param chat_id: Chat conversation identifier.
:param server_url: Base URL of the backend API server.
:param user_id: Opaque persistent user identifier.
"""
url = f"{server_url}/query/stream"
async with (
httpx.AsyncClient(timeout=None) as client,
client.stream(
"POST",
url,
json={"query": query, "chat_id": chat_id, "user_id": user_id},
) as response,
):
response.raise_for_status()
async for line in response.aiter_lines():
if not line.startswith("data: "):
if line.strip():
logger.warning("Skipping non-data line: %s", line[:80])
continue
# Strip the SSE "data: " prefix (6 chars) to get raw JSON.
yield json.loads(line[6:])
# TODO: if more fetch-json-from-endpoint functions are added, consider
# extracting the async/sync boilerplate into _fetch_json / _fetch_json_sync
# helpers in utils.py to avoid repetition.
[docs]
async def fetch_active_models(
server_url: str,
user_id: str,
chat_id: str,
) -> dict[str, dict[str, str]]:
"""Fetch the resolved model config per role for a chat.
Calls ``GET /chat/{user_id}/{chat_id}/models/active`` and returns the
merged default + override config dict.
:param server_url: Base URL of the backend API server.
:param user_id: Opaque persistent user identifier.
:param chat_id: Chat conversation identifier.
:returns: ``{"chat": {"model": "...", "provider": "..."}, "guard": ..., "embedding": ...}``
"""
url = f"{server_url}/chat/{user_id}/{chat_id}/models/active"
async with httpx.AsyncClient(timeout=5) as client:
try:
resp = await client.get(url)
if resp.status_code != 200:
logger.warning(
"Failed to fetch active models: HTTP %s from %s",
resp.status_code,
url,
)
return {}
data: dict[str, dict[str, str]] = resp.json()
logger.debug("Active models for %s:%s: %s", user_id, chat_id, data)
return data
except Exception as e:
logger.warning(
"Failed to fetch active models from %s: %s",
url,
e,
)
return {}
[docs]
def fetch_active_models_sync(
server_url: str,
user_id: str,
chat_id: str,
) -> dict[str, dict[str, str]]:
"""Synchronous counterpart of :func:`fetch_active_models`.
Intended for frontends that cannot use asyncio.
:param server_url: Base URL of the backend API server.
:param user_id: Opaque persistent user identifier.
:param chat_id: Chat conversation identifier.
"""
url = f"{server_url}/chat/{user_id}/{chat_id}/models/active"
with httpx.Client(timeout=5) as client:
try:
resp = client.get(url)
if resp.status_code != 200:
logger.warning(
"Failed to fetch active models: HTTP %s from %s",
resp.status_code,
url,
)
return {}
data: dict[str, dict[str, str]] = resp.json()
logger.debug("Active models for %s:%s: %s", user_id, chat_id, data)
return data
except Exception as e:
logger.warning(
"Failed to fetch active models from %s: %s",
url,
e,
)
return {}
[docs]
def stream_events_sync(
query: str,
chat_id: str,
server_url: str,
user_id: str = "",
) -> Generator[dict, None, None]:
"""Synchronous counterpart of :func:`stream_events`.
Intended for frontends that cannot use asyncio. Async frontends
(NiceGUI, TUI) should use :func:`stream_events` instead.
:param query: User's query string.
:param chat_id: Chat conversation identifier.
:param server_url: Base URL of the backend API server.
:param user_id: Opaque persistent user identifier.
"""
url = f"{server_url}/query/stream"
with (
httpx.Client(timeout=None) as client,
client.stream(
"POST",
url,
json={"query": query, "chat_id": chat_id, "user_id": user_id},
) as response,
):
response.raise_for_status()
for line in response.iter_lines():
if not line.startswith("data: "):
if line.strip():
logger.warning("Skipping non-data line: %s", line[:80])
continue
# Strip the SSE "data: " prefix (6 chars) to get raw JSON.
yield json.loads(line[6:])