Files
2026-07-13 13:39:38 +08:00

206 lines
6.8 KiB
Python

import asyncio
import os
from typing import Any
import aiohttp
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectionError,
APIConnectOptions,
APIStatusError,
NotGivenOr,
utils,
)
from .log import logger
class TavusException(Exception):
"""Exception for Tavus errors"""
DEFAULT_API_URL = "https://tavusapi.com/v2"
# Stock Tavus PAL. Use create_pal() to create a PAL with the appearance you'd like.
DEFAULT_PAL_ID = "pb87e71797da"
def _coalesce_with_deprecated(
new_value: NotGivenOr[str],
deprecated_value: NotGivenOr[str],
*,
deprecated_name: str,
new_name: str,
) -> NotGivenOr[str]:
# Prefer the new arg; fall back to the deprecated alias and warn only when it's used.
if deprecated_value and not new_value:
logger.warning(f"`{deprecated_name}` is deprecated, use `{new_name}` instead")
return new_value or deprecated_value
def _deprecated_env(deprecated_name: str, new_name: str) -> str | None:
# Read a deprecated env var, warning if it's set so callers migrate to `new_name`.
value = os.getenv(deprecated_name)
if value:
logger.warning(f"`{deprecated_name}` is deprecated, use `{new_name}` instead")
return value
class TavusAPI:
def __init__(
self,
api_key: NotGivenOr[str] = NOT_GIVEN,
api_url: NotGivenOr[str] = NOT_GIVEN,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
session: aiohttp.ClientSession | None = None,
) -> None:
tavus_api_key = api_key or os.getenv("TAVUS_API_KEY")
if tavus_api_key is None:
raise TavusException("TAVUS_API_KEY must be set")
self._api_key = tavus_api_key
self._api_url = api_url or DEFAULT_API_URL
self._conn_options = conn_options
self._session = session or aiohttp.ClientSession()
async def create_conversation(
self,
*,
face_id: NotGivenOr[str] = NOT_GIVEN,
pal_id: NotGivenOr[str] = NOT_GIVEN,
replica_id: NotGivenOr[str] = NOT_GIVEN,
persona_id: NotGivenOr[str] = NOT_GIVEN,
properties: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
extra_payload: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> str:
# `replica_id`/`persona_id` are deprecated aliases for `face_id`/`pal_id`.
face_id = _coalesce_with_deprecated(
face_id, replica_id, deprecated_name="replica_id", new_name="face_id"
)
pal_id = _coalesce_with_deprecated(
pal_id, persona_id, deprecated_name="persona_id", new_name="pal_id"
)
face_id = (
face_id
or os.getenv("TAVUS_FACE_ID")
or _deprecated_env("TAVUS_REPLICA_ID", "TAVUS_FACE_ID")
or NOT_GIVEN
)
pal_id = (
pal_id
or os.getenv("TAVUS_PAL_ID")
or _deprecated_env("TAVUS_PERSONA_ID", "TAVUS_PAL_ID")
or NOT_GIVEN
)
if not pal_id:
# no pal supplied — use the default stock pal (carries its own face)
pal_id = DEFAULT_PAL_ID
properties = properties or {}
payload: dict[str, Any] = {"pal_id": pal_id, "properties": properties}
# send face_id only when given; otherwise the pal's default_face_id is used
if face_id:
payload["face_id"] = face_id
if utils.is_given(extra_payload):
payload.update(extra_payload)
if "conversation_name" not in payload:
payload["conversation_name"] = utils.shortuuid("lk_conversation_")
response_data = await self._post("conversations", payload)
return response_data["conversation_id"] # type: ignore
async def create_pal(
self,
name: NotGivenOr[str] = NOT_GIVEN,
*,
default_face_id: str,
extra_payload: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> str:
name = name or utils.shortuuid("lk_pal_")
payload = {
"pal_name": name,
"default_face_id": default_face_id,
"pipeline_mode": "echo",
"layers": {
"transport": {"transport_type": "livekit"},
},
}
if utils.is_given(extra_payload):
payload.update(extra_payload)
response_data = await self._post("pals", payload)
return response_data["pal_id"] # type: ignore
async def create_persona(
self,
name: NotGivenOr[str] = NOT_GIVEN,
*,
extra_payload: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> str:
# Deprecated: use create_pal(). Kept on the legacy /v2/personas endpoint.
logger.warning("`create_persona` is deprecated, use `create_pal` instead")
name = name or utils.shortuuid("lk_persona_")
payload = {
"persona_name": name,
"pipeline_mode": "echo",
"layers": {
"transport": {"transport_type": "livekit"},
},
}
if utils.is_given(extra_payload):
payload.update(extra_payload)
response_data = await self._post("personas", payload)
return response_data["persona_id"] # type: ignore
async def _post(self, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
"""
Make a POST request to the Tavus API with retry logic.
Args:
endpoint: API endpoint path (without leading slash)
payload: JSON payload for the request
Returns:
Response data as a dictionary
Raises:
APIConnectionError: If the request fails after all retries
"""
for i in range(self._conn_options.max_retry):
try:
async with self._session.post(
f"{self._api_url}/{endpoint}",
headers={
"Content-Type": "application/json",
"x-api-key": self._api_key,
},
json=payload,
timeout=aiohttp.ClientTimeout(sock_connect=self._conn_options.timeout),
) as response:
if not response.ok:
text = await response.text()
raise APIStatusError(
"Server returned an error", status_code=response.status, body=text
)
return await response.json() # type: ignore
except Exception as e:
if isinstance(e, APIConnectionError):
logger.warning("failed to call tavus api", extra={"error": str(e)})
else:
logger.exception("failed to call tavus api")
if i < self._conn_options.max_retry - 1:
await asyncio.sleep(self._conn_options.retry_interval)
raise APIConnectionError("Failed to call Tavus API after all retries")