Skip to content

Commit 057d043

Browse files
authored
Merge pull request #40 from hatimanees/feat/livekit-adapter
LiveKit Agents adapter — reserve-before / settle-after (closes #39)
2 parents 69990df + 1183392 commit 057d043

6 files changed

Lines changed: 525 additions & 4 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,10 @@ jobs:
6565
# (instead of skipping). langchain-core covers the LangChain handler
6666
# tests; litellm covers the callback-swallowing regression tests (the
6767
# CrewAI adapter tests stub crewai itself, so the heavy extra stays out);
68-
# langgraph covers the fan-out reserve/settle and advisory-channel tests.
69-
- run: pip install -e ".[dev,langchain,litellm,langgraph]"
68+
# langgraph covers the fan-out reserve/settle and advisory-channel tests;
69+
# livekit covers the LiveKit voice-agent reserve/settle/release tests
70+
# (livekit-agents supports Python >=3.10, so it goes in the base install).
71+
- run: pip install -e ".[dev,langchain,litellm,langgraph,livekit]"
7072
# pipecat-ai requires Python >=3.11, so add it (and exercise the Pipecat
7173
# adapter tests) on every matrix cell except 3.10 — there the tests
7274
# importorskip cleanly.

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@ both packages adhere to [Semantic Versioning](https://semver.org/).
1010

1111
## Unreleased
1212

13+
## py 0.8.0 — 2026-07-23
14+
15+
### Added (py)
16+
17+
- **LiveKit Agents adapter** (`floe-guard[livekit]`, issue #39):
18+
`LiveKitBudgetGuard.attach(session, agent)` wires the reserve-before /
19+
settle-after contract onto a LiveKit `AgentSession` — reserve in the agent's
20+
`llm_node`, settle on the session's `metrics_collected` `LLMMetrics`, release
21+
on `close` or a bypassed turn. Optional per-second / per-1k-char knobs meter
22+
STT/TTS spend via `record_tool`. `on_budget_exceeded` async callback for a
23+
graceful spoken wrap-up instead of a hard cut.
24+
1325
## py 0.7.0 / js 0.5.0 — 2026-07-21
1426

1527
### Added (py + js)

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "floe-guard"
7-
version = "0.7.0"
7+
version = "0.8.0"
88
description = "Local budget guardrail for AI agents — hard-stops a runaway loop before its next LLM call crosses a spend ceiling. No account, no network."
99
readme = "README.md"
1010
requires-python = ">=3.10"
@@ -31,6 +31,7 @@ langgraph = ["langgraph>=1.0"]
3131
openai = ["openai>=1.0"]
3232
anthropic = ["anthropic>=0.40"]
3333
pipecat = ["pipecat-ai>=1.0"]
34+
livekit = ["livekit-agents>=1.0"]
3435
dev = ["pytest>=7.0", "pytest-asyncio>=0.23", "ruff>=0.4"]
3536

3637
[project.urls]

src/floe_guard/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from .pricing import ManualPrice, PricedModel, price_tokens, resolve_price
2929
from .stream import StreamGuard, guard_stream
3030

31-
__version__ = "0.7.0" # keep in lockstep with pyproject.toml
31+
__version__ = "0.8.0" # keep in lockstep with pyproject.toml
3232

3333
__all__ = [
3434
"BudgetGuard",
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
"""LiveKit Agents adapter (optional extra: ``pip install floe-guard[livekit]``).
2+
3+
Like Pipecat, a LiveKit ``AgentSession`` (STT -> LLM -> TTS) has no single call
4+
site to wrap: turns fire for the life of a call. The two enforcement points are
5+
the agent's ``llm_node`` (before the LLM call, so a turn is blocked before its
6+
TTS/audio spend piles on) and the session's ``metrics_collected`` event (real
7+
usage, after). ``LiveKitBudgetGuard`` holds the reservation state across both.
8+
9+
from floe_guard import BudgetGuard, ManualPrice
10+
from floe_guard.integrations.livekit import LiveKitBudgetGuard
11+
12+
guard = BudgetGuard(
13+
limit_usd=1.00,
14+
price_overrides={"gemini-2.0-flash": ManualPrice(0.30e-6, 2.50e-6)},
15+
)
16+
budget = LiveKitBudgetGuard(guard, model="gemini-2.0-flash")
17+
18+
session = AgentSession(...)
19+
budget.attach(session, agent) # wire reserve / settle / release
20+
await session.start(agent=agent, room=ctx.room)
21+
22+
LiveKit's ``LLMMetrics`` does not report the served model, so cost is settled
23+
against the ``model`` passed here (unlike the Pipecat adapter, which reads it
24+
off the frame). STT/TTS spend — often a voice agent's larger bill — is metered
25+
only if per-unit prices are supplied, since the bundled cost map is LLM-only.
26+
"""
27+
28+
from __future__ import annotations
29+
30+
import inspect
31+
import logging
32+
from collections import deque
33+
from collections.abc import Awaitable, Callable
34+
from dataclasses import dataclass
35+
36+
from livekit.agents.metrics import LLMMetrics, STTMetrics, TTSMetrics
37+
38+
from ..errors import BudgetExceeded
39+
from ..guard import BudgetGuard
40+
41+
logger = logging.getLogger(__name__)
42+
43+
44+
@dataclass
45+
class _TurnSlot:
46+
"""One llm_node invocation awaiting (or already past) its LLMMetrics event.
47+
48+
``open`` means the USD amount is still held on the BudgetGuard. Early
49+
release (next turn, exception, close) clears the guard hold but leaves the
50+
slot in the FIFO queue so a delayed metrics event settles against this
51+
turn's slot (with ``amount`` 0) instead of stealing a later turn's hold.
52+
"""
53+
54+
amount: float
55+
open: bool = True
56+
57+
58+
class LiveKitBudgetGuard:
59+
"""Enforce a BudgetGuard ceiling on a LiveKit ``AgentSession``, one turn at a time.
60+
61+
Args:
62+
guard: the BudgetGuard to enforce.
63+
model: model id to settle LLM cost against (LiveKit's LLMMetrics carries
64+
no model name). Must be priceable via the cost map or the guard's
65+
``price_overrides``.
66+
on_budget_exceeded: optional async callback invoked with the
67+
``BudgetExceeded`` when a turn is blocked, so a bot can speak a
68+
graceful "wrapping up" line before the turn ends silently. If
69+
omitted, the ``BudgetExceeded`` propagates out of ``llm_node``.
70+
stt_usd_per_second: if set, meter ``STTMetrics.audio_duration`` via
71+
``record_tool`` (per-second). Omit to keep the token-only contract.
72+
tts_usd_per_1k_chars: if set, meter ``TTSMetrics.characters_count`` via
73+
``record_tool`` (per 1k chars). Omit to keep the token-only contract.
74+
"""
75+
76+
def __init__(
77+
self,
78+
guard: BudgetGuard,
79+
model: str,
80+
on_budget_exceeded: Callable[[BudgetExceeded], Awaitable[None]] | None = None,
81+
*,
82+
stt_usd_per_second: float | None = None,
83+
tts_usd_per_1k_chars: float | None = None,
84+
):
85+
self._guard = guard
86+
self._model = model
87+
self._on_budget_exceeded = on_budget_exceeded
88+
self._stt_usd_per_second = stt_usd_per_second
89+
self._tts_usd_per_1k_chars = tts_usd_per_1k_chars
90+
self._reserved: float = 0.0
91+
self._pending = False
92+
self._active: _TurnSlot | None = None
93+
# FIFO of turns that may still emit LLMMetrics (including early-released).
94+
self._slots: deque[_TurnSlot] = deque()
95+
96+
def attach(self, session, agent) -> None:
97+
"""Wire reserve (agent.llm_node), settle/meter (metrics_collected) and
98+
release (close) onto a session + agent pair."""
99+
orig_llm_node = agent.llm_node
100+
101+
# forward LiveKit's (chat_ctx, tools, model_settings) unchanged, so an
102+
# upstream signature change can't break the wrapper.
103+
async def _guarded_llm_node(*args, **kwargs):
104+
# A previous turn that never settled still holds its reservation —
105+
# release the guard hold before opening this one, but keep a queue
106+
# slot so delayed metrics cannot steal this turn's hold.
107+
if self._pending:
108+
self._early_release_active()
109+
try:
110+
amount = self._guard.reserve()
111+
except BudgetExceeded as exc:
112+
self._clear_active()
113+
logger.warning("floe-guard blocked a turn: %s", exc)
114+
if self._on_budget_exceeded is None:
115+
raise
116+
await self._on_budget_exceeded(exc)
117+
return
118+
slot = _TurnSlot(amount=amount)
119+
self._slots.append(slot)
120+
self._active = slot
121+
self._reserved = amount
122+
self._pending = True
123+
try:
124+
stream = orig_llm_node(*args, **kwargs)
125+
if inspect.isawaitable(stream):
126+
stream = await stream
127+
async for chunk in stream:
128+
yield chunk
129+
except BaseException:
130+
# Release only if this invocation still owns the open hold — a
131+
# later turn may already have early-released ours and reserved
132+
# its own, making its slot (not ours) the active one.
133+
if self._active is slot:
134+
self._early_release_active()
135+
raise
136+
137+
agent.llm_node = _guarded_llm_node
138+
session.on("metrics_collected", self._on_metrics)
139+
session.on("close", self._on_close)
140+
141+
def _clear_active(self) -> None:
142+
self._active = None
143+
self._reserved = 0.0
144+
self._pending = False
145+
146+
def _early_release_active(self) -> None:
147+
"""Drop the open guard hold; leave a zeroed queue slot for delayed metrics."""
148+
slot = self._active
149+
if slot is None or not slot.open:
150+
self._clear_active()
151+
return
152+
self._guard.release(slot.amount)
153+
slot.open = False
154+
slot.amount = 0.0
155+
self._clear_active()
156+
157+
def _on_metrics(self, ev) -> None:
158+
m = ev.metrics
159+
if isinstance(m, LLMMetrics):
160+
# Pop the oldest turn slot. Early-released turns contribute 0 so a
161+
# delayed metrics event meters usage without consuming a later hold.
162+
# An empty queue (reserve hook bypassed) settles as a plain record().
163+
if self._slots:
164+
slot = self._slots.popleft()
165+
reserved = slot.amount if slot.open else 0.0
166+
if slot.open:
167+
slot.open = False
168+
if self._active is slot:
169+
self._clear_active()
170+
else:
171+
reserved = 0.0
172+
self._guard.settle(self._model, m.prompt_tokens, m.completion_tokens, reserved=reserved)
173+
elif self._stt_usd_per_second is not None and isinstance(m, STTMetrics):
174+
self._guard.record_tool("livekit-stt", m.audio_duration * self._stt_usd_per_second)
175+
elif self._tts_usd_per_1k_chars is not None and isinstance(m, TTSMetrics):
176+
self._guard.record_tool(
177+
"livekit-tts", m.characters_count / 1000 * self._tts_usd_per_1k_chars
178+
)
179+
180+
def _on_close(self, ev) -> None:
181+
# Session torn down with a turn still reserved — release it. Drop any
182+
# leftover slots so a post-close metrics event cannot touch a stale hold.
183+
if self._pending:
184+
self._early_release_active()
185+
self._slots.clear()
186+
187+
188+
__all__ = ["LiveKitBudgetGuard"]

0 commit comments

Comments
 (0)