132 lines
5.4 KiB
Python
132 lines
5.4 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
from collections.abc import AsyncGenerator
|
|
from typing import Any
|
|
|
|
from realtime_voice_service.providers.base import BaseLLM
|
|
|
|
|
|
def _timeout_seconds() -> float:
|
|
raw = os.getenv("OPENAI_TIMEOUT_SECONDS")
|
|
if raw is None:
|
|
return 30.0
|
|
try:
|
|
return max(float(raw.strip()), 1.0)
|
|
except ValueError:
|
|
return 30.0
|
|
|
|
|
|
class OpenAILLM(BaseLLM):
|
|
def __init__(
|
|
self,
|
|
*,
|
|
api_key: str | None = None,
|
|
model: str | None = None,
|
|
base_url: str | None = None,
|
|
system_prompt: str | None = None,
|
|
timeout_seconds: float | None = None,
|
|
temperature: float = 0.3,
|
|
max_retries: int = 2,
|
|
) -> None:
|
|
self._api_key = str(api_key if api_key is not None else os.getenv("OPENAI_API_KEY", "")).strip()
|
|
self._model = str(model or os.getenv("OPENAI_LLM_MODEL", "gpt-4o-mini")).strip() or "gpt-4o-mini"
|
|
self._base_url = str(base_url or os.getenv("OPENAI_BASE_URL", "")).strip() or None
|
|
self._system_prompt = str(
|
|
system_prompt
|
|
if system_prompt is not None
|
|
else os.getenv(
|
|
"OPENAI_LLM_SYSTEM_PROMPT",
|
|
"You are a concise voice assistant for a telecom call center. Answer clearly and briefly.",
|
|
)
|
|
).strip()
|
|
self._timeout_seconds = max(float(timeout_seconds if timeout_seconds is not None else _timeout_seconds()), 1.0)
|
|
self._temperature = max(min(float(temperature), 2.0), 0.0)
|
|
self._max_retries = max(int(max_retries), 0)
|
|
self._client: Any | None = None
|
|
self._openai_module: Any | None = None
|
|
|
|
async def generate_stream(self, text: str, context: list) -> AsyncGenerator[str, None]:
|
|
if not self._api_key:
|
|
raise RuntimeError("OPENAI_API_KEY is required for OpenAI LLM")
|
|
|
|
client = self._get_client()
|
|
messages = self._build_messages(text, context)
|
|
try:
|
|
stream = await client.chat.completions.create(
|
|
model=self._model,
|
|
messages=messages,
|
|
temperature=self._temperature,
|
|
stream=True,
|
|
)
|
|
async for chunk in stream:
|
|
choices = getattr(chunk, "choices", None) or []
|
|
if not choices:
|
|
continue
|
|
delta = getattr(choices[0], "delta", None)
|
|
if delta is None:
|
|
continue
|
|
content = getattr(delta, "content", None)
|
|
if content:
|
|
yield str(content)
|
|
except Exception as exc: # noqa: BLE001
|
|
openai_module = self._openai_module
|
|
if openai_module is not None and isinstance(exc, getattr(openai_module, "APITimeoutError", ())):
|
|
raise RuntimeError("OpenAI LLM request timed out") from exc
|
|
if openai_module is not None and isinstance(exc, getattr(openai_module, "RateLimitError", ())):
|
|
raise RuntimeError("OpenAI LLM rate limit exceeded") from exc
|
|
if openai_module is not None and isinstance(exc, getattr(openai_module, "APIStatusError", ())):
|
|
status_code = getattr(exc, "status_code", "unknown")
|
|
raise RuntimeError(f"OpenAI LLM returned HTTP {status_code}") from exc
|
|
if openai_module is not None and isinstance(exc, getattr(openai_module, "APIConnectionError", ())):
|
|
raise RuntimeError("OpenAI LLM connection failed") from exc
|
|
raise RuntimeError("OpenAI LLM streaming failed") from exc
|
|
|
|
def _get_client(self):
|
|
if self._client is not None:
|
|
return self._client
|
|
try:
|
|
import httpx
|
|
import openai
|
|
from openai import AsyncOpenAI
|
|
except Exception as exc: # noqa: BLE001
|
|
raise RuntimeError("The `openai` package is required for OpenAI LLM") from exc
|
|
|
|
timeout = httpx.Timeout(
|
|
self._timeout_seconds,
|
|
connect=min(self._timeout_seconds, 5.0),
|
|
write=min(self._timeout_seconds, 15.0),
|
|
read=self._timeout_seconds,
|
|
)
|
|
self._openai_module = openai
|
|
self._client = AsyncOpenAI(
|
|
api_key=self._api_key,
|
|
base_url=self._base_url,
|
|
timeout=timeout,
|
|
max_retries=self._max_retries,
|
|
)
|
|
return self._client
|
|
|
|
def _build_messages(self, text: str, context: list) -> list[dict[str, str]]:
|
|
messages: list[dict[str, str]] = []
|
|
if self._system_prompt:
|
|
messages.append({"role": "system", "content": self._system_prompt})
|
|
|
|
for entry in context:
|
|
role: str | None = None
|
|
content: str | None = None
|
|
if isinstance(entry, dict):
|
|
role = str(entry.get("role") or "").strip().lower() or None
|
|
content = str(entry.get("content") or "").strip() or None
|
|
elif isinstance(entry, (tuple, list)) and len(entry) >= 2:
|
|
speaker = str(entry[0]).strip().lower()
|
|
role = "assistant" if speaker == "assistant" else "user"
|
|
content = str(entry[1]).strip() or None
|
|
if role and content:
|
|
messages.append({"role": role, "content": content})
|
|
|
|
if text.strip():
|
|
if not messages or messages[-1].get("role") != "user" or messages[-1].get("content") != text:
|
|
messages.append({"role": "user", "content": text})
|
|
return messages
|