Skip to content

Commit 4ecea0a

Browse files
authored
Merge pull request #30 from yajushivudatha/feat/pipecat-integration
feat: add Pipecat integration for voice-agent pipelines
2 parents 1e00254 + 13d3f54 commit 4ecea0a

5 files changed

Lines changed: 598 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ jobs:
6666
# tests; litellm covers the callback-swallowing regression tests (the
6767
# CrewAI adapter tests stub crewai itself, so the heavy extra stays out).
6868
- run: pip install -e ".[dev,langchain,litellm]"
69+
# pipecat-ai requires Python >=3.11, so add it (and exercise the Pipecat
70+
# adapter tests) on every matrix cell except 3.10 — there the tests
71+
# importorskip cleanly.
72+
- if: matrix.python-version != '3.10'
73+
run: pip install -e ".[pipecat]"
6974
- name: Pytest
7075
run: pytest -q
7176

examples/voice_turn_budget.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Reproducible demo: floe-guard stopping a runaway voice conversation.
2+
3+
No API key, no account, no network -- runs a real (single-processor)
4+
Pipecat Pipeline via PipelineTask/PipelineRunner, so FloeBudgetGuardProcessor
5+
gets a fully initialized lifecycle (TaskManager, clock, etc.) exactly like it
6+
would in a real bot. A second coroutine queues frames in to simulate a
7+
multi-turn conversation, mirroring the style of examples/runaway_loop.py.
8+
9+
Uses a fixed ManualPrice rather than the bundled gpt-4o cost-map entry, so
10+
the demo deterministically crosses its ceiling regardless of what the live
11+
cost map says gpt-4o costs on any given day.
12+
13+
Run:
14+
python examples/voice_turn_budget.py
15+
"""
16+
17+
import asyncio
18+
19+
from pipecat.frames.frames import EndFrame, LLMFullResponseStartFrame, MetricsFrame
20+
from pipecat.metrics.metrics import LLMTokenUsage, LLMUsageMetricsData
21+
from pipecat.pipeline.pipeline import Pipeline
22+
from pipecat.pipeline.runner import PipelineRunner
23+
from pipecat.pipeline.task import PipelineParams, PipelineTask
24+
25+
from floe_guard import BudgetGuard, ManualPrice
26+
from floe_guard.integrations.pipecat import FloeBudgetGuardProcessor
27+
28+
MODEL = "demo-voice-model"
29+
TURN_COUNT = 10
30+
31+
32+
def _metrics_frame(prompt_tokens: int, completion_tokens: int) -> MetricsFrame:
33+
usage = LLMTokenUsage(
34+
prompt_tokens=prompt_tokens,
35+
completion_tokens=completion_tokens,
36+
total_tokens=prompt_tokens + completion_tokens,
37+
)
38+
return MetricsFrame(data=[LLMUsageMetricsData(processor="fake-llm", model=MODEL, value=usage)])
39+
40+
41+
async def main():
42+
guard = BudgetGuard(
43+
limit_usd=0.02,
44+
price_overrides={MODEL: ManualPrice(1e-5, 3e-5)}, # USD/token -- fixed, demo-only price
45+
)
46+
47+
async def on_budget_exceeded(exc):
48+
# In a real bot you'd push one final TTSSpeakFrame here ("wrapping up
49+
# now...") before ending the call, instead of just ending it outright.
50+
print(f"\nBUDGET EXCEEDED: {exc}")
51+
await task.queue_frame(EndFrame())
52+
53+
processor = FloeBudgetGuardProcessor(guard, model=MODEL, on_budget_exceeded=on_budget_exceeded)
54+
55+
pipeline = Pipeline([processor])
56+
task = PipelineTask(
57+
pipeline, params=PipelineParams(enable_metrics=True, enable_usage_metrics=True)
58+
)
59+
runner = PipelineRunner()
60+
61+
async def drive_conversation():
62+
for turn in range(1, TURN_COUNT + 1):
63+
if task.has_finished():
64+
break
65+
print(f"\n--- Turn {turn} ---")
66+
await task.queue_frame(LLMFullResponseStartFrame())
67+
await task.queue_frame(_metrics_frame(prompt_tokens=300, completion_tokens=120))
68+
await asyncio.sleep(0.05) # let this turn finish processing before the next
69+
else:
70+
print("\nConversation finished without hitting the ceiling.")
71+
if not task.has_finished():
72+
await task.queue_frame(EndFrame())
73+
74+
await asyncio.gather(runner.run(task), drive_conversation())
75+
76+
77+
if __name__ == "__main__":
78+
asyncio.run(main())

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ crewai = ["crewai>=0.30", "litellm>=1.0"]
2929
langchain = ["langchain-core>=0.3"]
3030
openai = ["openai>=1.0"]
3131
anthropic = ["anthropic>=0.40"]
32-
dev = ["pytest>=7.0", "ruff>=0.4"]
32+
pipecat = ["pipecat-ai>=1.0"]
33+
dev = ["pytest>=7.0", "pytest-asyncio>=0.23", "ruff>=0.4"]
3334

3435
[project.urls]
3536
Homepage = "https://floelabs.xyz"
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
"""Pipecat voice-agent pipeline adapter (optional extra: ``pip install floe-guard[pipecat]``).
2+
3+
Unlike request/response frameworks (OpenAI, Anthropic, LangChain), a Pipecat
4+
pipeline has no single call site to wrap: the LLM sits inside a running
5+
``Pipeline`` of ``FrameProcessor``s (STT -> context aggregator -> LLM -> TTS)
6+
and turns fire continuously for the life of a call. So the enforcement
7+
surface here is a ``FrameProcessor`` placed directly after the LLM service,
8+
not a function wrapper around a single call.
9+
10+
The contract matches the OpenAI/Anthropic adapters: ``reserve()`` when a turn
11+
starts (before TTS/audio spend piles on top of a call that would already
12+
cross the ceiling), ``settle(model, prompt_tokens, completion_tokens,
13+
reserved=...)`` once real usage is reported, and ``release(reserved)`` if a
14+
turn ends without ever reporting usage (e.g. an interrupted turn) so the
15+
reservation doesn't leak against the ceiling forever.
16+
17+
from pipecat.pipeline.pipeline import Pipeline
18+
from pipecat.pipeline.task import PipelineTask, PipelineParams
19+
from floe_guard import BudgetGuard
20+
from floe_guard.integrations.pipecat import FloeBudgetGuardProcessor
21+
22+
guard = BudgetGuard(limit_usd=1.00)
23+
24+
pipeline = Pipeline([
25+
transport.input(),
26+
stt,
27+
context_aggregator.user(),
28+
llm,
29+
FloeBudgetGuardProcessor(guard, model="gpt-4o"),
30+
tts,
31+
transport.output(),
32+
context_aggregator.assistant(),
33+
])
34+
# enable_usage_metrics=True is required -- without it Pipecat never emits
35+
# the LLMUsageMetricsData this adapter settles from.
36+
task = PipelineTask(
37+
pipeline, params=PipelineParams(enable_metrics=True, enable_usage_metrics=True)
38+
)
39+
"""
40+
41+
from __future__ import annotations
42+
43+
import logging
44+
from collections.abc import Awaitable, Callable
45+
from typing import Any
46+
47+
from pipecat.frames.frames import (
48+
CancelFrame,
49+
EndFrame,
50+
ErrorFrame,
51+
Frame,
52+
LLMFullResponseEndFrame,
53+
LLMFullResponseStartFrame,
54+
MetricsFrame,
55+
)
56+
from pipecat.metrics.metrics import LLMUsageMetricsData
57+
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
58+
59+
from ..errors import BudgetExceeded
60+
from ..guard import BudgetGuard
61+
from ..pricing import resolve_price
62+
63+
logger = logging.getLogger(__name__)
64+
65+
66+
def _usage_from(frame: MetricsFrame) -> tuple[str | None, int, int] | None:
67+
"""Pull (model, prompt_tokens, completion_tokens) from a MetricsFrame.
68+
69+
``frame.data`` is a list of MetricsData entries (TTFB, processing time,
70+
token usage, etc. can all show up on the same frame) -- only the
71+
LLMUsageMetricsData entry carries token usage, so find that one and
72+
ignore the rest.
73+
"""
74+
for entry in frame.data:
75+
if isinstance(entry, LLMUsageMetricsData):
76+
usage = entry.value
77+
return entry.model, usage.prompt_tokens, usage.completion_tokens
78+
return None
79+
80+
81+
class FloeBudgetGuardProcessor(FrameProcessor):
82+
"""Enforce a BudgetGuard ceiling on a Pipecat pipeline, one turn at a time.
83+
84+
Place directly after the LLM service. Calls ``guard.reserve()`` on
85+
``LLMFullResponseStartFrame`` (raising ``BudgetExceeded`` before the
86+
turn's TTS/audio spend accrues on top of a call that would already cross
87+
the ceiling), ``guard.settle()`` once a ``MetricsFrame`` reports real
88+
token usage, and ``guard.release()`` whenever a turn ends with its
89+
reservation still unsettled — an ``LLMFullResponseEndFrame``, a new turn
90+
starting before the last one settled, or the pipeline tearing down
91+
(``EndFrame``/``CancelFrame``/``ErrorFrame``) — so the reservation never
92+
leaks against the ceiling.
93+
94+
Requires the pipeline's ``PipelineTask`` to be created with
95+
``PipelineParams(enable_metrics=True, enable_usage_metrics=True)`` --
96+
Pipecat only emits ``LLMUsageMetricsData`` when usage metrics are on.
97+
98+
Args:
99+
guard: the BudgetGuard instance to enforce.
100+
model: fallback model name to settle cost against, used only if the
101+
LLM service's own MetricsFrame doesn't report one. If the
102+
reported model can't be priced but this fallback can, the
103+
fallback is used instead (mirrors openai.py's served-vs-requested
104+
model handling) -- so a provider snapshot newer than the bundled
105+
cost map doesn't fail-close a call that would otherwise price
106+
cleanly.
107+
on_budget_exceeded: optional async callback invoked with the
108+
``BudgetExceeded`` exception when a turn is blocked, so the
109+
caller can push a graceful "wrapping up" TTS frame before the
110+
pipeline ends. If omitted, a fatal ``ErrorFrame`` is pushed
111+
instead, which terminates the pipeline -- this is the "hard
112+
stop" default that matches every other floe-guard adapter.
113+
(A bare raise here would *not* achieve that: Pipecat's
114+
FrameProcessor catches exceptions raised inside process_frame()
115+
and downgrades them to a non-fatal, merely-logged ErrorFrame.)
116+
"""
117+
118+
def __init__(
119+
self,
120+
guard: BudgetGuard,
121+
model: str,
122+
on_budget_exceeded: Callable[[BudgetExceeded], Awaitable[None]] | None = None,
123+
**kwargs: Any,
124+
):
125+
super().__init__(**kwargs)
126+
self._guard = guard
127+
self._model = model
128+
self._on_budget_exceeded = on_budget_exceeded
129+
self._reserved: float = 0.0
130+
self._pending = False
131+
132+
def _settle_model(self, reported_model: str | None) -> str:
133+
if (
134+
reported_model
135+
and reported_model != self._model
136+
and resolve_price(reported_model, self._guard.price_overrides) is None
137+
and resolve_price(self._model, self._guard.price_overrides) is not None
138+
):
139+
return self._model
140+
return reported_model or self._model
141+
142+
def _release_pending(self) -> None:
143+
"""Drop a still-held turn reservation -- an interrupted turn, a new turn
144+
starting before the last one settled, or pipeline teardown -- so it
145+
never leaks against the ceiling."""
146+
self._guard.release(self._reserved)
147+
self._reserved = 0.0
148+
self._pending = False
149+
150+
async def process_frame(self, frame: Frame, direction: FrameDirection):
151+
await super().process_frame(frame, direction)
152+
153+
if isinstance(frame, LLMFullResponseStartFrame):
154+
# If the previous turn never settled its usage (interrupted, or no
155+
# MetricsFrame ever arrived), its reservation is still held -- release
156+
# it before opening the new turn's, or back-to-back start frames would
157+
# overwrite the handle and leak the earlier hold against the ceiling.
158+
if self._pending:
159+
self._release_pending()
160+
try:
161+
self._reserved = self._guard.reserve()
162+
self._pending = True
163+
except BudgetExceeded as exc:
164+
logger.warning("floe-guard blocked a turn: %s", exc)
165+
self._pending = False
166+
if self._on_budget_exceeded is not None:
167+
await self._on_budget_exceeded(exc)
168+
else:
169+
# Pipecat's FrameProcessor catches any exception raised
170+
# inside process_frame() and downgrades it to a
171+
# *non-fatal* ErrorFrame rather than propagating it or
172+
# stopping the pipeline (confirmed empirically -- see
173+
# push_error_frame in frame_processor.py). Simply raising
174+
# here would just log a warning and let the pipeline
175+
# keep running, silently defeating floe-guard's whole
176+
# point of being a *hard* stop. Push a fatal ErrorFrame
177+
# ourselves instead, which does actually terminate the
178+
# pipeline, so the default (no-callback) behavior is a
179+
# real hard stop rather than a swallowed log line.
180+
await self.push_error(str(exc), exception=exc, fatal=True)
181+
return # don't forward the frame for a call that was blocked
182+
183+
elif isinstance(frame, MetricsFrame):
184+
# Settle whenever a frame carries real token usage -- NOT gated on
185+
# _pending, so usage is still metered if the start frame was missed
186+
# or a prior frame already cleared the reservation. With no live
187+
# reservation ``self._reserved`` is 0.0, i.e. a plain record().
188+
usage = _usage_from(frame)
189+
if usage is not None:
190+
reported_model, prompt_tokens, completion_tokens = usage
191+
reserved = self._reserved
192+
self._reserved = 0.0
193+
self._pending = False
194+
try:
195+
self._guard.settle(
196+
self._settle_model(reported_model),
197+
prompt_tokens,
198+
completion_tokens,
199+
reserved=reserved,
200+
)
201+
except Exception as exc:
202+
# settle() fail-closes on a model it can't price (and
203+
# re-raises a non-finite cost). A guard that can't measure
204+
# spend must hard-stop the pipeline, not let the exception be
205+
# downgraded to a non-fatal log by Pipecat (same reasoning as
206+
# the BudgetExceeded branch above). settle() already released
207+
# the reservation on its raise path, so don't release again.
208+
logger.warning("floe-guard could not settle a turn: %s", exc)
209+
await self.push_error(str(exc), exception=exc, fatal=True)
210+
return
211+
212+
elif isinstance(frame, (LLMFullResponseEndFrame, EndFrame, CancelFrame, ErrorFrame)):
213+
# A turn that ended (LLMFullResponseEndFrame) or the pipeline tearing
214+
# down (End/Cancel/Error) while a reservation is still held and no
215+
# usage was ever reported -- release it instead of leaking. Fall
216+
# through so the terminating frame still propagates downstream.
217+
if self._pending:
218+
self._release_pending()
219+
220+
# Always forward every frame -- a FrameProcessor that swallows frames
221+
# breaks the pipeline for everything downstream.
222+
await self.push_frame(frame, direction)
223+
224+
225+
__all__ = ["FloeBudgetGuardProcessor"]

0 commit comments

Comments
 (0)