livekit-api 1.2.0__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 (23) hide show
  1. livekit_api-1.2.2/PKG-INFO +62 -0
  2. livekit_api-1.2.2/README.md +29 -0
  3. {livekit_api-1.2.0 → livekit_api-1.2.2}/livekit/api/__init__.py +6 -0
  4. {livekit_api-1.2.0 → livekit_api-1.2.2}/livekit/api/_failover.py +10 -4
  5. {livekit_api-1.2.0 → livekit_api-1.2.2}/livekit/api/access_token.py +101 -9
  6. {livekit_api-1.2.0 → livekit_api-1.2.2}/livekit/api/egress_service.py +11 -0
  7. {livekit_api-1.2.0 → livekit_api-1.2.2}/livekit/api/sip_service.py +12 -7
  8. {livekit_api-1.2.0 → livekit_api-1.2.2}/livekit/api/twirp_client.py +55 -19
  9. livekit_api-1.2.2/livekit/api/version.py +1 -0
  10. {livekit_api-1.2.0 → livekit_api-1.2.2}/livekit/api/webhook.py +1 -1
  11. {livekit_api-1.2.0 → livekit_api-1.2.2}/pyproject.toml +8 -3
  12. livekit_api-1.2.0/PKG-INFO +0 -36
  13. livekit_api-1.2.0/README.md +0 -6
  14. livekit_api-1.2.0/livekit/api/version.py +0 -1
  15. {livekit_api-1.2.0 → livekit_api-1.2.2}/.gitignore +0 -0
  16. {livekit_api-1.2.0 → livekit_api-1.2.2}/livekit/api/_dial_timeout.py +0 -0
  17. {livekit_api-1.2.0 → livekit_api-1.2.2}/livekit/api/_service.py +0 -0
  18. {livekit_api-1.2.0 → livekit_api-1.2.2}/livekit/api/agent_dispatch_service.py +0 -0
  19. {livekit_api-1.2.0 → livekit_api-1.2.2}/livekit/api/connector_service.py +0 -0
  20. {livekit_api-1.2.0 → livekit_api-1.2.2}/livekit/api/ingress_service.py +0 -0
  21. {livekit_api-1.2.0 → livekit_api-1.2.2}/livekit/api/livekit_api.py +0 -0
  22. {livekit_api-1.2.0 → livekit_api-1.2.2}/livekit/api/py.typed +0 -0
  23. {livekit_api-1.2.0 → livekit_api-1.2.2}/livekit/api/room_service.py +0 -0
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.5
2
+ Name: livekit-api
3
+ Version: 1.2.2
4
+ Summary: Python Server API for LiveKit
5
+ Project-URL: Documentation, https://docs.livekit.io
6
+ Project-URL: Website, https://livekit.io/
7
+ Project-URL: Source, https://github.com/livekit/python-sdks/
8
+ Author-email: LiveKit <support@livekit.io>
9
+ License-Expression: Apache-2.0
10
+ Keywords: audio,livekit,realtime,video,webrtc
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
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
23
+ Requires-Python: >=3.9.0
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'
29
+ Requires-Dist: protobuf>=4
30
+ Requires-Dist: pyjwt>=2.15.1
31
+ Requires-Dist: types-protobuf>=4
32
+ Description-Content-Type: text/markdown
33
+
34
+ # LiveKit Server APIs
35
+
36
+ Access LiveKit server APIs and generate access tokens.
37
+
38
+ See https://docs.livekit.io/reference/server/server-apis for more information.
39
+
40
+ ## Authentication
41
+
42
+ Every request to the server APIs is authenticated. `LiveKitAPI` supports two modes:
43
+
44
+ - **API key & secret** — recommended for backend use. The SDK signs a short-lived token per request from your key and secret. Keep your API secret on the server; never ship it to a client.
45
+ - **Access token** — for client-side use where the API secret must not be exposed. Pass a pre-signed [access token](https://docs.livekit.io/home/get-started/authentication/) that already carries the grants for the operations you'll perform; the SDK sends it verbatim.
46
+
47
+ ```python
48
+ from livekit import api
49
+
50
+ # API key & secret: set LIVEKIT_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET,
51
+ # then construct with no arguments...
52
+ lkapi = api.LiveKitAPI()
53
+
54
+ # ...or pass any of them explicitly to override the corresponding env var:
55
+ lkapi = api.LiveKitAPI("https://my.livekit.host", api_key="api-key", api_secret="api-secret")
56
+
57
+ # Pre-signed access token (client-side): with LIVEKIT_URL set, pass just the token:
58
+ lkapi = api.LiveKitAPI.with_token("a-pre-signed-token")
59
+ ```
60
+
61
+ The url and credentials fall back to the `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`, and `LIVEKIT_TOKEN` environment variables. Values you pass explicitly take precedence; the environment variables are used only as a fallback for arguments you omit — an ambient `LIVEKIT_TOKEN`, for example, won't override an explicitly-provided API key and secret.
62
+
@@ -0,0 +1,29 @@
1
+ # LiveKit Server APIs
2
+
3
+ Access LiveKit server APIs and generate access tokens.
4
+
5
+ See https://docs.livekit.io/reference/server/server-apis for more information.
6
+
7
+ ## Authentication
8
+
9
+ Every request to the server APIs is authenticated. `LiveKitAPI` supports two modes:
10
+
11
+ - **API key & secret** — recommended for backend use. The SDK signs a short-lived token per request from your key and secret. Keep your API secret on the server; never ship it to a client.
12
+ - **Access token** — for client-side use where the API secret must not be exposed. Pass a pre-signed [access token](https://docs.livekit.io/home/get-started/authentication/) that already carries the grants for the operations you'll perform; the SDK sends it verbatim.
13
+
14
+ ```python
15
+ from livekit import api
16
+
17
+ # API key & secret: set LIVEKIT_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET,
18
+ # then construct with no arguments...
19
+ lkapi = api.LiveKitAPI()
20
+
21
+ # ...or pass any of them explicitly to override the corresponding env var:
22
+ lkapi = api.LiveKitAPI("https://my.livekit.host", api_key="api-key", api_secret="api-secret")
23
+
24
+ # Pre-signed access token (client-side): with LIVEKIT_URL set, pass just the token:
25
+ lkapi = api.LiveKitAPI.with_token("a-pre-signed-token")
26
+ ```
27
+
28
+ The url and credentials fall back to the `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`, and `LIVEKIT_TOKEN` environment variables. Values you pass explicitly take precedence; the environment variables are used only as a fallback for arguments you omit — an ambient `LIVEKIT_TOKEN`, for example, won't override an explicitly-provided API key and secret.
29
+
@@ -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
@@ -233,14 +288,26 @@ class TokenVerifier:
233
288
  if verify_signature and (not self.api_key or not self.api_secret):
234
289
  raise ValueError("api_key and api_secret must be set")
235
290
 
236
- claims = jwt.decode(
237
- token,
238
- key=self.api_secret or "",
239
- issuer=self.api_key or "",
240
- algorithms=["HS256"],
241
- leeway=self._leeway.total_seconds(),
242
- options={"verify_signature": verify_signature},
243
- )
291
+ # First-party minters always set exp. Without this, a hand-rolled token
292
+ # with a valid signature and no exp verifies forever (livekit/protocol#1706).
293
+ if verify_signature:
294
+ claims = jwt.decode(
295
+ token,
296
+ key=self.api_secret or "",
297
+ issuer=self.api_key or "",
298
+ algorithms=["HS256"],
299
+ leeway=self._leeway.total_seconds(),
300
+ options={"verify_signature": True, "require": ["exp"]},
301
+ )
302
+ else:
303
+ claims = jwt.decode(
304
+ token,
305
+ key=self.api_secret or "",
306
+ issuer=self.api_key or "",
307
+ algorithms=["HS256"],
308
+ leeway=self._leeway.total_seconds(),
309
+ options={"verify_signature": False},
310
+ )
244
311
  video_dict = claims.get("video", dict())
245
312
  video_dict = {camel_to_snake(k): v for k, v in video_dict.items()}
246
313
  video_dict = {k: v for k, v in video_dict.items() if k in VideoGrants.__dataclass_fields__}
@@ -258,15 +325,29 @@ class TokenVerifier:
258
325
  }
259
326
  inference = InferenceGrants(**inference_dict)
260
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
+
261
337
  grant_claims = Claims(
262
338
  identity=claims.get("sub", ""),
263
339
  name=claims.get("name", ""),
340
+ kind=claims.get("kind", ""),
264
341
  video=video,
265
342
  sip=sip,
266
343
  inference=inference,
344
+ observability=observability,
267
345
  attributes=claims.get("attributes", {}),
268
346
  metadata=claims.get("metadata", ""),
269
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),
270
351
  )
271
352
 
272
353
  if claims.get("roomPreset"):
@@ -282,6 +363,17 @@ class TokenVerifier:
282
363
  return grant_claims
283
364
 
284
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
+
285
377
  def camel_to_snake(t: str) -> str:
286
378
  return re.sub(r"(?<!^)(?=[A-Z])", "_", t).lower()
287
379
 
@@ -9,6 +9,7 @@ from livekit.protocol.egress import (
9
9
  UpdateStreamRequest,
10
10
  ListEgressRequest,
11
11
  StopEgressRequest,
12
+ StartEgressRequest,
12
13
  EgressInfo,
13
14
  ListEgressResponse,
14
15
  )
@@ -93,6 +94,16 @@ class EgressService(Service):
93
94
  EgressInfo,
94
95
  )
95
96
 
97
+ async def start_egress(self, start: StartEgressRequest) -> EgressInfo:
98
+ """Starts an egress; the request's source and outputs determine the egress type."""
99
+ return await self._client.request(
100
+ SVC,
101
+ "StartEgress",
102
+ start,
103
+ self._auth_header(VideoGrants(room_record=True)),
104
+ EgressInfo,
105
+ )
106
+
96
107
  async def update_layout(self, update: UpdateLayoutRequest) -> EgressInfo:
97
108
  """Updates the layout of a composite recording."""
98
109
  return await self._client.request(
@@ -31,6 +31,7 @@ from livekit.protocol.sip import (
31
31
  DeleteSIPDispatchRuleRequest,
32
32
  CreateSIPParticipantRequest,
33
33
  TransferSIPParticipantRequest,
34
+ TransferSIPParticipantResponse,
34
35
  SIPParticipantInfo,
35
36
  SIPTransport,
36
37
  SIPMediaConfig,
@@ -48,9 +49,10 @@ SVC = "SIP"
48
49
 
49
50
 
50
51
  def _as_sip_error(err: ServerError) -> ServerError:
51
- """Surface a SIP dialing failure as a SipCallError so callers can branch on
52
- the SIP status; other failures (auth, validation) are returned unchanged."""
53
- 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:
54
56
  return SipCallError.from_server_error(err)
55
57
  return err
56
58
 
@@ -832,8 +834,8 @@ class SipService(Service):
832
834
  transfer: TransferSIPParticipantRequest,
833
835
  *,
834
836
  timeout: Optional[float] = None,
835
- ) -> SIPParticipantInfo:
836
- """Transfer a SIP participant to a different room.
837
+ ) -> TransferSIPParticipantResponse:
838
+ """Transfer a SIP participant to another number or SIP endpoint.
837
839
 
838
840
  Args:
839
841
  transfer: Request containing transfer details
@@ -842,7 +844,10 @@ class SipService(Service):
842
844
  longer timeout when unset.
843
845
 
844
846
  Returns:
845
- Updated SIP participant information
847
+ The outcome of the transfer: its id, status, reason, and the SIP
848
+ status when the outcome came from a SIP response. A status other
849
+ than successful means the transfer did not complete, even when no
850
+ error was raised.
846
851
  """
847
852
  # Transferring a call dials a phone, which takes longer than a normal
848
853
  # call, so keep the request alive past ringing. Pin the ring window so the
@@ -861,7 +866,7 @@ class SipService(Service):
861
866
  ),
862
867
  sip=SIPGrants(call=True),
863
868
  ),
864
- SIPParticipantInfo,
869
+ TransferSIPParticipantResponse,
865
870
  timeout=client_timeout,
866
871
  )
867
872
  except ServerError as e:
@@ -14,6 +14,7 @@
14
14
 
15
15
  import asyncio
16
16
  import logging
17
+ import uuid
17
18
  from typing import Dict, List, Optional, Type, TypeVar
18
19
 
19
20
  import aiohttp
@@ -25,6 +26,7 @@ from ._failover import (
25
26
  RegionCache,
26
27
  failover_attempts,
27
28
  host_key,
29
+ is_cloud_api,
28
30
  origin_of,
29
31
  pick_next,
30
32
  )
@@ -37,6 +39,11 @@ logger = logging.getLogger("livekit")
37
39
  # Identifies the SDK and version to the server on every request.
38
40
  _USER_AGENT = f"livekit-server-sdk-python/{__version__}"
39
41
 
42
+ # Carries a per-request idempotency key. The SDK's auto-retries (see _failover)
43
+ # keep the same key across attempts, so the server can identify and deduplicate
44
+ # repeated requests.
45
+ REQUEST_ID_HEADER = "X-Livekit-Request-Id"
46
+
40
47
  # Shared across all clients in the process so the region list is fetched once.
41
48
  _REGION_CACHE = RegionCache()
42
49
 
@@ -81,10 +88,19 @@ class ServerError(Exception):
81
88
  return result
82
89
 
83
90
 
91
+ _SIP_META_KEYS = (
92
+ "sip_status_code",
93
+ "sip_status",
94
+ "sip_transfer_reason",
95
+ "error_details",
96
+ )
97
+
98
+
84
99
  class SipCallError(ServerError):
85
100
  """A :class:`ServerError` from a SIP dialing call (``create_sip_participant`` /
86
- ``transfer_sip_participant``) that failed with a SIP response status. The SIP
87
- 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
88
104
  available via :attr:`metadata`."""
89
105
 
90
106
  @property
@@ -103,25 +119,36 @@ class SipCallError(ServerError):
103
119
  """The SIP reason phrase of the failed call, e.g. "Busy Here"."""
104
120
  return self.metadata.get("sip_status")
105
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
+
106
133
  @classmethod
107
134
  def from_server_error(cls, err: ServerError) -> "SipCallError":
108
135
  return cls(err.code, err.message, status=err.status, metadata=err.metadata)
109
136
 
110
137
  def __str__(self) -> str:
111
138
  code = self.metadata.get("sip_status_code")
112
- if code is None:
139
+ transfer_reason = self.metadata.get("sip_transfer_reason")
140
+ if code is None and transfer_reason is None:
113
141
  return super().__str__()
114
142
  # A clear, SIP-specific representation, including any extra metadata.
115
- reason = self.metadata.get("sip_status")
116
- result = f"SIP call failed: {code}"
117
- if reason:
118
- result += f" {reason}"
119
- result += f" ({self.code})"
120
- extra = {
121
- k: v
122
- for k, v in self.metadata.items()
123
- if k not in ("sip_status_code", "sip_status", "error_details")
124
- }
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}
125
152
  if extra:
126
153
  result += " [" + ", ".join(f"{k}={v}" for k, v in extra.items()) + "]"
127
154
  return result
@@ -176,7 +203,7 @@ class TwirpClient:
176
203
  if scheme.startswith("ws"):
177
204
  scheme = scheme.replace("ws", "http")
178
205
 
179
- host = f"{scheme}://{parse_res.netloc}/{parse_res.path}"
206
+ host = f"{scheme}://{parse_res.netloc}{parse_res.path}"
180
207
  self.host = host.rstrip("/")
181
208
  self.pkg = pkg
182
209
  self.prefix = prefix
@@ -207,6 +234,7 @@ class TwirpClient:
207
234
  headers["User-Agent"] = _USER_AGENT
208
235
  forward_headers = dict(headers) # for the discovery fetch (no content-type yet)
209
236
  headers["Content-Type"] = "application/protobuf"
237
+ headers[REQUEST_ID_HEADER] = str(uuid.uuid4())
210
238
  serialized_data = data.SerializeToString()
211
239
 
212
240
  # The effective per-attempt timeout is the per-call override, or the
@@ -219,12 +247,16 @@ class TwirpClient:
219
247
  self._failover, host, self._failover_force, effective_timeout
220
248
  )
221
249
  attempted = {host_key(self._origin)}
222
- 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
223
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
224
256
 
225
257
  for attempt in range(max_attempts):
226
258
  is_last = attempt + 1 >= max_attempts
227
- url = f"{current_origin}/{path}"
259
+ url = f"{current_base}/{path}"
228
260
 
229
261
  transport_exc: Optional[BaseException] = None
230
262
  retryable_status: Optional[int] = None
@@ -257,9 +289,12 @@ class TwirpClient:
257
289
  next_origin = pick_next(region_origins, attempted)
258
290
 
259
291
  if next_origin is None:
260
- if transport_exc is not None:
261
- raise transport_exc
262
- 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
263
298
 
264
299
  reason = transport_exc if transport_exc is not None else f"status {retryable_status}"
265
300
  logger.warning(
@@ -271,6 +306,7 @@ class TwirpClient:
271
306
  await asyncio.sleep(self._failover_backoff * (2**attempt))
272
307
  attempted.add(host_key(next_origin))
273
308
  current_origin = next_origin
309
+ current_base = next_origin
274
310
 
275
311
  raise RuntimeError("failover loop exited without returning") # unreachable
276
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.19,<2.0.0",
40
+ "livekit-protocol>=1.1.27,<2.0.0",
36
41
  ]
37
42
 
38
43
  [project.urls]
@@ -1,36 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: livekit-api
3
- Version: 1.2.0
4
- Summary: Python Server API for LiveKit
5
- Project-URL: Documentation, https://docs.livekit.io
6
- Project-URL: Website, https://livekit.io/
7
- Project-URL: Source, https://github.com/livekit/python-sdks/
8
- Author-email: LiveKit <support@livekit.io>
9
- License-Expression: Apache-2.0
10
- Keywords: audio,livekit,realtime,video,webrtc
11
- Classifier: Intended Audience :: Developers
12
- Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3 :: Only
14
- Classifier: Programming Language :: Python :: 3.9
15
- Classifier: Programming Language :: Python :: 3.10
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
23
- Requires-Python: >=3.9.0
24
- Requires-Dist: aiohttp>=3.9.0
25
- Requires-Dist: livekit-protocol<2.0.0,>=1.1.19
26
- Requires-Dist: protobuf>=4
27
- Requires-Dist: pyjwt>=2.0.0
28
- Requires-Dist: types-protobuf>=4
29
- Description-Content-Type: text/markdown
30
-
31
- # LiveKit Server APIs
32
-
33
- Access LiveKit server APIs and generate access tokens.
34
-
35
- See https://docs.livekit.io/reference/server/server-apis for more information.
36
-
@@ -1,6 +0,0 @@
1
- # LiveKit Server APIs
2
-
3
- Access LiveKit server APIs and generate access tokens.
4
-
5
- See https://docs.livekit.io/reference/server/server-apis for more information.
6
-
@@ -1 +0,0 @@
1
- __version__ = "1.2.0"
File without changes