feat: add OCI GenAI streaming agent foundation
This commit is contained in:
147
src/voice_assistant/agent/oci_stream.py
Normal file
147
src/voice_assistant/agent/oci_stream.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Async bridge for synchronous OCI SSE streams.
|
||||
|
||||
The bridge runs blocking SSE iteration in a worker thread and
|
||||
delivers events to an asynchronous consumer with backpressure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def stream_sse_events(
|
||||
stream: Any,
|
||||
*,
|
||||
max_queue_size: int = 16,
|
||||
shutdown_timeout: float = 5.0,
|
||||
) -> AsyncIterator[str]:
|
||||
"""
|
||||
Convert a synchronous SSE stream into asynchronous text events.
|
||||
|
||||
The stream must provide:
|
||||
events() -> Iterator
|
||||
close() -> None
|
||||
|
||||
Cancellation stops delivery, requests stream closure and
|
||||
waits briefly for the worker to finish.
|
||||
|
||||
Worker termination is not guaranteed if the underlying HTTP
|
||||
read ignores stream closure.
|
||||
"""
|
||||
if max_queue_size < 1:
|
||||
raise ValueError("max_queue_size must be positive.")
|
||||
|
||||
if shutdown_timeout <= 0:
|
||||
raise ValueError("shutdown_timeout must be positive.")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue(
|
||||
maxsize=max_queue_size,
|
||||
)
|
||||
|
||||
stop_requested = threading.Event()
|
||||
|
||||
def publish(kind: str, value: Any = None) -> bool:
|
||||
if stop_requested.is_set():
|
||||
return False
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
queue.put((kind, value)),
|
||||
loop,
|
||||
)
|
||||
|
||||
while not stop_requested.is_set():
|
||||
try:
|
||||
future.result(timeout=0.1)
|
||||
return True
|
||||
except TimeoutError:
|
||||
continue
|
||||
except Exception:
|
||||
future.cancel()
|
||||
raise
|
||||
|
||||
future.cancel()
|
||||
return False
|
||||
|
||||
def worker() -> None:
|
||||
try:
|
||||
for event in stream.events():
|
||||
if stop_requested.is_set():
|
||||
break
|
||||
|
||||
data = getattr(event, "data", None)
|
||||
|
||||
if not isinstance(data, str):
|
||||
continue
|
||||
|
||||
if not publish("data", data):
|
||||
break
|
||||
|
||||
except Exception as exc:
|
||||
if not stop_requested.is_set():
|
||||
publish("error", exc)
|
||||
|
||||
finally:
|
||||
if not stop_requested.is_set():
|
||||
publish("done")
|
||||
|
||||
worker_task = asyncio.create_task(
|
||||
asyncio.to_thread(worker)
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
kind, value = await queue.get()
|
||||
|
||||
if kind == "done":
|
||||
break
|
||||
|
||||
if kind == "error":
|
||||
raise value
|
||||
|
||||
yield value
|
||||
|
||||
finally:
|
||||
stop_requested.set()
|
||||
|
||||
# Request transport closure outside the event loop.
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.to_thread(stream.close),
|
||||
timeout=shutdown_timeout,
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.error(
|
||||
"OCI SSE stream closure timed out."
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to close OCI SSE stream."
|
||||
)
|
||||
|
||||
# Do not assume that closing the stream immediately
|
||||
# terminates the synchronous reader.
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(worker_task),
|
||||
timeout=shutdown_timeout,
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.error(
|
||||
"OCI SSE worker did not terminate after closure."
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"OCI SSE worker failed during shutdown."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user