@livekit/agents-plugin-anam 0.0.0-next-20260624041820
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.
- package/LICENSE +201 -0
- package/README.md +17 -0
- package/dist/api.cjs +165 -0
- package/dist/api.cjs.map +1 -0
- package/dist/api.d.cts +26 -0
- package/dist/api.d.ts +26 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +143 -0
- package/dist/api.js.map +1 -0
- package/dist/avatar.cjs +117 -0
- package/dist/avatar.cjs.map +1 -0
- package/dist/avatar.d.cts +34 -0
- package/dist/avatar.d.ts +34 -0
- package/dist/avatar.d.ts.map +1 -0
- package/dist/avatar.js +94 -0
- package/dist/avatar.js.map +1 -0
- package/dist/avatar.test.cjs +23 -0
- package/dist/avatar.test.cjs.map +1 -0
- package/dist/avatar.test.d.cts +2 -0
- package/dist/avatar.test.d.ts +2 -0
- package/dist/avatar.test.d.ts.map +1 -0
- package/dist/avatar.test.js +22 -0
- package/dist/avatar.test.js.map +1 -0
- package/dist/index.cjs +38 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/types.cjs +30 -0
- package/dist/types.cjs.map +1 -0
- package/dist/types.d.cts +28 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/package.json +54 -0
- package/src/api.ts +187 -0
- package/src/avatar.test.ts +30 -0
- package/src/avatar.ts +138 -0
- package/src/index.ts +19 -0
- package/src/types.ts +32 -0
package/src/api.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025 LiveKit, Inc.
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
import { log } from '@livekit/agents';
|
|
5
|
+
import {
|
|
6
|
+
type APIConnectOptions,
|
|
7
|
+
AnamException,
|
|
8
|
+
type PersonaConfig,
|
|
9
|
+
type SessionOptions,
|
|
10
|
+
} from './types.js';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_API_URL = 'https://api.anam.ai';
|
|
13
|
+
|
|
14
|
+
/** @public */
|
|
15
|
+
export class AnamAPI {
|
|
16
|
+
constructor(
|
|
17
|
+
private apiKey: string,
|
|
18
|
+
private apiUrl: string = DEFAULT_API_URL,
|
|
19
|
+
private conn: APIConnectOptions = { maxRetry: 3, retryInterval: 2, timeout: 10 },
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
private get tokenPath(): string {
|
|
23
|
+
return '/v1/auth/session-token';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private get startPath(): string {
|
|
27
|
+
return '/v1/engine/session';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private async postWithHeaders<T>(
|
|
31
|
+
path: string,
|
|
32
|
+
body: unknown,
|
|
33
|
+
headersIn: Record<string, string>,
|
|
34
|
+
): Promise<T> {
|
|
35
|
+
const url = `${this.apiUrl}${path}`;
|
|
36
|
+
const { maxRetry = 3, retryInterval = 2 } = this.conn;
|
|
37
|
+
let lastErr: unknown;
|
|
38
|
+
const logger = log().child({ module: 'AnamAPI' });
|
|
39
|
+
|
|
40
|
+
for (let attempt = 0; attempt < maxRetry; attempt++) {
|
|
41
|
+
try {
|
|
42
|
+
const headers: Record<string, string> = {
|
|
43
|
+
'Content-Type': 'application/json',
|
|
44
|
+
...headersIn,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const redactedHeaders: Record<string, string> = { ...headers };
|
|
48
|
+
if (redactedHeaders.Authorization) {
|
|
49
|
+
redactedHeaders.Authorization = 'Bearer ****';
|
|
50
|
+
}
|
|
51
|
+
const redactedBody = (() => {
|
|
52
|
+
if (body && typeof body === 'object') {
|
|
53
|
+
try {
|
|
54
|
+
const clone = { ...(body as Record<string, unknown>) } as Record<string, unknown>;
|
|
55
|
+
if ('livekitToken' in clone) clone.livekitToken = '****';
|
|
56
|
+
if ('sessionToken' in clone) clone.sessionToken = '****' as unknown as never;
|
|
57
|
+
return clone;
|
|
58
|
+
} catch {
|
|
59
|
+
return { note: 'unserializable body' };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return body;
|
|
63
|
+
})();
|
|
64
|
+
|
|
65
|
+
logger.debug(
|
|
66
|
+
{
|
|
67
|
+
url,
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: redactedHeaders,
|
|
70
|
+
body: redactedBody,
|
|
71
|
+
attempt: attempt + 1,
|
|
72
|
+
},
|
|
73
|
+
'calling Anam API',
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const res = await fetch(url, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers,
|
|
79
|
+
body: JSON.stringify(body),
|
|
80
|
+
// simple timeout: rely on AbortController in real impl
|
|
81
|
+
});
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
const text = await res.text();
|
|
84
|
+
logger.error(
|
|
85
|
+
{
|
|
86
|
+
url,
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: redactedHeaders,
|
|
89
|
+
body: redactedBody,
|
|
90
|
+
status: res.status,
|
|
91
|
+
response: text,
|
|
92
|
+
},
|
|
93
|
+
'Anam API request failed',
|
|
94
|
+
);
|
|
95
|
+
throw new AnamException(`Anam ${path} failed: ${res.status} ${text}`);
|
|
96
|
+
}
|
|
97
|
+
const json = (await res.json()) as T;
|
|
98
|
+
logger.debug({ url }, 'Anam API request succeeded');
|
|
99
|
+
return json;
|
|
100
|
+
} catch (e) {
|
|
101
|
+
lastErr = e;
|
|
102
|
+
if (attempt === maxRetry - 1) break;
|
|
103
|
+
logger.warn(
|
|
104
|
+
{
|
|
105
|
+
url,
|
|
106
|
+
method: 'POST',
|
|
107
|
+
body:
|
|
108
|
+
body && typeof body === 'object'
|
|
109
|
+
? { ...(body as Record<string, unknown>), livekitToken: '****' }
|
|
110
|
+
: body,
|
|
111
|
+
error: (e as Error)?.message,
|
|
112
|
+
nextRetrySec: retryInterval,
|
|
113
|
+
},
|
|
114
|
+
'Anam API error, retrying',
|
|
115
|
+
);
|
|
116
|
+
await new Promise((r) => setTimeout(r, retryInterval * 1000));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
throw lastErr instanceof Error ? lastErr : new AnamException('Anam API error');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async post<T>(path: string, body: unknown): Promise<T> {
|
|
123
|
+
return this.postWithHeaders<T>(path, body, { Authorization: `Bearer ${this.apiKey}` });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
createSessionToken(params: {
|
|
127
|
+
personaConfig: PersonaConfig;
|
|
128
|
+
livekitUrl?: string;
|
|
129
|
+
livekitToken?: string;
|
|
130
|
+
sessionOptions?: SessionOptions;
|
|
131
|
+
}) {
|
|
132
|
+
const pc = params.personaConfig;
|
|
133
|
+
// Anam's personaConfig is a `oneOf`: reference a previously created
|
|
134
|
+
// (stateful) persona by `personaId` — the "dev flow" — or configure an
|
|
135
|
+
// ephemeral persona inline with name/avatarId/llmId. The two are mutually
|
|
136
|
+
// exclusive, so when a personaId is given we must not also send the
|
|
137
|
+
// ephemeral fields.
|
|
138
|
+
const personaPayload: Record<string, unknown> = pc.personaId
|
|
139
|
+
? { personaId: pc.personaId }
|
|
140
|
+
: {
|
|
141
|
+
type: 'ephemeral',
|
|
142
|
+
name: pc.name,
|
|
143
|
+
avatarId: pc.avatarId,
|
|
144
|
+
llmId: 'CUSTOMER_CLIENT_V1',
|
|
145
|
+
// Only forward the avatar model version when set; otherwise let Anam
|
|
146
|
+
// fall back to the avatar's default model.
|
|
147
|
+
...(pc.avatarModel ? { avatarModel: pc.avatarModel } : {}),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const payload: Record<string, unknown> = {
|
|
151
|
+
personaConfig: personaPayload,
|
|
152
|
+
};
|
|
153
|
+
payload.environment = {
|
|
154
|
+
livekitUrl: params.livekitUrl,
|
|
155
|
+
livekitToken: params.livekitToken,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (
|
|
159
|
+
params.sessionOptions &&
|
|
160
|
+
(params.sessionOptions.videoWidth !== undefined ||
|
|
161
|
+
params.sessionOptions.videoHeight !== undefined)
|
|
162
|
+
) {
|
|
163
|
+
if (
|
|
164
|
+
params.sessionOptions.videoWidth === undefined ||
|
|
165
|
+
params.sessionOptions.videoHeight === undefined
|
|
166
|
+
) {
|
|
167
|
+
throw new AnamException(
|
|
168
|
+
'videoWidth and videoHeight must be set together (both or neither)',
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
payload.sessionOptions = {
|
|
172
|
+
videoWidth: params.sessionOptions.videoWidth,
|
|
173
|
+
videoHeight: params.sessionOptions.videoHeight,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return this.post<{ sessionToken: string }>(this.tokenPath, payload);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
startEngineSession(params: { sessionToken: string }) {
|
|
181
|
+
return this.postWithHeaders<{ sessionId: string }>(
|
|
182
|
+
this.startPath,
|
|
183
|
+
{},
|
|
184
|
+
{ Authorization: `Bearer ${params.sessionToken}` },
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
import { voice } from '@livekit/agents';
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { AvatarSession } from './avatar.js';
|
|
7
|
+
|
|
8
|
+
describe('Anam AvatarSession', () => {
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.restoreAllMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('calls base AvatarSession.start first', async () => {
|
|
14
|
+
const sentinel = new Error('super-start-called');
|
|
15
|
+
const superStartSpy = vi
|
|
16
|
+
.spyOn(voice.AvatarSession.prototype, 'start')
|
|
17
|
+
.mockRejectedValue(sentinel);
|
|
18
|
+
|
|
19
|
+
const avatar = new AvatarSession({
|
|
20
|
+
personaConfig: {
|
|
21
|
+
personaId: 'persona-test',
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
await expect(
|
|
26
|
+
avatar.start({ _started: false, output: { audio: null } } as any, {} as any),
|
|
27
|
+
).rejects.toThrow('super-start-called');
|
|
28
|
+
expect(superStartSpy).toHaveBeenCalledTimes(1);
|
|
29
|
+
});
|
|
30
|
+
});
|
package/src/avatar.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025 LiveKit, Inc.
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
import { log, voice } from '@livekit/agents';
|
|
5
|
+
import type { Room } from '@livekit/rtc-node';
|
|
6
|
+
import { TrackKind } from '@livekit/rtc-node';
|
|
7
|
+
import { AccessToken, type VideoGrant } from 'livekit-server-sdk';
|
|
8
|
+
import { AnamAPI } from './api.js';
|
|
9
|
+
import {
|
|
10
|
+
type APIConnectOptions,
|
|
11
|
+
AnamException,
|
|
12
|
+
type PersonaConfig,
|
|
13
|
+
type SessionOptions,
|
|
14
|
+
} from './types.js';
|
|
15
|
+
|
|
16
|
+
/** @public */
|
|
17
|
+
export async function mintAvatarJoinToken({
|
|
18
|
+
roomName,
|
|
19
|
+
avatarIdentity,
|
|
20
|
+
publishOnBehalf,
|
|
21
|
+
apiKey = process.env.LIVEKIT_API_KEY!,
|
|
22
|
+
apiSecret = process.env.LIVEKIT_API_SECRET!,
|
|
23
|
+
ttl = '60s',
|
|
24
|
+
}: {
|
|
25
|
+
roomName: string;
|
|
26
|
+
avatarIdentity: string;
|
|
27
|
+
publishOnBehalf: string;
|
|
28
|
+
apiKey?: string;
|
|
29
|
+
apiSecret?: string;
|
|
30
|
+
ttl?: string | number;
|
|
31
|
+
}): Promise<string> {
|
|
32
|
+
const at = new AccessToken(apiKey, apiSecret);
|
|
33
|
+
at.identity = avatarIdentity;
|
|
34
|
+
at.name = 'Anam Avatar';
|
|
35
|
+
at.kind = 'agent';
|
|
36
|
+
at.ttl = ttl;
|
|
37
|
+
at.attributes = { 'lk.publish_on_behalf': publishOnBehalf };
|
|
38
|
+
|
|
39
|
+
at.addGrant({ roomJoin: true, room: roomName } as VideoGrant);
|
|
40
|
+
return at.toJwt();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const AVATAR_IDENTITY = 'anam-avatar-agent';
|
|
44
|
+
const _AVATAR_NAME = 'anam-avatar-agent';
|
|
45
|
+
|
|
46
|
+
/** @public */
|
|
47
|
+
export class AvatarSession extends voice.AvatarSession {
|
|
48
|
+
private sessionId?: string;
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
private opts: {
|
|
52
|
+
personaConfig: PersonaConfig;
|
|
53
|
+
sessionOptions?: SessionOptions;
|
|
54
|
+
apiUrl?: string;
|
|
55
|
+
apiKey?: string;
|
|
56
|
+
avatarParticipantIdentity?: string;
|
|
57
|
+
avatarParticipantName?: string;
|
|
58
|
+
connOptions?: APIConnectOptions;
|
|
59
|
+
},
|
|
60
|
+
) {
|
|
61
|
+
super();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
override get avatarIdentity(): string {
|
|
65
|
+
return this.opts.avatarParticipantIdentity ?? AVATAR_IDENTITY;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
override get provider(): string {
|
|
69
|
+
return 'anam';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async start(
|
|
73
|
+
agentSession: voice.AgentSession,
|
|
74
|
+
room: Room,
|
|
75
|
+
params?: {
|
|
76
|
+
livekitUrl?: string;
|
|
77
|
+
livekitApiKey?: string;
|
|
78
|
+
livekitApiSecret?: string;
|
|
79
|
+
},
|
|
80
|
+
) {
|
|
81
|
+
await super.start(agentSession, room);
|
|
82
|
+
|
|
83
|
+
const logger = log().child({ module: 'AnamAvatar' });
|
|
84
|
+
const apiKey = this.opts.apiKey ?? process.env.ANAM_API_KEY;
|
|
85
|
+
if (!apiKey) throw new AnamException('ANAM_API_KEY is required');
|
|
86
|
+
|
|
87
|
+
const apiUrl = this.opts.apiUrl ?? process.env.ANAM_API_URL;
|
|
88
|
+
const livekitUrl = params?.livekitUrl ?? process.env.LIVEKIT_URL;
|
|
89
|
+
const lkKey = params?.livekitApiKey ?? process.env.LIVEKIT_API_KEY;
|
|
90
|
+
const lkSecret = params?.livekitApiSecret ?? process.env.LIVEKIT_API_SECRET;
|
|
91
|
+
|
|
92
|
+
if (!livekitUrl || !lkKey || !lkSecret) {
|
|
93
|
+
throw new AnamException('LIVEKIT_URL/API_KEY/API_SECRET must be set');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const localIdentity = (room.localParticipant && room.localParticipant.identity) || 'agent';
|
|
97
|
+
|
|
98
|
+
logger.debug(
|
|
99
|
+
{
|
|
100
|
+
personaName: this.opts.personaConfig?.name,
|
|
101
|
+
avatarId: this.opts.personaConfig?.avatarId,
|
|
102
|
+
personaId: this.opts.personaConfig?.personaId,
|
|
103
|
+
apiUrl: apiUrl ?? '(default https://api.anam.ai)',
|
|
104
|
+
livekitUrl,
|
|
105
|
+
avatarParticipantIdentity: this.opts.avatarParticipantIdentity ?? 'anam-avatar-agent',
|
|
106
|
+
publishOnBehalf: localIdentity,
|
|
107
|
+
},
|
|
108
|
+
'starting Anam avatar session',
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const jwt = await mintAvatarJoinToken({
|
|
112
|
+
roomName: room.name!,
|
|
113
|
+
avatarIdentity: this.avatarIdentity,
|
|
114
|
+
publishOnBehalf: localIdentity,
|
|
115
|
+
apiKey: lkKey,
|
|
116
|
+
apiSecret: lkSecret,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const anam = new AnamAPI(apiKey, apiUrl, this.opts.connOptions);
|
|
120
|
+
logger.debug({ livekitUrl }, 'requesting Anam session token');
|
|
121
|
+
|
|
122
|
+
const { sessionToken } = await anam.createSessionToken({
|
|
123
|
+
personaConfig: this.opts.personaConfig,
|
|
124
|
+
livekitUrl,
|
|
125
|
+
livekitToken: jwt,
|
|
126
|
+
sessionOptions: this.opts.sessionOptions,
|
|
127
|
+
});
|
|
128
|
+
logger.debug('starting Anam engine session');
|
|
129
|
+
const started = await anam.startEngineSession({ sessionToken });
|
|
130
|
+
this.sessionId = started.sessionId;
|
|
131
|
+
|
|
132
|
+
agentSession.output.audio = new voice.DataStreamAudioOutput({
|
|
133
|
+
room,
|
|
134
|
+
destinationIdentity: this.avatarIdentity,
|
|
135
|
+
waitRemoteTrack: TrackKind.KIND_VIDEO,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025 LiveKit, Inc.
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
import { Plugin } from '@livekit/agents';
|
|
5
|
+
|
|
6
|
+
export * from './types.js';
|
|
7
|
+
export * from './api.js';
|
|
8
|
+
export * from './avatar.js';
|
|
9
|
+
|
|
10
|
+
class AnamPlugin extends Plugin {
|
|
11
|
+
constructor() {
|
|
12
|
+
super({
|
|
13
|
+
title: 'anam',
|
|
14
|
+
version: __PACKAGE_VERSION__,
|
|
15
|
+
package: __PACKAGE_NAME__,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
Plugin.registerPlugin(new AnamPlugin());
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025 LiveKit, Inc.
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
/** @public */
|
|
5
|
+
export type PersonaConfig = {
|
|
6
|
+
/** Optional display name (prod flow) */
|
|
7
|
+
name?: string;
|
|
8
|
+
/** Optional avatar asset id (prod flow) */
|
|
9
|
+
avatarId?: string;
|
|
10
|
+
/** Optional avatar model version, e.g. "cara-3" or "cara-4-latest" (prod flow) */
|
|
11
|
+
avatarModel?: string;
|
|
12
|
+
/** Optional persona id (dev flow) */
|
|
13
|
+
personaId?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** @public */
|
|
17
|
+
export type SessionOptions = {
|
|
18
|
+
/** Output video frame width in pixels. Provide together with videoHeight. */
|
|
19
|
+
videoWidth?: number;
|
|
20
|
+
/** Output video frame height in pixels. Provide together with videoWidth. */
|
|
21
|
+
videoHeight?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** @public */
|
|
25
|
+
export type APIConnectOptions = {
|
|
26
|
+
maxRetry?: number;
|
|
27
|
+
retryInterval?: number; // seconds
|
|
28
|
+
timeout?: number; // seconds
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** @public */
|
|
32
|
+
export class AnamException extends Error {}
|