Skip to content

Commit 172a2d3

Browse files
committed
Keyframe Labs Plugin Init
1 parent d3e4279 commit 172a2d3

File tree

14 files changed

+535
-0
lines changed

14 files changed

+535
-0
lines changed

CONTRIBUTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ uv pip install pip && uv run mypy --install-types --non-interactive \
9292
-p livekit.plugins.baseten \
9393
-p livekit.plugins.sarvam \
9494
-p livekit.plugins.inworld \
95+
-p livekit.plugins.keyframe \
9596
-p livekit.plugins.simli \
9697
-p livekit.plugins.anam
9798
```
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# LiveKit Keyframe Labs Avatar Agent
2+
3+
This example demonstrates how to create an avatar using [Keyframe Labs](https://keyframelabs.com/).
4+
5+
See available personas [here](https://platform.keyframelabs.com).
6+
7+
## Usage
8+
9+
* Update the environment:
10+
11+
```bash
12+
# Keyframe Config (use either PERSONA_ID or PERSONA_SLUG, not both)
13+
export KEYFRAME_API_KEY="..."
14+
export KEYFRAME_PERSONA_ID="..."
15+
# or: export KEYFRAME_PERSONA_SLUG="public:luna"
16+
17+
# LiveKit config
18+
export LIVEKIT_API_KEY="..."
19+
export LIVEKIT_API_SECRET="..."
20+
export LIVEKIT_URL="..."
21+
```
22+
23+
* Start the agent worker:
24+
25+
```bash
26+
python examples/avatar_agents/keyframe/agent_worker.py dev
27+
```
28+
29+
## Emotion Control
30+
31+
You can change the avatar's facial expression at runtime using `set_emotion()`:
32+
33+
```python
34+
await avatar.set_emotion("happy") # "neutral", "happy", "sad", "angry"
35+
```
36+
37+
To let the LLM control the avatar's expression, register it as a tool:
38+
39+
```python
40+
@session.tool()
41+
async def set_avatar_emotion(emotion: keyframe.Emotion):
42+
"""Set the avatar's facial expression and demeanor."""
43+
await avatar.set_emotion(emotion)
44+
```
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""
2+
Minimal LiveKit agent with Keyframe avatar plugin.
3+
"""
4+
5+
from dotenv import load_dotenv
6+
7+
from livekit.agents import (
8+
Agent,
9+
AgentSession,
10+
JobContext,
11+
RunContext,
12+
WorkerOptions,
13+
cli,
14+
function_tool,
15+
)
16+
from livekit.plugins import keyframe, openai
17+
from livekit.plugins.keyframe import Emotion
18+
19+
load_dotenv()
20+
21+
22+
class AvatarAgent(Agent):
23+
def __init__(self, avatar: keyframe.AvatarSession) -> None:
24+
super().__init__(
25+
instructions=(
26+
"You are a friendly voice assistant with an avatar. "
27+
"Use the set_emotion tool to change your facial expression "
28+
"whenever the conversation mood shifts."
29+
),
30+
)
31+
self._avatar = avatar
32+
33+
@function_tool()
34+
async def set_emotion(self, context: RunContext, emotion: Emotion) -> str:
35+
"""Set the avatar's facial expression to match the conversation mood.
36+
37+
Args:
38+
emotion: The emotion to express. One of 'neutral', 'happy', 'sad', or 'angry'.
39+
"""
40+
await self._avatar.set_emotion(emotion)
41+
return f"Emotion set to {emotion}"
42+
43+
44+
async def entrypoint(ctx: JobContext):
45+
await ctx.connect()
46+
47+
session = AgentSession(
48+
llm=openai.realtime.RealtimeModel(voice="marin"),
49+
)
50+
51+
avatar = keyframe.AvatarSession(persona_slug="public:lyra_persona-1.5-live")
52+
await avatar.start(session, room=ctx.room)
53+
54+
await session.start(
55+
agent=AvatarAgent(avatar=avatar),
56+
room=ctx.room,
57+
)
58+
59+
60+
if __name__ == "__main__":
61+
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# livekit-plugins-keyframe
2+
3+
Agent Framework plugin for [Keyframe Labs](https://keyframelabs.com) avatars.
4+
5+
## Installation
6+
7+
```bash
8+
pip install livekit-plugins-keyframe
9+
```
10+
11+
## Usage
12+
13+
```python
14+
from livekit.agents import AgentSession
15+
from livekit.plugins import keyframe
16+
17+
session = AgentSession(stt=..., llm=..., tts=...)
18+
19+
avatar = keyframe.AvatarSession(
20+
persona_id="ab85a2a0-0555-428d-87b2-ff3019a58b93", # or persona_slug="public:cosmo_persona-1.5-live"
21+
api_key="keyframe_sk_live_...", # or set KEYFRAME_API_KEY env var
22+
)
23+
24+
await avatar.start(session, room=ctx.room)
25+
await session.start(room=ctx.room, agent=my_agent)
26+
```
27+
28+
## Authentication
29+
30+
Set the following environment variables:
31+
32+
- `KEYFRAME_API_KEY` - Your Keyframe API key
33+
- `LIVEKIT_URL` - LiveKit server URL
34+
- `LIVEKIT_API_KEY` - LiveKit API key
35+
- `LIVEKIT_API_SECRET` - LiveKit API secret
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Copyright 2023 LiveKit, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from .avatar import AvatarSession
16+
from .errors import KeyframeException
17+
from .types import Emotion, PersonaConfig
18+
from .version import __version__
19+
20+
__all__ = [
21+
"AvatarSession",
22+
"Emotion",
23+
"KeyframeException",
24+
"PersonaConfig",
25+
"__version__",
26+
]
27+
28+
from livekit.agents import Plugin
29+
30+
from .log import logger
31+
32+
33+
class KeyframePlugin(Plugin):
34+
def __init__(self) -> None:
35+
super().__init__(__name__, __version__, __package__, logger)
36+
37+
38+
Plugin.register_plugin(KeyframePlugin())
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from typing import Any
5+
6+
import aiohttp
7+
8+
from livekit.agents import (
9+
DEFAULT_API_CONNECT_OPTIONS,
10+
APIConnectionError,
11+
APIConnectOptions,
12+
APIStatusError,
13+
)
14+
15+
from .log import logger
16+
17+
DEFAULT_API_URL = "https://api.keyframelabs.com"
18+
19+
20+
class KeyframeAPI:
21+
"""Asynchronous client for the Keyframe Labs session API."""
22+
23+
def __init__(
24+
self,
25+
api_key: str,
26+
api_url: str,
27+
*,
28+
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
29+
session: aiohttp.ClientSession | None = None,
30+
) -> None:
31+
self._api_key = api_key
32+
self._api_url = api_url
33+
self._conn_options = conn_options
34+
self._session = session
35+
self._own_session = session is None
36+
37+
async def __aenter__(self) -> KeyframeAPI:
38+
if self._own_session:
39+
self._session = aiohttp.ClientSession()
40+
return self
41+
42+
async def __aexit__(
43+
self, exc_type: type | None, exc_val: Exception | None, exc_tb: Any
44+
) -> None:
45+
if self._own_session and self._session and not self._session.closed:
46+
await self._session.close()
47+
48+
async def create_plugin_session(
49+
self,
50+
*,
51+
persona_id: str | None = None,
52+
persona_slug: str | None = None,
53+
room_name: str,
54+
livekit_url: str,
55+
livekit_token: str,
56+
source_participant_identity: str,
57+
) -> dict[str, Any]:
58+
"""Create a plugin session via POST /v1/sessions/plugins/livekit.
59+
60+
Returns dict with reservation_id and avatar_participant_identity.
61+
"""
62+
payload: dict[str, Any] = {
63+
"room_name": room_name,
64+
"livekit_url": livekit_url,
65+
"livekit_token": livekit_token,
66+
"source_participant_identity": source_participant_identity,
67+
}
68+
if persona_id:
69+
payload["persona_id"] = persona_id
70+
if persona_slug:
71+
payload["persona_slug"] = persona_slug
72+
73+
headers = {
74+
"Authorization": f"Bearer {self._api_key}",
75+
"Content-Type": "application/json",
76+
}
77+
return await self._post("/v1/sessions/plugins/livekit", payload, headers)
78+
79+
async def _post(
80+
self, endpoint: str, payload: dict[str, Any], headers: dict[str, str]
81+
) -> dict[str, Any]:
82+
url = f"{self._api_url}{endpoint}"
83+
session = self._session or aiohttp.ClientSession()
84+
try:
85+
for i in range(self._conn_options.max_retry + 1):
86+
try:
87+
async with session.post(
88+
url,
89+
headers=headers,
90+
json=payload,
91+
timeout=aiohttp.ClientTimeout(sock_connect=self._conn_options.timeout),
92+
) as response:
93+
if not response.ok:
94+
text = await response.text()
95+
raise APIStatusError(
96+
f"Keyframe API error for {url}: {response.status}",
97+
status_code=response.status,
98+
body=text,
99+
)
100+
return await response.json() # type: ignore
101+
except Exception as e:
102+
if isinstance(e, APIStatusError) and not e.retryable:
103+
raise APIConnectionError(
104+
f"Failed to call Keyframe API at {url} with non-retryable error",
105+
retryable=False,
106+
) from e
107+
108+
if isinstance(e, APIConnectionError):
109+
logger.warning("Failed to call Keyframe API", extra={"error": str(e)})
110+
else:
111+
logger.exception("Failed to call Keyframe API")
112+
113+
if i < self._conn_options.max_retry:
114+
await asyncio.sleep(self._conn_options._interval_for_retry(i))
115+
finally:
116+
if not self._session:
117+
await session.close()
118+
119+
raise APIConnectionError("Failed to call Keyframe API after all retries.")

0 commit comments

Comments
 (0)