Files
2026-07-13 13:39:38 +08:00

263 lines
9.5 KiB
Python

from __future__ import annotations
import logging
import os
import time
from collections.abc import Iterable
from types import TracebackType
from typing import TypedDict
import aiohttp
logger = logging.getLogger(__name__)
class RTZRAPIError(Exception):
"""Base exception for RTZR API errors."""
pass
class RTZRConnectionError(RTZRAPIError):
"""Exception raised when connection to RTZR API fails."""
pass
class RTZRStatusError(RTZRAPIError):
"""Exception raised when RTZR API returns an error status."""
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.message = message
self.status_code = status_code
class RTZRTimeoutError(RTZRAPIError):
"""Exception raised when RTZR API request times out."""
pass
DEFAULT_SAMPLE_RATE = 8000
class _Token(TypedDict):
access_token: str
expire_at: float
def _format_keywords(keywords: Iterable[str | tuple[str, float]]) -> str:
formatted: list[str] = []
keyword_list: list[str | tuple[str, float]] = list(keywords)
if len(keyword_list) > 100:
raise ValueError("RTZR keyword boosting supports up to 100 keywords")
for item in keyword_list:
if isinstance(item, tuple):
if len(item) != 2:
raise ValueError("RTZR keyword boosting tuples must be (keyword, boost)")
word, boost = item
if not isinstance(word, str):
raise ValueError("RTZR keyword boosting keywords must be strings")
if not isinstance(boost, (int, float)):
raise ValueError("RTZR keyword boost must be a number")
if not word:
raise ValueError("RTZR keyword boosting keywords must be non-empty")
if len(word) > 20:
raise ValueError("RTZR keyword boosting keywords must be <= 20 chars")
boost_value = float(boost)
if boost_value < -5.0 or boost_value > 5.0:
raise ValueError("RTZR keyword boost must be between -5.0 and 5.0")
formatted.append(f"{word}:{boost_value}")
continue
if not isinstance(item, str):
raise ValueError("RTZR keyword boosting items must be strings or (keyword, boost)")
keyword = item.strip()
if not keyword:
raise ValueError("RTZR keyword boosting keywords must be non-empty")
if ":" in keyword:
word, boost_str = keyword.rsplit(":", 1)
if not word:
raise ValueError("RTZR keyword boosting keywords must be non-empty")
if len(word) > 20:
raise ValueError("RTZR keyword boosting keywords must be <= 20 chars")
try:
boost = float(boost_str)
except ValueError as exc:
raise ValueError("RTZR keyword boost must be a number") from exc
if boost < -5.0 or boost > 5.0:
raise ValueError("RTZR keyword boost must be between -5.0 and 5.0")
formatted.append(f"{word}:{boost}")
continue
if len(keyword) > 20:
raise ValueError("RTZR keyword boosting keywords must be <= 20 chars")
formatted.append(keyword)
return ",".join(formatted)
class RTZROpenAPIClient:
"""RTZR OpenAPI Client for authentication and WebSocket streaming.
This is an independent SDK client that can be used without livekit dependencies.
It supports both manual session management and async context manager usage.
Example:
# Manual session management
client = RTZROpenAPIClient()
token = await client.get_token()
ws = await client.connect_websocket(config)
await client.close()
# Context manager (recommended)
async with RTZROpenAPIClient() as client:
token = await client.get_token()
ws = await client.connect_websocket(config)
"""
def __init__(
self,
client_id: str | None = None,
client_secret: str | None = None,
http_session: aiohttp.ClientSession | None = None,
) -> None:
self.client_id = client_id or os.environ.get("RTZR_CLIENT_ID")
self.client_secret = client_secret or os.environ.get("RTZR_CLIENT_SECRET")
if not (self.client_id and self.client_secret):
raise ValueError("RTZR_CLIENT_ID and RTZR_CLIENT_SECRET must be set")
self._http_session = http_session
self._owns_session = http_session is None # Track if we own the session
self._token: _Token | None = None
self._api_base = "https://openapi.vito.ai"
self._ws_base = "wss://" + self._api_base.split("://", 1)[1]
async def __aenter__(self) -> RTZROpenAPIClient:
"""Async context manager entry."""
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
"""Async context manager exit."""
await self.close()
async def get_token(self) -> str:
"""Get a valid access token, refreshing if necessary."""
token = self._token
if token is None or token["expire_at"] < time.time() - 3600:
await self._refresh_token()
token = self._token
if token is None:
raise RTZRAPIError("Failed to obtain RTZR access token")
return token["access_token"]
async def _refresh_token(self) -> None:
"""Refresh the access token."""
sess = self._ensure_http_session()
url = f"{self._api_base}/v1/authenticate"
try:
async with sess.post(
url, data={"client_id": self.client_id, "client_secret": self.client_secret}
) as resp:
resp.raise_for_status()
data = await resp.json()
if not isinstance(data, dict):
raise RTZRStatusError("Invalid token response payload")
access_token = data.get("access_token")
expire_at = data.get("expire_at")
if not isinstance(access_token, str) or not isinstance(expire_at, (int, float)):
raise RTZRStatusError("Invalid token response payload")
self._token = {"access_token": access_token, "expire_at": float(expire_at)}
logger.debug("Successfully refreshed RTZR access token")
except aiohttp.ClientResponseError as e:
logger.error("RTZR authentication failed: %s %s", e.status, e.message)
raise RTZRStatusError(
message=f"Authentication failed: {e.message}",
status_code=e.status,
) from e
except aiohttp.ClientError as e:
logger.error("RTZR authentication connection error: %s", e)
raise RTZRConnectionError("Failed to authenticate with RTZR API") from e
def _ensure_http_session(self) -> aiohttp.ClientSession:
"""Ensure HTTP session is available."""
if not self._http_session:
self._http_session = aiohttp.ClientSession()
return self._http_session
async def close(self) -> None:
"""Close the HTTP session if we own it."""
if self._http_session and self._owns_session:
await self._http_session.close()
self._http_session = None
async def connect_websocket(
self, config: dict[str, str], headers: dict[str, str] | None = None
) -> aiohttp.ClientWebSocketResponse:
"""Connect to the streaming WebSocket endpoint."""
# Build URL like reference client
query_string = "&".join(f"{k}={v}" for k, v in config.items())
url = f"{self._ws_base}/v1/transcribe:streaming?{query_string}"
# Prepare headers
token = await self.get_token()
ws_headers = {"Authorization": f"bearer {token}"}
if headers:
ws_headers.update(headers)
session = self._ensure_http_session()
try:
ws = await session.ws_connect(url, headers=ws_headers)
logger.debug("Connected to RTZR WebSocket at %s", url)
return ws
except aiohttp.ClientResponseError as e:
logger.error("RTZR WebSocket connection failed: %s %s", e.status, e.message)
raise RTZRStatusError(
message=f"WebSocket connection failed: {e.message}",
status_code=e.status,
) from e
except aiohttp.ClientError as e:
logger.error("RTZR WebSocket client error: %s", e)
raise RTZRConnectionError("WebSocket connection failed") from e
def build_config(
self,
model_name: str = "sommers_ko",
domain: str = "CALL",
sample_rate: int = DEFAULT_SAMPLE_RATE,
encoding: str = "LINEAR16",
epd_time: float = 0.5,
noise_threshold: float = 0.60,
active_threshold: float = 0.80,
use_punctuation: bool = False,
keywords: Iterable[str | tuple[str, float]] | None = None,
) -> dict[str, str]:
"""Build configuration dictionary for WebSocket connection."""
config = {
"model_name": model_name,
"domain": domain,
"sample_rate": str(sample_rate),
"encoding": encoding,
"epd_time": str(epd_time),
"noise_threshold": str(noise_threshold),
"active_threshold": str(active_threshold),
"use_punctuation": "true" if use_punctuation else "false",
}
if keywords:
config["keywords"] = _format_keywords(keywords)
return config