Skip to content

Commit 1d79a8e

Browse files
feat: add Protoface avatar plugin
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3d14674 commit 1d79a8e

15 files changed

Lines changed: 605 additions & 1 deletion

File tree

examples/avatar_agents/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ These providers work with pre-configured avatars using unique avatar identifiers
1414
- **[BitHuman](./bithuman/)** (Cloud mode) - [Platform](https://bithuman.ai/) | [Integration Guide](https://sdk.docs.bithuman.ai/#/preview/livekit-cloud-plugin)
1515
- **[LemonSlice](./lemonslice/)** - [Platform](https://www.lemonslice.com/) | [Integration Guide](https://lemonslice.com/docs/self-managed/livekit-agent-integration)
1616
- **[LiveAvatar](./liveavatar/)** - [Platform](https://www.liveavatar.com/)
17+
- **[Protoface](./protoface/)** - [Platform](https://protoface.com/)
1718
- **[Simli](./simli/)** - [Platform](https://app.simli.com/)
1819
- **[Tavus](./tavus/)** - [Platform](https://www.tavus.io/)
1920
- **[TruGen](./trugen/)** - [Platform](https://app.trugen.ai/)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# LiveKit Protoface Avatar Agent
2+
3+
This example demonstrates how to create a realtime [Protoface](https://protoface.com/)
4+
avatar with LiveKit Agents.
5+
6+
## Usage
7+
8+
- Update the environment:
9+
10+
```bash
11+
# Protoface config. PROTOFACE_AVATAR_ID is optional and defaults to av_stock_001.
12+
export PROTOFACE_API_KEY="..."
13+
export PROTOFACE_AVATAR_ID="av_stock_001"
14+
15+
# Google config
16+
export GOOGLE_API_KEY="..."
17+
18+
# LiveKit config
19+
export LIVEKIT_API_KEY="..."
20+
export LIVEKIT_API_SECRET="..."
21+
export LIVEKIT_URL="..."
22+
```
23+
24+
- Start the agent worker:
25+
26+
```bash
27+
python examples/avatar_agents/protoface/agent_worker.py dev
28+
```
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import logging
2+
import os
3+
4+
from dotenv import load_dotenv
5+
6+
from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli
7+
from livekit.plugins import google, protoface
8+
9+
logger = logging.getLogger("protoface-avatar-example")
10+
logger.setLevel(logging.INFO)
11+
12+
load_dotenv()
13+
14+
server = AgentServer()
15+
16+
17+
@server.rtc_session()
18+
async def entrypoint(ctx: JobContext):
19+
session = AgentSession(
20+
llm=google.realtime.RealtimeModel(voice="Charon"),
21+
resume_false_interruption=False,
22+
)
23+
24+
avatar = protoface.AvatarSession(
25+
avatar_id=os.getenv("PROTOFACE_AVATAR_ID", protoface.DEFAULT_STOCK_AVATAR_ID),
26+
)
27+
await avatar.start(session, room=ctx.room)
28+
29+
await session.start(
30+
agent=Agent(instructions="Talk to me!"),
31+
room=ctx.room,
32+
)
33+
34+
35+
if __name__ == "__main__":
36+
cli.run_app(server)

livekit-agents/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ nltk = ["livekit-plugins-nltk>=1.6.3"]
106106
nvidia = ["livekit-plugins-nvidia>=1.6.3"]
107107
openai = ["livekit-plugins-openai>=1.6.3"]
108108
perplexity = ["livekit-plugins-perplexity>=1.6.3"]
109+
protoface = ["livekit-plugins-protoface>=1.6.3"]
109110
resemble = ["livekit-plugins-resemble>=1.6.3"]
110111
respeecher = ["livekit-plugins-respeecher>=1.6.3"]
111112
rime = ["livekit-plugins-rime>=1.6.3"]
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Protoface plugin for LiveKit Agents
2+
3+
Support for the [Protoface](https://protoface.com/) virtual avatar.
4+
5+
See the [Protoface docs](https://docs.protoface.com/) for more information.
6+
7+
## Installation
8+
9+
```bash
10+
pip install livekit-plugins-protoface
11+
```
12+
13+
## Pre-requisites
14+
15+
You'll need an API key from Protoface. It can be set as an environment variable: `PROTOFACE_API_KEY`
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Protoface avatar plugin for LiveKit Agents."""
2+
3+
from .avatar import DEFAULT_STOCK_AVATAR_ID, AvatarSession
4+
from .errors import ProtofaceException
5+
from .version import __version__
6+
7+
__all__ = [
8+
"DEFAULT_STOCK_AVATAR_ID",
9+
"AvatarSession",
10+
"ProtofaceException",
11+
"__version__",
12+
]
13+
14+
from livekit.agents import Plugin
15+
16+
from .log import logger
17+
18+
19+
class ProtofacePlugin(Plugin):
20+
def __init__(self) -> None:
21+
super().__init__(__name__, __version__, __package__, logger)
22+
23+
24+
Plugin.register_plugin(ProtofacePlugin())
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import os
5+
from typing import Any
6+
7+
import aiohttp
8+
9+
from livekit.agents import (
10+
DEFAULT_API_CONNECT_OPTIONS,
11+
NOT_GIVEN,
12+
APIConnectionError,
13+
APIConnectOptions,
14+
APIStatusError,
15+
APITimeoutError,
16+
NotGivenOr,
17+
utils,
18+
)
19+
20+
from .errors import ProtofaceException
21+
from .log import logger
22+
from .version import __version__
23+
24+
DEFAULT_API_URL = "https://api.protoface.com"
25+
_USER_AGENT = f"livekit-plugins-protoface/{__version__}"
26+
27+
28+
class ProtofaceAPI:
29+
"""Async client for the Protoface session API."""
30+
31+
def __init__(
32+
self,
33+
*,
34+
api_key: NotGivenOr[str | None] = NOT_GIVEN,
35+
api_url: NotGivenOr[str | None] = NOT_GIVEN,
36+
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
37+
session: aiohttp.ClientSession | None = None,
38+
) -> None:
39+
"""Create a Protoface API client.
40+
41+
Args:
42+
api_key: Protoface API key. Defaults to the `PROTOFACE_API_KEY`
43+
environment variable.
44+
api_url: Protoface API base URL. Defaults to `PROTOFACE_API_URL` or
45+
`https://api.protoface.com`.
46+
conn_options: Timeout and retry settings for API calls.
47+
session: Optional caller-owned HTTP session. When omitted, the
48+
client uses LiveKit's shared HTTP session.
49+
50+
Raises:
51+
ProtofaceException: If no API key is passed and `PROTOFACE_API_KEY`
52+
is not set.
53+
"""
54+
self._api_key = _resolve_optional_string(api_key, "PROTOFACE_API_KEY")
55+
if not self._api_key:
56+
raise ProtofaceException(
57+
"api_key must be set by passing it to ProtofaceAPI or "
58+
"setting the PROTOFACE_API_KEY environment variable"
59+
)
60+
61+
api_url_value = _resolve_optional_string(api_url, "PROTOFACE_API_URL")
62+
self._api_url = (api_url_value or DEFAULT_API_URL).rstrip("/")
63+
self._conn_options = conn_options
64+
self._session = session
65+
66+
async def start_session(
67+
self,
68+
*,
69+
avatar_id: str,
70+
transport: dict[str, Any],
71+
max_duration_seconds: NotGivenOr[int | None] = NOT_GIVEN,
72+
) -> dict[str, Any]:
73+
"""Create a hosted Protoface avatar session.
74+
75+
Args:
76+
avatar_id: Protoface avatar ID to render.
77+
transport: Protoface transport configuration. The LiveKit Agents
78+
plugin uses `audio_source="data_stream"`.
79+
max_duration_seconds: Optional maximum session duration. Protoface
80+
applies the lower of this value and the account plan limit.
81+
82+
Returns:
83+
The decoded Protoface session object.
84+
85+
Raises:
86+
APIConnectionError: If a retryable API or network error persists
87+
after all retry attempts.
88+
APIStatusError: If Protoface returns a non-retryable error response.
89+
"""
90+
body: dict[str, Any] = {"avatar_id": avatar_id, "transport": transport}
91+
if utils.is_given(max_duration_seconds) and max_duration_seconds is not None:
92+
body["max_duration_seconds"] = max_duration_seconds
93+
94+
return await self._json("POST", "/v1/sessions", json=body)
95+
96+
async def end_session(self, session_id: str) -> dict[str, Any]:
97+
"""Request a graceful end for a hosted Protoface session.
98+
99+
Args:
100+
session_id: Protoface session ID returned by `start_session()`.
101+
102+
Returns:
103+
The decoded Protoface response body.
104+
105+
Raises:
106+
APIConnectionError: If a retryable API or network error persists
107+
after all retry attempts.
108+
APIStatusError: If Protoface returns a non-retryable error response.
109+
"""
110+
return await self._json("POST", f"/v1/sessions/{session_id}/end")
111+
112+
def _ensure_http_session(self) -> aiohttp.ClientSession:
113+
if self._session is None:
114+
self._session = utils.http_context.http_session()
115+
return self._session
116+
117+
async def _json(
118+
self,
119+
method: str,
120+
path: str,
121+
*,
122+
json: dict[str, Any] | None = None,
123+
headers: dict[str, str] | None = None,
124+
) -> dict[str, Any]:
125+
request_headers = {
126+
"Authorization": f"Bearer {self._api_key}",
127+
"User-Agent": _USER_AGENT,
128+
"Accept": "application/json",
129+
**(headers or {}),
130+
}
131+
url = f"{self._api_url}{path}"
132+
error: Exception | None = None
133+
134+
for attempt in range(self._conn_options.max_retry + 1):
135+
try:
136+
async with self._ensure_http_session().request(
137+
method,
138+
url,
139+
json=json,
140+
headers=request_headers,
141+
timeout=aiohttp.ClientTimeout(total=self._conn_options.timeout),
142+
) as response:
143+
payload = await _read_payload(response)
144+
if response.ok:
145+
if not isinstance(payload, dict):
146+
raise APIStatusError(
147+
"Protoface API returned a non-object JSON response",
148+
status_code=response.status,
149+
body=payload,
150+
retryable=False,
151+
)
152+
return payload
153+
154+
raise APIStatusError(
155+
"Protoface API returned an error",
156+
status_code=response.status,
157+
body=payload,
158+
)
159+
except asyncio.TimeoutError as exc:
160+
error = APITimeoutError()
161+
error.__cause__ = exc
162+
except aiohttp.ClientError as exc:
163+
error = APIConnectionError()
164+
error.__cause__ = exc
165+
except APIStatusError as exc:
166+
if not exc.retryable:
167+
raise
168+
error = exc
169+
170+
if attempt == self._conn_options.max_retry:
171+
break
172+
173+
logger.warning(
174+
"protoface api request failed, retrying",
175+
extra={"attempt": attempt + 1, "method": method, "path": path},
176+
)
177+
await asyncio.sleep(self._conn_options._interval_for_retry(attempt))
178+
179+
raise APIConnectionError("Failed to call Protoface API after all retries.") from error
180+
181+
182+
def _resolve_optional_string(value: NotGivenOr[str | None], env_name: str) -> str | None:
183+
if utils.is_given(value) and value is not None:
184+
return value
185+
return os.getenv(env_name)
186+
187+
188+
async def _read_payload(response: aiohttp.ClientResponse) -> object:
189+
text = await response.text()
190+
if not text:
191+
return {}
192+
193+
try:
194+
return await response.json(content_type=None)
195+
except ValueError:
196+
return {"raw": text}
197+
198+
199+
__all__ = ["DEFAULT_API_URL", "ProtofaceAPI"]

0 commit comments

Comments
 (0)