-
Notifications
You must be signed in to change notification settings - Fork 2.8k
LemonSlice Plugin #4539
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
Merged
tinalenguyen
merged 10 commits into
livekit:main
from
lemonsliceai:jp/lemonslice-plugin
Jan 20, 2026
Merged
LemonSlice Plugin #4539
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
306db5a
LemonSlice Plugin
jp-lemon fdfa333
linted and formatted lemonslice files
jp-lemon 6484ede
Fixed typing issues
jp-lemon 9ca787a
url typo fix
jp-lemon ccd7a69
Added LemonSlice to avatar_agents/README
jp-lemon 1cb8153
validate LEMONSLICE_IMAGE_URL before use
jp-lemon 7600927
remove duplicate https
jp-lemon 30429e6
Updated lemonslice api retry logic
jp-lemon a96583e
session lifecycle handling + livekit arg handling
jp-lemon 23d795f
Livekit params are required + NOT_GIVEN check
jp-lemon File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| # LiveKit LemonSlice Avatar Agent | ||
|
|
||
| This example demonstrates how to create an animated avatar using [LemonSlice](https://www.lemonslice.com/). | ||
|
|
||
| ## Usage | ||
|
|
||
| * Update the environment: | ||
|
|
||
| ```bash | ||
| # LemonSlice Config | ||
| export LEMONSLICE_API_KEY="..." | ||
| export LEMONSLICE_IMAGE_URL="..." # Publicly accessible image url for the avatar. | ||
|
|
||
| # STT + LLM + TTS config | ||
| export OPENAI_API_KEY="..." | ||
| export DEEPGRAM_API_KEY="..." | ||
|
|
||
| # LiveKit config | ||
| export LIVEKIT_API_KEY="..." | ||
| export LIVEKIT_API_SECRET="..." | ||
| export LIVEKIT_URL="..." | ||
| ``` | ||
|
|
||
| * Start the agent worker: | ||
|
|
||
| ```bash | ||
| python examples/avatar_agents/lemonslice/agent_worker.py dev | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import logging | ||
| import os | ||
|
|
||
| from dotenv import load_dotenv | ||
|
|
||
| from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli, inference | ||
| from livekit.plugins import lemonslice | ||
|
|
||
| logger = logging.getLogger("lemonslice-avatar-example") | ||
| logger.setLevel(logging.INFO) | ||
|
|
||
| load_dotenv() | ||
|
|
||
|
|
||
| server = AgentServer() | ||
|
|
||
|
|
||
| @server.rtc_session() | ||
| async def entrypoint(ctx: JobContext): | ||
| session = AgentSession( | ||
| stt=inference.STT("deepgram/nova-3"), | ||
| llm=inference.LLM("google/gemini-2.5-flash"), | ||
| tts=inference.TTS("cartesia/sonic-3"), | ||
| resume_false_interruption=False, | ||
| ) | ||
|
|
||
| lemonslice_image_url = os.getenv("LEMONSLICE_IMAGE_URL") | ||
| if lemonslice_image_url is None: | ||
| raise ValueError("LEMONSLICE_IMAGE_URL must be set") | ||
| avatar = lemonslice.AvatarSession( | ||
| agent_image_url=lemonslice_image_url, | ||
| # Prompt to guide the avatar's movements | ||
| agent_prompt="Be expressive in your movements and use your hands while talking.", | ||
| ) | ||
| await avatar.start(session, room=ctx.room) | ||
|
|
||
| agent = Agent(instructions="Talk to me!") | ||
|
|
||
| await session.start( | ||
| agent=agent, | ||
| room=ctx.room, | ||
| ) | ||
|
|
||
| session.generate_reply(instructions="say hello to the user") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| cli.run_app(server) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| # LemonSlice virtual avatar plugin for LiveKit Agents | ||
|
|
||
| Support for the [LemonSlice](https://www.lemonslice.com/) virtual avatar. | ||
|
|
||
| See [https://docs.livekit.io/agents/models/avatar/plugins/lemonslice/](https://docs.livekit.io/agents/models/avatar/plugins/lemonslice/) for more information. | ||
|
|
40 changes: 40 additions & 0 deletions
40
livekit-plugins/livekit-plugins-lemonslice/livekit/plugins/lemonslice/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| # Copyright 2023 LiveKit, Inc. | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| """LemonSlice virtual avatar plugin for LiveKit Agents | ||
|
|
||
| See https://docs.livekit.io/agents/models/avatar/plugins/lemonslice/ for more information. | ||
| """ | ||
|
|
||
| from .api import LemonSliceException | ||
| from .avatar import AvatarSession | ||
| from .version import __version__ | ||
|
|
||
| __all__ = [ | ||
| "LemonSliceException", | ||
| "AvatarSession", | ||
| "__version__", | ||
| ] | ||
|
|
||
| from livekit.agents import Plugin | ||
|
|
||
| from .log import logger | ||
|
|
||
|
|
||
| class LemonSlicePlugin(Plugin): | ||
| def __init__(self) -> None: | ||
| super().__init__(__name__, __version__, __package__, logger) | ||
|
|
||
|
|
||
| Plugin.register_plugin(LemonSlicePlugin()) |
173 changes: 173 additions & 0 deletions
173
livekit-plugins/livekit-plugins-lemonslice/livekit/plugins/lemonslice/api.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| from __future__ import annotations | ||
|
|
||
| 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 LemonSliceException(Exception): | ||
| """Exception for LemonSlice errors""" | ||
|
|
||
|
|
||
| DEFAULT_API_URL = "https://lemonslice.com/api/liveai/sessions" | ||
|
|
||
|
|
||
| class LemonSliceAPI: | ||
| 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: | ||
| """ | ||
| Initializes the LemonSliceAPI client. | ||
|
|
||
| Args: | ||
| api_key: Your LemonSlice API key. If not provided, it is read from | ||
| the LEMONSLICE_API_KEY environment variable. | ||
| api_url: The base URL of the LemonSlice API. | ||
| conn_options: Connection options for the aiohttp session. | ||
| session: An optional existing aiohttp.ClientSession to use for requests. | ||
| """ | ||
| ls_api_key = api_key if utils.is_given(api_key) else os.getenv("LEMONSLICE_API_KEY") | ||
| if not ls_api_key: | ||
| raise LemonSliceException("LEMONSLICE_API_KEY must be set") | ||
| self._api_key = ls_api_key | ||
|
|
||
| self._api_url = api_url or DEFAULT_API_URL | ||
| self._conn_options = conn_options | ||
| self._session = session | ||
| self._owns_session = session is None | ||
|
|
||
| async def __aenter__(self) -> LemonSliceAPI: | ||
| if self._owns_session: | ||
| self._session = aiohttp.ClientSession() | ||
| return self | ||
|
|
||
| async def __aexit__( | ||
| self, exc_type: type | None, exc_val: Exception | None, exc_tb: Any | ||
| ) -> None: | ||
| if self._owns_session and self._session and not self._session.closed: | ||
| await self._session.close() | ||
|
|
||
tinalenguyen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| async def start_agent_session( | ||
| self, | ||
| *, | ||
| livekit_url: str, | ||
| livekit_token: str, | ||
| agent_id: NotGivenOr[str] = NOT_GIVEN, | ||
| agent_image_url: NotGivenOr[str] = NOT_GIVEN, | ||
| agent_prompt: NotGivenOr[str] = NOT_GIVEN, | ||
| idle_timeout: NotGivenOr[int] = NOT_GIVEN, | ||
| extra_payload: NotGivenOr[dict[str, Any]] = NOT_GIVEN, | ||
| ) -> str: | ||
| """ | ||
| Initiates a new LemonSlice agent session. | ||
|
|
||
| Args: | ||
| livekit_url: The LiveKit Cloud server URL. | ||
| livekit_token: The LiveKit access token for the agent. | ||
| agent_id: The ID of the LemonSlice agent to add to the session. | ||
| agent_image_url: The URL of the image to use as the agent's avatar. | ||
| agent_prompt: A prompt that subtly influences the avatar's movements and expressions. | ||
| idle_timeout: The idle timeout, in seconds. | ||
| extra_payload: Additional payload to include in the request. | ||
|
|
||
| Returns: | ||
| The unique session ID for the LemonSlice agent session. | ||
| """ | ||
| if not utils.is_given(agent_id) and not utils.is_given(agent_image_url): | ||
| raise LemonSliceException("Missing agent_id or agent_image_url") | ||
|
|
||
| if utils.is_given(agent_id) and utils.is_given(agent_image_url): | ||
| raise LemonSliceException("Only one of agent_id or agent_image_url can be provided") | ||
|
|
||
| payload: dict[str, Any] = { | ||
| "transport_type": "livekit", | ||
| "properties": { | ||
| "livekit_url": livekit_url, | ||
| "livekit_token": livekit_token, | ||
| }, | ||
| } | ||
|
|
||
| if utils.is_given(agent_id): | ||
| payload["agent_id"] = agent_id | ||
| if utils.is_given(agent_image_url): | ||
| payload["agent_image_url"] = agent_image_url | ||
| if utils.is_given(agent_prompt): | ||
| payload["agent_prompt"] = agent_prompt | ||
| if utils.is_given(idle_timeout): | ||
| payload["idle_timeout"] = idle_timeout | ||
| if utils.is_given(extra_payload): | ||
| payload.update(extra_payload) | ||
|
|
||
| response_data = await self._post(payload) | ||
| return response_data["session_id"] # type: ignore | ||
|
|
||
| async def _post(self, payload: dict[str, Any]) -> dict[str, Any]: | ||
| """ | ||
| Make a POST request to the LemonSlice API with retry logic. | ||
|
|
||
| Args: | ||
| payload: JSON payload for the request | ||
|
|
||
| Returns: | ||
| Response data as a dictionary | ||
|
|
||
| Raises: | ||
| APIConnectionError: If the request fails after all retries | ||
| """ | ||
| session = self._session or aiohttp.ClientSession() | ||
| try: | ||
| for i in range(self._conn_options.max_retry + 1): | ||
| try: | ||
| async with session.post( | ||
| self._api_url, | ||
| 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, APIStatusError) and not e.retryable: | ||
| raise APIConnectionError( | ||
| "Failed to call LemonSlice API with non-retryable error", | ||
| retryable=False, | ||
| ) from e | ||
|
|
||
| if isinstance(e, APIConnectionError): | ||
| logger.warning("failed to call LemonSlice api", extra={"error": str(e)}) | ||
| else: | ||
| logger.exception("failed to call lemonslice api") | ||
|
|
||
| if i < self._conn_options.max_retry: | ||
| await asyncio.sleep(self._conn_options._interval_for_retry(i)) | ||
| finally: | ||
| if not self._session: # if we created the session, we close it | ||
| await session.close() | ||
|
|
||
| raise APIConnectionError("Failed to call LemonSlice API after all retries") | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.