@livekit/agents-plugin-anthropic 1.0.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.
package/src/llm.ts ADDED
@@ -0,0 +1,447 @@
1
+ // SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import Anthropic from '@anthropic-ai/sdk';
5
+ import type { APIConnectOptions } from '@livekit/agents';
6
+ import {
7
+ APIConnectionError,
8
+ APIStatusError,
9
+ APITimeoutError,
10
+ DEFAULT_API_CONNECT_OPTIONS,
11
+ llm,
12
+ } from '@livekit/agents';
13
+ import type { ChatModels } from './models.js';
14
+
15
+ /** Configuration options for the Anthropic LLM plugin. */
16
+ export interface LLMOptions {
17
+ /** The model identifier to use. */
18
+ model: string | ChatModels;
19
+ /** Anthropic API key. Falls back to the `ANTHROPIC_API_KEY` environment variable. */
20
+ apiKey?: string;
21
+ /** Custom base URL for the Anthropic API. */
22
+ baseURL?: string;
23
+ /** Sampling temperature. */
24
+ temperature?: number;
25
+ /** Pre-configured Anthropic client instance. */
26
+ client?: Anthropic;
27
+ /** Tool selection strategy. */
28
+ toolChoice?: llm.ToolChoice;
29
+ /** Whether to allow parallel tool calls. */
30
+ parallelToolCalls?: boolean;
31
+ /** Maximum number of tokens in the response. Defaults to 4096. */
32
+ maxTokens?: number;
33
+ }
34
+
35
+ const defaultLLMOptions: LLMOptions = {
36
+ model: 'claude-sonnet-4-6',
37
+ /* eslint-disable-next-line turbo/no-undeclared-env-vars */
38
+ apiKey: process.env.ANTHROPIC_API_KEY,
39
+ parallelToolCalls: true,
40
+ };
41
+
42
+ /**
43
+ * Anthropic LLM provider for LiveKit Agents.
44
+ *
45
+ * @remarks
46
+ * Implements the {@link llm.LLM} interface using the Anthropic Messages API.
47
+ * Supports streaming, tool calling, and system prompt isolation required by
48
+ * Claude 3.5+ models.
49
+ */
50
+ export class LLM extends llm.LLM {
51
+ #opts: LLMOptions;
52
+ #client: Anthropic;
53
+
54
+ constructor(opts: Partial<LLMOptions> = defaultLLMOptions) {
55
+ super();
56
+
57
+ this.#opts = { ...defaultLLMOptions, ...opts };
58
+ if (!this.#opts.apiKey && !this.#opts.client) {
59
+ throw new Error(
60
+ 'Anthropic API key is required, whether as an argument or as $ANTHROPIC_API_KEY',
61
+ );
62
+ }
63
+
64
+ this.#client =
65
+ this.#opts.client ||
66
+ new Anthropic({
67
+ baseURL: this.#opts.baseURL,
68
+ apiKey: this.#opts.apiKey,
69
+ });
70
+ }
71
+
72
+ /** @returns Human-readable label for logging. */
73
+ label(): string {
74
+ return 'anthropic.LLM';
75
+ }
76
+
77
+ /** @returns The model identifier being used. */
78
+ get model(): string {
79
+ return this.#opts.model;
80
+ }
81
+
82
+ /** @returns The API provider host. */
83
+ get provider(): string {
84
+ try {
85
+ const url = new URL(this.#client.baseURL);
86
+ return url.host;
87
+ } catch {
88
+ return 'api.anthropic.com';
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Converts a framework ChatContext into Anthropic's message format.
94
+ *
95
+ * @remarks
96
+ * - System prompts are isolated into a separate `TextBlockParam[]` array
97
+ * (required by Claude 3.5+).
98
+ * - `function_call` items are mapped to Anthropic `tool_use` content blocks.
99
+ * - `function_call_output` items are mapped to `tool_result` content blocks.
100
+ * - Consecutive same-role messages are merged to satisfy Anthropic's
101
+ * strict alternating-turn requirement.
102
+ * - A dummy `(empty)` user message is injected if the conversation doesn't
103
+ * start with a user turn.
104
+ * - A dummy user message is appended if the conversation ends with an
105
+ * assistant turn, since Claude 4.6+ does not support prefilling.
106
+ */
107
+ protected _buildAnthropicContext(chatCtx: llm.ChatContext): {
108
+ system: Anthropic.TextBlockParam[];
109
+ messages: Anthropic.MessageParam[];
110
+ } {
111
+ const system: Anthropic.TextBlockParam[] = [];
112
+ const rawMessages: Anthropic.MessageParam[] = [];
113
+
114
+ for (const msg of chatCtx.items) {
115
+ if (msg.type === 'message') {
116
+ const textContent = msg.textContent || '';
117
+ if (msg.role === 'system' || msg.role === 'developer') {
118
+ system.push({ type: 'text', text: textContent });
119
+ } else if (msg.role === 'user' || msg.role === 'assistant') {
120
+ rawMessages.push({
121
+ role: msg.role,
122
+ content: textContent,
123
+ });
124
+ }
125
+ } else if (msg.type === 'function_call') {
126
+ // Map to Anthropic's tool_use content block (assistant role)
127
+ rawMessages.push({
128
+ role: 'assistant',
129
+ content: [
130
+ {
131
+ type: 'tool_use',
132
+ id: msg.callId,
133
+ name: msg.name,
134
+ input: JSON.parse(msg.args || '{}'),
135
+ },
136
+ ],
137
+ });
138
+ } else if (msg.type === 'function_call_output') {
139
+ // Map to Anthropic's tool_result content block (user role)
140
+ rawMessages.push({
141
+ role: 'user',
142
+ content: [
143
+ {
144
+ type: 'tool_result',
145
+ tool_use_id: msg.callId,
146
+ content: msg.output,
147
+ is_error: msg.isError,
148
+ },
149
+ ],
150
+ });
151
+ }
152
+ }
153
+
154
+ // Merge consecutive same-role messages (Anthropic requires alternating turns)
155
+ const messages: Anthropic.MessageParam[] = [];
156
+ for (const msg of rawMessages) {
157
+ const prev = messages[messages.length - 1];
158
+ if (prev && prev.role === msg.role) {
159
+ // Merge content into a single content array
160
+ const prevContent = Array.isArray(prev.content)
161
+ ? prev.content
162
+ : [{ type: 'text' as const, text: prev.content as string }];
163
+ const curContent = Array.isArray(msg.content)
164
+ ? msg.content
165
+ : [{ type: 'text' as const, text: msg.content as string }];
166
+ prev.content = [...prevContent, ...curContent] as Anthropic.ContentBlockParam[];
167
+ } else {
168
+ messages.push({ ...msg });
169
+ }
170
+ }
171
+
172
+ // Anthropic requires conversations to start with a user turn
173
+ if (messages.length === 0 || messages[0]!.role !== 'user') {
174
+ messages.unshift({ role: 'user', content: '(empty)' });
175
+ }
176
+
177
+ // Claude 4.6+ does not support prefilling (trailing assistant messages).
178
+ if (messages.length > 0 && messages[messages.length - 1]!.role === 'assistant') {
179
+ messages.push({ role: 'user', content: [{ type: 'text', text: '.' }] });
180
+ }
181
+
182
+ return { system, messages };
183
+ }
184
+
185
+ /**
186
+ * Creates a streaming chat completion.
187
+ *
188
+ * @remarks
189
+ * Maps `toolChoice` to Anthropic's format:
190
+ * - `"required"` → `{ type: "any" }`
191
+ * - `"none"` → clears tools
192
+ * - `{ type: "function", function: { name } }` → `{ type: "tool", name }`
193
+ */
194
+ chat({
195
+ chatCtx,
196
+ toolCtx: toolCtxInput,
197
+ connOptions = DEFAULT_API_CONNECT_OPTIONS,
198
+ parallelToolCalls,
199
+ toolChoice,
200
+ extraKwargs,
201
+ }: {
202
+ chatCtx: llm.ChatContext;
203
+ toolCtx?: llm.ToolContextLike;
204
+ connOptions?: APIConnectOptions;
205
+ parallelToolCalls?: boolean;
206
+ toolChoice?: llm.ToolChoice;
207
+ extraKwargs?: Record<string, unknown>;
208
+ }): LLMStream {
209
+ const extras: Record<string, unknown> = { ...extraKwargs };
210
+
211
+ if (this.#opts.temperature !== undefined) extras.temperature = this.#opts.temperature;
212
+
213
+ const { system, messages } = this._buildAnthropicContext(chatCtx);
214
+
215
+ // Build Anthropic tool schemas
216
+ const toolCtx = llm.toToolContext(toolCtxInput);
217
+ const anthropicTools: Anthropic.Tool[] = [];
218
+ if (toolCtx) {
219
+ for (const [name, tool] of llm.sortedToolEntries(toolCtx)) {
220
+ anthropicTools.push({
221
+ name: name,
222
+ description: tool.description || '',
223
+ input_schema: (tool.parameters
224
+ ? llm.toJsonSchema(tool.parameters, false)
225
+ : { type: 'object', properties: {} }) as Anthropic.Tool.InputSchema,
226
+ });
227
+ }
228
+ }
229
+
230
+ // Map toolChoice and parallelToolCalls to Anthropic format
231
+ const resolvedToolChoice = toolChoice ?? this.#opts.toolChoice;
232
+ const resolvedParallel = parallelToolCalls ?? this.#opts.parallelToolCalls;
233
+
234
+ if ((resolvedToolChoice || resolvedParallel !== undefined) && anthropicTools.length > 0) {
235
+ let anthropicToolChoice: Record<string, unknown> | undefined = { type: 'auto' };
236
+
237
+ if (typeof resolvedToolChoice === 'string') {
238
+ if (resolvedToolChoice === 'required') {
239
+ anthropicToolChoice = { type: 'any' };
240
+ } else if (resolvedToolChoice === 'none') {
241
+ // Clear tools entirely when none is requested
242
+ anthropicTools.length = 0;
243
+ anthropicToolChoice = undefined;
244
+ }
245
+ } else if (
246
+ typeof resolvedToolChoice === 'object' &&
247
+ 'type' in resolvedToolChoice &&
248
+ resolvedToolChoice.type === 'function'
249
+ ) {
250
+ const fn = (resolvedToolChoice as { function: { name: string } }).function;
251
+ anthropicToolChoice = { type: 'tool', name: fn.name };
252
+ }
253
+
254
+ if (anthropicToolChoice) {
255
+ // Map parallelToolCalls
256
+ if (resolvedParallel !== undefined) {
257
+ anthropicToolChoice.disable_parallel_tool_use = !resolvedParallel;
258
+ }
259
+ extras.tool_choice = anthropicToolChoice;
260
+ }
261
+ }
262
+
263
+ const requestParams: Anthropic.MessageCreateParamsStreaming = {
264
+ model: this.#opts.model,
265
+ messages: messages,
266
+ system: system.length > 0 ? system : undefined,
267
+ tools: anthropicTools.length > 0 ? anthropicTools : undefined,
268
+ stream: true,
269
+ max_tokens: this.#opts.maxTokens || 4096,
270
+ ...extras,
271
+ };
272
+
273
+ return new LLMStream(this, this.#client, requestParams, chatCtx, toolCtx, connOptions);
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Streaming response handler for Anthropic Messages API.
279
+ *
280
+ * @remarks
281
+ * Parses SSE events including:
282
+ * - `content_block_start` / `content_block_delta` / `content_block_stop` for text and tool use
283
+ * - `message_start` / `message_delta` for token usage tracking
284
+ * - Chain-of-thought `<thinking>` block filtering when tools are active
285
+ */
286
+ export class LLMStream extends llm.LLMStream {
287
+ #client: Anthropic;
288
+ #requestParams: Anthropic.MessageCreateParamsStreaming;
289
+ #toolCallId?: string;
290
+ #fncName?: string;
291
+ #fncRawArgs?: string;
292
+ #requestId = '';
293
+ #ignoringCoT = false;
294
+ #inputTokens = 0;
295
+ #outputTokens = 0;
296
+ #cacheCreationTokens = 0;
297
+ #cacheReadTokens = 0;
298
+ #toolCtx?: llm.ToolContext;
299
+
300
+ constructor(
301
+ llmInst: LLM,
302
+ client: Anthropic,
303
+ requestParams: Anthropic.MessageCreateParamsStreaming,
304
+ chatCtx: llm.ChatContext,
305
+ toolCtx: llm.ToolContext | undefined,
306
+ connOptions: APIConnectOptions,
307
+ ) {
308
+ super(llmInst, { chatCtx, toolCtx, connOptions });
309
+ this.#client = client;
310
+ this.#requestParams = requestParams;
311
+ this.#toolCtx = toolCtx;
312
+ }
313
+
314
+ protected async run(): Promise<void> {
315
+ let retryable = true;
316
+ this.#toolCallId = undefined;
317
+ this.#fncName = undefined;
318
+ this.#fncRawArgs = undefined;
319
+ this.#requestId = '';
320
+ this.#ignoringCoT = false;
321
+ this.#inputTokens = 0;
322
+ this.#outputTokens = 0;
323
+ this.#cacheCreationTokens = 0;
324
+ this.#cacheReadTokens = 0;
325
+
326
+ try {
327
+ const stream = await this.#client.messages.create(this.#requestParams, {
328
+ timeout: this.connOptions.timeoutMs,
329
+ });
330
+ for await (const event of stream) {
331
+ if (event.type === 'message_start') {
332
+ this.#requestId = event.message.id;
333
+ this.#inputTokens = event.message.usage.input_tokens;
334
+ this.#outputTokens = event.message.usage.output_tokens;
335
+ this.#cacheCreationTokens = event.message.usage.cache_creation_input_tokens ?? 0;
336
+ this.#cacheReadTokens = event.message.usage.cache_read_input_tokens ?? 0;
337
+ } else if (event.type === 'message_delta') {
338
+ this.#outputTokens = event.usage.output_tokens;
339
+ } else if (
340
+ event.type === 'content_block_start' &&
341
+ event.content_block.type === 'tool_use'
342
+ ) {
343
+ this.#toolCallId = event.content_block.id;
344
+ this.#fncName = event.content_block.name;
345
+ this.#fncRawArgs = '';
346
+ } else if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
347
+ let text = event.delta.text;
348
+
349
+ // Filter chain-of-thought <thinking> blocks when tools are active
350
+ if (this.#toolCtx) {
351
+ const thinkingStart = text.indexOf('<thinking>');
352
+ if (thinkingStart >= 0) {
353
+ const preThinking = text.slice(0, thinkingStart);
354
+ if (preThinking) {
355
+ this.queue.put({
356
+ id: this.#requestId,
357
+ delta: { role: 'assistant', content: preThinking },
358
+ });
359
+ retryable = false;
360
+ }
361
+ text = text.slice(thinkingStart + '<thinking>'.length);
362
+ this.#ignoringCoT = true;
363
+ }
364
+ if (this.#ignoringCoT) {
365
+ const thinkingEnd = text.indexOf('</thinking>');
366
+ if (thinkingEnd >= 0) {
367
+ text = text.slice(thinkingEnd + '</thinking>'.length);
368
+ this.#ignoringCoT = false;
369
+ }
370
+ }
371
+ }
372
+
373
+ if (this.#ignoringCoT) {
374
+ continue;
375
+ }
376
+
377
+ // A delta can be reduced to an empty string when it was entirely a
378
+ // <thinking> block; don't emit it or mark the stream non-retryable.
379
+ if (text) {
380
+ this.queue.put({
381
+ id: this.#requestId,
382
+ delta: { role: 'assistant', content: text },
383
+ });
384
+ retryable = false;
385
+ }
386
+ } else if (
387
+ event.type === 'content_block_delta' &&
388
+ event.delta.type === 'input_json_delta'
389
+ ) {
390
+ this.#fncRawArgs += event.delta.partial_json;
391
+ } else if (event.type === 'content_block_stop' && this.#toolCallId) {
392
+ this.queue.put({
393
+ id: this.#requestId,
394
+ delta: {
395
+ role: 'assistant',
396
+ toolCalls: [
397
+ llm.FunctionCall.create({
398
+ callId: this.#toolCallId,
399
+ name: this.#fncName || '',
400
+ args: this.#fncRawArgs || '',
401
+ }),
402
+ ],
403
+ },
404
+ });
405
+ this.#toolCallId = undefined;
406
+ this.#fncName = undefined;
407
+ this.#fncRawArgs = undefined;
408
+ retryable = false;
409
+ }
410
+ }
411
+
412
+ // Emit final usage chunk. Anthropic reports cached tokens separately from
413
+ // input_tokens, so fold them into promptTokens like the Python plugin does.
414
+ const promptTokens = this.#inputTokens + this.#cacheCreationTokens + this.#cacheReadTokens;
415
+ this.queue.put({
416
+ id: this.#requestId,
417
+ usage: {
418
+ completionTokens: this.#outputTokens,
419
+ promptTokens,
420
+ totalTokens: promptTokens + this.#outputTokens,
421
+ promptCachedTokens: this.#cacheReadTokens,
422
+ },
423
+ });
424
+ } catch (e: unknown) {
425
+ if (e instanceof Anthropic.APIError) {
426
+ if (e.status === 408) {
427
+ throw new APITimeoutError({
428
+ message: e.message,
429
+ options: { retryable },
430
+ });
431
+ }
432
+ throw new APIStatusError({
433
+ message: e.message,
434
+ options: {
435
+ statusCode: e.status,
436
+ body: e.error as object,
437
+ retryable: retryable && (e.status === 429 || e.status >= 500),
438
+ },
439
+ });
440
+ }
441
+ throw new APIConnectionError({
442
+ message: e instanceof Error ? e.message : String(e),
443
+ options: { retryable },
444
+ });
445
+ }
446
+ }
447
+ }
package/src/models.ts ADDED
@@ -0,0 +1,21 @@
1
+ // SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+
5
+ /**
6
+ * Supported Anthropic Chat Models.
7
+ *
8
+ * @remarks
9
+ * Based on https://docs.anthropic.com/en/docs/about-claude/model-deprecations
10
+ */
11
+ export type ChatModels =
12
+ | 'claude-3-5-sonnet-20241022'
13
+ | 'claude-3-5-haiku-20241022'
14
+ | 'claude-3-haiku-20240307'
15
+ | 'claude-3-7-sonnet-20250219'
16
+ | 'claude-sonnet-4-20250514'
17
+ | 'claude-sonnet-4-6'
18
+ | 'claude-opus-4-20250514'
19
+ | 'claude-opus-4-1-20250805'
20
+ | 'claude-opus-4-6'
21
+ | (string & Record<never, never>);