livekit-server-sdk 2.15.5 → 2.17.0

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 (101) hide show
  1. package/dist/AgentDispatchClient.cjs +5 -3
  2. package/dist/AgentDispatchClient.cjs.map +1 -1
  3. package/dist/AgentDispatchClient.js +5 -3
  4. package/dist/AgentDispatchClient.js.map +1 -1
  5. package/dist/ClientOptions.cjs.map +1 -1
  6. package/dist/ClientOptions.d.cts +12 -0
  7. package/dist/ClientOptions.d.ts +12 -0
  8. package/dist/ClientOptions.d.ts.map +1 -1
  9. package/dist/ConnectorClient.cjs +9 -5
  10. package/dist/ConnectorClient.cjs.map +1 -1
  11. package/dist/ConnectorClient.d.cts +6 -3
  12. package/dist/ConnectorClient.d.ts +6 -3
  13. package/dist/ConnectorClient.d.ts.map +1 -1
  14. package/dist/ConnectorClient.js +9 -5
  15. package/dist/ConnectorClient.js.map +1 -1
  16. package/dist/EgressClient.cjs +5 -3
  17. package/dist/EgressClient.cjs.map +1 -1
  18. package/dist/EgressClient.js +5 -3
  19. package/dist/EgressClient.js.map +1 -1
  20. package/dist/IngressClient.cjs +5 -3
  21. package/dist/IngressClient.cjs.map +1 -1
  22. package/dist/IngressClient.js +5 -3
  23. package/dist/IngressClient.js.map +1 -1
  24. package/dist/LiveKitAPI.cjs +80 -0
  25. package/dist/LiveKitAPI.cjs.map +1 -0
  26. package/dist/LiveKitAPI.d.cts +77 -0
  27. package/dist/LiveKitAPI.d.ts +77 -0
  28. package/dist/LiveKitAPI.d.ts.map +1 -0
  29. package/dist/LiveKitAPI.js +56 -0
  30. package/dist/LiveKitAPI.js.map +1 -0
  31. package/dist/RoomServiceClient.cjs +5 -3
  32. package/dist/RoomServiceClient.cjs.map +1 -1
  33. package/dist/RoomServiceClient.js +5 -3
  34. package/dist/RoomServiceClient.js.map +1 -1
  35. package/dist/ServiceBase.cjs +9 -9
  36. package/dist/ServiceBase.cjs.map +1 -1
  37. package/dist/ServiceBase.d.cts +20 -1
  38. package/dist/ServiceBase.d.ts +20 -1
  39. package/dist/ServiceBase.d.ts.map +1 -1
  40. package/dist/ServiceBase.js +9 -9
  41. package/dist/ServiceBase.js.map +1 -1
  42. package/dist/SipClient.cjs +56 -21
  43. package/dist/SipClient.cjs.map +1 -1
  44. package/dist/SipClient.d.cts +22 -4
  45. package/dist/SipClient.d.ts +22 -4
  46. package/dist/SipClient.d.ts.map +1 -1
  47. package/dist/SipClient.js +58 -22
  48. package/dist/SipClient.js.map +1 -1
  49. package/dist/TwirpRPC.cjs +135 -48
  50. package/dist/TwirpRPC.cjs.map +1 -1
  51. package/dist/TwirpRPC.d.cts +38 -2
  52. package/dist/TwirpRPC.d.ts +38 -2
  53. package/dist/TwirpRPC.d.ts.map +1 -1
  54. package/dist/TwirpRPC.js +140 -38
  55. package/dist/TwirpRPC.js.map +1 -1
  56. package/dist/dialTimeout.cjs +39 -0
  57. package/dist/dialTimeout.cjs.map +1 -0
  58. package/dist/dialTimeout.d.cts +21 -0
  59. package/dist/dialTimeout.d.ts +21 -0
  60. package/dist/dialTimeout.d.ts.map +1 -0
  61. package/dist/dialTimeout.js +13 -0
  62. package/dist/dialTimeout.js.map +1 -0
  63. package/dist/failover.cjs +139 -0
  64. package/dist/failover.cjs.map +1 -0
  65. package/dist/failover.d.cts +30 -0
  66. package/dist/failover.d.ts +30 -0
  67. package/dist/failover.d.ts.map +1 -0
  68. package/dist/failover.js +107 -0
  69. package/dist/failover.js.map +1 -0
  70. package/dist/index.cjs +8 -0
  71. package/dist/index.cjs.map +1 -1
  72. package/dist/index.d.cts +5 -4
  73. package/dist/index.d.ts +5 -4
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +6 -1
  76. package/dist/index.js.map +1 -1
  77. package/dist/version.cjs +29 -0
  78. package/dist/version.cjs.map +1 -0
  79. package/dist/version.d.cts +3 -0
  80. package/dist/version.d.ts +3 -0
  81. package/dist/version.d.ts.map +1 -0
  82. package/dist/version.js +5 -0
  83. package/dist/version.js.map +1 -0
  84. package/package.json +3 -3
  85. package/src/AgentDispatchClient.ts +5 -5
  86. package/src/ClientOptions.ts +12 -0
  87. package/src/ConnectorClient.ts +20 -11
  88. package/src/EgressClient.ts +5 -5
  89. package/src/IngressClient.ts +5 -5
  90. package/src/LiveKitAPI.ts +126 -0
  91. package/src/RoomServiceClient.ts +5 -5
  92. package/src/ServiceBase.ts +35 -4
  93. package/src/SipClient.ts +89 -24
  94. package/src/TwirpRPC.test.ts +42 -0
  95. package/src/TwirpRPC.ts +187 -39
  96. package/src/dialTimeout.test.ts +41 -0
  97. package/src/dialTimeout.ts +37 -0
  98. package/src/failover.test.ts +188 -0
  99. package/src/failover.ts +170 -0
  100. package/src/index.ts +4 -1
  101. package/src/version.ts +1 -0
@@ -0,0 +1,126 @@
1
+ // SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import { AgentDispatchClient } from './AgentDispatchClient.js';
5
+ import type { ClientOptions } from './ClientOptions.js';
6
+ import { ConnectorClient } from './ConnectorClient.js';
7
+ import { EgressClient } from './EgressClient.js';
8
+ import { IngressClient } from './IngressClient.js';
9
+ import { RoomServiceClient } from './RoomServiceClient.js';
10
+ import { SipClient } from './SipClient.js';
11
+
12
+ /** Server host and non-auth options, shared by both authentication modes. */
13
+ interface LiveKitAPICommonOptions {
14
+ /** Server host, including protocol. Falls back to the `LIVEKIT_URL` env var. */
15
+ host?: string;
16
+ /** Optional timeout, in seconds, for all server requests. */
17
+ requestTimeout?: number;
18
+ /**
19
+ * Whether to fail over to alternative regions on retryable errors (LiveKit
20
+ * Cloud hosts only). Defaults to true; set to false to disable.
21
+ */
22
+ failover?: boolean;
23
+ }
24
+
25
+ /** API key and secret authentication (recommended for backend use). */
26
+ interface ApiKeyAuth {
27
+ /** API key. Falls back to the `LIVEKIT_API_KEY` env var. */
28
+ apiKey?: string;
29
+ /** API secret. Falls back to the `LIVEKIT_API_SECRET` env var. */
30
+ secret?: string;
31
+ token?: never;
32
+ }
33
+
34
+ /** Pre-signed token authentication (client-side use; no secret required). */
35
+ interface TokenAuth {
36
+ /** Pre-signed token, sent verbatim. Falls back to the `LIVEKIT_TOKEN` env var. */
37
+ token: string;
38
+ apiKey?: never;
39
+ secret?: never;
40
+ }
41
+
42
+ /**
43
+ * Options for {@link LiveKitAPI}. Provide either an `apiKey` and `secret` or a
44
+ * pre-signed `token` — the two modes are mutually exclusive. Any omitted value
45
+ * falls back to its environment variable (`LIVEKIT_URL`, `LIVEKIT_API_KEY`,
46
+ * `LIVEKIT_API_SECRET`, `LIVEKIT_TOKEN`).
47
+ */
48
+ export type LiveKitAPIOptions = LiveKitAPICommonOptions & (ApiKeyAuth | TokenAuth);
49
+
50
+ /**
51
+ * A single entry point to every LiveKit server API, exposing each service
52
+ * through a property, e.g. `api.room.createRoom(...)`.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * const api = new LiveKitAPI({ apiKey, secret }); // or new LiveKitAPI() to read from env
57
+ * await api.room.createRoom({ name: 'my-room' });
58
+ * ```
59
+ */
60
+ export class LiveKitAPI {
61
+ private readonly _room: RoomServiceClient;
62
+
63
+ private readonly _egress: EgressClient;
64
+
65
+ private readonly _ingress: IngressClient;
66
+
67
+ private readonly _sip: SipClient;
68
+
69
+ private readonly _agentDispatch: AgentDispatchClient;
70
+
71
+ private readonly _connector: ConnectorClient;
72
+
73
+ /**
74
+ * @param options - server host, credentials, and client options; each value
75
+ * falls back to its environment variable when omitted.
76
+ */
77
+ constructor(options: LiveKitAPIOptions = {}) {
78
+ const host = options.host || process.env.LIVEKIT_URL;
79
+ if (!host) {
80
+ throw new Error('host is required (pass it or set LIVEKIT_URL)');
81
+ }
82
+ const { apiKey, secret } = options;
83
+ // Only fall back to LIVEKIT_TOKEN when no explicit credentials were given, so
84
+ // an ambient token can't silently override a passed-in api key and secret.
85
+ const token = options.token || (apiKey || secret ? undefined : process.env.LIVEKIT_TOKEN);
86
+ if (!token && !(apiKey ?? process.env.LIVEKIT_API_KEY)) {
87
+ throw new Error('either a token or an API key and secret are required');
88
+ }
89
+
90
+ const clientOptions: ClientOptions = {
91
+ requestTimeout: options.requestTimeout,
92
+ failover: options.failover,
93
+ token,
94
+ };
95
+ this._room = new RoomServiceClient(host, apiKey, secret, clientOptions);
96
+ this._egress = new EgressClient(host, apiKey, secret, clientOptions);
97
+ this._ingress = new IngressClient(host, apiKey, secret, clientOptions);
98
+ this._sip = new SipClient(host, apiKey, secret, clientOptions);
99
+ this._agentDispatch = new AgentDispatchClient(host, apiKey, secret, clientOptions);
100
+ this._connector = new ConnectorClient(host, apiKey, secret, clientOptions);
101
+ }
102
+
103
+ get room(): RoomServiceClient {
104
+ return this._room;
105
+ }
106
+
107
+ get egress(): EgressClient {
108
+ return this._egress;
109
+ }
110
+
111
+ get ingress(): IngressClient {
112
+ return this._ingress;
113
+ }
114
+
115
+ get sip(): SipClient {
116
+ return this._sip;
117
+ }
118
+
119
+ get agentDispatch(): AgentDispatchClient {
120
+ return this._agentDispatch;
121
+ }
122
+
123
+ get connector(): ConnectorClient {
124
+ return this._connector;
125
+ }
126
+ }
@@ -131,11 +131,11 @@ export class RoomServiceClient extends ServiceBase {
131
131
  * @param options - client options
132
132
  */
133
133
  constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) {
134
- super(apiKey, secret);
135
- const rpcOptions = options?.requestTimeout
136
- ? { requestTimeout: options.requestTimeout }
137
- : undefined;
138
- this.rpc = new TwirpRpc(host, livekitPackage, rpcOptions);
134
+ super({ apiKey, secret, token: options?.token });
135
+ this.rpc = new TwirpRpc(host, livekitPackage, {
136
+ requestTimeout: options?.requestTimeout,
137
+ failover: options?.failover,
138
+ });
139
139
  }
140
140
 
141
141
  /**
@@ -4,6 +4,20 @@
4
4
  import { AccessToken } from './AccessToken.js';
5
5
  import type { SIPGrant, VideoGrant } from './grants.js';
6
6
 
7
+ /**
8
+ * Authentication options for a service client.
9
+ */
10
+ export interface ServiceBaseOptions {
11
+ /** API Key. */
12
+ apiKey?: string;
13
+ /** API Secret. */
14
+ secret?: string;
15
+ /** Token TTL. Defaults to `10m`. */
16
+ ttl?: string;
17
+ /** Pre-signed token; sent verbatim, skipping per-call signing. */
18
+ token?: string;
19
+ }
20
+
7
21
  /**
8
22
  * Utilities to handle authentication
9
23
  */
@@ -12,20 +26,37 @@ export class ServiceBase {
12
26
 
13
27
  private readonly secret?: string;
14
28
 
29
+ private readonly token?: string;
30
+
15
31
  private readonly ttl: string;
16
32
 
17
33
  /**
34
+ * @param options - authentication options
35
+ */
36
+ constructor(options?: ServiceBaseOptions);
37
+ /**
38
+ * @deprecated pass a {@link ServiceBaseOptions} object instead.
18
39
  * @param apiKey - API Key.
19
40
  * @param secret - API Secret.
20
41
  * @param ttl - token TTL
21
42
  */
22
- constructor(apiKey?: string, secret?: string, ttl?: string) {
23
- this.apiKey = apiKey;
24
- this.secret = secret;
25
- this.ttl = ttl || '10m';
43
+ constructor(apiKey?: string, secret?: string, ttl?: string);
44
+ constructor(apiKeyOrOptions?: string | ServiceBaseOptions, secret?: string, ttl?: string) {
45
+ const options: ServiceBaseOptions =
46
+ typeof apiKeyOrOptions === 'object'
47
+ ? apiKeyOrOptions
48
+ : { apiKey: apiKeyOrOptions, secret, ttl };
49
+ this.apiKey = options.apiKey;
50
+ this.secret = options.secret;
51
+ this.ttl = options.ttl || '10m';
52
+ this.token = options.token;
26
53
  }
27
54
 
28
55
  async authHeader(grant: VideoGrant, sip?: SIPGrant): Promise<Record<string, string>> {
56
+ // A pre-signed token is sent verbatim; the caller is responsible for its grants.
57
+ if (this.token) {
58
+ return { Authorization: `Bearer ${this.token}` };
59
+ }
29
60
  const at = new AccessToken(this.apiKey, this.secret, { ttl: this.ttl });
30
61
  if (grant) {
31
62
  at.addGrant(grant);
package/src/SipClient.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  Pagination,
8
8
  RoomConfiguration,
9
9
  SIPHeaderOptions,
10
+ SIPMediaConfig,
10
11
  SIPMediaEncryption,
11
12
  SIPOutboundConfig,
12
13
  } from '@livekit/protocol';
@@ -27,6 +28,7 @@ import {
27
28
  ListSIPTrunkRequest,
28
29
  ListSIPTrunkResponse,
29
30
  SIPDispatchRule,
31
+ SIPDispatchRuleCallee,
30
32
  SIPDispatchRuleDirect,
31
33
  SIPDispatchRuleIndividual,
32
34
  SIPDispatchRuleInfo,
@@ -43,7 +45,17 @@ import {
43
45
  import type { ClientOptions } from './ClientOptions.js';
44
46
  import { ServiceBase } from './ServiceBase.js';
45
47
  import type { Rpc } from './TwirpRPC.js';
46
- import { TwirpRpc, livekitPackage } from './TwirpRPC.js';
48
+ import { ServerError, SipCallError, TwirpRpc, livekitPackage } from './TwirpRPC.js';
49
+ import { DEFAULT_RINGING_TIMEOUT_SECONDS, dialRequestTimeout } from './dialTimeout.js';
50
+
51
+ // A SIP dialing failure carries a SIP status in its error metadata; surface it as
52
+ // a SipCallError so callers can branch on the SIP code.
53
+ function asSipCallError(e: unknown): unknown {
54
+ if (e instanceof ServerError && e.metadata && 'sip_status_code' in e.metadata) {
55
+ return SipCallError.fromServerError(e);
56
+ }
57
+ return e;
58
+ }
47
59
 
48
60
  const svc = 'SIP';
49
61
 
@@ -80,7 +92,9 @@ export interface CreateSipInboundTrunkOptions {
80
92
  // Map SIP response headers from INVITE to sip.h.* participant attributes automatically.
81
93
  includeHeaders?: SIPHeaderOptions;
82
94
  krispEnabled?: boolean;
95
+ /** @deprecated - use `media.encryption` instead */
83
96
  mediaEncryption?: SIPMediaEncryption;
97
+ media?: SIPMediaConfig;
84
98
  /** Maximum time for a call to ring in seconds. */
85
99
  ringingTimeout?: number;
86
100
  }
@@ -98,7 +112,9 @@ export interface CreateSipOutboundTrunkOptions {
98
112
  headersToAttributes?: { [key: string]: string };
99
113
  // Map SIP response headers from INVITE to sip.h.* participant attributes automatically.
100
114
  includeHeaders?: SIPHeaderOptions;
115
+ /** @deprecated - use `media.encryption` instead */
101
116
  mediaEncryption?: SIPMediaEncryption;
117
+ media?: SIPMediaConfig;
102
118
  }
103
119
 
104
120
  export interface SipDispatchRuleDirect {
@@ -113,6 +129,14 @@ export interface SipDispatchRuleIndividual {
113
129
  pin?: string;
114
130
  }
115
131
 
132
+ export interface SipDispatchRuleCallee {
133
+ type: 'callee';
134
+ roomPrefix: string;
135
+ pin?: string;
136
+ /** Optionally append a random suffix to the room name. */
137
+ randomize?: boolean;
138
+ }
139
+
116
140
  export interface CreateSipDispatchRuleOptions {
117
141
  name?: string;
118
142
  metadata?: string;
@@ -156,8 +180,9 @@ export interface CreateSipParticipantOptions {
156
180
  krispEnabled?: boolean;
157
181
  /** If `true`, this will wait until the call is answered before returning. */
158
182
  waitUntilAnswered?: boolean;
159
- /** Optional request timeout in seconds. default 60 seconds if waitUntilAnswered is true, otherwise 10 seconds */
183
+ /** Optional request timeout in seconds. Defaults to 30s when waitUntilAnswered is true (dialing takes time), otherwise the client default. */
160
184
  timeout?: number;
185
+ media?: SIPMediaConfig;
161
186
  }
162
187
 
163
188
  export interface ListSipDispatchRuleOptions {
@@ -194,7 +219,9 @@ export interface SipInboundTrunkUpdateOptions {
194
219
  authPassword?: string;
195
220
  name?: string;
196
221
  metadata?: string;
222
+ /** @deprecated - use `media.encryption` instead */
197
223
  mediaEncryption?: SIPMediaEncryption;
224
+ media?: SIPMediaConfig;
198
225
  }
199
226
 
200
227
  export interface SipOutboundTrunkUpdateOptions {
@@ -206,7 +233,9 @@ export interface SipOutboundTrunkUpdateOptions {
206
233
  destinationCountry?: string;
207
234
  name?: string;
208
235
  metadata?: string;
236
+ /** @deprecated - use `media.encryption` instead */
209
237
  mediaEncryption?: SIPMediaEncryption;
238
+ media?: SIPMediaConfig;
210
239
  }
211
240
 
212
241
  export interface TransferSipParticipantOptions {
@@ -214,6 +243,8 @@ export interface TransferSipParticipantOptions {
214
243
  headers?: { [key: string]: string };
215
244
  /** Maximum time for the transfer destination to answer the call, in seconds. */
216
245
  ringingTimeout?: number;
246
+ /** Optional request timeout in seconds. Defaults to 30s (dialing takes time). */
247
+ timeout?: number;
217
248
  }
218
249
 
219
250
  /**
@@ -229,11 +260,11 @@ export class SipClient extends ServiceBase {
229
260
  * @param options - client options
230
261
  */
231
262
  constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) {
232
- super(apiKey, secret);
233
- const rpcOptions = options?.requestTimeout
234
- ? { requestTimeout: options.requestTimeout }
235
- : undefined;
236
- this.rpc = new TwirpRpc(host, livekitPackage, rpcOptions);
263
+ super({ apiKey, secret, token: options?.token });
264
+ this.rpc = new TwirpRpc(host, livekitPackage, {
265
+ requestTimeout: options?.requestTimeout,
266
+ failover: options?.failover,
267
+ });
237
268
  }
238
269
 
239
270
  /**
@@ -316,6 +347,7 @@ export class SipClient extends ServiceBase {
316
347
  includeHeaders: opts.includeHeaders,
317
348
  krispEnabled: opts.krispEnabled,
318
349
  mediaEncryption: opts.mediaEncryption,
350
+ media: opts.media,
319
351
  ringingTimeout: opts.ringingTimeout
320
352
  ? new Duration({ seconds: BigInt(opts.ringingTimeout) })
321
353
  : undefined,
@@ -366,6 +398,7 @@ export class SipClient extends ServiceBase {
366
398
  includeHeaders: opts.includeHeaders,
367
399
  destinationCountry: opts.destinationCountry,
368
400
  mediaEncryption: opts.mediaEncryption,
401
+ media: opts.media,
369
402
  }),
370
403
  }).toJson();
371
404
 
@@ -450,7 +483,7 @@ export class SipClient extends ServiceBase {
450
483
  * @returns Created SIP dispatch rule
451
484
  */
452
485
  async createSipDispatchRule(
453
- rule: SipDispatchRuleDirect | SipDispatchRuleIndividual,
486
+ rule: SipDispatchRuleDirect | SipDispatchRuleIndividual | SipDispatchRuleCallee,
454
487
  opts?: CreateSipDispatchRuleOptions,
455
488
  ): Promise<SIPDispatchRuleInfo> {
456
489
  if (opts === undefined) {
@@ -477,6 +510,17 @@ export class SipClient extends ServiceBase {
477
510
  }),
478
511
  },
479
512
  });
513
+ } else if (rule.type == 'callee') {
514
+ ruleProto = new SIPDispatchRule({
515
+ rule: {
516
+ case: 'dispatchRuleCallee',
517
+ value: new SIPDispatchRuleCallee({
518
+ roomPrefix: rule.roomPrefix,
519
+ pin: rule.pin || '',
520
+ randomize: rule.randomize || false,
521
+ }),
522
+ },
523
+ });
480
524
  }
481
525
 
482
526
  const req = new CreateSIPDispatchRuleRequest({
@@ -732,8 +776,13 @@ export class SipClient extends ServiceBase {
732
776
  opts = {};
733
777
  }
734
778
 
735
- if (opts.timeout === undefined) {
736
- opts.timeout = opts.waitUntilAnswered ? 60 : 10;
779
+ // Dialing a phone and waiting for an answer takes longer than a normal call,
780
+ // and the request must outlast ringing so the call can be answered. Pin the
781
+ // ring window explicitly so our request timeout doesn't depend on the server's
782
+ // default (which could change out from under us).
783
+ if (opts.waitUntilAnswered) {
784
+ opts.ringingTimeout ??= DEFAULT_RINGING_TIMEOUT_SECONDS;
785
+ opts.timeout = dialRequestTimeout(opts.timeout, opts.ringingTimeout);
737
786
  }
738
787
 
739
788
  const req = new CreateSIPParticipantRequest({
@@ -760,16 +809,21 @@ export class SipClient extends ServiceBase {
760
809
  : undefined,
761
810
  krispEnabled: opts.krispEnabled,
762
811
  waitUntilAnswered: opts.waitUntilAnswered,
812
+ media: opts.media,
763
813
  }).toJson();
764
814
 
765
- const data = await this.rpc.request(
766
- svc,
767
- 'CreateSIPParticipant',
768
- req,
769
- await this.authHeader({}, { call: true }),
770
- opts.timeout,
771
- );
772
- return SIPParticipantInfo.fromJson(data, { ignoreUnknownFields: true });
815
+ try {
816
+ const data = await this.rpc.request(
817
+ svc,
818
+ 'CreateSIPParticipant',
819
+ req,
820
+ await this.authHeader({}, { call: true }),
821
+ opts.timeout,
822
+ );
823
+ return SIPParticipantInfo.fromJson(data, { ignoreUnknownFields: true });
824
+ } catch (e) {
825
+ throw asSipCallError(e);
826
+ }
773
827
  }
774
828
 
775
829
  /**
@@ -790,6 +844,12 @@ export class SipClient extends ServiceBase {
790
844
  opts = {};
791
845
  }
792
846
 
847
+ // Transferring a call dials a phone, which takes longer than a normal call,
848
+ // so keep the request alive past ringing. Pin the ring window explicitly so
849
+ // our request timeout doesn't depend on the server's default.
850
+ opts.ringingTimeout ??= DEFAULT_RINGING_TIMEOUT_SECONDS;
851
+ opts.timeout = dialRequestTimeout(opts.timeout, opts.ringingTimeout);
852
+
793
853
  const req = new TransferSIPParticipantRequest({
794
854
  participantIdentity: participantIdentity,
795
855
  roomName: roomName,
@@ -801,11 +861,16 @@ export class SipClient extends ServiceBase {
801
861
  : undefined,
802
862
  }).toJson();
803
863
 
804
- await this.rpc.request(
805
- svc,
806
- 'TransferSIPParticipant',
807
- req,
808
- await this.authHeader({ roomAdmin: true, room: roomName }, { call: true }),
809
- );
864
+ try {
865
+ await this.rpc.request(
866
+ svc,
867
+ 'TransferSIPParticipant',
868
+ req,
869
+ await this.authHeader({ roomAdmin: true, room: roomName }, { call: true }),
870
+ opts.timeout,
871
+ );
872
+ } catch (e) {
873
+ throw asSipCallError(e);
874
+ }
810
875
  }
811
876
  }
@@ -0,0 +1,42 @@
1
+ // SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import { describe, expect, it } from 'vitest';
5
+ import { ServerError, SipCallError } from './TwirpRPC.js';
6
+
7
+ describe('SipCallError', () => {
8
+ it('renders the SIP status, Twirp code, and extra metadata', () => {
9
+ const err = SipCallError.fromServerError(
10
+ new ServerError(
11
+ 'Too Many Requests',
12
+ 'twirp error: sip status 486',
13
+ 429,
14
+ 'resource_exhausted',
15
+ {
16
+ sip_status_code: '486',
17
+ sip_status: 'Busy Here',
18
+ error_details: 'CAgS...base64...',
19
+ region: 'us-east',
20
+ },
21
+ ),
22
+ );
23
+
24
+ expect(err).toBeInstanceOf(ServerError);
25
+ expect(err.name).toBe('SipCallError');
26
+ expect(err.sipStatusCode).toBe(486);
27
+ expect(err.sipStatus).toBe('Busy Here');
28
+
29
+ const printed = err.toString();
30
+ expect(printed).toContain('SipCallError');
31
+ expect(printed).toContain('486');
32
+ expect(printed).toContain('Busy Here');
33
+ expect(printed).toContain('resource_exhausted');
34
+ expect(printed).toContain('region=us-east'); // other metadata is surfaced
35
+ expect(printed).not.toContain('error_details'); // opaque blob is omitted
36
+ });
37
+
38
+ it('falls back to the original message when there is no SIP status', () => {
39
+ const err = SipCallError.fromServerError(new ServerError('Internal', 'boom', 500, 'internal'));
40
+ expect(err.message).toBe('boom');
41
+ });
42
+ });