livekit-server-sdk 2.16.0 → 2.18.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 (105) hide show
  1. package/README.md +82 -15
  2. package/dist/AgentDispatchClient.cjs +5 -3
  3. package/dist/AgentDispatchClient.cjs.map +1 -1
  4. package/dist/AgentDispatchClient.js +5 -3
  5. package/dist/AgentDispatchClient.js.map +1 -1
  6. package/dist/ClientOptions.cjs.map +1 -1
  7. package/dist/ClientOptions.d.cts +12 -0
  8. package/dist/ClientOptions.d.ts +12 -0
  9. package/dist/ClientOptions.d.ts.map +1 -1
  10. package/dist/ConnectorClient.cjs +9 -5
  11. package/dist/ConnectorClient.cjs.map +1 -1
  12. package/dist/ConnectorClient.d.cts +6 -3
  13. package/dist/ConnectorClient.d.ts +6 -3
  14. package/dist/ConnectorClient.d.ts.map +1 -1
  15. package/dist/ConnectorClient.js +9 -5
  16. package/dist/ConnectorClient.js.map +1 -1
  17. package/dist/EgressClient.cjs +19 -3
  18. package/dist/EgressClient.cjs.map +1 -1
  19. package/dist/EgressClient.d.cts +7 -1
  20. package/dist/EgressClient.d.ts +7 -1
  21. package/dist/EgressClient.d.ts.map +1 -1
  22. package/dist/EgressClient.js +20 -3
  23. package/dist/EgressClient.js.map +1 -1
  24. package/dist/IngressClient.cjs +5 -3
  25. package/dist/IngressClient.cjs.map +1 -1
  26. package/dist/IngressClient.js +5 -3
  27. package/dist/IngressClient.js.map +1 -1
  28. package/dist/LiveKitAPI.cjs +80 -0
  29. package/dist/LiveKitAPI.cjs.map +1 -0
  30. package/dist/LiveKitAPI.d.cts +77 -0
  31. package/dist/LiveKitAPI.d.ts +77 -0
  32. package/dist/LiveKitAPI.d.ts.map +1 -0
  33. package/dist/LiveKitAPI.js +56 -0
  34. package/dist/LiveKitAPI.js.map +1 -0
  35. package/dist/RoomServiceClient.cjs +5 -3
  36. package/dist/RoomServiceClient.cjs.map +1 -1
  37. package/dist/RoomServiceClient.js +5 -3
  38. package/dist/RoomServiceClient.js.map +1 -1
  39. package/dist/ServiceBase.cjs +9 -9
  40. package/dist/ServiceBase.cjs.map +1 -1
  41. package/dist/ServiceBase.d.cts +20 -1
  42. package/dist/ServiceBase.d.ts +20 -1
  43. package/dist/ServiceBase.d.ts.map +1 -1
  44. package/dist/ServiceBase.js +9 -9
  45. package/dist/ServiceBase.js.map +1 -1
  46. package/dist/SipClient.cjs +40 -19
  47. package/dist/SipClient.cjs.map +1 -1
  48. package/dist/SipClient.d.cts +4 -2
  49. package/dist/SipClient.d.ts +4 -2
  50. package/dist/SipClient.d.ts.map +1 -1
  51. package/dist/SipClient.js +41 -20
  52. package/dist/SipClient.js.map +1 -1
  53. package/dist/TwirpRPC.cjs +135 -48
  54. package/dist/TwirpRPC.cjs.map +1 -1
  55. package/dist/TwirpRPC.d.cts +38 -2
  56. package/dist/TwirpRPC.d.ts +38 -2
  57. package/dist/TwirpRPC.d.ts.map +1 -1
  58. package/dist/TwirpRPC.js +140 -38
  59. package/dist/TwirpRPC.js.map +1 -1
  60. package/dist/dialTimeout.cjs +39 -0
  61. package/dist/dialTimeout.cjs.map +1 -0
  62. package/dist/dialTimeout.d.cts +21 -0
  63. package/dist/dialTimeout.d.ts +21 -0
  64. package/dist/dialTimeout.d.ts.map +1 -0
  65. package/dist/dialTimeout.js +13 -0
  66. package/dist/dialTimeout.js.map +1 -0
  67. package/dist/failover.cjs +139 -0
  68. package/dist/failover.cjs.map +1 -0
  69. package/dist/failover.d.cts +30 -0
  70. package/dist/failover.d.ts +30 -0
  71. package/dist/failover.d.ts.map +1 -0
  72. package/dist/failover.js +107 -0
  73. package/dist/failover.js.map +1 -0
  74. package/dist/index.cjs +22 -0
  75. package/dist/index.cjs.map +1 -1
  76. package/dist/index.d.cts +4 -3
  77. package/dist/index.d.ts +4 -3
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +20 -1
  80. package/dist/index.js.map +1 -1
  81. package/dist/version.cjs +29 -0
  82. package/dist/version.cjs.map +1 -0
  83. package/dist/version.d.cts +3 -0
  84. package/dist/version.d.ts +3 -0
  85. package/dist/version.d.ts.map +1 -0
  86. package/dist/version.js +5 -0
  87. package/dist/version.js.map +1 -0
  88. package/package.json +3 -3
  89. package/src/AgentDispatchClient.ts +5 -5
  90. package/src/ClientOptions.ts +12 -0
  91. package/src/ConnectorClient.ts +20 -11
  92. package/src/EgressClient.ts +21 -5
  93. package/src/IngressClient.ts +5 -5
  94. package/src/LiveKitAPI.ts +126 -0
  95. package/src/RoomServiceClient.ts +5 -5
  96. package/src/ServiceBase.ts +35 -4
  97. package/src/SipClient.ts +55 -23
  98. package/src/TwirpRPC.test.ts +42 -0
  99. package/src/TwirpRPC.ts +187 -39
  100. package/src/dialTimeout.test.ts +41 -0
  101. package/src/dialTimeout.ts +37 -0
  102. package/src/failover.test.ts +188 -0
  103. package/src/failover.ts +170 -0
  104. package/src/index.ts +11 -1
  105. package/src/version.ts +1 -0
@@ -18,6 +18,7 @@ import {
18
18
  ListEgressResponse,
19
19
  ParticipantEgressRequest,
20
20
  RoomCompositeEgressRequest,
21
+ StartEgressRequest,
21
22
  StopEgressRequest,
22
23
  TrackCompositeEgressRequest,
23
24
  TrackEgressRequest,
@@ -141,11 +142,11 @@ export class EgressClient extends ServiceBase {
141
142
  * @param options - client options
142
143
  */
143
144
  constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) {
144
- super(apiKey, secret);
145
- const rpcOptions = options?.requestTimeout
146
- ? { requestTimeout: options.requestTimeout }
147
- : undefined;
148
- this.rpc = new TwirpRpc(host, livekitPackage, rpcOptions);
145
+ super({ apiKey, secret, token: options?.token });
146
+ this.rpc = new TwirpRpc(host, livekitPackage, {
147
+ requestTimeout: options?.requestTimeout,
148
+ failover: options?.failover,
149
+ });
149
150
  }
150
151
 
151
152
  /**
@@ -580,6 +581,21 @@ export class EgressClient extends ServiceBase {
580
581
  return EgressInfo.fromJson(data, { ignoreUnknownFields: true });
581
582
  }
582
583
 
584
+ /**
585
+ * Starts an egress using the unified v2 request.
586
+ *
587
+ * @param request - the egress request
588
+ */
589
+ async startEgress(request: StartEgressRequest): Promise<EgressInfo> {
590
+ const data = await this.rpc.request(
591
+ svc,
592
+ 'StartEgress',
593
+ request.toJson(),
594
+ await this.authHeader({ roomRecord: true }),
595
+ );
596
+ return EgressInfo.fromJson(data, { ignoreUnknownFields: true });
597
+ }
598
+
583
599
  /**
584
600
  * @param egressId -
585
601
  * @param layout -
@@ -128,11 +128,11 @@ export class IngressClient extends ServiceBase {
128
128
  * @param options - client options
129
129
  */
130
130
  constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) {
131
- super(apiKey, secret);
132
- const rpcOptions = options?.requestTimeout
133
- ? { requestTimeout: options.requestTimeout }
134
- : undefined;
135
- this.rpc = new TwirpRpc(host, livekitPackage, rpcOptions);
131
+ super({ apiKey, secret, token: options?.token });
132
+ this.rpc = new TwirpRpc(host, livekitPackage, {
133
+ requestTimeout: options?.requestTimeout,
134
+ failover: options?.failover,
135
+ });
136
136
  }
137
137
 
138
138
  /**
@@ -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
@@ -45,7 +45,17 @@ import {
45
45
  import type { ClientOptions } from './ClientOptions.js';
46
46
  import { ServiceBase } from './ServiceBase.js';
47
47
  import type { Rpc } from './TwirpRPC.js';
48
- 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
+ }
49
59
 
50
60
  const svc = 'SIP';
51
61
 
@@ -170,7 +180,7 @@ export interface CreateSipParticipantOptions {
170
180
  krispEnabled?: boolean;
171
181
  /** If `true`, this will wait until the call is answered before returning. */
172
182
  waitUntilAnswered?: boolean;
173
- /** 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. */
174
184
  timeout?: number;
175
185
  media?: SIPMediaConfig;
176
186
  }
@@ -233,6 +243,8 @@ export interface TransferSipParticipantOptions {
233
243
  headers?: { [key: string]: string };
234
244
  /** Maximum time for the transfer destination to answer the call, in seconds. */
235
245
  ringingTimeout?: number;
246
+ /** Optional request timeout in seconds. Defaults to 30s (dialing takes time). */
247
+ timeout?: number;
236
248
  }
237
249
 
238
250
  /**
@@ -248,11 +260,11 @@ export class SipClient extends ServiceBase {
248
260
  * @param options - client options
249
261
  */
250
262
  constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) {
251
- super(apiKey, secret);
252
- const rpcOptions = options?.requestTimeout
253
- ? { requestTimeout: options.requestTimeout }
254
- : undefined;
255
- 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
+ });
256
268
  }
257
269
 
258
270
  /**
@@ -764,8 +776,13 @@ export class SipClient extends ServiceBase {
764
776
  opts = {};
765
777
  }
766
778
 
767
- if (opts.timeout === undefined) {
768
- 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);
769
786
  }
770
787
 
771
788
  const req = new CreateSIPParticipantRequest({
@@ -795,14 +812,18 @@ export class SipClient extends ServiceBase {
795
812
  media: opts.media,
796
813
  }).toJson();
797
814
 
798
- const data = await this.rpc.request(
799
- svc,
800
- 'CreateSIPParticipant',
801
- req,
802
- await this.authHeader({}, { call: true }),
803
- opts.timeout,
804
- );
805
- 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
+ }
806
827
  }
807
828
 
808
829
  /**
@@ -823,6 +844,12 @@ export class SipClient extends ServiceBase {
823
844
  opts = {};
824
845
  }
825
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
+
826
853
  const req = new TransferSIPParticipantRequest({
827
854
  participantIdentity: participantIdentity,
828
855
  roomName: roomName,
@@ -834,11 +861,16 @@ export class SipClient extends ServiceBase {
834
861
  : undefined,
835
862
  }).toJson();
836
863
 
837
- await this.rpc.request(
838
- svc,
839
- 'TransferSIPParticipant',
840
- req,
841
- await this.authHeader({ roomAdmin: true, room: roomName }, { call: true }),
842
- );
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
+ }
843
875
  }
844
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
+ });