Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/livekitapi-unified-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'livekit-server-sdk': minor
---

Add `LiveKitAPI`, a unified entry point that exposes every server API through a property (`api.room`, `api.egress`, `api.ingress`, `api.sip`, `api.agentDispatch`, `api.connector`). It can be constructed with an API key and secret, or with a pre-signed token (no secret required, so it can run client-side), and falls back to the `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`, and `LIVEKIT_TOKEN` environment variables. Individual clients also accept a `token` via `ClientOptions`.
5 changes: 5 additions & 0 deletions .changeset/sip-call-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'livekit-server-sdk': minor
---

Add `SipCallError`, a `TwirpError` subclass thrown by `SipClient.createSipParticipant` / `transferSipParticipant` when a call fails with a SIP status. It exposes the SIP response code and reason as `sipStatusCode` / `sipStatus` getters, while other error metadata remains available via `metadata`.
3 changes: 3 additions & 0 deletions .github/workflows/test-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ jobs:
- name: Install dependencies
run: pnpm install

- name: Generate version file
run: pnpm --filter="livekit-server-sdk" run prebuild

- name: Wait for mock server
run: |
for i in $(seq 1 30); do
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,4 @@ packages/*/docs

# generated version file
packages/livekit-rtc/src/version.ts
packages/livekit-server-sdk/src/version.ts
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ node_modules
packages/livekit-rtc/src/napi
packages/livekit-rtc/src/proto
packages/livekit-rtc/target
packages/livekit-rtc/src/version.ts
packages/livekit-rtc/src/version.ts
packages/livekit-server-sdk/src/version.ts
1 change: 1 addition & 0 deletions packages/livekit-server-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"src"
],
"scripts": {
"prebuild": "node -p \"'export const SDK_VERSION = ' + JSON.stringify(require('./package.json').version) + ';'\" > src/version.ts",
"build": "tsup --onSuccess \"tsc -p tsconfig.json --declaration --emitDeclarationOnly --skipLibCheck\"",
"build:watch": "tsc --watch",
"build-docs": "typedoc",
Expand Down
2 changes: 1 addition & 1 deletion packages/livekit-server-sdk/src/AgentDispatchClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export class AgentDispatchClient extends ServiceBase {
* @param options - client options
*/
constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) {
super(apiKey, secret);
super({ apiKey, secret, token: options?.token });
this.rpc = new TwirpRpc(host, livekitPackage, {
requestTimeout: options?.requestTimeout,
failover: options?.failover,
Expand Down
7 changes: 7 additions & 0 deletions packages/livekit-server-sdk/src/ClientOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,11 @@ export type ClientOptions = {
* Cloud hosts only). Defaults to true; set to false to disable.
*/
failover?: boolean;
/**
* A pre-signed access token, sent verbatim as the Authorization header on
* every request instead of signing one per call from an API key and secret.
* The token must already carry the grants for the calls it's used with; since
* it needs no secret, the client can run client-side.
*/
token?: string;
};
22 changes: 7 additions & 15 deletions packages/livekit-server-sdk/src/ConnectorClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,11 @@ export interface AcceptWhatsAppCallOptions {
participantAttributes?: { [key: string]: string };
/** Optional - Country where the call terminates as ISO 3166-1 alpha-2 */
destinationCountry?: string;
/** Optional - Max time in seconds for the callee to answer the call */
ringingTimeout?: number;
/** Optional - Wait for the call to be answered before returning */
/** Optional - Wait until the inbound party joins before returning. */
waitUntilAnswered?: boolean;
/**
* Optional - Request timeout in seconds. When `waitUntilAnswered` is set,
* defaults to a longer value (dialing takes time) and is raised, if needed,
* to stay above `ringingTimeout`; otherwise the client default applies.
* Optional - Request timeout in seconds. When `waitUntilAnswered` is set it
* defaults to the standard ring window; otherwise the client default applies.
*/
timeout?: number;
}
Comment thread
davidzhao marked this conversation as resolved.
Expand Down Expand Up @@ -129,7 +126,7 @@ export class ConnectorClient extends ServiceBase {
* @param options - client options
*/
constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) {
super(apiKey, secret);
super({ apiKey, secret, token: options?.token });
this.rpc = new TwirpRpc(host, livekitPackage, {
requestTimeout: options?.requestTimeout,
failover: options?.failover,
Expand Down Expand Up @@ -207,17 +204,12 @@ export class ConnectorClient extends ServiceBase {
participantMetadata,
participantAttributes: options.participantAttributes,
destinationCountry,
ringingTimeout: options.ringingTimeout
? new Duration({ seconds: BigInt(options.ringingTimeout) })
: undefined,
waitUntilAnswered: options.waitUntilAnswered,
}).toJson();

// Accept can block until the call is answered, so default the request timeout
// to the standard ring window. The caller overrides it via `timeout` and
// should set it above the ringing_timeout passed to dialWhatsAppCall; the
// two calls are separate, so the SDK can't derive it. Non-waiting returns
// promptly and uses the client default.
// When waiting for the inbound party to join, the request can block, so
// default the timeout to the standard ring window; otherwise the client
// default applies.
const timeout = options.waitUntilAnswered
? (options.timeout ?? DEFAULT_RINGING_TIMEOUT_SECONDS)
: options.timeout;
Comment thread
davidzhao marked this conversation as resolved.
Expand Down
2 changes: 1 addition & 1 deletion packages/livekit-server-sdk/src/EgressClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export class EgressClient extends ServiceBase {
* @param options - client options
*/
constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) {
super(apiKey, secret);
super({ apiKey, secret, token: options?.token });
this.rpc = new TwirpRpc(host, livekitPackage, {
requestTimeout: options?.requestTimeout,
failover: options?.failover,
Expand Down
2 changes: 1 addition & 1 deletion packages/livekit-server-sdk/src/IngressClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export class IngressClient extends ServiceBase {
* @param options - client options
*/
constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) {
super(apiKey, secret);
super({ apiKey, secret, token: options?.token });
this.rpc = new TwirpRpc(host, livekitPackage, {
requestTimeout: options?.requestTimeout,
failover: options?.failover,
Expand Down
102 changes: 102 additions & 0 deletions packages/livekit-server-sdk/src/LiveKitAPI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
import { AgentDispatchClient } from './AgentDispatchClient.js';
import type { ClientOptions } from './ClientOptions.js';
import { ConnectorClient } from './ConnectorClient.js';
import { EgressClient } from './EgressClient.js';
import { IngressClient } from './IngressClient.js';
import { RoomServiceClient } from './RoomServiceClient.js';
import { SipClient } from './SipClient.js';

/**
* Options for {@link LiveKitAPI}. Provide either an `apiKey` and `secret`, or a
* pre-signed `token`. Any omitted value falls back to its environment variable.
*/
export interface LiveKitAPIOptions extends ClientOptions {
/** API key. Falls back to the `LIVEKIT_API_KEY` env var. */
apiKey?: string;
/** API secret. Falls back to the `LIVEKIT_API_SECRET` env var. */
secret?: string;
/** Pre-signed token, sent verbatim. Falls back to the `LIVEKIT_TOKEN` env var. */
token?: string;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a scenario in which we'd want to allow all three to be set together?
If not, it would be nice if the types reflect a "either keys or token" approach

}

/**
* A single entry point to every LiveKit server API, exposing each service
* through a property, e.g. `api.room.createRoom(...)`.
*
* @example
* ```ts
* const api = new LiveKitAPI('https://my.livekit.cloud', { apiKey, secret });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: if all of host, key and secret can be set via env vars, I wonder why the API here pulls out the host to be its own parameter? (those three always come together in a bundle in our docs/sdks)

* await api.room.createRoom({ name: 'my-room' });
* ```
*/
export class LiveKitAPI {
readonly #room: RoomServiceClient;

readonly #egress: EgressClient;

readonly #ingress: IngressClient;

readonly #sip: SipClient;

readonly #agentDispatch: AgentDispatchClient;

readonly #connector: ConnectorClient;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the livekit-server-sdk doesn't use # as private fields. They also have a couple of downsides. Would prefer these to be regular private fields


/**
* @param host - server host, including protocol. Falls back to the
* `LIVEKIT_URL` env var.
* @param options - credentials and client options
*/
constructor(host?: string, options: LiveKitAPIOptions = {}) {
host = host || process.env.LIVEKIT_URL;
if (!host) {
throw new Error('host is required (pass it or set LIVEKIT_URL)');
}
const { apiKey, secret } = options;
// Only fall back to LIVEKIT_TOKEN when no explicit credentials were given, so
// an ambient token can't silently override a passed-in api key and secret.
const token = options.token || (apiKey || secret ? undefined : process.env.LIVEKIT_TOKEN);
if (!token && !(apiKey ?? process.env.LIVEKIT_API_KEY)) {
throw new Error('either a token or an API key and secret are required');
}

const clientOptions: ClientOptions = {
requestTimeout: options.requestTimeout,
failover: options.failover,
token,
};
this.#room = new RoomServiceClient(host, apiKey, secret, clientOptions);
this.#egress = new EgressClient(host, apiKey, secret, clientOptions);
this.#ingress = new IngressClient(host, apiKey, secret, clientOptions);
this.#sip = new SipClient(host, apiKey, secret, clientOptions);
this.#agentDispatch = new AgentDispatchClient(host, apiKey, secret, clientOptions);
this.#connector = new ConnectorClient(host, apiKey, secret, clientOptions);
}

get room(): RoomServiceClient {
return this.#room;
}

get egress(): EgressClient {
return this.#egress;
}

get ingress(): IngressClient {
return this.#ingress;
}

get sip(): SipClient {
return this.#sip;
}

get agentDispatch(): AgentDispatchClient {
return this.#agentDispatch;
}

get connector(): ConnectorClient {
return this.#connector;
}
}
2 changes: 1 addition & 1 deletion packages/livekit-server-sdk/src/RoomServiceClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export class RoomServiceClient extends ServiceBase {
* @param options - client options
*/
constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) {
super(apiKey, secret);
super({ apiKey, secret, token: options?.token });
this.rpc = new TwirpRpc(host, livekitPackage, {
requestTimeout: options?.requestTimeout,
failover: options?.failover,
Expand Down
39 changes: 35 additions & 4 deletions packages/livekit-server-sdk/src/ServiceBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@
import { AccessToken } from './AccessToken.js';
import type { SIPGrant, VideoGrant } from './grants.js';

/**
* Authentication options for a service client.
*/
export interface ServiceBaseOptions {
/** API Key. */
apiKey?: string;
/** API Secret. */
secret?: string;
/** Token TTL. Defaults to `10m`. */
ttl?: string;
/** Pre-signed token; sent verbatim, skipping per-call signing. */
token?: string;
}

/**
* Utilities to handle authentication
*/
Expand All @@ -12,20 +26,37 @@ export class ServiceBase {

private readonly secret?: string;

private readonly token?: string;

private readonly ttl: string;

/**
* @param options - authentication options
*/
constructor(options?: ServiceBaseOptions);
/**
* @deprecated pass a {@link ServiceBaseOptions} object instead.
* @param apiKey - API Key.
* @param secret - API Secret.
* @param ttl - token TTL
*/
constructor(apiKey?: string, secret?: string, ttl?: string) {
this.apiKey = apiKey;
this.secret = secret;
this.ttl = ttl || '10m';
constructor(apiKey?: string, secret?: string, ttl?: string);
constructor(apiKeyOrOptions?: string | ServiceBaseOptions, secret?: string, ttl?: string) {
const options: ServiceBaseOptions =
typeof apiKeyOrOptions === 'object'
? apiKeyOrOptions
: { apiKey: apiKeyOrOptions, secret, ttl };
this.apiKey = options.apiKey;
this.secret = options.secret;
this.ttl = options.ttl || '10m';
this.token = options.token;
}

async authHeader(grant: VideoGrant, sip?: SIPGrant): Promise<Record<string, string>> {
// A pre-signed token is sent verbatim; the caller is responsible for its grants.
if (this.token) {
return { Authorization: `Bearer ${this.token}` };
}
Comment on lines 55 to +59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: It might be nice to expose here some sort of event the caller can use to determine if the token is expired or not. And if it is expired, then give the caller the ability to potentially regenerate it before actually making the API request.

This sets the stage well for a future TokenSource type integration (once that is officially added to node) nicely.

const at = new AccessToken(this.apiKey, this.secret, { ttl: this.ttl });
if (grant) {
at.addGrant(grant);
Expand Down
51 changes: 34 additions & 17 deletions packages/livekit-server-sdk/src/SipClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,18 @@ import {
import type { ClientOptions } from './ClientOptions.js';
import { ServiceBase } from './ServiceBase.js';
import type { Rpc } from './TwirpRPC.js';
import { TwirpRpc, livekitPackage } from './TwirpRPC.js';
import { ServerError, SipCallError, TwirpRpc, livekitPackage } from './TwirpRPC.js';
import { DEFAULT_RINGING_TIMEOUT_SECONDS, dialRequestTimeout } from './dialTimeout.js';

// A SIP dialing failure carries a SIP status in its error metadata; surface it as
// a SipCallError so callers can branch on the SIP code.
function asSipCallError(e: unknown): unknown {
if (e instanceof ServerError && e.metadata && 'sip_status_code' in e.metadata) {
return SipCallError.fromServerError(e);
}
return e;
}

const svc = 'SIP';

/**
Expand Down Expand Up @@ -251,7 +260,7 @@ export class SipClient extends ServiceBase {
* @param options - client options
*/
constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) {
super(apiKey, secret);
super({ apiKey, secret, token: options?.token });
this.rpc = new TwirpRpc(host, livekitPackage, {
requestTimeout: options?.requestTimeout,
failover: options?.failover,
Expand Down Expand Up @@ -803,14 +812,18 @@ export class SipClient extends ServiceBase {
media: opts.media,
}).toJson();

const data = await this.rpc.request(
svc,
'CreateSIPParticipant',
req,
await this.authHeader({}, { call: true }),
opts.timeout,
);
return SIPParticipantInfo.fromJson(data, { ignoreUnknownFields: true });
try {
const data = await this.rpc.request(
svc,
'CreateSIPParticipant',
req,
await this.authHeader({}, { call: true }),
opts.timeout,
);
return SIPParticipantInfo.fromJson(data, { ignoreUnknownFields: true });
} catch (e) {
throw asSipCallError(e);
}
}

/**
Expand Down Expand Up @@ -848,12 +861,16 @@ export class SipClient extends ServiceBase {
: undefined,
}).toJson();

await this.rpc.request(
svc,
'TransferSIPParticipant',
req,
await this.authHeader({ roomAdmin: true, room: roomName }, { call: true }),
opts.timeout,
);
try {
await this.rpc.request(
svc,
'TransferSIPParticipant',
req,
await this.authHeader({ roomAdmin: true, room: roomName }, { call: true }),
opts.timeout,
);
} catch (e) {
throw asSipCallError(e);
}
}
}
Loading
Loading