livekit-plugins-synthesia 1.8.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,180 @@
1
+ **/.vscode
2
+ **/.DS_Store
3
+ .env
4
+
5
+ # Byte-compiled / optimized / DLL files
6
+ __pycache__/
7
+ *.py[cod]
8
+ *$py.class
9
+
10
+ # C extensions
11
+ *.so
12
+
13
+ # Distribution / packaging
14
+ .Python
15
+ build/
16
+ develop-eggs/
17
+ dist/
18
+ downloads/
19
+ eggs/
20
+ .eggs/
21
+ lib/
22
+ lib64/
23
+ parts/
24
+ sdist/
25
+ var/
26
+ wheels/
27
+ share/python-wheels/
28
+ *.egg-info/
29
+ .installed.cfg
30
+ *.egg
31
+ MANIFEST
32
+
33
+ # PyInstaller
34
+ # Usually these files are written by a python script from a template
35
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
36
+ *.manifest
37
+ *.spec
38
+
39
+ # Installer logs
40
+ pip-log.txt
41
+ pip-delete-this-directory.txt
42
+
43
+ # Unit test / coverage reports
44
+ htmlcov/
45
+ .tox/
46
+ .nox/
47
+ .coverage
48
+ .coverage.*
49
+ .cache
50
+ nosetests.xml
51
+ coverage.xml
52
+ *.cover
53
+ *.py,cover
54
+ .hypothesis/
55
+ .pytest_cache/
56
+ cover/
57
+
58
+ # Translations
59
+ *.mo
60
+ *.pot
61
+
62
+ # Django stuff:
63
+ *.log
64
+ local_settings.py
65
+ db.sqlite3
66
+ db.sqlite3-journal
67
+
68
+ # Flask stuff:
69
+ instance/
70
+ .webassets-cache
71
+
72
+ # Scrapy stuff:
73
+ .scrapy
74
+
75
+ # Sphinx documentation
76
+ docs/_build/
77
+
78
+ # PyBuilder
79
+ .pybuilder/
80
+ target/
81
+
82
+ # Jupyter Notebook
83
+ .ipynb_checkpoints
84
+
85
+ # IPython
86
+ profile_default/
87
+ ipython_config.py
88
+
89
+ # pyenv
90
+ # For a library or package, you might want to ignore these files since the code is
91
+ # intended to run in multiple environments; otherwise, check them in:
92
+ # .python-version
93
+
94
+ # pipenv
95
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
96
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
97
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
98
+ # install all needed dependencies.
99
+ #Pipfile.lock
100
+
101
+ # poetry
102
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
103
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
104
+ # commonly ignored for libraries.
105
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
106
+ #poetry.lock
107
+
108
+ # pdm
109
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
110
+ #pdm.lock
111
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
112
+ # in version control.
113
+ # https://pdm.fming.dev/#use-with-ide
114
+ .pdm.toml
115
+
116
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
117
+ __pypackages__/
118
+
119
+ # Celery stuff
120
+ celerybeat-schedule
121
+ celerybeat.pid
122
+
123
+ # SageMath parsed files
124
+ *.sage.py
125
+
126
+ # Environments
127
+ .env
128
+ .venv
129
+ env/
130
+ venv/
131
+ ENV/
132
+ env.bak/
133
+ venv.bak/
134
+
135
+ # Spyder project settings
136
+ .spyderproject
137
+ .spyproject
138
+
139
+ # Rope project settings
140
+ .ropeproject
141
+
142
+ # mkdocs documentation
143
+ /site
144
+
145
+ # mypy
146
+ .mypy_cache/
147
+ .dmypy.json
148
+ dmypy.json
149
+
150
+ # trunk
151
+ .trunk/
152
+
153
+ # Pyre type checker
154
+ .pyre/
155
+
156
+ # pytype static type analyzer
157
+ .pytype/
158
+
159
+ # Cython debug symbols
160
+ cython_debug/
161
+
162
+ # PyCharm
163
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
164
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
165
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
166
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
167
+ .idea/
168
+
169
+ node_modules
170
+
171
+ credentials.json
172
+ pyrightconfig.json
173
+ docs/
174
+
175
+ # Database files
176
+ *.db
177
+
178
+
179
+ # Examples for development
180
+ examples/dev/*
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.5
2
+ Name: livekit-plugins-synthesia
3
+ Version: 1.8.2
4
+ Summary: LiveKit Agents plugin for Synthesia interactive avatars
5
+ Project-URL: Documentation, https://docs.livekit.io
6
+ Project-URL: Website, https://livekit.io/
7
+ Project-URL: Source, https://github.com/livekit/agents
8
+ Author-email: LiveKit <support@livekit.io>
9
+ License-Expression: Apache-2.0
10
+ Keywords: ai,audio,avatar,livekit,realtime,synthesia,video,voice,webrtc
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Multimedia :: Sound/Audio
19
+ Classifier: Topic :: Multimedia :: Video
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Requires-Python: >=3.10.0
22
+ Requires-Dist: livekit-agents>=1.8.2
23
+ Description-Content-Type: text/markdown
24
+
25
+ # Synthesia plugin for LiveKit Agents
26
+
27
+ Attach a [Synthesia](https://www.synthesia.io/) interactive avatar to a LiveKit voice agent. The avatar joins
28
+ the room and lip-syncs the agent's speech in real time.
29
+
30
+ See the [Synthesia integration docs](https://docs.livekit.io/agents/models/avatar/plugins/synthesia/) for more information.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install livekit-plugins-synthesia
36
+ ```
37
+
38
+ ## Pre-requisites
39
+
40
+ You'll need an API key from Synthesia. It can be set as an environment variable:
41
+ `SYNTHESIA_API_KEY`
42
+
43
+ ## Usage
44
+
45
+ ```python
46
+ from livekit.plugins import synthesia
47
+
48
+ avatar = synthesia.AvatarSession(
49
+ synthesia.AvatarConfig(
50
+ avatar_ids=["03cee7ec-ac90-45ec-8c20-74a399cf3dc4"]
51
+ ), # SYNTHESIA_API_KEY from env
52
+ )
53
+ await avatar.start(session, room=ctx.room) # before session.start
54
+ await session.start(agent=Agent(...), room=ctx.room)
55
+ ```
56
+
57
+ Set your Synthesia workspace API key in `SYNTHESIA_API_KEY`, or pass `api_key=`.
58
+ `avatar_ids` takes one to five gallery ids of avatars available to your
59
+ workspace. The first is the active avatar; the rest are precomputed by the
60
+ worker so `swap_avatar()` can switch to them mid-session. An id your workspace
61
+ cannot access raises `synthesia.SynthesiaError` with `type=synthesia.ErrorType.UNKNOWN_AVATAR`.
62
+
63
+ ```python
64
+ await avatar.swap_avatar("<another-id-from-avatar-ids>") # switch mid-session
65
+ await avatar.swap_avatar("default") # back to the first id
66
+ ```
67
+
68
+ ## Parameters
69
+
70
+ | Parameter | Default | Description |
71
+ | --- | --- | --- |
72
+ | `avatar_participant_identity` | `"synthesia-avatar-agent"` | The LiveKit identity the avatar joins under. Must be unique per concurrent avatar in a room: LiveKit evicts an existing participant when a second joins with the same identity, so give each avatar its own identity to run several in one room. |
73
+ | `avatar_participant_name` | `"Synthesia avatar"` | The LiveKit display name the avatar joins under. |
74
+
75
+ ```python
76
+ avatar = synthesia.AvatarSession(
77
+ synthesia.AvatarConfig(avatar_ids=["03cee7ec-ac90-45ec-8c20-74a399cf3dc4"]),
78
+ avatar_participant_identity="avatar-host",
79
+ )
80
+ ```
@@ -0,0 +1,56 @@
1
+ # Synthesia plugin for LiveKit Agents
2
+
3
+ Attach a [Synthesia](https://www.synthesia.io/) interactive avatar to a LiveKit voice agent. The avatar joins
4
+ the room and lip-syncs the agent's speech in real time.
5
+
6
+ See the [Synthesia integration docs](https://docs.livekit.io/agents/models/avatar/plugins/synthesia/) for more information.
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ pip install livekit-plugins-synthesia
12
+ ```
13
+
14
+ ## Pre-requisites
15
+
16
+ You'll need an API key from Synthesia. It can be set as an environment variable:
17
+ `SYNTHESIA_API_KEY`
18
+
19
+ ## Usage
20
+
21
+ ```python
22
+ from livekit.plugins import synthesia
23
+
24
+ avatar = synthesia.AvatarSession(
25
+ synthesia.AvatarConfig(
26
+ avatar_ids=["03cee7ec-ac90-45ec-8c20-74a399cf3dc4"]
27
+ ), # SYNTHESIA_API_KEY from env
28
+ )
29
+ await avatar.start(session, room=ctx.room) # before session.start
30
+ await session.start(agent=Agent(...), room=ctx.room)
31
+ ```
32
+
33
+ Set your Synthesia workspace API key in `SYNTHESIA_API_KEY`, or pass `api_key=`.
34
+ `avatar_ids` takes one to five gallery ids of avatars available to your
35
+ workspace. The first is the active avatar; the rest are precomputed by the
36
+ worker so `swap_avatar()` can switch to them mid-session. An id your workspace
37
+ cannot access raises `synthesia.SynthesiaError` with `type=synthesia.ErrorType.UNKNOWN_AVATAR`.
38
+
39
+ ```python
40
+ await avatar.swap_avatar("<another-id-from-avatar-ids>") # switch mid-session
41
+ await avatar.swap_avatar("default") # back to the first id
42
+ ```
43
+
44
+ ## Parameters
45
+
46
+ | Parameter | Default | Description |
47
+ | --- | --- | --- |
48
+ | `avatar_participant_identity` | `"synthesia-avatar-agent"` | The LiveKit identity the avatar joins under. Must be unique per concurrent avatar in a room: LiveKit evicts an existing participant when a second joins with the same identity, so give each avatar its own identity to run several in one room. |
49
+ | `avatar_participant_name` | `"Synthesia avatar"` | The LiveKit display name the avatar joins under. |
50
+
51
+ ```python
52
+ avatar = synthesia.AvatarSession(
53
+ synthesia.AvatarConfig(avatar_ids=["03cee7ec-ac90-45ec-8c20-74a399cf3dc4"]),
54
+ avatar_participant_identity="avatar-host",
55
+ )
56
+ ```
@@ -0,0 +1,52 @@
1
+ # Copyright 2026 LiveKit, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Synthesia plugin for LiveKit Agents
16
+
17
+ See https://docs.livekit.io/agents/models/avatar/plugins/synthesia/ for more information.
18
+ """
19
+
20
+ from .avatar import AvatarSession
21
+ from .errors import ErrorType, SynthesiaError
22
+ from .types import AvatarConfig
23
+ from .version import __version__
24
+
25
+ __all__ = [
26
+ "AvatarSession",
27
+ "AvatarConfig",
28
+ "SynthesiaError",
29
+ "ErrorType",
30
+ "__version__",
31
+ ]
32
+
33
+ from livekit.agents import Plugin
34
+
35
+ from .log import logger
36
+
37
+
38
+ class SynthesiaPlugin(Plugin):
39
+ def __init__(self) -> None:
40
+ super().__init__(__name__, __version__, __package__, logger)
41
+
42
+
43
+ Plugin.register_plugin(SynthesiaPlugin())
44
+
45
+ # Cleanup docs of unexported modules
46
+ _module = dir()
47
+ NOT_IN_ALL = [m for m in _module if m not in __all__]
48
+
49
+ __pdoc__ = {}
50
+
51
+ for n in NOT_IN_ALL:
52
+ __pdoc__[n] = False
@@ -0,0 +1,275 @@
1
+ # Copyright 2026 LiveKit, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+
20
+ import aiohttp
21
+
22
+ from livekit.agents import DEFAULT_API_CONNECT_OPTIONS, APIConnectOptions, utils
23
+
24
+ from .errors import ErrorType, SynthesiaError
25
+ from .log import logger
26
+ from .types import StartSessionRequest, StartSessionResponse
27
+
28
+ SESSION_PATH = "/api/interactive-avatars/sessions"
29
+ _AVATAR_ID_PREFIX = "av_"
30
+
31
+ # Machine-readable error codes returned by the backend in the response body.
32
+ _CODE_TO_ERROR: dict[str, ErrorType] = {
33
+ "unauthorized": ErrorType.AUTH,
34
+ "invalid_api_key": ErrorType.AUTH,
35
+ "unknown_avatar": ErrorType.UNKNOWN_AVATAR,
36
+ "avatar_not_accessible": ErrorType.UNKNOWN_AVATAR,
37
+ "quota_exceeded": ErrorType.QUOTA_EXCEEDED,
38
+ "rate_limited": ErrorType.RATE_LIMITED,
39
+ "invalid_token": ErrorType.INVALID_ROOM_TOKEN,
40
+ "invalid_livekit_credentials": ErrorType.LIVEKIT_CREDENTIALS_REJECTED,
41
+ "validation_error": ErrorType.INVALID_SESSION_REQUEST,
42
+ "bad_request": ErrorType.INVALID_SESSION_REQUEST,
43
+ "unknown_reference": ErrorType.UNKNOWN_AVATAR,
44
+ "unauthenticated": ErrorType.AUTH,
45
+ "forbidden": ErrorType.AUTH,
46
+ "insufficient_scope": ErrorType.AUTH,
47
+ "not_authorized": ErrorType.AUTH,
48
+ "feature_not_in_plan": ErrorType.FEATURE_NOT_IN_PLAN,
49
+ "payment_required": ErrorType.QUOTA_EXCEEDED,
50
+ "concurrency_limit": ErrorType.CONCURRENCY_LIMIT,
51
+ # Production still emits this until the taxonomy rename reaches prod. Drop
52
+ # once the API only sends "concurrency_limit".
53
+ "concurrency_limit_exceeded": ErrorType.CONCURRENCY_LIMIT,
54
+ }
55
+
56
+ # Fallback when the body carries no code. A bare 401 is ambiguous
57
+ # between the two credentials in play, so it stays the broader auth error.
58
+ _STATUS_TO_ERROR: dict[int, ErrorType] = {
59
+ 401: ErrorType.AUTH,
60
+ 403: ErrorType.AUTH,
61
+ 402: ErrorType.QUOTA_EXCEEDED,
62
+ 404: ErrorType.UNKNOWN_AVATAR,
63
+ 429: ErrorType.RATE_LIMITED,
64
+ }
65
+
66
+
67
+ class SynthesiaAPI:
68
+ """Async client for the Synthesia interactive-avatar session API.
69
+
70
+ Opens one session per ``start_session`` call. Pass ``session`` to reuse an
71
+ ``aiohttp.ClientSession``; when omitted the LiveKit Agents shared session is
72
+ used.
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ *,
78
+ api_key: str,
79
+ api_url: str,
80
+ session: aiohttp.ClientSession | None = None,
81
+ conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
82
+ ) -> None:
83
+ self._api_key = api_key
84
+ self._api_url = api_url.rstrip("/")
85
+ self._session = session
86
+ self._conn_options = conn_options
87
+
88
+ def _http(self) -> aiohttp.ClientSession:
89
+ return self._session or utils.http_context.http_session()
90
+
91
+ async def start_session(
92
+ self,
93
+ request: StartSessionRequest,
94
+ *,
95
+ conn_options: APIConnectOptions | None = None,
96
+ ) -> StartSessionResponse:
97
+ conn_options = conn_options or self._conn_options
98
+ url = self._api_url + SESSION_PATH
99
+ headers = {"Authorization": self._api_key}
100
+ payload: dict[str, object] = {
101
+ "avatarIds": [_public_avatar_id(a) for a in request.avatar_ids],
102
+ "livekitUrl": request.livekit_url,
103
+ "livekitToken": request.lk_token,
104
+ }
105
+ timeout = aiohttp.ClientTimeout(total=conn_options.timeout)
106
+
107
+ last_status: int | None = None
108
+ last_body: object = None
109
+ last_exc: Exception | None = None
110
+
111
+ for attempt in range(conn_options.max_retry + 1):
112
+ status: int | None = None
113
+ try:
114
+ async with self._http().post(
115
+ url, headers=headers, json=payload, timeout=timeout
116
+ ) as resp:
117
+ status = resp.status
118
+ body = await _read_json(resp)
119
+ if resp.ok:
120
+ return self._parse_success(body)
121
+ if status < 500:
122
+ raise self._mapped_error(resp, body)
123
+ last_status, last_body, last_exc = status, body, None
124
+ logger.debug(
125
+ "synthesia session request failed, retrying",
126
+ extra={"status": status},
127
+ )
128
+ except (aiohttp.ClientError, asyncio.TimeoutError) as e:
129
+ # A 5xx whose body fails to arrive is still a 5xx, not a transport failure.
130
+ last_status = status if status is not None and status >= 500 else None
131
+ last_body, last_exc = None, e
132
+ logger.debug(
133
+ "synthesia session request errored, retrying",
134
+ extra={"error": type(e).__name__},
135
+ )
136
+
137
+ if attempt < conn_options.max_retry:
138
+ await asyncio.sleep(conn_options.retry_interval)
139
+
140
+ raise SynthesiaError(
141
+ _exhausted_message(conn_options.max_retry + 1, last_status, last_body),
142
+ type=ErrorType.CONNECTION,
143
+ body=last_body,
144
+ status=last_status,
145
+ request_id=_body_request_id(last_body),
146
+ ) from last_exc
147
+
148
+ def _parse_success(self, body: object) -> StartSessionResponse:
149
+ if isinstance(body, dict):
150
+ session_id = body.get("id") or body.get("session_id")
151
+ if isinstance(session_id, str) and session_id:
152
+ return StartSessionResponse(session_id=session_id)
153
+ raise SynthesiaError("Synthesia response did not contain a session id")
154
+
155
+ def _mapped_error(self, resp: aiohttp.ClientResponse, body: object) -> SynthesiaError:
156
+ code = _body_code(body)
157
+ error_type = _CODE_TO_ERROR.get(code or "")
158
+ if error_type is None:
159
+ # A problem body names its code; an unknown one is a new code, not a hint to
160
+ # guess the type from the status.
161
+ error_type = (
162
+ None
163
+ if _is_problem(body) and code is not None
164
+ else _STATUS_TO_ERROR.get(resp.status)
165
+ )
166
+ message = _body_message(body) or (
167
+ f"Synthesia request failed ({code or f'HTTP {resp.status}'})"
168
+ )
169
+ request_id = _body_request_id(body)
170
+ retry_after = (
171
+ _parse_retry_after(resp, body)
172
+ if error_type in (ErrorType.RATE_LIMITED, ErrorType.CONCURRENCY_LIMIT)
173
+ else None
174
+ )
175
+ return SynthesiaError(
176
+ message,
177
+ type=error_type,
178
+ retry_after=retry_after,
179
+ body=body,
180
+ status=resp.status,
181
+ request_id=request_id,
182
+ )
183
+
184
+
185
+ def _exhausted_message(attempts: int, status: int | None, body: object) -> str:
186
+ message = "could not start a Synthesia session"
187
+ if attempts > 1:
188
+ message = f"{message} after {attempts} attempts"
189
+ if status is None:
190
+ return f"{message}; last attempt: connection error"
191
+ message = f"{message}; last attempt: HTTP {status}"
192
+ detail = _body_message(body)
193
+ if detail is not None:
194
+ message = f"{message}: {detail}"
195
+ request_id = _body_request_id(body)
196
+ if request_id is not None:
197
+ message = f"{message} (request {request_id})"
198
+ return message
199
+
200
+
201
+ def _is_problem(body: object) -> bool:
202
+ return isinstance(body, dict) and isinstance(body.get("type"), str)
203
+
204
+
205
+ def _body_code(body: object) -> str | None:
206
+ if not isinstance(body, dict):
207
+ return None
208
+ err = body.get("error")
209
+ code = err.get("code") if isinstance(err, dict) else None
210
+ code = code or body.get("code")
211
+ return code if isinstance(code, str) else None
212
+
213
+
214
+ def _body_message(body: object) -> str | None:
215
+ if not isinstance(body, dict):
216
+ return None
217
+ if _is_problem(body):
218
+ candidates = (body.get("detail"), body.get("title"))
219
+ return next((c for c in candidates if isinstance(c, str) and c), None)
220
+ err = body.get("error")
221
+ message = err.get("message") if isinstance(err, dict) else None
222
+ # Some responses use a flat shape, e.g.
223
+ # {"error": "Forbidden", "context": "User is not authenticated"}.
224
+ detail = message or body.get("message") or body.get("context")
225
+ if detail is None:
226
+ return err if isinstance(err, str) else None
227
+ if isinstance(detail, str):
228
+ return detail
229
+ detail = json.dumps(detail, separators=(", ", ": "))
230
+ code = _body_code(body)
231
+ return f"{code}: {detail}" if code else detail
232
+
233
+
234
+ def _body_request_id(body: object) -> str | None:
235
+ if isinstance(body, dict):
236
+ request_id = body.get("requestId")
237
+ if isinstance(request_id, str):
238
+ return request_id
239
+ return None
240
+
241
+
242
+ def _public_avatar_id(avatar_id: str) -> str:
243
+ """Prefix a gallery id for the wire, leaving an already-prefixed id alone.
244
+
245
+ Callers pass the raw gallery id they see in Synthesia, and the API takes the
246
+ namespaced form.
247
+ """
248
+ return avatar_id if avatar_id.startswith(_AVATAR_ID_PREFIX) else _AVATAR_ID_PREFIX + avatar_id
249
+
250
+
251
+ def _parse_retry_after(resp: aiohttp.ClientResponse, body: object) -> float | None:
252
+ header = resp.headers.get("Retry-After")
253
+ if header is not None:
254
+ try:
255
+ return float(header)
256
+ except ValueError:
257
+ pass
258
+ if isinstance(body, dict):
259
+ candidates = [body.get("retry_after")]
260
+ err = body.get("error")
261
+ if isinstance(err, dict):
262
+ candidates.append(err.get("retry_after"))
263
+ for value in candidates:
264
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
265
+ return float(value)
266
+ return None
267
+
268
+
269
+ async def _read_json(resp: aiohttp.ClientResponse) -> object:
270
+ # Errors arrive as application/problem+json, which aiohttp refuses by default,
271
+ # so the content type is checked here rather than delegated.
272
+ try:
273
+ return await resp.json(content_type=resp.content_type)
274
+ except (aiohttp.ContentTypeError, ValueError):
275
+ return None
@@ -0,0 +1,405 @@
1
+ # Copyright 2026 LiveKit, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import dataclasses
19
+ import enum
20
+ import json
21
+ import os
22
+ from typing import TypeGuard
23
+
24
+ from livekit import api, rtc
25
+ from livekit.agents import DEFAULT_API_CONNECT_OPTIONS, AgentSession, get_job_context
26
+ from livekit.agents.types import ATTRIBUTE_PUBLISH_ON_BEHALF
27
+ from livekit.agents.voice.avatar import AvatarSession as BaseAvatarSession, DataStreamAudioOutput
28
+
29
+ from .api import SynthesiaAPI
30
+ from .errors import ErrorType, SynthesiaError
31
+ from .log import logger
32
+ from .types import (
33
+ AVATAR_IDENTITY,
34
+ AVATAR_NAME,
35
+ DEFAULT_API_URL,
36
+ DEFAULT_JOIN_TIMEOUT,
37
+ DEFAULT_SWAP_TIMEOUT,
38
+ TOKEN_TTL,
39
+ AvatarConfig,
40
+ SessionConfig,
41
+ StartSessionRequest,
42
+ )
43
+
44
+
45
+ def _present(value: str | None) -> TypeGuard[str]:
46
+ return bool(value and value.strip())
47
+
48
+
49
+ def _to_ws_url(url: str) -> str:
50
+ """Coerce a LiveKit URL to the ws(s):// scheme Synthesia requires; LiveKit
51
+ Cloud injects https:// on deployed agents."""
52
+ if url.startswith("https://"):
53
+ return "wss://" + url[len("https://") :]
54
+ if url.startswith("http://"):
55
+ return "ws://" + url[len("http://") :]
56
+ return url
57
+
58
+
59
+ class _State(enum.Enum):
60
+ IDLE = "idle"
61
+ STARTED = "started"
62
+ CLOSED = "closed"
63
+
64
+
65
+ class AvatarSession(BaseAvatarSession):
66
+ """A Synthesia interactive avatar for a LiveKit voice agent.
67
+
68
+ Construct with up to five gallery ``avatar_ids`` and call ``start()`` before
69
+ ``AgentSession.start()``. The first id is the active avatar and the rest are
70
+ precomputed for a future mid-session swap. Credentials fall back to the
71
+ ``SYNTHESIA_API_KEY`` and ``SYNTHESIA_API_URL`` environment variables.
72
+
73
+ Logs when the room ends cleanly and when the avatar track drops
74
+ unexpectedly mid-session.
75
+
76
+ Pass ``avatar_participant_identity`` to override the LiveKit identity the
77
+ avatar joins under. It must be unique per concurrent avatar in a room, since
78
+ LiveKit evicts an existing participant when a second joins with the same
79
+ identity. It defaults to ``synthesia-avatar-agent``.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ avatar_config: AvatarConfig,
85
+ *,
86
+ api_key: str | None = None,
87
+ api_url: str | None = None,
88
+ join_timeout: float = DEFAULT_JOIN_TIMEOUT,
89
+ avatar_participant_identity: str | None = None,
90
+ avatar_participant_name: str | None = None,
91
+ ) -> None:
92
+ super().__init__()
93
+
94
+ if avatar_participant_identity is not None and not avatar_participant_identity.strip():
95
+ raise SynthesiaError("avatar_participant_identity must be a non-empty string")
96
+ if avatar_participant_name is not None and not avatar_participant_name.strip():
97
+ raise SynthesiaError("avatar_participant_name must be a non-empty string")
98
+ self._avatar_identity = avatar_participant_identity or AVATAR_IDENTITY
99
+ self._avatar_name = avatar_participant_name or AVATAR_NAME
100
+
101
+ key = api_key or os.environ.get("SYNTHESIA_API_KEY")
102
+ if not key:
103
+ raise SynthesiaError(
104
+ "a Synthesia API key is required: pass api_key or set SYNTHESIA_API_KEY"
105
+ )
106
+
107
+ url = api_url or os.environ.get("SYNTHESIA_API_URL") or DEFAULT_API_URL
108
+
109
+ self._config = SessionConfig(
110
+ avatar_ids=tuple(avatar_config.avatar_ids),
111
+ api_key=key,
112
+ api_url=url,
113
+ join_timeout=join_timeout,
114
+ )
115
+ self._state = _State.IDLE
116
+ # Guards against re-entrant start() calls. Distinct from _state: while a
117
+ # start is awaiting a prior teardown the lifecycle is still its old
118
+ # state, so this cannot be folded into _state.
119
+ self._starting = False
120
+ self._close_done = asyncio.Event()
121
+ self._session_id: str | None = None
122
+ self._teardown_task: asyncio.Task[None] | None = None
123
+ # The exact sink this session installed, tracked by identity rather than
124
+ # type or chain position: AgentSession.start() can wrap it in
125
+ # TranscriptSynchronizer/RecorderAudioOutput, and a caller may already
126
+ # have their own DataStreamAudioOutput installed before start() runs.
127
+ self._audio_output: DataStreamAudioOutput | None = None
128
+
129
+ @property
130
+ def avatar_identity(self) -> str:
131
+ return self._avatar_identity
132
+
133
+ @property
134
+ def provider(self) -> str:
135
+ return "synthesia"
136
+
137
+ async def start(
138
+ self,
139
+ agent_session: AgentSession,
140
+ room: rtc.Room,
141
+ *,
142
+ livekit_url: str | None = None,
143
+ livekit_api_key: str | None = None,
144
+ livekit_api_secret: str | None = None,
145
+ ) -> None:
146
+ if self._starting:
147
+ raise SynthesiaError("start() is already in progress")
148
+ self._starting = True
149
+ try:
150
+ if self._teardown_task is not None:
151
+ teardown = self._teardown_task
152
+ await asyncio.shield(asyncio.gather(teardown, return_exceptions=True))
153
+ if not teardown.cancelled() and teardown.exception() is not None:
154
+ raise SynthesiaError(
155
+ "the previous avatar session did not tear down cleanly; not restarting"
156
+ ) from teardown.exception()
157
+ if self._state is _State.STARTED:
158
+ return
159
+
160
+ url = livekit_url or os.environ.get("LIVEKIT_URL")
161
+ lk_key = livekit_api_key or os.environ.get("LIVEKIT_API_KEY")
162
+ lk_secret = livekit_api_secret or os.environ.get("LIVEKIT_API_SECRET")
163
+ # A blank key or secret still mints a syntactically valid token that
164
+ # Synthesia accepts, since it cannot verify a signature made with the
165
+ # developer's own secret. LiveKit only rejects it once the avatar
166
+ # tries to join, so catch it here instead.
167
+ if not _present(url) or not _present(lk_key) or not _present(lk_secret):
168
+ raise SynthesiaError(
169
+ "LiveKit url, API key, and API secret are required: pass them or "
170
+ "set LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET"
171
+ )
172
+ url = _to_ws_url(url)
173
+ if not url.startswith(("ws://", "wss://")):
174
+ raise SynthesiaError(f"livekit_url {url!r} is not a ws:// or wss:// URL")
175
+
176
+ self._state = _State.IDLE
177
+ self._close_done.clear()
178
+ self._teardown_task = None
179
+
180
+ try:
181
+ await super().start(agent_session, room)
182
+ token = self._mint_token(room=room, lk_key=lk_key, lk_secret=lk_secret)
183
+ client = SynthesiaAPI(api_key=self._config.api_key, api_url=self._config.api_url)
184
+ launch_conn = dataclasses.replace(
185
+ DEFAULT_API_CONNECT_OPTIONS,
186
+ timeout=self._config.join_timeout,
187
+ max_retry=0,
188
+ )
189
+ response = await client.start_session(
190
+ StartSessionRequest(
191
+ avatar_ids=list(self._config.avatar_ids),
192
+ livekit_url=url,
193
+ lk_token=token,
194
+ ),
195
+ conn_options=launch_conn,
196
+ )
197
+ self._session_id = response.session_id
198
+
199
+ # TODO: confirm the avatar worker's expected audio sample rate and
200
+ # pass sample_rate explicitly once it is verified against the real
201
+ # worker.
202
+ audio_output = DataStreamAudioOutput(
203
+ room,
204
+ destination_identity=self.avatar_identity,
205
+ wait_remote_track=rtc.TrackKind.KIND_VIDEO,
206
+ )
207
+ # replace_audio_tail keeps any wrapper AgentSession.start() adds
208
+ # later (TranscriptSynchronizer, RecorderAudioOutput) attached.
209
+ agent_session.output.replace_audio_tail(audio_output)
210
+ self._audio_output = audio_output
211
+
212
+ await self.wait_for_join(timeout=self._config.join_timeout)
213
+ except asyncio.TimeoutError as e:
214
+ await self.aclose()
215
+ await self._discard_partial_start()
216
+ raise SynthesiaError(
217
+ f"avatar did not join within {self._config.join_timeout}s",
218
+ type=ErrorType.TIMEOUT,
219
+ ) from e
220
+ except BaseException:
221
+ await self.aclose()
222
+ await self._discard_partial_start()
223
+ raise
224
+
225
+ if self._state is _State.CLOSED:
226
+ await self._close_done.wait()
227
+ await self._discard_partial_start()
228
+ raise SynthesiaError("avatar session was closed while starting")
229
+
230
+ room.on("disconnected", self._on_room_disconnected)
231
+ room.on("track_unpublished", self._on_track_unpublished)
232
+ room.on("participant_disconnected", self._on_participant_disconnected)
233
+ self._state = _State.STARTED
234
+ finally:
235
+ self._starting = False
236
+
237
+ async def _discard_partial_start(self) -> None:
238
+ # An aclose() that ran concurrently with start() cannot see the audio
239
+ # output and session id that start() set after it completed, so a failed
240
+ # start cleans them up itself, closing the audio output's background
241
+ # tasks rather than just dropping the reference. Only the sink this
242
+ # session installed is touched: it may now be wrapped by
243
+ # AgentSession.start() (TranscriptSynchronizer, RecorderAudioOutput), and
244
+ # the caller may have had their own DataStreamAudioOutput before this
245
+ # session ever ran, so neither the current chain head nor its type says
246
+ # what to close.
247
+ audio_output, self._audio_output = self._audio_output, None
248
+ if audio_output is not None and hasattr(audio_output, "aclose"):
249
+ await audio_output.aclose()
250
+ self._session_id = None
251
+
252
+ async def swap_avatar(self, avatar_id: str, *, timeout: float = DEFAULT_SWAP_TIMEOUT) -> str:
253
+ """Switch the rendered avatar mid-session.
254
+
255
+ ``avatar_id`` must be one of the ids passed in ``AvatarConfig``, or
256
+ ``"default"`` for the first id in that list. Returns the now-active
257
+ avatar id once the swap has taken effect. Raises a ``SynthesiaError``
258
+ of type ``UNKNOWN_AVATAR`` for an id that was not precomputed,
259
+ ``CONNECTION`` if the worker cannot be reached, and no ``type`` if the
260
+ worker rejects the swap.
261
+ """
262
+ if self._state is not _State.STARTED or self._ending() or self._room is None:
263
+ raise SynthesiaError("swap_avatar() requires a started avatar session")
264
+ if avatar_id != "default" and avatar_id not in self._config.avatar_ids:
265
+ raise SynthesiaError(
266
+ f"avatar {avatar_id!r} was not in initial list of avatar_ids",
267
+ type=ErrorType.UNKNOWN_AVATAR,
268
+ )
269
+
270
+ try:
271
+ raw = await self._room.local_participant.perform_rpc(
272
+ destination_identity=self.avatar_identity,
273
+ method="swapAvatar",
274
+ payload=json.dumps({"avatar_id": avatar_id}),
275
+ response_timeout=timeout,
276
+ )
277
+ except Exception as e:
278
+ raise SynthesiaError(f"avatar swap RPC failed: {e}", type=ErrorType.CONNECTION) from e
279
+
280
+ try:
281
+ response = json.loads(raw)
282
+ except ValueError as e:
283
+ raise SynthesiaError("avatar swap returned a malformed response") from e
284
+ avatar_id_result = response.get("avatar_id") if isinstance(response, dict) else None
285
+ if (
286
+ not isinstance(response, dict)
287
+ or response.get("error")
288
+ or not isinstance(avatar_id_result, str)
289
+ ):
290
+ detail = response.get("error") if isinstance(response, dict) else None
291
+ raise SynthesiaError(f"avatar swap failed: {detail or raw}")
292
+ return avatar_id_result
293
+
294
+ def _mint_token(self, *, room: rtc.Room, lk_key: str, lk_secret: str) -> str:
295
+ # Synthesia rejects a token whose publish-on-behalf attribute is empty,
296
+ # since the worker has no agent to publish the avatar's audio for. Prefer
297
+ # the job context over room.local_participant: inside a job, the room may
298
+ # not have finished connecting yet when start() runs. Standalone callers
299
+ # (no job context) connect the room themselves before calling start(), so
300
+ # room.local_participant is safe there.
301
+ job_ctx = get_job_context(required=False)
302
+ agent_identity = (
303
+ job_ctx.local_participant_identity
304
+ if job_ctx is not None
305
+ else room.local_participant.identity
306
+ )
307
+ if not _present(agent_identity):
308
+ raise SynthesiaError(
309
+ "the room's local participant has no identity; connect the room "
310
+ "before starting the avatar session"
311
+ )
312
+
313
+ token = (
314
+ api.AccessToken(lk_key, lk_secret)
315
+ .with_identity(self.avatar_identity)
316
+ .with_name(self._avatar_name)
317
+ .with_kind("agent")
318
+ .with_grants(
319
+ api.VideoGrants(
320
+ room_join=True,
321
+ room=room.name,
322
+ can_publish=True,
323
+ can_subscribe=True,
324
+ can_publish_data=True,
325
+ )
326
+ )
327
+ .with_attributes({ATTRIBUTE_PUBLISH_ON_BEHALF: agent_identity})
328
+ .with_ttl(TOKEN_TTL)
329
+ )
330
+ return token.to_jwt()
331
+
332
+ def _on_room_disconnected(self, *args: object) -> None:
333
+ if self._ending():
334
+ return
335
+ logger.info("avatar session ended")
336
+ self._begin_teardown()
337
+
338
+ def _on_track_unpublished(
339
+ self,
340
+ publication: rtc.RemoteTrackPublication,
341
+ participant: rtc.RemoteParticipant,
342
+ ) -> None:
343
+ if (
344
+ participant.identity == self.avatar_identity
345
+ and publication.kind == rtc.TrackKind.KIND_VIDEO
346
+ ):
347
+ self._report_avatar_lost()
348
+
349
+ def _on_participant_disconnected(self, participant: rtc.RemoteParticipant) -> None:
350
+ if participant.identity == self.avatar_identity:
351
+ self._report_avatar_lost()
352
+
353
+ def _report_avatar_lost(self) -> None:
354
+ if self._ending():
355
+ return
356
+ # The avatar leaving while the room is still up is a worker crash. If the
357
+ # room itself is going down, this is a clean end and _on_room_disconnected
358
+ # reports it instead.
359
+ if self._room is not None and not self._room.isconnected():
360
+ return
361
+ logger.warning("avatar left the room unexpectedly")
362
+ self._begin_teardown()
363
+
364
+ def _ending(self) -> bool:
365
+ return self._state is _State.CLOSED or self._teardown_task is not None
366
+
367
+ def _begin_teardown(self) -> None:
368
+ if self._teardown_task is None and self._state is not _State.CLOSED:
369
+ self._teardown_task = asyncio.create_task(self.aclose())
370
+ self._teardown_task.add_done_callback(self._on_teardown_done)
371
+
372
+ def _on_teardown_done(self, task: asyncio.Task[None]) -> None:
373
+ if task.cancelled():
374
+ return
375
+ exc = task.exception()
376
+ if exc is not None:
377
+ logger.error("avatar teardown failed", exc_info=exc)
378
+
379
+ async def aclose(self) -> None:
380
+ if self._state is _State.CLOSED:
381
+ await self._close_done.wait()
382
+ return
383
+ self._state = _State.CLOSED
384
+ self._session_id = None
385
+
386
+ try:
387
+ # Only the sink this session installed is closed, by identity: it may
388
+ # now be wrapped by AgentSession.start() (TranscriptSynchronizer,
389
+ # RecorderAudioOutput), and the caller may have had their own
390
+ # DataStreamAudioOutput before this session ever ran, so neither the
391
+ # current chain head nor its type says what to close. The wrapper
392
+ # chain itself is left in place; there is no public API to remove a
393
+ # tail sink from it without supplying a replacement.
394
+ audio_output, self._audio_output = self._audio_output, None
395
+ if audio_output is not None and hasattr(audio_output, "aclose"):
396
+ await audio_output.aclose()
397
+
398
+ if self._room is not None:
399
+ self._room.off("disconnected", self._on_room_disconnected)
400
+ self._room.off("track_unpublished", self._on_track_unpublished)
401
+ self._room.off("participant_disconnected", self._on_participant_disconnected)
402
+
403
+ await super().aclose()
404
+ finally:
405
+ self._close_done.set()
@@ -0,0 +1,133 @@
1
+ # Copyright 2026 LiveKit, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import enum
18
+ import json
19
+
20
+ from livekit.agents import APIError
21
+
22
+ __all__ = [
23
+ "ErrorType",
24
+ "SynthesiaError",
25
+ ]
26
+
27
+
28
+ class ErrorType(enum.Enum):
29
+ """What went wrong, for callers that want to branch on it."""
30
+
31
+ AUTH = "auth"
32
+ """The API key is invalid, expired, or lacks the scope this endpoint requires."""
33
+
34
+ FEATURE_NOT_IN_PLAN = "feature_not_in_plan"
35
+ """The workspace's plan does not include interactive avatars."""
36
+
37
+ INVALID_ROOM_TOKEN = "invalid_room_token"
38
+ """The room token cannot produce a joined session.
39
+
40
+ Distinct from ``AUTH``, which concerns the Synthesia API key. Raised for a
41
+ token that is malformed or missing the attribute naming the agent the
42
+ avatar publishes audio for.
43
+ """
44
+
45
+ LIVEKIT_CREDENTIALS_REJECTED = "livekit_credentials_rejected"
46
+ """The LiveKit project the token was signed for would not accept it.
47
+
48
+ The token itself is well formed, so the API key and secret it was minted
49
+ with are the thing to check, not the Synthesia credentials.
50
+ """
51
+
52
+ INVALID_SESSION_REQUEST = "invalid_session_request"
53
+ """The backend rejected the session request payload."""
54
+
55
+ UNKNOWN_AVATAR = "unknown_avatar"
56
+ """Avatar is not in the gallery or not accessible to the workspace."""
57
+
58
+ QUOTA_EXCEEDED = "quota_exceeded"
59
+ """The workspace's session quota is exhausted."""
60
+
61
+ RATE_LIMITED = "rate_limited"
62
+ """The request was throttled. ``retry_after`` may carry the back-off."""
63
+
64
+ CONCURRENCY_LIMIT = "concurrency_limit"
65
+ """Every concurrent-session slot is in use.
66
+
67
+ The API sends no ``Retry-After`` for this today, so ``retry_after`` is
68
+ normally ``None``; it is honoured if one ever arrives.
69
+ """
70
+
71
+ TIMEOUT = "timeout"
72
+ """The avatar did not join within ``join_timeout``."""
73
+
74
+ CONNECTION = "connection"
75
+ """No usable response after retries.
76
+
77
+ The last attempt either failed to connect or was answered with a 5xx;
78
+ ``status`` and ``request_id`` describe that answer when there was one.
79
+ """
80
+
81
+
82
+ _RETRYABLE_TYPES = {
83
+ ErrorType.RATE_LIMITED,
84
+ ErrorType.CONCURRENCY_LIMIT,
85
+ ErrorType.TIMEOUT,
86
+ ErrorType.CONNECTION,
87
+ }
88
+
89
+
90
+ class SynthesiaError(APIError):
91
+ """Every error the Synthesia plugin raises. Also a :class:`~livekit.agents.APIError`.
92
+
93
+ - ``type`` identifies what went wrong; see :class:`ErrorType`.
94
+ - ``retryable`` reports whether retrying the same call could plausibly
95
+ succeed, and defaults based on ``type`` unless overridden.
96
+ - ``retry_after`` carries the server-provided back-off in seconds for a
97
+ ``RATE_LIMITED`` or ``CONCURRENCY_LIMIT`` error, when the backend
98
+ supplied one.
99
+ - ``status`` is the HTTP status the API answered, or ``None`` when no
100
+ answer arrived.
101
+ - ``request_id`` is the API's ``requestId`` when the body carried one.
102
+ Quote it when contacting support.
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ message: str,
108
+ *,
109
+ type: ErrorType | None = None,
110
+ body: object | None = None,
111
+ retryable: bool | None = None,
112
+ retry_after: float | None = None,
113
+ status: int | None = None,
114
+ request_id: str | None = None,
115
+ ) -> None:
116
+ if not isinstance(message, str):
117
+ message = _stringify(message)
118
+ self.type = type
119
+ self.retry_after = retry_after
120
+ self.status = status
121
+ self.request_id = request_id
122
+ if retryable is None:
123
+ retryable = type in _RETRYABLE_TYPES
124
+ super().__init__(message, body=body, retryable=retryable)
125
+
126
+
127
+ def _stringify(value: object) -> str:
128
+ if isinstance(value, (dict, list)):
129
+ try:
130
+ return json.dumps(value, separators=(", ", ": "))
131
+ except (TypeError, ValueError):
132
+ pass
133
+ return str(value)
@@ -0,0 +1,17 @@
1
+ # Copyright 2026 LiveKit, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import logging
16
+
17
+ logger = logging.getLogger("livekit.plugins.synthesia")
@@ -0,0 +1,72 @@
1
+ # Copyright 2026 LiveKit, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Sequence
18
+ from dataclasses import dataclass, field
19
+ from datetime import timedelta
20
+
21
+ DEFAULT_API_URL = "https://developers.synthesia.io"
22
+ DEFAULT_JOIN_TIMEOUT = 30.0
23
+ DEFAULT_SWAP_TIMEOUT = 15.0
24
+ AVATAR_IDENTITY = "synthesia-avatar-agent"
25
+ AVATAR_NAME = "Synthesia avatar"
26
+ MAX_AVATAR_IDS = 5
27
+
28
+ # Lifetime of the room-join token minted for the avatar worker. Long enough to
29
+ # cover the full session and any reconnects.
30
+ TOKEN_TTL = timedelta(hours=6)
31
+
32
+
33
+ @dataclass
34
+ class AvatarConfig:
35
+ """The avatars to render in the room.
36
+
37
+ ``avatar_ids`` holds one to five gallery ids of Synthesia avatars available
38
+ to your workspace. The first id is the active avatar; the rest are
39
+ available for swapping in during the session. Normalized to a tuple.
40
+ """
41
+
42
+ avatar_ids: Sequence[str]
43
+
44
+ def __post_init__(self) -> None:
45
+ if isinstance(self.avatar_ids, (str, bytes)):
46
+ raise ValueError("avatar_ids must be a list of ids, not a single string")
47
+ self.avatar_ids = tuple(self.avatar_ids)
48
+ if not 1 <= len(self.avatar_ids) <= MAX_AVATAR_IDS:
49
+ raise ValueError(
50
+ f"avatar_ids must contain between 1 and {MAX_AVATAR_IDS} ids, "
51
+ f"got {len(self.avatar_ids)}"
52
+ )
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class SessionConfig:
57
+ avatar_ids: tuple[str, ...]
58
+ api_key: str = field(repr=False)
59
+ api_url: str
60
+ join_timeout: float
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class StartSessionRequest:
65
+ avatar_ids: list[str]
66
+ livekit_url: str
67
+ lk_token: str = field(repr=False)
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class StartSessionResponse:
72
+ session_id: str
@@ -0,0 +1,15 @@
1
+ # Copyright 2026 LiveKit, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ __version__ = "1.8.2"
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "livekit-plugins-synthesia"
7
+ dynamic = ["version"]
8
+ description = "LiveKit Agents plugin for Synthesia interactive avatars"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.10.0"
12
+ authors = [{ name = "LiveKit", email = "support@livekit.io" }]
13
+ keywords = ["voice", "ai", "realtime", "audio", "video", "livekit", "webrtc", "avatar", "synthesia"]
14
+ classifiers = [
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Topic :: Multimedia :: Sound/Audio",
23
+ "Topic :: Multimedia :: Video",
24
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
25
+ ]
26
+ dependencies = ["livekit-agents>=1.8.2"]
27
+
28
+ [project.urls]
29
+ Documentation = "https://docs.livekit.io"
30
+ Website = "https://livekit.io/"
31
+ Source = "https://github.com/livekit/agents"
32
+
33
+ [tool.hatch.version]
34
+ path = "livekit/plugins/synthesia/version.py"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["livekit"]
38
+
39
+ [tool.hatch.build.targets.sdist]
40
+ include = ["/livekit"]
41
+
42
+ [tool.uv]
43
+ exclude-newer = "7 days"
44
+ exclude-newer-package = { livekit-agents = "0 days" }