Skip to content

Commit c469c7b

Browse files
authored
Merge branch 'main' into theo/v0.18.1
2 parents 6961248 + bc48e91 commit c469c7b

35 files changed

+1483
-481
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,4 +162,6 @@ cython_debug/
162162
# vscode project settings
163163
.vscode
164164

165-
.DS_Store
165+
.DS_Store
166+
167+
docs

examples/api.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33

44

55
async def main():
6-
# will automatically use the LIVEKIT_API_KEY and LIVEKIT_API_SECRET env vars
7-
lkapi = api.LiveKitAPI("http://localhost:7880")
6+
# will automatically use LIVEKIT_URL, LIVEKIT_API_KEY and LIVEKIT_API_SECRET env vars
7+
lkapi = api.LiveKitAPI()
88
room_info = await lkapi.room.create_room(
99
api.CreateRoomRequest(name="my-room"),
1010
)
@@ -15,4 +15,4 @@ async def main():
1515

1616

1717
if __name__ == "__main__":
18-
asyncio.get_event_loop().run_until_complete(main())
18+
asyncio.run(main())

livekit-api/livekit/api/__init__.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,21 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
"""LiveKit API SDK"""
15+
"""LiveKit Server APIs for Python
16+
17+
`pip install livekit-api`
18+
19+
Manage rooms, participants, egress, ingress, SIP, and Agent dispatch.
20+
21+
Primary entry point is `LiveKitAPI`.
22+
23+
See https://docs.livekit.io/reference/server/server-apis for more information.
24+
"""
1625

1726
# flake8: noqa
1827
# re-export packages from protocol
28+
from livekit.protocol.agent_dispatch import *
29+
from livekit.protocol.agent import *
1930
from livekit.protocol.egress import *
2031
from livekit.protocol.ingress import *
2132
from livekit.protocol.models import *
@@ -28,3 +39,17 @@
2839
from .access_token import VideoGrants, SIPGrants, AccessToken, TokenVerifier
2940
from .webhook import WebhookReceiver
3041
from .version import __version__
42+
43+
__all__ = [
44+
"LiveKitAPI",
45+
"room_service",
46+
"egress_service",
47+
"ingress_service",
48+
"sip_service",
49+
"agent_dispatch_service",
50+
"VideoGrants",
51+
"SIPGrants",
52+
"AccessToken",
53+
"TokenVerifier",
54+
"WebhookReceiver",
55+
]

livekit-api/livekit/api/_service.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ def __init__(
1818
self.api_secret = api_secret
1919

2020
def _auth_header(
21-
self, grants: VideoGrants, sip: SIPGrants | None = None
21+
self, grants: VideoGrants | None, sip: SIPGrants | None = None
2222
) -> Dict[str, str]:
23-
tok = AccessToken(self.api_key, self.api_secret).with_grants(grants)
23+
tok = AccessToken(self.api_key, self.api_secret)
24+
if grants:
25+
tok.with_grants(grants)
2426
if sip is not None:
25-
tok = tok.with_sip_grants(sip)
27+
tok.with_sip_grants(sip)
2628

2729
token = tok.to_jwt()
2830

livekit-api/livekit/api/access_token.py

Lines changed: 61 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
import os
2020
import jwt
2121
from typing import Optional, List, Literal
22+
from google.protobuf.json_format import MessageToDict, ParseDict
23+
24+
from livekit.protocol.room import RoomConfiguration
2225

2326
DEFAULT_TTL = datetime.timedelta(hours=6)
2427
DEFAULT_LEEWAY = datetime.timedelta(minutes=1)
@@ -27,13 +30,13 @@
2730
@dataclasses.dataclass
2831
class VideoGrants:
2932
# actions on rooms
30-
room_create: bool = False
31-
room_list: bool = False
32-
room_record: bool = False
33+
room_create: Optional[bool] = None
34+
room_list: Optional[bool] = None
35+
room_record: Optional[bool] = None
3336

3437
# actions on a particular room
35-
room_admin: bool = False
36-
room_join: bool = False
38+
room_admin: Optional[bool] = None
39+
room_join: Optional[bool] = None
3740
room: str = ""
3841

3942
# permissions within a room
@@ -44,23 +47,22 @@ class VideoGrants:
4447
# TrackSource types that a participant may publish.
4548
# When set, it supersedes CanPublish. Only sources explicitly set here can be
4649
# published
47-
can_publish_sources: List[str] = dataclasses.field(default_factory=list)
50+
can_publish_sources: Optional[List[str]] = None
4851

4952
# by default, a participant is not allowed to update its own metadata
50-
can_update_own_metadata: bool = False
53+
can_update_own_metadata: Optional[bool] = None
5154

5255
# actions on ingresses
53-
ingress_admin: bool = False # applies to all ingress
56+
ingress_admin: Optional[bool] = None # applies to all ingress
5457

5558
# participant is not visible to other participants (useful when making bots)
56-
hidden: bool = False
59+
hidden: Optional[bool] = None
5760

58-
# indicates to the room that current participant is a recorder
59-
recorder: bool = False
61+
# [deprecated] indicates to the room that current participant is a recorder
62+
recorder: Optional[bool] = None
6063

6164
# indicates that the holder can register as an Agent framework worker
62-
# it is also set on all participants that are joining as Agent
63-
agent: bool = False
65+
agent: Optional[bool] = None
6466

6567

6668
@dataclasses.dataclass
@@ -75,12 +77,28 @@ class SIPGrants:
7577
class Claims:
7678
identity: str = ""
7779
name: str = ""
78-
video: VideoGrants = dataclasses.field(default_factory=VideoGrants)
79-
sip: SIPGrants = dataclasses.field(default_factory=SIPGrants)
80-
attributes: dict[str, str] = dataclasses.field(default_factory=dict)
81-
metadata: str = ""
82-
sha256: str = ""
8380
kind: str = ""
81+
metadata: str = ""
82+
video: Optional[VideoGrants] = None
83+
sip: Optional[SIPGrants] = None
84+
attributes: Optional[dict[str, str]] = None
85+
sha256: Optional[str] = None
86+
room_preset: Optional[str] = None
87+
room_config: Optional[RoomConfiguration] = None
88+
89+
def asdict(self) -> dict:
90+
# in order to produce minimal JWT size, exclude None or empty values
91+
claims = dataclasses.asdict(
92+
self,
93+
dict_factory=lambda items: {
94+
snake_to_lower_camel(k): v
95+
for k, v in items
96+
if v is not None and v != ""
97+
},
98+
)
99+
if self.room_config:
100+
claims["roomConfig"] = MessageToDict(self.room_config)
101+
return claims
84102

85103

86104
class AccessToken:
@@ -141,16 +159,22 @@ def with_sha256(self, sha256: str) -> "AccessToken":
141159
self.claims.sha256 = sha256
142160
return self
143161

162+
def with_room_preset(self, preset: str) -> "AccessToken":
163+
self.claims.room_preset = preset
164+
return self
165+
166+
def with_room_config(self, config: RoomConfiguration) -> "AccessToken":
167+
self.claims.room_config = config
168+
return self
169+
144170
def to_jwt(self) -> str:
145171
video = self.claims.video
146-
if video.room_join and (not self.identity or not video.room):
172+
if video and video.room_join and (not self.identity or not video.room):
147173
raise ValueError("identity and room must be set when joining a room")
148174

149-
claims = dataclasses.asdict(
150-
self.claims,
151-
dict_factory=lambda items: {snake_to_lower_camel(k): v for k, v in items},
152-
)
153-
claims.update(
175+
# we want to exclude None values from the token
176+
jwt_claims = self.claims.asdict()
177+
jwt_claims.update(
154178
{
155179
"sub": self.identity,
156180
"iss": self.api_key,
@@ -164,7 +188,7 @@ def to_jwt(self) -> str:
164188
),
165189
}
166190
)
167-
return jwt.encode(claims, self.api_secret, algorithm="HS256")
191+
return jwt.encode(jwt_claims, self.api_secret, algorithm="HS256")
168192

169193

170194
class TokenVerifier:
@@ -208,7 +232,7 @@ def verify(self, token: str) -> Claims:
208232
}
209233
sip = SIPGrants(**sip_dict)
210234

211-
return Claims(
235+
grant_claims = Claims(
212236
identity=claims.get("sub", ""),
213237
name=claims.get("name", ""),
214238
video=video,
@@ -218,6 +242,17 @@ def verify(self, token: str) -> Claims:
218242
sha256=claims.get("sha256", ""),
219243
)
220244

245+
if claims.get("roomPreset"):
246+
grant_claims.room_preset = claims.get("roomPreset")
247+
if claims.get("roomConfig"):
248+
grant_claims.room_config = ParseDict(
249+
claims.get("roomConfig"),
250+
RoomConfiguration(),
251+
ignore_unknown_fields=True,
252+
)
253+
254+
return grant_claims
255+
221256

222257
def camel_to_snake(t: str):
223258
return re.sub(r"(?<!^)(?=[A-Z])", "_", t).lower()
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import aiohttp
2+
from typing import Optional
3+
from livekit.protocol.agent_dispatch import (
4+
CreateAgentDispatchRequest,
5+
AgentDispatch,
6+
DeleteAgentDispatchRequest,
7+
ListAgentDispatchRequest,
8+
ListAgentDispatchResponse,
9+
)
10+
from ._service import Service
11+
from .access_token import VideoGrants
12+
13+
SVC = "AgentDispatchService"
14+
"""@private"""
15+
16+
17+
class AgentDispatchService(Service):
18+
"""Manage agent dispatches. Service APIs require roomAdmin permissions.
19+
20+
Recommended way to use this service is via `livekit.api.LiveKitAPI`:
21+
22+
```python
23+
from livekit import api
24+
lkapi = api.LiveKitAPI()
25+
agent_dispatch = lkapi.agent_dispatch
26+
```
27+
"""
28+
29+
def __init__(
30+
self, session: aiohttp.ClientSession, url: str, api_key: str, api_secret: str
31+
):
32+
super().__init__(session, url, api_key, api_secret)
33+
34+
async def create_dispatch(self, req: CreateAgentDispatchRequest) -> AgentDispatch:
35+
"""Create an explicit dispatch for an agent to join a room.
36+
37+
To use explicit dispatch, your agent must be registered with an `agentName`.
38+
39+
Args:
40+
req (CreateAgentDispatchRequest): Request containing dispatch creation parameters
41+
42+
Returns:
43+
AgentDispatch: The created agent dispatch object
44+
"""
45+
return await self._client.request(
46+
SVC,
47+
"CreateDispatch",
48+
req,
49+
self._auth_header(VideoGrants(room_admin=True, room=req.room)),
50+
AgentDispatch,
51+
)
52+
53+
async def delete_dispatch(self, dispatch_id: str, room_name: str) -> AgentDispatch:
54+
"""Delete an explicit dispatch for an agent in a room.
55+
56+
Args:
57+
dispatch_id (str): ID of the dispatch to delete
58+
room_name (str): Name of the room containing the dispatch
59+
60+
Returns:
61+
AgentDispatch: The deleted agent dispatch object
62+
"""
63+
return await self._client.request(
64+
SVC,
65+
"DeleteDispatch",
66+
DeleteAgentDispatchRequest(
67+
dispatch_id=dispatch_id,
68+
room=room_name,
69+
),
70+
self._auth_header(VideoGrants(room_admin=True, room=room_name)),
71+
AgentDispatch,
72+
)
73+
74+
async def list_dispatch(self, room_name: str) -> list[AgentDispatch]:
75+
"""List all agent dispatches in a room.
76+
77+
Args:
78+
room_name (str): Name of the room to list dispatches from
79+
80+
Returns:
81+
list[AgentDispatch]: List of agent dispatch objects in the room
82+
"""
83+
res = await self._client.request(
84+
SVC,
85+
"ListDispatch",
86+
ListAgentDispatchRequest(room=room_name),
87+
self._auth_header(VideoGrants(room_admin=True, room=room_name)),
88+
ListAgentDispatchResponse,
89+
)
90+
return list(res.agent_dispatches)
91+
92+
async def get_dispatch(
93+
self, dispatch_id: str, room_name: str
94+
) -> Optional[AgentDispatch]:
95+
"""Get an Agent dispatch by ID
96+
97+
Args:
98+
dispatch_id (str): ID of the dispatch to retrieve
99+
room_name (str): Name of the room containing the dispatch
100+
101+
Returns:
102+
Optional[AgentDispatch]: The requested agent dispatch object if found, None otherwise
103+
"""
104+
res = await self._client.request(
105+
SVC,
106+
"ListDispatch",
107+
ListAgentDispatchRequest(dispatch_id=dispatch_id, room=room_name),
108+
self._auth_header(VideoGrants(room_admin=True, room=room_name)),
109+
ListAgentDispatchResponse,
110+
)
111+
if len(res.agent_dispatches) > 0:
112+
return res.agent_dispatches[0]
113+
return None

0 commit comments

Comments
 (0)