271 lines
6.9 KiB
Python
271 lines
6.9 KiB
Python
"""
|
|
OCI Generative AI agent integration.
|
|
|
|
Provides parsing and streaming support for OCI Generative AI
|
|
chat inference using the GenericChatRequest format.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from typing import Any
|
|
|
|
import logging
|
|
from collections.abc import AsyncGenerator
|
|
from contextlib import aclosing
|
|
|
|
import oci
|
|
|
|
from voice_assistant.agent.adapter import StreamingAgentAdapter
|
|
from voice_assistant.agent.oci_stream import stream_sse_events
|
|
from voice_assistant.agent.oci_connection import open_oci_stream
|
|
|
|
|
|
def extract_text_delta(raw_data: str) -> str | None:
|
|
"""
|
|
Extract incremental text from an OCI Generative AI SSE event.
|
|
|
|
Returns:
|
|
Text delta when available.
|
|
None for empty or non-text events.
|
|
|
|
Raises:
|
|
ValueError for malformed JSON or invalid event structures.
|
|
"""
|
|
if raw_data == "[DONE]":
|
|
return None
|
|
|
|
try:
|
|
payload: Any = json.loads(raw_data)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(
|
|
"Invalid JSON in OCI Generative AI SSE event."
|
|
) from exc
|
|
|
|
if not isinstance(payload, dict):
|
|
raise ValueError(
|
|
"OCI Generative AI SSE event must be an object."
|
|
)
|
|
|
|
message = payload.get("message")
|
|
|
|
if message is None:
|
|
return None
|
|
|
|
if not isinstance(message, dict):
|
|
raise ValueError(
|
|
"Invalid OCI Generative AI message structure."
|
|
)
|
|
|
|
content = message.get("content", [])
|
|
|
|
if not isinstance(content, list):
|
|
raise ValueError(
|
|
"Invalid OCI Generative AI content structure."
|
|
)
|
|
|
|
deltas: list[str] = []
|
|
|
|
for item in content:
|
|
if not isinstance(item, dict):
|
|
raise ValueError(
|
|
"Invalid OCI Generative AI content item."
|
|
)
|
|
|
|
if item.get("type") != "TEXT":
|
|
continue
|
|
|
|
text = item.get("text")
|
|
|
|
if not isinstance(text, str):
|
|
raise ValueError(
|
|
"Invalid OCI Generative AI text delta."
|
|
)
|
|
|
|
if text:
|
|
deltas.append(text)
|
|
|
|
return "".join(deltas) or None
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class OCIAgentAdapter(StreamingAgentAdapter):
|
|
"""
|
|
OCI Generative AI streaming Agent.
|
|
|
|
Responsibilities:
|
|
- Authenticate using Instance Principal.
|
|
- Build OCI chat requests.
|
|
- Execute inference outside the event loop.
|
|
- Stream text deltas incrementally.
|
|
- Close SSE resources after consumption.
|
|
|
|
Non-responsibilities:
|
|
- Managing semantic turn state.
|
|
- Sending WebSocket events.
|
|
- Performing TTS synthesis.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
region: str,
|
|
compartment_id: str,
|
|
model_id: str = "openai.gpt-4o-mini",
|
|
temperature: float = 0.3,
|
|
max_completion_tokens: int = 256,
|
|
timeout_seconds: float = 60.0,
|
|
) -> None:
|
|
if not region:
|
|
raise ValueError("OCI region is required.")
|
|
|
|
if not compartment_id:
|
|
raise ValueError("OCI compartment_id is required.")
|
|
|
|
if not model_id:
|
|
raise ValueError("OCI model_id is required.")
|
|
|
|
if timeout_seconds <= 0:
|
|
raise ValueError("timeout_seconds must be positive.")
|
|
|
|
self.region = region
|
|
self.compartment_id = compartment_id
|
|
self.model_id = model_id
|
|
self.temperature = temperature
|
|
self.max_completion_tokens = max_completion_tokens
|
|
self.timeout_seconds = timeout_seconds
|
|
self._closed = False
|
|
|
|
signer = (
|
|
oci.auth.signers
|
|
.InstancePrincipalsSecurityTokenSigner()
|
|
)
|
|
|
|
self.client = (
|
|
oci.generative_ai_inference
|
|
.GenerativeAiInferenceClient(
|
|
config={"region": region},
|
|
signer=signer,
|
|
timeout=(10, timeout_seconds),
|
|
)
|
|
)
|
|
|
|
def _build_chat_details(
|
|
self,
|
|
text: str,
|
|
):
|
|
models = oci.generative_ai_inference.models
|
|
|
|
chat_request = models.GenericChatRequest(
|
|
messages=[
|
|
models.UserMessage(
|
|
content=[
|
|
models.TextContent(
|
|
text=text,
|
|
)
|
|
]
|
|
)
|
|
],
|
|
is_stream=True,
|
|
temperature=self.temperature,
|
|
max_completion_tokens=self.max_completion_tokens,
|
|
)
|
|
|
|
return models.ChatDetails(
|
|
compartment_id=self.compartment_id,
|
|
serving_mode=models.OnDemandServingMode(
|
|
model_id=self.model_id,
|
|
),
|
|
chat_request=chat_request,
|
|
)
|
|
|
|
async def stream(
|
|
self,
|
|
session_id: str,
|
|
turn_id: int,
|
|
text: str,
|
|
) -> AsyncGenerator[str, None]:
|
|
"""
|
|
Stream OCI Generative AI text deltas.
|
|
|
|
The synchronous OCI request is executed outside the
|
|
event loop. SSE events are consumed through the
|
|
asynchronous bridge.
|
|
"""
|
|
if self._closed:
|
|
raise RuntimeError("OCI Agent is closed.")
|
|
|
|
if not text.strip():
|
|
raise ValueError("Agent input text cannot be empty.")
|
|
|
|
details = self._build_chat_details(text)
|
|
|
|
logger.debug(
|
|
"Starting OCI Agent stream. "
|
|
"session_id=%s turn_id=%s model=%s",
|
|
session_id,
|
|
turn_id,
|
|
self.model_id,
|
|
)
|
|
|
|
response = await open_oci_stream(
|
|
lambda: self.client.chat(details)
|
|
)
|
|
|
|
if response.status != 200:
|
|
raise RuntimeError(
|
|
f"OCI inference returned HTTP {response.status}."
|
|
)
|
|
|
|
sse = response.data
|
|
|
|
async with aclosing(
|
|
stream_sse_events(sse)
|
|
) as events:
|
|
async for raw_data in events:
|
|
delta = extract_text_delta(raw_data)
|
|
|
|
if delta:
|
|
yield delta
|
|
|
|
async def process(
|
|
self,
|
|
session_id: str,
|
|
turn_id: int,
|
|
text: str,
|
|
) -> str:
|
|
"""
|
|
Collect the complete response for compatibility
|
|
with the existing AgentAdapter contract.
|
|
"""
|
|
parts: list[str] = []
|
|
|
|
async with aclosing(
|
|
self.stream(
|
|
session_id=session_id,
|
|
turn_id=turn_id,
|
|
text=text,
|
|
)
|
|
) as deltas:
|
|
async for delta in deltas:
|
|
parts.append(delta)
|
|
|
|
response_text = "".join(parts).strip()
|
|
|
|
if not response_text:
|
|
raise RuntimeError(
|
|
"OCI Agent returned an empty response."
|
|
)
|
|
|
|
return response_text
|
|
|
|
|
|
async def aclose(self) -> None:
|
|
"""Release the OCI HTTP session after turn execution has stopped."""
|
|
if self._closed:
|
|
return
|
|
await asyncio.to_thread(self.client.base_client.session.close)
|
|
self._closed = True
|