livekit-api 1.1.0__tar.gz → 1.2.0__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_api-1.2.0/.gitignore +167 -0
- {livekit_api-1.1.0 → livekit_api-1.2.0}/PKG-INFO +15 -24
- {livekit_api-1.1.0 → livekit_api-1.2.0}/livekit/api/__init__.py +10 -1
- livekit_api-1.2.0/livekit/api/_dial_timeout.py +51 -0
- livekit_api-1.2.0/livekit/api/_failover.py +162 -0
- livekit_api-1.2.0/livekit/api/_service.py +41 -0
- {livekit_api-1.1.0 → livekit_api-1.2.0}/livekit/api/access_token.py +8 -4
- {livekit_api-1.1.0 → livekit_api-1.2.0}/livekit/api/agent_dispatch_service.py +12 -3
- {livekit_api-1.1.0 → livekit_api-1.2.0}/livekit/api/connector_service.py +30 -3
- {livekit_api-1.1.0 → livekit_api-1.2.0}/livekit/api/egress_service.py +9 -2
- {livekit_api-1.1.0 → livekit_api-1.2.0}/livekit/api/ingress_service.py +9 -2
- livekit_api-1.2.0/livekit/api/livekit_api.py +170 -0
- {livekit_api-1.1.0 → livekit_api-1.2.0}/livekit/api/room_service.py +9 -2
- {livekit_api-1.1.0 → livekit_api-1.2.0}/livekit/api/sip_service.py +74 -33
- livekit_api-1.2.0/livekit/api/twirp_client.py +284 -0
- livekit_api-1.2.0/livekit/api/version.py +1 -0
- livekit_api-1.2.0/pyproject.toml +53 -0
- livekit_api-1.1.0/livekit/api/_service.py +0 -30
- livekit_api-1.1.0/livekit/api/livekit_api.py +0 -117
- livekit_api-1.1.0/livekit/api/twirp_client.py +0 -134
- livekit_api-1.1.0/livekit/api/version.py +0 -1
- livekit_api-1.1.0/livekit_api.egg-info/PKG-INFO +0 -45
- livekit_api-1.1.0/livekit_api.egg-info/SOURCES.txt +0 -24
- livekit_api-1.1.0/livekit_api.egg-info/dependency_links.txt +0 -1
- livekit_api-1.1.0/livekit_api.egg-info/requires.txt +0 -5
- livekit_api-1.1.0/livekit_api.egg-info/top_level.txt +0 -1
- livekit_api-1.1.0/pyproject.toml +0 -5
- livekit_api-1.1.0/setup.cfg +0 -4
- livekit_api-1.1.0/setup.py +0 -66
- livekit_api-1.1.0/tests/test_access_token.py +0 -101
- livekit_api-1.1.0/tests/test_webhook.py +0 -81
- {livekit_api-1.1.0 → livekit_api-1.2.0}/README.md +0 -0
- {livekit_api-1.1.0 → livekit_api-1.2.0}/livekit/api/py.typed +0 -0
- {livekit_api-1.1.0 → livekit_api-1.2.0}/livekit/api/webhook.py +0 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
# *.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
# lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py,cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# poetry
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
102
|
+
#poetry.lock
|
|
103
|
+
|
|
104
|
+
# pdm
|
|
105
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
106
|
+
#pdm.lock
|
|
107
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
108
|
+
# in version control.
|
|
109
|
+
# https://pdm.fming.dev/#use-with-ide
|
|
110
|
+
.pdm.toml
|
|
111
|
+
|
|
112
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
113
|
+
__pypackages__/
|
|
114
|
+
|
|
115
|
+
# Celery stuff
|
|
116
|
+
celerybeat-schedule
|
|
117
|
+
celerybeat.pid
|
|
118
|
+
|
|
119
|
+
# SageMath parsed files
|
|
120
|
+
*.sage.py
|
|
121
|
+
|
|
122
|
+
# Environments
|
|
123
|
+
.env
|
|
124
|
+
.venv
|
|
125
|
+
env/
|
|
126
|
+
venv/
|
|
127
|
+
ENV/
|
|
128
|
+
env.bak/
|
|
129
|
+
venv.bak/
|
|
130
|
+
|
|
131
|
+
# Spyder project settings
|
|
132
|
+
.spyderproject
|
|
133
|
+
.spyproject
|
|
134
|
+
|
|
135
|
+
# Rope project settings
|
|
136
|
+
.ropeproject
|
|
137
|
+
|
|
138
|
+
# mkdocs documentation
|
|
139
|
+
/site
|
|
140
|
+
|
|
141
|
+
# mypy
|
|
142
|
+
.mypy_cache/
|
|
143
|
+
.dmypy.json
|
|
144
|
+
dmypy.json
|
|
145
|
+
|
|
146
|
+
# Pyre type checker
|
|
147
|
+
.pyre/
|
|
148
|
+
|
|
149
|
+
# pytype static type analyzer
|
|
150
|
+
.pytype/
|
|
151
|
+
|
|
152
|
+
# Cython debug symbols
|
|
153
|
+
cython_debug/
|
|
154
|
+
|
|
155
|
+
# PyCharm
|
|
156
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
157
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
158
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
159
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
160
|
+
#.idea/
|
|
161
|
+
|
|
162
|
+
# vscode project settings
|
|
163
|
+
.vscode
|
|
164
|
+
|
|
165
|
+
.DS_Store
|
|
166
|
+
|
|
167
|
+
docs
|
|
@@ -1,41 +1,32 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: livekit-api
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Python Server API for LiveKit
|
|
5
|
-
Home-page: https://github.com/livekit/python-sdks
|
|
6
|
-
License: Apache-2.0
|
|
7
5
|
Project-URL: Documentation, https://docs.livekit.io
|
|
8
6
|
Project-URL: Website, https://livekit.io/
|
|
9
7
|
Project-URL: Source, https://github.com/livekit/python-sdks/
|
|
10
|
-
|
|
8
|
+
Author-email: LiveKit <support@livekit.io>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: audio,livekit,realtime,video,webrtc
|
|
11
11
|
Classifier: Intended Audience :: Developers
|
|
12
|
-
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
-
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
14
|
-
Classifier: Topic :: Multimedia :: Video
|
|
15
|
-
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
12
|
Classifier: Programming Language :: Python :: 3
|
|
17
|
-
Classifier: Programming Language :: Python :: 3
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
14
|
Classifier: Programming Language :: Python :: 3.9
|
|
20
15
|
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
-
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
21
|
+
Classifier: Topic :: Multimedia :: Video
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
23
|
Requires-Python: >=3.9.0
|
|
23
|
-
Description-Content-Type: text/markdown
|
|
24
|
-
Requires-Dist: pyjwt>=2.0.0
|
|
25
24
|
Requires-Dist: aiohttp>=3.9.0
|
|
25
|
+
Requires-Dist: livekit-protocol<2.0.0,>=1.1.19
|
|
26
26
|
Requires-Dist: protobuf>=4
|
|
27
|
+
Requires-Dist: pyjwt>=2.0.0
|
|
27
28
|
Requires-Dist: types-protobuf>=4
|
|
28
|
-
|
|
29
|
-
Dynamic: classifier
|
|
30
|
-
Dynamic: description
|
|
31
|
-
Dynamic: description-content-type
|
|
32
|
-
Dynamic: home-page
|
|
33
|
-
Dynamic: keywords
|
|
34
|
-
Dynamic: license
|
|
35
|
-
Dynamic: project-url
|
|
36
|
-
Dynamic: requires-dist
|
|
37
|
-
Dynamic: requires-python
|
|
38
|
-
Dynamic: summary
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
39
30
|
|
|
40
31
|
# LiveKit Server APIs
|
|
41
32
|
|
|
@@ -36,7 +36,13 @@ from livekit.protocol.sip import *
|
|
|
36
36
|
from livekit.protocol.connector_whatsapp import *
|
|
37
37
|
from livekit.protocol.connector_twilio import *
|
|
38
38
|
|
|
39
|
-
from .twirp_client import
|
|
39
|
+
from .twirp_client import (
|
|
40
|
+
ServerError,
|
|
41
|
+
ServerErrorCode,
|
|
42
|
+
SipCallError,
|
|
43
|
+
TwirpError,
|
|
44
|
+
TwirpErrorCode,
|
|
45
|
+
)
|
|
40
46
|
from .livekit_api import LiveKitAPI
|
|
41
47
|
from .access_token import (
|
|
42
48
|
InferenceGrants,
|
|
@@ -64,6 +70,9 @@ __all__ = [
|
|
|
64
70
|
"AccessToken",
|
|
65
71
|
"TokenVerifier",
|
|
66
72
|
"WebhookReceiver",
|
|
73
|
+
"ServerError",
|
|
74
|
+
"ServerErrorCode",
|
|
67
75
|
"TwirpError",
|
|
68
76
|
"TwirpErrorCode",
|
|
77
|
+
"SipCallError",
|
|
69
78
|
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Union
|
|
4
|
+
|
|
5
|
+
from livekit.protocol.sip import CreateSIPParticipantRequest, TransferSIPParticipantRequest
|
|
6
|
+
|
|
7
|
+
# Requests that carry ringing_timeout and share the phone-dialing timeout behavior.
|
|
8
|
+
DialRequest = Union[
|
|
9
|
+
CreateSIPParticipantRequest,
|
|
10
|
+
TransferSIPParticipantRequest,
|
|
11
|
+
]
|
|
12
|
+
"""@private"""
|
|
13
|
+
|
|
14
|
+
# Ring window (seconds) assumed when a request doesn't set ringing_timeout;
|
|
15
|
+
# matches the server default. A dialing request must outlast it.
|
|
16
|
+
DEFAULT_RINGING_TIMEOUT = 30.0
|
|
17
|
+
"""@private"""
|
|
18
|
+
|
|
19
|
+
# A dialing request must outlast the ringing window, or it would abort before
|
|
20
|
+
# the call can be answered. Keep the request timeout at least this many seconds
|
|
21
|
+
# above the ringing timeout.
|
|
22
|
+
RINGING_TIMEOUT_MARGIN = 2.0
|
|
23
|
+
"""@private"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def pin_ringing_timeout(request: DialRequest) -> None:
|
|
27
|
+
"""Set the ring window explicitly on a dialing request when the caller left it
|
|
28
|
+
unset, so the derived request timeout doesn't depend on the server's default
|
|
29
|
+
(which could change out from under us).
|
|
30
|
+
|
|
31
|
+
@private
|
|
32
|
+
"""
|
|
33
|
+
if not request.HasField("ringing_timeout"):
|
|
34
|
+
request.ringing_timeout.seconds = int(DEFAULT_RINGING_TIMEOUT)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def dial_timeout(user_timeout: Optional[float], request: DialRequest) -> float:
|
|
38
|
+
"""Request timeout (seconds) for a phone-dialing call: the ring window plus a
|
|
39
|
+
margin, so the request doesn't abort before the call can be answered. The
|
|
40
|
+
ring window is the request's ringing_timeout when set, else
|
|
41
|
+
DEFAULT_RINGING_TIMEOUT. A longer user_timeout is honored; a shorter one is
|
|
42
|
+
raised to the floor.
|
|
43
|
+
|
|
44
|
+
@private
|
|
45
|
+
"""
|
|
46
|
+
if request.HasField("ringing_timeout"):
|
|
47
|
+
ring: float = request.ringing_timeout.seconds
|
|
48
|
+
else:
|
|
49
|
+
ring = DEFAULT_RINGING_TIMEOUT
|
|
50
|
+
floor = ring + RINGING_TIMEOUT_MARGIN
|
|
51
|
+
return max(user_timeout if user_timeout else floor, floor)
|
|
@@ -0,0 +1,162 @@
|
|
|
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
|
+
"""Region failover for the Twirp API clients.
|
|
16
|
+
|
|
17
|
+
On a retryable failure (any transport error or HTTP 5xx) the client discovers
|
|
18
|
+
alternative LiveKit Cloud regions via ``/settings/regions`` and replays the
|
|
19
|
+
request against the next region, with exponential backoff. 4xx responses are
|
|
20
|
+
returned immediately.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Dict, List, Optional
|
|
28
|
+
from urllib.parse import urlparse
|
|
29
|
+
|
|
30
|
+
import aiohttp
|
|
31
|
+
|
|
32
|
+
FAILOVER_MAX_ATTEMPTS = 3
|
|
33
|
+
FAILOVER_BACKOFF_BASE = 0.2 # seconds
|
|
34
|
+
# Below this per-request timeout (seconds) a retry is unlikely to help and many
|
|
35
|
+
# clients would retry in lockstep across regions, so a short request gets a
|
|
36
|
+
# single attempt (thundering-herd guard).
|
|
37
|
+
MIN_FAILOVER_TIMEOUT = 5.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def failover_attempts(
|
|
41
|
+
enabled: bool,
|
|
42
|
+
host: Optional[str],
|
|
43
|
+
force: bool = False,
|
|
44
|
+
timeout: Optional[float] = None,
|
|
45
|
+
) -> int:
|
|
46
|
+
"""Total request attempts for a host; 1 means no failover. Failover only
|
|
47
|
+
engages when enabled, the host is a LiveKit Cloud domain, and the request
|
|
48
|
+
timeout is long enough to retry. ``force`` bypasses the cloud-host check and
|
|
49
|
+
is for internal testing only.
|
|
50
|
+
"""
|
|
51
|
+
if not (enabled and (force or (host is not None and is_cloud(host)))):
|
|
52
|
+
return 1
|
|
53
|
+
if timeout is not None and 0 < timeout < MIN_FAILOVER_TIMEOUT:
|
|
54
|
+
return 1
|
|
55
|
+
return FAILOVER_MAX_ATTEMPTS
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_cloud(host: str) -> bool:
|
|
59
|
+
# Failover only engages for LiveKit Cloud project domains.
|
|
60
|
+
return host.endswith(".livekit.cloud")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def to_http(url: str) -> str:
|
|
64
|
+
"""Normalizes a region URL to an http(s) scheme (ws -> http, wss -> https)."""
|
|
65
|
+
if url.startswith("ws"):
|
|
66
|
+
return "http" + url[2:]
|
|
67
|
+
return url
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def origin_of(url: str) -> str:
|
|
71
|
+
"""Returns the scheme://host[:port] origin of a URL, dropping any path."""
|
|
72
|
+
parsed = urlparse(url)
|
|
73
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def host_key(url: str) -> str:
|
|
77
|
+
"""A stable key identifying a host (including port) for dedup across attempts."""
|
|
78
|
+
return urlparse(url).netloc.lower()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def pick_next(region_origins: List[str], attempted: set[str]) -> Optional[str]:
|
|
82
|
+
"""Returns the first region origin whose host has not yet been attempted."""
|
|
83
|
+
for origin in region_origins:
|
|
84
|
+
if host_key(origin) not in attempted:
|
|
85
|
+
return origin
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class _CacheEntry:
|
|
91
|
+
origins: List[str]
|
|
92
|
+
fetched_at: float
|
|
93
|
+
ttl: float
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class RegionCache:
|
|
97
|
+
"""Process-wide cache of the LiveKit Cloud region list, keyed by host."""
|
|
98
|
+
|
|
99
|
+
def __init__(self) -> None:
|
|
100
|
+
self._entries: Dict[str, _CacheEntry] = {}
|
|
101
|
+
|
|
102
|
+
async def region_origins(
|
|
103
|
+
self,
|
|
104
|
+
session: aiohttp.ClientSession,
|
|
105
|
+
origin: str,
|
|
106
|
+
headers: Dict[str, str],
|
|
107
|
+
) -> List[str]:
|
|
108
|
+
"""Returns alternative region origins for ``origin``, fetching
|
|
109
|
+
``/settings/regions`` if the cache is stale. Best-effort: on a fetch
|
|
110
|
+
failure it serves a stale cached list when available, otherwise an empty
|
|
111
|
+
list. Forwards ``headers`` so a valid token — and any test directives —
|
|
112
|
+
reach the discovery endpoint."""
|
|
113
|
+
key = host_key(origin)
|
|
114
|
+
entry = self._entries.get(key)
|
|
115
|
+
if entry is not None and (time.monotonic() - entry.fetched_at) < entry.ttl:
|
|
116
|
+
return entry.origins
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
origins, ttl = await self._fetch(session, origin, headers)
|
|
120
|
+
except Exception:
|
|
121
|
+
return entry.origins if entry is not None else []
|
|
122
|
+
|
|
123
|
+
# A zero TTL (e.g. Cache-Control: max-age=0) means "do not cache".
|
|
124
|
+
if ttl > 0:
|
|
125
|
+
self._entries[key] = _CacheEntry(origins, time.monotonic(), ttl)
|
|
126
|
+
return origins
|
|
127
|
+
|
|
128
|
+
async def _fetch(
|
|
129
|
+
self,
|
|
130
|
+
session: aiohttp.ClientSession,
|
|
131
|
+
origin: str,
|
|
132
|
+
headers: Dict[str, str],
|
|
133
|
+
) -> tuple[List[str], float]:
|
|
134
|
+
fetch_headers = {
|
|
135
|
+
k: v for k, v in headers.items() if k.lower() not in ("content-type", "content-length")
|
|
136
|
+
}
|
|
137
|
+
# Short timeout so a slow/unreachable discovery endpoint doesn't stall
|
|
138
|
+
# the failover path.
|
|
139
|
+
async with session.get(
|
|
140
|
+
f"{origin}/settings/regions",
|
|
141
|
+
headers=fetch_headers,
|
|
142
|
+
timeout=aiohttp.ClientTimeout(total=2),
|
|
143
|
+
) as resp:
|
|
144
|
+
if resp.status != 200:
|
|
145
|
+
raise RuntimeError(f"region discovery failed: {resp.status}")
|
|
146
|
+
ttl = _parse_max_age(resp.headers.get("Cache-Control"))
|
|
147
|
+
body = await resp.json()
|
|
148
|
+
origins = [origin_of(to_http(r["url"])) for r in body.get("regions", []) if r.get("url")]
|
|
149
|
+
return origins, ttl
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _parse_max_age(cache_control: Optional[str]) -> float:
|
|
153
|
+
if not cache_control:
|
|
154
|
+
return 0.0
|
|
155
|
+
for directive in cache_control.split(","):
|
|
156
|
+
directive = directive.strip().lower()
|
|
157
|
+
if directive.startswith("max-age="):
|
|
158
|
+
try:
|
|
159
|
+
return float(int(directive[len("max-age=") :]))
|
|
160
|
+
except ValueError:
|
|
161
|
+
return 0.0
|
|
162
|
+
return 0.0
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import aiohttp
|
|
4
|
+
from abc import ABC
|
|
5
|
+
from .twirp_client import TwirpClient
|
|
6
|
+
from .access_token import AccessToken, VideoGrants, SIPGrants
|
|
7
|
+
|
|
8
|
+
AUTHORIZATION = "authorization"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Service(ABC):
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
session: aiohttp.ClientSession,
|
|
15
|
+
host: str,
|
|
16
|
+
api_key: str,
|
|
17
|
+
api_secret: str,
|
|
18
|
+
failover: bool = True,
|
|
19
|
+
):
|
|
20
|
+
self._client = TwirpClient(session, host, "livekit", failover=failover)
|
|
21
|
+
self.api_key = api_key
|
|
22
|
+
self.api_secret = api_secret
|
|
23
|
+
# A pre-signed token set by LiveKitAPI for token auth; sent verbatim,
|
|
24
|
+
# skipping per-call signing. Per-service constructors stay key/secret-only.
|
|
25
|
+
self._token: str | None = None
|
|
26
|
+
|
|
27
|
+
def _auth_header(
|
|
28
|
+
self, grants: VideoGrants | None, sip: SIPGrants | None = None
|
|
29
|
+
) -> dict[str, str]:
|
|
30
|
+
# A pre-signed token is sent verbatim; the caller is responsible for its grants.
|
|
31
|
+
if self._token:
|
|
32
|
+
return {AUTHORIZATION: "Bearer {}".format(self._token)}
|
|
33
|
+
|
|
34
|
+
tok = AccessToken(self.api_key, self.api_secret)
|
|
35
|
+
if grants:
|
|
36
|
+
tok.with_grants(grants)
|
|
37
|
+
if sip is not None:
|
|
38
|
+
tok.with_sip_grants(sip)
|
|
39
|
+
|
|
40
|
+
token = tok.to_jwt()
|
|
41
|
+
return {AUTHORIZATION: "Bearer {}".format(token)}
|
|
@@ -68,6 +68,9 @@ class VideoGrants:
|
|
|
68
68
|
# indicates that the holder can register as an Agent framework worker
|
|
69
69
|
agent: Optional[bool] = None
|
|
70
70
|
|
|
71
|
+
# allow participant to manage an agent session via RemoteSession
|
|
72
|
+
can_manage_agent_session: Optional[bool] = None
|
|
73
|
+
|
|
71
74
|
|
|
72
75
|
@dataclass
|
|
73
76
|
class SIPGrants:
|
|
@@ -268,9 +271,10 @@ class TokenVerifier:
|
|
|
268
271
|
|
|
269
272
|
if claims.get("roomPreset"):
|
|
270
273
|
grant_claims.room_preset = claims.get("roomPreset")
|
|
271
|
-
|
|
274
|
+
room_config = claims.get("roomConfig")
|
|
275
|
+
if room_config:
|
|
272
276
|
grant_claims.room_config = ParseDict(
|
|
273
|
-
|
|
277
|
+
room_config,
|
|
274
278
|
RoomConfiguration(),
|
|
275
279
|
ignore_unknown_fields=True,
|
|
276
280
|
)
|
|
@@ -278,9 +282,9 @@ class TokenVerifier:
|
|
|
278
282
|
return grant_claims
|
|
279
283
|
|
|
280
284
|
|
|
281
|
-
def camel_to_snake(t: str):
|
|
285
|
+
def camel_to_snake(t: str) -> str:
|
|
282
286
|
return re.sub(r"(?<!^)(?=[A-Z])", "_", t).lower()
|
|
283
287
|
|
|
284
288
|
|
|
285
|
-
def snake_to_lower_camel(t: str):
|
|
289
|
+
def snake_to_lower_camel(t: str) -> str:
|
|
286
290
|
return "".join(word.capitalize() if i else word for i, word in enumerate(t.split("_")))
|
|
@@ -26,8 +26,15 @@ class AgentDispatchService(Service):
|
|
|
26
26
|
```
|
|
27
27
|
"""
|
|
28
28
|
|
|
29
|
-
def __init__(
|
|
30
|
-
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
session: aiohttp.ClientSession,
|
|
32
|
+
url: str,
|
|
33
|
+
api_key: str,
|
|
34
|
+
api_secret: str,
|
|
35
|
+
failover: bool = True,
|
|
36
|
+
):
|
|
37
|
+
super().__init__(session, url, api_key, api_secret, failover=failover)
|
|
31
38
|
|
|
32
39
|
async def create_dispatch(self, req: CreateAgentDispatchRequest) -> AgentDispatch:
|
|
33
40
|
"""Create an explicit dispatch for an agent to join a room.
|
|
@@ -35,7 +42,9 @@ class AgentDispatchService(Service):
|
|
|
35
42
|
To use explicit dispatch, your agent must be registered with an `agentName`.
|
|
36
43
|
|
|
37
44
|
Args:
|
|
38
|
-
req (CreateAgentDispatchRequest): Request containing dispatch creation parameters
|
|
45
|
+
req (CreateAgentDispatchRequest): Request containing dispatch creation parameters.
|
|
46
|
+
The request can include an optional `deployment` field to target a specific
|
|
47
|
+
deployment. Leave empty to target the production deployment.
|
|
39
48
|
|
|
40
49
|
Returns:
|
|
41
50
|
AgentDispatch: The created agent dispatch object
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import aiohttp
|
|
4
|
+
from typing import Optional
|
|
4
5
|
|
|
5
6
|
from livekit.protocol.connector_whatsapp import (
|
|
6
7
|
DialWhatsAppCallRequest,
|
|
@@ -17,6 +18,7 @@ from livekit.protocol.connector_twilio import (
|
|
|
17
18
|
ConnectTwilioCallResponse,
|
|
18
19
|
)
|
|
19
20
|
from ._service import Service
|
|
21
|
+
from ._dial_timeout import DEFAULT_RINGING_TIMEOUT
|
|
20
22
|
from .access_token import VideoGrants
|
|
21
23
|
|
|
22
24
|
SVC = "Connector"
|
|
@@ -35,8 +37,15 @@ class ConnectorService(Service):
|
|
|
35
37
|
```
|
|
36
38
|
"""
|
|
37
39
|
|
|
38
|
-
def __init__(
|
|
39
|
-
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
session: aiohttp.ClientSession,
|
|
43
|
+
url: str,
|
|
44
|
+
api_key: str,
|
|
45
|
+
api_secret: str,
|
|
46
|
+
failover: bool = True,
|
|
47
|
+
):
|
|
48
|
+
super().__init__(session, url, api_key, api_secret, failover=failover)
|
|
40
49
|
|
|
41
50
|
async def dial_whatsapp_call(
|
|
42
51
|
self, request: DialWhatsAppCallRequest
|
|
@@ -99,23 +108,41 @@ class ConnectorService(Service):
|
|
|
99
108
|
)
|
|
100
109
|
|
|
101
110
|
async def accept_whatsapp_call(
|
|
102
|
-
self,
|
|
111
|
+
self,
|
|
112
|
+
request: AcceptWhatsAppCallRequest,
|
|
113
|
+
*,
|
|
114
|
+
timeout: Optional[float] = None,
|
|
103
115
|
) -> AcceptWhatsAppCallResponse:
|
|
104
116
|
"""
|
|
105
117
|
Accept an inbound WhatsApp call
|
|
106
118
|
|
|
107
119
|
Args:
|
|
108
120
|
request: AcceptWhatsAppCallRequest containing call parameters and SDP
|
|
121
|
+
timeout: Optional request timeout in seconds. When the request waits
|
|
122
|
+
for the inbound party to join (wait_until_answered), it defaults
|
|
123
|
+
to the standard ring window.
|
|
109
124
|
|
|
110
125
|
Returns:
|
|
111
126
|
AcceptWhatsAppCallResponse with the room name
|
|
112
127
|
"""
|
|
128
|
+
client_timeout: Optional[aiohttp.ClientTimeout] = None
|
|
129
|
+
if request.wait_until_answered:
|
|
130
|
+
# Waiting for the inbound party to join can block, so default the
|
|
131
|
+
# request timeout to the standard ring window; the caller overrides
|
|
132
|
+
# via timeout.
|
|
133
|
+
client_timeout = aiohttp.ClientTimeout(
|
|
134
|
+
total=timeout if timeout else DEFAULT_RINGING_TIMEOUT
|
|
135
|
+
)
|
|
136
|
+
elif timeout:
|
|
137
|
+
client_timeout = aiohttp.ClientTimeout(total=timeout)
|
|
138
|
+
|
|
113
139
|
return await self._client.request(
|
|
114
140
|
SVC,
|
|
115
141
|
"AcceptWhatsAppCall",
|
|
116
142
|
request,
|
|
117
143
|
self._auth_header(VideoGrants(room_create=True)),
|
|
118
144
|
AcceptWhatsAppCallResponse,
|
|
145
|
+
timeout=client_timeout,
|
|
119
146
|
)
|
|
120
147
|
|
|
121
148
|
async def connect_twilio_call(
|
|
@@ -33,8 +33,15 @@ class EgressService(Service):
|
|
|
33
33
|
Also see https://docs.livekit.io/home/egress/overview/
|
|
34
34
|
"""
|
|
35
35
|
|
|
36
|
-
def __init__(
|
|
37
|
-
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
session: aiohttp.ClientSession,
|
|
39
|
+
url: str,
|
|
40
|
+
api_key: str,
|
|
41
|
+
api_secret: str,
|
|
42
|
+
failover: bool = True,
|
|
43
|
+
):
|
|
44
|
+
super().__init__(session, url, api_key, api_secret, failover=failover)
|
|
38
45
|
|
|
39
46
|
async def start_room_composite_egress(self, start: RoomCompositeEgressRequest) -> EgressInfo:
|
|
40
47
|
"""Starts a composite recording of a room."""
|
|
@@ -28,8 +28,15 @@ class IngressService(Service):
|
|
|
28
28
|
Also see https://docs.livekit.io/home/ingress/overview/
|
|
29
29
|
"""
|
|
30
30
|
|
|
31
|
-
def __init__(
|
|
32
|
-
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
session: aiohttp.ClientSession,
|
|
34
|
+
url: str,
|
|
35
|
+
api_key: str,
|
|
36
|
+
api_secret: str,
|
|
37
|
+
failover: bool = True,
|
|
38
|
+
):
|
|
39
|
+
super().__init__(session, url, api_key, api_secret, failover=failover)
|
|
33
40
|
|
|
34
41
|
async def create_ingress(self, create: CreateIngressRequest) -> IngressInfo:
|
|
35
42
|
return await self._client.request(
|