Module livekit.plugins.synthesia
Synthesia plugin for LiveKit Agents
See https://docs.livekit.io/agents/models/avatar/plugins/synthesia/ for more information.
Classes
class AvatarConfig (avatar_ids: Sequence[str])-
Expand source code
@dataclass class AvatarConfig: """The avatars to render in the room. ``avatar_ids`` holds one to five gallery ids of Synthesia avatars available to your workspace. The first id is the active avatar; the rest are available for swapping in during the session. Normalized to a tuple. """ avatar_ids: Sequence[str] def __post_init__(self) -> None: if isinstance(self.avatar_ids, (str, bytes)): raise ValueError("avatar_ids must be a list of ids, not a single string") self.avatar_ids = tuple(self.avatar_ids) if not 1 <= len(self.avatar_ids) <= MAX_AVATAR_IDS: raise ValueError( f"avatar_ids must contain between 1 and {MAX_AVATAR_IDS} ids, " f"got {len(self.avatar_ids)}" )The avatars to render in the room.
avatar_idsholds one to five gallery ids of Synthesia avatars available to your workspace. The first id is the active avatar; the rest are available for swapping in during the session. Normalized to a tuple.Instance variables
var avatar_ids : Sequence[str]
class AvatarSession (avatar_config: AvatarConfig,
*,
api_key: str | None = None,
api_url: str | None = None,
join_timeout: float = 30.0,
avatar_participant_identity: str | None = None,
avatar_participant_name: str | None = None)-
Expand source code
class AvatarSession(BaseAvatarSession): """A Synthesia interactive avatar for a LiveKit voice agent. Construct with up to five gallery ``avatar_ids`` and call ``start()`` before ``AgentSession.start()``. The first id is the active avatar and the rest are precomputed for a future mid-session swap. Credentials fall back to the ``SYNTHESIA_API_KEY`` and ``SYNTHESIA_API_URL`` environment variables. Logs when the room ends cleanly and when the avatar track drops unexpectedly mid-session. Pass ``avatar_participant_identity`` to override the LiveKit identity the avatar joins under. It must be unique per concurrent avatar in a room, since LiveKit evicts an existing participant when a second joins with the same identity. It defaults to ``synthesia-avatar-agent``. """ def __init__( self, avatar_config: AvatarConfig, *, api_key: str | None = None, api_url: str | None = None, join_timeout: float = DEFAULT_JOIN_TIMEOUT, avatar_participant_identity: str | None = None, avatar_participant_name: str | None = None, ) -> None: super().__init__() if avatar_participant_identity is not None and not avatar_participant_identity.strip(): raise SynthesiaError("avatar_participant_identity must be a non-empty string") if avatar_participant_name is not None and not avatar_participant_name.strip(): raise SynthesiaError("avatar_participant_name must be a non-empty string") self._avatar_identity = avatar_participant_identity or AVATAR_IDENTITY self._avatar_name = avatar_participant_name or AVATAR_NAME key = api_key or os.environ.get("SYNTHESIA_API_KEY") if not key: raise SynthesiaError( "a Synthesia API key is required: pass api_key or set SYNTHESIA_API_KEY" ) url = api_url or os.environ.get("SYNTHESIA_API_URL") or DEFAULT_API_URL self._config = SessionConfig( avatar_ids=tuple(avatar_config.avatar_ids), api_key=key, api_url=url, join_timeout=join_timeout, ) self._state = _State.IDLE # Guards against re-entrant start() calls. Distinct from _state: while a # start is awaiting a prior teardown the lifecycle is still its old # state, so this cannot be folded into _state. self._starting = False self._close_done = asyncio.Event() self._session_id: str | None = None self._teardown_task: asyncio.Task[None] | None = None # The exact sink this session installed, tracked by identity rather than # type or chain position: AgentSession.start() can wrap it in # TranscriptSynchronizer/RecorderAudioOutput, and a caller may already # have their own DataStreamAudioOutput installed before start() runs. self._audio_output: DataStreamAudioOutput | None = None @property def avatar_identity(self) -> str: return self._avatar_identity @property def provider(self) -> str: return "synthesia" async def start( self, agent_session: AgentSession, room: rtc.Room, *, livekit_url: str | None = None, livekit_api_key: str | None = None, livekit_api_secret: str | None = None, ) -> None: if self._starting: raise SynthesiaError("start() is already in progress") self._starting = True try: if self._teardown_task is not None: teardown = self._teardown_task await asyncio.shield(asyncio.gather(teardown, return_exceptions=True)) if not teardown.cancelled() and teardown.exception() is not None: raise SynthesiaError( "the previous avatar session did not tear down cleanly; not restarting" ) from teardown.exception() if self._state is _State.STARTED: return url = livekit_url or os.environ.get("LIVEKIT_URL") lk_key = livekit_api_key or os.environ.get("LIVEKIT_API_KEY") lk_secret = livekit_api_secret or os.environ.get("LIVEKIT_API_SECRET") # A blank key or secret still mints a syntactically valid token that # Synthesia accepts, since it cannot verify a signature made with the # developer's own secret. LiveKit only rejects it once the avatar # tries to join, so catch it here instead. if not _present(url) or not _present(lk_key) or not _present(lk_secret): raise SynthesiaError( "LiveKit url, API key, and API secret are required: pass them or " "set LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET" ) url = _to_ws_url(url) if not url.startswith(("ws://", "wss://")): raise SynthesiaError(f"livekit_url {url!r} is not a ws:// or wss:// URL") self._state = _State.IDLE self._close_done.clear() self._teardown_task = None try: await super().start(agent_session, room) token = self._mint_token(room=room, lk_key=lk_key, lk_secret=lk_secret) client = SynthesiaAPI(api_key=self._config.api_key, api_url=self._config.api_url) launch_conn = dataclasses.replace( DEFAULT_API_CONNECT_OPTIONS, timeout=self._config.join_timeout, max_retry=0, ) response = await client.start_session( StartSessionRequest( avatar_ids=list(self._config.avatar_ids), livekit_url=url, lk_token=token, ), conn_options=launch_conn, ) self._session_id = response.session_id # TODO: confirm the avatar worker's expected audio sample rate and # pass sample_rate explicitly once it is verified against the real # worker. audio_output = DataStreamAudioOutput( room, destination_identity=self.avatar_identity, wait_remote_track=rtc.TrackKind.KIND_VIDEO, ) # replace_audio_tail keeps any wrapper AgentSession.start() adds # later (TranscriptSynchronizer, RecorderAudioOutput) attached. agent_session.output.replace_audio_tail(audio_output) self._audio_output = audio_output await self.wait_for_join(timeout=self._config.join_timeout) except asyncio.TimeoutError as e: await self.aclose() await self._discard_partial_start() raise SynthesiaError( f"avatar did not join within {self._config.join_timeout}s", type=ErrorType.TIMEOUT, ) from e except BaseException: await self.aclose() await self._discard_partial_start() raise if self._state is _State.CLOSED: await self._close_done.wait() await self._discard_partial_start() raise SynthesiaError("avatar session was closed while starting") room.on("disconnected", self._on_room_disconnected) room.on("track_unpublished", self._on_track_unpublished) room.on("participant_disconnected", self._on_participant_disconnected) self._state = _State.STARTED finally: self._starting = False async def _discard_partial_start(self) -> None: # An aclose() that ran concurrently with start() cannot see the audio # output and session id that start() set after it completed, so a failed # start cleans them up itself, closing the audio output's background # tasks rather than just dropping the reference. Only the sink this # session installed is touched: it may now be wrapped by # AgentSession.start() (TranscriptSynchronizer, RecorderAudioOutput), and # the caller may have had their own DataStreamAudioOutput before this # session ever ran, so neither the current chain head nor its type says # what to close. audio_output, self._audio_output = self._audio_output, None if audio_output is not None and hasattr(audio_output, "aclose"): await audio_output.aclose() self._session_id = None async def swap_avatar(self, avatar_id: str, *, timeout: float = DEFAULT_SWAP_TIMEOUT) -> str: """Switch the rendered avatar mid-session. ``avatar_id`` must be one of the ids passed in ``AvatarConfig``, or ``"default"`` for the first id in that list. Returns the now-active avatar id once the swap has taken effect. Raises a ``SynthesiaError`` of type ``UNKNOWN_AVATAR`` for an id that was not precomputed, ``CONNECTION`` if the worker cannot be reached, and no ``type`` if the worker rejects the swap. """ if self._state is not _State.STARTED or self._ending() or self._room is None: raise SynthesiaError("swap_avatar() requires a started avatar session") if avatar_id != "default" and avatar_id not in self._config.avatar_ids: raise SynthesiaError( f"avatar {avatar_id!r} was not in initial list of avatar_ids", type=ErrorType.UNKNOWN_AVATAR, ) try: raw = await self._room.local_participant.perform_rpc( destination_identity=self.avatar_identity, method="swapAvatar", payload=json.dumps({"avatar_id": avatar_id}), response_timeout=timeout, ) except Exception as e: raise SynthesiaError(f"avatar swap RPC failed: {e}", type=ErrorType.CONNECTION) from e try: response = json.loads(raw) except ValueError as e: raise SynthesiaError("avatar swap returned a malformed response") from e avatar_id_result = response.get("avatar_id") if isinstance(response, dict) else None if ( not isinstance(response, dict) or response.get("error") or not isinstance(avatar_id_result, str) ): detail = response.get("error") if isinstance(response, dict) else None raise SynthesiaError(f"avatar swap failed: {detail or raw}") return avatar_id_result def _mint_token(self, *, room: rtc.Room, lk_key: str, lk_secret: str) -> str: # Synthesia rejects a token whose publish-on-behalf attribute is empty, # since the worker has no agent to publish the avatar's audio for. Prefer # the job context over room.local_participant: inside a job, the room may # not have finished connecting yet when start() runs. Standalone callers # (no job context) connect the room themselves before calling start(), so # room.local_participant is safe there. job_ctx = get_job_context(required=False) agent_identity = ( job_ctx.local_participant_identity if job_ctx is not None else room.local_participant.identity ) if not _present(agent_identity): raise SynthesiaError( "the room's local participant has no identity; connect the room " "before starting the avatar session" ) token = ( api.AccessToken(lk_key, lk_secret) .with_identity(self.avatar_identity) .with_name(self._avatar_name) .with_kind("agent") .with_grants( api.VideoGrants( room_join=True, room=room.name, can_publish=True, can_subscribe=True, can_publish_data=True, ) ) .with_attributes({ATTRIBUTE_PUBLISH_ON_BEHALF: agent_identity}) .with_ttl(TOKEN_TTL) ) return token.to_jwt() def _on_room_disconnected(self, *args: object) -> None: if self._ending(): return logger.info("avatar session ended") self._begin_teardown() def _on_track_unpublished( self, publication: rtc.RemoteTrackPublication, participant: rtc.RemoteParticipant, ) -> None: if ( participant.identity == self.avatar_identity and publication.kind == rtc.TrackKind.KIND_VIDEO ): self._report_avatar_lost() def _on_participant_disconnected(self, participant: rtc.RemoteParticipant) -> None: if participant.identity == self.avatar_identity: self._report_avatar_lost() def _report_avatar_lost(self) -> None: if self._ending(): return # The avatar leaving while the room is still up is a worker crash. If the # room itself is going down, this is a clean end and _on_room_disconnected # reports it instead. if self._room is not None and not self._room.isconnected(): return logger.warning("avatar left the room unexpectedly") self._begin_teardown() def _ending(self) -> bool: return self._state is _State.CLOSED or self._teardown_task is not None def _begin_teardown(self) -> None: if self._teardown_task is None and self._state is not _State.CLOSED: self._teardown_task = asyncio.create_task(self.aclose()) self._teardown_task.add_done_callback(self._on_teardown_done) def _on_teardown_done(self, task: asyncio.Task[None]) -> None: if task.cancelled(): return exc = task.exception() if exc is not None: logger.error("avatar teardown failed", exc_info=exc) async def aclose(self) -> None: if self._state is _State.CLOSED: await self._close_done.wait() return self._state = _State.CLOSED self._session_id = None try: # Only the sink this session installed is closed, by identity: it may # now be wrapped by AgentSession.start() (TranscriptSynchronizer, # RecorderAudioOutput), and the caller may have had their own # DataStreamAudioOutput before this session ever ran, so neither the # current chain head nor its type says what to close. The wrapper # chain itself is left in place; there is no public API to remove a # tail sink from it without supplying a replacement. audio_output, self._audio_output = self._audio_output, None if audio_output is not None and hasattr(audio_output, "aclose"): await audio_output.aclose() if self._room is not None: self._room.off("disconnected", self._on_room_disconnected) self._room.off("track_unpublished", self._on_track_unpublished) self._room.off("participant_disconnected", self._on_participant_disconnected) await super().aclose() finally: self._close_done.set()A Synthesia interactive avatar for a LiveKit voice agent.
Construct with up to five gallery
avatar_idsand callstart()beforeAgentSession.start(). The first id is the active avatar and the rest are precomputed for a future mid-session swap. Credentials fall back to theSYNTHESIA_API_KEYandSYNTHESIA_API_URLenvironment variables.Logs when the room ends cleanly and when the avatar track drops unexpectedly mid-session.
Pass
avatar_participant_identityto override the LiveKit identity the avatar joins under. It must be unique per concurrent avatar in a room, since LiveKit evicts an existing participant when a second joins with the same identity. It defaults tosynthesia-avatar-agent.Ancestors
- livekit.agents.voice.avatar._types.AvatarSession
- abc.ABC
- EventEmitter
- typing.Generic
Instance variables
prop avatar_identity : str-
Expand source code
@property def avatar_identity(self) -> str: return self._avatar_identityThe participant identifier of the avatar
prop provider : str-
Expand source code
@property def provider(self) -> str: return "synthesia"The provider of the avatar
Methods
async def aclose(self) ‑> None-
Expand source code
async def aclose(self) -> None: if self._state is _State.CLOSED: await self._close_done.wait() return self._state = _State.CLOSED self._session_id = None try: # Only the sink this session installed is closed, by identity: it may # now be wrapped by AgentSession.start() (TranscriptSynchronizer, # RecorderAudioOutput), and the caller may have had their own # DataStreamAudioOutput before this session ever ran, so neither the # current chain head nor its type says what to close. The wrapper # chain itself is left in place; there is no public API to remove a # tail sink from it without supplying a replacement. audio_output, self._audio_output = self._audio_output, None if audio_output is not None and hasattr(audio_output, "aclose"): await audio_output.aclose() if self._room is not None: self._room.off("disconnected", self._on_room_disconnected) self._room.off("track_unpublished", self._on_track_unpublished) self._room.off("participant_disconnected", self._on_participant_disconnected) await super().aclose() finally: self._close_done.set() async def start(self,
agent_session: AgentSession,
room: rtc.Room,
*,
livekit_url: str | None = None,
livekit_api_key: str | None = None,
livekit_api_secret: str | None = None) ‑> None-
Expand source code
async def start( self, agent_session: AgentSession, room: rtc.Room, *, livekit_url: str | None = None, livekit_api_key: str | None = None, livekit_api_secret: str | None = None, ) -> None: if self._starting: raise SynthesiaError("start() is already in progress") self._starting = True try: if self._teardown_task is not None: teardown = self._teardown_task await asyncio.shield(asyncio.gather(teardown, return_exceptions=True)) if not teardown.cancelled() and teardown.exception() is not None: raise SynthesiaError( "the previous avatar session did not tear down cleanly; not restarting" ) from teardown.exception() if self._state is _State.STARTED: return url = livekit_url or os.environ.get("LIVEKIT_URL") lk_key = livekit_api_key or os.environ.get("LIVEKIT_API_KEY") lk_secret = livekit_api_secret or os.environ.get("LIVEKIT_API_SECRET") # A blank key or secret still mints a syntactically valid token that # Synthesia accepts, since it cannot verify a signature made with the # developer's own secret. LiveKit only rejects it once the avatar # tries to join, so catch it here instead. if not _present(url) or not _present(lk_key) or not _present(lk_secret): raise SynthesiaError( "LiveKit url, API key, and API secret are required: pass them or " "set LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET" ) url = _to_ws_url(url) if not url.startswith(("ws://", "wss://")): raise SynthesiaError(f"livekit_url {url!r} is not a ws:// or wss:// URL") self._state = _State.IDLE self._close_done.clear() self._teardown_task = None try: await super().start(agent_session, room) token = self._mint_token(room=room, lk_key=lk_key, lk_secret=lk_secret) client = SynthesiaAPI(api_key=self._config.api_key, api_url=self._config.api_url) launch_conn = dataclasses.replace( DEFAULT_API_CONNECT_OPTIONS, timeout=self._config.join_timeout, max_retry=0, ) response = await client.start_session( StartSessionRequest( avatar_ids=list(self._config.avatar_ids), livekit_url=url, lk_token=token, ), conn_options=launch_conn, ) self._session_id = response.session_id # TODO: confirm the avatar worker's expected audio sample rate and # pass sample_rate explicitly once it is verified against the real # worker. audio_output = DataStreamAudioOutput( room, destination_identity=self.avatar_identity, wait_remote_track=rtc.TrackKind.KIND_VIDEO, ) # replace_audio_tail keeps any wrapper AgentSession.start() adds # later (TranscriptSynchronizer, RecorderAudioOutput) attached. agent_session.output.replace_audio_tail(audio_output) self._audio_output = audio_output await self.wait_for_join(timeout=self._config.join_timeout) except asyncio.TimeoutError as e: await self.aclose() await self._discard_partial_start() raise SynthesiaError( f"avatar did not join within {self._config.join_timeout}s", type=ErrorType.TIMEOUT, ) from e except BaseException: await self.aclose() await self._discard_partial_start() raise if self._state is _State.CLOSED: await self._close_done.wait() await self._discard_partial_start() raise SynthesiaError("avatar session was closed while starting") room.on("disconnected", self._on_room_disconnected) room.on("track_unpublished", self._on_track_unpublished) room.on("participant_disconnected", self._on_participant_disconnected) self._state = _State.STARTED finally: self._starting = False async def swap_avatar(self, avatar_id: str, *, timeout: float = 15.0) ‑> str-
Expand source code
async def swap_avatar(self, avatar_id: str, *, timeout: float = DEFAULT_SWAP_TIMEOUT) -> str: """Switch the rendered avatar mid-session. ``avatar_id`` must be one of the ids passed in ``AvatarConfig``, or ``"default"`` for the first id in that list. Returns the now-active avatar id once the swap has taken effect. Raises a ``SynthesiaError`` of type ``UNKNOWN_AVATAR`` for an id that was not precomputed, ``CONNECTION`` if the worker cannot be reached, and no ``type`` if the worker rejects the swap. """ if self._state is not _State.STARTED or self._ending() or self._room is None: raise SynthesiaError("swap_avatar() requires a started avatar session") if avatar_id != "default" and avatar_id not in self._config.avatar_ids: raise SynthesiaError( f"avatar {avatar_id!r} was not in initial list of avatar_ids", type=ErrorType.UNKNOWN_AVATAR, ) try: raw = await self._room.local_participant.perform_rpc( destination_identity=self.avatar_identity, method="swapAvatar", payload=json.dumps({"avatar_id": avatar_id}), response_timeout=timeout, ) except Exception as e: raise SynthesiaError(f"avatar swap RPC failed: {e}", type=ErrorType.CONNECTION) from e try: response = json.loads(raw) except ValueError as e: raise SynthesiaError("avatar swap returned a malformed response") from e avatar_id_result = response.get("avatar_id") if isinstance(response, dict) else None if ( not isinstance(response, dict) or response.get("error") or not isinstance(avatar_id_result, str) ): detail = response.get("error") if isinstance(response, dict) else None raise SynthesiaError(f"avatar swap failed: {detail or raw}") return avatar_id_resultSwitch the rendered avatar mid-session.
avatar_idmust be one of the ids passed inAvatarConfig, or"default"for the first id in that list. Returns the now-active avatar id once the swap has taken effect. Raises aSynthesiaErrorof typeUNKNOWN_AVATARfor an id that was not precomputed,CONNECTIONif the worker cannot be reached, and notypeif the worker rejects the swap.
Inherited members
class ErrorType (*args, **kwds)-
Expand source code
class ErrorType(enum.Enum): """What went wrong, for callers that want to branch on it.""" AUTH = "auth" """The API key is invalid, expired, or lacks the scope this endpoint requires.""" FEATURE_NOT_IN_PLAN = "feature_not_in_plan" """The workspace's plan does not include interactive avatars.""" INVALID_ROOM_TOKEN = "invalid_room_token" """The room token cannot produce a joined session. Distinct from ``AUTH``, which concerns the Synthesia API key. Raised for a token that is malformed or missing the attribute naming the agent the avatar publishes audio for. """ LIVEKIT_CREDENTIALS_REJECTED = "livekit_credentials_rejected" """The LiveKit project the token was signed for would not accept it. The token itself is well formed, so the API key and secret it was minted with are the thing to check, not the Synthesia credentials. """ INVALID_SESSION_REQUEST = "invalid_session_request" """The backend rejected the session request payload.""" UNKNOWN_AVATAR = "unknown_avatar" """Avatar is not in the gallery or not accessible to the workspace.""" QUOTA_EXCEEDED = "quota_exceeded" """The workspace's session quota is exhausted.""" RATE_LIMITED = "rate_limited" """The request was throttled. ``retry_after`` may carry the back-off.""" CONCURRENCY_LIMIT = "concurrency_limit" """Every concurrent-session slot is in use. The API sends no ``Retry-After`` for this today, so ``retry_after`` is normally ``None``; it is honoured if one ever arrives. """ TIMEOUT = "timeout" """The avatar did not join within ``join_timeout``.""" CONNECTION = "connection" """No usable response after retries. The last attempt either failed to connect or was answered with a 5xx; ``status`` and ``request_id`` describe that answer when there was one. """What went wrong, for callers that want to branch on it.
Ancestors
- enum.Enum
Class variables
var AUTH-
The API key is invalid, expired, or lacks the scope this endpoint requires.
var CONCURRENCY_LIMIT-
Every concurrent-session slot is in use.
The API sends no
Retry-Afterfor this today, soretry_afteris normallyNone; it is honoured if one ever arrives. var CONNECTION-
No usable response after retries.
The last attempt either failed to connect or was answered with a 5xx;
statusandrequest_iddescribe that answer when there was one. var FEATURE_NOT_IN_PLAN-
The workspace's plan does not include interactive avatars.
var INVALID_ROOM_TOKEN-
The room token cannot produce a joined session.
Distinct from
AUTH, which concerns the Synthesia API key. Raised for a token that is malformed or missing the attribute naming the agent the avatar publishes audio for. var INVALID_SESSION_REQUEST-
The backend rejected the session request payload.
var LIVEKIT_CREDENTIALS_REJECTED-
The LiveKit project the token was signed for would not accept it.
The token itself is well formed, so the API key and secret it was minted with are the thing to check, not the Synthesia credentials.
var QUOTA_EXCEEDED-
The workspace's session quota is exhausted.
var RATE_LIMITED-
The request was throttled.
retry_aftermay carry the back-off. var TIMEOUT-
The avatar did not join within
join_timeout. var UNKNOWN_AVATAR-
Avatar is not in the gallery or not accessible to the workspace.
class SynthesiaError (message: str,
*,
type: ErrorType | None = None,
body: object | None = None,
retryable: bool | None = None,
retry_after: float | None = None,
status: int | None = None,
request_id: str | None = None)-
Expand source code
class SynthesiaError(APIError): """Every error the Synthesia plugin raises. Also a :class:`~livekit.agents.APIError`. - ``type`` identifies what went wrong; see :class:`ErrorType`. - ``retryable`` reports whether retrying the same call could plausibly succeed, and defaults based on ``type`` unless overridden. - ``retry_after`` carries the server-provided back-off in seconds for a ``RATE_LIMITED`` or ``CONCURRENCY_LIMIT`` error, when the backend supplied one. - ``status`` is the HTTP status the API answered, or ``None`` when no answer arrived. - ``request_id`` is the API's ``requestId`` when the body carried one. Quote it when contacting support. """ def __init__( self, message: str, *, type: ErrorType | None = None, body: object | None = None, retryable: bool | None = None, retry_after: float | None = None, status: int | None = None, request_id: str | None = None, ) -> None: if not isinstance(message, str): message = _stringify(message) self.type = type self.retry_after = retry_after self.status = status self.request_id = request_id if retryable is None: retryable = type in _RETRYABLE_TYPES super().__init__(message, body=body, retryable=retryable)Every error the Synthesia plugin raises. Also a :class:
~livekit.agents.APIError.typeidentifies what went wrong; see :class:ErrorType.retryablereports whether retrying the same call could plausibly succeed, and defaults based ontypeunless overridden.retry_aftercarries the server-provided back-off in seconds for aRATE_LIMITEDorCONCURRENCY_LIMITerror, when the backend supplied one.statusis the HTTP status the API answered, orNonewhen no answer arrived.request_idis the API'srequestIdwhen the body carried one. Quote it when contacting support.
Ancestors
- livekit.agents._exceptions.APIError
- builtins.Exception
- builtins.BaseException