-
Notifications
You must be signed in to change notification settings - Fork 312
feat(tavus): rename replicaId/personaId options to faceId/palId #1886
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 7 commits
75ee9fb
906bc54
c7a6bf1
ce486c3
4ca47ca
c83b34a
363abf7
5562793
bbc9aa6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@livekit/agents-plugin-tavus': minor | ||
| --- | ||
|
|
||
| Align the Tavus plugin with the new face/pal API: `faceId`/`palId` options and `TAVUS_FACE_ID`/`TAVUS_PAL_ID` env vars, sending `face_id`/`pal_id` on the wire and auto-creating pals via `/v2/pals` (`createPal`). The old `replicaId`/`personaId` options, `TAVUS_REPLICA_ID`/`TAVUS_PERSONA_ID` env vars, and `createPersona()` keep working as deprecated aliases. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| // SPDX-FileCopyrightText: 2026 LiveKit, Inc. | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import { TavusAPI } from './api.js'; | ||
|
|
||
| const warnMock = vi.hoisted(() => vi.fn()); | ||
| vi.mock('./log.js', () => ({ log: () => ({ warn: warnMock, error: vi.fn() }) })); | ||
|
|
||
| function mockFetchOk(body: Record<string, unknown>) { | ||
| const f = vi.fn().mockResolvedValue({ ok: true, json: async () => body } as Response); | ||
| global.fetch = f as unknown as typeof fetch; | ||
| return f; | ||
| } | ||
|
|
||
| function sentBody(f: ReturnType<typeof mockFetchOk>): Record<string, unknown> { | ||
| return JSON.parse(String((f.mock.calls[0]![1] as RequestInit).body)); | ||
| } | ||
|
|
||
| describe('Tavus TavusAPI.createConversation', () => { | ||
| beforeEach(() => { | ||
| warnMock.mockClear(); | ||
| for (const v of ['TAVUS_FACE_ID', 'TAVUS_PAL_ID', 'TAVUS_REPLICA_ID', 'TAVUS_PERSONA_ID']) { | ||
| delete process.env[v]; | ||
| } | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| it('maps faceId/palId onto the unchanged wire keys without warning', async () => { | ||
| const f = mockFetchOk({ conversation_id: 'c1' }); | ||
| const id = await new TavusAPI({ apiKey: 'k' }).createConversation({ | ||
| faceId: 'f1', | ||
| palId: 'p1', | ||
| }); | ||
| expect(id).toBe('c1'); | ||
| expect(f).toHaveBeenCalledTimes(1); | ||
| const body = sentBody(f); | ||
| expect(body.face_id).toBe('f1'); | ||
| expect(body.pal_id).toBe('p1'); | ||
| expect(warnMock).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('still accepts deprecated replicaId/personaId and warns', async () => { | ||
| const f = mockFetchOk({ conversation_id: 'c2' }); | ||
| await new TavusAPI({ apiKey: 'k' }).createConversation({ replicaId: 'r1', personaId: 'x1' }); | ||
| const body = sentBody(f); | ||
| expect(body.face_id).toBe('r1'); | ||
| expect(body.pal_id).toBe('x1'); | ||
| const msgs = warnMock.mock.calls.map((c) => String(c[0])); | ||
| expect(msgs.some((m) => m.includes('replicaId') && m.includes('faceId'))).toBe(true); | ||
| expect(msgs.some((m) => m.includes('personaId') && m.includes('palId'))).toBe(true); | ||
| }); | ||
|
|
||
| it('falls back to TAVUS_FACE_ID / TAVUS_PAL_ID env vars', async () => { | ||
| process.env.TAVUS_FACE_ID = 'envf'; | ||
| process.env.TAVUS_PAL_ID = 'envp'; | ||
| const f = mockFetchOk({ conversation_id: 'c3' }); | ||
| await new TavusAPI({ apiKey: 'k' }).createConversation(); | ||
| const body = sentBody(f); | ||
| expect(body.face_id).toBe('envf'); | ||
| expect(body.pal_id).toBe('envp'); | ||
| expect(warnMock).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('auto-creates a pal via /v2/pals (with default_face_id) when no pal is given', async () => { | ||
| const f = vi | ||
| .fn() | ||
| .mockResolvedValueOnce({ ok: true, json: async () => ({ pal_id: 'pal_new' }) } as Response) | ||
| .mockResolvedValueOnce({ | ||
| ok: true, | ||
| json: async () => ({ conversation_id: 'c5' }), | ||
| } as Response); | ||
| global.fetch = f as unknown as typeof fetch; | ||
|
|
||
| await new TavusAPI({ apiKey: 'k' }).createConversation({ faceId: 'f1' }); | ||
|
|
||
| const palUrl = String(f.mock.calls[0]![0]); | ||
| const palBody = JSON.parse(String((f.mock.calls[0]![1] as RequestInit).body)); | ||
| const convBody = JSON.parse(String((f.mock.calls[1]![1] as RequestInit).body)); | ||
| expect(palUrl).toContain('/pals'); | ||
| expect(palBody.default_face_id).toBe('f1'); | ||
| expect(convBody.face_id).toBe('f1'); | ||
| expect(convBody.pal_id).toBe('pal_new'); | ||
| }); | ||
|
|
||
| it('with only palId, skips pal creation and omits face_id (pal carries its own face)', async () => { | ||
| const f = mockFetchOk({ conversation_id: 'c4' }); | ||
| await new TavusAPI({ apiKey: 'k' }).createConversation({ palId: 'p1' }); | ||
| expect(f).toHaveBeenCalledTimes(1); | ||
| expect(String(f.mock.calls[0]![0])).toContain('/conversations'); | ||
| const body = sentBody(f); | ||
| expect(body.pal_id).toBe('p1'); | ||
| expect(body).not.toHaveProperty('face_id'); | ||
| }); | ||
|
|
||
| it('falls back to the default face id when neither face nor pal is provided', async () => { | ||
| const f = vi | ||
| .fn() | ||
| .mockResolvedValueOnce({ ok: true, json: async () => ({ pal_id: 'pal_new' }) } as Response) | ||
| .mockResolvedValueOnce({ | ||
| ok: true, | ||
| json: async () => ({ conversation_id: 'c5' }), | ||
| } as Response); | ||
| global.fetch = f as unknown as typeof fetch; | ||
|
|
||
| await new TavusAPI({ apiKey: 'k' }).createConversation(); | ||
|
|
||
| expect(String(f.mock.calls[0]![0])).toContain('/pals'); | ||
| const palBody = JSON.parse(String((f.mock.calls[0]![1] as RequestInit).body)); | ||
| expect(palBody.default_face_id).toBe('r72f7f7f7c8b'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,9 @@ import { log } from './log.js'; | |
| /** @public */ | ||
| export const DEFAULT_API_URL = 'https://tavusapi.com/v2'; | ||
|
|
||
| // Stock face used when the caller provides neither a face nor a pal. | ||
| const DEFAULT_FACE_ID = 'r72f7f7f7c8b'; | ||
|
|
||
| /** | ||
| * Exception thrown when the Tavus plugin or Tavus service errors. | ||
| * | ||
|
|
@@ -28,16 +31,52 @@ export class TavusException extends Error { | |
|
|
||
| /** @public */ | ||
| export interface CreateConversationOptions { | ||
| /** Tavus replica id. Falls back to `TAVUS_REPLICA_ID`. */ | ||
| /** Tavus face id. Falls back to `TAVUS_FACE_ID`. */ | ||
| faceId?: string; | ||
| /** Tavus pal id. Falls back to `TAVUS_PAL_ID`; created automatically when omitted. */ | ||
| palId?: string; | ||
| /** @deprecated Use {@link CreateConversationOptions.faceId | faceId} instead. */ | ||
| replicaId?: string; | ||
| /** Tavus persona id. Falls back to `TAVUS_PERSONA_ID`; created automatically when omitted. */ | ||
| /** @deprecated Use {@link CreateConversationOptions.palId | palId} instead. */ | ||
| personaId?: string; | ||
| /** Conversation properties passed through to Tavus. */ | ||
| properties?: Record<string, unknown>; | ||
| /** Additional fields to merge into the Tavus conversation creation payload. */ | ||
| extraPayload?: Record<string, unknown>; | ||
| } | ||
|
|
||
| function resolveRenamedOption( | ||
| newValue: string | undefined, | ||
| deprecatedValue: string | undefined, | ||
| deprecatedName: string, | ||
| newName: string, | ||
| ): string | undefined { | ||
| // Prefer the new option; fall back to the deprecated alias and warn when it's used. | ||
| if (deprecatedValue) { | ||
| log().warn(`\`${deprecatedName}\` is deprecated, use \`${newName}\` instead`); | ||
| } | ||
| return newValue || deprecatedValue; | ||
| } | ||
|
|
||
| function deprecatedEnv(deprecatedName: string, newName: string): string | undefined { | ||
| // Read a deprecated env var, warning if it's set so callers migrate to `newName`. | ||
| const value = process.env[deprecatedName]; | ||
| if (value) { | ||
| log().warn(`\`${deprecatedName}\` is deprecated, use \`${newName}\` instead`); | ||
| } | ||
| return value; | ||
| } | ||
|
|
||
| /** @public */ | ||
| export interface CreatePalOptions { | ||
| /** Tavus pal name. Generated automatically when omitted. */ | ||
| name?: string; | ||
| /** Default face id for the pal (required by `/v2/pals`). */ | ||
| defaultFaceId: string; | ||
| /** Additional fields to merge into the Tavus pal creation payload. */ | ||
| extraPayload?: Record<string, unknown>; | ||
| } | ||
|
|
||
| /** @public */ | ||
| export interface CreatePersonaOptions { | ||
| /** Tavus persona name. Generated automatically when omitted. */ | ||
|
|
@@ -80,21 +119,29 @@ export class TavusAPI { | |
| } | ||
|
|
||
| async createConversation(options: CreateConversationOptions = {}): Promise<string> { | ||
| const replicaId = options.replicaId || process.env.TAVUS_REPLICA_ID; | ||
| if (!replicaId) { | ||
| throw new TavusException('TAVUS_REPLICA_ID must be set'); | ||
| } | ||
|
|
||
| let personaId = options.personaId || process.env.TAVUS_PERSONA_ID; | ||
| if (!personaId) { | ||
| personaId = await this.createPersona(); | ||
| const faceId = | ||
| resolveRenamedOption(options.faceId, options.replicaId, 'replicaId', 'faceId') || | ||
| process.env.TAVUS_FACE_ID || | ||
| deprecatedEnv('TAVUS_REPLICA_ID', 'TAVUS_FACE_ID'); | ||
|
|
||
| let palId = | ||
| resolveRenamedOption(options.palId, options.personaId, 'personaId', 'palId') || | ||
| process.env.TAVUS_PAL_ID || | ||
| deprecatedEnv('TAVUS_PERSONA_ID', 'TAVUS_PAL_ID'); | ||
|
Comment on lines
+127
to
+130
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚩 Deprecated personaId values are now sent as pal_id on the wire When a caller passes the deprecated Was this helpful? React with 👍 or 👎 to provide feedback.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @carolin-tavus is this a valid comment? |
||
|
|
||
| if (!palId) { | ||
| // no pal to reuse, so create one — falling back to the default face | ||
| palId = await this.createPal({ defaultFaceId: faceId || DEFAULT_FACE_ID }); | ||
|
tinalenguyen marked this conversation as resolved.
Comment on lines
+132
to
+134
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚩 New pal is created on every conversation when palId is not cached When no Was this helpful? React with 👍 or 👎 to provide feedback.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @carolin-tavus is this a valid comment? |
||
| } | ||
|
|
||
| const payload: Record<string, unknown> = { | ||
| replica_id: replicaId, | ||
| persona_id: personaId, | ||
| pal_id: palId, | ||
| properties: options.properties ?? {}, | ||
| }; | ||
| // send face_id only when given; otherwise the pal's default_face_id is used | ||
| if (faceId) { | ||
| payload.face_id = faceId; | ||
| } | ||
|
|
||
| if (options.extraPayload) { | ||
| Object.assign(payload, options.extraPayload); | ||
|
|
@@ -108,7 +155,27 @@ export class TavusAPI { | |
| return responseData.conversation_id; | ||
| } | ||
|
|
||
| async createPal(options: CreatePalOptions): Promise<string> { | ||
| const payload: Record<string, unknown> = { | ||
| pal_name: options.name || shortuuid('lk_pal_'), | ||
| default_face_id: options.defaultFaceId, | ||
| pipeline_mode: 'echo', | ||
| layers: { | ||
| transport: { transport_type: 'livekit' }, | ||
| }, | ||
| }; | ||
|
|
||
| if (options.extraPayload) { | ||
| Object.assign(payload, options.extraPayload); | ||
| } | ||
|
|
||
| const responseData = (await this.post('pals', payload)) as { pal_id: string }; | ||
| return responseData.pal_id; | ||
| } | ||
|
|
||
| /** @deprecated Use {@link TavusAPI.createPal | createPal} instead. */ | ||
| async createPersona(options: CreatePersonaOptions = {}): Promise<string> { | ||
| log().warn('`createPersona` is deprecated, use `createPal` instead'); | ||
| const payload: Record<string, unknown> = { | ||
| persona_name: options.name || shortuuid('lk_persona_'), | ||
| pipeline_mode: 'echo', | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| // SPDX-FileCopyrightText: 2026 LiveKit, Inc. | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { voice } from '@livekit/agents'; | ||
| import type { Room } from '@livekit/rtc-node'; | ||
| import { afterEach, describe, expect, it, vi } from 'vitest'; | ||
| import { AvatarSession } from './avatar.js'; | ||
|
|
||
| describe('Tavus AvatarSession', () => { | ||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| it('calls base AvatarSession.start first', async () => { | ||
| const sentinel = new Error('super-start-called'); | ||
| const superStartSpy = vi | ||
| .spyOn(voice.AvatarSession.prototype, 'start') | ||
| .mockRejectedValue(sentinel); | ||
|
|
||
| const avatar = new AvatarSession({ apiKey: 'k', faceId: 'f1' }); | ||
|
|
||
| await expect( | ||
| avatar.start( | ||
| { _started: false, output: { audio: null } } as unknown as voice.AgentSession, | ||
| {} as unknown as Room, | ||
| ), | ||
| ).rejects.toThrow('super-start-called'); | ||
| expect(superStartSpy).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('accepts both new (faceId/palId) and deprecated (replicaId/personaId) options', () => { | ||
| expect(() => new AvatarSession({ apiKey: 'k', faceId: 'f1', palId: 'p1' })).not.toThrow(); | ||
| expect( | ||
| () => new AvatarSession({ apiKey: 'k', replicaId: 'r1', personaId: 'x1' }), | ||
| ).not.toThrow(); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.