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

200 lines
6.8 KiB
Python

from __future__ import annotations
import asyncio
import os
from typing import Any
import aiohttp
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
NotGivenOr,
utils,
)
from .errors import ProtofaceException
from .log import logger
from .version import __version__
DEFAULT_API_URL = "https://api.protoface.com"
_USER_AGENT = f"livekit-plugins-protoface/{__version__}"
class ProtofaceAPI:
"""Async client for the Protoface session API."""
def __init__(
self,
*,
api_key: NotGivenOr[str | None] = NOT_GIVEN,
api_url: NotGivenOr[str | None] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
session: aiohttp.ClientSession | None = None,
) -> None:
"""Create a Protoface API client.
Args:
api_key: Protoface API key. Defaults to the `PROTOFACE_API_KEY`
environment variable.
api_url: Protoface API base URL. Defaults to `PROTOFACE_API_URL` or
`https://api.protoface.com`.
conn_options: Timeout and retry settings for API calls.
session: Optional caller-owned HTTP session. When omitted, the
client uses LiveKit's shared HTTP session.
Raises:
ProtofaceException: If no API key is passed and `PROTOFACE_API_KEY`
is not set.
"""
self._api_key = _resolve_optional_string(api_key, "PROTOFACE_API_KEY")
if not self._api_key:
raise ProtofaceException(
"api_key must be set by passing it to ProtofaceAPI or "
"setting the PROTOFACE_API_KEY environment variable"
)
api_url_value = _resolve_optional_string(api_url, "PROTOFACE_API_URL")
self._api_url = (api_url_value or DEFAULT_API_URL).rstrip("/")
self._conn_options = conn_options
self._session = session
async def start_session(
self,
*,
avatar_id: str,
transport: dict[str, Any],
max_duration_seconds: NotGivenOr[int | None] = NOT_GIVEN,
) -> dict[str, Any]:
"""Create a hosted Protoface avatar session.
Args:
avatar_id: Protoface avatar ID to render.
transport: Protoface transport configuration. The LiveKit Agents
plugin uses `audio_source="data_stream"`.
max_duration_seconds: Optional maximum session duration. Protoface
applies the lower of this value and the account plan limit.
Returns:
The decoded Protoface session object.
Raises:
APIConnectionError: If a retryable API or network error persists
after all retry attempts.
APIStatusError: If Protoface returns a non-retryable error response.
"""
body: dict[str, Any] = {"avatar_id": avatar_id, "transport": transport}
if utils.is_given(max_duration_seconds) and max_duration_seconds is not None:
body["max_duration_seconds"] = max_duration_seconds
return await self._json("POST", "/v1/sessions", json=body)
async def end_session(self, session_id: str) -> dict[str, Any]:
"""Request a graceful end for a hosted Protoface session.
Args:
session_id: Protoface session ID returned by `start_session()`.
Returns:
The decoded Protoface response body.
Raises:
APIConnectionError: If a retryable API or network error persists
after all retry attempts.
APIStatusError: If Protoface returns a non-retryable error response.
"""
return await self._json("POST", f"/v1/sessions/{session_id}/end")
def _ensure_http_session(self) -> aiohttp.ClientSession:
if self._session is None:
self._session = utils.http_context.http_session()
return self._session
async def _json(
self,
method: str,
path: str,
*,
json: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> dict[str, Any]:
request_headers = {
"Authorization": f"Bearer {self._api_key}",
"User-Agent": _USER_AGENT,
"Accept": "application/json",
**(headers or {}),
}
url = f"{self._api_url}{path}"
error: Exception | None = None
for attempt in range(self._conn_options.max_retry + 1):
try:
async with self._ensure_http_session().request(
method,
url,
json=json,
headers=request_headers,
timeout=aiohttp.ClientTimeout(total=self._conn_options.timeout),
) as response:
payload = await _read_payload(response)
if response.ok:
if not isinstance(payload, dict):
raise APIStatusError(
"Protoface API returned a non-object JSON response",
status_code=response.status,
body=payload,
retryable=False,
)
return payload
raise APIStatusError(
"Protoface API returned an error",
status_code=response.status,
body=payload,
)
except asyncio.TimeoutError as exc:
error = APITimeoutError()
error.__cause__ = exc
except aiohttp.ClientError as exc:
error = APIConnectionError()
error.__cause__ = exc
except APIStatusError as exc:
if not exc.retryable:
raise
error = exc
if attempt == self._conn_options.max_retry:
break
logger.warning(
"protoface api request failed, retrying",
extra={"attempt": attempt + 1, "method": method, "path": path},
)
await asyncio.sleep(self._conn_options._interval_for_retry(attempt))
raise APIConnectionError("Failed to call Protoface API after all retries.") from error
def _resolve_optional_string(value: NotGivenOr[str | None], env_name: str) -> str | None:
if utils.is_given(value) and value is not None:
return value
return os.getenv(env_name)
async def _read_payload(response: aiohttp.ClientResponse) -> object:
text = await response.text()
if not text:
return {}
try:
return await response.json(content_type=None)
except ValueError:
return {"raw": text}
__all__ = ["DEFAULT_API_URL", "ProtofaceAPI"]