livekit-api 1.2.1__tar.gz → 1.2.2__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.
Files changed (21) hide show
  1. {livekit_api-1.2.1 → livekit_api-1.2.2}/PKG-INFO +7 -4
  2. {livekit_api-1.2.1 → livekit_api-1.2.2}/livekit/api/__init__.py +6 -0
  3. {livekit_api-1.2.1 → livekit_api-1.2.2}/livekit/api/_failover.py +10 -4
  4. {livekit_api-1.2.1 → livekit_api-1.2.2}/livekit/api/access_token.py +81 -1
  5. {livekit_api-1.2.1 → livekit_api-1.2.2}/livekit/api/sip_service.py +4 -3
  6. {livekit_api-1.2.1 → livekit_api-1.2.2}/livekit/api/twirp_client.py +48 -19
  7. livekit_api-1.2.2/livekit/api/version.py +1 -0
  8. {livekit_api-1.2.1 → livekit_api-1.2.2}/livekit/api/webhook.py +1 -1
  9. {livekit_api-1.2.1 → livekit_api-1.2.2}/pyproject.toml +8 -3
  10. livekit_api-1.2.1/livekit/api/version.py +0 -1
  11. {livekit_api-1.2.1 → livekit_api-1.2.2}/.gitignore +0 -0
  12. {livekit_api-1.2.1 → livekit_api-1.2.2}/README.md +0 -0
  13. {livekit_api-1.2.1 → livekit_api-1.2.2}/livekit/api/_dial_timeout.py +0 -0
  14. {livekit_api-1.2.1 → livekit_api-1.2.2}/livekit/api/_service.py +0 -0
  15. {livekit_api-1.2.1 → livekit_api-1.2.2}/livekit/api/agent_dispatch_service.py +0 -0
  16. {livekit_api-1.2.1 → livekit_api-1.2.2}/livekit/api/connector_service.py +0 -0
  17. {livekit_api-1.2.1 → livekit_api-1.2.2}/livekit/api/egress_service.py +0 -0
  18. {livekit_api-1.2.1 → livekit_api-1.2.2}/livekit/api/ingress_service.py +0 -0
  19. {livekit_api-1.2.1 → livekit_api-1.2.2}/livekit/api/livekit_api.py +0 -0
  20. {livekit_api-1.2.1 → livekit_api-1.2.2}/livekit/api/py.typed +0 -0
  21. {livekit_api-1.2.1 → livekit_api-1.2.2}/livekit/api/room_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: livekit-api
3
- Version: 1.2.1
3
+ Version: 1.2.2
4
4
  Summary: Python Server API for LiveKit
5
5
  Project-URL: Documentation, https://docs.livekit.io
6
6
  Project-URL: Website, https://livekit.io/
@@ -21,10 +21,13 @@ Classifier: Topic :: Multimedia :: Sound/Audio
21
21
  Classifier: Topic :: Multimedia :: Video
22
22
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
23
23
  Requires-Python: >=3.9.0
24
- Requires-Dist: aiohttp>=3.9.0
25
- Requires-Dist: livekit-protocol<2.0.0,>=1.1.25
24
+ Requires-Dist: aiohttp>=3.13.5; python_version < '3.10'
25
+ Requires-Dist: aiohttp>=3.14.4; python_version >= '3.10'
26
+ Requires-Dist: idna>=3.15
27
+ Requires-Dist: livekit-protocol<2.0.0,>=1.1.27
28
+ Requires-Dist: multidict>=6.9.1; python_version >= '3.10'
26
29
  Requires-Dist: protobuf>=4
27
- Requires-Dist: pyjwt>=2.0.0
30
+ Requires-Dist: pyjwt>=2.15.1
28
31
  Requires-Dist: types-protobuf>=4
29
32
  Description-Content-Type: text/markdown
30
33
 
@@ -47,6 +47,9 @@ from .livekit_api import LiveKitAPI
47
47
  from .access_token import (
48
48
  InferenceGrants,
49
49
  ObservabilityGrants,
50
+ AgentGrants,
51
+ AgentEndpointGrants,
52
+ AgentThreadGrants,
50
53
  VideoGrants,
51
54
  SIPGrants,
52
55
  AccessToken,
@@ -65,6 +68,9 @@ __all__ = [
65
68
  "connector_service",
66
69
  "InferenceGrants",
67
70
  "ObservabilityGrants",
71
+ "AgentGrants",
72
+ "AgentEndpointGrants",
73
+ "AgentThreadGrants",
68
74
  "VideoGrants",
69
75
  "SIPGrants",
70
76
  "AccessToken",
@@ -44,11 +44,11 @@ def failover_attempts(
44
44
  timeout: Optional[float] = None,
45
45
  ) -> int:
46
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.
47
+ engages when enabled, the host is a LiveKit Cloud project or Cloud API
48
+ domain, and the request timeout is long enough to retry. ``force`` bypasses
49
+ the cloud-host check and is for internal testing only.
50
50
  """
51
- if not (enabled and (force or (host is not None and is_cloud(host)))):
51
+ if not (enabled and (force or (host is not None and (is_cloud(host) or is_cloud_api(host))))):
52
52
  return 1
53
53
  if timeout is not None and 0 < timeout < MIN_FAILOVER_TIMEOUT:
54
54
  return 1
@@ -60,6 +60,12 @@ def is_cloud(host: str) -> bool:
60
60
  return host.endswith(".livekit.cloud")
61
61
 
62
62
 
63
+ def is_cloud_api(host: str) -> bool:
64
+ # cloud-api.livekit.io or a cloud-api.<env>.livekit.io variant; hostnames are case-insensitive.
65
+ host = host.lower()
66
+ return host.startswith("cloud-api.") and host.endswith(".livekit.io")
67
+
68
+
63
69
  def to_http(url: str) -> str:
64
70
  """Normalizes a region URL to an http(s) scheme (ws -> http, wss -> https)."""
65
71
  if url.startswith("ws"):
@@ -19,7 +19,7 @@ import os
19
19
  import jwt
20
20
  import dataclasses
21
21
  from dataclasses import dataclass
22
- from typing import Optional, List, Literal
22
+ from typing import Optional, List, Literal, Type, TypeVar
23
23
  from google.protobuf.json_format import MessageToDict, ParseDict
24
24
 
25
25
  from livekit.protocol.room import RoomConfiguration
@@ -71,6 +71,9 @@ class VideoGrants:
71
71
  # allow participant to manage an agent session via RemoteSession
72
72
  can_manage_agent_session: Optional[bool] = None
73
73
 
74
+ # allow participant to subscribe to metrics
75
+ can_subscribe_metrics: Optional[bool] = None
76
+
74
77
 
75
78
  @dataclass
76
79
  class SIPGrants:
@@ -92,6 +95,43 @@ class ObservabilityGrants:
92
95
  write: bool = False
93
96
 
94
97
 
98
+ @dataclass
99
+ class AgentGrants:
100
+ # create/update/delete Cloud Agents
101
+ admin: bool = False
102
+ # manage simulations and scenarios for evaluating agents
103
+ simulation_admin: bool = False
104
+ # access the project's agent databases
105
+ database_admin: bool = False
106
+ # manage the project's agent dispatch queue
107
+ dispatch_admin: bool = False
108
+
109
+
110
+ @dataclass
111
+ class AgentEndpointGrants:
112
+ # invoke an agent's non-public HTTP endpoints
113
+ call: bool = False
114
+ # restrict to one agent; empty grants every agent in the project
115
+ agent_name: str = ""
116
+ # restrict to one deployment; empty grants every deployment
117
+ deployment: str = ""
118
+
119
+
120
+ @dataclass
121
+ class AgentThreadGrants:
122
+ # every thread and scope of the project; the action flags still apply
123
+ all: bool = False
124
+ # scope in which threads may be listed and created
125
+ scope: str = ""
126
+ # threads that may be read, written and deleted
127
+ thread_ids: Optional[List[str]] = None
128
+ list: bool = False
129
+ create: bool = False
130
+ write: bool = False
131
+ read: bool = False
132
+ delete: bool = False
133
+
134
+
95
135
  @dataclass
96
136
  class Claims:
97
137
  identity: str = ""
@@ -102,6 +142,9 @@ class Claims:
102
142
  sip: Optional[SIPGrants] = None
103
143
  inference: Optional[InferenceGrants] = None
104
144
  observability: Optional[ObservabilityGrants] = None
145
+ agent: Optional[AgentGrants] = None
146
+ agent_endpoint: Optional[AgentEndpointGrants] = None
147
+ agent_thread: Optional[AgentThreadGrants] = None
105
148
  attributes: Optional[dict[str, str]] = None
106
149
  sha256: Optional[str] = None
107
150
  room_preset: Optional[str] = None
@@ -162,6 +205,18 @@ class AccessToken:
162
205
  self.claims.inference = grants
163
206
  return self
164
207
 
208
+ def with_agent_grants(self, grants: AgentGrants) -> "AccessToken":
209
+ self.claims.agent = grants
210
+ return self
211
+
212
+ def with_agent_endpoint_grants(self, grants: AgentEndpointGrants) -> "AccessToken":
213
+ self.claims.agent_endpoint = grants
214
+ return self
215
+
216
+ def with_agent_thread_grants(self, grants: AgentThreadGrants) -> "AccessToken":
217
+ self.claims.agent_thread = grants
218
+ return self
219
+
165
220
  def with_identity(self, identity: str) -> "AccessToken":
166
221
  self.identity = identity
167
222
  return self
@@ -270,15 +325,29 @@ class TokenVerifier:
270
325
  }
271
326
  inference = InferenceGrants(**inference_dict)
272
327
 
328
+ observability_dict = claims.get("observability", dict())
329
+ observability_dict = {camel_to_snake(k): v for k, v in observability_dict.items()}
330
+ observability_dict = {
331
+ k: v
332
+ for k, v in observability_dict.items()
333
+ if k in ObservabilityGrants.__dataclass_fields__
334
+ }
335
+ observability = ObservabilityGrants(**observability_dict)
336
+
273
337
  grant_claims = Claims(
274
338
  identity=claims.get("sub", ""),
275
339
  name=claims.get("name", ""),
340
+ kind=claims.get("kind", ""),
276
341
  video=video,
277
342
  sip=sip,
278
343
  inference=inference,
344
+ observability=observability,
279
345
  attributes=claims.get("attributes", {}),
280
346
  metadata=claims.get("metadata", ""),
281
347
  sha256=claims.get("sha256", ""),
348
+ agent=_parse_grants(claims, "agent", AgentGrants),
349
+ agent_endpoint=_parse_grants(claims, "agentEndpoint", AgentEndpointGrants),
350
+ agent_thread=_parse_grants(claims, "agentThread", AgentThreadGrants),
282
351
  )
283
352
 
284
353
  if claims.get("roomPreset"):
@@ -294,6 +363,17 @@ class TokenVerifier:
294
363
  return grant_claims
295
364
 
296
365
 
366
+ _G = TypeVar("_G")
367
+
368
+
369
+ def _parse_grants(claims: dict, key: str, cls: Type[_G]) -> Optional[_G]:
370
+ grants = claims.get(key)
371
+ if grants is None:
372
+ return None
373
+ fields = cls.__dataclass_fields__ # type: ignore[attr-defined]
374
+ return cls(**{camel_to_snake(k): v for k, v in grants.items() if camel_to_snake(k) in fields})
375
+
376
+
297
377
  def camel_to_snake(t: str) -> str:
298
378
  return re.sub(r"(?<!^)(?=[A-Z])", "_", t).lower()
299
379
 
@@ -49,9 +49,10 @@ SVC = "SIP"
49
49
 
50
50
 
51
51
  def _as_sip_error(err: ServerError) -> ServerError:
52
- """Surface a SIP dialing failure as a SipCallError so callers can branch on
53
- the SIP status; other failures (auth, validation) are returned unchanged."""
54
- if "sip_status_code" in err.metadata:
52
+ """Surface a SIP dialing or transfer failure as a SipCallError so callers can
53
+ branch on the SIP status or the transfer reason; other failures (auth,
54
+ validation) are returned unchanged."""
55
+ if "sip_status_code" in err.metadata or "sip_transfer_reason" in err.metadata:
55
56
  return SipCallError.from_server_error(err)
56
57
  return err
57
58
 
@@ -26,6 +26,7 @@ from ._failover import (
26
26
  RegionCache,
27
27
  failover_attempts,
28
28
  host_key,
29
+ is_cloud_api,
29
30
  origin_of,
30
31
  pick_next,
31
32
  )
@@ -87,10 +88,19 @@ class ServerError(Exception):
87
88
  return result
88
89
 
89
90
 
91
+ _SIP_META_KEYS = (
92
+ "sip_status_code",
93
+ "sip_status",
94
+ "sip_transfer_reason",
95
+ "error_details",
96
+ )
97
+
98
+
90
99
  class SipCallError(ServerError):
91
100
  """A :class:`ServerError` from a SIP dialing call (``create_sip_participant`` /
92
- ``transfer_sip_participant``) that failed with a SIP response status. The SIP
93
- code and reason are exposed as properties; any other error metadata remains
101
+ ``transfer_sip_participant``) that failed with a SIP response status, or a
102
+ transfer that failed for any other reason. The SIP code and reason, and the
103
+ transfer reason, are exposed as properties; any other error metadata remains
94
104
  available via :attr:`metadata`."""
95
105
 
96
106
  @property
@@ -109,25 +119,36 @@ class SipCallError(ServerError):
109
119
  """The SIP reason phrase of the failed call, e.g. "Busy Here"."""
110
120
  return self.metadata.get("sip_status")
111
121
 
122
+ @property
123
+ def sip_transfer_reason(self) -> Optional[str]:
124
+ """Why a transfer failed, e.g. "STR_RINGING_TIMEOUT". Only set for
125
+ ``transfer_sip_participant``."""
126
+ return self.metadata.get("sip_transfer_reason")
127
+
128
+ @property
129
+ def sip_transfer_id(self) -> Optional[str]:
130
+ """The id of the failed transfer, for matching against SIP transfer logs."""
131
+ return self.metadata.get("sip_transfer_id")
132
+
112
133
  @classmethod
113
134
  def from_server_error(cls, err: ServerError) -> "SipCallError":
114
135
  return cls(err.code, err.message, status=err.status, metadata=err.metadata)
115
136
 
116
137
  def __str__(self) -> str:
117
138
  code = self.metadata.get("sip_status_code")
118
- if code is None:
139
+ transfer_reason = self.metadata.get("sip_transfer_reason")
140
+ if code is None and transfer_reason is None:
119
141
  return super().__str__()
120
142
  # A clear, SIP-specific representation, including any extra metadata.
121
- reason = self.metadata.get("sip_status")
122
- result = f"SIP call failed: {code}"
123
- if reason:
124
- result += f" {reason}"
125
- result += f" ({self.code})"
126
- extra = {
127
- k: v
128
- for k, v in self.metadata.items()
129
- if k not in ("sip_status_code", "sip_status", "error_details")
130
- }
143
+ parts = []
144
+ if transfer_reason is not None:
145
+ parts.append(transfer_reason)
146
+ if code is not None:
147
+ reason = self.metadata.get("sip_status")
148
+ parts.append(f"{code} {reason}" if reason else str(code))
149
+ what = "SIP transfer failed" if transfer_reason is not None else "SIP call failed"
150
+ result = f"{what}: {', '.join(parts)} ({self.code})"
151
+ extra = {k: v for k, v in self.metadata.items() if k not in _SIP_META_KEYS}
131
152
  if extra:
132
153
  result += " [" + ", ".join(f"{k}={v}" for k, v in extra.items()) + "]"
133
154
  return result
@@ -182,7 +203,7 @@ class TwirpClient:
182
203
  if scheme.startswith("ws"):
183
204
  scheme = scheme.replace("ws", "http")
184
205
 
185
- host = f"{scheme}://{parse_res.netloc}/{parse_res.path}"
206
+ host = f"{scheme}://{parse_res.netloc}{parse_res.path}"
186
207
  self.host = host.rstrip("/")
187
208
  self.pkg = pkg
188
209
  self.prefix = prefix
@@ -226,12 +247,16 @@ class TwirpClient:
226
247
  self._failover, host, self._failover_force, effective_timeout
227
248
  )
228
249
  attempted = {host_key(self._origin)}
229
- region_origins: Optional[List[str]] = None
250
+ # A Cloud API host has a single origin; region discovery is never consulted.
251
+ region_origins: Optional[List[str]] = [] if host and is_cloud_api(host) else None
230
252
  current_origin = self._origin
253
+ # The configured host may carry a base path (e.g. behind a reverse
254
+ # proxy); fallback regions are bare origins.
255
+ current_base = self.host
231
256
 
232
257
  for attempt in range(max_attempts):
233
258
  is_last = attempt + 1 >= max_attempts
234
- url = f"{current_origin}/{path}"
259
+ url = f"{current_base}/{path}"
235
260
 
236
261
  transport_exc: Optional[BaseException] = None
237
262
  retryable_status: Optional[int] = None
@@ -264,9 +289,12 @@ class TwirpClient:
264
289
  next_origin = pick_next(region_origins, attempted)
265
290
 
266
291
  if next_origin is None:
267
- if transport_exc is not None:
268
- raise transport_exc
269
- raise self._server_error(error_data, retryable_status or 500)
292
+ if is_last:
293
+ if transport_exc is not None:
294
+ raise transport_exc
295
+ raise self._server_error(error_data, retryable_status or 500)
296
+ # With no fallback origin, a retryable failure is retried against the same host.
297
+ next_origin = current_origin
270
298
 
271
299
  reason = transport_exc if transport_exc is not None else f"status {retryable_status}"
272
300
  logger.warning(
@@ -278,6 +306,7 @@ class TwirpClient:
278
306
  await asyncio.sleep(self._failover_backoff * (2**attempt))
279
307
  attempted.add(host_key(next_origin))
280
308
  current_origin = next_origin
309
+ current_base = next_origin
281
310
 
282
311
  raise RuntimeError("failover loop exited without returning") # unreachable
283
312
 
@@ -0,0 +1 @@
1
+ __version__ = "1.2.2"
@@ -11,7 +11,7 @@ class WebhookReceiver:
11
11
 
12
12
  def receive(self, body: str, auth_token: str) -> WebhookEvent:
13
13
  claims = self._verifier.verify(auth_token)
14
- if claims.sha256 is None:
14
+ if not claims.sha256:
15
15
  raise Exception("sha256 was not found in the token")
16
16
 
17
17
  body_hash = hashlib.sha256(body.encode()).digest()
@@ -28,11 +28,16 @@ classifiers = [
28
28
  "Programming Language :: Python :: 3 :: Only",
29
29
  ]
30
30
  dependencies = [
31
- "pyjwt>=2.0.0",
32
- "aiohttp>=3.9.0",
31
+ "pyjwt>=2.15.1",
32
+ # aiohttp 3.14 dropped Python 3.9; keep its final compatible release there.
33
+ "aiohttp>=3.13.5; python_version < '3.10'",
34
+ "aiohttp>=3.14.4; python_version >= '3.10'",
35
+ # Security floors for aiohttp's transitive dependencies.
36
+ "idna>=3.15",
37
+ "multidict>=6.9.1; python_version >= '3.10'",
33
38
  "protobuf>=4",
34
39
  "types-protobuf>=4",
35
- "livekit-protocol>=1.1.25,<2.0.0",
40
+ "livekit-protocol>=1.1.27,<2.0.0",
36
41
  ]
37
42
 
38
43
  [project.urls]
@@ -1 +0,0 @@
1
- __version__ = "1.2.1"
File without changes
File without changes