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.
- livekit_plugins_thunderphone-1.8.9/.gitignore +180 -0
- livekit_plugins_thunderphone-1.8.9/PKG-INFO +65 -0
- livekit_plugins_thunderphone-1.8.9/README.md +42 -0
- livekit_plugins_thunderphone-1.8.9/livekit/plugins/thunderphone/__init__.py +36 -0
- livekit_plugins_thunderphone-1.8.9/livekit/plugins/thunderphone/log.py +3 -0
- livekit_plugins_thunderphone-1.8.9/livekit/plugins/thunderphone/py.typed +0 -0
- livekit_plugins_thunderphone-1.8.9/livekit/plugins/thunderphone/realtime/__init__.py +3 -0
- livekit_plugins_thunderphone-1.8.9/livekit/plugins/thunderphone/realtime/realtime_model.py +526 -0
- livekit_plugins_thunderphone-1.8.9/livekit/plugins/thunderphone/version.py +15 -0
- livekit_plugins_thunderphone-1.8.9/pyproject.toml +45 -0
|
@@ -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
|
|
File without changes
|
|
@@ -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" }
|