"""HTTP client for the internal chat engine. Voice stays on the legacy LiveKit path until the agent is pointed at CHAT_ENGINE_URL. Closing the response cancels the upstream request, which aborts the engine turn. """ import json import os from urllib.error import HTTPError from urllib.request import Request, urlopen class ChatEngineClient: def __init__(self, base_url=None, token=None): self.base_url = (base_url or os.environ["CHAT_ENGINE_URL"]).rstrip("/") self.token = token or os.environ["CHAT_ENGINE_TOKEN"] self._response = None def _open(self, path, body, accept="application/json"): request = Request( f"{self.base_url}{path}", data=json.dumps(body).encode("utf-8"), method="POST", headers={ "Authorization": f"Bearer {self.token}", "Content-Type": "application/json", "Accept": accept, }, ) return urlopen(request) def create_session(self, book_id, user_id=None, channel="voice", mode="auto", style="default", plan=None): payload = { "bookId": str(book_id), "userId": user_id, "channel": channel, "mode": mode, "style": style, "plan": plan, } try: with self._open("/internal/sessions", payload) as response: return json.load(response) except HTTPError as error: detail = error.read().decode("utf-8", errors="replace") raise RuntimeError(f"chat engine session failed ({error.code}): {detail}") from error def iter_events(self, session_id, text, interrupt=False): """Yield parsed SSE events. Call close() to cancel the turn.""" self.close() try: response = self._open( f"/internal/sessions/{session_id}/messages", {"text": text, "interrupt": interrupt}, accept="text/event-stream", ) except HTTPError as error: detail = error.read().decode("utf-8", errors="replace") raise RuntimeError(f"chat engine message failed ({error.code}): {detail}") from error self._response = response try: while True: line = response.readline() if not line: break decoded = line.decode("utf-8").strip() if not decoded.startswith("data:"): continue yield json.loads(decoded[5:].strip()) finally: self.close() def mark_interrupted(self, session_id, spoken_text): try: with self._open(f"/internal/sessions/{session_id}/interrupted", {"spokenText": spoken_text}) as response: return json.load(response) except HTTPError as error: detail = error.read().decode("utf-8", errors="replace") raise RuntimeError(f"chat engine interrupt failed ({error.code}): {detail}") from error def end_session(self, session_id): try: with self._open(f"/internal/sessions/{session_id}/end", {}) as response: return json.load(response) except HTTPError as error: detail = error.read().decode("utf-8", errors="replace") raise RuntimeError(f"chat engine end failed ({error.code}): {detail}") from error def close(self): response = self._response self._response = None if response is not None: response.close()