|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import asyncio |
| 4 | +import os |
| 5 | +from typing import Any |
| 6 | + |
| 7 | +import aiohttp |
| 8 | + |
| 9 | +from livekit.agents import ( |
| 10 | + DEFAULT_API_CONNECT_OPTIONS, |
| 11 | + NOT_GIVEN, |
| 12 | + APIConnectionError, |
| 13 | + APIConnectOptions, |
| 14 | + APIStatusError, |
| 15 | + APITimeoutError, |
| 16 | + NotGivenOr, |
| 17 | + utils, |
| 18 | +) |
| 19 | + |
| 20 | +from .errors import ProtofaceException |
| 21 | +from .log import logger |
| 22 | +from .version import __version__ |
| 23 | + |
| 24 | +DEFAULT_API_URL = "https://api.protoface.com" |
| 25 | +_USER_AGENT = f"livekit-plugins-protoface/{__version__}" |
| 26 | + |
| 27 | + |
| 28 | +class ProtofaceAPI: |
| 29 | + """Async client for the Protoface session API.""" |
| 30 | + |
| 31 | + def __init__( |
| 32 | + self, |
| 33 | + *, |
| 34 | + api_key: NotGivenOr[str | None] = NOT_GIVEN, |
| 35 | + api_url: NotGivenOr[str | None] = NOT_GIVEN, |
| 36 | + conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS, |
| 37 | + session: aiohttp.ClientSession | None = None, |
| 38 | + ) -> None: |
| 39 | + """Create a Protoface API client. |
| 40 | +
|
| 41 | + Args: |
| 42 | + api_key: Protoface API key. Defaults to the `PROTOFACE_API_KEY` |
| 43 | + environment variable. |
| 44 | + api_url: Protoface API base URL. Defaults to `PROTOFACE_API_URL` or |
| 45 | + `https://api.protoface.com`. |
| 46 | + conn_options: Timeout and retry settings for API calls. |
| 47 | + session: Optional caller-owned HTTP session. When omitted, the |
| 48 | + client uses LiveKit's shared HTTP session. |
| 49 | +
|
| 50 | + Raises: |
| 51 | + ProtofaceException: If no API key is passed and `PROTOFACE_API_KEY` |
| 52 | + is not set. |
| 53 | + """ |
| 54 | + self._api_key = _resolve_optional_string(api_key, "PROTOFACE_API_KEY") |
| 55 | + if not self._api_key: |
| 56 | + raise ProtofaceException( |
| 57 | + "api_key must be set by passing it to ProtofaceAPI or " |
| 58 | + "setting the PROTOFACE_API_KEY environment variable" |
| 59 | + ) |
| 60 | + |
| 61 | + api_url_value = _resolve_optional_string(api_url, "PROTOFACE_API_URL") |
| 62 | + self._api_url = (api_url_value or DEFAULT_API_URL).rstrip("/") |
| 63 | + self._conn_options = conn_options |
| 64 | + self._session = session |
| 65 | + |
| 66 | + async def start_session( |
| 67 | + self, |
| 68 | + *, |
| 69 | + avatar_id: str, |
| 70 | + transport: dict[str, Any], |
| 71 | + max_duration_seconds: NotGivenOr[int | None] = NOT_GIVEN, |
| 72 | + ) -> dict[str, Any]: |
| 73 | + """Create a hosted Protoface avatar session. |
| 74 | +
|
| 75 | + Args: |
| 76 | + avatar_id: Protoface avatar ID to render. |
| 77 | + transport: Protoface transport configuration. The LiveKit Agents |
| 78 | + plugin uses `audio_source="data_stream"`. |
| 79 | + max_duration_seconds: Optional maximum session duration. Protoface |
| 80 | + applies the lower of this value and the account plan limit. |
| 81 | +
|
| 82 | + Returns: |
| 83 | + The decoded Protoface session object. |
| 84 | +
|
| 85 | + Raises: |
| 86 | + APIConnectionError: If a retryable API or network error persists |
| 87 | + after all retry attempts. |
| 88 | + APIStatusError: If Protoface returns a non-retryable error response. |
| 89 | + """ |
| 90 | + body: dict[str, Any] = {"avatar_id": avatar_id, "transport": transport} |
| 91 | + if utils.is_given(max_duration_seconds) and max_duration_seconds is not None: |
| 92 | + body["max_duration_seconds"] = max_duration_seconds |
| 93 | + |
| 94 | + return await self._json("POST", "/v1/sessions", json=body) |
| 95 | + |
| 96 | + async def end_session(self, session_id: str) -> dict[str, Any]: |
| 97 | + """Request a graceful end for a hosted Protoface session. |
| 98 | +
|
| 99 | + Args: |
| 100 | + session_id: Protoface session ID returned by `start_session()`. |
| 101 | +
|
| 102 | + Returns: |
| 103 | + The decoded Protoface response body. |
| 104 | +
|
| 105 | + Raises: |
| 106 | + APIConnectionError: If a retryable API or network error persists |
| 107 | + after all retry attempts. |
| 108 | + APIStatusError: If Protoface returns a non-retryable error response. |
| 109 | + """ |
| 110 | + return await self._json("POST", f"/v1/sessions/{session_id}/end") |
| 111 | + |
| 112 | + def _ensure_http_session(self) -> aiohttp.ClientSession: |
| 113 | + if self._session is None: |
| 114 | + self._session = utils.http_context.http_session() |
| 115 | + return self._session |
| 116 | + |
| 117 | + async def _json( |
| 118 | + self, |
| 119 | + method: str, |
| 120 | + path: str, |
| 121 | + *, |
| 122 | + json: dict[str, Any] | None = None, |
| 123 | + headers: dict[str, str] | None = None, |
| 124 | + ) -> dict[str, Any]: |
| 125 | + request_headers = { |
| 126 | + "Authorization": f"Bearer {self._api_key}", |
| 127 | + "User-Agent": _USER_AGENT, |
| 128 | + "Accept": "application/json", |
| 129 | + **(headers or {}), |
| 130 | + } |
| 131 | + url = f"{self._api_url}{path}" |
| 132 | + error: Exception | None = None |
| 133 | + |
| 134 | + for attempt in range(self._conn_options.max_retry + 1): |
| 135 | + try: |
| 136 | + async with self._ensure_http_session().request( |
| 137 | + method, |
| 138 | + url, |
| 139 | + json=json, |
| 140 | + headers=request_headers, |
| 141 | + timeout=aiohttp.ClientTimeout(total=self._conn_options.timeout), |
| 142 | + ) as response: |
| 143 | + payload = await _read_payload(response) |
| 144 | + if response.ok: |
| 145 | + if not isinstance(payload, dict): |
| 146 | + raise APIStatusError( |
| 147 | + "Protoface API returned a non-object JSON response", |
| 148 | + status_code=response.status, |
| 149 | + body=payload, |
| 150 | + retryable=False, |
| 151 | + ) |
| 152 | + return payload |
| 153 | + |
| 154 | + raise APIStatusError( |
| 155 | + "Protoface API returned an error", |
| 156 | + status_code=response.status, |
| 157 | + body=payload, |
| 158 | + ) |
| 159 | + except asyncio.TimeoutError as exc: |
| 160 | + error = APITimeoutError() |
| 161 | + error.__cause__ = exc |
| 162 | + except aiohttp.ClientError as exc: |
| 163 | + error = APIConnectionError() |
| 164 | + error.__cause__ = exc |
| 165 | + except APIStatusError as exc: |
| 166 | + if not exc.retryable: |
| 167 | + raise |
| 168 | + error = exc |
| 169 | + |
| 170 | + if attempt == self._conn_options.max_retry: |
| 171 | + break |
| 172 | + |
| 173 | + logger.warning( |
| 174 | + "protoface api request failed, retrying", |
| 175 | + extra={"attempt": attempt + 1, "method": method, "path": path}, |
| 176 | + ) |
| 177 | + await asyncio.sleep(self._conn_options._interval_for_retry(attempt)) |
| 178 | + |
| 179 | + raise APIConnectionError("Failed to call Protoface API after all retries.") from error |
| 180 | + |
| 181 | + |
| 182 | +def _resolve_optional_string(value: NotGivenOr[str | None], env_name: str) -> str | None: |
| 183 | + if utils.is_given(value) and value is not None: |
| 184 | + return value |
| 185 | + return os.getenv(env_name) |
| 186 | + |
| 187 | + |
| 188 | +async def _read_payload(response: aiohttp.ClientResponse) -> object: |
| 189 | + text = await response.text() |
| 190 | + if not text: |
| 191 | + return {} |
| 192 | + |
| 193 | + try: |
| 194 | + return await response.json(content_type=None) |
| 195 | + except ValueError: |
| 196 | + return {"raw": text} |
| 197 | + |
| 198 | + |
| 199 | +__all__ = ["DEFAULT_API_URL", "ProtofaceAPI"] |
0 commit comments