elevenagents-livekit-plugin 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: elevenagents-livekit-plugin
3
+ Version: 0.1.0
4
+ Summary: Bridge between LiveKit Agents and ElevenAgents Voice Orchestration
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: livekit>=1.0.0
7
+ Requires-Dist: livekit-api>=1.0.0
8
+ Requires-Dist: fastapi>=0.100.0
9
+ Requires-Dist: uvicorn>=0.20.0
10
+ Requires-Dist: python-dotenv>=1.0.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest; extra == "dev"
13
+ Requires-Dist: pytest-asyncio; extra == "dev"
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "elevenagents-livekit-plugin"
7
+ version = "0.1.0"
8
+ description = "Bridge between LiveKit Agents and ElevenAgents Voice Orchestration"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "livekit>=1.0.0",
12
+ "livekit-api>=1.0.0",
13
+ "fastapi>=0.100.0",
14
+ "uvicorn>=0.20.0",
15
+ "python-dotenv>=1.0.0",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = ["pytest", "pytest-asyncio"]
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from .bridge import ElevenAgentsBridge
2
+ from .tools import elevenagents_tools
3
+
4
+ __all__ = ["ElevenAgentsBridge", "elevenagents_tools"]
5
+ __version__ = "0.1.0"
@@ -0,0 +1,93 @@
1
+ """Converts LiveKit text chunks and tool calls into OpenAI-compatible SSE format."""
2
+
3
+ import json
4
+ import time
5
+ import uuid
6
+
7
+
8
+ def _make_id() -> str:
9
+ return f"chatcmpl-{uuid.uuid4().hex[:12]}"
10
+
11
+
12
+ def format_chunk(content: str, model: str = "livekit-agent") -> str:
13
+ """Format a text chunk as an OpenAI chat completion SSE chunk."""
14
+ chunk = {
15
+ "id": _make_id(),
16
+ "object": "chat.completion.chunk",
17
+ "created": int(time.time()),
18
+ "model": model,
19
+ "choices": [
20
+ {
21
+ "index": 0,
22
+ "delta": {"content": content},
23
+ "finish_reason": None,
24
+ }
25
+ ],
26
+ }
27
+ return f"data: {json.dumps(chunk)}\n\n"
28
+
29
+
30
+ def format_first_chunk(model: str = "livekit-agent") -> str:
31
+ """Format the initial SSE chunk with the role."""
32
+ chunk = {
33
+ "id": _make_id(),
34
+ "object": "chat.completion.chunk",
35
+ "created": int(time.time()),
36
+ "model": model,
37
+ "choices": [
38
+ {
39
+ "index": 0,
40
+ "delta": {"role": "assistant", "content": ""},
41
+ "finish_reason": None,
42
+ }
43
+ ],
44
+ }
45
+ return f"data: {json.dumps(chunk)}\n\n"
46
+
47
+
48
+ def format_tool_call(tool_name: str, tool_args: dict, model: str = "livekit-agent") -> str:
49
+ """Format a tool call as an OpenAI chat completion SSE chunk."""
50
+ chunk = {
51
+ "id": _make_id(),
52
+ "object": "chat.completion.chunk",
53
+ "created": int(time.time()),
54
+ "model": model,
55
+ "choices": [
56
+ {
57
+ "index": 0,
58
+ "delta": {
59
+ "tool_calls": [
60
+ {
61
+ "index": 0,
62
+ "id": f"call_{uuid.uuid4().hex[:8]}",
63
+ "type": "function",
64
+ "function": {
65
+ "name": tool_name,
66
+ "arguments": json.dumps(tool_args),
67
+ },
68
+ }
69
+ ]
70
+ },
71
+ "finish_reason": None,
72
+ }
73
+ ],
74
+ }
75
+ return f"data: {json.dumps(chunk)}\n\n"
76
+
77
+
78
+ def format_done_chunk(model: str = "livekit-agent") -> str:
79
+ """Format the final SSE chunk with finish_reason=stop."""
80
+ chunk = {
81
+ "id": _make_id(),
82
+ "object": "chat.completion.chunk",
83
+ "created": int(time.time()),
84
+ "model": model,
85
+ "choices": [
86
+ {
87
+ "index": 0,
88
+ "delta": {},
89
+ "finish_reason": "stop",
90
+ }
91
+ ],
92
+ }
93
+ return f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n"
@@ -0,0 +1,83 @@
1
+ """Main entry point — wires up the LiveKit client and HTTP server."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+
7
+ import uvicorn
8
+ from dotenv import load_dotenv
9
+
10
+ from .livekit_client import LiveKitClient
11
+ from .server import create_app
12
+
13
+ logger = logging.getLogger("elevenagents-livekit-plugin")
14
+
15
+
16
+ class ElevenAgentsBridge:
17
+ def __init__(
18
+ self,
19
+ *,
20
+ livekit_url: str | None = None,
21
+ api_key: str | None = None,
22
+ api_secret: str | None = None,
23
+ room_name: str = "elevenagents-bridge",
24
+ identity: str = "elevenagents-bridge",
25
+ port: int = 8013,
26
+ host: str = "0.0.0.0",
27
+ buffer_words: str = "... ",
28
+ ):
29
+ load_dotenv()
30
+
31
+ self.livekit_url = livekit_url or os.getenv("LIVEKIT_URL", "")
32
+ self.api_key = api_key or os.getenv("LIVEKIT_API_KEY", "")
33
+ self.api_secret = api_secret or os.getenv("LIVEKIT_API_SECRET", "")
34
+ self.room_name = room_name
35
+ self.identity = identity
36
+ self.port = port
37
+ self.host = host
38
+
39
+ if not all([self.livekit_url, self.api_key, self.api_secret]):
40
+ raise ValueError(
41
+ "LIVEKIT_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must be "
42
+ "set via arguments or environment variables."
43
+ )
44
+
45
+ self.lk_client = LiveKitClient(
46
+ url=self.livekit_url,
47
+ api_key=self.api_key,
48
+ api_secret=self.api_secret,
49
+ room_name=self.room_name,
50
+ identity=self.identity,
51
+ )
52
+ self.app = create_app(self.lk_client, buffer_words=buffer_words)
53
+
54
+ def run(self) -> None:
55
+ """Start the bridge (blocking)."""
56
+ asyncio.run(self._run())
57
+
58
+ async def _run(self) -> None:
59
+ # Connect to LiveKit room
60
+ await self.lk_client.connect()
61
+ logger.info(
62
+ "Bridge connected to LiveKit at %s, room '%s'",
63
+ self.livekit_url,
64
+ self.room_name,
65
+ )
66
+
67
+ # Start HTTP server
68
+ config = uvicorn.Config(
69
+ self.app, host=self.host, port=self.port, log_level="info"
70
+ )
71
+ server = uvicorn.Server(config)
72
+
73
+ logger.info("Starting server on %s:%d", self.host, self.port)
74
+ logger.info(
75
+ "ElevenLabs custom LLM endpoint: http://%s:%d/v1",
76
+ self.host,
77
+ self.port,
78
+ )
79
+
80
+ try:
81
+ await server.serve()
82
+ finally:
83
+ await self.lk_client.disconnect()
@@ -0,0 +1,190 @@
1
+ """Connects to a LiveKit room and relays text to/from the agent."""
2
+
3
+ import asyncio
4
+ import datetime
5
+ import json
6
+ import logging
7
+ from dataclasses import dataclass
8
+ from typing import AsyncGenerator, Optional
9
+
10
+ from livekit.api import AccessToken, LiveKitAPI, VideoGrants
11
+ from livekit.protocol import agent_dispatch
12
+ from livekit.rtc import Room, TextStreamReader
13
+
14
+ from .tools import TOOL_SIGNAL_PREFIX
15
+
16
+ logger = logging.getLogger("elevenagents-livekit-plugin.client")
17
+
18
+
19
+ @dataclass
20
+ class StreamEvent:
21
+ """An event in the response stream — either a text chunk or a tool call."""
22
+
23
+ type: str # "text" or "tool_call"
24
+ content: str = ""
25
+ tool_name: str = ""
26
+ tool_args: dict = None
27
+
28
+ def __post_init__(self):
29
+ if self.tool_args is None:
30
+ self.tool_args = {}
31
+
32
+
33
+ class LiveKitClient:
34
+ def __init__(
35
+ self,
36
+ url: str,
37
+ api_key: str,
38
+ api_secret: str,
39
+ room_name: str = "elevenagents-bridge",
40
+ identity: str = "elevenagents-bridge",
41
+ ):
42
+ self.url = url
43
+ self.api_key = api_key
44
+ self.api_secret = api_secret
45
+ self.room_name = room_name
46
+ self.identity = identity
47
+ self.room: Optional[Room] = None
48
+ self._response_queue: asyncio.Queue[Optional[StreamEvent]] = asyncio.Queue()
49
+ self._connected = False
50
+
51
+ def _generate_token(self) -> str:
52
+ token = AccessToken(
53
+ api_key=self.api_key,
54
+ api_secret=self.api_secret,
55
+ )
56
+ token.with_identity(self.identity)
57
+ token.with_grants(
58
+ VideoGrants(
59
+ room_join=True,
60
+ room=self.room_name,
61
+ can_publish=True,
62
+ can_subscribe=True,
63
+ )
64
+ )
65
+ token.with_ttl(datetime.timedelta(hours=24))
66
+ return token.to_jwt()
67
+
68
+ async def connect(self) -> None:
69
+ """Connect to the LiveKit room and register text stream handlers."""
70
+ self.room = Room()
71
+
72
+ def on_stream(reader: TextStreamReader, participant_identity: str) -> None:
73
+ # Skip our own messages (echo from send_text)
74
+ if participant_identity == self.identity:
75
+ return
76
+ logger.debug("Stream from %s", participant_identity)
77
+ asyncio.ensure_future(self._handle_stream(reader))
78
+
79
+ self.room.register_text_stream_handler("lk.chat", on_stream)
80
+ self.room.register_text_stream_handler("lk.transcription", on_stream)
81
+
82
+ token = self._generate_token()
83
+ await self.room.connect(self.url, token)
84
+ self._connected = True
85
+ logger.info(
86
+ "Connected to room '%s' as '%s'", self.room_name, self.identity
87
+ )
88
+ await self._dispatch_agent()
89
+
90
+ async def _dispatch_agent(self) -> None:
91
+ """Dispatch a LiveKit agent to join this room."""
92
+ http_url = self.url.replace("ws://", "http://").replace("wss://", "https://")
93
+ api = LiveKitAPI(http_url, self.api_key, self.api_secret)
94
+ try:
95
+ await api.agent_dispatch.create_dispatch(
96
+ agent_dispatch.CreateAgentDispatchRequest(room=self.room_name)
97
+ )
98
+ logger.info("Dispatched agent to room '%s'", self.room_name)
99
+ except Exception as e:
100
+ logger.warning("Failed to dispatch agent: %s", e)
101
+ finally:
102
+ await api.aclose()
103
+
104
+ async def _handle_stream(self, reader: TextStreamReader) -> None:
105
+ """Handle a text stream — detect tool signals, otherwise stream text."""
106
+ first = True
107
+ is_tool = False
108
+ tool_chunks: list[str] = []
109
+
110
+ async for chunk in reader:
111
+ if first:
112
+ first = False
113
+ if chunk.startswith(TOOL_SIGNAL_PREFIX):
114
+ is_tool = True
115
+ tool_chunks.append(chunk)
116
+ continue
117
+
118
+ if is_tool:
119
+ tool_chunks.append(chunk)
120
+ else:
121
+ await self._response_queue.put(
122
+ StreamEvent(type="text", content=chunk)
123
+ )
124
+
125
+ if is_tool:
126
+ full = "".join(tool_chunks)
127
+ json_str = full[len(TOOL_SIGNAL_PREFIX):]
128
+ try:
129
+ data = json.loads(json_str)
130
+ tool_name = data.pop("tool")
131
+ await self._response_queue.put(
132
+ StreamEvent(type="tool_call", tool_name=tool_name, tool_args=data)
133
+ )
134
+ logger.info("Tool call: %s", tool_name)
135
+ except (json.JSONDecodeError, KeyError) as e:
136
+ logger.warning("Bad tool signal: %s", e)
137
+ else:
138
+ # Text stream done
139
+ await self._response_queue.put(None)
140
+
141
+ async def _ensure_agent_in_room(self) -> None:
142
+ """Re-dispatch agent if none present."""
143
+ for p in self.room.remote_participants.values():
144
+ if p.kind == 4:
145
+ return
146
+ logger.info("No agent in room, re-dispatching...")
147
+ await self._dispatch_agent()
148
+ for _ in range(20):
149
+ await asyncio.sleep(0.5)
150
+ for p in self.room.remote_participants.values():
151
+ if p.kind == 4:
152
+ logger.info("Agent joined: %s", p.identity)
153
+ return
154
+ logger.warning("Agent did not join after re-dispatch")
155
+
156
+ async def send_and_stream(
157
+ self, text: str, timeout: float = 30.0, tool_wait: float = 0.5
158
+ ) -> AsyncGenerator[StreamEvent, None]:
159
+ """Send text to the agent and yield streamed response events."""
160
+ if not self._connected or self.room is None:
161
+ raise RuntimeError("Not connected to LiveKit room")
162
+
163
+ await self._ensure_agent_in_room()
164
+
165
+ # Drain stale events
166
+ while not self._response_queue.empty():
167
+ self._response_queue.get_nowait()
168
+
169
+ await self.room.local_participant.send_text(text, topic="lk.chat")
170
+ logger.info("Sent: %s", text[:100])
171
+
172
+ text_done = False
173
+ while True:
174
+ try:
175
+ wait = tool_wait if text_done else timeout
176
+ event = await asyncio.wait_for(
177
+ self._response_queue.get(), timeout=wait
178
+ )
179
+ except asyncio.TimeoutError:
180
+ break
181
+ if event is None:
182
+ text_done = True
183
+ continue
184
+ yield event
185
+
186
+ async def disconnect(self) -> None:
187
+ if self.room:
188
+ await self.room.disconnect()
189
+ self._connected = False
190
+ logger.info("Disconnected from room")
@@ -0,0 +1,92 @@
1
+ """FastAPI server exposing an OpenAI-compatible /v1/chat/completions endpoint."""
2
+
3
+ import json
4
+ import logging
5
+
6
+ import fastapi
7
+ from fastapi.responses import StreamingResponse
8
+
9
+ from .adapter import format_chunk, format_done_chunk, format_first_chunk, format_tool_call
10
+ from .livekit_client import LiveKitClient
11
+
12
+ logging.basicConfig(level=logging.DEBUG)
13
+ logger = logging.getLogger("elevenagents-livekit-plugin.server")
14
+
15
+
16
+ def extract_text(content) -> str:
17
+ """Extract text from content that may be a string or a list of content parts."""
18
+ if isinstance(content, str):
19
+ return content
20
+ if isinstance(content, list):
21
+ parts = []
22
+ for part in content:
23
+ if isinstance(part, dict) and part.get("type") == "text":
24
+ parts.append(part.get("text", ""))
25
+ elif isinstance(part, str):
26
+ parts.append(part)
27
+ return " ".join(parts)
28
+ return str(content) if content else ""
29
+
30
+
31
+ DEFAULT_BUFFER_WORDS = "... "
32
+
33
+
34
+ def create_app(lk_client: LiveKitClient, buffer_words: str = DEFAULT_BUFFER_WORDS) -> fastapi.FastAPI:
35
+ app = fastapi.FastAPI(title="ElevenAgents LiveKit Plugin")
36
+
37
+ @app.post("/v1/chat/completions")
38
+ async def chat_completions(request: fastapi.Request) -> StreamingResponse:
39
+ body = await request.json()
40
+ logger.debug("Incoming request body: %s", json.dumps(body, indent=2))
41
+
42
+ messages = body.get("messages", [])
43
+ if not messages:
44
+ return fastapi.responses.JSONResponse(
45
+ {"error": "No messages provided"}, status_code=400
46
+ )
47
+
48
+ # Extract the latest user message (content can be string or list)
49
+ user_message = None
50
+ for msg in reversed(messages):
51
+ if msg.get("role") == "user":
52
+ user_message = extract_text(msg.get("content", ""))
53
+ break
54
+
55
+ if not user_message:
56
+ return fastapi.responses.JSONResponse(
57
+ {"error": "No user message found"}, status_code=400
58
+ )
59
+
60
+ logger.debug("Extracted user message: %s", user_message)
61
+ model = body.get("model", "livekit-agent")
62
+
63
+ async def event_stream():
64
+ yield format_first_chunk(model)
65
+ # Send buffer words immediately so ElevenLabs doesn't time out
66
+ if buffer_words:
67
+ yield format_chunk(buffer_words, model)
68
+ try:
69
+ async for event in lk_client.send_and_stream(user_message):
70
+ if event.type == "text":
71
+ yield format_chunk(event.content, model)
72
+ elif event.type == "tool_call":
73
+ yield format_tool_call(
74
+ event.tool_name, event.tool_args, model
75
+ )
76
+ logger.info(
77
+ "Forwarding tool call to ElevenLabs: %s",
78
+ event.tool_name,
79
+ )
80
+ yield format_done_chunk(model)
81
+ except Exception as e:
82
+ logger.error("Error streaming response: %s", str(e))
83
+ yield format_chunk(f"Error: {str(e)}", model)
84
+ yield format_done_chunk(model)
85
+
86
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
87
+
88
+ @app.get("/health")
89
+ async def health():
90
+ return {"status": "ok", "connected": lk_client._connected}
91
+
92
+ return app
@@ -0,0 +1,99 @@
1
+ """
2
+ Pre-built ElevenLabs tools for LiveKit agents.
3
+
4
+ Usage:
5
+ from elevenagents_livekit_plugin import elevenagents_tools
6
+
7
+ class MyAgent(Agent):
8
+ def __init__(self):
9
+ super().__init__(
10
+ instructions="...",
11
+ tools=[*elevenagents_tools()],
12
+ )
13
+
14
+ When the agent's LLM calls these tools, they signal back to the bridge
15
+ via the lk.chat TextStream topic. The bridge picks up the signal and
16
+ includes the tool call in the SSE response back to ElevenLabs Voice Engine.
17
+ """
18
+
19
+ import json
20
+ import logging
21
+
22
+ from livekit.agents import RunContext, function_tool
23
+
24
+ logger = logging.getLogger("elevenagents-livekit-plugin.tools")
25
+
26
+ TOOL_SIGNAL_PREFIX = "__ELEVENAGENTS_TOOL__:"
27
+
28
+
29
+ async def _send_tool_signal(ctx: RunContext, tool_name: str, args: dict) -> None:
30
+ """Send a tool call signal to the bridge via TextStream on lk.chat topic."""
31
+ room = ctx.session.room_io.room
32
+ payload = TOOL_SIGNAL_PREFIX + json.dumps({"tool": tool_name, **args})
33
+ await room.local_participant.send_text(payload, topic="lk.chat")
34
+ logger.info("Sent tool signal: %s", tool_name)
35
+
36
+
37
+ @function_tool(name="end_call")
38
+ async def _end_call(
39
+ ctx: RunContext, reason: str, system__message_to_speak: str = ""
40
+ ) -> str:
41
+ """End the current voice call. Call this when:
42
+ 1. The user says goodbye or wants to end the conversation.
43
+ 2. The main task has been completed and the user is satisfied.
44
+ 3. The conversation has reached a natural conclusion.
45
+
46
+ Args:
47
+ reason: Why the call is ending.
48
+ system__message_to_speak: A farewell message to speak before ending.
49
+ """
50
+ await _send_tool_signal(
51
+ ctx,
52
+ "end_call",
53
+ {"reason": reason, "system__message_to_speak": system__message_to_speak},
54
+ )
55
+ return system__message_to_speak if system__message_to_speak else "Ending the call."
56
+
57
+
58
+ @function_tool(name="skip_turn")
59
+ async def _skip_turn(ctx: RunContext, reason: str = "") -> str:
60
+ """Skip the agent's turn and wait silently for the user to speak.
61
+ Call this when the user says they need a moment to think, are
62
+ looking something up, or otherwise need a pause.
63
+
64
+ Args:
65
+ reason: Why the pause is needed.
66
+ """
67
+ await _send_tool_signal(ctx, "skip_turn", {"reason": reason})
68
+ return ""
69
+
70
+
71
+ @function_tool(name="language_detection")
72
+ async def _language_detection(ctx: RunContext, reason: str, language: str) -> str:
73
+ """Switch the conversation language. ONLY call this tool by itself with
74
+ NO text response. ONLY call ONCE when the user FIRST speaks in a different
75
+ language or explicitly requests a language change. Do NOT call again if you
76
+ are already responding in the target language.
77
+
78
+ Args:
79
+ reason: Why the language switch is needed.
80
+ language: The language code to switch to (e.g. 'es', 'fr', 'de', 'ja').
81
+ """
82
+ await _send_tool_signal(
83
+ ctx, "language_detection", {"reason": reason, "language": language}
84
+ )
85
+ return ""
86
+
87
+
88
+ def elevenagents_tools() -> list:
89
+ """Return all ElevenLabs tools for use in a LiveKit agent.
90
+
91
+ Example:
92
+ class MyAgent(Agent):
93
+ def __init__(self):
94
+ super().__init__(
95
+ instructions="You are a helpful assistant.",
96
+ tools=[*elevenagents_tools()],
97
+ )
98
+ """
99
+ return [_end_call, _skip_turn, _language_detection]
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: elevenagents-livekit-plugin
3
+ Version: 0.1.0
4
+ Summary: Bridge between LiveKit Agents and ElevenAgents Voice Orchestration
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: livekit>=1.0.0
7
+ Requires-Dist: livekit-api>=1.0.0
8
+ Requires-Dist: fastapi>=0.100.0
9
+ Requires-Dist: uvicorn>=0.20.0
10
+ Requires-Dist: python-dotenv>=1.0.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest; extra == "dev"
13
+ Requires-Dist: pytest-asyncio; extra == "dev"
@@ -0,0 +1,12 @@
1
+ pyproject.toml
2
+ src/elevenagents_livekit_plugin/__init__.py
3
+ src/elevenagents_livekit_plugin/adapter.py
4
+ src/elevenagents_livekit_plugin/bridge.py
5
+ src/elevenagents_livekit_plugin/livekit_client.py
6
+ src/elevenagents_livekit_plugin/server.py
7
+ src/elevenagents_livekit_plugin/tools.py
8
+ src/elevenagents_livekit_plugin.egg-info/PKG-INFO
9
+ src/elevenagents_livekit_plugin.egg-info/SOURCES.txt
10
+ src/elevenagents_livekit_plugin.egg-info/dependency_links.txt
11
+ src/elevenagents_livekit_plugin.egg-info/requires.txt
12
+ src/elevenagents_livekit_plugin.egg-info/top_level.txt
@@ -0,0 +1,9 @@
1
+ livekit>=1.0.0
2
+ livekit-api>=1.0.0
3
+ fastapi>=0.100.0
4
+ uvicorn>=0.20.0
5
+ python-dotenv>=1.0.0
6
+
7
+ [dev]
8
+ pytest
9
+ pytest-asyncio