livekit-plugins-thunderphone 1.8.9__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,65 @@
1
+ Metadata-Version: 2.5
2
+ Name: livekit-plugins-thunderphone
3
+ Version: 1.8.9
4
+ Summary: Agent Framework plugin for ThunderPhone voice agents (speech-to-speech realtime model)
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 <hello@livekit.io>
9
+ License-Expression: Apache-2.0
10
+ Keywords: ai,audio,livekit,phone,realtime,thunderphone,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: Topic :: Multimedia :: Sound/Audio
17
+ Classifier: Topic :: Multimedia :: Video
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Requires-Python: >=3.10.0
20
+ Requires-Dist: livekit-agents[codecs]>=1.8.9
21
+ Requires-Dist: livekit-plugins-openai>=1.8.8
22
+ Description-Content-Type: text/markdown
23
+
24
+ # ThunderPhone plugin for LiveKit Agents
25
+
26
+ Run a [ThunderPhone](https://thunderphone.com) voice agent as the realtime
27
+ (speech-to-speech) model of a LiveKit Agents session. ThunderPhone handles
28
+ speech recognition, the language model, the voice, turn-taking, 47 languages
29
+ and tools; LiveKit handles the room, the transport and the telephony.
30
+
31
+ See the docs at https://docs.livekit.io/agents/models/realtime/thunderphone/ for
32
+ setup and examples.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install livekit-plugins-thunderphone
38
+ ```
39
+
40
+ ## Pre-requisites
41
+
42
+ A ThunderPhone secret API key (`sk_live_...`), set as `THUNDERPHONE_API_KEY`.
43
+
44
+ ## Usage
45
+
46
+ ```python
47
+ from livekit.agents import Agent, AgentSession
48
+ from livekit.plugins import thunderphone
49
+
50
+ # a saved ThunderPhone agent: prompt, voice, engine, languages and tools live on ThunderPhone
51
+ session = AgentSession(llm=thunderphone.RealtimeModel(agent_id=12))
52
+
53
+ # ...or configure the session inline: instructions and tools come from the LiveKit Agent
54
+ session = AgentSession(llm=thunderphone.RealtimeModel(model="bolt", voice="olivia"))
55
+ await session.start(agent=Agent(instructions="You are Acme Dental's receptionist."), room=ctx.room)
56
+ ```
57
+
58
+ An inline call fixes its instructions and tools when it starts. A handoff to an `Agent` with
59
+ different instructions or tools therefore ends that ThunderPhone call and starts a new one with
60
+ the new configuration. With a saved agent, handoffs keep the call.
61
+
62
+ Every reply uses the session's instructions and tools (the saved agent's, or the inline
63
+ `Agent`'s). ThunderPhone has no per-reply overrides, so `generate_reply(instructions=...)`
64
+ replies normally and the plugin logs a warning. Framework features that steer a single reply
65
+ with instructions, such as the beta workflows, do not steer it on ThunderPhone.
@@ -0,0 +1,42 @@
1
+ # ThunderPhone plugin for LiveKit Agents
2
+
3
+ Run a [ThunderPhone](https://thunderphone.com) voice agent as the realtime
4
+ (speech-to-speech) model of a LiveKit Agents session. ThunderPhone handles
5
+ speech recognition, the language model, the voice, turn-taking, 47 languages
6
+ and tools; LiveKit handles the room, the transport and the telephony.
7
+
8
+ See the docs at https://docs.livekit.io/agents/models/realtime/thunderphone/ for
9
+ setup and examples.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ pip install livekit-plugins-thunderphone
15
+ ```
16
+
17
+ ## Pre-requisites
18
+
19
+ A ThunderPhone secret API key (`sk_live_...`), set as `THUNDERPHONE_API_KEY`.
20
+
21
+ ## Usage
22
+
23
+ ```python
24
+ from livekit.agents import Agent, AgentSession
25
+ from livekit.plugins import thunderphone
26
+
27
+ # a saved ThunderPhone agent: prompt, voice, engine, languages and tools live on ThunderPhone
28
+ session = AgentSession(llm=thunderphone.RealtimeModel(agent_id=12))
29
+
30
+ # ...or configure the session inline: instructions and tools come from the LiveKit Agent
31
+ session = AgentSession(llm=thunderphone.RealtimeModel(model="bolt", voice="olivia"))
32
+ await session.start(agent=Agent(instructions="You are Acme Dental's receptionist."), room=ctx.room)
33
+ ```
34
+
35
+ An inline call fixes its instructions and tools when it starts. A handoff to an `Agent` with
36
+ different instructions or tools therefore ends that ThunderPhone call and starts a new one with
37
+ the new configuration. With a saved agent, handoffs keep the call.
38
+
39
+ Every reply uses the session's instructions and tools (the saved agent's, or the inline
40
+ `Agent`'s). ThunderPhone has no per-reply overrides, so `generate_reply(instructions=...)`
41
+ replies normally and the plugin logs a warning. Framework features that steer a single reply
42
+ with instructions, such as the beta workflows, do not steer it on ThunderPhone.
@@ -0,0 +1,36 @@
1
+ """ThunderPhone plugin for LiveKit Agents
2
+
3
+ ThunderPhone voice agents as a realtime (speech-to-speech) model.
4
+ """
5
+
6
+ from . import realtime
7
+ from .realtime.realtime_model import RealtimeModel, RealtimeSession
8
+ from .version import __version__
9
+
10
+ __all__ = [
11
+ "realtime",
12
+ "RealtimeModel",
13
+ "RealtimeSession",
14
+ "__version__",
15
+ ]
16
+
17
+ from livekit.agents import Plugin
18
+
19
+ from .log import logger
20
+
21
+
22
+ class ThunderPhonePlugin(Plugin):
23
+ def __init__(self) -> None:
24
+ super().__init__(__name__, __version__, __package__, logger)
25
+
26
+
27
+ Plugin.register_plugin(ThunderPhonePlugin())
28
+
29
+ # Cleanup docs of unexported modules
30
+ _module = dir()
31
+ NOT_IN_ALL = [m for m in _module if m not in __all__]
32
+
33
+ __pdoc__ = {}
34
+
35
+ for n in NOT_IN_ALL:
36
+ __pdoc__[n] = False
@@ -0,0 +1,3 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger("livekit.plugins.thunderphone")
@@ -0,0 +1,3 @@
1
+ from .realtime_model import DEFAULT_BASE_URL, RealtimeModel, RealtimeSession
2
+
3
+ __all__ = ["DEFAULT_BASE_URL", "RealtimeModel", "RealtimeSession"]
@@ -0,0 +1,526 @@
1
+ # Copyright 2023 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
+ """ThunderPhone voice agents as a LiveKit realtime model.
16
+
17
+ ThunderPhone's Realtime WebSocket API speaks the OpenAI Realtime protocol, so
18
+ this plugin is a thin layer over ``livekit.plugins.openai.realtime`` that
19
+ knows the ThunderPhone-specific parts:
20
+
21
+ * the endpoint, its query parameters (saved agent, engine, language, caller
22
+ and callee numbers, wire audio) and ``sk_live_`` keys;
23
+ * the fact that an inline session *starts* on its first ``session.update``
24
+ and freezes instructions and tools from then on, so the plugin sends one
25
+ fully-configured update instead of the framework's incremental three;
26
+ * saved-agent sessions, whose prompt, voice and tools live on ThunderPhone
27
+ and must not be overwritten from the agent;
28
+ * ThunderPhone's ``call.*`` events (hang-up, transfer, keypad) and the fact
29
+ that every call ends with the server closing the socket, which must not be
30
+ treated as a connection failure to retry.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import dataclasses
37
+ import os
38
+ import time
39
+ from typing import Any, cast
40
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
41
+
42
+ import aiohttp
43
+ from openai.types.realtime.conversation_item_input_audio_transcription_completed_event import (
44
+ UsageTranscriptTextUsageDuration,
45
+ )
46
+ from pydantic import BaseModel, ValidationError
47
+
48
+ from livekit.agents import APIConnectionError, llm, utils
49
+ from livekit.agents.llm._realtime.openai_utils import _shorten_call_id
50
+ from livekit.agents.metrics import STTMetrics
51
+ from livekit.agents.metrics.base import Metadata
52
+ from livekit.agents.types import NOT_GIVEN, APIConnectOptions, NotGivenOr
53
+ from livekit.agents.utils import is_given
54
+ from livekit.plugins.openai.realtime import realtime_model as _openai
55
+
56
+ from ..log import logger
57
+
58
+ DEFAULT_BASE_URL = "wss://api.thunderphone.com/v1/realtime"
59
+ """ThunderPhone's Realtime WebSocket endpoint."""
60
+
61
+ SERVER_MODEL = "thunderphone-realtime"
62
+ """The model name ThunderPhone echoes; sending anything else back reads as a change."""
63
+
64
+ SAMPLE_RATE = _openai.SAMPLE_RATE # 24 kHz PCM, same as OpenAI
65
+ API_KEY_ENV = "THUNDERPHONE_API_KEY"
66
+ API_KEY_PREFIX = "sk_live_"
67
+
68
+ # Session keys owned by a saved ThunderPhone agent. The server rejects changes
69
+ # to them once the call is live, so they are stripped from every update.
70
+ # Session events beyond the framework's own; typed Any because the base class
71
+ # fixes its event-name Literal.
72
+ CALL_EVENT: Any = "thunderphone_call_event"
73
+ CALL_ENDED_EVENT: Any = "thunderphone_call_ended"
74
+
75
+ _AGENT_OWNED_KEYS = ("instructions", "tools", "tool_choice")
76
+ # OpenAI-only knobs ThunderPhone does not use; harmless, but keep the wire clean.
77
+ _OPENAI_ONLY_KEYS = ("tracing", "truncation", "reasoning", "max_output_tokens")
78
+ # Per-reply overrides on response.create. ThunderPhone ignores them: a reply
79
+ # always runs on the session's (or saved agent's) instructions and tools.
80
+ _RESPONSE_OVERRIDE_KEYS = ("instructions", "tools", "tool_choice")
81
+
82
+
83
+ class RealtimeModel(_openai.RealtimeModel):
84
+ """Run a ThunderPhone voice agent as the realtime model of an AgentSession.
85
+
86
+ Two ways to use it:
87
+
88
+ * **Saved agent** — pass ``agent_id``. The agent's prompt, voice, engine,
89
+ languages, tools, greeting and silence handling come from ThunderPhone;
90
+ the LiveKit Agent's instructions and tools are not sent.
91
+ * **Inline** — omit ``agent_id``. Instructions and tools come from the
92
+ LiveKit Agent, ``model`` picks the engine (``spark``, ``bolt`` or
93
+ ``storm``) and ``voice`` the ThunderPhone voice. Function tools run in
94
+ the LiveKit agent.
95
+
96
+ An inline call freezes its instructions and tools once it starts, so a
97
+ handoff to an Agent configured differently starts a new call; with a saved
98
+ agent, handoffs keep the call. Every call ends with ThunderPhone closing the
99
+ socket; the session does not reconnect, because a reconnect would start a
100
+ new, separately billed call.
101
+ """
102
+
103
+ def __init__(
104
+ self,
105
+ *,
106
+ api_key: str | None = None,
107
+ agent_id: int | str | None = None,
108
+ model: str | None = None,
109
+ voice: NotGivenOr[str] = NOT_GIVEN,
110
+ language: str | None = None,
111
+ caller_number: str | None = None,
112
+ callee_number: str | None = None,
113
+ live_transcripts: bool = False,
114
+ base_url: str = DEFAULT_BASE_URL,
115
+ http_session: Any = None,
116
+ conn_options: APIConnectOptions | None = None,
117
+ **kwargs: Any,
118
+ ) -> None:
119
+ """Create a ThunderPhone realtime model.
120
+
121
+ Args:
122
+ api_key: ThunderPhone secret key (``sk_live_...``). Defaults to the
123
+ ``THUNDERPHONE_API_KEY`` environment variable.
124
+ agent_id: Id of a saved ThunderPhone agent. Mutually exclusive with
125
+ ``model`` and ``voice``.
126
+ model: Engine for inline sessions: ``"spark"``, ``"bolt"`` or ``"storm"``.
127
+ voice: ThunderPhone voice name for inline sessions.
128
+ language: Primary language hint for inline sessions, e.g. ``"es"``.
129
+ caller_number: Phone number the call came from, when the session fronts a
130
+ phone call. Shown as the call's "from" number in ThunderPhone call history.
131
+ callee_number: Phone number that was dialed. Shown as the call's "to" number.
132
+ live_transcripts: Stream caller transcript fragments while the caller is
133
+ still speaking (billed extra). Off by default: one transcript per turn.
134
+ base_url: WebSocket endpoint. Defaults to the ThunderPhone API.
135
+ http_session: Optional aiohttp session to reuse.
136
+ conn_options: Connection options. Retries default to 0: a ThunderPhone
137
+ session is a call, and reconnecting would start a new one.
138
+ **kwargs: Passed to ``livekit.plugins.openai.realtime.RealtimeModel``
139
+ (``turn_detection``, ``modalities``, ...). ThunderPhone transcribes the
140
+ caller with the call's own engine, so ``input_audio_transcription``
141
+ is not sent; ``None`` still turns off the caller transcripts.
142
+ """
143
+ key = (api_key or os.environ.get(API_KEY_ENV, "")).strip()
144
+ if not key.startswith(API_KEY_PREFIX):
145
+ raise ValueError(
146
+ f"ThunderPhone needs a secret API key starting with '{API_KEY_PREFIX}' "
147
+ f"(pass api_key= or set {API_KEY_ENV})."
148
+ )
149
+ if agent_id is not None and (model or is_given(voice)):
150
+ raise ValueError(
151
+ "agent_id sessions take their engine and voice from the saved agent; "
152
+ "drop model/voice or drop agent_id."
153
+ )
154
+
155
+ if agent_id is not None and not str(agent_id).strip():
156
+ # The URL would drop it and the server would start an inline call,
157
+ # while the plugin strips the agent's instructions and tools.
158
+ raise ValueError("agent_id must not be empty")
159
+
160
+ self._tp_agent_id = str(agent_id).strip() if agent_id is not None else None
161
+ self._tp_engine = model
162
+ self._tp_live_transcripts = bool(live_transcripts)
163
+
164
+ url = _build_url(
165
+ base_url,
166
+ agent_id=self._tp_agent_id,
167
+ product=model,
168
+ language=language,
169
+ from_number=caller_number,
170
+ to_number=callee_number,
171
+ )
172
+ # The framework recycles an OpenAI socket after max_session_duration
173
+ # (20 min by default) by closing it and reconnecting. On ThunderPhone
174
+ # that would hang up and start a second, separately billed call.
175
+ kwargs["max_session_duration"] = None
176
+ super().__init__(
177
+ model=SERVER_MODEL,
178
+ voice=voice if is_given(voice) else "default",
179
+ base_url=url,
180
+ api_key=key,
181
+ http_session=http_session,
182
+ conn_options=conn_options or APIConnectOptions(max_retry=0),
183
+ **kwargs,
184
+ )
185
+ self._provider_label = "ThunderPhone"
186
+ # An inline call freezes its instructions and tools once it starts, so a
187
+ # handoff to an agent configured differently gets a new session (and a
188
+ # new call). A saved agent owns its instructions and tools and the
189
+ # plugin never sends them, so updates are no-ops and every handoff keeps
190
+ # the call. Turn taking is ThunderPhone's and always on; the framework
191
+ # must not run its own VAD or turn detector against it.
192
+ agent_mode = self._tp_agent_id is not None
193
+ self._capabilities = dataclasses.replace(
194
+ self._capabilities,
195
+ mutable_instructions=agent_mode,
196
+ mutable_tools=agent_mode,
197
+ turn_detection=True,
198
+ can_disable_turn_detection=False,
199
+ )
200
+
201
+ @property
202
+ def model(self) -> str:
203
+ # The engine when one was chosen; a saved agent's engine lives on ThunderPhone.
204
+ return self._tp_engine or SERVER_MODEL
205
+
206
+ @property
207
+ def provider(self) -> str:
208
+ return "ThunderPhone"
209
+
210
+ def session(self, *, turn_detection_disabled: bool = False) -> RealtimeSession:
211
+ if turn_detection_disabled:
212
+ logger.warning(
213
+ "ThunderPhone turn detection is server-side and always on; "
214
+ "turn_detection_disabled is ignored"
215
+ )
216
+ sess = RealtimeSession(self)
217
+ self._sessions.add(sess)
218
+ return sess
219
+
220
+
221
+ class RealtimeSession(_openai.RealtimeSession):
222
+ """One ThunderPhone call.
223
+
224
+ Emits, in addition to the framework events:
225
+
226
+ * ``thunderphone_call_event`` with every ``call.*`` event (transfer, keypad,
227
+ speech ignored, ...);
228
+ * ``thunderphone_call_ended`` with the ``call.ended`` event, after which the
229
+ session closes itself.
230
+
231
+ ``call_id`` holds the ThunderPhone call id once the session is live, for
232
+ fetching the recording, transcript and grade afterwards.
233
+ """
234
+
235
+ def __init__(self, realtime_model: RealtimeModel) -> None:
236
+ self._tp_model = realtime_model
237
+ self._tp_agent_mode = realtime_model._tp_agent_id is not None
238
+ # Inline sessions: hold session.update until the agent has supplied its
239
+ # instructions and tools, then send one update that starts the call.
240
+ self._tp_holding = not self._tp_agent_mode
241
+ self._tp_pending_session: dict[str, Any] = {}
242
+ self._tp_call_ended = False
243
+ self._tp_warned_reply_instructions = False
244
+ # Function call ids longer than OpenAI's 32-character cap come back
245
+ # from the framework hashed; map the hash back to the id the server
246
+ # expects on the function_call_output.
247
+ self._tp_call_ids: dict[str, str] = {}
248
+ self.call_id: int | None = None
249
+ super().__init__(realtime_model)
250
+ self.on("openai_server_event_received", self._tp_on_server_event)
251
+
252
+ # ----------------------------------------------------------- outgoing
253
+
254
+ def send_event(self, event: Any) -> None:
255
+ payload = (
256
+ event.model_dump(by_alias=True, exclude_unset=True, exclude_defaults=False)
257
+ if isinstance(event, BaseModel)
258
+ else dict(event)
259
+ )
260
+ if payload.get("type") == "session.update":
261
+ payload = self._tp_shape_session_update(payload)
262
+ if self._tp_holding:
263
+ _merge(self._tp_pending_session, payload.get("session") or {})
264
+ return
265
+ elif self._tp_holding and payload.get("type") != "input_audio_buffer.append":
266
+ # Anything but audio is meaningful only once the call exists;
267
+ # flush the configuration first so ordering is preserved.
268
+ self._tp_flush()
269
+ if payload.get("type") == "response.create":
270
+ response = payload.get("response")
271
+ if isinstance(response, dict):
272
+ for key in _RESPONSE_OVERRIDE_KEYS:
273
+ response.pop(key, None)
274
+ if payload.get("type") == "conversation.item.create":
275
+ item = payload.get("item")
276
+ if isinstance(item, dict) and item.get("type") == "function_call_output":
277
+ call_id = item.get("call_id")
278
+ if isinstance(call_id, str):
279
+ item["call_id"] = self._tp_call_ids.get(call_id, call_id)
280
+ super().send_event(payload)
281
+
282
+ def _tp_shape_session_update(self, payload: dict[str, Any]) -> dict[str, Any]:
283
+ session = dict(payload.get("session") or {})
284
+ session["model"] = SERVER_MODEL
285
+ for key in _OPENAI_ONLY_KEYS:
286
+ session.pop(key, None)
287
+ audio = session.get("audio")
288
+ leg = audio.get("input") if isinstance(audio, dict) else None
289
+ if isinstance(leg, dict):
290
+ # ThunderPhone transcribes the caller with the call's own engine.
291
+ leg.pop("transcription", None)
292
+ if self._tp_agent_mode:
293
+ # A saved agent is already running with its own prompt, tools,
294
+ # voice, transcription and turn-taking; the server rejects any
295
+ # change to those. Keep only what a client may still set.
296
+ for key in _AGENT_OWNED_KEYS:
297
+ session.pop(key, None)
298
+ session.pop("output_modalities", None)
299
+ audio = session.get("audio")
300
+ if isinstance(audio, dict):
301
+ for direction in ("input", "output"):
302
+ leg = audio.get(direction)
303
+ if isinstance(leg, dict):
304
+ audio[direction] = {k: v for k, v in leg.items() if k == "format"}
305
+ else:
306
+ config = dict(session.get("config") or {})
307
+ # Opt into ThunderPhone's call.* events (hang-up reason, transfer, keypad).
308
+ config.setdefault("call_events", True)
309
+ session["config"] = config
310
+ if self._tp_model._tp_live_transcripts:
311
+ session["live_transcripts"] = True
312
+ payload["session"] = session
313
+ return payload
314
+
315
+ def _tp_flush(self) -> None:
316
+ if not self._tp_holding:
317
+ return
318
+ self._tp_holding = False
319
+ if self._tp_pending_session:
320
+ super().send_event(
321
+ {
322
+ "event_id": utils.shortuuid("session_update_"),
323
+ "type": "session.update",
324
+ "session": self._tp_pending_session,
325
+ }
326
+ )
327
+ self._tp_pending_session = {}
328
+
329
+ def generate_reply(
330
+ self,
331
+ *,
332
+ instructions: NotGivenOr[str] = NOT_GIVEN,
333
+ tool_choice: NotGivenOr[llm.ToolChoice] = NOT_GIVEN,
334
+ tools: NotGivenOr[list[llm.Tool]] = NOT_GIVEN,
335
+ ) -> asyncio.Future[llm.GenerationCreatedEvent]:
336
+ if is_given(instructions) and not self._tp_warned_reply_instructions:
337
+ self._tp_warned_reply_instructions = True
338
+ logger.warning(
339
+ "ThunderPhone ignores per-reply instructions; "
340
+ "replies use the session's instructions"
341
+ )
342
+ return super().generate_reply(
343
+ instructions=instructions, tool_choice=tool_choice, tools=tools
344
+ )
345
+
346
+ async def _update_session(
347
+ self,
348
+ *,
349
+ instructions: NotGivenOr[str] = NOT_GIVEN,
350
+ chat_ctx: NotGivenOr[llm.ChatContext] = NOT_GIVEN,
351
+ tools: NotGivenOr[list[llm.Tool]] = NOT_GIVEN,
352
+ ) -> None:
353
+ # The framework sends instructions, then chat history, then tools. On
354
+ # ThunderPhone the first session.update starts the call and freezes
355
+ # instructions and tools, so send both before any conversation item.
356
+ if is_given(instructions):
357
+ try:
358
+ await self.update_instructions(instructions)
359
+ except llm.RealtimeError:
360
+ logger.exception("failed to update the instructions")
361
+ if is_given(tools):
362
+ try:
363
+ await self.update_tools(tools)
364
+ except llm.RealtimeError:
365
+ logger.exception("failed to update the tools")
366
+ self._tp_flush()
367
+ if is_given(chat_ctx):
368
+ try:
369
+ await self.update_chat_ctx(chat_ctx)
370
+ except llm.RealtimeError:
371
+ logger.exception("failed to update the chat_ctx")
372
+
373
+ # ----------------------------------------------------------- incoming
374
+
375
+ def _emit_transcription_metrics(self, event: Any) -> None:
376
+ # The base names the model after its OpenAI transcription config, which
377
+ # ThunderPhone never uses: the call's engine transcribes the caller and
378
+ # reports usage as seconds of caller audio (never tokens).
379
+ try:
380
+ usage = UsageTranscriptTextUsageDuration.model_validate(event.usage)
381
+ except ValidationError:
382
+ return
383
+ model = self._tp_model
384
+ self.emit(
385
+ "metrics_collected",
386
+ STTMetrics(
387
+ request_id=event.event_id,
388
+ timestamp=time.time(),
389
+ duration=0.0,
390
+ label=model.label,
391
+ audio_duration=usage.seconds,
392
+ streamed=True,
393
+ metadata=Metadata(model_name=model.model, model_provider=model.provider),
394
+ ),
395
+ )
396
+
397
+ def _tp_close_expected(self) -> bool:
398
+ # After call.ended the server hangs up; after aclose() we do.
399
+ return self._tp_call_ended or self._closing
400
+
401
+ async def _create_ws_conn(self) -> aiohttp.ClientWebSocketResponse:
402
+ ws = await super()._create_ws_conn()
403
+ return cast(aiohttp.ClientWebSocketResponse, _CallSocket(ws, self))
404
+
405
+ async def _run_ws(self, ws_conn: aiohttp.ClientWebSocketResponse) -> None:
406
+ try:
407
+ await super()._run_ws(ws_conn)
408
+ except APIConnectionError:
409
+ # The socket closed after the call ended or while closing, and our
410
+ # send loop did not get there first (see _CallSocket). With the
411
+ # outgoing channel closed the framework ends the session here.
412
+ if not self._tp_close_expected():
413
+ raise
414
+
415
+ def _tp_on_server_event(self, event: dict[str, Any]) -> None:
416
+ event_type = event.get("type")
417
+ if event_type == "response.created":
418
+ self._tp_claim_response(event)
419
+ return
420
+ if event_type == "response.output_item.done":
421
+ item = event.get("item")
422
+ if isinstance(item, dict) and item.get("type") == "function_call":
423
+ call_id = item.get("call_id")
424
+ if isinstance(call_id, str):
425
+ self._tp_call_ids[_shorten_call_id(call_id)] = call_id
426
+ return
427
+ if event_type in ("session.created", "session.updated"):
428
+ session = event.get("session") or {}
429
+ call_id = session.get("call_id") if isinstance(session, dict) else None
430
+ if isinstance(call_id, int):
431
+ self.call_id = call_id
432
+ elif isinstance(call_id, str) and call_id.isdigit():
433
+ self.call_id = int(call_id)
434
+ return
435
+ if not isinstance(event_type, str) or not event_type.startswith("call."):
436
+ return
437
+ self.emit(CALL_EVENT, event)
438
+ if event_type == "call.ended" and not self._tp_call_ended:
439
+ self._tp_call_ended = True
440
+ logger.info("ThunderPhone call ended", extra={"lk.pii.event": event})
441
+ self.emit(CALL_ENDED_EVENT, event)
442
+ # The server closes the socket right after this event. Closing the
443
+ # outgoing channel ends the send loop, which closes our side and
444
+ # ends the session; _CallSocket holds the server's close back until
445
+ # then so it is not read as a connection failure. Anything still
446
+ # queued was meant for a call that no longer exists.
447
+ self._msg_ch.close()
448
+ while not self._msg_ch.empty():
449
+ self._msg_ch.recv_nowait()
450
+
451
+ def _tp_claim_response(self, event: dict[str, Any]) -> None:
452
+ # generate_reply() resolves its future by the client_event_id the
453
+ # server echoes in response.metadata. ThunderPhone servers that
454
+ # predate that echo start the response but never label it, so the
455
+ # future times out while the audio plays anyway. Credit an unlabeled
456
+ # response to the oldest pending reply request instead; the hook runs
457
+ # on the raw dict before the framework parses it.
458
+ response = event.get("response")
459
+ if not isinstance(response, dict):
460
+ return
461
+ metadata = response.get("metadata")
462
+ if isinstance(metadata, dict) and metadata.get("client_event_id"):
463
+ return
464
+ pending = next(iter(self._response_created_futures), None)
465
+ if pending is None:
466
+ return
467
+ response["metadata"] = {
468
+ **(metadata if isinstance(metadata, dict) else {}),
469
+ "client_event_id": pending,
470
+ }
471
+
472
+
473
+ _CLOSE_TYPES = (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED)
474
+ _CLOSE_GRACE_S = 5.0
475
+
476
+
477
+ class _CallSocket:
478
+ """The call's WebSocket, holding back an expected close for the send loop.
479
+
480
+ The framework's receive loop treats a close as expected only once its send
481
+ loop has seen the outgoing channel close, and the server's close after
482
+ ``call.ended`` can be read first. The receive loop waits here instead, so
483
+ the send loop finishes and the framework tears the connection down without
484
+ logging an error. If the send loop is stuck, the close goes through after
485
+ a grace period and ``RealtimeSession._run_ws`` absorbs it.
486
+ """
487
+
488
+ def __init__(self, ws: aiohttp.ClientWebSocketResponse, session: RealtimeSession) -> None:
489
+ self._ws = ws
490
+ self._session = session
491
+
492
+ async def receive(self, timeout: float | None = None) -> aiohttp.WSMessage:
493
+ msg = await self._ws.receive(timeout)
494
+ if msg.type in _CLOSE_TYPES and self._session._tp_close_expected():
495
+ # cancelled on teardown once the send loop is done
496
+ await asyncio.sleep(_CLOSE_GRACE_S)
497
+ return msg
498
+
499
+ def __getattr__(self, name: str) -> Any:
500
+ return getattr(self._ws, name)
501
+
502
+
503
+ def _build_url(base_url: str, **params: str | None) -> str:
504
+ """Compose the endpoint URL; the framework only appends ``?model=``."""
505
+ parts = urlsplit(base_url)
506
+ query = dict(parse_qsl(parts.query, keep_blank_values=False))
507
+ query.update({k: v for k, v in params.items() if v})
508
+ query.update(
509
+ {
510
+ "input_audio_format": "pcm16",
511
+ "input_rate": str(SAMPLE_RATE),
512
+ "output_audio_format": "pcm16",
513
+ "output_rate": str(SAMPLE_RATE),
514
+ # Understood by newer servers for saved-agent sessions; ignored otherwise.
515
+ "call_events": "1",
516
+ }
517
+ )
518
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), ""))
519
+
520
+
521
+ def _merge(into: dict[str, Any], update: dict[str, Any]) -> None:
522
+ for key, value in update.items():
523
+ if isinstance(value, dict) and isinstance(into.get(key), dict):
524
+ _merge(into[key], value)
525
+ else:
526
+ into[key] = value
@@ -0,0 +1,15 @@
1
+ # Copyright 2023 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.9"
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "livekit-plugins-thunderphone"
7
+ dynamic = ["version"]
8
+ description = "Agent Framework plugin for ThunderPhone voice agents (speech-to-speech realtime model)"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.10.0"
12
+ authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
13
+ keywords = ["voice", "ai", "realtime", "audio", "video", "livekit", "webrtc", "thunderphone", "phone"]
14
+ classifiers = [
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Topic :: Multimedia :: Sound/Audio",
18
+ "Topic :: Multimedia :: Video",
19
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ ]
24
+ dependencies = ["livekit-agents[codecs]>=1.8.9", "livekit-plugins-openai>=1.8.8"]
25
+
26
+ [tool.mypy]
27
+ plugins = ["pydantic.mypy"]
28
+
29
+ [project.urls]
30
+ Documentation = "https://docs.livekit.io"
31
+ Website = "https://livekit.io/"
32
+ Source = "https://github.com/livekit/agents"
33
+
34
+ [tool.hatch.version]
35
+ path = "livekit/plugins/thunderphone/version.py"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["livekit"]
39
+
40
+ [tool.hatch.build.targets.sdist]
41
+ include = ["/livekit"]
42
+
43
+ [tool.uv]
44
+ exclude-newer = "7 days"
45
+ exclude-newer-package = { livekit-agents = "0 days" }