vent-livekit 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,10 @@
1
+ node_modules/
2
+ dist/
3
+ .next/
4
+ .turbo/
5
+ .env*
6
+ *.tsbuildinfo
7
+ .DS_Store
8
+ coverage/
9
+ .context/
10
+ tasks/
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: vent-livekit
3
+ Version: 0.1.0
4
+ Summary: Vent helper for forwarding LiveKit Agents SDK observability
5
+ Project-URL: Homepage, https://venthq.dev
6
+ Project-URL: Repository, https://github.com/vent-hq/vent
7
+ License-Expression: MIT
8
+ Keywords: agents,livekit,observability,vent,voice
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Multimedia :: Sound/Audio
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+
22
+ # vent-livekit
23
+
24
+ Thin helper for forwarding LiveKit Agents SDK observability into Vent.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install vent-livekit
30
+ ```
31
+
32
+ ## What it does
33
+
34
+ `instrument_livekit_agent()` automatically publishes the existing `vent:*` topics that the Vent LiveKit adapter already understands:
35
+
36
+ - `vent:metrics`
37
+ - `vent:function-tools-executed`
38
+ - `vent:conversation-item`
39
+ - `vent:user-input-transcribed`
40
+ - `vent:session-usage`
41
+ - `vent:session-report`
42
+
43
+ It subscribes to:
44
+
45
+ - `metrics_collected`
46
+ - `function_tools_executed`
47
+ - `conversation_item_added`
48
+ - `user_input_transcribed`
49
+ - `session_usage_updated`
50
+ - `close`
51
+
52
+ And, when `ctx.add_shutdown_callback()` / `ctx.make_session_report()` are available, it flushes a session report on shutdown.
53
+
54
+ ## Example
55
+
56
+ ```python
57
+ from vent_livekit import instrument_livekit_agent
58
+
59
+ vent = instrument_livekit_agent(ctx=ctx, session=session)
60
+ ```
61
+
62
+ If you have extra metadata that the outside room observer cannot already see, you can pass it explicitly:
63
+
64
+ ```python
65
+ vent = instrument_livekit_agent(
66
+ ctx=ctx,
67
+ session=session,
68
+ session_metadata={
69
+ "provider_call_id": "pstn-call-123",
70
+ },
71
+ debug_urls={"insight": "https://..."},
72
+ )
73
+ ```
74
+
75
+ ## Notes
76
+
77
+ - This keeps `vent:*` as the internal wire format, but the user no longer needs to hand-author those messages.
78
+ - Transcript, room/session identity, and timing should still come from native LiveKit room signals (`lk.transcription`, `lk.agent.state`, room name/sid).
79
+ - For the Node.js equivalent, use `@vent-hq/livekit` (`npm install @vent-hq/livekit`).
@@ -0,0 +1,58 @@
1
+ # vent-livekit
2
+
3
+ Thin helper for forwarding LiveKit Agents SDK observability into Vent.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install vent-livekit
9
+ ```
10
+
11
+ ## What it does
12
+
13
+ `instrument_livekit_agent()` automatically publishes the existing `vent:*` topics that the Vent LiveKit adapter already understands:
14
+
15
+ - `vent:metrics`
16
+ - `vent:function-tools-executed`
17
+ - `vent:conversation-item`
18
+ - `vent:user-input-transcribed`
19
+ - `vent:session-usage`
20
+ - `vent:session-report`
21
+
22
+ It subscribes to:
23
+
24
+ - `metrics_collected`
25
+ - `function_tools_executed`
26
+ - `conversation_item_added`
27
+ - `user_input_transcribed`
28
+ - `session_usage_updated`
29
+ - `close`
30
+
31
+ And, when `ctx.add_shutdown_callback()` / `ctx.make_session_report()` are available, it flushes a session report on shutdown.
32
+
33
+ ## Example
34
+
35
+ ```python
36
+ from vent_livekit import instrument_livekit_agent
37
+
38
+ vent = instrument_livekit_agent(ctx=ctx, session=session)
39
+ ```
40
+
41
+ If you have extra metadata that the outside room observer cannot already see, you can pass it explicitly:
42
+
43
+ ```python
44
+ vent = instrument_livekit_agent(
45
+ ctx=ctx,
46
+ session=session,
47
+ session_metadata={
48
+ "provider_call_id": "pstn-call-123",
49
+ },
50
+ debug_urls={"insight": "https://..."},
51
+ )
52
+ ```
53
+
54
+ ## Notes
55
+
56
+ - This keeps `vent:*` as the internal wire format, but the user no longer needs to hand-author those messages.
57
+ - Transcript, room/session identity, and timing should still come from native LiveKit room signals (`lk.transcription`, `lk.agent.state`, room name/sid).
58
+ - For the Node.js equivalent, use `@vent-hq/livekit` (`npm install @vent-hq/livekit`).
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "vent-livekit"
7
+ version = "0.1.0"
8
+ description = "Vent helper for forwarding LiveKit Agents SDK observability"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ keywords = ["vent", "livekit", "voice", "agents", "observability"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Multimedia :: Sound/Audio",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://venthq.dev"
28
+ Repository = "https://github.com/vent-hq/vent"
@@ -0,0 +1,327 @@
1
+ """Vent helper for forwarding LiveKit Agents SDK observability."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Callable, Dict, List, Optional
9
+
10
+ logger = logging.getLogger("vent-livekit")
11
+
12
+ VENT_TOPICS = {
13
+ "call_metadata": "vent:call-metadata",
14
+ "debug_url": "vent:debug-url",
15
+ "warning": "vent:warning",
16
+ "session_report": "vent:session-report",
17
+ "metrics": "vent:metrics",
18
+ "function_tools_executed": "vent:function-tools-executed",
19
+ "conversation_item": "vent:conversation-item",
20
+ "user_input_transcribed": "vent:user-input-transcribed",
21
+ "session_usage": "vent:session-usage",
22
+ }
23
+
24
+
25
+ def _sanitize(value: Any, depth: int = 0) -> Any:
26
+ if value is None or depth > 8:
27
+ return value
28
+ if isinstance(value, (str, int, float, bool)):
29
+ return value
30
+ if isinstance(value, bytes):
31
+ return value.decode("utf-8", errors="replace")
32
+ if isinstance(value, (list, tuple)):
33
+ return [_sanitize(v, depth + 1) for v in value]
34
+ if isinstance(value, set):
35
+ return [_sanitize(v, depth + 1) for v in value]
36
+ if isinstance(value, dict):
37
+ return {str(k): _sanitize(v, depth + 1) for k, v in value.items()}
38
+ if hasattr(value, "model_dump"):
39
+ return _sanitize(value.model_dump(), depth + 1)
40
+ if hasattr(value, "to_dict"):
41
+ return _sanitize(value.to_dict(), depth + 1)
42
+ if hasattr(value, "__dict__"):
43
+ return _sanitize(
44
+ {k: v for k, v in value.__dict__.items() if not k.startswith("_")},
45
+ depth + 1,
46
+ )
47
+ return str(value)
48
+
49
+
50
+ def _compact(d: Dict[str, Any]) -> Optional[Dict[str, Any]]:
51
+ filtered = {k: v for k, v in d.items() if v is not None}
52
+ return filtered if filtered else None
53
+
54
+
55
+ def _event_to_dict(event: Any) -> Dict[str, Any]:
56
+ if isinstance(event, dict):
57
+ return event
58
+ if hasattr(event, "model_dump"):
59
+ return event.model_dump()
60
+ if hasattr(event, "to_dict"):
61
+ return event.to_dict()
62
+ if hasattr(event, "__dict__"):
63
+ return {k: v for k, v in event.__dict__.items() if not k.startswith("_")}
64
+ return {"value": event}
65
+
66
+
67
+ @dataclass
68
+ class VentLiveKitBridge:
69
+ """Returned by instrument_livekit_agent(). Provides methods to publish
70
+ additional observability data and to clean up event subscriptions."""
71
+
72
+ _publish: Callable[..., Any] = field(repr=False)
73
+ _teardown_fns: List[Callable[[], None]] = field(default_factory=list, repr=False)
74
+
75
+ async def publish_call_metadata(self, metadata: Dict[str, Any]) -> None:
76
+ call_metadata = _compact({"platform": "livekit", **metadata}) or {
77
+ "platform": "livekit"
78
+ }
79
+ await self._publish(
80
+ VENT_TOPICS["call_metadata"], {"call_metadata": call_metadata}
81
+ )
82
+
83
+ async def publish_debug_url(self, label: str, url: str) -> None:
84
+ await self._publish(VENT_TOPICS["debug_url"], {"label": label, "url": url})
85
+
86
+ async def publish_warning(
87
+ self, message: str, extras: Optional[Dict[str, Any]] = None
88
+ ) -> None:
89
+ payload: Dict[str, Any] = {"message": message}
90
+ if extras:
91
+ payload.update(extras)
92
+ await self._publish(VENT_TOPICS["warning"], payload)
93
+
94
+ async def publish_session_usage(self, usage: Dict[str, Any]) -> None:
95
+ await self._publish(VENT_TOPICS["session_usage"], {"usage": usage})
96
+
97
+ async def flush_session_report(self) -> None:
98
+ pass # Overridden during setup
99
+
100
+ def dispose(self) -> None:
101
+ for fn in self._teardown_fns:
102
+ fn()
103
+ self._teardown_fns.clear()
104
+
105
+
106
+ def instrument_livekit_agent(
107
+ *,
108
+ ctx: Any = None,
109
+ session: Any = None,
110
+ room: Any = None,
111
+ participant: Any = None,
112
+ reliable: bool = True,
113
+ session_metadata: Optional[Dict[str, Any]] = None,
114
+ debug_urls: Optional[Dict[str, str]] = None,
115
+ ) -> VentLiveKitBridge:
116
+ """Instrument a LiveKit agent to forward observability events to Vent.
117
+
118
+ Subscribes to session events (metrics, tool calls, conversation items,
119
+ usage, close) and publishes them on ``vent:*`` DataChannel topics that
120
+ the Vent LiveKit adapter already understands.
121
+
122
+ Args:
123
+ ctx: LiveKit ``JobContext``. Used for room, shutdown callback, and
124
+ session report generation.
125
+ session: LiveKit ``AgentSession``. Event source for metrics, tool
126
+ calls, conversation items, transcription, and usage.
127
+ room: LiveKit ``Room`` (alternative to ``ctx``).
128
+ participant: LiveKit ``LocalParticipant`` (alternative to ``ctx``/``room``).
129
+ reliable: Whether to use reliable DataChannel delivery. Default ``True``.
130
+ session_metadata: Optional extra metadata to publish immediately.
131
+ debug_urls: Optional ``{label: url}`` debug links to publish immediately.
132
+
133
+ Returns:
134
+ A :class:`VentLiveKitBridge` with methods for publishing additional
135
+ data and a ``dispose()`` to unsubscribe from all events.
136
+ """
137
+ resolved_room = room or (ctx.room if ctx else None)
138
+ resolved_participant = participant or (
139
+ resolved_room.local_participant if resolved_room else None
140
+ )
141
+
142
+ if not resolved_participant:
143
+ raise ValueError(
144
+ "instrument_livekit_agent requires a LiveKit local_participant, room, or ctx."
145
+ )
146
+
147
+ session_report_published = False
148
+ teardown_fns: List[Callable[[], None]] = []
149
+
150
+ async def publish(topic: str, payload: Dict[str, Any]) -> None:
151
+ message = {"type": topic, **payload}
152
+ data = json.dumps(_sanitize(message)).encode("utf-8")
153
+ await resolved_participant.publish_data(data, reliable=reliable, topic=topic)
154
+
155
+ def safe_publish(topic: str, payload: Dict[str, Any], context: str) -> None:
156
+ import asyncio
157
+
158
+ async def _do() -> None:
159
+ try:
160
+ await publish(topic, payload)
161
+ except Exception as exc:
162
+ logger.warning("Failed to publish LiveKit %s: %s", context, exc)
163
+
164
+ try:
165
+ loop = asyncio.get_running_loop()
166
+ loop.create_task(_do())
167
+ except RuntimeError:
168
+ pass
169
+
170
+ bridge = VentLiveKitBridge(_publish=publish, _teardown_fns=teardown_fns)
171
+
172
+ # ── Session report flushing ──────────────────────────────────
173
+
174
+ async def flush_session_report() -> None:
175
+ nonlocal session_report_published
176
+ if session_report_published or not ctx:
177
+ return
178
+ make_report = getattr(ctx, "make_session_report", None)
179
+ if not make_report:
180
+ return
181
+
182
+ session_report_published = True
183
+ try:
184
+ report = make_report(session) if session else make_report()
185
+ if report is not None:
186
+ report_dict = (
187
+ report.to_dict()
188
+ if hasattr(report, "to_dict")
189
+ else _sanitize(report)
190
+ )
191
+ await publish(
192
+ VENT_TOPICS["session_report"],
193
+ {"report": report_dict},
194
+ )
195
+ except Exception as exc:
196
+ session_report_published = False
197
+ logger.warning("Failed to publish LiveKit session report: %s", exc)
198
+
199
+ bridge.flush_session_report = flush_session_report # type: ignore[assignment]
200
+
201
+ # ── Session event subscriptions ──────────────────────────────
202
+
203
+ if session is not None:
204
+
205
+ def _subscribe(event_name: str, handler: Callable[..., None]) -> None:
206
+ session.on(event_name, handler)
207
+
208
+ def _unsub() -> None:
209
+ try:
210
+ session.off(event_name, handler)
211
+ except Exception:
212
+ pass
213
+
214
+ teardown_fns.append(_unsub)
215
+
216
+ def _on_metrics(ev: Any) -> None:
217
+ safe_publish(
218
+ VENT_TOPICS["metrics"],
219
+ {"event": "metrics_collected", **_event_to_dict(ev)},
220
+ "metrics_collected event",
221
+ )
222
+
223
+ def _on_function_tools(ev: Any) -> None:
224
+ safe_publish(
225
+ VENT_TOPICS["function_tools_executed"],
226
+ {"event": "function_tools_executed", **_event_to_dict(ev)},
227
+ "function_tools_executed event",
228
+ )
229
+
230
+ def _on_conversation_item(ev: Any) -> None:
231
+ safe_publish(
232
+ VENT_TOPICS["conversation_item"],
233
+ {"event": "conversation_item_added", **_event_to_dict(ev)},
234
+ "conversation_item_added event",
235
+ )
236
+
237
+ def _on_user_input(ev: Any) -> None:
238
+ safe_publish(
239
+ VENT_TOPICS["user_input_transcribed"],
240
+ {"event": "user_input_transcribed", **_event_to_dict(ev)},
241
+ "user_input_transcribed event",
242
+ )
243
+
244
+ def _on_session_usage(ev: Any) -> None:
245
+ ev_dict = _event_to_dict(ev)
246
+ usage = ev_dict.get("usage", ev_dict)
247
+ safe_publish(
248
+ VENT_TOPICS["session_usage"],
249
+ {"usage": _sanitize(usage)},
250
+ "session_usage_updated event",
251
+ )
252
+
253
+ def _on_close(ev: Any) -> None:
254
+ ev_dict = _event_to_dict(ev)
255
+ error = ev_dict.get("error")
256
+ if error is not None:
257
+ safe_publish(
258
+ VENT_TOPICS["warning"],
259
+ {
260
+ "message": "LiveKit session closed with error",
261
+ "error": _sanitize(error),
262
+ },
263
+ "close warning",
264
+ )
265
+ safe_publish(
266
+ VENT_TOPICS["session_report"],
267
+ {},
268
+ "session report trigger",
269
+ )
270
+ import asyncio
271
+
272
+ async def _flush() -> None:
273
+ await flush_session_report()
274
+
275
+ try:
276
+ loop = asyncio.get_running_loop()
277
+ loop.create_task(_flush())
278
+ except RuntimeError:
279
+ pass
280
+
281
+ _subscribe("metrics_collected", _on_metrics)
282
+ _subscribe("function_tools_executed", _on_function_tools)
283
+ _subscribe("conversation_item_added", _on_conversation_item)
284
+ _subscribe("user_input_transcribed", _on_user_input)
285
+ _subscribe("session_usage_updated", _on_session_usage)
286
+ _subscribe("close", _on_close)
287
+
288
+ # ── Shutdown callback ────────────────────────────────────────
289
+
290
+ if ctx and hasattr(ctx, "add_shutdown_callback"):
291
+ ctx.add_shutdown_callback(flush_session_report)
292
+
293
+ # ── Initial metadata / debug URLs ────────────────────────────
294
+
295
+ if session_metadata:
296
+ # Filter out signals the adapter already gets from the room
297
+ filtered = dict(session_metadata)
298
+ filtered.pop("platform", None)
299
+ room_name = getattr(resolved_room, "name", None) or getattr(
300
+ resolved_room, "sid", None
301
+ )
302
+ if (
303
+ filtered.get("provider_session_id")
304
+ and filtered["provider_session_id"] == room_name
305
+ ):
306
+ filtered.pop("provider_session_id", None)
307
+ if _compact(filtered):
308
+ safe_publish(
309
+ VENT_TOPICS["call_metadata"],
310
+ {
311
+ "call_metadata": _compact(
312
+ {"platform": "livekit", **filtered}
313
+ )
314
+ or {"platform": "livekit"}
315
+ },
316
+ "call metadata",
317
+ )
318
+
319
+ if debug_urls:
320
+ for label, url in debug_urls.items():
321
+ safe_publish(
322
+ VENT_TOPICS["debug_url"],
323
+ {"label": label, "url": url},
324
+ f"debug url ({label})",
325
+ )
326
+
327
+ return bridge
File without changes