Skip to content

Commit 87428e3

Browse files
committed
Agent dispatch APIs, ability to set room config in token
1 parent 6ea5452 commit 87428e3

25 files changed

+1199
-399
lines changed

examples/api.py

Lines changed: 5 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,6 @@ async def main():
1515

1616

1717
if __name__ == "__main__":
18-
asyncio.get_event_loop().run_until_complete(main())
18+
loop = asyncio.new_event_loop()
19+
asyncio.set_event_loop(loop)
20+
loop.run_until_complete(main())

livekit-api/livekit/api/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
# flake8: noqa
1818
# re-export packages from protocol
19+
from livekit.protocol.agent_dispatch import *
20+
from livekit.protocol.agent import *
1921
from livekit.protocol.egress import *
2022
from livekit.protocol.ingress import *
2123
from livekit.protocol.models import *

livekit-api/livekit/api/access_token.py

Lines changed: 67 additions & 28 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+
# we don't
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,26 +159,36 @@ 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,
157-
"nbf": calendar.timegm(datetime.datetime.now(datetime.timezone.utc).utctimetuple()),
181+
"nbf": calendar.timegm(
182+
datetime.datetime.now(datetime.timezone.utc).utctimetuple()
183+
),
158184
"exp": calendar.timegm(
159-
(datetime.datetime.now(datetime.timezone.utc) + self.ttl).utctimetuple()
185+
(
186+
datetime.datetime.now(datetime.timezone.utc) + self.ttl
187+
).utctimetuple()
160188
),
161189
}
162190
)
163-
return jwt.encode(claims, self.api_secret, algorithm="HS256")
191+
return jwt.encode(jwt_claims, self.api_secret, algorithm="HS256")
164192

165193

166194
class TokenVerifier:
@@ -204,7 +232,7 @@ def verify(self, token: str) -> Claims:
204232
}
205233
sip = SIPGrants(**sip_dict)
206234

207-
return Claims(
235+
grant_claims = Claims(
208236
identity=claims.get("sub", ""),
209237
name=claims.get("name", ""),
210238
video=video,
@@ -214,6 +242,17 @@ def verify(self, token: str) -> Claims:
214242
sha256=claims.get("sha256", ""),
215243
)
216244

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+
217256

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

livekit-api/livekit/api/livekit_api.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .egress_service import EgressService
55
from .ingress_service import IngressService
66
from .sip_service import SipService
7+
from .agent_dispatch_service import AgentDispatchService
78
from typing import Optional
89

910

@@ -31,6 +32,13 @@ def __init__(
3132
self._ingress = IngressService(self._session, url, api_key, api_secret)
3233
self._egress = EgressService(self._session, url, api_key, api_secret)
3334
self._sip = SipService(self._session, url, api_key, api_secret)
35+
self._agent_dispatch = AgentDispatchService(
36+
self._session, url, api_key, api_secret
37+
)
38+
39+
@property
40+
def agent_dispatch(self):
41+
return self._agent_dispatch
3442

3543
@property
3644
def room(self):

livekit-api/tests/test_access_token.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import pytest # type: ignore
44
from livekit.api import AccessToken, TokenVerifier, VideoGrants, SIPGrants
5+
from livekit.protocol.room import RoomConfiguration
6+
from livekit.protocol.agent_dispatch import RoomAgentDispatch
57

68
TEST_API_KEY = "myapikey"
79
TEST_API_SECRET = "thiskeyistotallyunsafe"
@@ -32,6 +34,46 @@ def test_verify_token():
3234
assert claims.attributes["key2"] == "value2"
3335

3436

37+
def test_agent_config():
38+
token = (
39+
AccessToken(TEST_API_KEY, TEST_API_SECRET)
40+
.with_identity("test_identity")
41+
.with_grants(VideoGrants(room_join=True, room="test_room"))
42+
.with_room_config(
43+
RoomConfiguration(
44+
agents=[RoomAgentDispatch(agent_name="test-agent")],
45+
),
46+
)
47+
.to_jwt()
48+
)
49+
50+
token_verifier = TokenVerifier(TEST_API_KEY, TEST_API_SECRET)
51+
claims = token_verifier.verify(token)
52+
# Verify the decoded claims match
53+
assert claims.room_config.agents[0].agent_name == "test-agent"
54+
55+
# Split token into header.payload.signature
56+
parts = token.split(".")
57+
import base64
58+
import json
59+
60+
# Decode the payload (middle part)
61+
payload = parts[1]
62+
# Add padding if needed
63+
padding = len(payload) % 4
64+
if padding:
65+
payload += "=" * (4 - padding)
66+
decoded = base64.b64decode(payload)
67+
payload_json = json.loads(decoded)
68+
print(decoded)
69+
70+
# Verify the room_config and agents were encoded correctly
71+
assert "roomConfig" in payload_json
72+
assert "agents" in payload_json["roomConfig"]
73+
assert len(payload_json["roomConfig"]["agents"]) == 1
74+
assert payload_json["roomConfig"]["agents"][0]["agentName"] == "test-agent"
75+
76+
3577
def test_verify_token_invalid():
3678
token = (
3779
AccessToken(TEST_API_KEY, TEST_API_SECRET)

0 commit comments

Comments
 (0)