From 7034a9e3fd4883102400e981e59fc6be9b9dfd0b Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 25 Jun 2025 11:32:29 -0400 Subject: [PATCH 01/54] fix: add missing ConfigDict import in openai_realtime_beta/events --- src/pipecat/services/openai_realtime_beta/events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pipecat/services/openai_realtime_beta/events.py b/src/pipecat/services/openai_realtime_beta/events.py index 289835dae..ef62248af 100644 --- a/src/pipecat/services/openai_realtime_beta/events.py +++ b/src/pipecat/services/openai_realtime_beta/events.py @@ -9,7 +9,7 @@ import json import uuid from typing import Any, Dict, List, Literal, Optional, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field # # session properties From eb5ecab1044782ba734e0ccb767a868b33e14686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Tue, 24 Jun 2025 16:43:10 -0700 Subject: [PATCH 02/54] no need to call start_watchdog() only reset_watchdog() --- src/pipecat/pipeline/parallel_pipeline.py | 11 +- src/pipecat/pipeline/task.py | 19 ++-- src/pipecat/pipeline/task_observer.py | 9 +- .../processors/aggregators/dtmf_aggregator.py | 1 + .../processors/aggregators/llm_response.py | 1 + src/pipecat/processors/consumer_processor.py | 5 +- src/pipecat/processors/frame_processor.py | 20 ++-- src/pipecat/processors/frameworks/rtvi.py | 9 +- src/pipecat/processors/metrics/sentry.py | 12 ++- src/pipecat/processors/producer_processor.py | 5 +- src/pipecat/services/anthropic/llm.py | 3 +- src/pipecat/services/assemblyai/stt.py | 7 +- src/pipecat/services/aws/llm.py | 3 + src/pipecat/services/aws/stt.py | 8 +- src/pipecat/services/aws_nova_sonic/aws.py | 9 +- src/pipecat/services/cartesia/tts.py | 3 +- src/pipecat/services/elevenlabs/tts.py | 6 +- .../services/gemini_multimodal_live/gemini.py | 9 +- src/pipecat/services/gladia/stt.py | 7 +- src/pipecat/services/google/llm.py | 3 +- src/pipecat/services/google/llm_openai.py | 3 +- src/pipecat/services/google/stt.py | 16 +-- src/pipecat/services/openai/base_llm.py | 3 +- .../services/openai_realtime_beta/openai.py | 7 +- src/pipecat/services/riva/stt.py | 9 +- src/pipecat/services/sambanova/llm.py | 3 +- src/pipecat/services/simli/video.py | 11 +- src/pipecat/services/tavus/video.py | 8 +- src/pipecat/services/tts_service.py | 9 +- src/pipecat/transports/base_input.py | 2 - src/pipecat/transports/base_output.py | 7 +- .../transports/network/fastapi_websocket.py | 11 +- .../transports/network/small_webrtc.py | 11 +- src/pipecat/transports/services/daily.py | 15 ++- src/pipecat/transports/services/livekit.py | 12 +-- src/pipecat/utils/asyncio.py | 101 +++++------------- src/pipecat/utils/watchdog_async_iterator.py | 60 +++++++++++ src/pipecat/utils/watchdog_event.py | 31 ++++++ src/pipecat/utils/watchdog_priority_queue.py | 35 ++++++ src/pipecat/utils/watchdog_queue.py | 35 ++++++ src/pipecat/utils/watchdog_reseter.py | 13 +++ 41 files changed, 341 insertions(+), 211 deletions(-) create mode 100644 src/pipecat/utils/watchdog_async_iterator.py create mode 100644 src/pipecat/utils/watchdog_event.py create mode 100644 src/pipecat/utils/watchdog_priority_queue.py create mode 100644 src/pipecat/utils/watchdog_queue.py create mode 100644 src/pipecat/utils/watchdog_reseter.py diff --git a/src/pipecat/pipeline/parallel_pipeline.py b/src/pipecat/pipeline/parallel_pipeline.py index 3a08f44ed..4794ea708 100644 --- a/src/pipecat/pipeline/parallel_pipeline.py +++ b/src/pipecat/pipeline/parallel_pipeline.py @@ -21,6 +21,7 @@ from pipecat.frames.frames import ( from pipecat.pipeline.base_pipeline import BasePipeline from pipecat.pipeline.pipeline import Pipeline from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, FrameProcessorSetup +from pipecat.utils.watchdog_queue import WatchdogQueue class ParallelPipelineSource(FrameProcessor): @@ -83,8 +84,8 @@ class ParallelPipeline(BasePipeline): self._up_task = None self._down_task = None - self._up_queue = asyncio.Queue() - self._down_queue = asyncio.Queue() + self._up_queue = WatchdogQueue(self) + self._down_queue = WatchdogQueue(self) self._pipelines = [] @@ -202,18 +203,14 @@ class ParallelPipeline(BasePipeline): async def _process_up_queue(self): while True: frame = await self._up_queue.get() - self.start_watchdog() await self._parallel_push_frame(frame, FrameDirection.UPSTREAM) self._up_queue.task_done() - self.reset_watchdog() async def _process_down_queue(self): running = True while running: frame = await self._down_queue.get() - self.start_watchdog() - endframe_counter = self._endframe_counter.get(frame.id, 0) # If we have a counter, decrement it. @@ -228,5 +225,3 @@ class ParallelPipeline(BasePipeline): running = not (endframe_counter == 0 and isinstance(frame, EndFrame)) self._down_queue.task_done() - - self.reset_watchdog() diff --git a/src/pipecat/pipeline/task.py b/src/pipecat/pipeline/task.py index 88a47189b..ad7a5cda6 100644 --- a/src/pipecat/pipeline/task.py +++ b/src/pipecat/pipeline/task.py @@ -41,6 +41,8 @@ from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, F from pipecat.utils.asyncio import WATCHDOG_TIMEOUT, BaseTaskManager, TaskManager, TaskManagerParams from pipecat.utils.tracing.setup import is_tracing_available from pipecat.utils.tracing.turn_trace_observer import TurnTraceObserver +from pipecat.utils.watchdog_queue import WatchdogQueue +from pipecat.utils.watchdog_reseter import WatchdogReseter HEARTBEAT_SECONDS = 1.0 HEARTBEAT_MONITOR_SECONDS = HEARTBEAT_SECONDS * 10 @@ -131,7 +133,7 @@ class PipelineTaskSink(FrameProcessor): await self._down_queue.put(frame) -class PipelineTask(BasePipelineTask): +class PipelineTask(WatchdogReseter, BasePipelineTask): """Manages the execution of a pipeline, handling frame processing and task lifecycle. It has a couple of event handlers `on_frame_reached_upstream` and @@ -261,18 +263,18 @@ class PipelineTask(BasePipelineTask): self._cancelled = False # This queue receives frames coming from the pipeline upstream. - self._up_queue = asyncio.Queue() + self._up_queue = WatchdogQueue(self) # This queue receives frames coming from the pipeline downstream. - self._down_queue = asyncio.Queue() + self._down_queue = WatchdogQueue(self) # This queue is the queue used to push frames to the pipeline. - self._push_queue = asyncio.Queue() + self._push_queue = WatchdogQueue(self) # This is the heartbeat queue. When a heartbeat frame is received in the # down queue we add it to the heartbeat queue for processing. - self._heartbeat_queue = asyncio.Queue() + self._heartbeat_queue = WatchdogQueue(self) # This is the idle queue. When frames are received downstream they are # put in the queue. If no frame is received the pipeline is considered # idle. - self._idle_queue = asyncio.Queue() + self._idle_queue = WatchdogQueue(self) # This event is used to indicate a finalize frame (e.g. EndFrame, # StopFrame) has been received in the down queue. self._pipeline_end_event = asyncio.Event() @@ -424,6 +426,9 @@ class PipelineTask(BasePipelineTask): for frame in frames: await self.queue_frame(frame) + def reset_watchdog(self): + self._task_manager.reset_watchdog(asyncio.current_task()) + async def _cancel(self): if not self._cancelled: logger.debug(f"Canceling pipeline task {self}") @@ -526,8 +531,6 @@ class PipelineTask(BasePipelineTask): await self._pipeline.cleanup() await self._sink.cleanup() - await self._task_manager.cleanup() - async def _process_push_queue(self): """This is the task that runs the pipeline for the first time by sending a StartFrame and by pushing any other frames queued by the user. It runs diff --git a/src/pipecat/pipeline/task_observer.py b/src/pipecat/pipeline/task_observer.py index 950ddc43b..2f7450a75 100644 --- a/src/pipecat/pipeline/task_observer.py +++ b/src/pipecat/pipeline/task_observer.py @@ -12,6 +12,8 @@ from attr import dataclass from pipecat.observers.base_observer import BaseObserver, FramePushed from pipecat.utils.asyncio import BaseTaskManager +from pipecat.utils.watchdog_queue import WatchdogQueue +from pipecat.utils.watchdog_reseter import WatchdogReseter @dataclass @@ -26,7 +28,7 @@ class Proxy: observer: BaseObserver -class TaskObserver(BaseObserver): +class TaskObserver(WatchdogReseter, BaseObserver): """This is a pipeline frame observer that is meant to be used as a proxy to the user provided observers. That is, this is the observer that should be passed to the frame processors. Then, every time a frame is pushed this @@ -89,11 +91,14 @@ class TaskObserver(BaseObserver): for proxy in self._proxies.values(): await proxy.queue.put(data) + def reset_watchdog(self): + self._task_manager.reset_watchdog(asyncio.current_task()) + def _started(self) -> bool: return self._proxies is not None def _create_proxy(self, observer: BaseObserver) -> Proxy: - queue = asyncio.Queue() + queue = WatchdogQueue(self) task = self._task_manager.create_task( self._proxy_task_handler(queue, observer), f"TaskObserver::{observer}::_proxy_task_handler", diff --git a/src/pipecat/processors/aggregators/dtmf_aggregator.py b/src/pipecat/processors/aggregators/dtmf_aggregator.py index cc6218a1f..3008fb398 100644 --- a/src/pipecat/processors/aggregators/dtmf_aggregator.py +++ b/src/pipecat/processors/aggregators/dtmf_aggregator.py @@ -119,6 +119,7 @@ class DTMFAggregator(FrameProcessor): await asyncio.wait_for(self._digit_event.wait(), timeout=self._idle_timeout) self._digit_event.clear() except asyncio.TimeoutError: + self.reset_watchdog() if self._aggregation: await self._flush_aggregation() diff --git a/src/pipecat/processors/aggregators/llm_response.py b/src/pipecat/processors/aggregators/llm_response.py index 1984e3cf8..40016aaa0 100644 --- a/src/pipecat/processors/aggregators/llm_response.py +++ b/src/pipecat/processors/aggregators/llm_response.py @@ -470,6 +470,7 @@ class LLMUserContextAggregator(LLMContextResponseAggregator): ) self._emulating_vad = False finally: + self.reset_watchdog() self._aggregation_event.clear() async def _maybe_emulate_user_speaking(self): diff --git a/src/pipecat/processors/consumer_processor.py b/src/pipecat/processors/consumer_processor.py index 121dd7712..10cae11a3 100644 --- a/src/pipecat/processors/consumer_processor.py +++ b/src/pipecat/processors/consumer_processor.py @@ -10,6 +10,7 @@ from typing import Awaitable, Callable, Optional from pipecat.frames.frames import CancelFrame, EndFrame, Frame, StartFrame from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.producer_processor import ProducerProcessor, identity_transformer +from pipecat.utils.watchdog_queue import WatchdogQueue class ConsumerProcessor(FrameProcessor): @@ -31,7 +32,7 @@ class ConsumerProcessor(FrameProcessor): super().__init__(**kwargs) self._transformer = transformer self._direction = direction - self._queue: asyncio.Queue = producer.add_consumer() + self._queue: WatchdogQueue = producer.add_consumer(self) self._consumer_task: Optional[asyncio.Task] = None async def process_frame(self, frame: Frame, direction: FrameDirection): @@ -61,7 +62,5 @@ class ConsumerProcessor(FrameProcessor): async def _consumer_task_handler(self): while True: frame = await self._queue.get() - self.start_watchdog() new_frame = await self._transformer(frame) await self.push_frame(new_frame, self._direction) - self.reset_watchdog() diff --git a/src/pipecat/processors/frame_processor.py b/src/pipecat/processors/frame_processor.py index bee5ce91c..d7723d868 100644 --- a/src/pipecat/processors/frame_processor.py +++ b/src/pipecat/processors/frame_processor.py @@ -31,6 +31,9 @@ from pipecat.observers.base_observer import BaseObserver, FramePushed from pipecat.processors.metrics.frame_processor_metrics import FrameProcessorMetrics from pipecat.utils.asyncio import BaseTaskManager from pipecat.utils.base_object import BaseObject +from pipecat.utils.watchdog_event import WatchdogEvent +from pipecat.utils.watchdog_queue import WatchdogQueue +from pipecat.utils.watchdog_reseter import WatchdogReseter class FrameDirection(Enum): @@ -45,13 +48,13 @@ class FrameProcessorSetup: observer: Optional[BaseObserver] = None -class FrameProcessor(BaseObject): +class FrameProcessor(WatchdogReseter, BaseObject): def __init__( self, *, name: Optional[str] = None, - metrics: Optional[FrameProcessorMetrics] = None, enable_watchdog_logging: Optional[bool] = None, + metrics: Optional[FrameProcessorMetrics] = None, watchdog_timeout_secs: Optional[float] = None, **kwargs, ): @@ -101,7 +104,7 @@ class FrameProcessor(BaseObject): # is called. To resume processing frames we need to call # `resume_processing_frames()` which will wake up the event. self.__should_block_frames = False - self.__input_event = asyncio.Event() + self.__input_event = WatchdogEvent(self) self.__input_frame_task: Optional[asyncio.Task] = None # Every processor in Pipecat should only output frames from a single @@ -210,9 +213,6 @@ class FrameProcessor(BaseObject): async def wait_for_task(self, task: asyncio.Task, timeout: Optional[float] = None): await self.get_task_manager().wait_for_task(task, timeout) - def start_watchdog(self): - self.get_task_manager().start_watchdog(asyncio.current_task()) - def reset_watchdog(self): self.get_task_manager().reset_watchdog(asyncio.current_task()) @@ -397,7 +397,7 @@ class FrameProcessor(BaseObject): if not self.__input_frame_task: self.__should_block_frames = False self.__input_event.clear() - self.__input_queue = asyncio.Queue() + self.__input_queue = WatchdogQueue(self) self.__input_frame_task = self.create_task(self.__input_frame_task_handler()) async def __cancel_input_task(self): @@ -416,7 +416,6 @@ class FrameProcessor(BaseObject): (frame, direction, callback) = await self.__input_queue.get() try: - self.start_watchdog() # Process the frame. await self.process_frame(frame, direction) # If this frame has an associated callback, call it now. @@ -427,11 +426,10 @@ class FrameProcessor(BaseObject): await self.push_error(ErrorFrame(str(e))) finally: self.__input_queue.task_done() - self.reset_watchdog() def __create_push_task(self): if not self.__push_frame_task: - self.__push_queue = asyncio.Queue() + self.__push_queue = WatchdogQueue(self) self.__push_frame_task = self.create_task(self.__push_frame_task_handler()) async def __cancel_push_task(self): @@ -442,7 +440,5 @@ class FrameProcessor(BaseObject): async def __push_frame_task_handler(self): while True: (frame, direction) = await self.__push_queue.get() - self.start_watchdog() await self.__internal_push_frame(frame, direction) self.__push_queue.task_done() - self.reset_watchdog() diff --git a/src/pipecat/processors/frameworks/rtvi.py b/src/pipecat/processors/frameworks/rtvi.py index 29e85e5d7..e35f72e0b 100644 --- a/src/pipecat/processors/frameworks/rtvi.py +++ b/src/pipecat/processors/frameworks/rtvi.py @@ -68,6 +68,7 @@ from pipecat.transports.base_input import BaseInputTransport from pipecat.transports.base_output import BaseOutputTransport from pipecat.transports.base_transport import BaseTransport from pipecat.utils.string import match_endofsentence +from pipecat.utils.watchdog_queue import WatchdogQueue RTVI_PROTOCOL_VERSION = "0.3.0" @@ -650,11 +651,11 @@ class RTVIProcessor(FrameProcessor): self._registered_services: Dict[str, RTVIService] = {} # A task to process incoming action frames. - self._action_queue = asyncio.Queue() + self._action_queue = WatchdogQueue(self) self._action_task: Optional[asyncio.Task] = None # A task to process incoming transport messages. - self._message_queue = asyncio.Queue() + self._message_queue = WatchdogQueue(self) self._message_task: Optional[asyncio.Task] = None self._register_event_handler("on_bot_started") @@ -783,18 +784,14 @@ class RTVIProcessor(FrameProcessor): async def _action_task_handler(self): while True: frame = await self._action_queue.get() - self.start_watchdog() await self._handle_action(frame.message_id, frame.rtvi_action_run) self._action_queue.task_done() - self.reset_watchdog() async def _message_task_handler(self): while True: message = await self._message_queue.get() - self.start_watchdog() await self._handle_message(message) self._message_queue.task_done() - self.reset_watchdog() async def _handle_transport_message(self, frame: TransportMessageUrgentFrame): try: diff --git a/src/pipecat/processors/metrics/sentry.py b/src/pipecat/processors/metrics/sentry.py index 20d854ffb..ac8ca2095 100644 --- a/src/pipecat/processors/metrics/sentry.py +++ b/src/pipecat/processors/metrics/sentry.py @@ -9,6 +9,8 @@ import asyncio from loguru import logger from pipecat.utils.asyncio import TaskManager +from pipecat.utils.watchdog_queue import WatchdogQueue +from pipecat.utils.watchdog_reseter import WatchdogReseter try: import sentry_sdk @@ -20,7 +22,7 @@ except ModuleNotFoundError as e: from pipecat.processors.metrics.frame_processor_metrics import FrameProcessorMetrics -class SentryMetrics(FrameProcessorMetrics): +class SentryMetrics(WatchdogReseter, FrameProcessorMetrics): def __init__(self): super().__init__() self._ttfb_metrics_tx = None @@ -28,13 +30,12 @@ class SentryMetrics(FrameProcessorMetrics): self._sentry_available = sentry_sdk.is_initialized() if not self._sentry_available: logger.warning("Sentry SDK not initialized. Sentry features will be disabled.") - self._sentry_queue = asyncio.Queue() self._sentry_task = None async def setup(self, task_manager: TaskManager): await super().setup(task_manager) if self._sentry_available: - self._sentry_queue = asyncio.Queue() + self._sentry_queue = WatchdogQueue(self) self._sentry_task = self.task_manager.create_task( self._sentry_task_handler(), name=f"{self}::_sentry_task_handler" ) @@ -48,6 +49,10 @@ class SentryMetrics(FrameProcessorMetrics): logger.trace(f"{self} Flushing Sentry metrics") sentry_sdk.flush(timeout=5.0) + def reset_watchdog(self): + if self._task_manager: + self._task_manager.reset_watchdog(asyncio.current_task()) + async def start_ttfb_metrics(self, report_only_initial_ttfb): await super().start_ttfb_metrics(report_only_initial_ttfb) @@ -93,3 +98,4 @@ class SentryMetrics(FrameProcessorMetrics): if tx: await self.task_manager.get_event_loop().run_in_executor(None, tx.finish) running = tx is not None + self._sentry_queue.task_done() diff --git a/src/pipecat/processors/producer_processor.py b/src/pipecat/processors/producer_processor.py index 6ada2ed83..6dd381a51 100644 --- a/src/pipecat/processors/producer_processor.py +++ b/src/pipecat/processors/producer_processor.py @@ -9,6 +9,7 @@ from typing import Awaitable, Callable, List from pipecat.frames.frames import Frame from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +from pipecat.utils.watchdog_queue import WatchdogQueue async def identity_transformer(frame: Frame): @@ -36,14 +37,14 @@ class ProducerProcessor(FrameProcessor): self._passthrough = passthrough self._consumers: List[asyncio.Queue] = [] - def add_consumer(self): + def add_consumer(self, consumer: FrameProcessor): """ Adds a new consumer and returns its associated queue. Returns: asyncio.Queue: The queue for the newly added consumer. """ - queue = asyncio.Queue() + queue = WatchdogQueue(consumer) self._consumers.append(queue) return queue diff --git a/src/pipecat/services/anthropic/llm.py b/src/pipecat/services/anthropic/llm.py index 236f269fa..0d92e8b30 100644 --- a/src/pipecat/services/anthropic/llm.py +++ b/src/pipecat/services/anthropic/llm.py @@ -47,6 +47,7 @@ from pipecat.processors.aggregators.openai_llm_context import ( from pipecat.processors.frame_processor import FrameDirection from pipecat.services.llm_service import FunctionCallFromLLM, LLMService from pipecat.utils.tracing.service_decorators import traced_llm +from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator try: from anthropic import NOT_GIVEN, AsyncAnthropic, NotGiven @@ -203,7 +204,7 @@ class AnthropicLLMService(LLMService): json_accumulator = "" function_calls = [] - async for event in response: + async for event in WatchdogAsyncIterator(response, reseter=self): # Aggregate streaming content, create frames, trigger events if event.type == "content_block_delta": diff --git a/src/pipecat/services/assemblyai/stt.py b/src/pipecat/services/assemblyai/stt.py index 09aaa4d25..452d4cfb6 100644 --- a/src/pipecat/services/assemblyai/stt.py +++ b/src/pipecat/services/assemblyai/stt.py @@ -189,17 +189,16 @@ class AssemblyAISTTService(STTService): try: while self._connected: try: - message = await self._websocket.recv() - self.start_watchdog() + message = await asyncio.wait_for(self._websocket.recv(), timeout=1.0) data = json.loads(message) await self._handle_message(data) + except asyncio.TimeoutError: + self.reset_watchdog() except websockets.exceptions.ConnectionClosedOK: break except Exception as e: logger.error(f"Error processing WebSocket message: {e}") break - finally: - self.reset_watchdog() except Exception as e: logger.error(f"Fatal error in receive handler: {e}") diff --git a/src/pipecat/services/aws/llm.py b/src/pipecat/services/aws/llm.py index dcec91463..d3217e7a1 100644 --- a/src/pipecat/services/aws/llm.py +++ b/src/pipecat/services/aws/llm.py @@ -711,6 +711,8 @@ class AWSBedrockLLMService(LLMService): function_calls = [] for event in response["stream"]: + self.reset_watchdog() + # Handle text content if "contentBlockDelta" in event: delta = event["contentBlockDelta"]["delta"] @@ -762,6 +764,7 @@ class AWSBedrockLLMService(LLMService): completion_tokens += usage.get("outputTokens", 0) cache_read_input_tokens += usage.get("cacheReadInputTokens", 0) cache_creation_input_tokens += usage.get("cacheWriteInputTokens", 0) + await self.run_function_calls(function_calls) except asyncio.CancelledError: # If we're interrupted, we won't get a complete usage report. So set our flag to use the diff --git a/src/pipecat/services/aws/stt.py b/src/pipecat/services/aws/stt.py index c9c1b32d1..c4170ebad 100644 --- a/src/pipecat/services/aws/stt.py +++ b/src/pipecat/services/aws/stt.py @@ -284,9 +284,7 @@ class AWSTranscribeSTTService(STTService): break try: - response = await self._ws_client.recv() - - self.start_watchdog() + response = await asyncio.wait_for(self._ws_client.recv(), timeout=1.0) headers, payload = decode_event(response) @@ -337,6 +335,8 @@ class AWSTranscribeSTTService(STTService): else: logger.debug(f"{self} Other message type received: {headers}") logger.debug(f"{self} Payload: {payload}") + except asyncio.TimeoutError: + self.reset_watchdog() except websockets.exceptions.ConnectionClosed as e: logger.error( f"{self} WebSocket connection closed in receive loop with code {e.code}: {e.reason}" @@ -345,5 +345,3 @@ class AWSTranscribeSTTService(STTService): except Exception as e: logger.error(f"{self} Unexpected error in receive loop: {e}") break - finally: - self.reset_watchdog() diff --git a/src/pipecat/services/aws_nova_sonic/aws.py b/src/pipecat/services/aws_nova_sonic/aws.py index 718e3dc50..93eb77e90 100644 --- a/src/pipecat/services/aws_nova_sonic/aws.py +++ b/src/pipecat/services/aws_nova_sonic/aws.py @@ -697,9 +697,9 @@ class AWSNovaSonicLLMService(LLMService): try: while self._stream and not self._disconnecting: output = await self._stream.await_output() - result = await output[1].receive() + result = await asyncio.wait_for(output[1].receive(), timeout=1.0) - self.start_watchdog() + self.reset_watchdog() if result.value and result.value.bytes_: response_data = result.value.bytes_.decode("utf-8") @@ -728,13 +728,12 @@ class AWSNovaSonicLLMService(LLMService): elif "completionEnd" in event_json: # Handle the LLM completion ending await self._handle_completion_end_event(event_json) - + except asyncio.TimeoutError: + self.reset_watchdog() except Exception as e: logger.error(f"{self} error processing responses: {e}") if self._wants_connection: await self.reset_conversation() - finally: - self.reset_watchdog() async def _handle_completion_start_event(self, event_json): pass diff --git a/src/pipecat/services/cartesia/tts.py b/src/pipecat/services/cartesia/tts.py index 22493f229..30f5c0754 100644 --- a/src/pipecat/services/cartesia/tts.py +++ b/src/pipecat/services/cartesia/tts.py @@ -30,6 +30,7 @@ from pipecat.transcriptions.language import Language from pipecat.utils.text.base_text_aggregator import BaseTextAggregator from pipecat.utils.text.skip_tags_aggregator import SkipTagsAggregator from pipecat.utils.tracing.service_decorators import traced_tts +from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator # See .env.example for Cartesia configuration needed try: @@ -255,7 +256,7 @@ class CartesiaTTSService(AudioContextWordTTSService): self._context_id = None async def _receive_messages(self): - async for message in self._get_websocket(): + async for message in WatchdogAsyncIterator(self._get_websocket(), reseter=self): msg = json.loads(message) if not msg or not self.audio_context_available(msg["context_id"]): continue diff --git a/src/pipecat/services/elevenlabs/tts.py b/src/pipecat/services/elevenlabs/tts.py index ccd9b5b3f..d0bd29f5b 100644 --- a/src/pipecat/services/elevenlabs/tts.py +++ b/src/pipecat/services/elevenlabs/tts.py @@ -33,6 +33,7 @@ from pipecat.services.tts_service import ( ) from pipecat.transcriptions.language import Language from pipecat.utils.tracing.service_decorators import traced_tts +from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator # See .env.example for ElevenLabs configuration needed try: @@ -394,7 +395,7 @@ class ElevenLabsTTSService(AudioContextWordTTSService): self._started = False async def _receive_messages(self): - async for message in self._get_websocket(): + async for message in WatchdogAsyncIterator(self._get_websocket(), reseter=self): msg = json.loads(message) received_ctx_id = msg.get("contextId") @@ -426,7 +427,8 @@ class ElevenLabsTTSService(AudioContextWordTTSService): async def _keepalive_task_handler(self): while True: - await asyncio.sleep(10) + self.reset_watchdog() + await asyncio.sleep(4) try: if self._websocket and self._websocket.open: if self._context_id: diff --git a/src/pipecat/services/gemini_multimodal_live/gemini.py b/src/pipecat/services/gemini_multimodal_live/gemini.py index d8a90fada..44829500f 100644 --- a/src/pipecat/services/gemini_multimodal_live/gemini.py +++ b/src/pipecat/services/gemini_multimodal_live/gemini.py @@ -60,7 +60,8 @@ from pipecat.services.openai.llm import ( from pipecat.transcriptions.language import Language from pipecat.utils.string import match_endofsentence from pipecat.utils.time import time_now_iso8601 -from pipecat.utils.tracing.service_decorators import traced_gemini_live, traced_stt, traced_tts +from pipecat.utils.tracing.service_decorators import traced_gemini_live, traced_stt +from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator from . import events @@ -686,9 +687,7 @@ class GeminiMultimodalLiveLLMService(LLMService): # async def _receive_task_handler(self): - async for message in self._websocket: - self.start_watchdog() - + async for message in WatchdogAsyncIterator(self._websocket, reseter=self): evt = events.parse_server_event(message) # logger.debug(f"Received event: {message[:500]}") # logger.debug(f"Received event: {evt}") @@ -711,8 +710,6 @@ class GeminiMultimodalLiveLLMService(LLMService): # errors are fatal, so exit the receive loop return - self.reset_watchdog() - # # # diff --git a/src/pipecat/services/gladia/stt.py b/src/pipecat/services/gladia/stt.py index 885fe8dc2..ef73c9c97 100644 --- a/src/pipecat/services/gladia/stt.py +++ b/src/pipecat/services/gladia/stt.py @@ -27,6 +27,7 @@ from pipecat.services.stt_service import STTService from pipecat.transcriptions.language import Language from pipecat.utils.time import time_now_iso8601 from pipecat.utils.tracing.service_decorators import traced_stt +from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator try: import websockets @@ -501,9 +502,7 @@ class GladiaSTTService(STTService): async def _receive_task_handler(self): try: - async for message in self._websocket: - self.start_watchdog() - + async for message in WatchdogAsyncIterator(self._websocket, reseter=self): content = json.loads(message) # Handle audio chunk acknowledgments @@ -568,8 +567,6 @@ class GladiaSTTService(STTService): pass except Exception as e: logger.error(f"Error in Gladia WebSocket handler: {e}") - finally: - self.reset_watchdog() async def _maybe_reconnect(self) -> bool: """Handle exponential backoff reconnection logic.""" diff --git a/src/pipecat/services/google/llm.py b/src/pipecat/services/google/llm.py index f983b7342..38fba45b9 100644 --- a/src/pipecat/services/google/llm.py +++ b/src/pipecat/services/google/llm.py @@ -48,6 +48,7 @@ from pipecat.services.openai.llm import ( OpenAIUserContextAggregator, ) from pipecat.utils.tracing.service_decorators import traced_llm +from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator # Suppress gRPC fork warnings os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false" @@ -557,7 +558,7 @@ class GoogleLLMService(LLMService): ) function_calls = [] - async for chunk in response: + async for chunk in WatchdogAsyncIterator(response, reseter=self): # Stop TTFB metrics after the first chunk await self.stop_ttfb_metrics() if chunk.usage_metadata: diff --git a/src/pipecat/services/google/llm_openai.py b/src/pipecat/services/google/llm_openai.py index a497cb229..e7fcfdb0f 100644 --- a/src/pipecat/services/google/llm_openai.py +++ b/src/pipecat/services/google/llm_openai.py @@ -11,6 +11,7 @@ from openai import AsyncStream from openai.types.chat import ChatCompletionChunk from pipecat.services.llm_service import FunctionCallFromLLM +from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator # Suppress gRPC fork warnings os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false" @@ -53,7 +54,7 @@ class GoogleLLMOpenAIBetaService(OpenAILLMService): context ) - async for chunk in chunk_stream: + async for chunk in WatchdogAsyncIterator(chunk_stream, reseter=self): if chunk.usage: tokens = LLMTokenUsage( prompt_tokens=chunk.usage.prompt_tokens, diff --git a/src/pipecat/services/google/stt.py b/src/pipecat/services/google/stt.py index 26186e6bf..067db7a3a 100644 --- a/src/pipecat/services/google/stt.py +++ b/src/pipecat/services/google/stt.py @@ -10,6 +10,7 @@ import os import time from pipecat.utils.tracing.service_decorators import traced_stt +from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator # Suppress gRPC fork warnings os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false" @@ -747,8 +748,6 @@ class GoogleSTTService(STTService): try: while True: try: - self.start_watchdog() - if self._request_queue.empty(): # wait for 10ms in case we don't have audio await asyncio.sleep(0.01) @@ -763,8 +762,6 @@ class GoogleSTTService(STTService): # Process responses await self._process_responses(streaming_recognize) - self.reset_watchdog() - # If we're here, check if we need to reconnect if (int(time.time() * 1000) - self._stream_start_time) > self.STREAMING_LIMIT: logger.debug("Reconnecting stream after timeout") @@ -779,8 +776,6 @@ class GoogleSTTService(STTService): await asyncio.sleep(1) # Brief delay before reconnecting self._stream_start_time = int(time.time() * 1000) - finally: - self.reset_watchdog() except Exception as e: logger.error(f"Error in streaming task: {e}") @@ -804,17 +799,13 @@ class GoogleSTTService(STTService): async def _process_responses(self, streaming_recognize): """Process streaming recognition responses.""" try: - async for response in streaming_recognize: - self.start_watchdog() - + async for response in WatchdogAsyncIterator(streaming_recognize, reseter=self): # Check streaming limit if (int(time.time() * 1000) - self._stream_start_time) > self.STREAMING_LIMIT: logger.debug("Stream timeout reached in response processing") - self.reset_watchdog() break if not response.results: - self.reset_watchdog() continue for result in response.results: @@ -856,11 +847,8 @@ class GoogleSTTService(STTService): result=result, ) ) - - self.reset_watchdog() except Exception as e: logger.error(f"Error processing Google STT responses: {e}") - self.reset_watchdog() # Re-raise the exception to let it propagate (e.g. in the case of a # timeout, propagate to _stream_audio to reconnect) raise diff --git a/src/pipecat/services/openai/base_llm.py b/src/pipecat/services/openai/base_llm.py index 2badfed96..9651e0f99 100644 --- a/src/pipecat/services/openai/base_llm.py +++ b/src/pipecat/services/openai/base_llm.py @@ -36,6 +36,7 @@ from pipecat.processors.aggregators.openai_llm_context import ( from pipecat.processors.frame_processor import FrameDirection from pipecat.services.llm_service import FunctionCallFromLLM, LLMService from pipecat.utils.tracing.service_decorators import traced_llm +from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator class BaseOpenAILLMService(LLMService): @@ -192,7 +193,7 @@ class BaseOpenAILLMService(LLMService): context ) - async for chunk in chunk_stream: + async for chunk in WatchdogAsyncIterator(chunk_stream, reseter=self): if chunk.usage: tokens = LLMTokenUsage( prompt_tokens=chunk.usage.prompt_tokens, diff --git a/src/pipecat/services/openai_realtime_beta/openai.py b/src/pipecat/services/openai_realtime_beta/openai.py index 7f459000a..49e3383e7 100644 --- a/src/pipecat/services/openai_realtime_beta/openai.py +++ b/src/pipecat/services/openai_realtime_beta/openai.py @@ -52,7 +52,8 @@ from pipecat.services.llm_service import FunctionCallFromLLM, LLMService from pipecat.services.openai.llm import OpenAIContextAggregatorPair from pipecat.transcriptions.language import Language from pipecat.utils.time import time_now_iso8601 -from pipecat.utils.tracing.service_decorators import traced_openai_realtime, traced_stt, traced_tts +from pipecat.utils.tracing.service_decorators import traced_openai_realtime, traced_stt +from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator from . import events from .context import ( @@ -369,8 +370,7 @@ class OpenAIRealtimeBetaLLMService(LLMService): # async def _receive_task_handler(self): - async for message in self._websocket: - self.start_watchdog() + async for message in WatchdogAsyncIterator(self._websocket, reseter=self): evt = events.parse_server_event(message) if evt.type == "session.created": await self._handle_evt_session_created(evt) @@ -401,7 +401,6 @@ class OpenAIRealtimeBetaLLMService(LLMService): await self._handle_evt_error(evt) # errors are fatal, so exit the receive loop return - self.reset_watchdog() @traced_openai_realtime(operation="llm_setup") async def _handle_evt_session_created(self, evt): diff --git a/src/pipecat/services/riva/stt.py b/src/pipecat/services/riva/stt.py index 91c207c8b..16d9528c5 100644 --- a/src/pipecat/services/riva/stt.py +++ b/src/pipecat/services/riva/stt.py @@ -23,6 +23,7 @@ from pipecat.services.stt_service import SegmentedSTTService, STTService from pipecat.transcriptions.language import Language from pipecat.utils.time import time_now_iso8601 from pipecat.utils.tracing.service_decorators import traced_stt +from pipecat.utils.watchdog_queue import WatchdogQueue try: import riva.client @@ -198,7 +199,7 @@ class RivaSTTService(STTService): self._thread_task = self.create_task(self._thread_task_handler()) if not self._response_task: - self._response_queue = asyncio.Queue() + self._response_queue = WatchdogQueue(self) self._response_task = self.create_task(self._response_task_handler()) async def stop(self, frame: EndFrame): @@ -224,13 +225,12 @@ class RivaSTTService(STTService): streaming_config=self._config, ) for response in responses: - self.start_watchdog() + self.reset_watchdog() if not response.results: continue asyncio.run_coroutine_threadsafe( self._response_queue.put(response), self.get_event_loop() ) - self.reset_watchdog() async def _thread_task_handler(self): try: @@ -285,9 +285,8 @@ class RivaSTTService(STTService): async def _response_task_handler(self): while True: response = await self._response_queue.get() - self.start_watchdog() await self._handle_response(response) - self.reset_watchdog() + self._response_queue.task_done() async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: await self.start_ttfb_metrics() diff --git a/src/pipecat/services/sambanova/llm.py b/src/pipecat/services/sambanova/llm.py index 01a8d294c..6c44215a0 100644 --- a/src/pipecat/services/sambanova/llm.py +++ b/src/pipecat/services/sambanova/llm.py @@ -19,6 +19,7 @@ from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext from pipecat.services.llm_service import FunctionCallFromLLM from pipecat.services.openai.llm import OpenAILLMService from pipecat.utils.tracing.service_decorators import traced_llm +from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator class SambaNovaLLMService(OpenAILLMService): # type: ignore @@ -94,7 +95,7 @@ class SambaNovaLLMService(OpenAILLMService): # type: ignore context ) - async for chunk in chunk_stream: + async for chunk in WatchdogAsyncIterator(chunk_stream, reseter=self): if chunk.usage: tokens = LLMTokenUsage( prompt_tokens=chunk.usage.prompt_tokens, diff --git a/src/pipecat/services/simli/video.py b/src/pipecat/services/simli/video.py index 76381b9c6..19774ab4f 100644 --- a/src/pipecat/services/simli/video.py +++ b/src/pipecat/services/simli/video.py @@ -18,6 +18,7 @@ from pipecat.frames.frames import ( TTSAudioRawFrame, ) from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, StartFrame +from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator try: from av.audio.frame import AudioFrame @@ -61,8 +62,8 @@ class SimliVideoService(FrameProcessor): async def _consume_and_process_audio(self): await self._pipecat_resampler_event.wait() - async for audio_frame in self._simli_client.getAudioStreamIterator(): - self.start_watchdog() + audio_iterator = self._simli_client.getAudioStreamIterator() + async for audio_frame in WatchdogAsyncIterator(audio_iterator, reseter=self): resampled_frames = self._pipecat_resampler.resample(audio_frame) for resampled_frame in resampled_frames: audio_array = resampled_frame.to_ndarray() @@ -75,12 +76,11 @@ class SimliVideoService(FrameProcessor): num_channels=1, ), ) - self.reset_watchdog() async def _consume_and_process_video(self): await self._pipecat_resampler_event.wait() - async for video_frame in self._simli_client.getVideoStreamIterator(targetFormat="rgb24"): - self.start_watchdog() + video_iterator = self._simli_client.getVideoStreamIterator(targetFormat="rgb24") + async for video_frame in WatchdogAsyncIterator(video_iterator, reseter=self): # Process the video frame convertedFrame: OutputImageRawFrame = OutputImageRawFrame( image=video_frame.to_rgb().to_image().tobytes(), @@ -89,7 +89,6 @@ class SimliVideoService(FrameProcessor): ) convertedFrame.pts = video_frame.pts await self.push_frame(convertedFrame) - self.reset_watchdog() async def process_frame(self, frame: Frame, direction: FrameDirection): await super().process_frame(frame, direction) diff --git a/src/pipecat/services/tavus/video.py b/src/pipecat/services/tavus/video.py index 4ec744c51..41202d0e5 100644 --- a/src/pipecat/services/tavus/video.py +++ b/src/pipecat/services/tavus/video.py @@ -27,6 +27,7 @@ from pipecat.frames.frames import ( from pipecat.processors.frame_processor import FrameDirection, FrameProcessorSetup from pipecat.services.ai_service import AIService from pipecat.transports.services.tavus import TavusCallbacks, TavusParams, TavusTransportClient +from pipecat.utils.watchdog_queue import WatchdogQueue class TavusVideoService(AIService): @@ -71,7 +72,7 @@ class TavusVideoService(AIService): self._resampler = create_default_resampler() self._audio_buffer = bytearray() - self._queue = asyncio.Queue() + self._queue = WatchdogQueue(self) self._send_task: Optional[asyncio.Task] = None # This is the custom track destination expected by Tavus self._transport_destination: Optional[str] = "stream" @@ -188,7 +189,7 @@ class TavusVideoService(AIService): async def _create_send_task(self): if not self._send_task: - self._queue = asyncio.Queue() + self._queue = WatchdogQueue(self) self._send_task = self.create_task(self._send_task_handler()) async def _cancel_send_task(self): @@ -217,7 +218,6 @@ class TavusVideoService(AIService): async def _send_task_handler(self): while True: frame = await self._queue.get() - self.start_watchdog() if isinstance(frame, OutputAudioRawFrame) and self._client: await self._client.write_audio_frame(frame) - self.reset_watchdog() + self._queue.task_done() diff --git a/src/pipecat/services/tts_service.py b/src/pipecat/services/tts_service.py index 904f603a9..a623c9531 100644 --- a/src/pipecat/services/tts_service.py +++ b/src/pipecat/services/tts_service.py @@ -39,6 +39,7 @@ from pipecat.utils.text.base_text_aggregator import BaseTextAggregator from pipecat.utils.text.base_text_filter import BaseTextFilter from pipecat.utils.text.simple_text_aggregator import SimpleTextAggregator from pipecat.utils.time import seconds_to_nanoseconds +from pipecat.utils.watchdog_queue import WatchdogQueue class TTSService(AIService): @@ -315,6 +316,8 @@ class TTSService(AIService): if has_started: await self.push_frame(TTSStoppedFrame()) has_started = False + finally: + self.reset_watchdog() class WordTTSService(TTSService): @@ -327,7 +330,7 @@ class WordTTSService(TTSService): def __init__(self, **kwargs): super().__init__(**kwargs) self._initial_word_timestamp = -1 - self._words_queue = asyncio.Queue() + self._words_queue = WatchdogQueue(self) self._words_task = None self._llm_response_started: bool = False @@ -578,7 +581,7 @@ class AudioContextWordTTSService(WebsocketWordTTSService): def _create_audio_context_task(self): if not self._audio_context_task: - self._contexts_queue = asyncio.Queue() + self._contexts_queue = WatchdogQueue(self) self._contexts: Dict[str, asyncio.Queue] = {} self._audio_context_task = self.create_task(self._audio_context_task_handler()) @@ -620,10 +623,12 @@ class AudioContextWordTTSService(WebsocketWordTTSService): while running: try: frame = await asyncio.wait_for(queue.get(), timeout=AUDIO_CONTEXT_TIMEOUT) + self.reset_watchdog() if frame: await self.push_frame(frame) running = frame is not None except asyncio.TimeoutError: + self.reset_watchdog() # We didn't get audio, so let's consider this context finished. logger.trace(f"{self} time out on audio context {context_id}") break diff --git a/src/pipecat/transports/base_input.py b/src/pipecat/transports/base_input.py index d707c5969..93cd90825 100644 --- a/src/pipecat/transports/base_input.py +++ b/src/pipecat/transports/base_input.py @@ -368,8 +368,6 @@ class BaseInputTransport(FrameProcessor): self._audio_in_queue.get(), timeout=AUDIO_INPUT_TIMEOUT_SECS ) - self.start_watchdog() - # If an audio filter is available, run it before VAD. if self._params.audio_in_filter: frame.audio = await self._params.audio_in_filter.filter(frame.audio) diff --git a/src/pipecat/transports/base_output.py b/src/pipecat/transports/base_output.py index 386f223d7..2b37e7ddf 100644 --- a/src/pipecat/transports/base_output.py +++ b/src/pipecat/transports/base_output.py @@ -40,6 +40,7 @@ from pipecat.frames.frames import ( from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.transports.base_transport import TransportParams from pipecat.utils.time import nanoseconds_to_seconds +from pipecat.utils.watchdog_priority_queue import WatchdogPriorityQueue BOT_VAD_STOP_SECS = 0.35 @@ -441,8 +442,10 @@ class BaseOutputTransport(FrameProcessor): frame = await asyncio.wait_for( self._audio_queue.get(), timeout=vad_stop_secs ) + self._transport.reset_watchdog() yield frame except asyncio.TimeoutError: + self._transport.reset_watchdog() # Notify the bot stopped speaking upstream if necessary. await self._bot_stopped_speaking() @@ -452,11 +455,13 @@ class BaseOutputTransport(FrameProcessor): while True: try: frame = self._audio_queue.get_nowait() + self._transport.reset_watchdog() if isinstance(frame, OutputAudioRawFrame): frame.audio = await self._mixer.mix(frame.audio) last_frame_time = time.time() yield frame except asyncio.QueueEmpty: + self._transport.reset_watchdog() # Notify the bot stopped speaking upstream if necessary. diff_time = time.time() - last_frame_time if diff_time > vad_stop_secs: @@ -597,7 +602,7 @@ class BaseOutputTransport(FrameProcessor): def _create_clock_task(self): if not self._clock_task: - self._clock_queue = asyncio.PriorityQueue() + self._clock_queue = WatchdogPriorityQueue(self._transport) self._clock_task = self._transport.create_task(self._clock_task_handler()) async def _cancel_clock_task(self): diff --git a/src/pipecat/transports/network/fastapi_websocket.py b/src/pipecat/transports/network/fastapi_websocket.py index 7f6256b83..710c715a9 100644 --- a/src/pipecat/transports/network/fastapi_websocket.py +++ b/src/pipecat/transports/network/fastapi_websocket.py @@ -26,11 +26,12 @@ from pipecat.frames.frames import ( TransportMessageFrame, TransportMessageUrgentFrame, ) -from pipecat.processors.frame_processor import FrameDirection, FrameProcessorSetup +from pipecat.processors.frame_processor import FrameDirection from pipecat.serializers.base_serializer import FrameSerializer, FrameSerializerType from pipecat.transports.base_input import BaseInputTransport from pipecat.transports.base_output import BaseOutputTransport from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator try: from fastapi import WebSocket @@ -178,12 +179,10 @@ class FastAPIWebsocketInputTransport(BaseInputTransport): async def _receive_messages(self): try: - async for message in self._client.receive(): + async for message in WatchdogAsyncIterator(self._client.receive(), reseter=self): if not self._params.serializer: continue - self.start_watchdog() - frame = await self._params.serializer.deserialize(message) if not frame: @@ -193,13 +192,9 @@ class FastAPIWebsocketInputTransport(BaseInputTransport): await self.push_audio_frame(frame) else: await self.push_frame(frame) - - self.reset_watchdog() except Exception as e: logger.error(f"{self} exception receiving data: {e.__class__.__name__} ({e})") - self.reset_watchdog() - await self._client.trigger_client_disconnected() async def _monitor_websocket(self): diff --git a/src/pipecat/transports/network/small_webrtc.py b/src/pipecat/transports/network/small_webrtc.py index 5b36fec37..48ca9d53f 100644 --- a/src/pipecat/transports/network/small_webrtc.py +++ b/src/pipecat/transports/network/small_webrtc.py @@ -33,6 +33,7 @@ from pipecat.transports.base_input import BaseInputTransport from pipecat.transports.base_output import BaseOutputTransport from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection +from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator try: import cv2 @@ -422,19 +423,18 @@ class SmallWebRTCInputTransport(BaseInputTransport): async def _receive_audio(self): try: - async for audio_frame in self._client.read_audio_frame(): - self.start_watchdog() + audio_iterator = self._client.read_audio_frame() + async for audio_frame in WatchdogAsyncIterator(audio_iterator, reseter=self): if audio_frame: await self.push_audio_frame(audio_frame) - self.reset_watchdog() except Exception as e: logger.error(f"{self} exception receiving data: {e.__class__.__name__} ({e})") async def _receive_video(self): try: - async for video_frame in self._client.read_video_frame(): - self.start_watchdog() + video_iterator = self._client.read_video_frame() + async for video_frame in WatchdogAsyncIterator(video_iterator, reseter=self): if video_frame: await self.push_video_frame(video_frame) @@ -453,7 +453,6 @@ class SmallWebRTCInputTransport(BaseInputTransport): await self.push_video_frame(image_frame) # Remove from pending requests del self._image_requests[req_id] - self.reset_watchdog() except Exception as e: logger.error(f"{self} exception receiving data: {e.__class__.__name__} ({e})") diff --git a/src/pipecat/transports/services/daily.py b/src/pipecat/transports/services/daily.py index 776da1693..61afcb6c9 100644 --- a/src/pipecat/transports/services/daily.py +++ b/src/pipecat/transports/services/daily.py @@ -40,6 +40,8 @@ from pipecat.transports.base_input import BaseInputTransport from pipecat.transports.base_output import BaseOutputTransport from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.utils.asyncio import BaseTaskManager +from pipecat.utils.watchdog_queue import WatchdogQueue +from pipecat.utils.watchdog_reseter import WatchdogReseter try: from daily import ( @@ -250,7 +252,7 @@ class DailyAudioTrack: track: CustomAudioTrack -class DailyTransportClient(EventHandler): +class DailyTransportClient(WatchdogReseter, EventHandler): """Core client for interacting with Daily's API. Manages the connection to Daily rooms and handles all low-level API interactions. @@ -320,9 +322,9 @@ class DailyTransportClient(EventHandler): # waits for it to finish using completions (and a future) we will # deadlock because completions use event handlers (which are holding the # GIL). - self._event_queue = asyncio.Queue() - self._audio_queue = asyncio.Queue() - self._video_queue = asyncio.Queue() + self._event_queue = WatchdogQueue(self) + self._audio_queue = WatchdogQueue(self) + self._video_queue = WatchdogQueue(self) self._event_task = None self._audio_task = None self._video_task = None @@ -395,6 +397,10 @@ class DailyTransportClient(EventHandler): if not frame.transport_destination and self._camera: self._camera.write_frame(frame.image) + def reset_watchdog(self): + if self._task_manager: + self._task_manager.reset_watchdog(asyncio.current_task()) + async def setup(self, setup: FrameProcessorSetup): if self._task_manager: return @@ -934,6 +940,7 @@ class DailyTransportClient(EventHandler): await self._joined_event.wait() (callback, *args) = await queue.get() await callback(*args) + queue.task_done() def _get_event_loop(self) -> asyncio.AbstractEventLoop: if not self._task_manager: diff --git a/src/pipecat/transports/services/livekit.py b/src/pipecat/transports/services/livekit.py index ecb2718c8..5a4c38069 100644 --- a/src/pipecat/transports/services/livekit.py +++ b/src/pipecat/transports/services/livekit.py @@ -28,6 +28,7 @@ from pipecat.transports.base_input import BaseInputTransport from pipecat.transports.base_output import BaseOutputTransport from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.utils.asyncio import BaseTaskManager +from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator try: from livekit import rtc @@ -341,8 +342,9 @@ class LiveKitTransportClient: logger.warning(f"Received unexpected event type: {type(event)}") async def get_next_audio_frame(self): - frame, participant_id = await self._audio_queue.get() - return frame, participant_id + while True: + frame, participant_id = await self._audio_queue.get() + yield frame, participant_id def __str__(self): return f"{self._transport_name}::LiveKitTransportClient" @@ -413,9 +415,8 @@ class LiveKitInputTransport(BaseInputTransport): async def _audio_in_task_handler(self): logger.info("Audio input task started") - while True: - audio_data = await self._client.get_next_audio_frame() - self.start_watchdog() + audio_iterator = self._client.get_next_audio_frame() + async for audio_data in WatchdogAsyncIterator(audio_iterator, reseter=self): if audio_data: audio_frame_event, participant_id = audio_data pipecat_audio_frame = await self._convert_livekit_audio_to_pipecat( @@ -428,7 +429,6 @@ class LiveKitInputTransport(BaseInputTransport): num_channels=pipecat_audio_frame.num_channels, ) await self.push_audio_frame(input_audio_frame) - self.reset_watchdog() async def _convert_livekit_audio_to_pipecat( self, audio_frame_event: rtc.AudioFrameEvent diff --git a/src/pipecat/utils/asyncio.py b/src/pipecat/utils/asyncio.py index 36479edd4..a2c75ceb6 100644 --- a/src/pipecat/utils/asyncio.py +++ b/src/pipecat/utils/asyncio.py @@ -27,10 +27,6 @@ class BaseTaskManager(ABC): def setup(self, params: TaskManagerParams): pass - @abstractmethod - async def cleanup(self): - pass - @abstractmethod def get_event_loop(self) -> asyncio.AbstractEventLoop: pass @@ -97,14 +93,6 @@ class BaseTaskManager(ABC): """Returns the list of currently created/registered tasks.""" pass - @abstractmethod - def start_watchdog(self, task: asyncio.Task): - """Starts the given task watchdog timer. If not reset, a warning will be - logged indicating the task is stalling. - - """ - pass - @abstractmethod def reset_watchdog(self, task: asyncio.Task): """Resets the given task watchdog timer. If not reset, a warning will be @@ -117,31 +105,21 @@ class BaseTaskManager(ABC): @dataclass class TaskData: task: asyncio.Task - watchdog_start: asyncio.Event watchdog_timer: asyncio.Event enable_watchdog_logging: bool watchdog_timeout: float + watchdog_task: Optional[asyncio.Task] class TaskManager(BaseTaskManager): def __init__(self) -> None: self._tasks: Dict[str, TaskData] = {} self._params: Optional[TaskManagerParams] = None - self._watchdog_tasks: List[asyncio.Task] = [] def setup(self, params: TaskManagerParams): if not self._params: self._params = params - async def cleanup(self): - for task in self._watchdog_tasks: - try: - task.cancel() - await task - except asyncio.CancelledError: - # This is expected, no need to re-raise. - pass - def get_event_loop(self) -> asyncio.AbstractEventLoop: if not self._params: raise Exception("TaskManager is not setup: unable to get event loop") @@ -189,7 +167,6 @@ class TaskManager(BaseTaskManager): self._add_task( TaskData( task=task, - watchdog_start=asyncio.Event(), watchdog_timer=asyncio.Event(), enable_watchdog_logging=( enable_watchdog_logging @@ -199,6 +176,7 @@ class TaskManager(BaseTaskManager): watchdog_timeout=( watchdog_timeout if watchdog_timeout else self._params.watchdog_timeout ), + watchdog_task=None, ) ) logger.trace(f"{name}: task created") @@ -231,7 +209,7 @@ class TaskManager(BaseTaskManager): except Exception as e: logger.exception(f"{name}: unexpected exception while stopping task: {e}") finally: - self._remove_task(task) + await self._remove_task(task) async def cancel_task(self, task: asyncio.Task, timeout: Optional[float] = None): """Cancels the given asyncio Task and awaits its completion with an @@ -265,36 +243,19 @@ class TaskManager(BaseTaskManager): logger.critical(f"{name}: fatal base exception while cancelling task: {e}") raise finally: - self._remove_task(task) + await self._remove_task(task) def current_tasks(self) -> Sequence[asyncio.Task]: """Returns the list of currently created/registered tasks.""" return [data.task for data in self._tasks.values()] - def start_watchdog(self, task: asyncio.Task): - """Starts the given task watchdog timer. If not reset, a warning will be - logged indicating the task is stalling. If the timer was already started - a warning will be logged. - - """ - name = task.get_name() - if name in self._tasks: - if self._tasks[name].watchdog_start.is_set(): - logger.warning(f"Watchdog timer for task {name} already started") - else: - self._tasks[name].watchdog_timer.clear() - self._tasks[name].watchdog_start.set() - else: - logger.warning(f"Unable to start watchdog timer: task {name} does not exist") - def reset_watchdog(self, task: asyncio.Task): - """Resets the given task watchdog timer. If not reset, a warning will be - logged indicating the task is stalling. + """Resets the given task watchdog timer. If not reset on time, a warning + will be logged indicating the task is stalling. """ name = task.get_name() if name in self._tasks: - self._tasks[name].watchdog_start.clear() self._tasks[name].watchdog_timer.set() else: logger.warning(f"Unable to reset watchdog timer: task {name} does not exist") @@ -302,44 +263,40 @@ class TaskManager(BaseTaskManager): def _add_task(self, task_data: TaskData): name = task_data.task.get_name() self._tasks[name] = task_data - watchdog_task = self.get_event_loop().create_task( - self._watchdog_task_handler(self._tasks[name]) - ) - self._watchdog_tasks.append(watchdog_task) + watchdog_task = self.get_event_loop().create_task(self._watchdog_task_handler(task_data)) + task_data.watchdog_task = watchdog_task - def _remove_task(self, task: asyncio.Task): + async def _remove_task(self, task: asyncio.Task): name = task.get_name() try: + task_data = self._tasks[name] + if task_data.watchdog_task: + try: + task_data.watchdog_task.cancel() + await task_data.watchdog_task + except asyncio.CancelledError: + pass + task_data.watchdog_task = None del self._tasks[name] except KeyError as e: logger.trace(f"{name}: unable to remove task (already removed?): {e}") async def _watchdog_task_handler(self, task_data: TaskData): name = task_data.task.get_name() - start = task_data.watchdog_start timer = task_data.watchdog_timer enable_watchdog_logging = task_data.enable_watchdog_logging watchdog_timeout = task_data.watchdog_timeout - async def wait_for_reset(): - waiting = True - while waiting: - try: - start_time = time.time() - await asyncio.wait_for(timer.wait(), timeout=watchdog_timeout) - total_time = time.time() - start_time - if enable_watchdog_logging: - logger.debug(f"{name} task processing time: {total_time:.20f}") - waiting = False - except asyncio.TimeoutError: - logger.warning( - f"{name}: task is taking too long {WATCHDOG_TIMEOUT} second(s) (forgot to reset watchdog?)" - ) - finally: - timer.clear() - while True: - # Wait for the user to start the watchdog timer. - await start.wait() - # Now, waiting for the task to finish. - await wait_for_reset() + try: + start_time = time.time() + await asyncio.wait_for(timer.wait(), timeout=watchdog_timeout) + total_time = time.time() - start_time + if enable_watchdog_logging: + logger.debug(f"{name} task processing time: {total_time:.20f}") + except asyncio.TimeoutError: + logger.warning( + f"{name}: task is taking too long {WATCHDOG_TIMEOUT} second(s) (forgot to reset watchdog?)" + ) + finally: + timer.clear() diff --git a/src/pipecat/utils/watchdog_async_iterator.py b/src/pipecat/utils/watchdog_async_iterator.py new file mode 100644 index 000000000..db126a363 --- /dev/null +++ b/src/pipecat/utils/watchdog_async_iterator.py @@ -0,0 +1,60 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +from typing import AsyncIterator, Optional + +from pipecat.utils.watchdog_reseter import WatchdogReseter + + +class WatchdogAsyncIterator: + """An asynchronous iterator that monitors activity and resets the current + task watchdog timer. This is necessary to avoid task watchdog timers to + expire while we are waiting to get an item from the iterator. + + """ + + def __init__(self, async_iterable, *, reseter: WatchdogReseter, timeout: float = 2.0): + self._async_iterable = async_iterable + self._reseter = reseter + self._timeout = timeout + self._iter: Optional[AsyncIterator] = None + self._current_anext_task: Optional[asyncio.Task] = None + + def __aiter__(self): + return self + + async def __anext__(self): + if not self._iter: + self._iter = await self._ensure_async_iterator(self._async_iterable) + + while True: + try: + if not self._current_anext_task: + self._current_anext_task = asyncio.create_task(self._iter.__anext__()) + + item = await asyncio.wait_for( + asyncio.shield(self._current_anext_task), + timeout=self._timeout, + ) + + self._reseter.reset_watchdog() + + # The task has finish, so we will create a new one for th next item. + self._current_anext_task = None + + return item + except asyncio.TimeoutError: + self._reseter.reset_watchdog() + except StopAsyncIteration: + self._current_anext_task = None + raise + + async def _ensure_async_iterator(self, obj) -> AsyncIterator: + aiter = obj.__aiter__() + if asyncio.iscoroutine(aiter): + aiter = await aiter + return aiter diff --git a/src/pipecat/utils/watchdog_event.py b/src/pipecat/utils/watchdog_event.py new file mode 100644 index 000000000..3165c31df --- /dev/null +++ b/src/pipecat/utils/watchdog_event.py @@ -0,0 +1,31 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio + +from pipecat.utils.watchdog_reseter import WatchdogReseter + + +class WatchdogEvent(asyncio.Event): + """An asynchronous event that resets the current task watchdog timer. This + is necessary to avoid task watchdog timers to expire while we are waiting on + the event. + + """ + + def __init__(self, reseter: WatchdogReseter, timeout: float = 2.0) -> None: + super().__init__() + self._reseter = reseter + self._timeout = timeout + + async def wait(self): + while True: + try: + await asyncio.wait_for(super().wait(), timeout=self._timeout) + self._reseter.reset_watchdog() + return True + except asyncio.TimeoutError: + self._reseter.reset_watchdog() diff --git a/src/pipecat/utils/watchdog_priority_queue.py b/src/pipecat/utils/watchdog_priority_queue.py new file mode 100644 index 000000000..34782fbf8 --- /dev/null +++ b/src/pipecat/utils/watchdog_priority_queue.py @@ -0,0 +1,35 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio + +from pipecat.utils.watchdog_reseter import WatchdogReseter + + +class WatchdogPriorityQueue(asyncio.PriorityQueue): + """An asynchronous priority queue that resets the current task watchdog + timer. This is necessary to avoid task watchdog timers to expire while we + are waiting to get an item from the queue. + + """ + + def __init__(self, reseter: WatchdogReseter, maxsize: int = 0, timeout: float = 2.0) -> None: + super().__init__(maxsize) + self._reseter = reseter + self._timeout = timeout + + async def get(self): + while True: + try: + item = await asyncio.wait_for(super().get(), timeout=self._timeout) + self._reseter.reset_watchdog() + return item + except asyncio.TimeoutError: + self._reseter.reset_watchdog() + + def task_done(self): + self._reseter.reset_watchdog() + super().task_done() diff --git a/src/pipecat/utils/watchdog_queue.py b/src/pipecat/utils/watchdog_queue.py new file mode 100644 index 000000000..0d9ffd7a8 --- /dev/null +++ b/src/pipecat/utils/watchdog_queue.py @@ -0,0 +1,35 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio + +from pipecat.utils.watchdog_reseter import WatchdogReseter + + +class WatchdogQueue(asyncio.Queue): + """An asynchronous queue that resets the current task watchdog timer. This + is necessary to avoid task watchdog timers to expire while we are waiting to + get an item from the queue. + + """ + + def __init__(self, reseter: WatchdogReseter, maxsize: int = 0, timeout: float = 2.0) -> None: + super().__init__(maxsize) + self._reseter = reseter + self._timeout = timeout + + async def get(self): + while True: + try: + item = await asyncio.wait_for(super().get(), timeout=self._timeout) + self._reseter.reset_watchdog() + return item + except asyncio.TimeoutError: + self._reseter.reset_watchdog() + + def task_done(self): + self._reseter.reset_watchdog() + super().task_done() diff --git a/src/pipecat/utils/watchdog_reseter.py b/src/pipecat/utils/watchdog_reseter.py new file mode 100644 index 000000000..ee70207b3 --- /dev/null +++ b/src/pipecat/utils/watchdog_reseter.py @@ -0,0 +1,13 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +from abc import ABC, abstractmethod + + +class WatchdogReseter(ABC): + @abstractmethod + def reset_watchdog(self): + pass From d2730e67419204049304d8cf4fac34b86fd9d07f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Wed, 25 Jun 2025 10:22:29 -0700 Subject: [PATCH 03/54] GooglSTTService: cleanup request queues --- src/pipecat/services/google/stt.py | 45 ++++++++++-------------------- 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/src/pipecat/services/google/stt.py b/src/pipecat/services/google/stt.py index 067db7a3a..c87c857e5 100644 --- a/src/pipecat/services/google/stt.py +++ b/src/pipecat/services/google/stt.py @@ -437,7 +437,6 @@ class GoogleSTTService(STTService): self._location = location self._stream = None self._config = None - self._request_queue = asyncio.Queue() self._streaming_task = None # Used for keep-alive logic @@ -684,23 +683,15 @@ class GoogleSTTService(STTService): ), ) + self._request_queue = asyncio.Queue() self._streaming_task = self.create_task(self._stream_audio()) async def _disconnect(self): """Clean up streaming recognition resources.""" if self._streaming_task: logger.debug("Disconnecting from Google Speech-to-Text") - # Send sentinel value to stop request generator - await self._request_queue.put(None) await self.cancel_task(self._streaming_task) self._streaming_task = None - # Clear any remaining items in the queue - while not self._request_queue.empty(): - try: - self._request_queue.get_nowait() - self._request_queue.task_done() - except asyncio.QueueEmpty: - break async def _request_generator(self): """Generates requests for the streaming recognize method.""" @@ -715,29 +706,23 @@ class GoogleSTTService(STTService): ) while True: - try: - audio_data = await self._request_queue.get() - if audio_data is None: # Sentinel value to stop - break + audio_data = await self._request_queue.get() - # Check streaming limit - if (int(time.time() * 1000) - self._stream_start_time) > self.STREAMING_LIMIT: - logger.debug("Streaming limit reached, initiating graceful reconnection") - # Instead of immediate reconnection, we'll break and let the stream close naturally - self._last_audio_input = self._audio_input - self._audio_input = [] - self._restart_counter += 1 - # Put the current audio chunk back in the queue - await self._request_queue.put(audio_data) - break + self._request_queue.task_done() - self._audio_input.append(audio_data) - yield cloud_speech.StreamingRecognizeRequest(audio=audio_data) - - except asyncio.CancelledError: + # Check streaming limit + if (int(time.time() * 1000) - self._stream_start_time) > self.STREAMING_LIMIT: + logger.debug("Streaming limit reached, initiating graceful reconnection") + # Instead of immediate reconnection, we'll break and let the stream close naturally + self._last_audio_input = self._audio_input + self._audio_input = [] + self._restart_counter += 1 + # Put the current audio chunk back in the queue + await self._request_queue.put(audio_data) break - finally: - self._request_queue.task_done() + + self._audio_input.append(audio_data) + yield cloud_speech.StreamingRecognizeRequest(audio=audio_data) except Exception as e: logger.error(f"Error in request generator: {e}") From 327973657fc3fef35af0e0a317a8fb31d3968668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Wed, 25 Jun 2025 11:22:05 -0700 Subject: [PATCH 04/54] TaskManager: remove wathcdog timer when main task is done --- src/pipecat/utils/asyncio.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/pipecat/utils/asyncio.py b/src/pipecat/utils/asyncio.py index a2c75ceb6..a4b286273 100644 --- a/src/pipecat/utils/asyncio.py +++ b/src/pipecat/utils/asyncio.py @@ -8,7 +8,7 @@ import asyncio import time from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Coroutine, Dict, List, Optional, Sequence +from typing import Coroutine, Dict, Optional, Sequence from loguru import logger @@ -164,6 +164,7 @@ class TaskManager(BaseTaskManager): task = self._params.loop.create_task(run_coroutine()) task.set_name(name) + task.add_done_callback(self._task_done_handler) self._add_task( TaskData( task=task, @@ -269,14 +270,6 @@ class TaskManager(BaseTaskManager): async def _remove_task(self, task: asyncio.Task): name = task.get_name() try: - task_data = self._tasks[name] - if task_data.watchdog_task: - try: - task_data.watchdog_task.cancel() - await task_data.watchdog_task - except asyncio.CancelledError: - pass - task_data.watchdog_task = None del self._tasks[name] except KeyError as e: logger.trace(f"{name}: unable to remove task (already removed?): {e}") @@ -293,10 +286,20 @@ class TaskManager(BaseTaskManager): await asyncio.wait_for(timer.wait(), timeout=watchdog_timeout) total_time = time.time() - start_time if enable_watchdog_logging: - logger.debug(f"{name} task processing time: {total_time:.20f}") + logger.debug(f"{name} time between watchdog timer resets: {total_time:.20f}") except asyncio.TimeoutError: logger.warning( f"{name}: task is taking too long {WATCHDOG_TIMEOUT} second(s) (forgot to reset watchdog?)" ) finally: timer.clear() + + def _task_done_handler(self, task: asyncio.Task): + name = task.get_name() + try: + task_data = self._tasks[name] + if task_data.watchdog_task: + task_data.watchdog_task.cancel() + task_data.watchdog_task = None + except KeyError as e: + logger.trace(f"{name}: unable to find task (already removed?): {e}") From 357934a644adaf75d53230e767dbb4001234a8c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Wed, 25 Jun 2025 13:36:52 -0700 Subject: [PATCH 05/54] watchdog timers are disabled by default use enable_watchdog_timers --- src/pipecat/frames/frames.py | 2 +- src/pipecat/pipeline/parallel_pipeline.py | 41 ++++++++-------- .../pipeline/sync_parallel_pipeline.py | 35 +++++++------- src/pipecat/pipeline/task.py | 48 ++++++++++++++----- src/pipecat/pipeline/task_observer.py | 9 +++- src/pipecat/processors/frame_processor.py | 23 ++++++--- src/pipecat/processors/frameworks/rtvi.py | 4 +- .../metrics/frame_processor_metrics.py | 2 +- src/pipecat/processors/metrics/sentry.py | 6 +-- src/pipecat/processors/producer_processor.py | 2 +- src/pipecat/services/anthropic/llm.py | 4 +- src/pipecat/services/cartesia/tts.py | 4 +- src/pipecat/services/elevenlabs/tts.py | 4 +- .../services/gemini_multimodal_live/gemini.py | 4 +- src/pipecat/services/gladia/stt.py | 4 +- src/pipecat/services/google/llm.py | 4 +- src/pipecat/services/google/llm_openai.py | 4 +- src/pipecat/services/google/stt.py | 4 +- src/pipecat/services/openai/base_llm.py | 4 +- .../services/openai_realtime_beta/openai.py | 4 +- src/pipecat/services/riva/stt.py | 4 +- src/pipecat/services/sambanova/llm.py | 4 +- src/pipecat/services/simli/video.py | 8 +++- src/pipecat/services/tavus/video.py | 3 +- src/pipecat/services/tts_service.py | 6 ++- src/pipecat/transports/base_output.py | 4 +- .../transports/network/fastapi_websocket.py | 4 +- .../transports/network/small_webrtc.py | 8 +++- src/pipecat/transports/services/daily.py | 9 ++-- src/pipecat/transports/services/livekit.py | 4 +- src/pipecat/utils/asyncio.py | 11 ++++- src/pipecat/utils/watchdog_async_iterator.py | 16 ++++++- src/pipecat/utils/watchdog_event.py | 15 +++++- src/pipecat/utils/watchdog_priority_queue.py | 25 ++++++++-- src/pipecat/utils/watchdog_queue.py | 25 ++++++++-- 35 files changed, 256 insertions(+), 102 deletions(-) diff --git a/src/pipecat/frames/frames.py b/src/pipecat/frames/frames.py index 4b2d934ab..3a602a3e2 100644 --- a/src/pipecat/frames/frames.py +++ b/src/pipecat/frames/frames.py @@ -453,8 +453,8 @@ class StartFrame(SystemFrame): allow_interruptions: bool = False enable_metrics: bool = False enable_usage_metrics: bool = False - report_only_initial_ttfb: bool = False interruption_strategies: List[BaseInterruptionStrategy] = field(default_factory=list) + report_only_initial_ttfb: bool = False @dataclass diff --git a/src/pipecat/pipeline/parallel_pipeline.py b/src/pipecat/pipeline/parallel_pipeline.py index 4794ea708..f6ac78827 100644 --- a/src/pipecat/pipeline/parallel_pipeline.py +++ b/src/pipecat/pipeline/parallel_pipeline.py @@ -77,20 +77,36 @@ class ParallelPipeline(BasePipeline): if len(args) == 0: raise Exception(f"ParallelPipeline needs at least one argument") + self._args = args self._sources = [] self._sinks = [] + self._pipelines = [] + self._seen_ids = set() self._endframe_counter: Dict[int, int] = {} self._up_task = None self._down_task = None - self._up_queue = WatchdogQueue(self) - self._down_queue = WatchdogQueue(self) - self._pipelines = [] + # + # BasePipeline + # + + def processors_with_metrics(self) -> List[FrameProcessor]: + return list(chain.from_iterable(p.processors_with_metrics() for p in self._pipelines)) + + # + # Frame processor + # + + async def setup(self, setup: FrameProcessorSetup): + await super().setup(setup) + + self._up_queue = WatchdogQueue(self, watchdog_enabled=setup.watchdog_timers_enabled) + self._down_queue = WatchdogQueue(self, watchdog_enabled=setup.watchdog_timers_enabled) logger.debug(f"Creating {self} pipelines") - for processors in args: + for processors in self._args: if not isinstance(processors, list): raise TypeError(f"ParallelPipeline argument {processors} is not a list") @@ -108,19 +124,6 @@ class ParallelPipeline(BasePipeline): logger.debug(f"Finished creating {self} pipelines") - # - # BasePipeline - # - - def processors_with_metrics(self) -> List[FrameProcessor]: - return list(chain.from_iterable(p.processors_with_metrics() for p in self._pipelines)) - - # - # Frame processor - # - - async def setup(self, setup: FrameProcessorSetup): - await super().setup(setup) await asyncio.gather(*[s.setup(setup) for s in self._sources]) await asyncio.gather(*[p.setup(setup) for p in self._pipelines]) await asyncio.gather(*[s.setup(setup) for s in self._sinks]) @@ -135,7 +138,7 @@ class ParallelPipeline(BasePipeline): await super().process_frame(frame, direction) if isinstance(frame, StartFrame): - await self._start() + await self._start(frame) elif isinstance(frame, EndFrame): self._endframe_counter[frame.id] = len(self._pipelines) elif isinstance(frame, CancelFrame): @@ -155,7 +158,7 @@ class ParallelPipeline(BasePipeline): elif isinstance(frame, EndFrame): await self._stop() - async def _start(self): + async def _start(self, frame: StartFrame): await self._create_tasks() async def _stop(self): diff --git a/src/pipecat/pipeline/sync_parallel_pipeline.py b/src/pipecat/pipeline/sync_parallel_pipeline.py index 4cf9f5033..f78ca0de3 100644 --- a/src/pipecat/pipeline/sync_parallel_pipeline.py +++ b/src/pipecat/pipeline/sync_parallel_pipeline.py @@ -15,6 +15,7 @@ from pipecat.frames.frames import ControlFrame, EndFrame, Frame, SystemFrame from pipecat.pipeline.base_pipeline import BasePipeline from pipecat.pipeline.pipeline import Pipeline from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, FrameProcessorSetup +from pipecat.utils.watchdog_queue import WatchdogQueue @dataclass @@ -61,15 +62,30 @@ class SyncParallelPipeline(BasePipeline): if len(args) == 0: raise Exception(f"SyncParallelPipeline needs at least one argument") + self._args = args self._sinks = [] self._sources = [] self._pipelines = [] - self._up_queue = asyncio.Queue() - self._down_queue = asyncio.Queue() + # + # BasePipeline + # + + def processors_with_metrics(self) -> List[FrameProcessor]: + return list(chain.from_iterable(p.processors_with_metrics() for p in self._pipelines)) + + # + # Frame processor + # + + async def setup(self, setup: FrameProcessorSetup): + await super().setup(setup) + + self._up_queue = WatchdogQueue(self, watchdog_enabled=setup.watchdog_timers_enabled) + self._down_queue = WatchdogQueue(self, watchdog_enabled=setup.watchdog_timers_enabled) logger.debug(f"Creating {self} pipelines") - for processors in args: + for processors in self._args: if not isinstance(processors, list): raise TypeError(f"SyncParallelPipeline argument {processors} is not a list") @@ -92,19 +108,6 @@ class SyncParallelPipeline(BasePipeline): logger.debug(f"Finished creating {self} pipelines") - # - # BasePipeline - # - - def processors_with_metrics(self) -> List[FrameProcessor]: - return list(chain.from_iterable(p.processors_with_metrics() for p in self._pipelines)) - - # - # Frame processor - # - - async def setup(self, setup: FrameProcessorSetup): - await super().setup(setup) await asyncio.gather(*[s["processor"].setup(setup) for s in self._sources]) await asyncio.gather(*[p.setup(setup) for p in self._pipelines]) await asyncio.gather(*[s["processor"].setup(setup) for s in self._sinks]) diff --git a/src/pipecat/pipeline/task.py b/src/pipecat/pipeline/task.py index ad7a5cda6..68b76401e 100644 --- a/src/pipecat/pipeline/task.py +++ b/src/pipecat/pipeline/task.py @@ -190,6 +190,7 @@ class PipelineTask(WatchdogReseter, BasePipelineTask): enable_tracing: Whether to enable tracing. enable_turn_tracking: Whether to enable turn tracking. enable_watchdog_logging: Whether to print task processing times. + enable_watchdog_timers: Whether to enable task watchdog timers. idle_timeout_frames: A tuple with the frames that should trigger an idle timeout if not received withing `idle_timeout_seconds`. idle_timeout_secs: Timeout (in seconds) to consider pipeline idle or @@ -213,6 +214,7 @@ class PipelineTask(WatchdogReseter, BasePipelineTask): enable_tracing: bool = False, enable_turn_tracking: bool = True, enable_watchdog_logging: bool = False, + enable_watchdog_timers: bool = False, idle_timeout_frames: Tuple[Type[Frame], ...] = ( BotSpeakingFrame, LLMFullResponseEndFrame, @@ -233,6 +235,7 @@ class PipelineTask(WatchdogReseter, BasePipelineTask): self._enable_tracing = enable_tracing and is_tracing_available() self._enable_turn_tracking = enable_turn_tracking self._enable_watchdog_logging = enable_watchdog_logging + self._enable_watchdog_timers = enable_watchdog_timers self._idle_timeout_frames = idle_timeout_frames self._idle_timeout_secs = idle_timeout_secs self._watchdog_timeout_secs = watchdog_timeout_secs @@ -263,18 +266,24 @@ class PipelineTask(WatchdogReseter, BasePipelineTask): self._cancelled = False # This queue receives frames coming from the pipeline upstream. - self._up_queue = WatchdogQueue(self) + self._up_queue = WatchdogQueue(self, watchdog_enabled=enable_watchdog_timers) + self._process_up_task: Optional[asyncio.Task] = None # This queue receives frames coming from the pipeline downstream. - self._down_queue = WatchdogQueue(self) + self._down_queue = WatchdogQueue(self, watchdog_enabled=enable_watchdog_timers) + self._process_down_task: Optional[asyncio.Task] = None # This queue is the queue used to push frames to the pipeline. - self._push_queue = WatchdogQueue(self) + self._push_queue = WatchdogQueue(self, watchdog_enabled=enable_watchdog_timers) + self._process_push_task: Optional[asyncio.Task] = None # This is the heartbeat queue. When a heartbeat frame is received in the # down queue we add it to the heartbeat queue for processing. - self._heartbeat_queue = WatchdogQueue(self) + self._heartbeat_queue = WatchdogQueue(self, watchdog_enabled=enable_watchdog_timers) + self._heartbeat_push_task: Optional[asyncio.Task] = None + self._heartbeat_monitor_task: Optional[asyncio.Task] = None # This is the idle queue. When frames are received downstream they are # put in the queue. If no frame is received the pipeline is considered # idle. - self._idle_queue = WatchdogQueue(self) + self._idle_queue = WatchdogQueue(self, watchdog_enabled=enable_watchdog_timers) + self._idle_monitor_task: Optional[asyncio.Task] = None # This event is used to indicate a finalize frame (e.g. EndFrame, # StopFrame) has been received in the down queue. self._pipeline_end_event = asyncio.Event() @@ -438,7 +447,9 @@ class PipelineTask(WatchdogReseter, BasePipelineTask): # we want to cancel right away. await self._source.push_frame(CancelFrame()) # Only cancel the push task. Everything else will be cancelled in run(). - await self._task_manager.cancel_task(self._process_push_task) + if self._process_push_task: + await self._task_manager.cancel_task(self._process_push_task) + self._process_push_task = None async def _create_tasks(self): self._process_up_task = self._task_manager.create_task( @@ -451,7 +462,7 @@ class PipelineTask(WatchdogReseter, BasePipelineTask): self._process_push_queue(), f"{self}::_process_push_queue" ) - await self._observer.start() + await self._observer.start(self._enable_watchdog_timers) return self._process_push_task @@ -473,20 +484,33 @@ class PipelineTask(WatchdogReseter, BasePipelineTask): async def _cancel_tasks(self): await self._observer.stop() - await self._task_manager.cancel_task(self._process_up_task) - await self._task_manager.cancel_task(self._process_down_task) + if self._process_up_task: + await self._task_manager.cancel_task(self._process_up_task) + self._process_up_task = None + + if self._process_down_task: + await self._task_manager.cancel_task(self._process_down_task) + self._process_down_task = None await self._maybe_cancel_heartbeat_tasks() await self._maybe_cancel_idle_task() async def _maybe_cancel_heartbeat_tasks(self): - if self._params.enable_heartbeats: + if not self._params.enable_heartbeats: + return + + if self._heartbeat_push_task: await self._task_manager.cancel_task(self._heartbeat_push_task) + self._heartbeat_push_task = None + + if self._heartbeat_monitor_task: await self._task_manager.cancel_task(self._heartbeat_monitor_task) + self._heartbeat_monitor_task = None async def _maybe_cancel_idle_task(self): - if self._idle_timeout_secs: + if self._idle_timeout_secs and self._idle_monitor_task: await self._task_manager.cancel_task(self._idle_monitor_task) + self._idle_monitor_task = None def _initial_metrics_frame(self) -> MetricsFrame: processors = self._pipeline.processors_with_metrics() @@ -504,6 +528,7 @@ class PipelineTask(WatchdogReseter, BasePipelineTask): mgr_params = TaskManagerParams( loop=params.loop, enable_watchdog_logging=self._enable_watchdog_logging, + enable_watchdog_timers=self._enable_watchdog_timers, watchdog_timeout=self._watchdog_timeout_secs, ) self._task_manager.setup(mgr_params) @@ -512,6 +537,7 @@ class PipelineTask(WatchdogReseter, BasePipelineTask): clock=self._clock, task_manager=self._task_manager, observer=self._observer, + watchdog_timers_enabled=self._enable_watchdog_timers, ) await self._source.setup(setup) await self._pipeline.setup(setup) diff --git a/src/pipecat/pipeline/task_observer.py b/src/pipecat/pipeline/task_observer.py index 2f7450a75..40d4ef3ed 100644 --- a/src/pipecat/pipeline/task_observer.py +++ b/src/pipecat/pipeline/task_observer.py @@ -54,6 +54,7 @@ class TaskObserver(WatchdogReseter, BaseObserver): self._proxies: Optional[Dict[BaseObserver, Proxy]] = ( None # Becomes a dict after start() is called ) + self._watchdog_timers_enabled = False def add_observer(self, observer: BaseObserver): # Add the observer to the list. @@ -78,12 +79,16 @@ class TaskObserver(WatchdogReseter, BaseObserver): if observer in self._observers: self._observers.remove(observer) - async def start(self): + async def start(self, watchdog_timers_enabled: bool = False): """Starts all proxy observer tasks.""" + self._watchdog_timers_enabled = watchdog_timers_enabled self._proxies = self._create_proxies(self._observers) async def stop(self): """Stops all proxy observer tasks.""" + if not self._proxies: + return + for proxy in self._proxies.values(): await self._task_manager.cancel_task(proxy.task) @@ -98,7 +103,7 @@ class TaskObserver(WatchdogReseter, BaseObserver): return self._proxies is not None def _create_proxy(self, observer: BaseObserver) -> Proxy: - queue = WatchdogQueue(self) + queue = WatchdogQueue(self, watchdog_enabled=self._watchdog_timers_enabled) task = self._task_manager.create_task( self._proxy_task_handler(queue, observer), f"TaskObserver::{observer}::_proxy_task_handler", diff --git a/src/pipecat/processors/frame_processor.py b/src/pipecat/processors/frame_processor.py index d7723d868..9c61bd93a 100644 --- a/src/pipecat/processors/frame_processor.py +++ b/src/pipecat/processors/frame_processor.py @@ -46,6 +46,7 @@ class FrameProcessorSetup: clock: BaseClock task_manager: BaseTaskManager observer: Optional[BaseObserver] = None + watchdog_timers_enabled: bool = False class FrameProcessor(WatchdogReseter, BaseObject): @@ -84,6 +85,7 @@ class FrameProcessor(WatchdogReseter, BaseObject): self._enable_usage_metrics = False self._report_only_initial_ttfb = False self._interruption_strategies: List[BaseInterruptionStrategy] = [] + self._watchdog_timers_enabled = False # Indicates whether we have received the StartFrame. self.__started = False @@ -104,7 +106,7 @@ class FrameProcessor(WatchdogReseter, BaseObject): # is called. To resume processing frames we need to call # `resume_processing_frames()` which will wake up the event. self.__should_block_frames = False - self.__input_event = WatchdogEvent(self) + self.__input_event = None self.__input_frame_task: Optional[asyncio.Task] = None # Every processor in Pipecat should only output frames from a single @@ -140,6 +142,10 @@ class FrameProcessor(WatchdogReseter, BaseObject): def interruption_strategies(self) -> Sequence[BaseInterruptionStrategy]: return self._interruption_strategies + @property + def watchdog_timers_enabled(self): + return self._watchdog_timers_enabled + def can_generate_metrics(self) -> bool: return False @@ -220,8 +226,9 @@ class FrameProcessor(WatchdogReseter, BaseObject): self._clock = setup.clock self._task_manager = setup.task_manager self._observer = setup.observer + self._watchdog_timers_enabled = setup.watchdog_timers_enabled if self._metrics is not None: - await self._metrics.setup(self._task_manager) + await self._metrics.setup(self._task_manager, self.watchdog_timers_enabled) async def cleanup(self): await super().cleanup() @@ -313,8 +320,8 @@ class FrameProcessor(WatchdogReseter, BaseObject): self._allow_interruptions = frame.allow_interruptions self._enable_metrics = frame.enable_metrics self._enable_usage_metrics = frame.enable_usage_metrics - self._report_only_initial_ttfb = frame.report_only_initial_ttfb self._interruption_strategies = frame.interruption_strategies + self._report_only_initial_ttfb = frame.report_only_initial_ttfb self.__create_input_task() self.__create_push_task() @@ -396,8 +403,12 @@ class FrameProcessor(WatchdogReseter, BaseObject): def __create_input_task(self): if not self.__input_frame_task: self.__should_block_frames = False + if not self.__input_event: + self.__input_event = WatchdogEvent( + self, watchdog_enabled=self.watchdog_timers_enabled + ) self.__input_event.clear() - self.__input_queue = WatchdogQueue(self) + self.__input_queue = WatchdogQueue(self, watchdog_enabled=self.watchdog_timers_enabled) self.__input_frame_task = self.create_task(self.__input_frame_task_handler()) async def __cancel_input_task(self): @@ -407,7 +418,7 @@ class FrameProcessor(WatchdogReseter, BaseObject): async def __input_frame_task_handler(self): while True: - if self.__should_block_frames: + if self.__should_block_frames and self.__input_event: logger.trace(f"{self}: frame processing paused") await self.__input_event.wait() self.__input_event.clear() @@ -429,7 +440,7 @@ class FrameProcessor(WatchdogReseter, BaseObject): def __create_push_task(self): if not self.__push_frame_task: - self.__push_queue = WatchdogQueue(self) + self.__push_queue = WatchdogQueue(self, watchdog_enabled=self.watchdog_timers_enabled) self.__push_frame_task = self.create_task(self.__push_frame_task_handler()) async def __cancel_push_task(self): diff --git a/src/pipecat/processors/frameworks/rtvi.py b/src/pipecat/processors/frameworks/rtvi.py index e35f72e0b..1646a9fa6 100644 --- a/src/pipecat/processors/frameworks/rtvi.py +++ b/src/pipecat/processors/frameworks/rtvi.py @@ -651,11 +651,9 @@ class RTVIProcessor(FrameProcessor): self._registered_services: Dict[str, RTVIService] = {} # A task to process incoming action frames. - self._action_queue = WatchdogQueue(self) self._action_task: Optional[asyncio.Task] = None # A task to process incoming transport messages. - self._message_queue = WatchdogQueue(self) self._message_task: Optional[asyncio.Task] = None self._register_event_handler("on_bot_started") @@ -757,8 +755,10 @@ class RTVIProcessor(FrameProcessor): async def _start(self, frame: StartFrame): if not self._action_task: + self._action_queue = WatchdogQueue(self, watchdog_enabled=self.watchdog_timers_enabled) self._action_task = self.create_task(self._action_task_handler()) if not self._message_task: + self._message_queue = WatchdogQueue(self, watchdog_enabled=self.watchdog_timers_enabled) self._message_task = self.create_task(self._message_task_handler()) await self._call_event_handler("on_bot_started") diff --git a/src/pipecat/processors/metrics/frame_processor_metrics.py b/src/pipecat/processors/metrics/frame_processor_metrics.py index 40a83fa38..9ee1ccdd3 100644 --- a/src/pipecat/processors/metrics/frame_processor_metrics.py +++ b/src/pipecat/processors/metrics/frame_processor_metrics.py @@ -31,7 +31,7 @@ class FrameProcessorMetrics(BaseObject): self._last_ttfb_time = 0 self._should_report_ttfb = True - async def setup(self, task_manager: TaskManager): + async def setup(self, task_manager: TaskManager, watchdog_timers_enabled: bool = False): self._task_manager = task_manager async def cleanup(self): diff --git a/src/pipecat/processors/metrics/sentry.py b/src/pipecat/processors/metrics/sentry.py index ac8ca2095..083ff621b 100644 --- a/src/pipecat/processors/metrics/sentry.py +++ b/src/pipecat/processors/metrics/sentry.py @@ -32,10 +32,10 @@ class SentryMetrics(WatchdogReseter, FrameProcessorMetrics): logger.warning("Sentry SDK not initialized. Sentry features will be disabled.") self._sentry_task = None - async def setup(self, task_manager: TaskManager): - await super().setup(task_manager) + async def setup(self, task_manager: TaskManager, watchdog_timers_enabled: bool = False): + await super().setup(task_manager, watchdog_timers_enabled) if self._sentry_available: - self._sentry_queue = WatchdogQueue(self) + self._sentry_queue = WatchdogQueue(self, watchdog_enabled=watchdog_timers_enabled) self._sentry_task = self.task_manager.create_task( self._sentry_task_handler(), name=f"{self}::_sentry_task_handler" ) diff --git a/src/pipecat/processors/producer_processor.py b/src/pipecat/processors/producer_processor.py index 6dd381a51..ad08802e2 100644 --- a/src/pipecat/processors/producer_processor.py +++ b/src/pipecat/processors/producer_processor.py @@ -44,7 +44,7 @@ class ProducerProcessor(FrameProcessor): Returns: asyncio.Queue: The queue for the newly added consumer. """ - queue = WatchdogQueue(consumer) + queue = WatchdogQueue(consumer, watchdog_enabled=self.watchdog_timers_enabled) self._consumers.append(queue) return queue diff --git a/src/pipecat/services/anthropic/llm.py b/src/pipecat/services/anthropic/llm.py index 0d92e8b30..b5334c383 100644 --- a/src/pipecat/services/anthropic/llm.py +++ b/src/pipecat/services/anthropic/llm.py @@ -204,7 +204,9 @@ class AnthropicLLMService(LLMService): json_accumulator = "" function_calls = [] - async for event in WatchdogAsyncIterator(response, reseter=self): + async for event in WatchdogAsyncIterator( + response, reseter=self, watchdog_enabled=self.watchdog_timers_enabled + ): # Aggregate streaming content, create frames, trigger events if event.type == "content_block_delta": diff --git a/src/pipecat/services/cartesia/tts.py b/src/pipecat/services/cartesia/tts.py index 30f5c0754..8ac997f27 100644 --- a/src/pipecat/services/cartesia/tts.py +++ b/src/pipecat/services/cartesia/tts.py @@ -256,7 +256,9 @@ class CartesiaTTSService(AudioContextWordTTSService): self._context_id = None async def _receive_messages(self): - async for message in WatchdogAsyncIterator(self._get_websocket(), reseter=self): + async for message in WatchdogAsyncIterator( + self._get_websocket(), reseter=self, watchdog_enabled=self.watchdog_timers_enabled + ): msg = json.loads(message) if not msg or not self.audio_context_available(msg["context_id"]): continue diff --git a/src/pipecat/services/elevenlabs/tts.py b/src/pipecat/services/elevenlabs/tts.py index d0bd29f5b..fdb7bf1a8 100644 --- a/src/pipecat/services/elevenlabs/tts.py +++ b/src/pipecat/services/elevenlabs/tts.py @@ -395,7 +395,9 @@ class ElevenLabsTTSService(AudioContextWordTTSService): self._started = False async def _receive_messages(self): - async for message in WatchdogAsyncIterator(self._get_websocket(), reseter=self): + async for message in WatchdogAsyncIterator( + self._get_websocket(), reseter=self, watchdog_enabled=self.watchdog_timers_enabled + ): msg = json.loads(message) received_ctx_id = msg.get("contextId") diff --git a/src/pipecat/services/gemini_multimodal_live/gemini.py b/src/pipecat/services/gemini_multimodal_live/gemini.py index 44829500f..c713c3cab 100644 --- a/src/pipecat/services/gemini_multimodal_live/gemini.py +++ b/src/pipecat/services/gemini_multimodal_live/gemini.py @@ -687,7 +687,9 @@ class GeminiMultimodalLiveLLMService(LLMService): # async def _receive_task_handler(self): - async for message in WatchdogAsyncIterator(self._websocket, reseter=self): + async for message in WatchdogAsyncIterator( + self._websocket, reseter=self, watchdog_enabled=self.watchdog_timers_enabled + ): evt = events.parse_server_event(message) # logger.debug(f"Received event: {message[:500]}") # logger.debug(f"Received event: {evt}") diff --git a/src/pipecat/services/gladia/stt.py b/src/pipecat/services/gladia/stt.py index ef73c9c97..a21c26ad5 100644 --- a/src/pipecat/services/gladia/stt.py +++ b/src/pipecat/services/gladia/stt.py @@ -502,7 +502,9 @@ class GladiaSTTService(STTService): async def _receive_task_handler(self): try: - async for message in WatchdogAsyncIterator(self._websocket, reseter=self): + async for message in WatchdogAsyncIterator( + self._websocket, reseter=self, watchdog_enabled=self.watchdog_timers_enabled + ): content = json.loads(message) # Handle audio chunk acknowledgments diff --git a/src/pipecat/services/google/llm.py b/src/pipecat/services/google/llm.py index 38fba45b9..5fe005fbd 100644 --- a/src/pipecat/services/google/llm.py +++ b/src/pipecat/services/google/llm.py @@ -558,7 +558,9 @@ class GoogleLLMService(LLMService): ) function_calls = [] - async for chunk in WatchdogAsyncIterator(response, reseter=self): + async for chunk in WatchdogAsyncIterator( + response, reseter=self, watchdog_enabled=self.watchdog_timers_enabled + ): # Stop TTFB metrics after the first chunk await self.stop_ttfb_metrics() if chunk.usage_metadata: diff --git a/src/pipecat/services/google/llm_openai.py b/src/pipecat/services/google/llm_openai.py index e7fcfdb0f..e76ac1886 100644 --- a/src/pipecat/services/google/llm_openai.py +++ b/src/pipecat/services/google/llm_openai.py @@ -54,7 +54,9 @@ class GoogleLLMOpenAIBetaService(OpenAILLMService): context ) - async for chunk in WatchdogAsyncIterator(chunk_stream, reseter=self): + async for chunk in WatchdogAsyncIterator( + chunk_stream, reseter=self, watchdog_enabled=self.watchdog_timers_enabled + ): if chunk.usage: tokens = LLMTokenUsage( prompt_tokens=chunk.usage.prompt_tokens, diff --git a/src/pipecat/services/google/stt.py b/src/pipecat/services/google/stt.py index c87c857e5..80f061b44 100644 --- a/src/pipecat/services/google/stt.py +++ b/src/pipecat/services/google/stt.py @@ -784,7 +784,9 @@ class GoogleSTTService(STTService): async def _process_responses(self, streaming_recognize): """Process streaming recognition responses.""" try: - async for response in WatchdogAsyncIterator(streaming_recognize, reseter=self): + async for response in WatchdogAsyncIterator( + streaming_recognize, reseter=self, watchdog_enabled=self.watchdog_timers_enabled + ): # Check streaming limit if (int(time.time() * 1000) - self._stream_start_time) > self.STREAMING_LIMIT: logger.debug("Stream timeout reached in response processing") diff --git a/src/pipecat/services/openai/base_llm.py b/src/pipecat/services/openai/base_llm.py index 9651e0f99..f0029ad22 100644 --- a/src/pipecat/services/openai/base_llm.py +++ b/src/pipecat/services/openai/base_llm.py @@ -193,7 +193,9 @@ class BaseOpenAILLMService(LLMService): context ) - async for chunk in WatchdogAsyncIterator(chunk_stream, reseter=self): + async for chunk in WatchdogAsyncIterator( + chunk_stream, reseter=self, watchdog_enabled=self.watchdog_timers_enabled + ): if chunk.usage: tokens = LLMTokenUsage( prompt_tokens=chunk.usage.prompt_tokens, diff --git a/src/pipecat/services/openai_realtime_beta/openai.py b/src/pipecat/services/openai_realtime_beta/openai.py index 49e3383e7..8d5168c70 100644 --- a/src/pipecat/services/openai_realtime_beta/openai.py +++ b/src/pipecat/services/openai_realtime_beta/openai.py @@ -370,7 +370,9 @@ class OpenAIRealtimeBetaLLMService(LLMService): # async def _receive_task_handler(self): - async for message in WatchdogAsyncIterator(self._websocket, reseter=self): + async for message in WatchdogAsyncIterator( + self._websocket, reseter=self, watchdog_enabled=self.watchdog_timers_enabled + ): evt = events.parse_server_event(message) if evt.type == "session.created": await self._handle_evt_session_created(evt) diff --git a/src/pipecat/services/riva/stt.py b/src/pipecat/services/riva/stt.py index 16d9528c5..284252adb 100644 --- a/src/pipecat/services/riva/stt.py +++ b/src/pipecat/services/riva/stt.py @@ -199,7 +199,9 @@ class RivaSTTService(STTService): self._thread_task = self.create_task(self._thread_task_handler()) if not self._response_task: - self._response_queue = WatchdogQueue(self) + self._response_queue = WatchdogQueue( + self, watchdog_enabled=self.watchdog_timers_enabled + ) self._response_task = self.create_task(self._response_task_handler()) async def stop(self, frame: EndFrame): diff --git a/src/pipecat/services/sambanova/llm.py b/src/pipecat/services/sambanova/llm.py index 6c44215a0..3ca2ee5be 100644 --- a/src/pipecat/services/sambanova/llm.py +++ b/src/pipecat/services/sambanova/llm.py @@ -95,7 +95,9 @@ class SambaNovaLLMService(OpenAILLMService): # type: ignore context ) - async for chunk in WatchdogAsyncIterator(chunk_stream, reseter=self): + async for chunk in WatchdogAsyncIterator( + chunk_stream, reseter=self, watchdog_enabled=self.watchdog_timers_enabled + ): if chunk.usage: tokens = LLMTokenUsage( prompt_tokens=chunk.usage.prompt_tokens, diff --git a/src/pipecat/services/simli/video.py b/src/pipecat/services/simli/video.py index 19774ab4f..2bca697cb 100644 --- a/src/pipecat/services/simli/video.py +++ b/src/pipecat/services/simli/video.py @@ -63,7 +63,9 @@ class SimliVideoService(FrameProcessor): async def _consume_and_process_audio(self): await self._pipecat_resampler_event.wait() audio_iterator = self._simli_client.getAudioStreamIterator() - async for audio_frame in WatchdogAsyncIterator(audio_iterator, reseter=self): + async for audio_frame in WatchdogAsyncIterator( + audio_iterator, reseter=self, watchdog_enabled=self.watchdog_timers_enabled + ): resampled_frames = self._pipecat_resampler.resample(audio_frame) for resampled_frame in resampled_frames: audio_array = resampled_frame.to_ndarray() @@ -80,7 +82,9 @@ class SimliVideoService(FrameProcessor): async def _consume_and_process_video(self): await self._pipecat_resampler_event.wait() video_iterator = self._simli_client.getVideoStreamIterator(targetFormat="rgb24") - async for video_frame in WatchdogAsyncIterator(video_iterator, reseter=self): + async for video_frame in WatchdogAsyncIterator( + video_iterator, reseter=self, watchdog_enabled=self.watchdog_timers_enabled + ): # Process the video frame convertedFrame: OutputImageRawFrame = OutputImageRawFrame( image=video_frame.to_rgb().to_image().tobytes(), diff --git a/src/pipecat/services/tavus/video.py b/src/pipecat/services/tavus/video.py index 41202d0e5..9688aaebc 100644 --- a/src/pipecat/services/tavus/video.py +++ b/src/pipecat/services/tavus/video.py @@ -72,7 +72,6 @@ class TavusVideoService(AIService): self._resampler = create_default_resampler() self._audio_buffer = bytearray() - self._queue = WatchdogQueue(self) self._send_task: Optional[asyncio.Task] = None # This is the custom track destination expected by Tavus self._transport_destination: Optional[str] = "stream" @@ -189,7 +188,7 @@ class TavusVideoService(AIService): async def _create_send_task(self): if not self._send_task: - self._queue = WatchdogQueue(self) + self._queue = WatchdogQueue(self, watchdog_enabled=self.watchdog_timers_enabled) self._send_task = self.create_task(self._send_task_handler()) async def _cancel_send_task(self): diff --git a/src/pipecat/services/tts_service.py b/src/pipecat/services/tts_service.py index a623c9531..2c3bb7b61 100644 --- a/src/pipecat/services/tts_service.py +++ b/src/pipecat/services/tts_service.py @@ -330,7 +330,6 @@ class WordTTSService(TTSService): def __init__(self, **kwargs): super().__init__(**kwargs) self._initial_word_timestamp = -1 - self._words_queue = WatchdogQueue(self) self._words_task = None self._llm_response_started: bool = False @@ -372,6 +371,7 @@ class WordTTSService(TTSService): def _create_words_task(self): if not self._words_task: + self._words_queue = WatchdogQueue(self, watchdog_enabled=self.watchdog_timers_enabled) self._words_task = self.create_task(self._words_task_handler()) async def _stop_words_task(self): @@ -581,7 +581,9 @@ class AudioContextWordTTSService(WebsocketWordTTSService): def _create_audio_context_task(self): if not self._audio_context_task: - self._contexts_queue = WatchdogQueue(self) + self._contexts_queue = WatchdogQueue( + self, watchdog_enabled=self.watchdog_timers_enabled + ) self._contexts: Dict[str, asyncio.Queue] = {} self._audio_context_task = self.create_task(self._audio_context_task_handler()) diff --git a/src/pipecat/transports/base_output.py b/src/pipecat/transports/base_output.py index 2b37e7ddf..f477be447 100644 --- a/src/pipecat/transports/base_output.py +++ b/src/pipecat/transports/base_output.py @@ -602,7 +602,9 @@ class BaseOutputTransport(FrameProcessor): def _create_clock_task(self): if not self._clock_task: - self._clock_queue = WatchdogPriorityQueue(self._transport) + self._clock_queue = WatchdogPriorityQueue( + self._transport, watchdog_enabled=self._transport.watchdog_timers_enabled + ) self._clock_task = self._transport.create_task(self._clock_task_handler()) async def _cancel_clock_task(self): diff --git a/src/pipecat/transports/network/fastapi_websocket.py b/src/pipecat/transports/network/fastapi_websocket.py index 710c715a9..3d19cb05f 100644 --- a/src/pipecat/transports/network/fastapi_websocket.py +++ b/src/pipecat/transports/network/fastapi_websocket.py @@ -179,7 +179,9 @@ class FastAPIWebsocketInputTransport(BaseInputTransport): async def _receive_messages(self): try: - async for message in WatchdogAsyncIterator(self._client.receive(), reseter=self): + async for message in WatchdogAsyncIterator( + self._client.receive(), reseter=self, watchdog_enabled=self.watchdog_timers_enabled + ): if not self._params.serializer: continue diff --git a/src/pipecat/transports/network/small_webrtc.py b/src/pipecat/transports/network/small_webrtc.py index 48ca9d53f..086aa383c 100644 --- a/src/pipecat/transports/network/small_webrtc.py +++ b/src/pipecat/transports/network/small_webrtc.py @@ -424,7 +424,9 @@ class SmallWebRTCInputTransport(BaseInputTransport): async def _receive_audio(self): try: audio_iterator = self._client.read_audio_frame() - async for audio_frame in WatchdogAsyncIterator(audio_iterator, reseter=self): + async for audio_frame in WatchdogAsyncIterator( + audio_iterator, reseter=self, watchdog_enabled=self.watchdog_timers_enabled + ): if audio_frame: await self.push_audio_frame(audio_frame) @@ -434,7 +436,9 @@ class SmallWebRTCInputTransport(BaseInputTransport): async def _receive_video(self): try: video_iterator = self._client.read_video_frame() - async for video_frame in WatchdogAsyncIterator(video_iterator, reseter=self): + async for video_frame in WatchdogAsyncIterator( + video_iterator, reseter=self, watchdog_enabled=self.watchdog_timers_enabled + ): if video_frame: await self.push_video_frame(video_frame) diff --git a/src/pipecat/transports/services/daily.py b/src/pipecat/transports/services/daily.py index 61afcb6c9..f92d632ca 100644 --- a/src/pipecat/transports/services/daily.py +++ b/src/pipecat/transports/services/daily.py @@ -306,6 +306,7 @@ class DailyTransportClient(WatchdogReseter, EventHandler): self._leave_counter = 0 self._task_manager: Optional[BaseTaskManager] = None + self._watchdog_timers_enabled = False # We use the executor to cleanup the client. We just do it from one # place, so only one thread is really needed. @@ -322,9 +323,6 @@ class DailyTransportClient(WatchdogReseter, EventHandler): # waits for it to finish using completions (and a future) we will # deadlock because completions use event handlers (which are holding the # GIL). - self._event_queue = WatchdogQueue(self) - self._audio_queue = WatchdogQueue(self) - self._video_queue = WatchdogQueue(self) self._event_task = None self._audio_task = None self._video_task = None @@ -406,6 +404,9 @@ class DailyTransportClient(WatchdogReseter, EventHandler): return self._task_manager = setup.task_manager + self._watchdog_timers_enabled = setup.watchdog_timers_enabled + + self._event_queue = WatchdogQueue(self, watchdog_enabled=self._watchdog_timers_enabled) self._event_task = self._task_manager.create_task( self._callback_task_handler(self._event_queue), f"{self}::event_callback_task", @@ -430,12 +431,14 @@ class DailyTransportClient(WatchdogReseter, EventHandler): self._out_sample_rate = self._params.audio_out_sample_rate or frame.audio_out_sample_rate if self._params.audio_in_enabled and not self._audio_task and self._task_manager: + self._audio_queue = WatchdogQueue(self, watchdog_enabled=self._watchdog_timers_enabled) self._audio_task = self._task_manager.create_task( self._callback_task_handler(self._audio_queue), f"{self}::audio_callback_task", ) if self._params.video_in_enabled and not self._video_task and self._task_manager: + self._video_queue = WatchdogQueue(self, watchdog_enabled=self._watchdog_timers_enabled) self._video_task = self._task_manager.create_task( self._callback_task_handler(self._video_queue), f"{self}::video_callback_task", diff --git a/src/pipecat/transports/services/livekit.py b/src/pipecat/transports/services/livekit.py index 5a4c38069..67ea6b32a 100644 --- a/src/pipecat/transports/services/livekit.py +++ b/src/pipecat/transports/services/livekit.py @@ -416,7 +416,9 @@ class LiveKitInputTransport(BaseInputTransport): async def _audio_in_task_handler(self): logger.info("Audio input task started") audio_iterator = self._client.get_next_audio_frame() - async for audio_data in WatchdogAsyncIterator(audio_iterator, reseter=self): + async for audio_data in WatchdogAsyncIterator( + audio_iterator, reseter=self, watchdog_enabled=self.watchdog_timers_enabled + ): if audio_data: audio_frame_event, participant_id = audio_data pipecat_audio_frame = await self._convert_livekit_audio_to_pipecat( diff --git a/src/pipecat/utils/asyncio.py b/src/pipecat/utils/asyncio.py index a4b286273..1d1ed36e6 100644 --- a/src/pipecat/utils/asyncio.py +++ b/src/pipecat/utils/asyncio.py @@ -18,6 +18,7 @@ WATCHDOG_TIMEOUT = 5.0 @dataclass class TaskManagerParams: loop: asyncio.AbstractEventLoop + enable_watchdog_timers: bool = False enable_watchdog_logging: bool = False watchdog_timeout: float = WATCHDOG_TIMEOUT @@ -255,6 +256,9 @@ class TaskManager(BaseTaskManager): will be logged indicating the task is stalling. """ + if self._params and not self._params.enable_watchdog_timers: + return + name = task.get_name() if name in self._tasks: self._tasks[name].watchdog_timer.set() @@ -264,8 +268,11 @@ class TaskManager(BaseTaskManager): def _add_task(self, task_data: TaskData): name = task_data.task.get_name() self._tasks[name] = task_data - watchdog_task = self.get_event_loop().create_task(self._watchdog_task_handler(task_data)) - task_data.watchdog_task = watchdog_task + if self._params and self._params.enable_watchdog_timers: + watchdog_task = self.get_event_loop().create_task( + self._watchdog_task_handler(task_data) + ) + task_data.watchdog_task = watchdog_task async def _remove_task(self, task: asyncio.Task): name = task.get_name() diff --git a/src/pipecat/utils/watchdog_async_iterator.py b/src/pipecat/utils/watchdog_async_iterator.py index db126a363..62b6d1a23 100644 --- a/src/pipecat/utils/watchdog_async_iterator.py +++ b/src/pipecat/utils/watchdog_async_iterator.py @@ -17,12 +17,20 @@ class WatchdogAsyncIterator: """ - def __init__(self, async_iterable, *, reseter: WatchdogReseter, timeout: float = 2.0): + def __init__( + self, + async_iterable, + *, + reseter: WatchdogReseter, + timeout: float = 2.0, + watchdog_enabled: bool = False, + ): self._async_iterable = async_iterable self._reseter = reseter self._timeout = timeout self._iter: Optional[AsyncIterator] = None self._current_anext_task: Optional[asyncio.Task] = None + self._watchdog_enabled = watchdog_enabled def __aiter__(self): return self @@ -31,6 +39,12 @@ class WatchdogAsyncIterator: if not self._iter: self._iter = await self._ensure_async_iterator(self._async_iterable) + if self._watchdog_enabled: + return await self._watchdog_anext() + else: + return await self._iter.__anext__() + + async def _watchdog_anext(self): while True: try: if not self._current_anext_task: diff --git a/src/pipecat/utils/watchdog_event.py b/src/pipecat/utils/watchdog_event.py index 3165c31df..001a0cf26 100644 --- a/src/pipecat/utils/watchdog_event.py +++ b/src/pipecat/utils/watchdog_event.py @@ -16,12 +16,25 @@ class WatchdogEvent(asyncio.Event): """ - def __init__(self, reseter: WatchdogReseter, timeout: float = 2.0) -> None: + def __init__( + self, + reseter: WatchdogReseter, + *, + timeout: float = 2.0, + watchdog_enabled: bool = False, + ) -> None: super().__init__() self._reseter = reseter self._timeout = timeout + self._watchdog_enabled = watchdog_enabled async def wait(self): + if self._watchdog_enabled: + return await self._watchdog_wait() + else: + return await super().wait() + + async def _watchdog_wait(self): while True: try: await asyncio.wait_for(super().wait(), timeout=self._timeout) diff --git a/src/pipecat/utils/watchdog_priority_queue.py b/src/pipecat/utils/watchdog_priority_queue.py index 34782fbf8..a3635667c 100644 --- a/src/pipecat/utils/watchdog_priority_queue.py +++ b/src/pipecat/utils/watchdog_priority_queue.py @@ -16,12 +16,31 @@ class WatchdogPriorityQueue(asyncio.PriorityQueue): """ - def __init__(self, reseter: WatchdogReseter, maxsize: int = 0, timeout: float = 2.0) -> None: + def __init__( + self, + reseter: WatchdogReseter, + *, + maxsize: int = 0, + timeout: float = 2.0, + watchdog_enabled: bool = False, + ) -> None: super().__init__(maxsize) self._reseter = reseter self._timeout = timeout + self._watchdog_enabled = watchdog_enabled async def get(self): + if self._watchdog_enabled: + return await self._watchdog_get() + else: + return await super().get() + + def task_done(self): + if self._watchdog_enabled: + self._reseter.reset_watchdog() + super().task_done() + + async def _watchdog_get(self): while True: try: item = await asyncio.wait_for(super().get(), timeout=self._timeout) @@ -29,7 +48,3 @@ class WatchdogPriorityQueue(asyncio.PriorityQueue): return item except asyncio.TimeoutError: self._reseter.reset_watchdog() - - def task_done(self): - self._reseter.reset_watchdog() - super().task_done() diff --git a/src/pipecat/utils/watchdog_queue.py b/src/pipecat/utils/watchdog_queue.py index 0d9ffd7a8..6eb9dab10 100644 --- a/src/pipecat/utils/watchdog_queue.py +++ b/src/pipecat/utils/watchdog_queue.py @@ -16,12 +16,31 @@ class WatchdogQueue(asyncio.Queue): """ - def __init__(self, reseter: WatchdogReseter, maxsize: int = 0, timeout: float = 2.0) -> None: + def __init__( + self, + reseter: WatchdogReseter, + *, + maxsize: int = 0, + timeout: float = 2.0, + watchdog_enabled: bool = False, + ) -> None: super().__init__(maxsize) self._reseter = reseter self._timeout = timeout + self._watchdog_enabled = watchdog_enabled async def get(self): + if self._watchdog_enabled: + return await self._watchdog_get() + else: + return await super().get() + + def task_done(self): + if self._watchdog_enabled: + self._reseter.reset_watchdog() + super().task_done() + + async def _watchdog_get(self): while True: try: item = await asyncio.wait_for(super().get(), timeout=self._timeout) @@ -29,7 +48,3 @@ class WatchdogQueue(asyncio.Queue): return item except asyncio.TimeoutError: self._reseter.reset_watchdog() - - def task_done(self): - self._reseter.reset_watchdog() - super().task_done() From 72cb96778002cc231d1efa3f4a981f7e0726aaf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Wed, 25 Jun 2025 13:55:19 -0700 Subject: [PATCH 06/54] update CHANGELOG with watchdog timers updates --- CHANGELOG.md | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f153dda13..cc2d21f2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,22 +12,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added logging and improved error handling to help diagnose and prevent potential Pipeline freezes. +- Added `WatchdogQueue`, `WatchdogPriorityQueue`, `WatchdogEvent` and + `WatchdogAsyncIterator`. These helper utilities reset watchdog timers + appropriately before they expire. When watchdog timers are disabled, the + utilities behave as standard counterparts without side effects. + - Introduce task watchdog timers. Watchdog timers are used to detect if a - Pipecat task is taking longer than expected (by default 5 seconds). It is + Pipecat task is taking longer than expected (by default 5 seconds). Watchdog + timers are disabled by default and can be enabled by passing + `enable_watchdog_timers` argument to `PipelineTask` constructor. It is possible to change the default watchdog timer timeout by using the - `watchdog_timeout` constructor argument when creating a `PipelineTask`. With - watchdog timers it is also possible to log how long each processing step is - taking (e.g. processing an element from a queue inside a task). This is done - with the `enable_watchdog_logging` constructor argument when creating a - `PipelineTask.` It is also possible to control these two values per each frame - processor. That is, you can set set `enable_watchdog_logging` and - `watchdog_timeout` when creating any frame processor through their constructor - arguments. Finally, you can also set these values per task. So, if you are - writing a frame processor that creates multiple tasks and you only want to - enable logging for one of them, you can do so by passing the same argument - names to the `FrameProcessor.create_task()` function. Note that watchdog - timers only work with Pipecat tasks but not if you use `asycio.create_task()` - or similar. + `watchdog_timeout` argument. You can also log how long it takes to reset the + watchdog timers which is done with the `enable_watchdog_logging`. You can + control these settings per each frame processor or even per task. That is, you + can set set `enable_watchdog_logging` and `watchdog_timeout` when creating any + frame processor through their constructor arguments or when you create a task + with `FrameProcessor.create_task()`. Note that watchdog timers only work with + Pipecat tasks and will not work if you use `asycio.create_task()` or similar. - Added `lexicon_names` parameter to `AWSPollyTTSService.InputParams`. From 4f032f5b96349439533b7f62436ccfb2e26fe663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Wed, 25 Jun 2025 14:26:50 -0700 Subject: [PATCH 07/54] update keepalive times depending on watchdog timers --- src/pipecat/services/elevenlabs/tts.py | 3 ++- src/pipecat/services/gladia/stt.py | 14 ++++++++------ src/pipecat/services/neuphonic/tts.py | 9 +++++++-- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/pipecat/services/elevenlabs/tts.py b/src/pipecat/services/elevenlabs/tts.py index fdb7bf1a8..7665632fc 100644 --- a/src/pipecat/services/elevenlabs/tts.py +++ b/src/pipecat/services/elevenlabs/tts.py @@ -428,9 +428,10 @@ class ElevenLabsTTSService(AudioContextWordTTSService): self._cumulative_time = word_times[-1][1] async def _keepalive_task_handler(self): + KEEPALIVE_SLEEP = 10 if self.watchdog_timers_enabled else 3 while True: self.reset_watchdog() - await asyncio.sleep(4) + await asyncio.sleep(KEEPALIVE_SLEEP) try: if self._websocket and self._websocket.open: if self._context_id: diff --git a/src/pipecat/services/gladia/stt.py b/src/pipecat/services/gladia/stt.py index a21c26ad5..75e8bbee3 100644 --- a/src/pipecat/services/gladia/stt.py +++ b/src/pipecat/services/gladia/stt.py @@ -392,8 +392,8 @@ class GladiaSTTService(STTService): await self._send_buffered_audio() # Start tasks - self._receive_task = asyncio.create_task(self._receive_task_handler()) - self._keepalive_task = asyncio.create_task(self._keepalive_task_handler()) + self._receive_task = self.create_task(self._receive_task_handler()) + self._keepalive_task = self.create_task(self._keepalive_task_handler()) # Wait for tasks to complete await asyncio.gather(self._receive_task, self._keepalive_task) @@ -404,9 +404,9 @@ class GladiaSTTService(STTService): # Clean up tasks if self._receive_task: - self._receive_task.cancel() + await self.cancel_task(self._receive_task) if self._keepalive_task: - self._keepalive_task.cancel() + await self.cancel_task(self._keepalive_task) # Attempt reconnect using helper if not await self._maybe_reconnect(): @@ -485,9 +485,11 @@ class GladiaSTTService(STTService): async def _keepalive_task_handler(self): """Send periodic empty audio chunks to keep the connection alive.""" try: + KEEPALIVE_SLEEP = 20 if self.watchdog_timers_enabled else 3 while self._connection_active: - # Send keepalive every 20 seconds (Gladia times out after 30 seconds) - await asyncio.sleep(20) + self.reset_watchdog() + # Send keepalive (Gladia times out after 30 seconds) + await asyncio.sleep(KEEPALIVE_SLEEP) if self._websocket and not self._websocket.closed: # Send an empty audio chunk as keepalive empty_audio = b"" diff --git a/src/pipecat/services/neuphonic/tts.py b/src/pipecat/services/neuphonic/tts.py index bfceca50b..85bd9d0cc 100644 --- a/src/pipecat/services/neuphonic/tts.py +++ b/src/pipecat/services/neuphonic/tts.py @@ -30,6 +30,7 @@ from pipecat.processors.frame_processor import FrameDirection from pipecat.services.tts_service import InterruptibleTTSService, TTSService from pipecat.transcriptions.language import Language from pipecat.utils.tracing.service_decorators import traced_tts +from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator try: import websockets @@ -221,7 +222,9 @@ class NeuphonicTTSService(InterruptibleTTSService): self._websocket = None async def _receive_messages(self): - async for message in self._websocket: + async for message in WatchdogAsyncIterator( + self._websocket, reseter=self, watchdog_enabled=self.watchdog_timers_enabled + ): if isinstance(message, str): msg = json.loads(message) if msg.get("data", {}).get("audio") is not None: @@ -232,8 +235,10 @@ class NeuphonicTTSService(InterruptibleTTSService): await self.push_frame(frame) async def _keepalive_task_handler(self): + KEEPALIVE_SLEEP = 10 if self.watchdog_timers_enabled else 3 while True: - await asyncio.sleep(10) + self.reset_watchdog() + await asyncio.sleep(KEEPALIVE_SLEEP) await self._send_text("") async def _send_text(self, text: str): From ef1ade3a71ed3b966d23db89bc91e7aae834dfaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Wed, 25 Jun 2025 16:34:45 -0700 Subject: [PATCH 08/54] allow enabling watchdog timers per frame processor or task --- CHANGELOG.md | 13 +++++++------ src/pipecat/processors/frame_processor.py | 23 ++++++++++++++++++----- src/pipecat/services/tts_service.py | 1 - src/pipecat/utils/asyncio.py | 20 ++++++++++++++------ 4 files changed, 39 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc2d21f2b..33064fa80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,16 +19,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Introduce task watchdog timers. Watchdog timers are used to detect if a Pipecat task is taking longer than expected (by default 5 seconds). Watchdog - timers are disabled by default and can be enabled by passing + timers are disabled by default and can be enabled globally by passing `enable_watchdog_timers` argument to `PipelineTask` constructor. It is possible to change the default watchdog timer timeout by using the `watchdog_timeout` argument. You can also log how long it takes to reset the watchdog timers which is done with the `enable_watchdog_logging`. You can - control these settings per each frame processor or even per task. That is, you - can set set `enable_watchdog_logging` and `watchdog_timeout` when creating any - frame processor through their constructor arguments or when you create a task - with `FrameProcessor.create_task()`. Note that watchdog timers only work with - Pipecat tasks and will not work if you use `asycio.create_task()` or similar. + control all these settings per each frame processor or even per task. That is, + you can set `enable_watchdog_timers`, `enable_watchdog_logging` and + `watchdog_timeout` when creating any frame processor through their constructor + arguments or when you create a task with `FrameProcessor.create_task()`. Note + that watchdog timers only work with Pipecat tasks and will not work if you use + `asycio.create_task()` or similar. - Added `lexicon_names` parameter to `AWSPollyTTSService.InputParams`. diff --git a/src/pipecat/processors/frame_processor.py b/src/pipecat/processors/frame_processor.py index 9c61bd93a..134a69da7 100644 --- a/src/pipecat/processors/frame_processor.py +++ b/src/pipecat/processors/frame_processor.py @@ -55,6 +55,7 @@ class FrameProcessor(WatchdogReseter, BaseObject): *, name: Optional[str] = None, enable_watchdog_logging: Optional[bool] = None, + enable_watchdog_timers: Optional[bool] = None, metrics: Optional[FrameProcessorMetrics] = None, watchdog_timeout_secs: Optional[float] = None, **kwargs, @@ -64,11 +65,14 @@ class FrameProcessor(WatchdogReseter, BaseObject): self._prev: Optional["FrameProcessor"] = None self._next: Optional["FrameProcessor"] = None + # Enable watchdog timers for all tasks created by this frame processor. + self._enable_watchdog_timers = enable_watchdog_timers + # Enable watchdog logging for all tasks created by this frame processor. self._enable_watchdog_logging = enable_watchdog_logging # Allow this frame processor to control their tasks timeout. - self._watchdog_timeout = watchdog_timeout_secs + self._watchdog_timeout_secs = watchdog_timeout_secs # Clock self._clock: Optional[BaseClock] = None @@ -194,6 +198,7 @@ class FrameProcessor(WatchdogReseter, BaseObject): name: Optional[str] = None, *, enable_watchdog_logging: Optional[bool] = None, + enable_watchdog_timers: Optional[bool] = None, watchdog_timeout_secs: Optional[float] = None, ) -> asyncio.Task: if name: @@ -208,8 +213,11 @@ class FrameProcessor(WatchdogReseter, BaseObject): if enable_watchdog_logging else self._enable_watchdog_logging ), + enable_watchdog_timers=( + enable_watchdog_timers if enable_watchdog_timers else self.watchdog_timers_enabled + ), watchdog_timeout=( - watchdog_timeout_secs if watchdog_timeout_secs else self._watchdog_timeout + watchdog_timeout_secs if watchdog_timeout_secs else self._watchdog_timeout_secs ), ) @@ -226,9 +234,13 @@ class FrameProcessor(WatchdogReseter, BaseObject): self._clock = setup.clock self._task_manager = setup.task_manager self._observer = setup.observer - self._watchdog_timers_enabled = setup.watchdog_timers_enabled + self._watchdog_timers_enabled = ( + self._enable_watchdog_timers + if self._enable_watchdog_timers + else setup.watchdog_timers_enabled + ) if self._metrics is not None: - await self._metrics.setup(self._task_manager, self.watchdog_timers_enabled) + await self._metrics.setup(self._task_manager, self._watchdog_timers_enabled) async def cleanup(self): await super().cleanup() @@ -286,7 +298,8 @@ class FrameProcessor(WatchdogReseter, BaseObject): async def resume_processing_frames(self): logger.trace(f"{self}: resuming frame processing") - self.__input_event.set() + if self.__input_event: + self.__input_event.set() async def process_frame(self, frame: Frame, direction: FrameDirection): if isinstance(frame, StartFrame): diff --git a/src/pipecat/services/tts_service.py b/src/pipecat/services/tts_service.py index 2c3bb7b61..4fe7f1905 100644 --- a/src/pipecat/services/tts_service.py +++ b/src/pipecat/services/tts_service.py @@ -524,7 +524,6 @@ class AudioContextWordTTSService(WebsocketWordTTSService): def __init__(self, **kwargs): super().__init__(**kwargs) - self._contexts_queue = asyncio.Queue() self._contexts: Dict[str, asyncio.Queue] = {} self._audio_context_task = None diff --git a/src/pipecat/utils/asyncio.py b/src/pipecat/utils/asyncio.py index 1d1ed36e6..eda5e9d6e 100644 --- a/src/pipecat/utils/asyncio.py +++ b/src/pipecat/utils/asyncio.py @@ -39,6 +39,7 @@ class BaseTaskManager(ABC): name: str, *, enable_watchdog_logging: Optional[bool] = None, + enable_watchdog_timers: Optional[bool] = None, watchdog_timeout: Optional[float] = None, ) -> asyncio.Task: """ @@ -51,6 +52,7 @@ class BaseTaskManager(ABC): coroutine (Coroutine): The coroutine to be executed within the task. name (str): The name to assign to the task for identification. enable_watchdog_logging(bool): whether this task should log watchdog processing times. + enable_watchdog_timers(bool): whether this task should have a watchdog timer. watchdog_timeout(float): watchdog timer timeout for this task. Returns: @@ -108,6 +110,7 @@ class TaskData: task: asyncio.Task watchdog_timer: asyncio.Event enable_watchdog_logging: bool + enable_watchdog_timers: bool watchdog_timeout: float watchdog_task: Optional[asyncio.Task] @@ -132,6 +135,7 @@ class TaskManager(BaseTaskManager): name: str, *, enable_watchdog_logging: Optional[bool] = None, + enable_watchdog_timers: Optional[bool] = None, watchdog_timeout: Optional[float] = None, ) -> asyncio.Task: """ @@ -144,6 +148,7 @@ class TaskManager(BaseTaskManager): coroutine (Coroutine): The coroutine to be executed within the task. name (str): The name to assign to the task for identification. enable_watchdog_logging(bool): whether this task should log watchdog processing time. + enable_watchdog_timers(bool): whether this task should have a watchdog timer. watchdog_timeout(float): watchdog timer timeout for this task. Returns: @@ -175,11 +180,16 @@ class TaskManager(BaseTaskManager): if enable_watchdog_logging else self._params.enable_watchdog_logging ), + enable_watchdog_timers=( + enable_watchdog_timers + if enable_watchdog_timers + else self._params.enable_watchdog_timers + ), watchdog_timeout=( watchdog_timeout if watchdog_timeout else self._params.watchdog_timeout ), watchdog_task=None, - ) + ), ) logger.trace(f"{name}: task created") return task @@ -256,19 +266,17 @@ class TaskManager(BaseTaskManager): will be logged indicating the task is stalling. """ - if self._params and not self._params.enable_watchdog_timers: - return - name = task.get_name() if name in self._tasks: - self._tasks[name].watchdog_timer.set() + if self._tasks[name].enable_watchdog_timers: + self._tasks[name].watchdog_timer.set() else: logger.warning(f"Unable to reset watchdog timer: task {name} does not exist") def _add_task(self, task_data: TaskData): name = task_data.task.get_name() self._tasks[name] = task_data - if self._params and self._params.enable_watchdog_timers: + if self._params and task_data.enable_watchdog_timers: watchdog_task = self.get_event_loop().create_task( self._watchdog_task_handler(task_data) ) From e81d387971b78242ad05a54156e433032b8f0d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Wed, 25 Jun 2025 16:44:20 -0700 Subject: [PATCH 09/54] TaskManager: rely on add_done_callback() --- src/pipecat/utils/asyncio.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/pipecat/utils/asyncio.py b/src/pipecat/utils/asyncio.py index eda5e9d6e..1b8a3221f 100644 --- a/src/pipecat/utils/asyncio.py +++ b/src/pipecat/utils/asyncio.py @@ -220,8 +220,6 @@ class TaskManager(BaseTaskManager): raise except Exception as e: logger.exception(f"{name}: unexpected exception while stopping task: {e}") - finally: - await self._remove_task(task) async def cancel_task(self, task: asyncio.Task, timeout: Optional[float] = None): """Cancels the given asyncio Task and awaits its completion with an @@ -254,8 +252,6 @@ class TaskManager(BaseTaskManager): except BaseException as e: logger.critical(f"{name}: fatal base exception while cancelling task: {e}") raise - finally: - await self._remove_task(task) def current_tasks(self) -> Sequence[asyncio.Task]: """Returns the list of currently created/registered tasks.""" @@ -282,13 +278,6 @@ class TaskManager(BaseTaskManager): ) task_data.watchdog_task = watchdog_task - async def _remove_task(self, task: asyncio.Task): - name = task.get_name() - try: - del self._tasks[name] - except KeyError as e: - logger.trace(f"{name}: unable to remove task (already removed?): {e}") - async def _watchdog_task_handler(self, task_data: TaskData): name = task_data.task.get_name() timer = task_data.watchdog_timer @@ -316,5 +305,6 @@ class TaskManager(BaseTaskManager): if task_data.watchdog_task: task_data.watchdog_task.cancel() task_data.watchdog_task = None + del self._tasks[name] except KeyError as e: - logger.trace(f"{name}: unable to find task (already removed?): {e}") + logger.trace(f"{name}: unable to remove task data (already removed?): {e}") From 03502bed522afbb4dfed6202d076d0d3362dd515 Mon Sep 17 00:00:00 2001 From: Filipi Fuchter Date: Wed, 25 Jun 2025 20:53:30 -0300 Subject: [PATCH 10/54] Enabling watchdog and sentry into the freeze-test --- examples/freeze-test/freeze_test_bot.py | 29 ++++++++++++++++++------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/examples/freeze-test/freeze_test_bot.py b/examples/freeze-test/freeze_test_bot.py index 5be79ad12..52ef5fc89 100644 --- a/examples/freeze-test/freeze_test_bot.py +++ b/examples/freeze-test/freeze_test_bot.py @@ -7,9 +7,11 @@ import argparse import asyncio import os +import random from contextlib import asynccontextmanager from typing import Any, Dict +import sentry_sdk import uvicorn from dotenv import load_dotenv from fastapi import FastAPI, Request, WebSocket @@ -44,6 +46,7 @@ from pipecat.processors.aggregators.openai_llm_context import ( ) from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frameworks.rtvi import RTVIConfig, RTVIProcessor +from pipecat.processors.metrics.sentry import SentryMetrics from pipecat.serializers.protobuf import ProtobufFrameSerializer from pipecat.services.cartesia.tts import CartesiaTTSService from pipecat.services.deepgram.stt import DeepgramSTTService @@ -125,6 +128,7 @@ class SimulateFreezeInput(FrameProcessor): self._send_frames_task = None async def _send_user_text(self, text: str): + self.reset_watchdog() # Emulation as if the user has spoken and the stt transcribed await self.push_frame(UserStartedSpeakingFrame()) await self.push_frame(StartInterruptionFrame()) @@ -149,14 +153,13 @@ class SimulateFreezeInput(FrameProcessor): logger.debug("SimulateFreezeInput _send_frames") await self._send_user_text("Tell me a brief history of Brazil!") await asyncio.sleep(3) - await self._send_user_text("") - break - # i += 1 - # if i >= 5: - # break + await self._send_user_text("and who has discovered it") + i += 1 + if i >= 20: + break # sleeping 1s before interrupting - # wait_time = random.uniform(1, 10) - # await asyncio.sleep(wait_time) + wait_time = random.uniform(1, 10) + await asyncio.sleep(wait_time) except Exception as e: logger.error(f"{self} exception receiving data: {e.__class__.__name__} ({e})") @@ -176,6 +179,11 @@ async def run_example(websocket_client): ), ) + sentry_sdk.init( + dsn=os.getenv("SENTRY_DSN"), + traces_sample_rate=1.0, + ) + freeze = SimulateFreezeInput() stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) @@ -183,9 +191,13 @@ async def run_example(websocket_client): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + metrics=SentryMetrics(), ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + metrics=SentryMetrics(), + ) rtvi = RTVIProcessor(config=RTVIConfig(config=[])) @@ -247,6 +259,7 @@ async def run_example(websocket_client): }, ), ], + enable_watchdog_timers=True, ) @transport.event_handler("on_client_connected") From 27af50087ec53c47ffa11705afe11cf8aaacf27d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Wed, 25 Jun 2025 17:29:45 -0700 Subject: [PATCH 11/54] TaskManager: don't warn on reset_watchdog() --- src/pipecat/utils/asyncio.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/pipecat/utils/asyncio.py b/src/pipecat/utils/asyncio.py index 1b8a3221f..a6f2f1a7f 100644 --- a/src/pipecat/utils/asyncio.py +++ b/src/pipecat/utils/asyncio.py @@ -263,11 +263,8 @@ class TaskManager(BaseTaskManager): """ name = task.get_name() - if name in self._tasks: - if self._tasks[name].enable_watchdog_timers: - self._tasks[name].watchdog_timer.set() - else: - logger.warning(f"Unable to reset watchdog timer: task {name} does not exist") + if name in self._tasks and self._tasks[name].enable_watchdog_timers: + self._tasks[name].watchdog_timer.set() def _add_task(self, task_data: TaskData): name = task_data.task.get_name() From fb12bf9b4c6ba80757a44bdff857add5d9538f98 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 25 Jun 2025 15:57:43 -0400 Subject: [PATCH 12/54] Update LLMService docstrings --- src/pipecat/services/llm_service.py | 174 ++++++++++++++++++++++------ 1 file changed, 141 insertions(+), 33 deletions(-) diff --git a/src/pipecat/services/llm_service.py b/src/pipecat/services/llm_service.py index 5619cd35e..11a8eded7 100644 --- a/src/pipecat/services/llm_service.py +++ b/src/pipecat/services/llm_service.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Base classes for Large Language Model services with function calling support.""" + import asyncio import inspect from dataclasses import dataclass @@ -41,9 +43,21 @@ FunctionCallHandler = Callable[["FunctionCallParams"], Awaitable[None]] # Type alias for a callback function that handles the result of an LLM function call. class FunctionCallResultCallback(Protocol): + """Protocol for function call result callbacks. + + Handles the result of an LLM function call execution. + """ + async def __call__( self, result: Any, *, properties: Optional[FunctionCallResultProperties] = None - ) -> None: ... + ) -> None: + """Call the result callback. + + Args: + result: The result of the function call. + properties: Optional properties for the result. + """ + ... @dataclass @@ -51,13 +65,12 @@ class FunctionCallParams: """Parameters for a function call. Attributes: - function_name (str): The name of the function being called. - arguments (Mapping[str, Any]): The arguments for the function. - tool_call_id (str): A unique identifier for the function call. - llm (LLMService): The LLMService instance being used. - context (OpenAILLMContext): The LLM context. - result_callback (FunctionCallResultCallback): Callback to handle the result of the function call. - + function_name: The name of the function being called. + tool_call_id: A unique identifier for the function call. + arguments: The arguments for the function. + llm: The LLMService instance being used. + context: The LLM context. + result_callback: Callback to handle the result of the function call. """ function_name: str @@ -70,14 +83,14 @@ class FunctionCallParams: @dataclass class FunctionCallRegistryItem: - """Represents an entry in our function call registry. This is what the user - registers. + """Represents an entry in the function call registry. + + This is what the user registers when calling register_function. Attributes: - function_name (Optional[str]): The name of the function. - handler (FunctionCallHandler): The handler for processing function call parameters. - cancel_on_interruption (bool): Flag indicating whether to cancel the call on interruption. - + function_name: The name of the function (None for catch-all handler). + handler: The handler for processing function call parameters. + cancel_on_interruption: Whether to cancel the call on interruption. """ function_name: Optional[str] @@ -87,16 +100,17 @@ class FunctionCallRegistryItem: @dataclass class FunctionCallRunnerItem: - """Represents an internal function call entry to our function call - runner. The runner executes function calls in order. + """Internal function call entry for the function call runner. + + The runner executes function calls in order. Attributes: - registry_name (Optional[str]): The function call name registration (could be None). - function_name (str): The name of the function. - tool_call_id (str): A unique identifier for the function call. - arguments (Mapping[str, Any]): The arguments for the function. - context (OpenAILLMContext): The LLM context. - + registry_item: The registry item containing handler information. + function_name: The name of the function. + tool_call_id: A unique identifier for the function call. + arguments: The arguments for the function. + context: The LLM context. + run_llm: Optional flag to control LLM execution after function call. """ registry_item: FunctionCallRegistryItem @@ -108,22 +122,32 @@ class FunctionCallRunnerItem: class LLMService(AIService): - """This is the base class for all LLM services. It handles function calling - registration and execution. The class also provides event handlers. + """Base class for all LLM services. - An event to know when an LLM service completion timeout occurs: + Handles function calling registration and execution with support for both + parallel and sequential execution modes. Provides event handlers for + completion timeouts and function call lifecycle events. - @task.event_handler("on_completion_timeout") - async def on_completion_timeout(service): - ... + Args: + run_in_parallel: Whether to run function calls in parallel or sequentially. + Defaults to True. + **kwargs: Additional arguments passed to the parent AIService. - And an event to know that function calls have been received from the LLM - service and that we are going to start executing them: + Event handlers: + on_completion_timeout: Called when an LLM completion timeout occurs. + on_function_calls_started: Called when function calls are received and + execution is about to start. - @task.event_handler("on_function_calls_started") - async def on_function_calls_started(service, function_calls: Sequence[FunctionCallFromLLM]): - ... + Example: + ```python + @task.event_handler("on_completion_timeout") + async def on_completion_timeout(service): + logger.warning("LLM completion timed out") + @task.event_handler("on_function_calls_started") + async def on_function_calls_started(service, function_calls): + logger.info(f"Starting {len(function_calls)} function calls") + ``` """ # OpenAILLMAdapter is used as the default adapter since it aligns with most LLM implementations. @@ -143,6 +167,11 @@ class LLMService(AIService): self._register_event_handler("on_completion_timeout") def get_llm_adapter(self) -> BaseLLMAdapter: + """Get the LLM adapter instance. + + Returns: + The adapter instance used for LLM communication. + """ return self._adapter def create_context_aggregator( @@ -152,24 +181,57 @@ class LLMService(AIService): user_params: LLMUserAggregatorParams = LLMUserAggregatorParams(), assistant_params: LLMAssistantAggregatorParams = LLMAssistantAggregatorParams(), ) -> Any: + """Create a context aggregator for managing LLM conversation context. + + Must be implemented by subclasses. + + Args: + context: The LLM context to create an aggregator for. + user_params: Parameters for user message aggregation. + assistant_params: Parameters for assistant message aggregation. + + Returns: + A context aggregator instance. + """ pass async def start(self, frame: StartFrame): + """Start the LLM service. + + Args: + frame: The start frame. + """ await super().start(frame) if not self._run_in_parallel: await self._create_sequential_runner_task() async def stop(self, frame: EndFrame): + """Stop the LLM service. + + Args: + frame: The end frame. + """ await super().stop(frame) if not self._run_in_parallel: await self._cancel_sequential_runner_task() async def cancel(self, frame: CancelFrame): + """Cancel the LLM service. + + Args: + frame: The cancel frame. + """ await super().cancel(frame) if not self._run_in_parallel: await self._cancel_sequential_runner_task() async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process a frame. + + Args: + frame: The frame to process. + direction: The direction of frame processing. + """ await super().process_frame(frame, direction) if isinstance(frame, StartInterruptionFrame): @@ -188,6 +250,18 @@ class LLMService(AIService): *, cancel_on_interruption: bool = True, ): + """Register a function handler for LLM function calls. + + Args: + function_name: The name of the function to handle. Use None to handle + all function calls with a catch-all handler. + handler: The function handler. Should accept a single FunctionCallParams + parameter. + start_callback: Legacy callback function (deprecated). Put initialization + code at the top of your handler instead. + cancel_on_interruption: Whether to cancel this function call when an + interruption occurs. Defaults to True. + """ # Registering a function with the function_name set to None will run # that handler for all functions self._functions[function_name] = FunctionCallRegistryItem( @@ -210,16 +284,38 @@ class LLMService(AIService): self._start_callbacks[function_name] = start_callback def unregister_function(self, function_name: Optional[str]): + """Remove a registered function handler. + + Args: + function_name: The name of the function handler to remove. + """ del self._functions[function_name] if self._start_callbacks[function_name]: del self._start_callbacks[function_name] def has_function(self, function_name: str): + """Check if a function handler is registered. + + Args: + function_name: The name of the function to check. + + Returns: + True if the function is registered or if a catch-all handler (None) + is registered. + """ if None in self._functions.keys(): return True return function_name in self._functions.keys() async def run_function_calls(self, function_calls: Sequence[FunctionCallFromLLM]): + """Execute a sequence of function calls from the LLM. + + Triggers the on_function_calls_started event and executes functions + either in parallel or sequentially based on the run_in_parallel setting. + + Args: + function_calls: The function calls to execute. + """ if len(function_calls) == 0: return @@ -272,6 +368,18 @@ class LLMService(AIService): text_content: Optional[str] = None, video_source: Optional[str] = None, ): + """Request an image from a user. + + Pushes a UserImageRequestFrame upstream to request an image from the + specified user. + + Args: + user_id: The ID of the user to request an image from. + function_name: Optional function name associated with the request. + tool_call_id: Optional tool call ID associated with the request. + text_content: Optional text content/context for the image request. + video_source: Optional video source identifier. + """ await self.push_frame( UserImageRequestFrame( user_id=user_id, From f622b281d0af13d3b0250aca8f546548ad1fa913 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 25 Jun 2025 15:58:19 -0400 Subject: [PATCH 13/54] Make call_start_function a private function in llm_service --- src/pipecat/services/llm_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pipecat/services/llm_service.py b/src/pipecat/services/llm_service.py index 11a8eded7..1a1ac3783 100644 --- a/src/pipecat/services/llm_service.py +++ b/src/pipecat/services/llm_service.py @@ -353,7 +353,7 @@ class LLMService(AIService): else: await self._sequential_runner_queue.put(runner_item) - async def call_start_function(self, context: OpenAILLMContext, function_name: str): + async def _call_start_function(self, context: OpenAILLMContext, function_name: str): if function_name in self._start_callbacks.keys(): await self._start_callbacks[function_name](function_name, self, context) elif None in self._start_callbacks.keys(): @@ -424,7 +424,7 @@ class LLMService(AIService): ) # NOTE(aleix): This needs to be removed after we remove the deprecation. - await self.call_start_function(runner_item.context, runner_item.function_name) + await self._call_start_function(runner_item.context, runner_item.function_name) # Push a function call in-progress downstream. This frame will let our # assistant context aggregator know that we are in the middle of a From ab1d2dbe6a2186f8dcd834512922e301bfafa805 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 25 Jun 2025 16:24:44 -0400 Subject: [PATCH 14/54] Add STTService docstrings --- src/pipecat/services/stt_service.py | 99 ++++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 10 deletions(-) diff --git a/src/pipecat/services/stt_service.py b/src/pipecat/services/stt_service.py index 5e57b3104..e659b403b 100644 --- a/src/pipecat/services/stt_service.py +++ b/src/pipecat/services/stt_service.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Base classes for Speech-to-Text services with continuous and segmented processing.""" + import io import wave from abc import abstractmethod @@ -26,7 +28,19 @@ from pipecat.transcriptions.language import Language class STTService(AIService): - """STTService is a base class for speech-to-text services.""" + """Base class for speech-to-text services. + + Provides common functionality for STT services including audio passthrough, + muting, settings management, and audio processing. Subclasses must implement + the run_stt method to provide actual speech recognition. + + Args: + audio_passthrough: Whether to pass audio frames downstream after processing. + Defaults to True. + sample_rate: The sample rate for audio input. If None, will be determined + from the start frame. + **kwargs: Additional arguments passed to the parent AIService. + """ def __init__( self, @@ -44,25 +58,59 @@ class STTService(AIService): @property def is_muted(self) -> bool: - """Returns whether the STT service is currently muted.""" + """Check if the STT service is currently muted. + + Returns: + True if the service is muted and will not process audio. + """ return self._muted @property def sample_rate(self) -> int: + """Get the current sample rate for audio processing. + + Returns: + The sample rate in Hz. + """ return self._sample_rate async def set_model(self, model: str): + """Set the speech recognition model. + + Args: + model: The name of the model to use for speech recognition. + """ self.set_model_name(model) async def set_language(self, language: Language): + """Set the language for speech recognition. + + Args: + language: The language to use for speech recognition. + """ pass @abstractmethod async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: - """Returns transcript as a string""" + """Run speech-to-text on the provided audio data. + + This method must be implemented by subclasses to provide actual speech + recognition functionality. + + Args: + audio: Raw audio bytes to transcribe. + + Yields: + Frame: Frames containing transcription results (typically TextFrame). + """ pass async def start(self, frame: StartFrame): + """Start the STT service. + + Args: + frame: The start frame containing initialization parameters. + """ await super().start(frame) self._sample_rate = self._init_sample_rate or frame.audio_in_sample_rate @@ -80,13 +128,24 @@ class STTService(AIService): logger.warning(f"Unknown setting for STT service: {key}") async def process_audio_frame(self, frame: AudioRawFrame, direction: FrameDirection): + """Process an audio frame for speech recognition. + + Args: + frame: The audio frame to process. + direction: The direction of frame processing. + """ if self._muted: return await self.process_generator(self.run_stt(frame.audio)) async def process_frame(self, frame: Frame, direction: FrameDirection): - """Processes a frame of audio data, either buffering or transcribing it.""" + """Process frames, handling VAD events and audio segmentation. + + Args: + frame: The frame to process. + direction: The direction of frame processing. + """ await super().process_frame(frame, direction) if isinstance(frame, AudioRawFrame): @@ -106,14 +165,19 @@ class STTService(AIService): class SegmentedSTTService(STTService): - """SegmentedSTTService is an STTService that uses VAD events to detect - speech and will run speech-to-text on speech segments only, instead of a - continous stream. Since it uses VAD it means that VAD needs to be enabled in - the pipeline. + """STT service that processes speech in segments using VAD events. - This service always keeps a small audio buffer to take into account that VAD - events are delayed from when the user speech really starts. + Uses Voice Activity Detection (VAD) events to detect speech segments and runs + speech-to-text only on those segments, rather than continuously. + Requires VAD to be enabled in the pipeline to function properly. Maintains a + small audio buffer to account for the delay between actual speech start and + VAD detection. + + Args: + sample_rate: The sample rate for audio input. If None, will be determined + from the start frame. + **kwargs: Additional arguments passed to the parent STTService. """ def __init__(self, *, sample_rate: Optional[int] = None, **kwargs): @@ -125,10 +189,16 @@ class SegmentedSTTService(STTService): self._user_speaking = False async def start(self, frame: StartFrame): + """Start the segmented STT service and initialize audio buffer. + + Args: + frame: The start frame containing initialization parameters. + """ await super().start(frame) self._audio_buffer_size_1s = self.sample_rate * 2 async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames, handling VAD events and audio segmentation.""" await super().process_frame(frame, direction) if isinstance(frame, UserStartedSpeakingFrame): @@ -162,6 +232,15 @@ class SegmentedSTTService(STTService): self._audio_buffer.clear() async def process_audio_frame(self, frame: AudioRawFrame, direction: FrameDirection): + """Process audio frames by buffering them for segmented transcription. + + Continuously buffers audio, growing the buffer while user is speaking and + maintaining a small buffer when not speaking to account for VAD delay. + + Args: + frame: The audio frame to process. + direction: The direction of frame processing. + """ # If the user is speaking the audio buffer will keep growing. self._audio_buffer += frame.audio From 33f3a4cea1c37bbd8e78305e180d65d76ddc30d4 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 25 Jun 2025 17:20:21 -0400 Subject: [PATCH 15/54] Add TTSService docstrings --- src/pipecat/services/tts_service.py | 261 +++++++++++++++++++++++++--- 1 file changed, 234 insertions(+), 27 deletions(-) diff --git a/src/pipecat/services/tts_service.py b/src/pipecat/services/tts_service.py index 904f603a9..68b480de2 100644 --- a/src/pipecat/services/tts_service.py +++ b/src/pipecat/services/tts_service.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Base classes for Text-to-speech services.""" + import asyncio from abc import abstractmethod from typing import Any, AsyncGenerator, Dict, List, Mapping, Optional, Sequence, Tuple @@ -42,6 +44,28 @@ from pipecat.utils.time import seconds_to_nanoseconds class TTSService(AIService): + """Base class for text-to-speech services. + + Provides common functionality for TTS services including text aggregation, + filtering, audio generation, and frame management. Supports configurable + sentence aggregation, silence insertion, and frame processing control. + + Args: + aggregate_sentences: Whether to aggregate text into sentences before synthesis. + push_text_frames: Whether to push TextFrames and LLMFullResponseEndFrames. + push_stop_frames: Whether to automatically push TTSStoppedFrames. + stop_frame_timeout_s: Idle time before pushing TTSStoppedFrame when push_stop_frames is True. + push_silence_after_stop: Whether to push silence audio after TTSStoppedFrame. + silence_time_s: Duration of silence to push when push_silence_after_stop is True. + pause_frame_processing: Whether to pause frame processing during audio generation. + sample_rate: Output sample rate for generated audio. + text_aggregator: Custom text aggregator for processing incoming text. + text_filters: Sequence of text filters to apply after aggregation. + text_filter: Single text filter (deprecated, use text_filters). + transport_destination: Destination for generated audio frames. + **kwargs: Additional arguments passed to the parent AIService. + """ + def __init__( self, *, @@ -104,54 +128,113 @@ class TTSService(AIService): @property def sample_rate(self) -> int: + """Get the current sample rate for audio output. + + Returns: + The sample rate in Hz. + """ return self._sample_rate @property def chunk_size(self) -> int: - """This property indicates how much audio we download (from TTS services + """Get the recommended chunk size for audio streaming. + + This property indicates how much audio we download (from TTS services that require chunking) before we start pushing the first audio frame. This will make sure we download the rest of the audio while audio is being played without causing audio glitches (specially at the beginning). Of course, this will also depend on how fast the TTS service generates bytes. + Returns: + The recommended chunk size in bytes. """ CHUNK_SECONDS = 0.5 return int(self.sample_rate * CHUNK_SECONDS * 2) # 2 bytes/sample async def set_model(self, model: str): + """Set the TTS model to use. + + Args: + model: The name of the TTS model. + """ self.set_model_name(model) def set_voice(self, voice: str): + """Set the voice for speech synthesis. + + Args: + voice: The voice identifier or name. + """ self._voice_id = voice # Converts the text to audio. @abstractmethod async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + """Run text-to-speech synthesis on the provided text. + + This method must be implemented by subclasses to provide actual TTS functionality. + + Args: + text: The text to synthesize into speech. + + Yields: + Frame: Audio frames containing the synthesized speech. + """ pass def language_to_service_language(self, language: Language) -> Optional[str]: + """Convert a language to the service-specific language format. + + Args: + language: The language to convert. + + Returns: + The service-specific language identifier, or None if not supported. + """ return Language(language) async def update_setting(self, key: str, value: Any): + """Update a service-specific setting. + + Args: + key: The setting key to update. + value: The new value for the setting. + """ pass async def flush_audio(self): + """Flush any buffered audio data.""" pass async def start(self, frame: StartFrame): + """Start the TTS service. + + Args: + frame: The start frame containing initialization parameters. + """ await super().start(frame) self._sample_rate = self._init_sample_rate or frame.audio_out_sample_rate if self._push_stop_frames and not self._stop_frame_task: self._stop_frame_task = self.create_task(self._stop_frame_handler()) async def stop(self, frame: EndFrame): + """Stop the TTS service. + + Args: + frame: The end frame. + """ await super().stop(frame) if self._stop_frame_task: await self.cancel_task(self._stop_frame_task) self._stop_frame_task = None async def cancel(self, frame: CancelFrame): + """Cancel the TTS service. + + Args: + frame: The cancel frame. + """ await super().cancel(frame) if self._stop_frame_task: await self.cancel_task(self._stop_frame_task) @@ -175,9 +258,23 @@ class TTSService(AIService): logger.warning(f"Unknown setting for TTS service: {key}") async def say(self, text: str): + """Immediately speak the provided text. + + Args: + text: The text to speak. + """ await self.queue_frame(TTSSpeakFrame(text)) async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames for text-to-speech conversion. + + Handles TextFrames for synthesis, interruption frames, settings updates, + and various control frames. + + Args: + frame: The frame to process. + direction: The direction of frame processing. + """ await super().process_frame(frame, direction) if ( @@ -222,6 +319,12 @@ class TTSService(AIService): await self.push_frame(frame, direction) async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): + """Push a frame downstream with TTS-specific handling. + + Args: + frame: The frame to push. + direction: The direction to push the frame. + """ if self._push_silence_after_stop and isinstance(frame, TTSStoppedFrame): silence_num_bytes = int(self._silence_time_s * self.sample_rate * 2) # 16-bit silence_frame = TTSAudioRawFrame( @@ -318,10 +421,13 @@ class TTSService(AIService): class WordTTSService(TTSService): - """This is a base class for TTS services that support word timestamps. Word - timestamps are useful to synchronize audio with text of the spoken + """Base class for TTS services that support word timestamps. + + Word timestamps are useful to synchronize audio with text of the spoken words. This way only the spoken words are added to the conversation context. + Args: + **kwargs: Additional arguments passed to the parent TTSService. """ def __init__(self, **kwargs): @@ -332,29 +438,57 @@ class WordTTSService(TTSService): self._llm_response_started: bool = False def start_word_timestamps(self): + """Start tracking word timestamps from the current time.""" if self._initial_word_timestamp == -1: self._initial_word_timestamp = self.get_clock().get_time() def reset_word_timestamps(self): + """Reset word timestamp tracking.""" self._initial_word_timestamp = -1 async def add_word_timestamps(self, word_times: List[Tuple[str, float]]): + """Add word timestamps to the processing queue. + + Args: + word_times: List of (word, timestamp) tuples where timestamp is in seconds. + """ for word, timestamp in word_times: await self._words_queue.put((word, seconds_to_nanoseconds(timestamp))) async def start(self, frame: StartFrame): + """Start the word TTS service. + + Args: + frame: The start frame containing initialization parameters. + """ await super().start(frame) self._create_words_task() async def stop(self, frame: EndFrame): + """Stop the word TTS service. + + Args: + frame: The end frame. + """ await super().stop(frame) await self._stop_words_task() async def cancel(self, frame: CancelFrame): + """Cancel the word TTS service. + + Args: + frame: The cancel frame. + """ await super().cancel(frame) await self._stop_words_task() async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames with word timestamp awareness. + + Args: + frame: The frame to process. + direction: The direction of frame processing. + """ await super().process_frame(frame, direction) if isinstance(frame, LLMFullResponseStartFrame): @@ -400,15 +534,24 @@ class WordTTSService(TTSService): class WebsocketTTSService(TTSService, WebsocketService): - """This is a base class for websocket-based TTS services. + """Base class for websocket-based TTS services. - If an error occurs with the websocket, an "on_connection_error" event will - be triggered: + Combines TTS functionality with websocket connectivity, providing automatic + error handling and reconnection capabilities. - @tts.event_handler("on_connection_error") - async def on_connection_error(tts: TTSService, error: str): - ... + Args: + reconnect_on_error: Whether to automatically reconnect on websocket errors. + **kwargs: Additional arguments passed to parent classes. + Event handlers: + on_connection_error: Called when a websocket connection error occurs. + + Example: + ```python + @tts.event_handler("on_connection_error") + async def on_connection_error(tts: TTSService, error: str): + logger.error(f"TTS connection error: {error}") + ``` """ def __init__(self, *, reconnect_on_error: bool = True, **kwargs): @@ -422,10 +565,13 @@ class WebsocketTTSService(TTSService, WebsocketService): class InterruptibleTTSService(WebsocketTTSService): - """This is a base class for websocket-based TTS services that don't support - word timestamps and that don't offer a way to correlate the generated audio - to the requested text. + """Websocket-based TTS service that handles interruptions without word timestamps. + Designed for TTS services that don't support word timestamps. Handles interruptions + by reconnecting the websocket when the bot is speaking and gets interrupted. + + Args: + **kwargs: Additional arguments passed to the parent WebsocketTTSService. """ def __init__(self, **kwargs): @@ -443,6 +589,12 @@ class InterruptibleTTSService(WebsocketTTSService): await self._connect() async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames with bot speaking state tracking. + + Args: + frame: The frame to process. + direction: The direction of frame processing. + """ await super().process_frame(frame, direction) if isinstance(frame, BotStartedSpeakingFrame): @@ -452,16 +604,23 @@ class InterruptibleTTSService(WebsocketTTSService): class WebsocketWordTTSService(WordTTSService, WebsocketService): - """This is a base class for websocket-based TTS services that support word - timestamps. + """Base class for websocket-based TTS services that support word timestamps. - If an error occurs with the websocket a "on_connection_error" event will be - triggered: + Combines word timestamp functionality with websocket connectivity. - @tts.event_handler("on_connection_error") - async def on_connection_error(tts: TTSService, error: str): - ... + Args: + reconnect_on_error: Whether to automatically reconnect on websocket errors. + **kwargs: Additional arguments passed to parent classes. + Event handlers: + on_connection_error: Called when a websocket connection error occurs. + + Example: + ```python + @tts.event_handler("on_connection_error") + async def on_connection_error(tts: TTSService, error: str): + logger.error(f"TTS connection error: {error}") + ``` """ def __init__(self, *, reconnect_on_error: bool = True, **kwargs): @@ -475,10 +634,13 @@ class WebsocketWordTTSService(WordTTSService, WebsocketService): class InterruptibleWordTTSService(WebsocketWordTTSService): - """This is a base class for websocket-based TTS services that support word - timestamps but don't offer a way to correlate the generated audio to the - requested text. + """Websocket-based TTS service with word timestamps that handles interruptions. + For TTS services that support word timestamps but can't correlate generated + audio with requested text. Handles interruptions by reconnecting when needed. + + Args: + **kwargs: Additional arguments passed to the parent WebsocketWordTTSService. """ def __init__(self, **kwargs): @@ -496,6 +658,12 @@ class InterruptibleWordTTSService(WebsocketWordTTSService): await self._connect() async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames with bot speaking state tracking. + + Args: + frame: The frame to process. + direction: The direction of frame processing. + """ await super().process_frame(frame, direction) if isinstance(frame, BotStartedSpeakingFrame): @@ -505,7 +673,9 @@ class InterruptibleWordTTSService(WebsocketWordTTSService): class AudioContextWordTTSService(WebsocketWordTTSService): - """This is a base class for websocket-based TTS services that support word + """Websocket-based TTS service with word timestamps and audio context management. + + This is a base class for websocket-based TTS services that support word timestamps and also allow correlating the generated audio with the requested text. @@ -517,6 +687,8 @@ class AudioContextWordTTSService(WebsocketWordTTSService): we requested audio for a context "A" and then audio for context "B", the audio from context ID "A" will be played first. + Args: + **kwargs: Additional arguments passed to the parent WebsocketWordTTSService. """ def __init__(self, **kwargs): @@ -526,13 +698,22 @@ class AudioContextWordTTSService(WebsocketWordTTSService): self._audio_context_task = None async def create_audio_context(self, context_id: str): - """Create a new audio context.""" + """Create a new audio context for grouping related audio. + + Args: + context_id: Unique identifier for the audio context. + """ await self._contexts_queue.put(context_id) self._contexts[context_id] = asyncio.Queue() logger.trace(f"{self} created audio context {context_id}") async def append_to_audio_context(self, context_id: str, frame: TTSAudioRawFrame): - """Append audio to an existing context.""" + """Append audio to an existing context. + + Args: + context_id: The context to append audio to. + frame: The audio frame to append. + """ if self.audio_context_available(context_id): logger.trace(f"{self} appending audio {frame} to audio context {context_id}") await self._contexts[context_id].put(frame) @@ -540,7 +721,11 @@ class AudioContextWordTTSService(WebsocketWordTTSService): logger.warning(f"{self} unable to append audio to context {context_id}") async def remove_audio_context(self, context_id: str): - """Remove an existing audio context.""" + """Remove an existing audio context. + + Args: + context_id: The context to remove. + """ if self.audio_context_available(context_id): # We just mark the audio context for deletion by appending # None. Once we reach None while handling audio we know we can @@ -551,14 +736,31 @@ class AudioContextWordTTSService(WebsocketWordTTSService): logger.warning(f"{self} unable to remove context {context_id}") def audio_context_available(self, context_id: str) -> bool: - """Checks whether the given audio context is registered.""" + """Check whether the given audio context is registered. + + Args: + context_id: The context ID to check. + + Returns: + True if the context exists and is available. + """ return context_id in self._contexts async def start(self, frame: StartFrame): + """Start the audio context TTS service. + + Args: + frame: The start frame containing initialization parameters. + """ await super().start(frame) self._create_audio_context_task() async def stop(self, frame: EndFrame): + """Stop the audio context TTS service. + + Args: + frame: The end frame. + """ await super().stop(frame) if self._audio_context_task: # Indicate no more audio contexts are available. this will end the @@ -568,6 +770,11 @@ class AudioContextWordTTSService(WebsocketWordTTSService): self._audio_context_task = None async def cancel(self, frame: CancelFrame): + """Cancel the audio context TTS service. + + Args: + frame: The cancel frame. + """ await super().cancel(frame) await self._stop_audio_context_task() From 691999b402921c430b67812da9d148b616ba975a Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 25 Jun 2025 17:22:09 -0400 Subject: [PATCH 16/54] Add AIServices docstring --- src/pipecat/services/ai_services.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/pipecat/services/ai_services.py b/src/pipecat/services/ai_services.py index 1a1e1ab56..833a20eae 100644 --- a/src/pipecat/services/ai_services.py +++ b/src/pipecat/services/ai_services.py @@ -4,6 +4,17 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Deprecated AI services module. + +This module is deprecated. Import services directly from their respective modules: +- pipecat.services.ai_service +- pipecat.services.image_service +- pipecat.services.llm_service +- pipecat.services.stt_service +- pipecat.services.tts_service +- pipecat.services.vision_service +""" + import sys from pipecat.services import DeprecatedModuleProxy From a1e5a1eff40406d758efb3ab1c3022ecd8ea97bc Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 25 Jun 2025 17:24:37 -0400 Subject: [PATCH 17/54] Add AIService docstrings --- src/pipecat/services/ai_service.py | 68 ++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/pipecat/services/ai_service.py b/src/pipecat/services/ai_service.py index 985b61c8e..175673e43 100644 --- a/src/pipecat/services/ai_service.py +++ b/src/pipecat/services/ai_service.py @@ -4,6 +4,12 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Base AI service implementation. + +Provides the foundation for all AI services in the Pipecat framework, including +model management, settings handling, and frame processing lifecycle methods. +""" + from typing import Any, AsyncGenerator, Dict, Mapping from loguru import logger @@ -20,6 +26,17 @@ from pipecat.processors.frame_processor import FrameDirection, FrameProcessor class AIService(FrameProcessor): + """Base class for all AI services. + + Provides common functionality for AI services including model management, + settings handling, session properties, and frame processing lifecycle. + Subclasses should implement specific AI functionality while leveraging + this base infrastructure. + + Args: + **kwargs: Additional arguments passed to the parent FrameProcessor. + """ + def __init__(self, **kwargs): super().__init__(**kwargs) self._model_name: str = "" @@ -28,19 +45,53 @@ class AIService(FrameProcessor): @property def model_name(self) -> str: + """Get the current model name. + + Returns: + The name of the AI model being used. + """ return self._model_name def set_model_name(self, model: str): + """Set the AI model name and update metrics. + + Args: + model: The name of the AI model to use. + """ self._model_name = model self.set_core_metrics_data(MetricsData(processor=self.name, model=self._model_name)) async def start(self, frame: StartFrame): + """Start the AI service. + + Called when the service should begin processing. Subclasses should + override this method to perform service-specific initialization. + + Args: + frame: The start frame containing initialization parameters. + """ pass async def stop(self, frame: EndFrame): + """Stop the AI service. + + Called when the service should stop processing. Subclasses should + override this method to perform cleanup operations. + + Args: + frame: The end frame. + """ pass async def cancel(self, frame: CancelFrame): + """Cancel the AI service. + + Called when the service should cancel all operations. Subclasses should + override this method to handle cancellation logic. + + Args: + frame: The cancel frame. + """ pass async def _update_settings(self, settings: Mapping[str, Any]): @@ -87,6 +138,15 @@ class AIService(FrameProcessor): logger.warning(f"Unknown setting for {self.name} service: {key}") async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames and handle service lifecycle. + + Automatically handles StartFrame, EndFrame, and CancelFrame by calling + the appropriate lifecycle methods. + + Args: + frame: The frame to process. + direction: The direction of frame processing. + """ await super().process_frame(frame, direction) if isinstance(frame, StartFrame): @@ -97,6 +157,14 @@ class AIService(FrameProcessor): await self.stop(frame) async def process_generator(self, generator: AsyncGenerator[Frame | None, None]): + """Process frames from an async generator. + + Takes an async generator that yields frames and processes each one, + handling error frames specially by pushing them as errors. + + Args: + generator: An async generator that yields Frame objects or None. + """ async for f in generator: if f: if isinstance(f, ErrorFrame): From 2007ae43170a9c1aba1bee459921b24de6b16e51 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 25 Jun 2025 17:40:04 -0400 Subject: [PATCH 18/54] Add ImageGenService docstrings --- src/pipecat/services/image_service.py | 37 +++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/pipecat/services/image_service.py b/src/pipecat/services/image_service.py index 43dbd0bb5..27084f1d1 100644 --- a/src/pipecat/services/image_service.py +++ b/src/pipecat/services/image_service.py @@ -4,6 +4,12 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Image generation service implementation. + +Provides base functionality for AI-powered image generation services that convert +text prompts into images. +""" + from abc import abstractmethod from typing import AsyncGenerator @@ -13,15 +19,46 @@ from pipecat.services.ai_service import AIService class ImageGenService(AIService): + """Base class for image generation services. + + Processes TextFrames by using their content as prompts for image generation. + Subclasses must implement the run_image_gen method to provide actual image + generation functionality using their specific AI service. + + Args: + **kwargs: Additional arguments passed to the parent AIService. + """ + def __init__(self, **kwargs): super().__init__(**kwargs) # Renders the image. Returns an Image object. @abstractmethod async def run_image_gen(self, prompt: str) -> AsyncGenerator[Frame, None]: + """Generate an image from a text prompt. + + This method must be implemented by subclasses to provide actual image + generation functionality using their specific AI service. + + Args: + prompt: The text prompt to generate an image from. + + Yields: + Frame: Frames containing the generated image (typically ImageRawFrame + or URLImageRawFrame). + """ pass async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames for image generation. + + TextFrames are used as prompts for image generation, while other frames + are passed through unchanged. + + Args: + frame: The frame to process. + direction: The direction of frame processing. + """ await super().process_frame(frame, direction) if isinstance(frame, TextFrame): From f80f62c7d12b875918a3a36f5164eb6af58f1358 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 25 Jun 2025 17:43:31 -0400 Subject: [PATCH 19/54] Add VisionService docstrings --- src/pipecat/services/vision_service.py | 39 +++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/pipecat/services/vision_service.py b/src/pipecat/services/vision_service.py index 23eb79c4e..d4314f874 100644 --- a/src/pipecat/services/vision_service.py +++ b/src/pipecat/services/vision_service.py @@ -4,6 +4,13 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Vision service implementation. + +Provides base classes and implementations for computer vision services that can +analyze images and generate textual descriptions or answers to questions about +visual content. +""" + from abc import abstractmethod from typing import AsyncGenerator @@ -13,7 +20,15 @@ from pipecat.services.ai_service import AIService class VisionService(AIService): - """VisionService is a base class for vision services.""" + """Base class for vision services. + + Provides common functionality for vision services that process images and + generate textual responses. Handles image frame processing and integrates + with the AI service infrastructure for metrics and lifecycle management. + + Args: + **kwargs: Additional arguments passed to the parent AIService. + """ def __init__(self, **kwargs): super().__init__(**kwargs) @@ -21,9 +36,31 @@ class VisionService(AIService): @abstractmethod async def run_vision(self, frame: VisionImageRawFrame) -> AsyncGenerator[Frame, None]: + """Process a vision image frame and generate results. + + This method must be implemented by subclasses to provide actual computer + vision functionality such as image description, object detection, or + visual question answering. + + Args: + frame: The vision image frame to process, containing image data. + + Yields: + Frame: Frames containing the vision analysis results, typically TextFrame + objects with descriptions or answers. + """ pass async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames, handling vision image frames for analysis. + + Automatically processes VisionImageRawFrame objects by calling run_vision + and handles metrics tracking. Other frames are passed through unchanged. + + Args: + frame: The frame to process. + direction: The direction of frame processing. + """ await super().process_frame(frame, direction) if isinstance(frame, VisionImageRawFrame): From bb3bb8d9c6e2e5668da8e3ba238898acd4bb1eca Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 25 Jun 2025 17:48:43 -0400 Subject: [PATCH 20/54] Improve WebsocketService docstrings --- src/pipecat/services/websocket_service.py | 66 ++++++++++++++++------- 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/src/pipecat/services/websocket_service.py b/src/pipecat/services/websocket_service.py index d757946f9..6fc8efb2e 100644 --- a/src/pipecat/services/websocket_service.py +++ b/src/pipecat/services/websocket_service.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Base websocket service with automatic reconnection and error handling.""" + import asyncio from abc import ABC, abstractmethod from typing import Awaitable, Callable, Optional @@ -17,18 +19,26 @@ from pipecat.utils.network import exponential_backoff_time class WebsocketService(ABC): - """Base class for websocket-based services with reconnection logic.""" + """Base class for websocket-based services with automatic reconnection. + + Provides websocket connection management, automatic reconnection with + exponential backoff, connection verification, and error handling. + Subclasses implement service-specific connection and message handling logic. + + Args: + reconnect_on_error: Whether to automatically reconnect on connection errors. + **kwargs: Additional arguments (unused, for compatibility). + """ def __init__(self, *, reconnect_on_error: bool = True, **kwargs): - """Initialize websocket attributes.""" self._websocket: Optional[websockets.WebSocketClientProtocol] = None self._reconnect_on_error = reconnect_on_error async def _verify_connection(self) -> bool: - """Verify websocket connection is working. + """Verify the websocket connection is active and responsive. Returns: - bool: True if connection is verified working, False otherwise + True if connection is verified working, False otherwise. """ try: if not self._websocket or self._websocket.closed: @@ -40,13 +50,13 @@ class WebsocketService(ABC): return False async def _reconnect_websocket(self, attempt_number: int) -> bool: - """Reconnect the websocket. + """Reconnect the websocket with the current attempt number. Args: - attempt_number: Current retry attempt number + attempt_number: Current retry attempt number for logging. Returns: - bool: True if reconnection and verification successful, False otherwise + True if reconnection and verification successful, False otherwise. """ logger.warning(f"{self} reconnecting (attempt: {attempt_number})") await self._disconnect_websocket() @@ -54,10 +64,14 @@ class WebsocketService(ABC): return await self._verify_connection() async def _receive_task_handler(self, report_error: Callable[[ErrorFrame], Awaitable[None]]): - """Handles WebSocket message receiving with automatic retry logic. + """Handle websocket message receiving with automatic retry logic. + + Continuously receives messages with automatic reconnection on errors. + Uses exponential backoff between retry attempts and reports fatal errors + after maximum retries are exhausted. Args: - report_error: Callback to report errors + report_error: Callback function to report connection errors. """ retry_count = 0 MAX_RETRIES = 3 @@ -98,33 +112,45 @@ class WebsocketService(ABC): @abstractmethod async def _connect(self): - """Implement service-specific connection logic. This function will - connect to the websocket via _connect_websocket() among other connection - logic.""" + """Connect to the service. + + Implement service-specific connection logic including websocket connection + via _connect_websocket() and any additional setup required. + """ pass @abstractmethod async def _disconnect(self): - """Implement service-specific disconnection logic. This function will - disconnect to the websocket via _connect_websocket() among other - connection logic. + """Disconnect from the service. + Implement service-specific disconnection logic including websocket + disconnection via _disconnect_websocket() and any cleanup required. """ pass @abstractmethod async def _connect_websocket(self): - """Implement service-specific websocket connection logic. This function - should only connect to the websocket.""" + """Establish the websocket connection. + + Implement the low-level websocket connection logic specific to the service. + Should only handle websocket connection, not additional service setup. + """ pass @abstractmethod async def _disconnect_websocket(self): - """Implement service-specific websocket disconnection logic. This - function should only disconnect from the websocket.""" + """Close the websocket connection. + + Implement the low-level websocket disconnection logic specific to the service. + Should only handle websocket disconnection, not additional service cleanup. + """ pass @abstractmethod async def _receive_messages(self): - """Implement service-specific message receiving logic.""" + """Receive and process websocket messages. + + Implement service-specific logic for receiving and handling messages + from the websocket connection. Called continuously by the receive task handler. + """ pass From 04b70ddf131a400ac8ac45b0c584385d2d250f6b Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 25 Jun 2025 17:51:42 -0400 Subject: [PATCH 21/54] Add MCPClient docstrings --- src/pipecat/services/mcp_service.py | 37 ++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/pipecat/services/mcp_service.py b/src/pipecat/services/mcp_service.py index a644d8f1b..48b0f9f1d 100644 --- a/src/pipecat/services/mcp_service.py +++ b/src/pipecat/services/mcp_service.py @@ -1,3 +1,11 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""MCP (Model Context Protocol) client for integrating external tools with LLMs.""" + import json from typing import Any, Dict, List, Optional, Union @@ -19,6 +27,20 @@ except ModuleNotFoundError as e: class MCPClient(BaseObject): + """Client for Model Context Protocol (MCP) servers. + + Enables integration with MCP servers to provide external tools and resources + to LLMs. Supports both stdio and SSE server connections with automatic tool + registration and schema conversion. + + Args: + server_params: Server connection parameters (stdio or SSE). + **kwargs: Additional arguments passed to the parent BaseObject. + + Raises: + TypeError: If server_params is not a supported parameter type. + """ + def __init__( self, server_params: Union[StdioServerParameters, SseServerParameters], @@ -39,6 +61,17 @@ class MCPClient(BaseObject): ) async def register_tools(self, llm) -> ToolsSchema: + """Register all available MCP tools with an LLM service. + + Connects to the MCP server, discovers available tools, converts their + schemas to Pipecat format, and registers them with the LLM service. + + Args: + llm: The Pipecat LLM service to register tools with. + + Returns: + A ToolsSchema containing all successfully registered tools. + """ tools_schema = await self._register_tools(llm) return tools_schema @@ -46,13 +79,13 @@ class MCPClient(BaseObject): self, tool_name: str, tool_schema: Dict[str, Any] ) -> FunctionSchema: """Convert an mcp tool schema to Pipecat's FunctionSchema format. + Args: tool_name: The name of the tool tool_schema: The mcp tool schema Returns: A FunctionSchema instance """ - logger.debug(f"Converting schema for tool '{tool_name}'") logger.trace(f"Original schema: {json.dumps(tool_schema, indent=2)}") @@ -72,6 +105,7 @@ class MCPClient(BaseObject): async def _sse_register_tools(self, llm) -> ToolsSchema: """Register all available mcp.run tools with the LLM service. + Args: llm: The Pipecat LLM service to register tools with Returns: @@ -120,6 +154,7 @@ class MCPClient(BaseObject): async def _stdio_register_tools(self, llm) -> ToolsSchema: """Register all available mcp.run tools with the LLM service. + Args: llm: The Pipecat LLM service to register tools with Returns: From cc66fddca97c7f3e134a0b13f5327a3dc7323d5d Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 25 Jun 2025 19:55:51 -0400 Subject: [PATCH 22/54] Modify docs auto-gen rules to remove duplicate parameters listing --- CONTRIBUTING.md | 76 ++++++++++++++++++++++++++++++++++++------------ docs/api/conf.py | 3 +- pyproject.toml | 3 +- 3 files changed, 60 insertions(+), 22 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8fee75bd0..677dc6b7f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,36 +41,76 @@ We use Ruff for code linting and formatting. Please ensure your code passes all We follow Google-style docstrings with these specific conventions: -- Class docstrings should fully document all parameters used in `__init__` -- We don't require separate docstrings for `__init__` methods when parameters are documented in the class docstring -- Property methods should have docstrings explaining their purpose and return value +**Regular Classes:** -Example of correctly documented class: +- Class docstring describes the class purpose and documents all `__init__` parameters in an `Args:` section +- No separate `__init__` docstring needed +- All public methods must have docstrings with `Args:` and `Returns:` sections as appropriate + +**Dataclasses:** + +- Class docstring describes the purpose and documents all fields in a `Parameters:` section +- No `__init__` docstring (auto-generated) + +**Properties:** + +- Must have docstrings with `Returns:` section + +**Abstract Methods:** + +- Must have docstrings explaining what subclasses should implement + +#### Examples: ```python -class MyClass: - """Class description. - - Additional details about the class. +# Regular class +class MyService(BaseService): + """Description of what the service does. Args: - param1: Description of first parameter. - param2: Description of second parameter. + param1: Description of param1. + param2: Description of param2. Defaults to True. + **kwargs: Additional arguments passed to parent. """ - def __init__(self, param1, param2): - # No docstring required here as parameters are documented above - self.param1 = param1 - self.param2 = param2 + def __init__(self, param1: str, param2: bool = True, **kwargs): + # No docstring - parameters documented above + super().__init__(**kwargs) @property - def some_property(self) -> str: - """Get the formatted property value. + def sample_rate(self) -> int: + """Get the current sample rate. Returns: - A string representation of the property. + The sample rate in Hz. """ - return f"Property: {self.param1}" + return self._sample_rate + + async def process_data(self, data: str) -> bool: + """Process the provided data. + + Args: + data: The data to process. + + Returns: + True if processing succeeded. + """ + pass + +# Dataclass +@dataclass +class ConfigParams: + """Configuration parameters for the service. + + Parameters: + host: The host address. + port: The port number. Defaults to 8080. + timeout: Connection timeout in seconds. + """ + + host: str + port: int = 8080 + timeout: float = 30.0 ``` # Contributor Covenant Code of Conduct diff --git a/docs/api/conf.py b/docs/api/conf.py index a33caa10c..fee337eff 100644 --- a/docs/api/conf.py +++ b/docs/api/conf.py @@ -27,13 +27,12 @@ extensions = [ # Napoleon settings napoleon_google_docstring = True napoleon_numpy_docstring = False -napoleon_include_init_with_doc = True +napoleon_include_init_with_doc = False # AutoDoc settings autodoc_default_options = { "members": True, "member-order": "bysource", - "special-members": "__init__", "undoc-members": True, "exclude-members": "__weakref__", "no-index": True, diff --git a/pyproject.toml b/pyproject.toml index f7c73d49a..8fdab4742 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,8 +123,7 @@ select = [ "D", # Docstring rules "I", # Import rules ] -# We ignore D107 because class docstrings already document __init__ parameters -# and our Sphinx configuration uses napoleon_include_init_with_doc=True +# Ignore requirement for __init__ docstrings ignore = ["D107"] [tool.ruff.lint.pydocstyle] From fe6bbdaefe24b6c8a1269f5502dccdf13f37528d Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 25 Jun 2025 22:23:51 -0400 Subject: [PATCH 23/54] Skip dataclass attributes to remove duplicate entries --- docs/api/conf.py | 2 +- src/pipecat/services/llm_service.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/api/conf.py b/docs/api/conf.py index fee337eff..97ab73bc7 100644 --- a/docs/api/conf.py +++ b/docs/api/conf.py @@ -34,7 +34,7 @@ autodoc_default_options = { "members": True, "member-order": "bysource", "undoc-members": True, - "exclude-members": "__weakref__", + "exclude-members": "__weakref__,__init__", "no-index": True, "show-inheritance": True, } diff --git a/src/pipecat/services/llm_service.py b/src/pipecat/services/llm_service.py index 1a1ac3783..f7779df98 100644 --- a/src/pipecat/services/llm_service.py +++ b/src/pipecat/services/llm_service.py @@ -64,7 +64,7 @@ class FunctionCallResultCallback(Protocol): class FunctionCallParams: """Parameters for a function call. - Attributes: + Parameters: function_name: The name of the function being called. tool_call_id: A unique identifier for the function call. arguments: The arguments for the function. @@ -87,7 +87,7 @@ class FunctionCallRegistryItem: This is what the user registers when calling register_function. - Attributes: + Parameters: function_name: The name of the function (None for catch-all handler). handler: The handler for processing function call parameters. cancel_on_interruption: Whether to cancel the call on interruption. @@ -104,7 +104,7 @@ class FunctionCallRunnerItem: The runner executes function calls in order. - Attributes: + Parameters: registry_item: The registry item containing handler information. function_name: The name of the function. tool_call_id: A unique identifier for the function call. From 6ef2ae12b7f1f1c7c3edb421dd655ff6bbdd0fd9 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 25 Jun 2025 23:15:45 -0400 Subject: [PATCH 24/54] Mock mcp imports --- docs/api/conf.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/api/conf.py b/docs/api/conf.py index 97ab73bc7..bc518e3ad 100644 --- a/docs/api/conf.py +++ b/docs/api/conf.py @@ -144,6 +144,28 @@ autodoc_mock_imports = [ "transformers.AutoFeatureExtractor", # Also add specific classes that are imported "AutoFeatureExtractor", + # Sentry dependencies + "sentry_sdk", + # AWS Nova Sonic dependencies + "aws_sdk_bedrock_runtime", + "aws_sdk_bedrock_runtime.client", + "aws_sdk_bedrock_runtime.config", + "aws_sdk_bedrock_runtime.models", + "smithy_aws_core", + "smithy_aws_core.credentials_resolvers", + "smithy_aws_core.credentials_resolvers.static", + "smithy_aws_core.identity", + "smithy_core", + "smithy_core.aio", + "smithy_core.aio.eventstream", + # MCP dependencies (you may already have these) + "mcp", + "mcp.client", + "mcp.client.session_group", + "mcp.client.sse", + "mcp.client.stdio", + "mcp.ClientSession", + "mcp.StdioServerParameters", ] # HTML output settings From f04e058c96095834914796a820da96dae1f41f8a Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 25 Jun 2025 23:18:33 -0400 Subject: [PATCH 25/54] Programmatically set the copyright date in docs --- docs/api/conf.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/api/conf.py b/docs/api/conf.py index bc518e3ad..86a40a871 100644 --- a/docs/api/conf.py +++ b/docs/api/conf.py @@ -1,5 +1,6 @@ import logging import sys +from datetime import datetime from pathlib import Path # Configure logging @@ -13,7 +14,8 @@ sys.path.insert(0, str(project_root / "src")) # Project information project = "pipecat-ai" -copyright = "2024, Daily" +current_year = datetime.now().year +copyright = f"2024-{current_year}, Daily" if current_year > 2024 else "2024, Daily" author = "Daily" # General configuration From 0aa197e4a412c2aea84abe7e8211e77fae521ec7 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 25 Jun 2025 23:36:04 -0400 Subject: [PATCH 26/54] Add docstrings to DeepgramSTTService --- src/pipecat/services/deepgram/stt.py | 68 ++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/pipecat/services/deepgram/stt.py b/src/pipecat/services/deepgram/stt.py index 308ad1d1d..da7ec535f 100644 --- a/src/pipecat/services/deepgram/stt.py +++ b/src/pipecat/services/deepgram/stt.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Deepgram speech-to-text service implementation.""" + from typing import AsyncGenerator, Dict, Optional from loguru import logger @@ -41,6 +43,22 @@ except ModuleNotFoundError as e: class DeepgramSTTService(STTService): + """Deepgram speech-to-text service. + + Provides real-time speech recognition using Deepgram's WebSocket API. + Supports configurable models, languages, VAD events, and various audio + processing options. + + Args: + api_key: Deepgram API key for authentication. + url: Deprecated. Use base_url instead. + base_url: Custom Deepgram API base URL. + sample_rate: Audio sample rate. If None, uses default or live_options value. + live_options: Deepgram LiveOptions for detailed configuration. + addons: Additional Deepgram features to enable. + **kwargs: Additional arguments passed to the parent STTService. + """ + def __init__( self, *, @@ -108,12 +126,27 @@ class DeepgramSTTService(STTService): @property def vad_enabled(self): + """Check if Deepgram VAD events are enabled. + + Returns: + True if VAD events are enabled in the current settings. + """ return self._settings["vad_events"] def can_generate_metrics(self) -> bool: + """Check if this service can generate processing metrics. + + Returns: + True, as Deepgram service supports metrics generation. + """ return True async def set_model(self, model: str): + """Set the Deepgram model and reconnect. + + Args: + model: The Deepgram model name to use. + """ await super().set_model(model) logger.info(f"Switching STT model to: [{model}]") self._settings["model"] = model @@ -121,25 +154,53 @@ class DeepgramSTTService(STTService): await self._connect() async def set_language(self, language: Language): + """Set the recognition language and reconnect. + + Args: + language: The language to use for speech recognition. + """ logger.info(f"Switching STT language to: [{language}]") self._settings["language"] = language await self._disconnect() await self._connect() async def start(self, frame: StartFrame): + """Start the Deepgram STT service. + + Args: + frame: The start frame containing initialization parameters. + """ await super().start(frame) self._settings["sample_rate"] = self.sample_rate await self._connect() async def stop(self, frame: EndFrame): + """Stop the Deepgram STT service. + + Args: + frame: The end frame. + """ await super().stop(frame) await self._disconnect() async def cancel(self, frame: CancelFrame): + """Cancel the Deepgram STT service. + + Args: + frame: The cancel frame. + """ await super().cancel(frame) await self._disconnect() async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: + """Send audio data to Deepgram for transcription. + + Args: + audio: Raw audio bytes to transcribe. + + Yields: + Frame: None (transcription results come via WebSocket callbacks). + """ await self._connection.send(audio) yield None @@ -172,6 +233,7 @@ class DeepgramSTTService(STTService): await self._connection.finish() async def start_metrics(self): + """Start TTFB and processing metrics collection.""" await self.start_ttfb_metrics() await self.start_processing_metrics() @@ -235,6 +297,12 @@ class DeepgramSTTService(STTService): ) async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames with Deepgram-specific handling. + + Args: + frame: The frame to process. + direction: The direction of frame processing. + """ await super().process_frame(frame, direction) if isinstance(frame, UserStartedSpeakingFrame) and not self.vad_enabled: From 5b8f1fe3e392056d67e77164c8ec8a14c801721d Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 25 Jun 2025 23:50:55 -0400 Subject: [PATCH 27/54] Add Cartesia TTS docstrings --- src/pipecat/services/cartesia/tts.py | 143 +++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/src/pipecat/services/cartesia/tts.py b/src/pipecat/services/cartesia/tts.py index 22493f229..402ea27a0 100644 --- a/src/pipecat/services/cartesia/tts.py +++ b/src/pipecat/services/cartesia/tts.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Cartesia text-to-speech service implementations.""" + import base64 import json import uuid @@ -42,6 +44,14 @@ except ModuleNotFoundError as e: def language_to_cartesia_language(language: Language) -> Optional[str]: + """Convert a Language enum to Cartesia language code. + + Args: + language: The Language enum value to convert. + + Returns: + The corresponding Cartesia language code, or None if not supported. + """ BASE_LANGUAGES = { Language.DE: "de", Language.EN: "en", @@ -74,7 +84,35 @@ def language_to_cartesia_language(language: Language) -> Optional[str]: class CartesiaTTSService(AudioContextWordTTSService): + """Cartesia TTS service with WebSocket streaming and word timestamps. + + Provides text-to-speech using Cartesia's streaming WebSocket API. + Supports word-level timestamps, audio context management, and various voice + customization options including speed and emotion controls. + + Args: + api_key: Cartesia API key for authentication. + voice_id: ID of the voice to use for synthesis. + cartesia_version: API version string for Cartesia service. + url: WebSocket URL for Cartesia TTS API. + model: TTS model to use (e.g., "sonic-2"). + sample_rate: Audio sample rate. If None, uses default. + encoding: Audio encoding format. + container: Audio container format. + params: Additional input parameters for voice customization. + text_aggregator: Custom text aggregator for processing input text. + **kwargs: Additional arguments passed to the parent service. + """ + class InputParams(BaseModel): + """Input parameters for Cartesia TTS configuration. + + Parameters: + language: Language to use for synthesis. + speed: Voice speed control (string or float). + emotion: List of emotion controls (deprecated). + """ + language: Optional[Language] = Language.EN speed: Optional[Union[str, float]] = "" emotion: Optional[List[str]] = [] @@ -137,14 +175,32 @@ class CartesiaTTSService(AudioContextWordTTSService): self._receive_task = None def can_generate_metrics(self) -> bool: + """Check if this service can generate processing metrics. + + Returns: + True, as Cartesia service supports metrics generation. + """ return True async def set_model(self, model: str): + """Set the TTS model. + + Args: + model: The model name to use for synthesis. + """ self._model_id = model await super().set_model(model) logger.info(f"Switching TTS model to: [{model}]") def language_to_service_language(self, language: Language) -> Optional[str]: + """Convert a Language enum to Cartesia language format. + + Args: + language: The language to convert. + + Returns: + The Cartesia-specific language code, or None if not supported. + """ return language_to_cartesia_language(language) def _build_msg( @@ -182,15 +238,30 @@ class CartesiaTTSService(AudioContextWordTTSService): return json.dumps(msg) async def start(self, frame: StartFrame): + """Start the Cartesia TTS service. + + Args: + frame: The start frame containing initialization parameters. + """ await super().start(frame) self._settings["output_format"]["sample_rate"] = self.sample_rate await self._connect() async def stop(self, frame: EndFrame): + """Stop the Cartesia TTS service. + + Args: + frame: The end frame. + """ await super().stop(frame) await self._disconnect() async def cancel(self, frame: CancelFrame): + """Stop the Cartesia TTS service. + + Args: + frame: The end frame. + """ await super().cancel(frame) await self._disconnect() @@ -247,6 +318,7 @@ class CartesiaTTSService(AudioContextWordTTSService): self._context_id = None async def flush_audio(self): + """Flush any pending audio and finalize the current context.""" if not self._context_id or not self._websocket: return logger.trace(f"{self}: flushing audio") @@ -287,6 +359,14 @@ class CartesiaTTSService(AudioContextWordTTSService): @traced_tts async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + """Generate speech from text using Cartesia's streaming API. + + Args: + text: The text to synthesize into speech. + + Yields: + Frame: Audio frames containing the synthesized speech. + """ logger.debug(f"{self}: Generating TTS [{text}]") try: @@ -316,7 +396,34 @@ class CartesiaTTSService(AudioContextWordTTSService): class CartesiaHttpTTSService(TTSService): + """Cartesia HTTP-based TTS service. + + Provides text-to-speech using Cartesia's HTTP API for simpler, non-streaming + synthesis. Suitable for use cases where streaming is not required and simpler + integration is preferred. + + Args: + api_key: Cartesia API key for authentication. + voice_id: ID of the voice to use for synthesis. + model: TTS model to use (e.g., "sonic-2"). + base_url: Base URL for Cartesia HTTP API. + cartesia_version: API version string for Cartesia service. + sample_rate: Audio sample rate. If None, uses default. + encoding: Audio encoding format. + container: Audio container format. + params: Additional input parameters for voice customization. + **kwargs: Additional arguments passed to the parent TTSService. + """ + class InputParams(BaseModel): + """Input parameters for Cartesia HTTP TTS configuration. + + Parameters: + language: Language to use for synthesis. + speed: Voice speed control (string or float). + emotion: List of emotion controls (deprecated). + """ + language: Optional[Language] = Language.EN speed: Optional[Union[str, float]] = "" emotion: Optional[List[str]] = Field(default_factory=list) @@ -363,25 +470,61 @@ class CartesiaHttpTTSService(TTSService): ) def can_generate_metrics(self) -> bool: + """Check if this service can generate processing metrics. + + Returns: + True, as Cartesia HTTP service supports metrics generation. + """ return True def language_to_service_language(self, language: Language) -> Optional[str]: + """Convert a Language enum to Cartesia language format. + + Args: + language: The language to convert. + + Returns: + The Cartesia-specific language code, or None if not supported. + """ return language_to_cartesia_language(language) async def start(self, frame: StartFrame): + """Start the Cartesia HTTP TTS service. + + Args: + frame: The start frame containing initialization parameters. + """ await super().start(frame) self._settings["output_format"]["sample_rate"] = self.sample_rate async def stop(self, frame: EndFrame): + """Stop the Cartesia HTTP TTS service. + + Args: + frame: The end frame. + """ await super().stop(frame) await self._client.close() async def cancel(self, frame: CancelFrame): + """Cancel the Cartesia HTTP TTS service. + + Args: + frame: The cancel frame. + """ await super().cancel(frame) await self._client.close() @traced_tts async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + """Generate speech from text using Cartesia's HTTP API. + + Args: + text: The text to synthesize into speech. + + Yields: + Frame: Audio frames containing the synthesized speech. + """ logger.debug(f"{self}: Generating TTS [{text}]") try: From ac61139243b7c55d8fe0752422767e3b1410c886 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 00:06:57 -0400 Subject: [PATCH 28/54] Add OpenAI LLM docstrings --- src/pipecat/services/openai/base_llm.py | 71 ++++++++++++++++++-- src/pipecat/services/openai/llm.py | 88 +++++++++++++++++++++++-- 2 files changed, 148 insertions(+), 11 deletions(-) diff --git a/src/pipecat/services/openai/base_llm.py b/src/pipecat/services/openai/base_llm.py index 2badfed96..abeb1ea11 100644 --- a/src/pipecat/services/openai/base_llm.py +++ b/src/pipecat/services/openai/base_llm.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Base OpenAI LLM service implementation.""" + import base64 import json from typing import Any, Dict, List, Mapping, Optional @@ -39,16 +41,39 @@ from pipecat.utils.tracing.service_decorators import traced_llm class BaseOpenAILLMService(LLMService): - """This is the base for all services that use the AsyncOpenAI client. + """Base class for all services that use the AsyncOpenAI client. This service consumes OpenAILLMContextFrame frames, which contain a reference - to an OpenAILLMContext frame. The OpenAILLMContext object defines the context - sent to the LLM for a completion. This includes user, assistant and system messages - as well as tool choices and the tool, which is used if requesting function - calls from the LLM. + to an OpenAILLMContext object. The context defines what is sent to the LLM for + completion, including user, assistant, and system messages, as well as tool + choices and function call configurations. + + Args: + model: The OpenAI model name to use (e.g., "gpt-4.1", "gpt-4o"). + api_key: OpenAI API key. If None, uses environment variable. + base_url: Custom base URL for OpenAI API. If None, uses default. + organization: OpenAI organization ID. + project: OpenAI project ID. + default_headers: Additional HTTP headers to include in requests. + params: Input parameters for model configuration and behavior. + **kwargs: Additional arguments passed to the parent LLMService. """ class InputParams(BaseModel): + """Input parameters for OpenAI model configuration. + + Parameters: + frequency_penalty: Penalty for frequent tokens (-2.0 to 2.0). + presence_penalty: Penalty for new tokens (-2.0 to 2.0). + seed: Random seed for deterministic outputs. + temperature: Sampling temperature (0.0 to 2.0). + top_k: Top-k sampling parameter (currently ignored by OpenAI). + top_p: Top-p (nucleus) sampling parameter (0.0 to 1.0). + max_tokens: Maximum tokens in response (deprecated, use max_completion_tokens). + max_completion_tokens: Maximum completion tokens to generate. + extra: Additional model-specific parameters. + """ + frequency_penalty: Optional[float] = Field( default_factory=lambda: NOT_GIVEN, ge=-2.0, le=2.0 ) @@ -110,6 +135,19 @@ class BaseOpenAILLMService(LLMService): default_headers=None, **kwargs, ): + """Create an AsyncOpenAI client instance. + + Args: + api_key: OpenAI API key. + base_url: Custom base URL for the API. + organization: OpenAI organization ID. + project: OpenAI project ID. + default_headers: Additional HTTP headers. + **kwargs: Additional client configuration arguments. + + Returns: + Configured AsyncOpenAI client instance. + """ return AsyncOpenAI( api_key=api_key, base_url=base_url, @@ -124,11 +162,25 @@ class BaseOpenAILLMService(LLMService): ) def can_generate_metrics(self) -> bool: + """Check if this service can generate processing metrics. + + Returns: + True, as OpenAI service supports metrics generation. + """ return True async def get_chat_completions( self, context: OpenAILLMContext, messages: List[ChatCompletionMessageParam] ) -> AsyncStream[ChatCompletionChunk]: + """Get streaming chat completions from OpenAI API. + + Args: + context: The LLM context containing tools and configuration. + messages: List of chat completion messages to send. + + Returns: + Async stream of chat completion chunks. + """ params = { "model": self.model_name, "stream": True, @@ -274,6 +326,15 @@ class BaseOpenAILLMService(LLMService): await self.run_function_calls(function_calls) async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames for LLM completion requests. + + Handles OpenAILLMContextFrame, LLMMessagesFrame, VisionImageRawFrame, + and LLMUpdateSettingsFrame to trigger LLM completions and manage settings. + + Args: + frame: The frame to process. + direction: The direction of frame processing. + """ await super().process_frame(frame, direction) context = None diff --git a/src/pipecat/services/openai/llm.py b/src/pipecat/services/openai/llm.py index 44e0a40ce..c27eab867 100644 --- a/src/pipecat/services/openai/llm.py +++ b/src/pipecat/services/openai/llm.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""OpenAI LLM service implementation with context aggregators.""" + import json from dataclasses import dataclass from typing import Any, Optional @@ -26,17 +28,46 @@ from pipecat.services.openai.base_llm import BaseOpenAILLMService @dataclass class OpenAIContextAggregatorPair: + """Pair of OpenAI context aggregators for user and assistant messages. + + Parameters: + _user: User context aggregator for processing user messages. + _assistant: Assistant context aggregator for processing assistant messages. + """ + _user: "OpenAIUserContextAggregator" _assistant: "OpenAIAssistantContextAggregator" def user(self) -> "OpenAIUserContextAggregator": + """Get the user context aggregator. + + Returns: + The user context aggregator instance. + """ return self._user def assistant(self) -> "OpenAIAssistantContextAggregator": + """Get the assistant context aggregator. + + Returns: + The assistant context aggregator instance. + """ return self._assistant class OpenAILLMService(BaseOpenAILLMService): + """OpenAI LLM service implementation. + + Provides a complete OpenAI LLM service with context aggregation support. + Uses the BaseOpenAILLMService for core functionality and adds OpenAI-specific + context aggregator creation. + + Args: + model: The OpenAI model name to use. Defaults to "gpt-4.1". + params: Input parameters for model configuration. + **kwargs: Additional arguments passed to the parent BaseOpenAILLMService. + """ + def __init__( self, *, @@ -53,14 +84,15 @@ class OpenAILLMService(BaseOpenAILLMService): user_params: LLMUserAggregatorParams = LLMUserAggregatorParams(), assistant_params: LLMAssistantAggregatorParams = LLMAssistantAggregatorParams(), ) -> OpenAIContextAggregatorPair: - """Create an instance of OpenAIContextAggregatorPair from an - OpenAILLMContext. Constructor keyword arguments for both the user and - assistant aggregators can be provided. + """Create OpenAI-specific context aggregators. + + Creates a pair of context aggregators optimized for OpenAI's message format, + including support for function calls, tool usage, and image handling. Args: - context (OpenAILLMContext): The LLM context. - user_params (LLMUserAggregatorParams, optional): User aggregator parameters. - assistant_params (LLMAssistantAggregatorParams, optional): User aggregator parameters. + context: The LLM context to create aggregators for. + user_params: Parameters for user message aggregation. + assistant_params: Parameters for assistant message aggregation. Returns: OpenAIContextAggregatorPair: A pair of context aggregators, one for @@ -75,11 +107,32 @@ class OpenAILLMService(BaseOpenAILLMService): class OpenAIUserContextAggregator(LLMUserContextAggregator): + """OpenAI-specific user context aggregator. + + Handles aggregation of user messages for OpenAI LLM services. + Inherits all functionality from the base LLMUserContextAggregator. + """ + pass class OpenAIAssistantContextAggregator(LLMAssistantContextAggregator): + """OpenAI-specific assistant context aggregator. + + Handles aggregation of assistant messages for OpenAI LLM services, + with specialized support for OpenAI's function calling format, + tool usage tracking, and image message handling. + """ + async def handle_function_call_in_progress(self, frame: FunctionCallInProgressFrame): + """Handle a function call in progress. + + Adds the function call to the context with an IN_PROGRESS status + to track ongoing function execution. + + Args: + frame: Frame containing function call progress information. + """ self._context.add_message( { "role": "assistant", @@ -104,6 +157,14 @@ class OpenAIAssistantContextAggregator(LLMAssistantContextAggregator): ) async def handle_function_call_result(self, frame: FunctionCallResultFrame): + """Handle the result of a function call. + + Updates the context with the function call result, replacing any + previous IN_PROGRESS status. + + Args: + frame: Frame containing the function call result. + """ if frame.result: result = json.dumps(frame.result) await self._update_function_call_result(frame.function_name, frame.tool_call_id, result) @@ -113,6 +174,13 @@ class OpenAIAssistantContextAggregator(LLMAssistantContextAggregator): ) async def handle_function_call_cancel(self, frame: FunctionCallCancelFrame): + """Handle a cancelled function call. + + Updates the context to mark the function call as cancelled. + + Args: + frame: Frame containing the function call cancellation information. + """ await self._update_function_call_result( frame.function_name, frame.tool_call_id, "CANCELLED" ) @@ -129,6 +197,14 @@ class OpenAIAssistantContextAggregator(LLMAssistantContextAggregator): message["content"] = result async def handle_user_image_frame(self, frame: UserImageRawFrame): + """Handle a user image frame from a function call request. + + Marks the associated function call as completed and adds the image + to the context for processing. + + Args: + frame: Frame containing the user image and request context. + """ await self._update_function_call_result( frame.request.function_name, frame.request.tool_call_id, "COMPLETED" ) From 951c8d34da9638d78e836668193cbd3a6c1cbde3 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 00:15:09 -0400 Subject: [PATCH 29/54] Add special case handling for STT, TTS, LLM --- docs/api/conf.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/api/conf.py b/docs/api/conf.py index 86a40a871..a2a568134 100644 --- a/docs/api/conf.py +++ b/docs/api/conf.py @@ -272,6 +272,9 @@ def clean_title(title: str) -> str: "playht": "PlayHT", "xtts": "XTTS", "lmnt": "LMNT", + "stt": "STT", + "tts": "TTS", + "llm": "LLM", } # Check if the entire title is a special case From 990ee436e160714a8a5ba7289adc13855a919c78 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 07:42:22 -0400 Subject: [PATCH 30/54] Add Anthropic docstrings --- src/pipecat/services/anthropic/llm.py | 226 ++++++++++++++++++++++++-- 1 file changed, 210 insertions(+), 16 deletions(-) diff --git a/src/pipecat/services/anthropic/llm.py b/src/pipecat/services/anthropic/llm.py index b5334c383..e3fd50a51 100644 --- a/src/pipecat/services/anthropic/llm.py +++ b/src/pipecat/services/anthropic/llm.py @@ -4,6 +4,12 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Anthropic AI service integration for Pipecat. + +This module provides LLM services and context management for Anthropic's Claude models, +including support for function calling, vision, and prompt caching features. +""" + import asyncio import base64 import copy @@ -59,27 +65,66 @@ except ModuleNotFoundError as e: @dataclass class AnthropicContextAggregatorPair: + """Pair of context aggregators for Anthropic conversations. + + Encapsulates both user and assistant context aggregators + to manage conversation flow and message formatting. + + Parameters: + _user: The user context aggregator. + _assistant: The assistant context aggregator. + """ + _user: "AnthropicUserContextAggregator" _assistant: "AnthropicAssistantContextAggregator" def user(self) -> "AnthropicUserContextAggregator": + """Get the user context aggregator. + + Returns: + The user context aggregator instance. + """ return self._user def assistant(self) -> "AnthropicAssistantContextAggregator": + """Get the assistant context aggregator. + + Returns: + The assistant context aggregator instance. + """ return self._assistant class AnthropicLLMService(LLMService): - """This class implements inference with Anthropic's AI models. + """LLM service for Anthropic's Claude models. - Can provide a custom client via the `client` kwarg, allowing you to - use `AsyncAnthropicBedrock` and `AsyncAnthropicVertex` clients + Provides inference capabilities with Claude models including support for + function calling, vision processing, streaming responses, and prompt caching. + Can use custom clients like AsyncAnthropicBedrock and AsyncAnthropicVertex. + + Args: + api_key: Anthropic API key for authentication. + model: Model name to use. Defaults to "claude-sonnet-4-20250514". + params: Optional model parameters for inference. + client: Optional custom Anthropic client instance. + **kwargs: Additional arguments passed to parent LLMService. """ # Overriding the default adapter to use the Anthropic one. adapter_class = AnthropicLLMAdapter class InputParams(BaseModel): + """Input parameters for Anthropic model inference. + + Parameters: + enable_prompt_caching_beta: Whether to enable beta prompt caching feature. + max_tokens: Maximum tokens to generate. Must be at least 1. + temperature: Sampling temperature between 0.0 and 1.0. + top_k: Top-k sampling parameter. + top_p: Top-p sampling parameter between 0.0 and 1.0. + extra: Additional parameters to pass to the API. + """ + enable_prompt_caching_beta: Optional[bool] = False max_tokens: Optional[int] = Field(default_factory=lambda: 4096, ge=1) temperature: Optional[float] = Field(default_factory=lambda: NOT_GIVEN, ge=0.0, le=1.0) @@ -112,10 +157,20 @@ class AnthropicLLMService(LLMService): } def can_generate_metrics(self) -> bool: + """Check if this service can generate usage metrics. + + Returns: + True, as Anthropic provides detailed token usage metrics. + """ return True @property def enable_prompt_caching_beta(self) -> bool: + """Check if prompt caching beta feature is enabled. + + Returns: + True if prompt caching is enabled. + """ return self._enable_prompt_caching_beta def create_context_aggregator( @@ -125,22 +180,19 @@ class AnthropicLLMService(LLMService): user_params: LLMUserAggregatorParams = LLMUserAggregatorParams(), assistant_params: LLMAssistantAggregatorParams = LLMAssistantAggregatorParams(), ) -> AnthropicContextAggregatorPair: - """Create an instance of AnthropicContextAggregatorPair from an - OpenAILLMContext. Constructor keyword arguments for both the user and - assistant aggregators can be provided. + """Create Anthropic-specific context aggregators. + + Creates a pair of context aggregators optimized for Anthropic's message format, + including support for function calls, tool usage, and image handling. Args: - context (OpenAILLMContext): The LLM context. - user_params (LLMUserAggregatorParams, optional): User aggregator - parameters. - assistant_params (LLMAssistantAggregatorParams, optional): User - aggregator parameters. + context: The LLM context. + user_params: User aggregator parameters. + assistant_params: Assistant aggregator parameters. Returns: - AnthropicContextAggregatorPair: A pair of context aggregators, one - for the user and one for the assistant, encapsulated in an - AnthropicContextAggregatorPair. - + A pair of context aggregators, one for the user and one for the assistant, + encapsulated in an AnthropicContextAggregatorPair. """ context.set_llm_adapter(self.get_llm_adapter()) @@ -310,6 +362,15 @@ class AnthropicLLMService(LLMService): ) async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process incoming frames and route them appropriately. + + Handles various frame types including context frames, message frames, + vision frames, and settings updates. + + Args: + frame: The frame to process. + direction: The direction of frame processing. + """ await super().process_frame(frame, direction) context = None @@ -361,6 +422,19 @@ class AnthropicLLMService(LLMService): class AnthropicLLMContext(OpenAILLMContext): + """LLM context specialized for Anthropic's message format and features. + + Extends OpenAILLMContext to handle Anthropic-specific features like + system messages, prompt caching, and message format conversions. + Manages conversation state and message history formatting. + + Args: + messages: Initial list of conversation messages. + tools: Available function calling tools. + tool_choice: Tool selection preference. + system: System message content. + """ + def __init__( self, messages: Optional[List[dict]] = None, @@ -381,6 +455,16 @@ class AnthropicLLMContext(OpenAILLMContext): @staticmethod def upgrade_to_anthropic(obj: OpenAILLMContext) -> "AnthropicLLMContext": + """Upgrade an OpenAI context to Anthropic format. + + Converts message format and restructures content for Anthropic compatibility. + + Args: + obj: The OpenAI context to upgrade. + + Returns: + The upgraded Anthropic context. + """ logger.debug(f"Upgrading to Anthropic: {obj}") if isinstance(obj, OpenAILLMContext) and not isinstance(obj, AnthropicLLMContext): obj.__class__ = AnthropicLLMContext @@ -389,6 +473,14 @@ class AnthropicLLMContext(OpenAILLMContext): @classmethod def from_openai_context(cls, openai_context: OpenAILLMContext): + """Create Anthropic context from OpenAI context. + + Args: + openai_context: The OpenAI context to convert. + + Returns: + New Anthropic context with converted messages. + """ self = cls( messages=openai_context.messages, tools=openai_context.tools, @@ -400,12 +492,28 @@ class AnthropicLLMContext(OpenAILLMContext): @classmethod def from_messages(cls, messages: List[dict]) -> "AnthropicLLMContext": + """Create context from a list of messages. + + Args: + messages: List of conversation messages. + + Returns: + New Anthropic context with the provided messages. + """ self = cls(messages=messages) self._restructure_from_openai_messages() return self @classmethod def from_image_frame(cls, frame: VisionImageRawFrame) -> "AnthropicLLMContext": + """Create context from a vision image frame. + + Args: + frame: The vision image frame to process. + + Returns: + New Anthropic context with the image message. + """ context = cls() context.add_image_frame_message( format=frame.format, size=frame.size, image=frame.image, text=frame.text @@ -413,11 +521,15 @@ class AnthropicLLMContext(OpenAILLMContext): return context def set_messages(self, messages: List): + """Set the messages list and reset cache tracking. + + Args: + messages: New list of messages to set. + """ self.turns_above_cache_threshold = 0 self._messages[:] = messages self._restructure_from_openai_messages() - # convert a message in Anthropic format into one or more messages in OpenAI format def to_standard_messages(self, obj): """Convert Anthropic message format to standard structured format. @@ -558,6 +670,17 @@ class AnthropicLLMContext(OpenAILLMContext): def add_image_frame_message( self, *, format: str, size: tuple[int, int], image: bytes, text: str = None ): + """Add an image message to the context. + + Converts the image to base64 JPEG format and adds it as a user message + with optional accompanying text. + + Args: + format: The image format (e.g., 'RGB', 'RGBA'). + size: Image dimensions as (width, height). + image: Raw image bytes. + text: Optional text to accompany the image. + """ buffer = io.BytesIO() Image.frombytes(format, size, image).save(buffer, format="JPEG") encoded_image = base64.b64encode(buffer.getvalue()).decode("utf-8") @@ -578,6 +701,14 @@ class AnthropicLLMContext(OpenAILLMContext): self.add_message({"role": "user", "content": content}) def add_message(self, message): + """Add a message to the context, merging with previous message if same role. + + Anthropic requires alternating roles, so consecutive messages from the same + role are merged together. + + Args: + message: The message to add to the context. + """ try: if self.messages: # Anthropic requires that roles alternate. If this message's role is the same as the @@ -603,6 +734,14 @@ class AnthropicLLMContext(OpenAILLMContext): logger.error(f"Error adding message: {e}") def get_messages_with_cache_control_markers(self) -> List[dict]: + """Get messages with prompt caching markers applied. + + Adds cache control markers to appropriate messages based on the + number of turns above the cache threshold. + + Returns: + List of messages with cache control markers added. + """ try: messages = copy.deepcopy(self.messages) if self.turns_above_cache_threshold >= 1 and messages[-1]["role"] == "user": @@ -670,12 +809,26 @@ class AnthropicLLMContext(OpenAILLMContext): message["content"] = [{"type": "text", "text": "(empty)"}] def get_messages_for_persistent_storage(self): + """Get messages formatted for persistent storage. + + Includes system message at the beginning if present. + + Returns: + List of messages suitable for storage. + """ messages = super().get_messages_for_persistent_storage() if self.system: messages.insert(0, {"role": "system", "content": self.system}) return messages def get_messages_for_logging(self) -> str: + """Get messages formatted for logging with sensitive data redacted. + + Replaces image data with placeholder text for cleaner logs. + + Returns: + JSON string representation of messages for logging. + """ msgs = [] for message in self.messages: msg = copy.deepcopy(message) @@ -689,6 +842,12 @@ class AnthropicLLMContext(OpenAILLMContext): class AnthropicUserContextAggregator(LLMUserContextAggregator): + """Anthropic-specific user context aggregator. + + Handles aggregation of user messages for Anthropic LLM services. + Inherits all functionality from the base LLMUserContextAggregator. + """ + pass @@ -703,7 +862,20 @@ class AnthropicUserContextAggregator(LLMUserContextAggregator): class AnthropicAssistantContextAggregator(LLMAssistantContextAggregator): + """Context aggregator for assistant messages in Anthropic conversations. + + Handles function call lifecycle management including in-progress tracking, + result handling, and cancellation for Anthropic's tool use format. + """ + async def handle_function_call_in_progress(self, frame: FunctionCallInProgressFrame): + """Handle a function call that is starting. + + Creates tool use message and placeholder tool result for tracking. + + Args: + frame: Frame containing function call details. + """ assistant_message = {"role": "assistant", "content": []} assistant_message["content"].append( { @@ -728,6 +900,13 @@ class AnthropicAssistantContextAggregator(LLMAssistantContextAggregator): ) async def handle_function_call_result(self, frame: FunctionCallResultFrame): + """Handle the result of a completed function call. + + Updates the tool result with actual return value or completion status. + + Args: + frame: Frame containing function call result. + """ if frame.result: result = json.dumps(frame.result) await self._update_function_call_result(frame.function_name, frame.tool_call_id, result) @@ -737,6 +916,13 @@ class AnthropicAssistantContextAggregator(LLMAssistantContextAggregator): ) async def handle_function_call_cancel(self, frame: FunctionCallCancelFrame): + """Handle cancellation of a function call. + + Updates the tool result to indicate cancellation. + + Args: + frame: Frame containing function call cancellation details. + """ await self._update_function_call_result( frame.function_name, frame.tool_call_id, "CANCELLED" ) @@ -755,6 +941,14 @@ class AnthropicAssistantContextAggregator(LLMAssistantContextAggregator): content["content"] = result async def handle_user_image_frame(self, frame: UserImageRawFrame): + """Handle a user image frame with function call context. + + Marks the associated function call as completed and adds the image + to the conversation context. + + Args: + frame: User image frame with request context. + """ await self._update_function_call_result( frame.request.function_name, frame.request.tool_call_id, "COMPLETED" ) From 7bf805b8298fc8346023f5c5779fe273ae04b4b6 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 10:23:40 -0400 Subject: [PATCH 31/54] Update AWSBedrock docstrings --- src/pipecat/services/aws/llm.py | 209 +++++++++++++++++++++++++++++--- 1 file changed, 191 insertions(+), 18 deletions(-) diff --git a/src/pipecat/services/aws/llm.py b/src/pipecat/services/aws/llm.py index d3217e7a1..249fb81da 100644 --- a/src/pipecat/services/aws/llm.py +++ b/src/pipecat/services/aws/llm.py @@ -4,6 +4,13 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""AWS Bedrock integration for Large Language Model services. + +This module provides AWS Bedrock LLM service implementation with support for +Amazon Nova and Anthropic Claude models, including vision capabilities and +function calling. +""" + import asyncio import base64 import copy @@ -61,17 +68,50 @@ except ModuleNotFoundError as e: @dataclass class AWSBedrockContextAggregatorPair: + """Container for AWS Bedrock context aggregators. + + Provides convenient access to both user and assistant context aggregators + for AWS Bedrock LLM operations. + + Parameters: + _user: The user context aggregator instance. + _assistant: The assistant context aggregator instance. + """ + _user: "AWSBedrockUserContextAggregator" _assistant: "AWSBedrockAssistantContextAggregator" def user(self) -> "AWSBedrockUserContextAggregator": + """Get the user context aggregator. + + Returns: + The user context aggregator instance. + """ return self._user def assistant(self) -> "AWSBedrockAssistantContextAggregator": + """Get the assistant context aggregator. + + Returns: + The assistant context aggregator instance. + """ return self._assistant class AWSBedrockLLMContext(OpenAILLMContext): + """AWS Bedrock-specific LLM context implementation. + + Extends OpenAI LLM context to handle AWS Bedrock's specific message format + and system message handling. Manages conversion between OpenAI and Bedrock + message formats. + + Args: + messages: List of conversation messages in OpenAI format. + tools: List of available function calling tools. + tool_choice: Tool selection strategy or specific tool choice. + system: System message content for AWS Bedrock. + """ + def __init__( self, messages: Optional[List[dict]] = None, @@ -85,6 +125,14 @@ class AWSBedrockLLMContext(OpenAILLMContext): @staticmethod def upgrade_to_bedrock(obj: OpenAILLMContext) -> "AWSBedrockLLMContext": + """Upgrade an OpenAI LLM context to AWS Bedrock format. + + Args: + obj: The OpenAI LLM context to upgrade. + + Returns: + The upgraded AWS Bedrock LLM context. + """ logger.debug(f"Upgrading to AWS Bedrock: {obj}") if isinstance(obj, OpenAILLMContext) and not isinstance(obj, AWSBedrockLLMContext): obj.__class__ = AWSBedrockLLMContext @@ -95,6 +143,14 @@ class AWSBedrockLLMContext(OpenAILLMContext): @classmethod def from_openai_context(cls, openai_context: OpenAILLMContext): + """Create AWS Bedrock context from OpenAI context. + + Args: + openai_context: The OpenAI LLM context to convert. + + Returns: + New AWS Bedrock LLM context instance. + """ self = cls( messages=openai_context.messages, tools=openai_context.tools, @@ -106,12 +162,28 @@ class AWSBedrockLLMContext(OpenAILLMContext): @classmethod def from_messages(cls, messages: List[dict]) -> "AWSBedrockLLMContext": + """Create AWS Bedrock context from message list. + + Args: + messages: List of messages in OpenAI format. + + Returns: + New AWS Bedrock LLM context instance. + """ self = cls(messages=messages) self._restructure_from_openai_messages() return self @classmethod def from_image_frame(cls, frame: VisionImageRawFrame) -> "AWSBedrockLLMContext": + """Create AWS Bedrock context from vision image frame. + + Args: + frame: The vision image frame to convert. + + Returns: + New AWS Bedrock LLM context instance. + """ context = cls() context.add_image_frame_message( format=frame.format, size=frame.size, image=frame.image, text=frame.text @@ -119,10 +191,14 @@ class AWSBedrockLLMContext(OpenAILLMContext): return context def set_messages(self, messages: List): + """Set the messages list and restructure for Bedrock format. + + Args: + messages: List of messages to set. + """ self._messages[:] = messages self._restructure_from_openai_messages() - # convert a message in AWS Bedrock format into one or more messages in OpenAI format def to_standard_messages(self, obj): """Convert AWS Bedrock message format to standard structured format. @@ -295,6 +371,14 @@ class AWSBedrockLLMContext(OpenAILLMContext): def add_image_frame_message( self, *, format: str, size: tuple[int, int], image: bytes, text: str = None ): + """Add an image message to the context. + + Args: + format: The image format (e.g., 'RGB', 'RGBA'). + size: The image dimensions as (width, height). + image: The raw image data as bytes. + text: Optional text to accompany the image. + """ buffer = io.BytesIO() Image.frombytes(format, size, image).save(buffer, format="JPEG") encoded_image = base64.b64encode(buffer.getvalue()).decode("utf-8") @@ -306,6 +390,14 @@ class AWSBedrockLLMContext(OpenAILLMContext): self.add_message({"role": "user", "content": content}) def add_message(self, message): + """Add a message to the context, merging with previous message if same role. + + AWS Bedrock requires alternating roles, so consecutive messages from the + same role are merged together. + + Args: + message: The message to add to the context. + """ try: if self.messages: # AWS Bedrock requires that roles alternate. If this message's @@ -330,10 +422,10 @@ class AWSBedrockLLMContext(OpenAILLMContext): logger.error(f"Error adding message: {e}") def _restructure_from_bedrock_messages(self): - """Restructure messages in AWS Bedrock format by handling system - messages, merging consecutive messages with the same role, and ensuring - proper content formatting. + """Restructure messages in AWS Bedrock format. + Handles system messages, merging consecutive messages with the same role, + and ensuring proper content formatting. """ # Handle system message if present at the beginning if self.messages and self.messages[0]["role"] == "system": @@ -416,12 +508,22 @@ class AWSBedrockLLMContext(OpenAILLMContext): message["content"] = [{"type": "text", "text": "(empty)"}] def get_messages_for_persistent_storage(self): + """Get messages formatted for persistent storage. + + Returns: + List of messages including system message if present. + """ messages = super().get_messages_for_persistent_storage() if self.system: messages.insert(0, {"role": "system", "content": self.system}) return messages def get_messages_for_logging(self) -> str: + """Get messages formatted for logging with sensitive data redacted. + + Returns: + JSON string representation of messages with image data redacted. + """ msgs = [] for message in self.messages: msg = copy.deepcopy(message) @@ -435,11 +537,36 @@ class AWSBedrockLLMContext(OpenAILLMContext): class AWSBedrockUserContextAggregator(LLMUserContextAggregator): + """User context aggregator for AWS Bedrock LLM service. + + Handles aggregation of user messages and frames for AWS Bedrock format. + Inherits all functionality from the base LLM user context aggregator. + + Args: + context: The LLM context to aggregate messages into. + params: Configuration parameters for the aggregator. + """ + pass class AWSBedrockAssistantContextAggregator(LLMAssistantContextAggregator): + """Assistant context aggregator for AWS Bedrock LLM service. + + Handles aggregation of assistant responses and function calls for AWS Bedrock + format, including tool use and tool result handling. + + Args: + context: The LLM context to aggregate messages into. + params: Configuration parameters for the aggregator. + """ + async def handle_function_call_in_progress(self, frame: FunctionCallInProgressFrame): + """Handle function call in progress frame. + + Args: + frame: The function call in progress frame to handle. + """ # Format tool use according to AWS Bedrock API self._context.add_message( { @@ -470,6 +597,11 @@ class AWSBedrockAssistantContextAggregator(LLMAssistantContextAggregator): ) async def handle_function_call_result(self, frame: FunctionCallResultFrame): + """Handle function call result frame. + + Args: + frame: The function call result frame to handle. + """ if frame.result: result = json.dumps(frame.result) await self._update_function_call_result(frame.function_name, frame.tool_call_id, result) @@ -479,6 +611,11 @@ class AWSBedrockAssistantContextAggregator(LLMAssistantContextAggregator): ) async def handle_function_call_cancel(self, frame: FunctionCallCancelFrame): + """Handle function call cancel frame. + + Args: + frame: The function call cancel frame to handle. + """ await self._update_function_call_result( frame.function_name, frame.tool_call_id, "CANCELLED" ) @@ -497,6 +634,11 @@ class AWSBedrockAssistantContextAggregator(LLMAssistantContextAggregator): content["toolResult"]["content"] = [{"text": result}] async def handle_user_image_frame(self, frame: UserImageRawFrame): + """Handle user image frame. + + Args: + frame: The user image frame to handle. + """ await self._update_function_call_result( frame.request.function_name, frame.request.tool_call_id, "COMPLETED" ) @@ -509,18 +651,38 @@ class AWSBedrockAssistantContextAggregator(LLMAssistantContextAggregator): class AWSBedrockLLMService(LLMService): - """This class implements inference with AWS Bedrock models including Amazon - Nova and Anthropic Claude. + """AWS Bedrock Large Language Model service implementation. - Requires AWS credentials to be configured in the environment or through - boto3 configuration. + Provides inference capabilities for AWS Bedrock models including Amazon Nova + and Anthropic Claude. Supports streaming responses, function calling, and + vision capabilities. + Args: + model: The AWS Bedrock model identifier to use. + aws_access_key: AWS access key ID. If None, uses default credentials. + aws_secret_key: AWS secret access key. If None, uses default credentials. + aws_session_token: AWS session token for temporary credentials. + aws_region: AWS region for the Bedrock service. + params: Model parameters and configuration. + client_config: Custom boto3 client configuration. + **kwargs: Additional arguments passed to parent LLMService. """ # Overriding the default adapter to use the Anthropic one. adapter_class = AWSBedrockLLMAdapter class InputParams(BaseModel): + """Input parameters for AWS Bedrock LLM service. + + Parameters: + max_tokens: Maximum number of tokens to generate. + temperature: Sampling temperature between 0.0 and 1.0. + top_p: Nucleus sampling parameter between 0.0 and 1.0. + stop_sequences: List of strings that stop generation. + latency: Performance mode - "standard" or "optimized". + additional_model_request_fields: Additional model-specific parameters. + """ + max_tokens: Optional[int] = Field(default_factory=lambda: 4096, ge=1) temperature: Optional[float] = Field(default_factory=lambda: 0.7, ge=0.0, le=1.0) top_p: Optional[float] = Field(default_factory=lambda: 0.999, ge=0.0, le=1.0) @@ -573,6 +735,11 @@ class AWSBedrockLLMService(LLMService): logger.info(f"Using AWS Bedrock model: {model}") def can_generate_metrics(self) -> bool: + """Check if the service can generate usage metrics. + + Returns: + True if metrics generation is supported. + """ return True def create_context_aggregator( @@ -582,21 +749,21 @@ class AWSBedrockLLMService(LLMService): user_params: LLMUserAggregatorParams = LLMUserAggregatorParams(), assistant_params: LLMAssistantAggregatorParams = LLMAssistantAggregatorParams(), ) -> AWSBedrockContextAggregatorPair: - """Create an instance of AWSBedrockContextAggregatorPair from an - OpenAILLMContext. Constructor keyword arguments for both the user and - assistant aggregators can be provided. + """Create AWS Bedrock-specific context aggregators. + + Creates a pair of context aggregators optimized for AWS Bedrocks's message + format, including support for function calls, tool usage, and image handling. Args: - context (OpenAILLMContext): The LLM context. - user_params (LLMUserAggregatorParams, optional): User aggregator - parameters. - assistant_params (LLMAssistantAggregatorParams, optional): User - aggregator parameters. + context: The LLM context to create aggregators for. + user_params: Parameters for user message aggregation. + assistant_params: Parameters for assistant message aggregation. Returns: - AWSBedrockContextAggregatorPair: A pair of context aggregators, one - for the user and one for the assistant, encapsulated in an + AWSBedrockContextAggregatorPair: A pair of context aggregators, one for + the user and one for the assistant, encapsulated in an AWSBedrockContextAggregatorPair. + """ context.set_llm_adapter(self.get_llm_adapter()) @@ -792,6 +959,12 @@ class AWSBedrockLLMService(LLMService): ) async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process incoming frames and handle LLM-specific frame types. + + Args: + frame: The frame to process. + direction: The direction of frame processing. + """ await super().process_frame(frame, direction) context = None From 9cbe85bf99fb5dd652b39e5f3f6121b0faf91569 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 10:25:17 -0400 Subject: [PATCH 32/54] Update AzureLLMService docstrings --- src/pipecat/services/azure/llm.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/pipecat/services/azure/llm.py b/src/pipecat/services/azure/llm.py index 295a1f1c1..bc1242044 100644 --- a/src/pipecat/services/azure/llm.py +++ b/src/pipecat/services/azure/llm.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Azure OpenAI service implementation for the Pipecat AI framework.""" + from loguru import logger from openai import AsyncAzureOpenAI @@ -17,11 +19,11 @@ class AzureLLMService(OpenAILLMService): maintaining full compatibility with OpenAI's interface and functionality. Args: - api_key (str): The API key for accessing Azure OpenAI - endpoint (str): The Azure endpoint URL - model (str): The model identifier to use - api_version (str, optional): Azure API version. Defaults to "2024-09-01-preview" - **kwargs: Additional keyword arguments passed to OpenAILLMService + api_key: The API key for accessing Azure OpenAI. + endpoint: The Azure endpoint URL. + model: The model identifier to use. + api_version: Azure API version. Defaults to "2024-09-01-preview". + **kwargs: Additional keyword arguments passed to OpenAILLMService. """ def __init__( @@ -40,7 +42,16 @@ class AzureLLMService(OpenAILLMService): super().__init__(api_key=api_key, model=model, **kwargs) def create_client(self, api_key=None, base_url=None, **kwargs): - """Create OpenAI-compatible client for Azure OpenAI endpoint.""" + """Create OpenAI-compatible client for Azure OpenAI endpoint. + + Args: + api_key: API key for authentication. Uses instance key if None. + base_url: Base URL for the client. Ignored for Azure implementation. + **kwargs: Additional keyword arguments. Ignored for Azure implementation. + + Returns: + AsyncAzureOpenAI: Configured Azure OpenAI client instance. + """ logger.debug(f"Creating Azure OpenAI client with endpoint {self._endpoint}") return AsyncAzureOpenAI( api_key=api_key, From 3828df8cf9de88007db6487d67a375f4f5860cfa Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 10:26:42 -0400 Subject: [PATCH 33/54] Update CerebrasLLMService docstrings --- src/pipecat/services/cerebras/llm.py | 33 ++++++++++++++++++---------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/pipecat/services/cerebras/llm.py b/src/pipecat/services/cerebras/llm.py index 2217cc2f8..fa3802891 100644 --- a/src/pipecat/services/cerebras/llm.py +++ b/src/pipecat/services/cerebras/llm.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Cerebras LLM service implementation using OpenAI-compatible interface.""" + from typing import List from loguru import logger @@ -21,10 +23,10 @@ class CerebrasLLMService(OpenAILLMService): maintaining full compatibility with OpenAI's interface and functionality. Args: - api_key (str): The API key for accessing Cerebras's API - base_url (str, optional): The base URL for Cerebras API. Defaults to "https://api.cerebras.ai/v1" - model (str, optional): The model identifier to use. Defaults to "llama-3.3-70b" - **kwargs: Additional keyword arguments passed to OpenAILLMService + api_key: The API key for accessing Cerebras's API. + base_url: The base URL for Cerebras API. Defaults to "https://api.cerebras.ai/v1". + model: The model identifier to use. Defaults to "llama-3.3-70b". + **kwargs: Additional keyword arguments passed to OpenAILLMService. """ def __init__( @@ -38,7 +40,16 @@ class CerebrasLLMService(OpenAILLMService): super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) def create_client(self, api_key=None, base_url=None, **kwargs): - """Create OpenAI-compatible client for Cerebras API endpoint.""" + """Create OpenAI-compatible client for Cerebras API endpoint. + + Args: + api_key: The API key for authentication. If None, uses instance key. + base_url: The base URL for the API. If None, uses instance URL. + **kwargs: Additional arguments passed to the client constructor. + + Returns: + An OpenAI-compatible client configured for Cerebras API. + """ logger.debug(f"Creating Cerebras client with api {base_url}") return super().create_client(api_key, base_url, **kwargs) @@ -48,14 +59,14 @@ class CerebrasLLMService(OpenAILLMService): """Create a streaming chat completion using Cerebras's API. Args: - context (OpenAILLMContext): The context object containing tools configuration - and other settings for the chat completion. - messages (List[ChatCompletionMessageParam]): The list of messages comprising - the conversation history and current request. + context: The context object containing tools configuration + and other settings for the chat completion. + messages: The list of messages comprising + the conversation history and current request. Returns: - AsyncStream[ChatCompletionChunk]: A streaming response of chat completion - chunks that can be processed asynchronously. + A streaming response of chat completion + chunks that can be processed asynchronously. """ params = { "model": self.model_name, From 65234ae41a1896d2e0fbf87b65ab9a3b63ad1473 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 10:27:36 -0400 Subject: [PATCH 34/54] Update DeepSeekLLMService docstrings --- src/pipecat/services/deepseek/llm.py | 34 ++++++++++++++++++---------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/pipecat/services/deepseek/llm.py b/src/pipecat/services/deepseek/llm.py index 7bed5d33b..aec6c50ba 100644 --- a/src/pipecat/services/deepseek/llm.py +++ b/src/pipecat/services/deepseek/llm.py @@ -4,6 +4,7 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""DeepSeek LLM service implementation using OpenAI-compatible interface.""" from typing import List @@ -22,10 +23,10 @@ class DeepSeekLLMService(OpenAILLMService): maintaining full compatibility with OpenAI's interface and functionality. Args: - api_key (str): The API key for accessing DeepSeek's API - base_url (str, optional): The base URL for DeepSeek API. Defaults to "https://api.deepseek.com/v1" - model (str, optional): The model identifier to use. Defaults to "deepseek-chat" - **kwargs: Additional keyword arguments passed to OpenAILLMService + api_key: The API key for accessing DeepSeek's API. + base_url: The base URL for DeepSeek API. Defaults to "https://api.deepseek.com/v1". + model: The model identifier to use. Defaults to "deepseek-chat". + **kwargs: Additional keyword arguments passed to OpenAILLMService. """ def __init__( @@ -39,24 +40,33 @@ class DeepSeekLLMService(OpenAILLMService): super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) def create_client(self, api_key=None, base_url=None, **kwargs): - """Create OpenAI-compatible client for DeepSeek API endpoint.""" + """Create OpenAI-compatible client for DeepSeek API endpoint. + + Args: + api_key: The API key for authentication. If None, uses instance default. + base_url: The base URL for the API. If None, uses instance default. + **kwargs: Additional keyword arguments for client configuration. + + Returns: + An OpenAI-compatible client configured for DeepSeek's API. + """ logger.debug(f"Creating DeepSeek client with api {base_url}") return super().create_client(api_key, base_url, **kwargs) async def get_chat_completions( self, context: OpenAILLMContext, messages: List[ChatCompletionMessageParam] ) -> AsyncStream[ChatCompletionChunk]: - """Create a streaming chat completion using Cerebras's API. + """Create a streaming chat completion using DeepSeek's API. Args: - context (OpenAILLMContext): The context object containing tools configuration - and other settings for the chat completion. - messages (List[ChatCompletionMessageParam]): The list of messages comprising - the conversation history and current request. + context: The context object containing tools configuration + and other settings for the chat completion. + messages: The list of messages comprising the conversation + history and current request. Returns: - AsyncStream[ChatCompletionChunk]: A streaming response of chat completion - chunks that can be processed asynchronously. + A streaming response of chat completion chunks that can be + processed asynchronously. """ params = { "model": self.model_name, From 03e3e9fae93b39191dfd31c45a9fb8590ef6baf9 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 10:28:35 -0400 Subject: [PATCH 35/54] Update FireworksLLMService docstrings --- src/pipecat/services/fireworks/llm.py | 30 +++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/pipecat/services/fireworks/llm.py b/src/pipecat/services/fireworks/llm.py index d4003f86f..cccfb5556 100644 --- a/src/pipecat/services/fireworks/llm.py +++ b/src/pipecat/services/fireworks/llm.py @@ -4,6 +4,7 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Fireworks AI service implementation using OpenAI-compatible interface.""" from typing import List @@ -21,10 +22,10 @@ class FireworksLLMService(OpenAILLMService): maintaining full compatibility with OpenAI's interface and functionality. Args: - api_key (str): The API key for accessing Fireworks AI - model (str, optional): The model identifier to use. Defaults to "accounts/fireworks/models/firefunction-v2" - base_url (str, optional): The base URL for Fireworks API. Defaults to "https://api.fireworks.ai/inference/v1" - **kwargs: Additional keyword arguments passed to OpenAILLMService + api_key: The API key for accessing Fireworks AI. + model: The model identifier to use. Defaults to "accounts/fireworks/models/firefunction-v2". + base_url: The base URL for Fireworks API. Defaults to "https://api.fireworks.ai/inference/v1". + **kwargs: Additional keyword arguments passed to OpenAILLMService. """ def __init__( @@ -38,7 +39,16 @@ class FireworksLLMService(OpenAILLMService): super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) def create_client(self, api_key=None, base_url=None, **kwargs): - """Create OpenAI-compatible client for Fireworks API endpoint.""" + """Create OpenAI-compatible client for Fireworks API endpoint. + + Args: + api_key: API key for authentication. If None, uses instance default. + base_url: Base URL for the API. If None, uses instance default. + **kwargs: Additional arguments passed to the client constructor. + + Returns: + Configured OpenAI client instance for Fireworks API. + """ logger.debug(f"Creating Fireworks client with api {base_url}") return super().create_client(api_key, base_url, **kwargs) @@ -47,7 +57,15 @@ class FireworksLLMService(OpenAILLMService): ): """Get chat completions from Fireworks API. - Removes OpenAI-specific parameters not supported by Fireworks. + Removes OpenAI-specific parameters not supported by Fireworks and + configures the request with Fireworks-compatible settings. + + Args: + context: The OpenAI LLM context containing tools and settings. + messages: List of chat completion message parameters. + + Returns: + Async generator yielding chat completion chunks from Fireworks API. """ params = { "model": self.model_name, From 9b64d2c325a1492d566f3e9b7dab8530e54ddd93 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 10:37:22 -0400 Subject: [PATCH 36/54] Update GoogleLLMService docstrings --- src/pipecat/services/google/llm.py | 163 ++++++++++++++++++++++++++--- 1 file changed, 151 insertions(+), 12 deletions(-) diff --git a/src/pipecat/services/google/llm.py b/src/pipecat/services/google/llm.py index 5fe005fbd..d5b1efa8e 100644 --- a/src/pipecat/services/google/llm.py +++ b/src/pipecat/services/google/llm.py @@ -4,6 +4,12 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Google Gemini integration for Pipecat. + +This module provides Google Gemini integration for the Pipecat framework, +including LLM services, context management, and message aggregation. +""" + import base64 import io import json @@ -71,7 +77,14 @@ except ModuleNotFoundError as e: class GoogleUserContextAggregator(OpenAIUserContextAggregator): + """Google-specific user context aggregator. + + Extends OpenAI user context aggregator to handle Google AI's specific + Content and Part message format for user messages. + """ + async def push_aggregation(self): + """Push aggregated user text as a Google Content message.""" if len(self._aggregation) > 0: self._context.add_message(Content(role="user", parts=[Part(text=self._aggregation)])) @@ -88,10 +101,26 @@ class GoogleUserContextAggregator(OpenAIUserContextAggregator): class GoogleAssistantContextAggregator(OpenAIAssistantContextAggregator): + """Google-specific assistant context aggregator. + + Extends OpenAI assistant context aggregator to handle Google AI's specific + Content and Part message format for assistant responses and function calls. + """ + async def handle_aggregation(self, aggregation: str): + """Handle aggregated assistant text response. + + Args: + aggregation: The aggregated text response from the assistant. + """ self._context.add_message(Content(role="model", parts=[Part(text=aggregation)])) async def handle_function_call_in_progress(self, frame: FunctionCallInProgressFrame): + """Handle function call in progress frame. + + Args: + frame: Frame containing function call details. + """ self._context.add_message( Content( role="model", @@ -120,6 +149,11 @@ class GoogleAssistantContextAggregator(OpenAIAssistantContextAggregator): ) async def handle_function_call_result(self, frame: FunctionCallResultFrame): + """Handle function call result frame. + + Args: + frame: Frame containing function call result. + """ if frame.result: await self._update_function_call_result( frame.function_name, frame.tool_call_id, frame.result @@ -130,6 +164,11 @@ class GoogleAssistantContextAggregator(OpenAIAssistantContextAggregator): ) async def handle_function_call_cancel(self, frame: FunctionCallCancelFrame): + """Handle function call cancellation frame. + + Args: + frame: Frame containing function call cancellation details. + """ await self._update_function_call_result( frame.function_name, frame.tool_call_id, "CANCELLED" ) @@ -144,6 +183,11 @@ class GoogleAssistantContextAggregator(OpenAIAssistantContextAggregator): part.function_response.response = {"value": json.dumps(result)} async def handle_user_image_frame(self, frame: UserImageRawFrame): + """Handle user image frame. + + Args: + frame: Frame containing user image data and request context. + """ await self._update_function_call_result( frame.request.function_name, frame.request.tool_call_id, "COMPLETED" ) @@ -157,17 +201,45 @@ class GoogleAssistantContextAggregator(OpenAIAssistantContextAggregator): @dataclass class GoogleContextAggregatorPair: + """Pair of Google context aggregators for user and assistant messages. + + Parameters: + _user: User context aggregator for handling user messages. + _assistant: Assistant context aggregator for handling assistant responses. + """ + _user: GoogleUserContextAggregator _assistant: GoogleAssistantContextAggregator def user(self) -> GoogleUserContextAggregator: + """Get the user context aggregator. + + Returns: + The user context aggregator instance. + """ return self._user def assistant(self) -> GoogleAssistantContextAggregator: + """Get the assistant context aggregator. + + Returns: + The assistant context aggregator instance. + """ return self._assistant class GoogleLLMContext(OpenAILLMContext): + """Google AI LLM context that extends OpenAI context for Google-specific formatting. + + This class handles conversion between OpenAI-style messages and Google AI's + Content/Part format, including system messages, function calls, and media. + + Args: + messages: Initial messages in OpenAI format. + tools: Available tools/functions for the model. + tool_choice: Tool choice configuration. + """ + def __init__( self, messages: Optional[List[dict]] = None, @@ -179,6 +251,14 @@ class GoogleLLMContext(OpenAILLMContext): @staticmethod def upgrade_to_google(obj: OpenAILLMContext) -> "GoogleLLMContext": + """Upgrade an OpenAI context to a Google context. + + Args: + obj: OpenAI LLM context to upgrade. + + Returns: + GoogleLLMContext instance with converted messages. + """ if isinstance(obj, OpenAILLMContext) and not isinstance(obj, GoogleLLMContext): logger.debug(f"Upgrading to Google: {obj}") obj.__class__ = GoogleLLMContext @@ -186,10 +266,20 @@ class GoogleLLMContext(OpenAILLMContext): return obj def set_messages(self, messages: List): + """Set messages and restructure them for Google format. + + Args: + messages: List of messages to set. + """ self._messages[:] = messages self._restructure_from_openai_messages() def add_messages(self, messages: List): + """Add messages to the context, converting to Google format as needed. + + Args: + messages: List of messages to add (can be mixed formats). + """ # Convert each message individually converted_messages = [] for msg in messages: @@ -206,6 +296,11 @@ class GoogleLLMContext(OpenAILLMContext): self._messages.extend(converted_messages) def get_messages_for_logging(self): + """Get messages formatted for logging with sensitive data redacted. + + Returns: + List of message dictionaries with inline data redacted. + """ msgs = [] for message in self.messages: obj = message.to_json_dict() @@ -222,6 +317,14 @@ class GoogleLLMContext(OpenAILLMContext): def add_image_frame_message( self, *, format: str, size: tuple[int, int], image: bytes, text: str = None ): + """Add an image message to the context. + + Args: + format: Image format (e.g., 'RGB', 'RGBA'). + size: Image dimensions as (width, height). + image: Raw image bytes. + text: Optional text to accompany the image. + """ buffer = io.BytesIO() Image.frombytes(format, size, image).save(buffer, format="JPEG") @@ -235,6 +338,12 @@ class GoogleLLMContext(OpenAILLMContext): def add_audio_frames_message( self, *, audio_frames: list[AudioRawFrame], text: str = "Audio follows" ): + """Add audio frames as a message to the context. + + Args: + audio_frames: List of audio frames to add. + text: Text description of the audio content. + """ if not audio_frames: return @@ -448,17 +557,37 @@ class GoogleLLMContext(OpenAILLMContext): class GoogleLLMService(LLMService): - """This class implements inference with Google's AI models. + """Google AI (Gemini) LLM service implementation. - This service translates internally from OpenAILLMContext to the messages format - expected by the Google AI model. We are using the OpenAILLMContext as a lingua - franca for all LLM services, so that it is easy to switch between different LLMs. + This class implements inference with Google's AI models, translating internally + from OpenAILLMContext to the messages format expected by the Google AI model. + We use OpenAILLMContext as a lingua franca for all LLM services to enable + easy switching between different LLMs. + + Args: + api_key: Google AI API key for authentication. + model: Model name to use. Defaults to "gemini-2.0-flash". + params: Input parameters for the model. + system_instruction: System instruction/prompt for the model. + tools: List of available tools/functions. + tool_config: Configuration for tool usage. + **kwargs: Additional arguments passed to parent class. """ # Overriding the default adapter to use the Gemini one. adapter_class = GeminiLLMAdapter class InputParams(BaseModel): + """Input parameters for Google AI models. + + Parameters: + max_tokens: Maximum number of tokens to generate. + temperature: Sampling temperature between 0.0 and 2.0. + top_k: Top-k sampling parameter. + top_p: Top-p sampling parameter between 0.0 and 1.0. + extra: Additional parameters as a dictionary. + """ + max_tokens: Optional[int] = Field(default=4096, ge=1) temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0) top_k: Optional[int] = Field(default=None, ge=0) @@ -495,6 +624,11 @@ class GoogleLLMService(LLMService): self._tool_config = tool_config def can_generate_metrics(self) -> bool: + """Check if the service can generate usage metrics. + + Returns: + True, as Google AI provides token usage metrics. + """ return True def _create_client(self, api_key: str): @@ -653,6 +787,12 @@ class GoogleLLMService(LLMService): await self.push_frame(LLMFullResponseEndFrame()) async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process incoming frames and handle different frame types. + + Args: + frame: The frame to process. + direction: Direction of frame processing. + """ await super().process_frame(frame, direction) context = None @@ -681,16 +821,15 @@ class GoogleLLMService(LLMService): user_params: LLMUserAggregatorParams = LLMUserAggregatorParams(), assistant_params: LLMAssistantAggregatorParams = LLMAssistantAggregatorParams(), ) -> GoogleContextAggregatorPair: - """Create an instance of GoogleContextAggregatorPair from an - OpenAILLMContext. Constructor keyword arguments for both the user and - assistant aggregators can be provided. + """Create Google-specific context aggregators. + + Creates a pair of context aggregators optimized for Google's message format, + including support for function calls, tool usage, and image handling. Args: - context (OpenAILLMContext): The LLM context. - user_params (LLMUserAggregatorParams, optional): User aggregator - parameters. - assistant_params (LLMAssistantAggregatorParams, optional): User - aggregator parameters. + context: The LLM context to create aggregators for. + user_params: Parameters for user message aggregation. + assistant_params: Parameters for assistant message aggregation. Returns: GoogleContextAggregatorPair: A pair of context aggregators, one for From 166c8e8e82764fc0bce89e95b2affdcf8b9e15e7 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 10:39:46 -0400 Subject: [PATCH 37/54] Update GrokLLMService docstrings --- src/pipecat/services/grok/llm.py | 72 ++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/src/pipecat/services/grok/llm.py b/src/pipecat/services/grok/llm.py index a57434986..e7d817c5f 100644 --- a/src/pipecat/services/grok/llm.py +++ b/src/pipecat/services/grok/llm.py @@ -4,6 +4,13 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Grok LLM service implementation using OpenAI-compatible interface. + +This module provides a service for interacting with Grok's API through an +OpenAI-compatible interface, including specialized token usage tracking +and context aggregation functionality. +""" + from dataclasses import dataclass from loguru import logger @@ -23,13 +30,33 @@ from pipecat.services.openai.llm import ( @dataclass class GrokContextAggregatorPair: + """Pair of context aggregators for user and assistant interactions. + + Provides a convenient container for managing both user and assistant + context aggregators together for Grok LLM interactions. + + Parameters: + _user: The user context aggregator instance. + _assistant: The assistant context aggregator instance. + """ + _user: OpenAIUserContextAggregator _assistant: OpenAIAssistantContextAggregator def user(self) -> OpenAIUserContextAggregator: + """Get the user context aggregator. + + Returns: + The user context aggregator instance. + """ return self._user def assistant(self) -> OpenAIAssistantContextAggregator: + """Get the assistant context aggregator. + + Returns: + The assistant context aggregator instance. + """ return self._assistant @@ -38,12 +65,14 @@ class GrokLLMService(OpenAILLMService): This service extends OpenAILLMService to connect to Grok's API endpoint while maintaining full compatibility with OpenAI's interface and functionality. + Includes specialized token usage tracking that accumulates metrics during + processing and reports final totals. Args: - api_key (str): The API key for accessing Grok's API - base_url (str, optional): The base URL for Grok API. Defaults to "https://api.x.ai/v1" - model (str, optional): The model identifier to use. Defaults to "grok-3-beta" - **kwargs: Additional keyword arguments passed to OpenAILLMService + api_key: The API key for accessing Grok's API. + base_url: The base URL for Grok API. Defaults to "https://api.x.ai/v1". + model: The model identifier to use. Defaults to "grok-3-beta". + **kwargs: Additional keyword arguments passed to OpenAILLMService. """ def __init__( @@ -63,7 +92,16 @@ class GrokLLMService(OpenAILLMService): self._is_processing = False def create_client(self, api_key=None, base_url=None, **kwargs): - """Create OpenAI-compatible client for Grok API endpoint.""" + """Create OpenAI-compatible client for Grok API endpoint. + + Args: + api_key: The API key to use. If None, uses instance default. + base_url: The base URL to use. If None, uses instance default. + **kwargs: Additional arguments passed to client creation. + + Returns: + The configured client instance for Grok API. + """ logger.debug(f"Creating Grok client with api {base_url}") return super().create_client(api_key, base_url, **kwargs) @@ -75,8 +113,8 @@ class GrokLLMService(OpenAILLMService): them once at the end of processing. Args: - context (OpenAILLMContext): The context to process, containing messages - and other information needed for the LLM interaction. + context: The context to process, containing messages and other + information needed for the LLM interaction. """ # Reset all counters and flags at the start of processing self._prompt_tokens = 0 @@ -107,8 +145,8 @@ class GrokLLMService(OpenAILLMService): The final accumulated totals are reported at the end of processing. Args: - tokens (LLMTokenUsage): The token usage metrics for the current chunk - of processing, containing prompt_tokens and completion_tokens counts. + tokens: The token usage metrics for the current chunk of processing, + containing prompt_tokens and completion_tokens counts. """ # Only accumulate metrics during active processing if not self._is_processing: @@ -130,22 +168,20 @@ class GrokLLMService(OpenAILLMService): user_params: LLMUserAggregatorParams = LLMUserAggregatorParams(), assistant_params: LLMAssistantAggregatorParams = LLMAssistantAggregatorParams(), ) -> GrokContextAggregatorPair: - """Create an instance of GrokContextAggregatorPair from an - OpenAILLMContext. Constructor keyword arguments for both the user and - assistant aggregators can be provided. + """Create an instance of GrokContextAggregatorPair from an OpenAILLMContext. + + Constructor keyword arguments for both the user and assistant aggregators + can be provided. Args: - context (OpenAILLMContext): The LLM context. - user_params (LLMUserAggregatorParams, optional): User aggregator - parameters. - assistant_params (LLMAssistantAggregatorParams, optional): User - aggregator parameters. + context: The LLM context to create aggregators for. + user_params: Parameters for configuring the user aggregator. + assistant_params: Parameters for configuring the assistant aggregator. Returns: GrokContextAggregatorPair: A pair of context aggregators, one for the user and one for the assistant, encapsulated in an GrokContextAggregatorPair. - """ context.set_llm_adapter(self.get_llm_adapter()) From 79cca05e432302ee52337a266ad538d723068086 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 10:46:07 -0400 Subject: [PATCH 38/54] Update GroqLLMService docstrings --- src/pipecat/services/groq/llm.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/pipecat/services/groq/llm.py b/src/pipecat/services/groq/llm.py index be2ed5e72..e7edb4996 100644 --- a/src/pipecat/services/groq/llm.py +++ b/src/pipecat/services/groq/llm.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Groq LLM Service implementation using OpenAI-compatible interface.""" + from loguru import logger from pipecat.services.openai.llm import OpenAILLMService @@ -16,10 +18,10 @@ class GroqLLMService(OpenAILLMService): maintaining full compatibility with OpenAI's interface and functionality. Args: - api_key (str): The API key for accessing Groq's API - base_url (str, optional): The base URL for Groq API. Defaults to "https://api.groq.com/openai/v1" - model (str, optional): The model identifier to use. Defaults to "llama-3.3-70b-versatile" - **kwargs: Additional keyword arguments passed to OpenAILLMService + api_key: The API key for accessing Groq's API. + base_url: The base URL for Groq API. Defaults to "https://api.groq.com/openai/v1". + model: The model identifier to use. Defaults to "llama-3.3-70b-versatile". + **kwargs: Additional keyword arguments passed to OpenAILLMService. """ def __init__( @@ -33,6 +35,15 @@ class GroqLLMService(OpenAILLMService): super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) def create_client(self, api_key=None, base_url=None, **kwargs): - """Create OpenAI-compatible client for Groq API endpoint.""" + """Create OpenAI-compatible client for Groq API endpoint. + + Args: + api_key: API key for authentication. If None, uses instance api_key. + base_url: Base URL for the API. If None, uses instance base_url. + **kwargs: Additional arguments passed to the client constructor. + + Returns: + An OpenAI-compatible client configured for Groq's API. + """ logger.debug(f"Creating Groq client with api {base_url}") return super().create_client(api_key, base_url, **kwargs) From 56e2b006f5744e3cc0908884257b91a3134d7333 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 10:47:26 -0400 Subject: [PATCH 39/54] Update NimLLMService docstrings --- src/pipecat/services/nim/llm.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/pipecat/services/nim/llm.py b/src/pipecat/services/nim/llm.py index d57fa8d4c..a637a6602 100644 --- a/src/pipecat/services/nim/llm.py +++ b/src/pipecat/services/nim/llm.py @@ -4,6 +4,12 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""NVIDIA NIM API service implementation. + +This module provides a service for interacting with NVIDIA's NIM (NVIDIA Inference +Microservice) API while maintaining compatibility with the OpenAI-style interface. +""" + from pipecat.metrics.metrics import LLMTokenUsage from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext from pipecat.services.openai.llm import OpenAILLMService @@ -17,10 +23,10 @@ class NimLLMService(OpenAILLMService): in token usage reporting between NIM (incremental) and OpenAI (final summary). Args: - api_key (str): The API key for accessing NVIDIA's NIM API - base_url (str, optional): The base URL for NIM API. Defaults to "https://integrate.api.nvidia.com/v1" - model (str, optional): The model identifier to use. Defaults to "nvidia/llama-3.1-nemotron-70b-instruct" - **kwargs: Additional keyword arguments passed to OpenAILLMService + api_key: The API key for accessing NVIDIA's NIM API. + base_url: The base URL for NIM API. Defaults to "https://integrate.api.nvidia.com/v1". + model: The model identifier to use. Defaults to "nvidia/llama-3.1-nemotron-70b-instruct". + **kwargs: Additional keyword arguments passed to OpenAILLMService. """ def __init__( @@ -47,8 +53,8 @@ class NimLLMService(OpenAILLMService): them once at the end of processing. Args: - context (OpenAILLMContext): The context to process, containing messages - and other information needed for the LLM interaction. + context: The context to process, containing messages and other information + needed for the LLM interaction. """ # Reset all counters and flags at the start of processing self._prompt_tokens = 0 @@ -79,8 +85,8 @@ class NimLLMService(OpenAILLMService): The final accumulated totals are reported at the end of processing. Args: - tokens (LLMTokenUsage): The token usage metrics for the current chunk - of processing, containing prompt_tokens and completion_tokens counts. + tokens: The token usage metrics for the current chunk of processing, + containing prompt_tokens and completion_tokens counts. """ # Only accumulate metrics during active processing if not self._is_processing: From 8b8a37ae7cabd603658f52fdf22ea102eb75f8f3 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 10:48:19 -0400 Subject: [PATCH 40/54] Update OLLamaLLMService docstrings --- src/pipecat/services/ollama/llm.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/pipecat/services/ollama/llm.py b/src/pipecat/services/ollama/llm.py index bd1ac0d0d..9fc5ab840 100644 --- a/src/pipecat/services/ollama/llm.py +++ b/src/pipecat/services/ollama/llm.py @@ -4,9 +4,22 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""OLLama LLM service implementation for Pipecat AI framework.""" + from pipecat.services.openai.llm import OpenAILLMService class OLLamaLLMService(OpenAILLMService): + """OLLama LLM service that provides local language model capabilities. + + This service extends OpenAILLMService to work with locally hosted OLLama models, + providing a compatible interface for running large language models locally. + + Args: + model: The OLLama model to use. Defaults to "llama2". + base_url: The base URL for the OLLama API endpoint. + Defaults to "http://localhost:11434/v1". + """ + def __init__(self, *, model: str = "llama2", base_url: str = "http://localhost:11434/v1"): super().__init__(model=model, base_url=base_url, api_key="ollama") From 769f8c8f34ecd7d9f129e4185d750cb11fa81dd0 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 10:53:05 -0400 Subject: [PATCH 41/54] Update OpenPipeLLMService docstrings --- src/pipecat/services/openpipe/llm.py | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/pipecat/services/openpipe/llm.py b/src/pipecat/services/openpipe/llm.py index 2a2dd1d26..59dd543de 100644 --- a/src/pipecat/services/openpipe/llm.py +++ b/src/pipecat/services/openpipe/llm.py @@ -4,6 +4,12 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""OpenPipe LLM service implementation for Pipecat. + +This module provides an OpenPipe-specific implementation of the OpenAI LLM service, +enabling integration with OpenPipe's fine-tuning and monitoring capabilities. +""" + from typing import Dict, List, Optional from loguru import logger @@ -22,6 +28,22 @@ except ModuleNotFoundError as e: class OpenPipeLLMService(OpenAILLMService): + """OpenPipe-powered Large Language Model service. + + Extends OpenAI's LLM service to integrate with OpenPipe's fine-tuning and + monitoring platform. Provides enhanced request logging and tagging capabilities + for model training and evaluation. + + Args: + model: The model name to use. Defaults to "gpt-4.1". + api_key: OpenAI API key for authentication. If None, reads from environment. + base_url: Custom OpenAI API endpoint URL. Uses default if None. + openpipe_api_key: OpenPipe API key for enhanced features. If None, reads from environment. + openpipe_base_url: OpenPipe API endpoint URL. Defaults to "https://app.openpipe.ai/api/v1". + tags: Optional dictionary of tags to apply to all requests for tracking. + **kwargs: Additional arguments passed to parent OpenAILLMService. + """ + def __init__( self, *, @@ -44,6 +66,16 @@ class OpenPipeLLMService(OpenAILLMService): self._tags = tags def create_client(self, api_key=None, base_url=None, **kwargs): + """Create an OpenPipe client instance. + + Args: + api_key: OpenAI API key for authentication. + base_url: OpenAI API base URL. + **kwargs: Additional arguments including openpipe_api_key and openpipe_base_url. + + Returns: + Configured OpenPipe AsyncOpenAI client instance. + """ openpipe_api_key = kwargs.get("openpipe_api_key") or "" openpipe_base_url = kwargs.get("openpipe_base_url") or "" client = OpenPipeAI( @@ -56,6 +88,15 @@ class OpenPipeLLMService(OpenAILLMService): async def get_chat_completions( self, context: OpenAILLMContext, messages: List[ChatCompletionMessageParam] ) -> AsyncStream[ChatCompletionChunk]: + """Generate streaming chat completions with OpenPipe logging. + + Args: + context: The OpenAI LLM context containing conversation state. + messages: List of chat completion message parameters. + + Returns: + Async stream of chat completion chunks. + """ chunks = await self._client.chat.completions.create( model=self.model_name, stream=True, From 137282b7a9c584f3500e876a2de9ef40da61d546 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 10:53:42 -0400 Subject: [PATCH 42/54] Update OpenRouterLLMService docstrings --- src/pipecat/services/openrouter/llm.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/pipecat/services/openrouter/llm.py b/src/pipecat/services/openrouter/llm.py index 431724f94..85d1662fe 100644 --- a/src/pipecat/services/openrouter/llm.py +++ b/src/pipecat/services/openrouter/llm.py @@ -4,6 +4,12 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""OpenRouter LLM service implementation. + +This module provides an OpenAI-compatible interface for interacting with OpenRouter's API, +extending the base OpenAI LLM service functionality. +""" + from typing import Optional from loguru import logger @@ -18,10 +24,11 @@ class OpenRouterLLMService(OpenAILLMService): maintaining full compatibility with OpenAI's interface and functionality. Args: - api_key (str): The API key for accessing OpenRouter's API - base_url (str, optional): The base URL for OpenRouter API. Defaults to "https://openrouter.ai/api/v1" - model (str, optional): The model identifier to use. Defaults to "openai/gpt-4o-2024-11-20" - **kwargs: Additional keyword arguments passed to OpenAILLMService + api_key: The API key for accessing OpenRouter's API. If None, will attempt + to read from environment variables. + model: The model identifier to use. Defaults to "openai/gpt-4o-2024-11-20". + base_url: The base URL for OpenRouter API. Defaults to "https://openrouter.ai/api/v1". + **kwargs: Additional keyword arguments passed to OpenAILLMService. """ def __init__( @@ -40,5 +47,15 @@ class OpenRouterLLMService(OpenAILLMService): ) def create_client(self, api_key=None, base_url=None, **kwargs): + """Create an OpenRouter API client. + + Args: + api_key: The API key to use for authentication. If None, uses instance default. + base_url: The base URL for the API. If None, uses instance default. + **kwargs: Additional arguments passed to the parent client creation method. + + Returns: + The configured OpenRouter API client instance. + """ logger.debug(f"Creating OpenRouter client with api {base_url}") return super().create_client(api_key, base_url, **kwargs) From d7bfe54b7cadd4ab3168f027c950ad0755241e79 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 10:56:48 -0400 Subject: [PATCH 43/54] Update PerplexityLLMService docstrings --- src/pipecat/services/perplexity/llm.py | 28 +++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/pipecat/services/perplexity/llm.py b/src/pipecat/services/perplexity/llm.py index ff9f82bdb..049181cc9 100644 --- a/src/pipecat/services/perplexity/llm.py +++ b/src/pipecat/services/perplexity/llm.py @@ -4,6 +4,13 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Perplexity LLM service implementation. + +This module provides a service for interacting with Perplexity's API using +an OpenAI-compatible interface. It handles Perplexity's unique token usage +reporting patterns while maintaining compatibility with the Pipecat framework. +""" + from typing import List from openai import NOT_GIVEN, AsyncStream @@ -22,10 +29,10 @@ class PerplexityLLMService(OpenAILLMService): in token usage reporting between Perplexity (incremental) and OpenAI (final summary). Args: - api_key (str): The API key for accessing Perplexity's API - base_url (str, optional): The base URL for Perplexity's API. Defaults to "https://api.perplexity.ai" - model (str, optional): The model identifier to use. Defaults to "sonar" - **kwargs: Additional keyword arguments passed to OpenAILLMService + api_key: The API key for accessing Perplexity's API. + base_url: The base URL for Perplexity's API. Defaults to "https://api.perplexity.ai". + model: The model identifier to use. Defaults to "sonar". + **kwargs: Additional keyword arguments passed to OpenAILLMService. """ def __init__( @@ -50,11 +57,11 @@ class PerplexityLLMService(OpenAILLMService): """Get chat completions from Perplexity API using OpenAI-compatible parameters. Args: - context: The context containing conversation history and settings - messages: The messages to send to the API + context: The context containing conversation history and settings. + messages: The messages to send to the API. Returns: - A stream of chat completion chunks + A stream of chat completion chunks from the Perplexity API. """ params = { "model": self.model_name, @@ -85,8 +92,8 @@ class PerplexityLLMService(OpenAILLMService): and reporting them once at the end of processing. Args: - context (OpenAILLMContext): The context to process, containing messages - and other information needed for the LLM interaction. + context: The context to process, containing messages and other + information needed for the LLM interaction. """ # Reset all counters and flags at the start of processing self._prompt_tokens = 0 @@ -115,6 +122,9 @@ class PerplexityLLMService(OpenAILLMService): Perplexity reports token usage incrementally during streaming, unlike OpenAI which provides a final summary. We accumulate the counts and report the total at the end of processing. + + Args: + tokens: Token usage information to accumulate. """ if not self._is_processing: return From c018eb2f0e68f797c5c357f28fcffc778dad52f2 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 10:57:42 -0400 Subject: [PATCH 44/54] Update QwenLLMService docstrings --- src/pipecat/services/qwen/llm.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/pipecat/services/qwen/llm.py b/src/pipecat/services/qwen/llm.py index de910a741..2ffc6bc80 100644 --- a/src/pipecat/services/qwen/llm.py +++ b/src/pipecat/services/qwen/llm.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Qwen LLM service implementation using OpenAI-compatible interface.""" + from loguru import logger from pipecat.services.openai.llm import OpenAILLMService @@ -16,10 +18,10 @@ class QwenLLMService(OpenAILLMService): maintaining full compatibility with OpenAI's interface and functionality. Args: - api_key (str): The API key for accessing Qwen's API (DashScope API key) - base_url (str, optional): Base URL for Qwen API. Defaults to "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" - model (str, optional): The model identifier to use. Defaults to "qwen-plus". - **kwargs: Additional keyword arguments passed to OpenAILLMService + api_key: The API key for accessing Qwen's API (DashScope API key). + base_url: Base URL for Qwen API. Defaults to "https://dashscope-intl.aliyuncs.com/compatible-mode/v1". + model: The model identifier to use. Defaults to "qwen-plus". + **kwargs: Additional keyword arguments passed to OpenAILLMService. """ def __init__( @@ -34,6 +36,15 @@ class QwenLLMService(OpenAILLMService): logger.info(f"Initialized Qwen LLM service with model: {model}") def create_client(self, api_key=None, base_url=None, **kwargs): - """Create OpenAI-compatible client for Qwen API endpoint.""" + """Create OpenAI-compatible client for Qwen API endpoint. + + Args: + api_key: API key for authentication. If None, uses instance default. + base_url: Base URL for the API. If None, uses instance default. + **kwargs: Additional arguments passed to the parent client creation. + + Returns: + An OpenAI-compatible client configured for Qwen's API. + """ logger.debug(f"Creating Qwen client with base URL: {base_url}") return super().create_client(api_key, base_url, **kwargs) From efbf57461300212367b1ab96e765ab1ff0c245dc Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 11:00:40 -0400 Subject: [PATCH 45/54] Update SambaNovaLLMService docstrings --- src/pipecat/services/sambanova/llm.py | 41 +++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/src/pipecat/services/sambanova/llm.py b/src/pipecat/services/sambanova/llm.py index 3ca2ee5be..9e4cf47dc 100644 --- a/src/pipecat/services/sambanova/llm.py +++ b/src/pipecat/services/sambanova/llm.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""SambaNova LLM service implementation using OpenAI-compatible interface.""" + import json from typing import Any, Dict, List, Optional @@ -24,12 +26,14 @@ from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator class SambaNovaLLMService(OpenAILLMService): # type: ignore """A service for interacting with SambaNova using the OpenAI-compatible interface. + This service extends OpenAILLMService to connect to SambaNova's API endpoint while maintaining full compatibility with OpenAI's interface and functionality. + Args: - api_key (str): The API key for accessing SambaNova API. - model (str, optional): The model identifier to use. Defaults to "Meta-Llama-3.3-70B-Instruct". - base_url (str, optional): The base URL for SambaNova API. Defaults to "https://api.sambanova.ai/v1". + api_key: The API key for accessing SambaNova API. + model: The model identifier to use. Defaults to "Llama-4-Maverick-17B-128E-Instruct". + base_url: The base URL for SambaNova API. Defaults to "https://api.sambanova.ai/v1". **kwargs: Additional keyword arguments passed to OpenAILLMService. """ @@ -49,16 +53,31 @@ class SambaNovaLLMService(OpenAILLMService): # type: ignore base_url: Optional[str] = None, **kwargs: Dict[Any, Any], ) -> Any: - """Create OpenAI-compatible client for SambaNova API endpoint.""" + """Create OpenAI-compatible client for SambaNova API endpoint. + Args: + api_key: API key for authentication. If None, uses instance default. + base_url: Base URL for the API endpoint. If None, uses instance default. + **kwargs: Additional keyword arguments for client configuration. + + Returns: + Configured OpenAI-compatible client instance. + """ logger.debug(f"Creating SambaNova client with API {base_url}") return super().create_client(api_key, base_url, **kwargs) async def get_chat_completions( self, context: OpenAILLMContext, messages: List[ChatCompletionMessageParam] ) -> Any: - """Get chat completions from SambaNova API endpoint.""" + """Get chat completions from SambaNova API endpoint. + Args: + context: OpenAI LLM context containing tools and configuration. + messages: List of chat completion message parameters. + + Returns: + Chat completion response stream from SambaNova API. + """ params = { "model": self.model_name, "stream": True, @@ -79,8 +98,18 @@ class SambaNovaLLMService(OpenAILLMService): # type: ignore @traced_llm # type: ignore async def _process_context(self, context: OpenAILLMContext) -> AsyncStream[ChatCompletionChunk]: - """Redefine this method until SambaNova API introduces indexing in tool calls.""" + """Process OpenAI LLM context and stream chat completion chunks. + This method handles the streaming response from SambaNova API, including + function call processing and text frame generation. It includes special + handling for SambaNova's API limitations with tool call indexing. + + Args: + context: OpenAI LLM context containing conversation state and tools. + + Returns: + Async stream of chat completion chunks. + """ functions_list = [] arguments_list = [] tool_id_list = [] From 2856372ad661cff947ba0a95e078f882201c76c6 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 11:01:35 -0400 Subject: [PATCH 46/54] Update TogetherLLMService docstrings --- src/pipecat/services/together/llm.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/pipecat/services/together/llm.py b/src/pipecat/services/together/llm.py index 31b15ae73..e445be676 100644 --- a/src/pipecat/services/together/llm.py +++ b/src/pipecat/services/together/llm.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Together.ai LLM service implementation using OpenAI-compatible interface.""" + from loguru import logger from pipecat.services.openai.llm import OpenAILLMService @@ -16,10 +18,10 @@ class TogetherLLMService(OpenAILLMService): maintaining full compatibility with OpenAI's interface and functionality. Args: - api_key (str): The API key for accessing Together.ai's API - base_url (str, optional): The base URL for Together.ai API. Defaults to "https://api.together.xyz/v1" - model (str, optional): The model identifier to use. Defaults to "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo" - **kwargs: Additional keyword arguments passed to OpenAILLMService + api_key: The API key for accessing Together.ai's API. + base_url: The base URL for Together.ai API. Defaults to "https://api.together.xyz/v1". + model: The model identifier to use. Defaults to "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo". + **kwargs: Additional keyword arguments passed to OpenAILLMService. """ def __init__( @@ -33,6 +35,15 @@ class TogetherLLMService(OpenAILLMService): super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) def create_client(self, api_key=None, base_url=None, **kwargs): - """Create OpenAI-compatible client for Together.ai API endpoint.""" + """Create OpenAI-compatible client for Together.ai API endpoint. + + Args: + api_key: The API key to use for the client. If None, uses instance api_key. + base_url: The base URL for the API. If None, uses instance base_url. + **kwargs: Additional keyword arguments passed to the parent create_client method. + + Returns: + An OpenAI-compatible client configured for Together.ai's API. + """ logger.debug(f"Creating Together.ai client with api {base_url}") return super().create_client(api_key, base_url, **kwargs) From 9e518cf2baced2ef5f24d14a22cc0419146fb7a5 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 11:21:18 -0400 Subject: [PATCH 47/54] Update AWSNovaSonicLLMService docstrings --- src/pipecat/services/aws_nova_sonic/aws.py | 97 +++++++++++++ .../services/aws_nova_sonic/context.py | 131 ++++++++++++++++++ src/pipecat/services/aws_nova_sonic/frames.py | 11 ++ 3 files changed, 239 insertions(+) diff --git a/src/pipecat/services/aws_nova_sonic/aws.py b/src/pipecat/services/aws_nova_sonic/aws.py index 93eb77e90..c6ee1d6c7 100644 --- a/src/pipecat/services/aws_nova_sonic/aws.py +++ b/src/pipecat/services/aws_nova_sonic/aws.py @@ -4,6 +4,12 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""AWS Nova Sonic LLM service implementation for Pipecat AI framework. + +This module provides a speech-to-speech LLM service using AWS Nova Sonic, which supports +bidirectional audio streaming, text generation, and function calling capabilities. +""" + import asyncio import base64 import json @@ -83,22 +89,37 @@ except ModuleNotFoundError as e: class AWSNovaSonicUnhandledFunctionException(Exception): + """Exception raised when the LLM attempts to call an unregistered function.""" + pass class ContentType(Enum): + """Content types supported by AWS Nova Sonic.""" + AUDIO = "AUDIO" TEXT = "TEXT" TOOL = "TOOL" class TextStage(Enum): + """Text generation stages in AWS Nova Sonic responses.""" + FINAL = "FINAL" # what has been said SPECULATIVE = "SPECULATIVE" # what's planned to be said @dataclass class CurrentContent: + """Represents content currently being received from AWS Nova Sonic. + + Parameters: + type: The type of content (audio, text, or tool). + role: The role generating the content (user, assistant, etc.). + text_stage: The stage of text generation (final or speculative). + text_content: The actual text content if applicable. + """ + type: ContentType role: Role text_stage: TextStage # None if not text @@ -115,6 +136,20 @@ class CurrentContent: class Params(BaseModel): + """Configuration parameters for AWS Nova Sonic. + + Attributes: + input_sample_rate: Audio input sample rate in Hz. + input_sample_size: Audio input sample size in bits. + input_channel_count: Number of input audio channels. + output_sample_rate: Audio output sample rate in Hz. + output_sample_size: Audio output sample size in bits. + output_channel_count: Number of output audio channels. + max_tokens: Maximum number of tokens to generate. + top_p: Nucleus sampling parameter. + temperature: Sampling temperature for text generation. + """ + # Audio input input_sample_rate: Optional[int] = Field(default=16000) input_sample_size: Optional[int] = Field(default=16) @@ -132,6 +167,24 @@ class Params(BaseModel): class AWSNovaSonicLLMService(LLMService): + """AWS Nova Sonic speech-to-speech LLM service. + + Provides bidirectional audio streaming, real-time transcription, text generation, + and function calling capabilities using AWS Nova Sonic model. + + Args: + secret_access_key: AWS secret access key for authentication. + access_key_id: AWS access key ID for authentication. + region: AWS region where the service is hosted. + model: Model identifier. Defaults to "amazon.nova-sonic-v1:0". + voice_id: Voice ID for speech synthesis. Options: matthew, tiffany, amy. + params: Model parameters for audio configuration and inference. + system_instruction: System-level instruction for the model. + tools: Available tools/functions for the model to use. + send_transcription_frames: Whether to emit transcription frames. + **kwargs: Additional arguments passed to the parent LLMService. + """ + # Override the default adapter to use the AWSNovaSonicLLMAdapter one adapter_class = AWSNovaSonicLLMAdapter @@ -188,16 +241,31 @@ class AWSNovaSonicLLMService(LLMService): # async def start(self, frame: StartFrame): + """Start the service and initiate connection to AWS Nova Sonic. + + Args: + frame: The start frame triggering service initialization. + """ await super().start(frame) self._wants_connection = True await self._start_connecting() async def stop(self, frame: EndFrame): + """Stop the service and close connections. + + Args: + frame: The end frame triggering service shutdown. + """ await super().stop(frame) self._wants_connection = False await self._disconnect() async def cancel(self, frame: CancelFrame): + """Cancel the service and close connections. + + Args: + frame: The cancel frame triggering service cancellation. + """ await super().cancel(frame) self._wants_connection = False await self._disconnect() @@ -207,6 +275,11 @@ class AWSNovaSonicLLMService(LLMService): # async def reset_conversation(self): + """Reset the conversation state while preserving context. + + Handles bot stopped speaking event, disconnects from the service, + and reconnects with the preserved context. + """ logger.debug("Resetting conversation") await self._handle_bot_stopped_speaking(delay_to_catch_trailing_assistant_text=False) @@ -222,6 +295,12 @@ class AWSNovaSonicLLMService(LLMService): # async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process incoming frames and handle service-specific logic. + + Args: + frame: The frame to process. + direction: The direction the frame is traveling. + """ await super().process_frame(frame, direction) if isinstance(frame, OpenAILLMContextFrame): @@ -960,6 +1039,16 @@ class AWSNovaSonicLLMService(LLMService): user_params: LLMUserAggregatorParams = LLMUserAggregatorParams(), assistant_params: LLMAssistantAggregatorParams = LLMAssistantAggregatorParams(), ) -> AWSNovaSonicContextAggregatorPair: + """Create context aggregator pair for managing conversation context. + + Args: + context: The OpenAI LLM context to upgrade. + user_params: Parameters for the user context aggregator. + assistant_params: Parameters for the assistant context aggregator. + + Returns: + A pair of user and assistant context aggregators. + """ context.set_llm_adapter(self.get_llm_adapter()) user = AWSNovaSonicUserContextAggregator(context=context, params=user_params) @@ -978,6 +1067,14 @@ class AWSNovaSonicLLMService(LLMService): ) async def trigger_assistant_response(self): + """Trigger an assistant response by sending audio cue. + + Sends a pre-recorded "ready" audio trigger to prompt the assistant + to start speaking. This is useful for controlling conversation flow. + + Returns: + False if already triggering a response, True otherwise. + """ if self._triggering_assistant_response: return False diff --git a/src/pipecat/services/aws_nova_sonic/context.py b/src/pipecat/services/aws_nova_sonic/context.py index 95f330f61..327da4e40 100644 --- a/src/pipecat/services/aws_nova_sonic/context.py +++ b/src/pipecat/services/aws_nova_sonic/context.py @@ -4,6 +4,12 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Context management for AWS Nova Sonic LLM service. + +This module provides specialized context aggregators and message handling for AWS Nova Sonic, +including conversation history management and role-specific message processing. +""" + import copy from dataclasses import dataclass, field from enum import Enum @@ -35,6 +41,8 @@ from pipecat.services.openai.llm import ( class Role(Enum): + """Roles supported in AWS Nova Sonic conversations.""" + SYSTEM = "SYSTEM" USER = "USER" ASSISTANT = "ASSISTANT" @@ -43,17 +51,42 @@ class Role(Enum): @dataclass class AWSNovaSonicConversationHistoryMessage: + """A single message in AWS Nova Sonic conversation history. + + Parameters: + role: The role of the message sender (USER or ASSISTANT only). + text: The text content of the message. + """ + role: Role # only USER and ASSISTANT text: str @dataclass class AWSNovaSonicConversationHistory: + """Complete conversation history for AWS Nova Sonic initialization. + + Parameters: + system_instruction: System-level instruction for the conversation. + messages: List of conversation messages between user and assistant. + """ + system_instruction: str = None messages: list[AWSNovaSonicConversationHistoryMessage] = field(default_factory=list) class AWSNovaSonicLLMContext(OpenAILLMContext): + """Specialized LLM context for AWS Nova Sonic service. + + Extends OpenAI context with Nova Sonic-specific message handling, + conversation history management, and text buffering capabilities. + + Args: + messages: Initial messages for the context. + tools: Available tools for the context. + **kwargs: Additional arguments passed to parent class. + """ + def __init__(self, messages=None, tools=None, **kwargs): super().__init__(messages=messages, tools=tools, **kwargs) self.__setup_local() @@ -67,6 +100,15 @@ class AWSNovaSonicLLMContext(OpenAILLMContext): def upgrade_to_nova_sonic( obj: OpenAILLMContext, system_instruction: str ) -> "AWSNovaSonicLLMContext": + """Upgrade an OpenAI context to AWS Nova Sonic context. + + Args: + obj: The OpenAI context to upgrade. + system_instruction: System instruction for the context. + + Returns: + The upgraded AWS Nova Sonic context. + """ if isinstance(obj, OpenAILLMContext) and not isinstance(obj, AWSNovaSonicLLMContext): obj.__class__ = AWSNovaSonicLLMContext obj.__setup_local(system_instruction) @@ -74,6 +116,14 @@ class AWSNovaSonicLLMContext(OpenAILLMContext): # NOTE: this method has the side-effect of updating _system_instruction from messages def get_messages_for_initializing_history(self) -> AWSNovaSonicConversationHistory: + """Get conversation history for initializing AWS Nova Sonic session. + + Processes stored messages and extracts system instruction and conversation + history in the format expected by AWS Nova Sonic. + + Returns: + Formatted conversation history with system instruction and messages. + """ history = AWSNovaSonicConversationHistory(system_instruction=self._system_instruction) # Bail if there are no messages @@ -103,6 +153,11 @@ class AWSNovaSonicLLMContext(OpenAILLMContext): return history def get_messages_for_persistent_storage(self): + """Get messages formatted for persistent storage. + + Returns: + List of messages including system instruction if present. + """ messages = super().get_messages_for_persistent_storage() # If we have a system instruction and messages doesn't already contain it, add it if self._system_instruction and not (messages and messages[0].get("role") == "system"): @@ -110,6 +165,14 @@ class AWSNovaSonicLLMContext(OpenAILLMContext): return messages def from_standard_message(self, message) -> AWSNovaSonicConversationHistoryMessage: + """Convert standard message format to Nova Sonic format. + + Args: + message: Standard message dictionary to convert. + + Returns: + Nova Sonic conversation history message, or None if not convertible. + """ role = message.get("role") if message.get("role") == "user" or message.get("role") == "assistant": content = message.get("content") @@ -131,10 +194,20 @@ class AWSNovaSonicLLMContext(OpenAILLMContext): # Sonic conversation history def buffer_user_text(self, text): + """Buffer user text for later flushing to context. + + Args: + text: User text to buffer. + """ self._user_text += f" {text}" if self._user_text else text # logger.debug(f"User text buffered: {self._user_text}") def flush_aggregated_user_text(self) -> str: + """Flush buffered user text to context as a complete message. + + Returns: + The flushed user text, or empty string if no text was buffered. + """ if not self._user_text: return "" user_text = self._user_text @@ -148,10 +221,16 @@ class AWSNovaSonicLLMContext(OpenAILLMContext): return user_text def buffer_assistant_text(self, text): + """Buffer assistant text for later flushing to context. + + Args: + text: Assistant text to buffer. + """ self._assistant_text += text # logger.debug(f"Assistant text buffered: {self._assistant_text}") def flush_aggregated_assistant_text(self): + """Flush buffered assistant text to context as a complete message.""" if not self._assistant_text: return message = { @@ -165,13 +244,31 @@ class AWSNovaSonicLLMContext(OpenAILLMContext): @dataclass class AWSNovaSonicMessagesUpdateFrame(DataFrame): + """Frame containing updated AWS Nova Sonic context. + + Parameters: + context: The updated AWS Nova Sonic LLM context. + """ + context: AWSNovaSonicLLMContext class AWSNovaSonicUserContextAggregator(OpenAIUserContextAggregator): + """Context aggregator for user messages in AWS Nova Sonic conversations. + + Extends the OpenAI user context aggregator to emit Nova Sonic-specific + context update frames. + """ + async def process_frame( self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM ): + """Process frames and emit Nova Sonic-specific context updates. + + Args: + frame: The frame to process. + direction: The direction the frame is traveling. + """ await super().process_frame(frame, direction) # Parent does not push LLMMessagesUpdateFrame @@ -180,7 +277,19 @@ class AWSNovaSonicUserContextAggregator(OpenAIUserContextAggregator): class AWSNovaSonicAssistantContextAggregator(OpenAIAssistantContextAggregator): + """Context aggregator for assistant messages in AWS Nova Sonic conversations. + + Provides specialized handling for assistant responses and function calls + in AWS Nova Sonic context, with custom frame processing logic. + """ + async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames with Nova Sonic-specific logic. + + Args: + frame: The frame to process. + direction: The direction the frame is traveling. + """ # HACK: For now, disable the context aggregator by making it just pass through all frames # that the parent handles (except the function call stuff, which we still need). # For an explanation of this hack, see @@ -205,6 +314,11 @@ class AWSNovaSonicAssistantContextAggregator(OpenAIAssistantContextAggregator): await super().process_frame(frame, direction) async def handle_function_call_result(self, frame: FunctionCallResultFrame): + """Handle function call results for AWS Nova Sonic. + + Args: + frame: The function call result frame to handle. + """ await super().handle_function_call_result(frame) # The standard function callback code path pushes the FunctionCallResultFrame from the LLM @@ -217,11 +331,28 @@ class AWSNovaSonicAssistantContextAggregator(OpenAIAssistantContextAggregator): @dataclass class AWSNovaSonicContextAggregatorPair: + """Pair of user and assistant context aggregators for AWS Nova Sonic. + + Parameters: + _user: The user context aggregator. + _assistant: The assistant context aggregator. + """ + _user: AWSNovaSonicUserContextAggregator _assistant: AWSNovaSonicAssistantContextAggregator def user(self) -> AWSNovaSonicUserContextAggregator: + """Get the user context aggregator. + + Returns: + The user context aggregator instance. + """ return self._user def assistant(self) -> AWSNovaSonicAssistantContextAggregator: + """Get the assistant context aggregator. + + Returns: + The assistant context aggregator instance. + """ return self._assistant diff --git a/src/pipecat/services/aws_nova_sonic/frames.py b/src/pipecat/services/aws_nova_sonic/frames.py index 94d410f22..7d4feb2ae 100644 --- a/src/pipecat/services/aws_nova_sonic/frames.py +++ b/src/pipecat/services/aws_nova_sonic/frames.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Custom frames for AWS Nova Sonic LLM service.""" + from dataclasses import dataclass from pipecat.frames.frames import DataFrame, FunctionCallResultFrame @@ -11,4 +13,13 @@ from pipecat.frames.frames import DataFrame, FunctionCallResultFrame @dataclass class AWSNovaSonicFunctionCallResultFrame(DataFrame): + """Frame containing function call result for AWS Nova Sonic processing. + + This frame wraps a standard function call result frame to enable + AWS Nova Sonic-specific handling and context updates. + + Parameters: + result_frame: The underlying function call result frame. + """ + result_frame: FunctionCallResultFrame From b860e945826aad7de1ac7acae1a9bed64e38f387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Wed, 25 Jun 2025 22:03:13 -0700 Subject: [PATCH 48/54] move things to new utils.asyncio package --- src/pipecat/pipeline/parallel_pipeline.py | 2 +- src/pipecat/pipeline/sync_parallel_pipeline.py | 2 +- src/pipecat/pipeline/task.py | 11 ++++++++--- src/pipecat/pipeline/task_observer.py | 6 +++--- src/pipecat/processors/consumer_processor.py | 2 +- src/pipecat/processors/frame_processor.py | 8 ++++---- src/pipecat/processors/frameworks/rtvi.py | 2 +- .../processors/metrics/frame_processor_metrics.py | 2 +- src/pipecat/processors/metrics/sentry.py | 6 +++--- src/pipecat/processors/producer_processor.py | 2 +- src/pipecat/services/anthropic/llm.py | 2 +- src/pipecat/services/cartesia/tts.py | 2 +- src/pipecat/services/elevenlabs/tts.py | 2 +- src/pipecat/services/gemini_multimodal_live/gemini.py | 2 +- src/pipecat/services/gladia/stt.py | 2 +- src/pipecat/services/google/llm.py | 2 +- src/pipecat/services/google/llm_openai.py | 2 +- src/pipecat/services/google/stt.py | 2 +- src/pipecat/services/neuphonic/tts.py | 2 +- src/pipecat/services/openai/base_llm.py | 2 +- src/pipecat/services/openai_realtime_beta/openai.py | 2 +- src/pipecat/services/riva/stt.py | 2 +- src/pipecat/services/sambanova/llm.py | 2 +- src/pipecat/services/simli/video.py | 2 +- src/pipecat/services/tavus/video.py | 2 +- src/pipecat/services/tts_service.py | 2 +- src/pipecat/transports/base_output.py | 2 +- src/pipecat/transports/network/fastapi_websocket.py | 2 +- src/pipecat/transports/network/small_webrtc.py | 2 +- src/pipecat/transports/network/websocket_client.py | 2 +- src/pipecat/transports/services/daily.py | 6 +++--- src/pipecat/transports/services/livekit.py | 4 ++-- src/pipecat/utils/asyncio/__init__.py | 0 .../utils/{asyncio.py => asyncio/task_manager.py} | 0 .../utils/{ => asyncio}/watchdog_async_iterator.py | 2 +- src/pipecat/utils/{ => asyncio}/watchdog_event.py | 2 +- .../utils/{ => asyncio}/watchdog_priority_queue.py | 2 +- src/pipecat/utils/{ => asyncio}/watchdog_queue.py | 2 +- src/pipecat/utils/{ => asyncio}/watchdog_reseter.py | 0 39 files changed, 53 insertions(+), 48 deletions(-) create mode 100644 src/pipecat/utils/asyncio/__init__.py rename src/pipecat/utils/{asyncio.py => asyncio/task_manager.py} (100%) rename src/pipecat/utils/{ => asyncio}/watchdog_async_iterator.py (97%) rename src/pipecat/utils/{ => asyncio}/watchdog_event.py (94%) rename src/pipecat/utils/{ => asyncio}/watchdog_priority_queue.py (95%) rename src/pipecat/utils/{ => asyncio}/watchdog_queue.py (95%) rename src/pipecat/utils/{ => asyncio}/watchdog_reseter.py (100%) diff --git a/src/pipecat/pipeline/parallel_pipeline.py b/src/pipecat/pipeline/parallel_pipeline.py index f6ac78827..7068ed86d 100644 --- a/src/pipecat/pipeline/parallel_pipeline.py +++ b/src/pipecat/pipeline/parallel_pipeline.py @@ -21,7 +21,7 @@ from pipecat.frames.frames import ( from pipecat.pipeline.base_pipeline import BasePipeline from pipecat.pipeline.pipeline import Pipeline from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, FrameProcessorSetup -from pipecat.utils.watchdog_queue import WatchdogQueue +from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue class ParallelPipelineSource(FrameProcessor): diff --git a/src/pipecat/pipeline/sync_parallel_pipeline.py b/src/pipecat/pipeline/sync_parallel_pipeline.py index f78ca0de3..6b178bc1c 100644 --- a/src/pipecat/pipeline/sync_parallel_pipeline.py +++ b/src/pipecat/pipeline/sync_parallel_pipeline.py @@ -15,7 +15,7 @@ from pipecat.frames.frames import ControlFrame, EndFrame, Frame, SystemFrame from pipecat.pipeline.base_pipeline import BasePipeline from pipecat.pipeline.pipeline import Pipeline from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, FrameProcessorSetup -from pipecat.utils.watchdog_queue import WatchdogQueue +from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue @dataclass diff --git a/src/pipecat/pipeline/task.py b/src/pipecat/pipeline/task.py index 68b76401e..8b30d1a4e 100644 --- a/src/pipecat/pipeline/task.py +++ b/src/pipecat/pipeline/task.py @@ -38,11 +38,16 @@ from pipecat.pipeline.base_pipeline import BasePipeline from pipecat.pipeline.base_task import BasePipelineTask, PipelineTaskParams from pipecat.pipeline.task_observer import TaskObserver from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, FrameProcessorSetup -from pipecat.utils.asyncio import WATCHDOG_TIMEOUT, BaseTaskManager, TaskManager, TaskManagerParams +from pipecat.utils.asyncio.task_manager import ( + WATCHDOG_TIMEOUT, + BaseTaskManager, + TaskManager, + TaskManagerParams, +) +from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue +from pipecat.utils.asyncio.watchdog_reseter import WatchdogReseter from pipecat.utils.tracing.setup import is_tracing_available from pipecat.utils.tracing.turn_trace_observer import TurnTraceObserver -from pipecat.utils.watchdog_queue import WatchdogQueue -from pipecat.utils.watchdog_reseter import WatchdogReseter HEARTBEAT_SECONDS = 1.0 HEARTBEAT_MONITOR_SECONDS = HEARTBEAT_SECONDS * 10 diff --git a/src/pipecat/pipeline/task_observer.py b/src/pipecat/pipeline/task_observer.py index 40d4ef3ed..ae4decbce 100644 --- a/src/pipecat/pipeline/task_observer.py +++ b/src/pipecat/pipeline/task_observer.py @@ -11,9 +11,9 @@ from typing import Dict, List, Optional from attr import dataclass from pipecat.observers.base_observer import BaseObserver, FramePushed -from pipecat.utils.asyncio import BaseTaskManager -from pipecat.utils.watchdog_queue import WatchdogQueue -from pipecat.utils.watchdog_reseter import WatchdogReseter +from pipecat.utils.asyncio.task_manager import BaseTaskManager +from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue +from pipecat.utils.asyncio.watchdog_reseter import WatchdogReseter @dataclass diff --git a/src/pipecat/processors/consumer_processor.py b/src/pipecat/processors/consumer_processor.py index 10cae11a3..0440a74e1 100644 --- a/src/pipecat/processors/consumer_processor.py +++ b/src/pipecat/processors/consumer_processor.py @@ -10,7 +10,7 @@ from typing import Awaitable, Callable, Optional from pipecat.frames.frames import CancelFrame, EndFrame, Frame, StartFrame from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.producer_processor import ProducerProcessor, identity_transformer -from pipecat.utils.watchdog_queue import WatchdogQueue +from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue class ConsumerProcessor(FrameProcessor): diff --git a/src/pipecat/processors/frame_processor.py b/src/pipecat/processors/frame_processor.py index 134a69da7..260e549fa 100644 --- a/src/pipecat/processors/frame_processor.py +++ b/src/pipecat/processors/frame_processor.py @@ -29,11 +29,11 @@ from pipecat.frames.frames import ( from pipecat.metrics.metrics import LLMTokenUsage, MetricsData from pipecat.observers.base_observer import BaseObserver, FramePushed from pipecat.processors.metrics.frame_processor_metrics import FrameProcessorMetrics -from pipecat.utils.asyncio import BaseTaskManager +from pipecat.utils.asyncio.task_manager import BaseTaskManager +from pipecat.utils.asyncio.watchdog_event import WatchdogEvent +from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue +from pipecat.utils.asyncio.watchdog_reseter import WatchdogReseter from pipecat.utils.base_object import BaseObject -from pipecat.utils.watchdog_event import WatchdogEvent -from pipecat.utils.watchdog_queue import WatchdogQueue -from pipecat.utils.watchdog_reseter import WatchdogReseter class FrameDirection(Enum): diff --git a/src/pipecat/processors/frameworks/rtvi.py b/src/pipecat/processors/frameworks/rtvi.py index 1646a9fa6..b379e522a 100644 --- a/src/pipecat/processors/frameworks/rtvi.py +++ b/src/pipecat/processors/frameworks/rtvi.py @@ -67,8 +67,8 @@ from pipecat.services.llm_service import ( from pipecat.transports.base_input import BaseInputTransport from pipecat.transports.base_output import BaseOutputTransport from pipecat.transports.base_transport import BaseTransport +from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue from pipecat.utils.string import match_endofsentence -from pipecat.utils.watchdog_queue import WatchdogQueue RTVI_PROTOCOL_VERSION = "0.3.0" diff --git a/src/pipecat/processors/metrics/frame_processor_metrics.py b/src/pipecat/processors/metrics/frame_processor_metrics.py index 9ee1ccdd3..ec1501122 100644 --- a/src/pipecat/processors/metrics/frame_processor_metrics.py +++ b/src/pipecat/processors/metrics/frame_processor_metrics.py @@ -18,7 +18,7 @@ from pipecat.metrics.metrics import ( TTFBMetricsData, TTSUsageMetricsData, ) -from pipecat.utils.asyncio import TaskManager +from pipecat.utils.asyncio.task_manager import TaskManager from pipecat.utils.base_object import BaseObject diff --git a/src/pipecat/processors/metrics/sentry.py b/src/pipecat/processors/metrics/sentry.py index 083ff621b..b19e9aa04 100644 --- a/src/pipecat/processors/metrics/sentry.py +++ b/src/pipecat/processors/metrics/sentry.py @@ -8,9 +8,9 @@ import asyncio from loguru import logger -from pipecat.utils.asyncio import TaskManager -from pipecat.utils.watchdog_queue import WatchdogQueue -from pipecat.utils.watchdog_reseter import WatchdogReseter +from pipecat.utils.asyncio.task_manager import TaskManager +from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue +from pipecat.utils.asyncio.watchdog_reseter import WatchdogReseter try: import sentry_sdk diff --git a/src/pipecat/processors/producer_processor.py b/src/pipecat/processors/producer_processor.py index ad08802e2..e00ac6e84 100644 --- a/src/pipecat/processors/producer_processor.py +++ b/src/pipecat/processors/producer_processor.py @@ -9,7 +9,7 @@ from typing import Awaitable, Callable, List from pipecat.frames.frames import Frame from pipecat.processors.frame_processor import FrameDirection, FrameProcessor -from pipecat.utils.watchdog_queue import WatchdogQueue +from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue async def identity_transformer(frame: Frame): diff --git a/src/pipecat/services/anthropic/llm.py b/src/pipecat/services/anthropic/llm.py index b5334c383..246efcf26 100644 --- a/src/pipecat/services/anthropic/llm.py +++ b/src/pipecat/services/anthropic/llm.py @@ -46,8 +46,8 @@ from pipecat.processors.aggregators.openai_llm_context import ( ) from pipecat.processors.frame_processor import FrameDirection from pipecat.services.llm_service import FunctionCallFromLLM, LLMService +from pipecat.utils.asyncio.watchdog_async_iterator import WatchdogAsyncIterator from pipecat.utils.tracing.service_decorators import traced_llm -from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator try: from anthropic import NOT_GIVEN, AsyncAnthropic, NotGiven diff --git a/src/pipecat/services/cartesia/tts.py b/src/pipecat/services/cartesia/tts.py index ac65db692..2309c9d8c 100644 --- a/src/pipecat/services/cartesia/tts.py +++ b/src/pipecat/services/cartesia/tts.py @@ -29,10 +29,10 @@ from pipecat.frames.frames import ( from pipecat.processors.frame_processor import FrameDirection from pipecat.services.tts_service import AudioContextWordTTSService, TTSService from pipecat.transcriptions.language import Language +from pipecat.utils.asyncio.watchdog_async_iterator import WatchdogAsyncIterator from pipecat.utils.text.base_text_aggregator import BaseTextAggregator from pipecat.utils.text.skip_tags_aggregator import SkipTagsAggregator from pipecat.utils.tracing.service_decorators import traced_tts -from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator # See .env.example for Cartesia configuration needed try: diff --git a/src/pipecat/services/elevenlabs/tts.py b/src/pipecat/services/elevenlabs/tts.py index 7665632fc..a8c4ae0c9 100644 --- a/src/pipecat/services/elevenlabs/tts.py +++ b/src/pipecat/services/elevenlabs/tts.py @@ -32,8 +32,8 @@ from pipecat.services.tts_service import ( WordTTSService, ) from pipecat.transcriptions.language import Language +from pipecat.utils.asyncio.watchdog_async_iterator import WatchdogAsyncIterator from pipecat.utils.tracing.service_decorators import traced_tts -from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator # See .env.example for ElevenLabs configuration needed try: diff --git a/src/pipecat/services/gemini_multimodal_live/gemini.py b/src/pipecat/services/gemini_multimodal_live/gemini.py index c713c3cab..8424a8625 100644 --- a/src/pipecat/services/gemini_multimodal_live/gemini.py +++ b/src/pipecat/services/gemini_multimodal_live/gemini.py @@ -58,10 +58,10 @@ from pipecat.services.openai.llm import ( OpenAIUserContextAggregator, ) from pipecat.transcriptions.language import Language +from pipecat.utils.asyncio.watchdog_async_iterator import WatchdogAsyncIterator from pipecat.utils.string import match_endofsentence from pipecat.utils.time import time_now_iso8601 from pipecat.utils.tracing.service_decorators import traced_gemini_live, traced_stt -from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator from . import events diff --git a/src/pipecat/services/gladia/stt.py b/src/pipecat/services/gladia/stt.py index 75e8bbee3..b0ba81470 100644 --- a/src/pipecat/services/gladia/stt.py +++ b/src/pipecat/services/gladia/stt.py @@ -25,9 +25,9 @@ from pipecat.frames.frames import ( from pipecat.services.gladia.config import GladiaInputParams from pipecat.services.stt_service import STTService from pipecat.transcriptions.language import Language +from pipecat.utils.asyncio.watchdog_async_iterator import WatchdogAsyncIterator from pipecat.utils.time import time_now_iso8601 from pipecat.utils.tracing.service_decorators import traced_stt -from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator try: import websockets diff --git a/src/pipecat/services/google/llm.py b/src/pipecat/services/google/llm.py index 5fe005fbd..a7d9b018d 100644 --- a/src/pipecat/services/google/llm.py +++ b/src/pipecat/services/google/llm.py @@ -47,8 +47,8 @@ from pipecat.services.openai.llm import ( OpenAIAssistantContextAggregator, OpenAIUserContextAggregator, ) +from pipecat.utils.asyncio.watchdog_async_iterator import WatchdogAsyncIterator from pipecat.utils.tracing.service_decorators import traced_llm -from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator # Suppress gRPC fork warnings os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false" diff --git a/src/pipecat/services/google/llm_openai.py b/src/pipecat/services/google/llm_openai.py index e76ac1886..af49fa6e0 100644 --- a/src/pipecat/services/google/llm_openai.py +++ b/src/pipecat/services/google/llm_openai.py @@ -11,7 +11,7 @@ from openai import AsyncStream from openai.types.chat import ChatCompletionChunk from pipecat.services.llm_service import FunctionCallFromLLM -from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator +from pipecat.utils.asyncio.watchdog_async_iterator import WatchdogAsyncIterator # Suppress gRPC fork warnings os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false" diff --git a/src/pipecat/services/google/stt.py b/src/pipecat/services/google/stt.py index 80f061b44..157810dd7 100644 --- a/src/pipecat/services/google/stt.py +++ b/src/pipecat/services/google/stt.py @@ -9,8 +9,8 @@ import json import os import time +from pipecat.utils.asyncio.watchdog_async_iterator import WatchdogAsyncIterator from pipecat.utils.tracing.service_decorators import traced_stt -from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator # Suppress gRPC fork warnings os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false" diff --git a/src/pipecat/services/neuphonic/tts.py b/src/pipecat/services/neuphonic/tts.py index 85bd9d0cc..6d9a47a03 100644 --- a/src/pipecat/services/neuphonic/tts.py +++ b/src/pipecat/services/neuphonic/tts.py @@ -29,8 +29,8 @@ from pipecat.frames.frames import ( from pipecat.processors.frame_processor import FrameDirection from pipecat.services.tts_service import InterruptibleTTSService, TTSService from pipecat.transcriptions.language import Language +from pipecat.utils.asyncio.watchdog_async_iterator import WatchdogAsyncIterator from pipecat.utils.tracing.service_decorators import traced_tts -from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator try: import websockets diff --git a/src/pipecat/services/openai/base_llm.py b/src/pipecat/services/openai/base_llm.py index 32d154495..f3e6b0bb3 100644 --- a/src/pipecat/services/openai/base_llm.py +++ b/src/pipecat/services/openai/base_llm.py @@ -37,8 +37,8 @@ from pipecat.processors.aggregators.openai_llm_context import ( ) from pipecat.processors.frame_processor import FrameDirection from pipecat.services.llm_service import FunctionCallFromLLM, LLMService +from pipecat.utils.asyncio.watchdog_async_iterator import WatchdogAsyncIterator from pipecat.utils.tracing.service_decorators import traced_llm -from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator class BaseOpenAILLMService(LLMService): diff --git a/src/pipecat/services/openai_realtime_beta/openai.py b/src/pipecat/services/openai_realtime_beta/openai.py index 8d5168c70..da70b6118 100644 --- a/src/pipecat/services/openai_realtime_beta/openai.py +++ b/src/pipecat/services/openai_realtime_beta/openai.py @@ -51,9 +51,9 @@ from pipecat.processors.frame_processor import FrameDirection from pipecat.services.llm_service import FunctionCallFromLLM, LLMService from pipecat.services.openai.llm import OpenAIContextAggregatorPair from pipecat.transcriptions.language import Language +from pipecat.utils.asyncio.watchdog_async_iterator import WatchdogAsyncIterator from pipecat.utils.time import time_now_iso8601 from pipecat.utils.tracing.service_decorators import traced_openai_realtime, traced_stt -from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator from . import events from .context import ( diff --git a/src/pipecat/services/riva/stt.py b/src/pipecat/services/riva/stt.py index 284252adb..22fde1f54 100644 --- a/src/pipecat/services/riva/stt.py +++ b/src/pipecat/services/riva/stt.py @@ -21,9 +21,9 @@ from pipecat.frames.frames import ( ) from pipecat.services.stt_service import SegmentedSTTService, STTService from pipecat.transcriptions.language import Language +from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue from pipecat.utils.time import time_now_iso8601 from pipecat.utils.tracing.service_decorators import traced_stt -from pipecat.utils.watchdog_queue import WatchdogQueue try: import riva.client diff --git a/src/pipecat/services/sambanova/llm.py b/src/pipecat/services/sambanova/llm.py index 3ca2ee5be..d70860a7e 100644 --- a/src/pipecat/services/sambanova/llm.py +++ b/src/pipecat/services/sambanova/llm.py @@ -18,8 +18,8 @@ from pipecat.metrics.metrics import LLMTokenUsage from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext from pipecat.services.llm_service import FunctionCallFromLLM from pipecat.services.openai.llm import OpenAILLMService +from pipecat.utils.asyncio.watchdog_async_iterator import WatchdogAsyncIterator from pipecat.utils.tracing.service_decorators import traced_llm -from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator class SambaNovaLLMService(OpenAILLMService): # type: ignore diff --git a/src/pipecat/services/simli/video.py b/src/pipecat/services/simli/video.py index 2bca697cb..aef4d8022 100644 --- a/src/pipecat/services/simli/video.py +++ b/src/pipecat/services/simli/video.py @@ -18,7 +18,7 @@ from pipecat.frames.frames import ( TTSAudioRawFrame, ) from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, StartFrame -from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator +from pipecat.utils.asyncio.watchdog_async_iterator import WatchdogAsyncIterator try: from av.audio.frame import AudioFrame diff --git a/src/pipecat/services/tavus/video.py b/src/pipecat/services/tavus/video.py index 9688aaebc..c71a5159c 100644 --- a/src/pipecat/services/tavus/video.py +++ b/src/pipecat/services/tavus/video.py @@ -27,7 +27,7 @@ from pipecat.frames.frames import ( from pipecat.processors.frame_processor import FrameDirection, FrameProcessorSetup from pipecat.services.ai_service import AIService from pipecat.transports.services.tavus import TavusCallbacks, TavusParams, TavusTransportClient -from pipecat.utils.watchdog_queue import WatchdogQueue +from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue class TavusVideoService(AIService): diff --git a/src/pipecat/services/tts_service.py b/src/pipecat/services/tts_service.py index 6facf5650..6fb0f4988 100644 --- a/src/pipecat/services/tts_service.py +++ b/src/pipecat/services/tts_service.py @@ -37,11 +37,11 @@ from pipecat.processors.frame_processor import FrameDirection from pipecat.services.ai_service import AIService from pipecat.services.websocket_service import WebsocketService from pipecat.transcriptions.language import Language +from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue from pipecat.utils.text.base_text_aggregator import BaseTextAggregator from pipecat.utils.text.base_text_filter import BaseTextFilter from pipecat.utils.text.simple_text_aggregator import SimpleTextAggregator from pipecat.utils.time import seconds_to_nanoseconds -from pipecat.utils.watchdog_queue import WatchdogQueue class TTSService(AIService): diff --git a/src/pipecat/transports/base_output.py b/src/pipecat/transports/base_output.py index f477be447..95aba7320 100644 --- a/src/pipecat/transports/base_output.py +++ b/src/pipecat/transports/base_output.py @@ -39,8 +39,8 @@ from pipecat.frames.frames import ( ) from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.transports.base_transport import TransportParams +from pipecat.utils.asyncio.watchdog_priority_queue import WatchdogPriorityQueue from pipecat.utils.time import nanoseconds_to_seconds -from pipecat.utils.watchdog_priority_queue import WatchdogPriorityQueue BOT_VAD_STOP_SECS = 0.35 diff --git a/src/pipecat/transports/network/fastapi_websocket.py b/src/pipecat/transports/network/fastapi_websocket.py index 3d19cb05f..790f5f99a 100644 --- a/src/pipecat/transports/network/fastapi_websocket.py +++ b/src/pipecat/transports/network/fastapi_websocket.py @@ -31,7 +31,7 @@ from pipecat.serializers.base_serializer import FrameSerializer, FrameSerializer from pipecat.transports.base_input import BaseInputTransport from pipecat.transports.base_output import BaseOutputTransport from pipecat.transports.base_transport import BaseTransport, TransportParams -from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator +from pipecat.utils.asyncio.watchdog_async_iterator import WatchdogAsyncIterator try: from fastapi import WebSocket diff --git a/src/pipecat/transports/network/small_webrtc.py b/src/pipecat/transports/network/small_webrtc.py index 086aa383c..89895b8ab 100644 --- a/src/pipecat/transports/network/small_webrtc.py +++ b/src/pipecat/transports/network/small_webrtc.py @@ -33,7 +33,7 @@ from pipecat.transports.base_input import BaseInputTransport from pipecat.transports.base_output import BaseOutputTransport from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection -from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator +from pipecat.utils.asyncio.watchdog_async_iterator import WatchdogAsyncIterator try: import cv2 diff --git a/src/pipecat/transports/network/websocket_client.py b/src/pipecat/transports/network/websocket_client.py index 738904546..98c7f9e2d 100644 --- a/src/pipecat/transports/network/websocket_client.py +++ b/src/pipecat/transports/network/websocket_client.py @@ -30,7 +30,7 @@ from pipecat.serializers.protobuf import ProtobufFrameSerializer from pipecat.transports.base_input import BaseInputTransport from pipecat.transports.base_output import BaseOutputTransport from pipecat.transports.base_transport import BaseTransport, TransportParams -from pipecat.utils.asyncio import BaseTaskManager +from pipecat.utils.asyncio.task_manager import BaseTaskManager class WebsocketClientParams(TransportParams): diff --git a/src/pipecat/transports/services/daily.py b/src/pipecat/transports/services/daily.py index f92d632ca..42c126eb4 100644 --- a/src/pipecat/transports/services/daily.py +++ b/src/pipecat/transports/services/daily.py @@ -39,9 +39,9 @@ from pipecat.transcriptions.language import Language from pipecat.transports.base_input import BaseInputTransport from pipecat.transports.base_output import BaseOutputTransport from pipecat.transports.base_transport import BaseTransport, TransportParams -from pipecat.utils.asyncio import BaseTaskManager -from pipecat.utils.watchdog_queue import WatchdogQueue -from pipecat.utils.watchdog_reseter import WatchdogReseter +from pipecat.utils.asyncio.task_manager import BaseTaskManager +from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue +from pipecat.utils.asyncio.watchdog_reseter import WatchdogReseter try: from daily import ( diff --git a/src/pipecat/transports/services/livekit.py b/src/pipecat/transports/services/livekit.py index 67ea6b32a..9524bb9e3 100644 --- a/src/pipecat/transports/services/livekit.py +++ b/src/pipecat/transports/services/livekit.py @@ -27,8 +27,8 @@ from pipecat.processors.frame_processor import FrameDirection, FrameProcessorSet from pipecat.transports.base_input import BaseInputTransport from pipecat.transports.base_output import BaseOutputTransport from pipecat.transports.base_transport import BaseTransport, TransportParams -from pipecat.utils.asyncio import BaseTaskManager -from pipecat.utils.watchdog_async_iterator import WatchdogAsyncIterator +from pipecat.utils.asyncio.task_manager import BaseTaskManager +from pipecat.utils.asyncio.watchdog_async_iterator import WatchdogAsyncIterator try: from livekit import rtc diff --git a/src/pipecat/utils/asyncio/__init__.py b/src/pipecat/utils/asyncio/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pipecat/utils/asyncio.py b/src/pipecat/utils/asyncio/task_manager.py similarity index 100% rename from src/pipecat/utils/asyncio.py rename to src/pipecat/utils/asyncio/task_manager.py diff --git a/src/pipecat/utils/watchdog_async_iterator.py b/src/pipecat/utils/asyncio/watchdog_async_iterator.py similarity index 97% rename from src/pipecat/utils/watchdog_async_iterator.py rename to src/pipecat/utils/asyncio/watchdog_async_iterator.py index 62b6d1a23..e35d3d54c 100644 --- a/src/pipecat/utils/watchdog_async_iterator.py +++ b/src/pipecat/utils/asyncio/watchdog_async_iterator.py @@ -7,7 +7,7 @@ import asyncio from typing import AsyncIterator, Optional -from pipecat.utils.watchdog_reseter import WatchdogReseter +from pipecat.utils.asyncio.watchdog_reseter import WatchdogReseter class WatchdogAsyncIterator: diff --git a/src/pipecat/utils/watchdog_event.py b/src/pipecat/utils/asyncio/watchdog_event.py similarity index 94% rename from src/pipecat/utils/watchdog_event.py rename to src/pipecat/utils/asyncio/watchdog_event.py index 001a0cf26..823c0db82 100644 --- a/src/pipecat/utils/watchdog_event.py +++ b/src/pipecat/utils/asyncio/watchdog_event.py @@ -6,7 +6,7 @@ import asyncio -from pipecat.utils.watchdog_reseter import WatchdogReseter +from pipecat.utils.asyncio.watchdog_reseter import WatchdogReseter class WatchdogEvent(asyncio.Event): diff --git a/src/pipecat/utils/watchdog_priority_queue.py b/src/pipecat/utils/asyncio/watchdog_priority_queue.py similarity index 95% rename from src/pipecat/utils/watchdog_priority_queue.py rename to src/pipecat/utils/asyncio/watchdog_priority_queue.py index a3635667c..fb1071c58 100644 --- a/src/pipecat/utils/watchdog_priority_queue.py +++ b/src/pipecat/utils/asyncio/watchdog_priority_queue.py @@ -6,7 +6,7 @@ import asyncio -from pipecat.utils.watchdog_reseter import WatchdogReseter +from pipecat.utils.asyncio.watchdog_reseter import WatchdogReseter class WatchdogPriorityQueue(asyncio.PriorityQueue): diff --git a/src/pipecat/utils/watchdog_queue.py b/src/pipecat/utils/asyncio/watchdog_queue.py similarity index 95% rename from src/pipecat/utils/watchdog_queue.py rename to src/pipecat/utils/asyncio/watchdog_queue.py index 6eb9dab10..5a9d86cb6 100644 --- a/src/pipecat/utils/watchdog_queue.py +++ b/src/pipecat/utils/asyncio/watchdog_queue.py @@ -6,7 +6,7 @@ import asyncio -from pipecat.utils.watchdog_reseter import WatchdogReseter +from pipecat.utils.asyncio.watchdog_reseter import WatchdogReseter class WatchdogQueue(asyncio.Queue): diff --git a/src/pipecat/utils/watchdog_reseter.py b/src/pipecat/utils/asyncio/watchdog_reseter.py similarity index 100% rename from src/pipecat/utils/watchdog_reseter.py rename to src/pipecat/utils/asyncio/watchdog_reseter.py From d123cd4b2b5eeda56333b460b75b58f20d1dd88a Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 11:47:30 -0400 Subject: [PATCH 49/54] Update GeminiMultimodalLiveLLMService docstrings --- .../services/gemini_multimodal_live/events.py | 224 ++++++++++++++++- .../services/gemini_multimodal_live/gemini.py | 227 +++++++++++++++--- 2 files changed, 411 insertions(+), 40 deletions(-) diff --git a/src/pipecat/services/gemini_multimodal_live/events.py b/src/pipecat/services/gemini_multimodal_live/events.py index 97f7787a6..160ff1174 100644 --- a/src/pipecat/services/gemini_multimodal_live/events.py +++ b/src/pipecat/services/gemini_multimodal_live/events.py @@ -3,7 +3,8 @@ # # SPDX-License-Identifier: BSD 2-Clause License # -# + +"""Event models and utilities for Google Gemini Multimodal Live API.""" import base64 import io @@ -22,16 +23,37 @@ from pipecat.frames.frames import ImageRawFrame class MediaChunk(BaseModel): + """Represents a chunk of media data for transmission. + + Parameters: + mimeType: MIME type of the media content. + data: Base64-encoded media data. + """ + mimeType: str data: str class ContentPart(BaseModel): + """Represents a part of content that can contain text or media. + + Parameters: + text: Text content. Defaults to None. + inlineData: Inline media data. Defaults to None. + """ + text: Optional[str] = Field(default=None, validate_default=False) inlineData: Optional[MediaChunk] = Field(default=None, validate_default=False) class Turn(BaseModel): + """Represents a conversational turn in the dialogue. + + Parameters: + role: The role of the speaker, either "user" or "model". Defaults to "user". + parts: List of content parts that make up the turn. + """ + role: Literal["user", "model"] = "user" parts: List[ContentPart] @@ -53,7 +75,15 @@ class EndSensitivity(str, Enum): class AutomaticActivityDetection(BaseModel): - """Configures automatic detection of activity.""" + """Configures automatic detection of voice activity. + + Parameters: + disabled: Whether automatic activity detection is disabled. Defaults to None. + start_of_speech_sensitivity: Sensitivity for detecting speech start. Defaults to None. + prefix_padding_ms: Padding before speech start in milliseconds. Defaults to None. + end_of_speech_sensitivity: Sensitivity for detecting speech end. Defaults to None. + silence_duration_ms: Duration of silence to detect speech end. Defaults to None. + """ disabled: Optional[bool] = None start_of_speech_sensitivity: Optional[StartSensitivity] = None @@ -63,25 +93,57 @@ class AutomaticActivityDetection(BaseModel): class RealtimeInputConfig(BaseModel): - """Configures the realtime input behavior.""" + """Configures the realtime input behavior. + + Parameters: + automatic_activity_detection: Voice activity detection configuration. Defaults to None. + """ automatic_activity_detection: Optional[AutomaticActivityDetection] = None class RealtimeInput(BaseModel): + """Contains realtime input media chunks. + + Parameters: + mediaChunks: List of media chunks for realtime processing. + """ + mediaChunks: List[MediaChunk] class ClientContent(BaseModel): + """Content sent from client to the Gemini Live API. + + Parameters: + turns: List of conversation turns. Defaults to None. + turnComplete: Whether the client's turn is complete. Defaults to False. + """ + turns: Optional[List[Turn]] = None turnComplete: bool = False class AudioInputMessage(BaseModel): + """Message containing audio input data. + + Parameters: + realtimeInput: Realtime input containing audio chunks. + """ + realtimeInput: RealtimeInput @classmethod def from_raw_audio(cls, raw_audio: bytes, sample_rate: int) -> "AudioInputMessage": + """Create an audio input message from raw audio data. + + Args: + raw_audio: Raw audio bytes. + sample_rate: Audio sample rate in Hz. + + Returns: + AudioInputMessage instance with encoded audio data. + """ data = base64.b64encode(raw_audio).decode("utf-8") return cls( realtimeInput=RealtimeInput( @@ -91,10 +153,24 @@ class AudioInputMessage(BaseModel): class VideoInputMessage(BaseModel): + """Message containing video/image input data. + + Parameters: + realtimeInput: Realtime input containing video/image chunks. + """ + realtimeInput: RealtimeInput @classmethod def from_image_frame(cls, frame: ImageRawFrame) -> "VideoInputMessage": + """Create a video input message from an image frame. + + Args: + frame: Image frame to encode. + + Returns: + VideoInputMessage instance with encoded image data. + """ buffer = io.BytesIO() Image.frombytes(frame.format, frame.size, frame.image).save(buffer, format="JPEG") data = base64.b64encode(buffer.getvalue()).decode("utf-8") @@ -104,18 +180,44 @@ class VideoInputMessage(BaseModel): class ClientContentMessage(BaseModel): + """Message containing client content for the API. + + Parameters: + clientContent: The client content to send. + """ + clientContent: ClientContent class SystemInstruction(BaseModel): + """System instruction for the model. + + Parameters: + parts: List of content parts that make up the system instruction. + """ + parts: List[ContentPart] class AudioTranscriptionConfig(BaseModel): + """Configuration for audio transcription.""" + pass class Setup(BaseModel): + """Setup configuration for the Gemini Live session. + + Parameters: + model: Model identifier to use. + system_instruction: System instruction for the model. Defaults to None. + tools: List of available tools/functions. Defaults to None. + generation_config: Generation configuration parameters. Defaults to None. + input_audio_transcription: Input audio transcription config. Defaults to None. + output_audio_transcription: Output audio transcription config. Defaults to None. + realtime_input_config: Realtime input configuration. Defaults to None. + """ + model: str system_instruction: Optional[SystemInstruction] = None tools: Optional[List[dict]] = None @@ -126,6 +228,12 @@ class Setup(BaseModel): class Config(BaseModel): + """Configuration message for session setup. + + Parameters: + setup: Setup configuration for the session. + """ + setup: Setup @@ -135,36 +243,86 @@ class Config(BaseModel): class SetupComplete(BaseModel): + """Indicates that session setup is complete.""" + pass class InlineData(BaseModel): + """Inline data embedded in server responses. + + Parameters: + mimeType: MIME type of the data. + data: Base64-encoded data content. + """ + mimeType: str data: str class Part(BaseModel): + """Part of a server response containing data or text. + + Parameters: + inlineData: Inline binary data. Defaults to None. + text: Text content. Defaults to None. + """ + inlineData: Optional[InlineData] = None text: Optional[str] = None class ModelTurn(BaseModel): + """Represents a turn from the model in the conversation. + + Parameters: + parts: List of content parts in the model's response. + """ + parts: List[Part] class ServerContentInterrupted(BaseModel): + """Indicates server content was interrupted. + + Parameters: + interrupted: Whether the content was interrupted. + """ + interrupted: bool class ServerContentTurnComplete(BaseModel): + """Indicates the server's turn is complete. + + Parameters: + turnComplete: Whether the turn is complete. + """ + turnComplete: bool class BidiGenerateContentTranscription(BaseModel): + """Transcription data from bidirectional content generation. + + Parameters: + text: The transcribed text content. + """ + text: str class ServerContent(BaseModel): + """Content sent from server to client. + + Parameters: + modelTurn: Model's conversational turn. Defaults to None. + interrupted: Whether content was interrupted. Defaults to None. + turnComplete: Whether the turn is complete. Defaults to None. + inputTranscription: Transcription of input audio. Defaults to None. + outputTranscription: Transcription of output audio. Defaults to None. + """ + modelTurn: Optional[ModelTurn] = None interrupted: Optional[bool] = None turnComplete: Optional[bool] = None @@ -173,12 +331,26 @@ class ServerContent(BaseModel): class FunctionCall(BaseModel): + """Represents a function call from the model. + + Parameters: + id: Unique identifier for the function call. + name: Name of the function to call. + args: Arguments to pass to the function. + """ + id: str name: str args: dict class ToolCall(BaseModel): + """Contains one or more function calls. + + Parameters: + functionCalls: List of function calls to execute. + """ + functionCalls: List[FunctionCall] @@ -193,14 +365,32 @@ class Modality(str, Enum): class ModalityTokenCount(BaseModel): - """Token count for a specific modality.""" + """Token count for a specific modality. + + Parameters: + modality: The modality type. + tokenCount: Number of tokens for this modality. + """ modality: Modality tokenCount: int class UsageMetadata(BaseModel): - """Usage metadata about the response.""" + """Usage metadata about the API response. + + Parameters: + promptTokenCount: Number of tokens in the prompt. Defaults to None. + cachedContentTokenCount: Number of cached content tokens. Defaults to None. + responseTokenCount: Number of tokens in the response. Defaults to None. + toolUsePromptTokenCount: Number of tokens for tool use prompts. Defaults to None. + thoughtsTokenCount: Number of tokens for model thoughts. Defaults to None. + totalTokenCount: Total number of tokens used. Defaults to None. + promptTokensDetails: Detailed breakdown of prompt tokens by modality. Defaults to None. + cacheTokensDetails: Detailed breakdown of cache tokens by modality. Defaults to None. + responseTokensDetails: Detailed breakdown of response tokens by modality. Defaults to None. + toolUsePromptTokensDetails: Detailed breakdown of tool use tokens by modality. Defaults to None. + """ promptTokenCount: Optional[int] = None cachedContentTokenCount: Optional[int] = None @@ -215,6 +405,15 @@ class UsageMetadata(BaseModel): class ServerEvent(BaseModel): + """Server event received from the Gemini Live API. + + Parameters: + setupComplete: Setup completion notification. Defaults to None. + serverContent: Content from the server. Defaults to None. + toolCall: Tool/function call request. Defaults to None. + usageMetadata: Token usage metadata. Defaults to None. + """ + setupComplete: Optional[SetupComplete] = None serverContent: Optional[ServerContent] = None toolCall: Optional[ToolCall] = None @@ -222,6 +421,14 @@ class ServerEvent(BaseModel): def parse_server_event(str): + """Parse a server event from JSON string. + + Args: + str: JSON string containing the server event. + + Returns: + ServerEvent instance if parsing succeeds, None otherwise. + """ try: evt = json.loads(str) return ServerEvent.model_validate(evt) @@ -231,7 +438,12 @@ def parse_server_event(str): class ContextWindowCompressionConfig(BaseModel): - """Configuration for context window compression.""" + """Configuration for context window compression. + + Parameters: + sliding_window: Whether to use sliding window compression. Defaults to True. + trigger_tokens: Token count threshold to trigger compression. Defaults to None. + """ sliding_window: Optional[bool] = Field(default=True) trigger_tokens: Optional[int] = Field(default=None) diff --git a/src/pipecat/services/gemini_multimodal_live/gemini.py b/src/pipecat/services/gemini_multimodal_live/gemini.py index c713c3cab..1f62f4993 100644 --- a/src/pipecat/services/gemini_multimodal_live/gemini.py +++ b/src/pipecat/services/gemini_multimodal_live/gemini.py @@ -4,6 +4,13 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Google Gemini Multimodal Live API service implementation. + +This module provides real-time conversational AI capabilities using Google's +Gemini Multimodal Live API, supporting both text and audio modalities with +voice transcription, streaming responses, and tool usage. +""" + import base64 import json import time @@ -79,7 +86,11 @@ def language_to_gemini_language(language: Language) -> Optional[str]: Source: https://ai.google.dev/api/generate-content#MediaResolution - Returns None if the language is not supported by Gemini Live. + Args: + language: The language enum value to convert. + + Returns: + The Gemini language code string, or None if the language is not supported. """ language_map = { # Arabic @@ -166,8 +177,22 @@ def language_to_gemini_language(language: Language) -> Optional[str]: class GeminiMultimodalLiveContext(OpenAILLMContext): + """Extended OpenAI context for Gemini Multimodal Live API. + + Provides Gemini-specific context management including system instruction + extraction and message format conversion for the Live API. + """ + @staticmethod def upgrade(obj: OpenAILLMContext) -> "GeminiMultimodalLiveContext": + """Upgrade an OpenAI context to Gemini context. + + Args: + obj: The OpenAI context to upgrade. + + Returns: + The upgraded Gemini context instance. + """ if isinstance(obj, OpenAILLMContext) and not isinstance(obj, GeminiMultimodalLiveContext): logger.debug(f"Upgrading to Gemini Multimodal Live Context: {obj}") obj.__class__ = GeminiMultimodalLiveContext @@ -178,6 +203,11 @@ class GeminiMultimodalLiveContext(OpenAILLMContext): pass def extract_system_instructions(self): + """Extract system instructions from context messages. + + Returns: + Combined system instruction text from all system messages. + """ system_instruction = "" for item in self.messages: if item.get("role") == "system": @@ -189,6 +219,11 @@ class GeminiMultimodalLiveContext(OpenAILLMContext): return system_instruction def get_messages_for_initializing_history(self): + """Get messages formatted for Gemini history initialization. + + Returns: + List of messages in Gemini format for conversation history. + """ messages = [] for item in self.messages: role = item.get("role") @@ -216,7 +251,19 @@ class GeminiMultimodalLiveContext(OpenAILLMContext): class GeminiMultimodalLiveUserContextAggregator(OpenAIUserContextAggregator): + """User context aggregator for Gemini Multimodal Live. + + Extends OpenAI user aggregator to handle Gemini-specific message passing + while maintaining compatibility with the standard aggregation pipeline. + """ + async def process_frame(self, frame, direction): + """Process incoming frames for user context aggregation. + + Args: + frame: The frame to process. + direction: The frame processing direction. + """ await super().process_frame(frame, direction) # kind of a hack just to pass the LLMMessagesAppendFrame through, but it's fine for now if isinstance(frame, LLMMessagesAppendFrame): @@ -224,15 +271,33 @@ class GeminiMultimodalLiveUserContextAggregator(OpenAIUserContextAggregator): class GeminiMultimodalLiveAssistantContextAggregator(OpenAIAssistantContextAggregator): - # The LLMAssistantContextAggregator uses TextFrames to aggregate the LLM output, - # but the GeminiMultimodalLiveAssistantContextAggregator pushes LLMTextFrames and TTSTextFrames. We - # need to override this proces_frame for LLMTextFrame, so that only the TTSTextFrames - # are process. This ensures that the context gets only one set of messages. + """Assistant context aggregator for Gemini Multimodal Live. + + Handles assistant response aggregation while filtering out LLMTextFrames + to prevent duplicate context entries, as Gemini Live pushes both + LLMTextFrames and TTSTextFrames. + """ + async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process incoming frames for assistant context aggregation. + + Args: + frame: The frame to process. + direction: The frame processing direction. + """ + # The LLMAssistantContextAggregator uses TextFrames to aggregate the LLM output, + # but the GeminiMultimodalLiveAssistantContextAggregator pushes LLMTextFrames and TTSTextFrames. We + # need to override this proces_frame for LLMTextFrame, so that only the TTSTextFrames + # are process. This ensures that the context gets only one set of messages. if not isinstance(frame, LLMTextFrame): await super().process_frame(frame, direction) async def handle_user_image_frame(self, frame: UserImageRawFrame): + """Handle user image frames. + + Args: + frame: The user image frame to handle. + """ # We don't want to store any images in the context. Revisit this later # when the API evolves. pass @@ -240,17 +305,36 @@ class GeminiMultimodalLiveAssistantContextAggregator(OpenAIAssistantContextAggre @dataclass class GeminiMultimodalLiveContextAggregatorPair: + """Pair of user and assistant context aggregators for Gemini Multimodal Live. + + Parameters: + _user: The user context aggregator instance. + _assistant: The assistant context aggregator instance. + """ + _user: GeminiMultimodalLiveUserContextAggregator _assistant: GeminiMultimodalLiveAssistantContextAggregator def user(self) -> GeminiMultimodalLiveUserContextAggregator: + """Get the user context aggregator. + + Returns: + The user context aggregator instance. + """ return self._user def assistant(self) -> GeminiMultimodalLiveAssistantContextAggregator: + """Get the assistant context aggregator. + + Returns: + The assistant context aggregator instance. + """ return self._assistant class GeminiMultimodalModalities(Enum): + """Supported modalities for Gemini Multimodal Live.""" + TEXT = "TEXT" AUDIO = "AUDIO" @@ -265,7 +349,15 @@ class GeminiMediaResolution(str, Enum): class GeminiVADParams(BaseModel): - """Voice Activity Detection parameters.""" + """Voice Activity Detection parameters for Gemini Live. + + Parameters: + disabled: Whether to disable VAD. Defaults to None. + start_sensitivity: Sensitivity for speech start detection. Defaults to None. + end_sensitivity: Sensitivity for speech end detection. Defaults to None. + prefix_padding_ms: Prefix padding in milliseconds. Defaults to None. + silence_duration_ms: Silence duration threshold in milliseconds. Defaults to None. + """ disabled: Optional[bool] = Field(default=None) start_sensitivity: Optional[events.StartSensitivity] = Field(default=None) @@ -275,7 +367,12 @@ class GeminiVADParams(BaseModel): class ContextWindowCompressionParams(BaseModel): - """Parameters for context window compression.""" + """Parameters for context window compression in Gemini Live. + + Parameters: + enabled: Whether compression is enabled. Defaults to False. + trigger_tokens: Token count to trigger compression. None uses 80% of context window. + """ enabled: bool = Field(default=False) trigger_tokens: Optional[int] = Field( @@ -284,6 +381,23 @@ class ContextWindowCompressionParams(BaseModel): class InputParams(BaseModel): + """Input parameters for Gemini Multimodal Live generation. + + Parameters: + frequency_penalty: Frequency penalty for generation (0.0-2.0). Defaults to None. + max_tokens: Maximum tokens to generate. Must be >= 1. Defaults to 4096. + presence_penalty: Presence penalty for generation (0.0-2.0). Defaults to None. + temperature: Sampling temperature (0.0-2.0). Defaults to None. + top_k: Top-k sampling parameter. Must be >= 0. Defaults to None. + top_p: Top-p sampling parameter (0.0-1.0). Defaults to None. + modalities: Response modalities. Defaults to AUDIO. + language: Language for generation. Defaults to EN_US. + media_resolution: Media resolution setting. Defaults to UNSPECIFIED. + vad: Voice activity detection parameters. Defaults to None. + context_window_compression: Context compression settings. Defaults to None. + extra: Additional parameters. Defaults to empty dict. + """ + frequency_penalty: Optional[float] = Field(default=None, ge=0.0, le=2.0) max_tokens: Optional[int] = Field(default=4096, ge=1) presence_penalty: Optional[float] = Field(default=None, ge=0.0, le=2.0) @@ -310,23 +424,18 @@ class GeminiMultimodalLiveLLMService(LLMService): responses, and tool usage. Args: - api_key (str): Google AI API key - base_url (str, optional): API endpoint base URL. Defaults to - "generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent". - model (str, optional): Model identifier to use. Defaults to - "models/gemini-2.0-flash-live-001". - voice_id (str, optional): TTS voice identifier. Defaults to "Charon". - start_audio_paused (bool, optional): Whether to start with audio input paused. - Defaults to False. - start_video_paused (bool, optional): Whether to start with video input paused. - Defaults to False. - system_instruction (str, optional): System prompt for the model. Defaults to None. - tools (Union[List[dict], ToolsSchema], optional): Tools/functions available to the model. - Defaults to None. - params (InputParams, optional): Configuration parameters for the model. - Defaults to InputParams(). - inference_on_context_initialization (bool, optional): Whether to generate a response - when context is first set. Defaults to True. + api_key: Google AI API key for authentication. + base_url: API endpoint base URL. Defaults to the official Gemini Live endpoint. + model: Model identifier to use. Defaults to "models/gemini-2.0-flash-live-001". + voice_id: TTS voice identifier. Defaults to "Charon". + start_audio_paused: Whether to start with audio input paused. Defaults to False. + start_video_paused: Whether to start with video input paused. Defaults to False. + system_instruction: System prompt for the model. Defaults to None. + tools: Tools/functions available to the model. Defaults to None. + params: Configuration parameters for the model. Defaults to InputParams(). + inference_on_context_initialization: Whether to generate a response when context + is first set. Defaults to True. + **kwargs: Additional arguments passed to parent LLMService. """ # Overriding the default adapter to use the Gemini one. @@ -408,19 +517,43 @@ class GeminiMultimodalLiveLLMService(LLMService): } def can_generate_metrics(self) -> bool: + """Check if the service can generate usage metrics. + + Returns: + True as Gemini Live supports token usage metrics. + """ return True def set_audio_input_paused(self, paused: bool): + """Set the audio input pause state. + + Args: + paused: Whether to pause audio input. + """ self._audio_input_paused = paused def set_video_input_paused(self, paused: bool): + """Set the video input pause state. + + Args: + paused: Whether to pause video input. + """ self._video_input_paused = paused def set_model_modalities(self, modalities: GeminiMultimodalModalities): + """Set the model response modalities. + + Args: + modalities: The modalities to use for responses. + """ self._settings["modalities"] = modalities def set_language(self, language: Language): - """Set the language for generation.""" + """Set the language for generation. + + Args: + language: The language to use for generation. + """ self._language = language self._language_code = language_to_gemini_language(language) or "en-US" self._settings["language"] = self._language_code @@ -433,6 +566,9 @@ class GeminiMultimodalLiveLLMService(LLMService): way to trigger the pipeline. This sends the history to the server. The `inference_on_context_initialization` flag controls whether to set the turnComplete flag when we do this. Without that flag, the model will not respond. This is often what we want when setting the context at the beginning of a conversation. + + Args: + context: The OpenAI LLM context to set. """ if self._context: logger.error( @@ -447,14 +583,29 @@ class GeminiMultimodalLiveLLMService(LLMService): # async def start(self, frame: StartFrame): + """Start the service and establish websocket connection. + + Args: + frame: The start frame. + """ await super().start(frame) await self._connect() async def stop(self, frame: EndFrame): + """Stop the service and close connections. + + Args: + frame: The end frame. + """ await super().stop(frame) await self._disconnect() async def cancel(self, frame: CancelFrame): + """Cancel the service and close connections. + + Args: + frame: The cancel frame. + """ await super().cancel(frame) await self._disconnect() @@ -489,6 +640,12 @@ class GeminiMultimodalLiveLLMService(LLMService): # async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process incoming frames for the Gemini Live service. + + Args: + frame: The frame to process. + direction: The frame processing direction. + """ await super().process_frame(frame, direction) if isinstance(frame, TranscriptionFrame): @@ -544,6 +701,11 @@ class GeminiMultimodalLiveLLMService(LLMService): # async def send_client_event(self, event): + """Send a client event to the Gemini Live API. + + Args: + event: The event to send. + """ await self._ws_send(event.model_dump(exclude_none=True)) async def _connect(self): @@ -1033,22 +1195,19 @@ class GeminiMultimodalLiveLLMService(LLMService): user_params: LLMUserAggregatorParams = LLMUserAggregatorParams(), assistant_params: LLMAssistantAggregatorParams = LLMAssistantAggregatorParams(), ) -> GeminiMultimodalLiveContextAggregatorPair: - """Create an instance of GeminiMultimodalLiveContextAggregatorPair from - an OpenAILLMContext. Constructor keyword arguments for both the user and - assistant aggregators can be provided. + """Create an instance of GeminiMultimodalLiveContextAggregatorPair from an OpenAILLMContext. + + Constructor keyword arguments for both the user and assistant aggregators can be provided. Args: - context (OpenAILLMContext): The LLM context. - user_params (LLMUserAggregatorParams, optional): User aggregator - parameters. - assistant_params (LLMAssistantAggregatorParams, optional): User - aggregator parameters. + context: The LLM context to use. + user_params: User aggregator parameters. Defaults to LLMUserAggregatorParams(). + assistant_params: Assistant aggregator parameters. Defaults to LLMAssistantAggregatorParams(). Returns: GeminiMultimodalLiveContextAggregatorPair: A pair of context aggregators, one for the user and one for the assistant, encapsulated in an GeminiMultimodalLiveContextAggregatorPair. - """ context.set_llm_adapter(self.get_llm_adapter()) From d8ce108ccd3f42258e0d701fd8912fe87b145fbd Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 12:06:47 -0400 Subject: [PATCH 50/54] Update OpenAIRealtimeBetaLLMService docstrings --- .../services/openai_realtime_beta/context.py | 85 +++ .../services/openai_realtime_beta/events.py | 498 +++++++++++++++++- .../services/openai_realtime_beta/frames.py | 15 + .../services/openai_realtime_beta/openai.py | 104 +++- 4 files changed, 688 insertions(+), 14 deletions(-) diff --git a/src/pipecat/services/openai_realtime_beta/context.py b/src/pipecat/services/openai_realtime_beta/context.py index 85d1a5457..7caee0ece 100644 --- a/src/pipecat/services/openai_realtime_beta/context.py +++ b/src/pipecat/services/openai_realtime_beta/context.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""OpenAI Realtime LLM context and aggregator implementations.""" + import copy import json @@ -30,6 +32,18 @@ from .frames import RealtimeFunctionCallResultFrame, RealtimeMessagesUpdateFrame class OpenAIRealtimeLLMContext(OpenAILLMContext): + """OpenAI Realtime LLM context with session management and message conversion. + + Extends the standard OpenAI LLM context to support real-time session properties, + instruction management, and conversion between standard message formats and + realtime conversation items. + + Args: + messages: Initial conversation messages. Defaults to None. + tools: Available function tools. Defaults to None. + **kwargs: Additional arguments passed to parent OpenAILLMContext. + """ + def __init__(self, messages=None, tools=None, **kwargs): super().__init__(messages=messages, tools=tools, **kwargs) self.__setup_local() @@ -43,6 +57,14 @@ class OpenAIRealtimeLLMContext(OpenAILLMContext): @staticmethod def upgrade_to_realtime(obj: OpenAILLMContext) -> "OpenAIRealtimeLLMContext": + """Upgrade a standard OpenAI LLM context to a realtime context. + + Args: + obj: The OpenAILLMContext instance to upgrade. + + Returns: + The upgraded OpenAIRealtimeLLMContext instance. + """ if isinstance(obj, OpenAILLMContext) and not isinstance(obj, OpenAIRealtimeLLMContext): obj.__class__ = OpenAIRealtimeLLMContext obj.__setup_local() @@ -52,6 +74,14 @@ class OpenAIRealtimeLLMContext(OpenAILLMContext): # - finish implementing all frames def from_standard_message(self, message): + """Convert a standard message format to a realtime conversation item. + + Args: + message: The standard message dictionary to convert. + + Returns: + A ConversationItem instance for the realtime API. + """ if message.get("role") == "user": content = message.get("content") if isinstance(message.get("content"), list): @@ -79,6 +109,14 @@ class OpenAIRealtimeLLMContext(OpenAILLMContext): logger.error(f"Unhandled message type in from_standard_message: {message}") def get_messages_for_initializing_history(self): + """Get conversation items for initializing the realtime session history. + + Converts the context's messages to a format suitable for the realtime API, + handling system instructions and conversation history packaging. + + Returns: + List of conversation items for session initialization. + """ # We can't load a long conversation history into the openai realtime api yet. (The API/model # forgets that it can do audio, if you do a series of `conversation.item.create` calls.) So # our general strategy until this is fixed is just to put everything into a first "user" @@ -133,6 +171,11 @@ class OpenAIRealtimeLLMContext(OpenAILLMContext): ] def add_user_content_item_as_message(self, item): + """Add a user content item as a standard message to the context. + + Args: + item: The conversation item to add as a user message. + """ message = { "role": "user", "content": [{"type": "text", "text": item.content[0].transcript}], @@ -141,9 +184,25 @@ class OpenAIRealtimeLLMContext(OpenAILLMContext): class OpenAIRealtimeUserContextAggregator(OpenAIUserContextAggregator): + """User context aggregator for OpenAI Realtime API. + + Handles user input frames and generates appropriate context updates + for the realtime conversation, including message updates and tool settings. + + Args: + context: The OpenAI realtime LLM context. + **kwargs: Additional arguments passed to parent aggregator. + """ + async def process_frame( self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM ): + """Process incoming frames and handle realtime-specific frame types. + + Args: + frame: The frame to process. + direction: The direction of frame flow in the pipeline. + """ await super().process_frame(frame, direction) # Parent does not push LLMMessagesUpdateFrame. This ensures that in a typical pipeline, # messages are only processed by the user context aggregator, which is generally what we want. But @@ -157,6 +216,11 @@ class OpenAIRealtimeUserContextAggregator(OpenAIUserContextAggregator): await self.push_frame(frame, direction) async def push_aggregation(self): + """Push user input aggregation. + + Currently ignores all user input coming into the pipeline as realtime + audio input is handled directly by the service. + """ # for the moment, ignore all user input coming into the pipeline. # todo: think about whether/how to fix this to allow for text input from # upstream (transport/transcription, or other sources) @@ -164,6 +228,16 @@ class OpenAIRealtimeUserContextAggregator(OpenAIUserContextAggregator): class OpenAIRealtimeAssistantContextAggregator(OpenAIAssistantContextAggregator): + """Assistant context aggregator for OpenAI Realtime API. + + Handles assistant output frames from the realtime service, filtering + out duplicate text frames and managing function call results. + + Args: + context: The OpenAI realtime LLM context. + **kwargs: Additional arguments passed to parent aggregator. + """ + # The LLMAssistantContextAggregator uses TextFrames to aggregate the LLM output, # but the OpenAIRealtimeLLMService pushes LLMTextFrames and TTSTextFrames. We # need to override this proces_frame for LLMTextFrame, so that only the TTSTextFrames @@ -171,10 +245,21 @@ class OpenAIRealtimeAssistantContextAggregator(OpenAIAssistantContextAggregator) # OpenAIRealtimeLLMService also pushes TranscriptionFrames and InterimTranscriptionFrames, # so we need to ignore pushing those as well, as they're also TextFrames. async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process assistant frames, filtering out duplicate text content. + + Args: + frame: The frame to process. + direction: The direction of frame flow in the pipeline. + """ if not isinstance(frame, (LLMTextFrame, TranscriptionFrame, InterimTranscriptionFrame)): await super().process_frame(frame, direction) async def handle_function_call_result(self, frame: FunctionCallResultFrame): + """Handle function call result and notify the realtime service. + + Args: + frame: The function call result frame to handle. + """ await super().handle_function_call_result(frame) # The standard function callback code path pushes the FunctionCallResultFrame from the llm itself, diff --git a/src/pipecat/services/openai_realtime_beta/events.py b/src/pipecat/services/openai_realtime_beta/events.py index ef62248af..695cd3015 100644 --- a/src/pipecat/services/openai_realtime_beta/events.py +++ b/src/pipecat/services/openai_realtime_beta/events.py @@ -3,7 +3,8 @@ # # SPDX-License-Identifier: BSD 2-Clause License # -# + +"""Event models and data structures for OpenAI Realtime API communication.""" import json import uuid @@ -19,7 +20,7 @@ from pydantic import BaseModel, ConfigDict, Field class InputAudioTranscription(BaseModel): """Configuration for audio transcription settings. - Attributes: + Parameters: model: Transcription model to use (e.g., "gpt-4o-transcribe", "whisper-1"). language: Optional language code for transcription. prompt: Optional transcription hint text. @@ -39,6 +40,15 @@ class InputAudioTranscription(BaseModel): class TurnDetection(BaseModel): + """Server-side voice activity detection configuration. + + Parameters: + type: Detection type, must be "server_vad". + threshold: Voice activity detection threshold (0.0-1.0). Defaults to 0.5. + prefix_padding_ms: Padding before speech starts in milliseconds. Defaults to 300. + silence_duration_ms: Silence duration to detect speech end in milliseconds. Defaults to 800. + """ + type: Optional[Literal["server_vad"]] = "server_vad" threshold: Optional[float] = 0.5 prefix_padding_ms: Optional[int] = 300 @@ -46,6 +56,15 @@ class TurnDetection(BaseModel): class SemanticTurnDetection(BaseModel): + """Semantic-based turn detection configuration. + + Parameters: + type: Detection type, must be "semantic_vad". + eagerness: Turn detection eagerness level. Can be "low", "medium", "high", or "auto". + create_response: Whether to automatically create responses on turn detection. + interrupt_response: Whether to interrupt ongoing responses on turn detection. + """ + type: Optional[Literal["semantic_vad"]] = "semantic_vad" eagerness: Optional[Literal["low", "medium", "high", "auto"]] = None create_response: Optional[bool] = None @@ -53,10 +72,33 @@ class SemanticTurnDetection(BaseModel): class InputAudioNoiseReduction(BaseModel): + """Input audio noise reduction configuration. + + Parameters: + type: Noise reduction type for different microphone scenarios. + """ + type: Optional[Literal["near_field", "far_field"]] class SessionProperties(BaseModel): + """Configuration properties for an OpenAI Realtime session. + + Parameters: + modalities: Communication modalities to enable (text, audio, or both). + instructions: System instructions for the assistant. + voice: Voice ID for text-to-speech output. + input_audio_format: Format for input audio data. + output_audio_format: Format for output audio data. + input_audio_transcription: Configuration for input audio transcription. + input_audio_noise_reduction: Configuration for input audio noise reduction. + turn_detection: Turn detection configuration or False to disable. + tools: Available function tools for the assistant. + tool_choice: Tool usage strategy ("auto", "none", or "required"). + temperature: Sampling temperature for response generation. + max_response_output_tokens: Maximum tokens in response or "inf" for unlimited. + """ + modalities: Optional[List[Literal["text", "audio"]]] = None instructions: Optional[str] = None voice: Optional[str] = None @@ -80,6 +122,15 @@ class SessionProperties(BaseModel): class ItemContent(BaseModel): + """Content within a conversation item. + + Parameters: + type: Content type (text, audio, input_text, or input_audio). + text: Text content for text-based items. + audio: Base64-encoded audio data for audio items. + transcript: Transcribed text for audio items. + """ + type: Literal["text", "audio", "input_text", "input_audio"] text: Optional[str] = None audio: Optional[str] = None # base64-encoded audio @@ -87,6 +138,21 @@ class ItemContent(BaseModel): class ConversationItem(BaseModel): + """A conversation item in the realtime session. + + Parameters: + id: Unique identifier for the item, auto-generated if not provided. + object: Object type identifier for the realtime API. + type: Item type (message, function_call, or function_call_output). + status: Current status of the item. + role: Speaker role for message items (user, assistant, or system). + content: Content list for message items. + call_id: Function call identifier for function_call items. + name: Function name for function_call items. + arguments: Function arguments as JSON string for function_call items. + output: Function output as JSON string for function_call_output items. + """ + id: str = Field(default_factory=lambda: str(uuid.uuid4().hex)) object: Optional[Literal["realtime.item"]] = None type: Literal["message", "function_call", "function_call_output"] @@ -102,11 +168,31 @@ class ConversationItem(BaseModel): class RealtimeConversation(BaseModel): + """A realtime conversation session. + + Parameters: + id: Unique identifier for the conversation. + object: Object type identifier, always "realtime.conversation". + """ + id: str object: Literal["realtime.conversation"] class ResponseProperties(BaseModel): + """Properties for configuring assistant responses. + + Parameters: + modalities: Output modalities for the response. Defaults to ["audio", "text"]. + instructions: Specific instructions for this response. + voice: Voice ID for text-to-speech in this response. + output_audio_format: Audio format for this response. + tools: Available tools for this response. + tool_choice: Tool usage strategy for this response. + temperature: Sampling temperature for this response. + max_response_output_tokens: Maximum tokens for this response. + """ + modalities: Optional[List[Literal["text", "audio"]]] = ["audio", "text"] instructions: Optional[str] = None voice: Optional[str] = None @@ -121,6 +207,16 @@ class ResponseProperties(BaseModel): # error class # class RealtimeError(BaseModel): + """Error information from the realtime API. + + Parameters: + type: Error type identifier. + code: Specific error code. + message: Human-readable error message. + param: Parameter name that caused the error, if applicable. + event_id: Event ID associated with the error, if applicable. + """ + type: str code: Optional[str] = "" message: str @@ -134,14 +230,38 @@ class RealtimeError(BaseModel): class ClientEvent(BaseModel): + """Base class for client events sent to the realtime API. + + Parameters: + event_id: Unique identifier for the event, auto-generated if not provided. + """ + event_id: str = Field(default_factory=lambda: str(uuid.uuid4())) class SessionUpdateEvent(ClientEvent): + """Event to update session properties. + + Parameters: + type: Event type, always "session.update". + session: Updated session properties. + """ + type: Literal["session.update"] = "session.update" session: SessionProperties def model_dump(self, *args, **kwargs) -> Dict[str, Any]: + """Serialize the event to a dictionary. + + Handles special serialization for turn_detection where False becomes null. + + Args: + *args: Positional arguments passed to parent model_dump. + **kwargs: Keyword arguments passed to parent model_dump. + + Returns: + Dictionary representation of the event. + """ dump = super().model_dump(*args, **kwargs) # Handle turn_detection so that False is serialized as null @@ -153,25 +273,61 @@ class SessionUpdateEvent(ClientEvent): class InputAudioBufferAppendEvent(ClientEvent): + """Event to append audio data to the input buffer. + + Parameters: + type: Event type, always "input_audio_buffer.append". + audio: Base64-encoded audio data to append. + """ + type: Literal["input_audio_buffer.append"] = "input_audio_buffer.append" audio: str # base64-encoded audio class InputAudioBufferCommitEvent(ClientEvent): + """Event to commit the current input audio buffer. + + Parameters: + type: Event type, always "input_audio_buffer.commit". + """ + type: Literal["input_audio_buffer.commit"] = "input_audio_buffer.commit" class InputAudioBufferClearEvent(ClientEvent): + """Event to clear the input audio buffer. + + Parameters: + type: Event type, always "input_audio_buffer.clear". + """ + type: Literal["input_audio_buffer.clear"] = "input_audio_buffer.clear" class ConversationItemCreateEvent(ClientEvent): + """Event to create a new conversation item. + + Parameters: + type: Event type, always "conversation.item.create". + previous_item_id: ID of the item to insert after, if any. + item: The conversation item to create. + """ + type: Literal["conversation.item.create"] = "conversation.item.create" previous_item_id: Optional[str] = None item: ConversationItem class ConversationItemTruncateEvent(ClientEvent): + """Event to truncate a conversation item's audio content. + + Parameters: + type: Event type, always "conversation.item.truncate". + item_id: ID of the item to truncate. + content_index: Index of the content to truncate within the item. + audio_end_ms: End time in milliseconds for the truncated audio. + """ + type: Literal["conversation.item.truncate"] = "conversation.item.truncate" item_id: str content_index: int @@ -179,21 +335,48 @@ class ConversationItemTruncateEvent(ClientEvent): class ConversationItemDeleteEvent(ClientEvent): + """Event to delete a conversation item. + + Parameters: + type: Event type, always "conversation.item.delete". + item_id: ID of the item to delete. + """ + type: Literal["conversation.item.delete"] = "conversation.item.delete" item_id: str class ConversationItemRetrieveEvent(ClientEvent): + """Event to retrieve a conversation item by ID. + + Parameters: + type: Event type, always "conversation.item.retrieve". + item_id: ID of the item to retrieve. + """ + type: Literal["conversation.item.retrieve"] = "conversation.item.retrieve" item_id: str class ResponseCreateEvent(ClientEvent): + """Event to create a new assistant response. + + Parameters: + type: Event type, always "response.create". + response: Optional response configuration properties. + """ + type: Literal["response.create"] = "response.create" response: Optional[ResponseProperties] = None class ResponseCancelEvent(ClientEvent): + """Event to cancel the current assistant response. + + Parameters: + type: Event type, always "response.cancel". + """ + type: Literal["response.cancel"] = "response.cancel" @@ -203,6 +386,13 @@ class ResponseCancelEvent(ClientEvent): class ServerEvent(BaseModel): + """Base class for server events received from the realtime API. + + Parameters: + event_id: Unique identifier for the event. + type: Type of the server event. + """ + model_config = ConfigDict(arbitrary_types_allowed=True) event_id: str @@ -210,27 +400,65 @@ class ServerEvent(BaseModel): class SessionCreatedEvent(ServerEvent): + """Event indicating a session has been created. + + Parameters: + type: Event type, always "session.created". + session: The created session properties. + """ + type: Literal["session.created"] session: SessionProperties class SessionUpdatedEvent(ServerEvent): + """Event indicating a session has been updated. + + Parameters: + type: Event type, always "session.updated". + session: The updated session properties. + """ + type: Literal["session.updated"] session: SessionProperties class ConversationCreated(ServerEvent): + """Event indicating a conversation has been created. + + Parameters: + type: Event type, always "conversation.created". + conversation: The created conversation. + """ + type: Literal["conversation.created"] conversation: RealtimeConversation class ConversationItemCreated(ServerEvent): + """Event indicating a conversation item has been created. + + Parameters: + type: Event type, always "conversation.item.created". + previous_item_id: ID of the previous item, if any. + item: The created conversation item. + """ + type: Literal["conversation.item.created"] previous_item_id: Optional[str] = None item: ConversationItem class ConversationItemInputAudioTranscriptionDelta(ServerEvent): + """Event containing incremental input audio transcription. + + Parameters: + type: Event type, always "conversation.item.input_audio_transcription.delta". + item_id: ID of the conversation item being transcribed. + content_index: Index of the content within the item. + delta: Incremental transcription text. + """ + type: Literal["conversation.item.input_audio_transcription.delta"] item_id: str content_index: int @@ -238,6 +466,15 @@ class ConversationItemInputAudioTranscriptionDelta(ServerEvent): class ConversationItemInputAudioTranscriptionCompleted(ServerEvent): + """Event indicating input audio transcription is complete. + + Parameters: + type: Event type, always "conversation.item.input_audio_transcription.completed". + item_id: ID of the conversation item that was transcribed. + content_index: Index of the content within the item. + transcript: Complete transcription text. + """ + type: Literal["conversation.item.input_audio_transcription.completed"] item_id: str content_index: int @@ -245,6 +482,15 @@ class ConversationItemInputAudioTranscriptionCompleted(ServerEvent): class ConversationItemInputAudioTranscriptionFailed(ServerEvent): + """Event indicating input audio transcription failed. + + Parameters: + type: Event type, always "conversation.item.input_audio_transcription.failed". + item_id: ID of the conversation item that failed transcription. + content_index: Index of the content within the item. + error: Error details for the transcription failure. + """ + type: Literal["conversation.item.input_audio_transcription.failed"] item_id: str content_index: int @@ -252,6 +498,15 @@ class ConversationItemInputAudioTranscriptionFailed(ServerEvent): class ConversationItemTruncated(ServerEvent): + """Event indicating a conversation item has been truncated. + + Parameters: + type: Event type, always "conversation.item.truncated". + item_id: ID of the truncated conversation item. + content_index: Index of the content within the item. + audio_end_ms: End time in milliseconds for the truncated audio. + """ + type: Literal["conversation.item.truncated"] item_id: str content_index: int @@ -259,26 +514,63 @@ class ConversationItemTruncated(ServerEvent): class ConversationItemDeleted(ServerEvent): + """Event indicating a conversation item has been deleted. + + Parameters: + type: Event type, always "conversation.item.deleted". + item_id: ID of the deleted conversation item. + """ + type: Literal["conversation.item.deleted"] item_id: str class ConversationItemRetrieved(ServerEvent): + """Event containing a retrieved conversation item. + + Parameters: + type: Event type, always "conversation.item.retrieved". + item: The retrieved conversation item. + """ + type: Literal["conversation.item.retrieved"] item: ConversationItem class ResponseCreated(ServerEvent): + """Event indicating an assistant response has been created. + + Parameters: + type: Event type, always "response.created". + response: The created response object. + """ + type: Literal["response.created"] response: "Response" class ResponseDone(ServerEvent): + """Event indicating an assistant response is complete. + + Parameters: + type: Event type, always "response.done". + response: The completed response object. + """ + type: Literal["response.done"] response: "Response" class ResponseOutputItemAdded(ServerEvent): + """Event indicating an output item has been added to a response. + + Parameters: + type: Event type, always "response.output_item.added". + response_id: ID of the response. + output_index: Index of the output item. + item: The added conversation item. + """ + type: Literal["response.output_item.added"] response_id: str output_index: int @@ -286,6 +578,15 @@ class ResponseOutputItemAdded(ServerEvent): class ResponseOutputItemDone(ServerEvent): + """Event indicating an output item is complete. + + Parameters: + type: Event type, always "response.output_item.done". + response_id: ID of the response. + output_index: Index of the output item. + item: The completed conversation item. + """ + type: Literal["response.output_item.done"] response_id: str output_index: int @@ -293,6 +594,17 @@ class ResponseOutputItemDone(ServerEvent): class ResponseContentPartAdded(ServerEvent): + """Event indicating a content part has been added to a response. + + Parameters: + type: Event type, always "response.content_part.added". + response_id: ID of the response. + item_id: ID of the conversation item. + output_index: Index of the output item. + content_index: Index of the content part. + part: The added content part. + """ + type: Literal["response.content_part.added"] response_id: str item_id: str @@ -302,6 +614,17 @@ class ResponseContentPartAdded(ServerEvent): class ResponseContentPartDone(ServerEvent): + """Event indicating a content part is complete. + + Parameters: + type: Event type, always "response.content_part.done". + response_id: ID of the response. + item_id: ID of the conversation item. + output_index: Index of the output item. + content_index: Index of the content part. + part: The completed content part. + """ + type: Literal["response.content_part.done"] response_id: str item_id: str @@ -311,6 +634,17 @@ class ResponseContentPartDone(ServerEvent): class ResponseTextDelta(ServerEvent): + """Event containing incremental text from a response. + + Parameters: + type: Event type, always "response.text.delta". + response_id: ID of the response. + item_id: ID of the conversation item. + output_index: Index of the output item. + content_index: Index of the content part. + delta: Incremental text content. + """ + type: Literal["response.text.delta"] response_id: str item_id: str @@ -320,6 +654,17 @@ class ResponseTextDelta(ServerEvent): class ResponseTextDone(ServerEvent): + """Event indicating text content is complete. + + Parameters: + type: Event type, always "response.text.done". + response_id: ID of the response. + item_id: ID of the conversation item. + output_index: Index of the output item. + content_index: Index of the content part. + text: Complete text content. + """ + type: Literal["response.text.done"] response_id: str item_id: str @@ -329,6 +674,17 @@ class ResponseTextDone(ServerEvent): class ResponseAudioTranscriptDelta(ServerEvent): + """Event containing incremental audio transcript from a response. + + Parameters: + type: Event type, always "response.audio_transcript.delta". + response_id: ID of the response. + item_id: ID of the conversation item. + output_index: Index of the output item. + content_index: Index of the content part. + delta: Incremental transcript text. + """ + type: Literal["response.audio_transcript.delta"] response_id: str item_id: str @@ -338,6 +694,17 @@ class ResponseAudioTranscriptDelta(ServerEvent): class ResponseAudioTranscriptDone(ServerEvent): + """Event indicating audio transcript is complete. + + Parameters: + type: Event type, always "response.audio_transcript.done". + response_id: ID of the response. + item_id: ID of the conversation item. + output_index: Index of the output item. + content_index: Index of the content part. + transcript: Complete transcript text. + """ + type: Literal["response.audio_transcript.done"] response_id: str item_id: str @@ -347,6 +714,17 @@ class ResponseAudioTranscriptDone(ServerEvent): class ResponseAudioDelta(ServerEvent): + """Event containing incremental audio data from a response. + + Parameters: + type: Event type, always "response.audio.delta". + response_id: ID of the response. + item_id: ID of the conversation item. + output_index: Index of the output item. + content_index: Index of the content part. + delta: Base64-encoded incremental audio data. + """ + type: Literal["response.audio.delta"] response_id: str item_id: str @@ -356,6 +734,16 @@ class ResponseAudioDelta(ServerEvent): class ResponseAudioDone(ServerEvent): + """Event indicating audio content is complete. + + Parameters: + type: Event type, always "response.audio.done". + response_id: ID of the response. + item_id: ID of the conversation item. + output_index: Index of the output item. + content_index: Index of the content part. + """ + type: Literal["response.audio.done"] response_id: str item_id: str @@ -364,6 +752,17 @@ class ResponseAudioDone(ServerEvent): class ResponseFunctionCallArgumentsDelta(ServerEvent): + """Event containing incremental function call arguments. + + Parameters: + type: Event type, always "response.function_call_arguments.delta". + response_id: ID of the response. + item_id: ID of the conversation item. + output_index: Index of the output item. + call_id: ID of the function call. + delta: Incremental function arguments as JSON. + """ + type: Literal["response.function_call_arguments.delta"] response_id: str item_id: str @@ -373,6 +772,17 @@ class ResponseFunctionCallArgumentsDelta(ServerEvent): class ResponseFunctionCallArgumentsDone(ServerEvent): + """Event indicating function call arguments are complete. + + Parameters: + type: Event type, always "response.function_call_arguments.done". + response_id: ID of the response. + item_id: ID of the conversation item. + output_index: Index of the output item. + call_id: ID of the function call. + arguments: Complete function arguments as JSON string. + """ + type: Literal["response.function_call_arguments.done"] response_id: str item_id: str @@ -382,38 +792,90 @@ class ResponseFunctionCallArgumentsDone(ServerEvent): class InputAudioBufferSpeechStarted(ServerEvent): + """Event indicating speech has started in the input audio buffer. + + Parameters: + type: Event type, always "input_audio_buffer.speech_started". + audio_start_ms: Start time of speech in milliseconds. + item_id: ID of the associated conversation item. + """ + type: Literal["input_audio_buffer.speech_started"] audio_start_ms: int item_id: str class InputAudioBufferSpeechStopped(ServerEvent): + """Event indicating speech has stopped in the input audio buffer. + + Parameters: + type: Event type, always "input_audio_buffer.speech_stopped". + audio_end_ms: End time of speech in milliseconds. + item_id: ID of the associated conversation item. + """ + type: Literal["input_audio_buffer.speech_stopped"] audio_end_ms: int item_id: str class InputAudioBufferCommitted(ServerEvent): + """Event indicating the input audio buffer has been committed. + + Parameters: + type: Event type, always "input_audio_buffer.committed". + previous_item_id: ID of the previous item, if any. + item_id: ID of the committed conversation item. + """ + type: Literal["input_audio_buffer.committed"] previous_item_id: Optional[str] = None item_id: str class InputAudioBufferCleared(ServerEvent): + """Event indicating the input audio buffer has been cleared. + + Parameters: + type: Event type, always "input_audio_buffer.cleared". + """ + type: Literal["input_audio_buffer.cleared"] class ErrorEvent(ServerEvent): + """Event indicating an error occurred. + + Parameters: + type: Event type, always "error". + error: Error details. + """ + type: Literal["error"] error: RealtimeError class RateLimitsUpdated(ServerEvent): + """Event indicating rate limits have been updated. + + Parameters: + type: Event type, always "rate_limits.updated". + rate_limits: List of rate limit information. + """ + type: Literal["rate_limits.updated"] rate_limits: List[Dict[str, Any]] class TokenDetails(BaseModel): + """Detailed token usage information. + + Parameters: + cached_tokens: Number of cached tokens used. Defaults to 0. + text_tokens: Number of text tokens used. Defaults to 0. + audio_tokens: Number of audio tokens used. Defaults to 0. + """ + cached_tokens: Optional[int] = 0 text_tokens: Optional[int] = 0 audio_tokens: Optional[int] = 0 @@ -423,6 +885,16 @@ class TokenDetails(BaseModel): class Usage(BaseModel): + """Token usage statistics for a response. + + Parameters: + total_tokens: Total number of tokens used. + input_tokens: Number of input tokens used. + output_tokens: Number of output tokens used. + input_token_details: Detailed breakdown of input token usage. + output_token_details: Detailed breakdown of output token usage. + """ + total_tokens: int input_tokens: int output_tokens: int @@ -431,6 +903,17 @@ class Usage(BaseModel): class Response(BaseModel): + """A complete assistant response. + + Parameters: + id: Unique identifier for the response. + object: Object type, always "realtime.response". + status: Current status of the response. + status_details: Additional status information. + output: List of conversation items in the response. + usage: Token usage statistics for the response. + """ + id: str object: Literal["realtime.response"] status: Literal["completed", "in_progress", "incomplete", "cancelled", "failed"] @@ -474,6 +957,17 @@ _server_event_types = { def parse_server_event(str): + """Parse a server event from JSON string. + + Args: + str: JSON string containing the server event. + + Returns: + Parsed server event object of the appropriate type. + + Raises: + Exception: If the event type is unimplemented or parsing fails. + """ try: event = json.loads(str) event_type = event["type"] diff --git a/src/pipecat/services/openai_realtime_beta/frames.py b/src/pipecat/services/openai_realtime_beta/frames.py index 39de49b34..c28c9212f 100644 --- a/src/pipecat/services/openai_realtime_beta/frames.py +++ b/src/pipecat/services/openai_realtime_beta/frames.py @@ -4,16 +4,31 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Custom frame types for OpenAI Realtime API integration.""" + from dataclasses import dataclass from pipecat.frames.frames import DataFrame, FunctionCallResultFrame +from pipecat.services.openai_realtime_beta.context import OpenAIRealtimeLLMContext @dataclass class RealtimeMessagesUpdateFrame(DataFrame): + """Frame indicating that the realtime context messages have been updated. + + Parameters: + context: The updated OpenAI realtime LLM context. + """ + context: "OpenAIRealtimeLLMContext" @dataclass class RealtimeFunctionCallResultFrame(DataFrame): + """Frame containing function call results for the realtime service. + + Parameters: + result_frame: The function call result frame to send to the realtime API. + """ + result_frame: FunctionCallResultFrame diff --git a/src/pipecat/services/openai_realtime_beta/openai.py b/src/pipecat/services/openai_realtime_beta/openai.py index 8d5168c70..09761941b 100644 --- a/src/pipecat/services/openai_realtime_beta/openai.py +++ b/src/pipecat/services/openai_realtime_beta/openai.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""OpenAI Realtime Beta LLM service implementation with WebSocket support.""" + import base64 import json import time @@ -73,6 +75,15 @@ except ModuleNotFoundError as e: @dataclass class CurrentAudioResponse: + """Tracks the current audio response from the assistant. + + Parameters: + item_id: Unique identifier for the audio response item. + content_index: Index of the audio content within the item. + start_time_ms: Timestamp when the audio response started in milliseconds. + total_size: Total size of audio data received in bytes. Defaults to 0. + """ + item_id: str content_index: int start_time_ms: int @@ -80,6 +91,24 @@ class CurrentAudioResponse: class OpenAIRealtimeBetaLLMService(LLMService): + """OpenAI Realtime Beta LLM service providing real-time audio and text communication. + + Implements the OpenAI Realtime API Beta with WebSocket communication for low-latency + bidirectional audio and text interactions. Supports function calling, conversation + management, and real-time transcription. + + Args: + api_key: OpenAI API key for authentication. + model: OpenAI model name. Defaults to "gpt-4o-realtime-preview-2025-06-03". + base_url: WebSocket base URL for the realtime API. + Defaults to "wss://api.openai.com/v1/realtime". + session_properties: Configuration properties for the realtime session. + If None, uses default SessionProperties. + start_audio_paused: Whether to start with audio input paused. Defaults to False. + send_transcription_frames: Whether to emit transcription frames. Defaults to True. + **kwargs: Additional arguments passed to parent LLMService. + """ + # Overriding the default adapter to use the OpenAIRealtimeLLMAdapter one. adapter_class = OpenAIRealtimeLLMAdapter @@ -125,12 +154,30 @@ class OpenAIRealtimeBetaLLMService(LLMService): self._retrieve_conversation_item_futures = {} def can_generate_metrics(self) -> bool: + """Check if the service can generate usage metrics. + + Returns: + True if metrics generation is supported. + """ return True def set_audio_input_paused(self, paused: bool): + """Set whether audio input is paused. + + Args: + paused: True to pause audio input, False to resume. + """ self._audio_input_paused = paused async def retrieve_conversation_item(self, item_id: str): + """Retrieve a conversation item by ID from the server. + + Args: + item_id: The ID of the conversation item to retrieve. + + Returns: + The retrieved conversation item. + """ future = self.get_event_loop().create_future() retrieval_in_flight = False if not self._retrieve_conversation_item_futures.get(item_id): @@ -154,14 +201,29 @@ class OpenAIRealtimeBetaLLMService(LLMService): # async def start(self, frame: StartFrame): + """Start the service and establish WebSocket connection. + + Args: + frame: The start frame triggering service initialization. + """ await super().start(frame) await self._connect() async def stop(self, frame: EndFrame): + """Stop the service and close WebSocket connection. + + Args: + frame: The end frame triggering service shutdown. + """ await super().stop(frame) await self._disconnect() async def cancel(self, frame: CancelFrame): + """Cancel the service and close WebSocket connection. + + Args: + frame: The cancel frame triggering service cancellation. + """ await super().cancel(frame) await self._disconnect() @@ -247,6 +309,12 @@ class OpenAIRealtimeBetaLLMService(LLMService): # async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process incoming frames from the pipeline. + + Args: + frame: The frame to process. + direction: The direction of frame flow in the pipeline. + """ await super().process_frame(frame, direction) if isinstance(frame, TranscriptionFrame): @@ -304,6 +372,11 @@ class OpenAIRealtimeBetaLLMService(LLMService): # async def send_client_event(self, event: events.ClientEvent): + """Send a client event to the OpenAI Realtime API. + + Args: + event: The client event to send. + """ await self._ws_send(event.model_dump(exclude_none=True)) async def _connect(self): @@ -478,6 +551,11 @@ class OpenAIRealtimeBetaLLMService(LLMService): pass async def handle_evt_input_audio_transcription_completed(self, evt): + """Handle completion of input audio transcription. + + Args: + evt: The transcription completed event. + """ await self._call_event_handler("on_conversation_item_updated", evt.item_id, None) if self._send_transcription_frames: @@ -558,7 +636,9 @@ class OpenAIRealtimeBetaLLMService(LLMService): await self.push_frame(UserStoppedSpeakingFrame()) async def _maybe_handle_evt_retrieve_conversation_item_error(self, evt: events.ErrorEvent): - """If the given error event is an error retrieving a conversation item: + """Maybe handle an error event related to retrieving a conversation item. + + If the given error event is an error retrieving a conversation item: - set an exception on the future that retrieve_conversation_item() is waiting on - return true Otherwise: @@ -605,8 +685,11 @@ class OpenAIRealtimeBetaLLMService(LLMService): # async def reset_conversation(self): - # Disconnect/reconnect is the safest way to start a new conversation. - # Note that this will fail if called from the receive task. + """Reset the conversation by disconnecting and reconnecting. + + This is the safest way to start a new conversation. Note that this will + fail if called from the receive task. + """ logger.debug("Resetting conversation") await self._disconnect() if self._context: @@ -654,22 +737,19 @@ class OpenAIRealtimeBetaLLMService(LLMService): user_params: LLMUserAggregatorParams = LLMUserAggregatorParams(), assistant_params: LLMAssistantAggregatorParams = LLMAssistantAggregatorParams(), ) -> OpenAIContextAggregatorPair: - """Create an instance of OpenAIContextAggregatorPair from an - OpenAILLMContext. Constructor keyword arguments for both the user and - assistant aggregators can be provided. + """Create an instance of OpenAIContextAggregatorPair from an OpenAILLMContext. + + Constructor keyword arguments for both the user and assistant aggregators can be provided. Args: - context (OpenAILLMContext): The LLM context. - user_params (LLMUserAggregatorParams, optional): User aggregator - parameters. - assistant_params (LLMAssistantAggregatorParams, optional): User - aggregator parameters. + context: The LLM context. + user_params: User aggregator parameters. + assistant_params: Assistant aggregator parameters. Returns: OpenAIContextAggregatorPair: A pair of context aggregators, one for the user and one for the assistant, encapsulated in an OpenAIContextAggregatorPair. - """ context.set_llm_adapter(self.get_llm_adapter()) From 0e4d2be98cc040563232a9fc2e344871e6aaae29 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 26 Jun 2025 12:12:00 -0400 Subject: [PATCH 51/54] Update AzureRealtimeBetaLLMService docstrings --- .../services/openai_realtime_beta/azure.py | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/pipecat/services/openai_realtime_beta/azure.py b/src/pipecat/services/openai_realtime_beta/azure.py index 799c5e686..a6cde33f9 100644 --- a/src/pipecat/services/openai_realtime_beta/azure.py +++ b/src/pipecat/services/openai_realtime_beta/azure.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD 2-Clause License # +"""Azure OpenAI Realtime Beta LLM service implementation.""" + from loguru import logger from .openai import OpenAIRealtimeBetaLLMService @@ -19,7 +21,18 @@ except ModuleNotFoundError as e: class AzureRealtimeBetaLLMService(OpenAIRealtimeBetaLLMService): - """Subclass of OpenAI Realtime API Service with adjustments for Azure's wss connection.""" + """Azure OpenAI Realtime Beta LLM service with Azure-specific authentication. + + Extends the OpenAI Realtime service to work with Azure OpenAI endpoints, + using Azure's authentication headers and endpoint format. Provides the same + real-time audio and text communication capabilities as the base OpenAI service. + + Args: + api_key: The API key for the Azure OpenAI service. + base_url: The full Azure WebSocket endpoint URL including api-version and deployment. + Example: "wss://my-project.openai.azure.com/openai/realtime?api-version=2024-10-01-preview&deployment=my-realtime-deployment" + **kwargs: Additional arguments passed to parent OpenAIRealtimeBetaLLMService. + """ def __init__( self, @@ -28,16 +41,6 @@ class AzureRealtimeBetaLLMService(OpenAIRealtimeBetaLLMService): base_url: str, **kwargs, ): - """Constructor takes the same arguments as the parent class, OpenAIRealtimeBetaLLMService. - - Note that the following are required arguments: - api_key: The API key for the Azure OpenAI service. - base_url: The base URL for the Azure OpenAI service. - - base_url should be set to the full Azure endpoint URL including the api-version and the deployment name. For example, - - wss://my-project.openai.azure.com/openai/realtime?api-version=2024-10-01-preview&deployment=my-realtime-deployment - """ super().__init__(base_url=base_url, api_key=api_key, **kwargs) self.api_key = api_key self.base_url = base_url From 3de4f22d34ded3b19331925ba56eaa66caec9508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Thu, 26 Jun 2025 09:29:33 -0700 Subject: [PATCH 52/54] utils(asyncio): simplify watchdog helpers --- src/pipecat/pipeline/parallel_pipeline.py | 4 +- .../pipeline/sync_parallel_pipeline.py | 4 +- src/pipecat/pipeline/task.py | 24 +++++------- src/pipecat/pipeline/task_observer.py | 10 +---- src/pipecat/processors/consumer_processor.py | 3 +- src/pipecat/processors/frame_processor.py | 37 ++++++++----------- src/pipecat/processors/frameworks/rtvi.py | 4 +- .../metrics/frame_processor_metrics.py | 6 +-- src/pipecat/processors/metrics/sentry.py | 15 +++----- src/pipecat/processors/producer_processor.py | 4 +- src/pipecat/services/anthropic/llm.py | 4 +- src/pipecat/services/cartesia/tts.py | 2 +- src/pipecat/services/elevenlabs/tts.py | 2 +- .../services/gemini_multimodal_live/gemini.py | 4 +- src/pipecat/services/gladia/stt.py | 4 +- src/pipecat/services/google/llm.py | 4 +- src/pipecat/services/google/llm_openai.py | 4 +- src/pipecat/services/google/stt.py | 2 +- src/pipecat/services/neuphonic/tts.py | 4 +- src/pipecat/services/openai/base_llm.py | 4 +- .../services/openai_realtime_beta/openai.py | 4 +- src/pipecat/services/riva/stt.py | 4 +- src/pipecat/services/sambanova/llm.py | 4 +- src/pipecat/services/simli/video.py | 8 +--- src/pipecat/services/tavus/video.py | 2 +- src/pipecat/services/tts_service.py | 6 +-- src/pipecat/transports/base_output.py | 4 +- .../transports/network/fastapi_websocket.py | 2 +- .../transports/network/small_webrtc.py | 4 +- src/pipecat/transports/services/daily.py | 13 ++----- src/pipecat/transports/services/livekit.py | 4 +- src/pipecat/utils/asyncio/task_manager.py | 33 +++++++++++++---- .../utils/asyncio/watchdog_async_iterator.py | 14 +++---- src/pipecat/utils/asyncio/watchdog_event.py | 14 +++---- .../utils/asyncio/watchdog_priority_queue.py | 18 ++++----- src/pipecat/utils/asyncio/watchdog_queue.py | 18 ++++----- src/pipecat/utils/asyncio/watchdog_reseter.py | 13 ------- 37 files changed, 126 insertions(+), 184 deletions(-) delete mode 100644 src/pipecat/utils/asyncio/watchdog_reseter.py diff --git a/src/pipecat/pipeline/parallel_pipeline.py b/src/pipecat/pipeline/parallel_pipeline.py index 7068ed86d..300492cc5 100644 --- a/src/pipecat/pipeline/parallel_pipeline.py +++ b/src/pipecat/pipeline/parallel_pipeline.py @@ -102,8 +102,8 @@ class ParallelPipeline(BasePipeline): async def setup(self, setup: FrameProcessorSetup): await super().setup(setup) - self._up_queue = WatchdogQueue(self, watchdog_enabled=setup.watchdog_timers_enabled) - self._down_queue = WatchdogQueue(self, watchdog_enabled=setup.watchdog_timers_enabled) + self._up_queue = WatchdogQueue(setup.task_manager) + self._down_queue = WatchdogQueue(setup.task_manager) logger.debug(f"Creating {self} pipelines") for processors in self._args: diff --git a/src/pipecat/pipeline/sync_parallel_pipeline.py b/src/pipecat/pipeline/sync_parallel_pipeline.py index 6b178bc1c..006290710 100644 --- a/src/pipecat/pipeline/sync_parallel_pipeline.py +++ b/src/pipecat/pipeline/sync_parallel_pipeline.py @@ -81,8 +81,8 @@ class SyncParallelPipeline(BasePipeline): async def setup(self, setup: FrameProcessorSetup): await super().setup(setup) - self._up_queue = WatchdogQueue(self, watchdog_enabled=setup.watchdog_timers_enabled) - self._down_queue = WatchdogQueue(self, watchdog_enabled=setup.watchdog_timers_enabled) + self._up_queue = WatchdogQueue(setup.task_manager) + self._down_queue = WatchdogQueue(setup.task_manager) logger.debug(f"Creating {self} pipelines") for processors in self._args: diff --git a/src/pipecat/pipeline/task.py b/src/pipecat/pipeline/task.py index 8b30d1a4e..3e8a36b6c 100644 --- a/src/pipecat/pipeline/task.py +++ b/src/pipecat/pipeline/task.py @@ -45,7 +45,6 @@ from pipecat.utils.asyncio.task_manager import ( TaskManagerParams, ) from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue -from pipecat.utils.asyncio.watchdog_reseter import WatchdogReseter from pipecat.utils.tracing.setup import is_tracing_available from pipecat.utils.tracing.turn_trace_observer import TurnTraceObserver @@ -138,7 +137,7 @@ class PipelineTaskSink(FrameProcessor): await self._down_queue.put(frame) -class PipelineTask(WatchdogReseter, BasePipelineTask): +class PipelineTask(BasePipelineTask): """Manages the execution of a pipeline, handling frame processing and task lifecycle. It has a couple of event handlers `on_frame_reached_upstream` and @@ -270,24 +269,28 @@ class PipelineTask(WatchdogReseter, BasePipelineTask): self._finished = False self._cancelled = False + # This task maneger will handle all the asyncio tasks created by this + # PipelineTask and its frame processors. + self._task_manager = task_manager or TaskManager() + # This queue receives frames coming from the pipeline upstream. - self._up_queue = WatchdogQueue(self, watchdog_enabled=enable_watchdog_timers) + self._up_queue = WatchdogQueue(self._task_manager) self._process_up_task: Optional[asyncio.Task] = None # This queue receives frames coming from the pipeline downstream. - self._down_queue = WatchdogQueue(self, watchdog_enabled=enable_watchdog_timers) + self._down_queue = WatchdogQueue(self._task_manager) self._process_down_task: Optional[asyncio.Task] = None # This queue is the queue used to push frames to the pipeline. - self._push_queue = WatchdogQueue(self, watchdog_enabled=enable_watchdog_timers) + self._push_queue = WatchdogQueue(self._task_manager) self._process_push_task: Optional[asyncio.Task] = None # This is the heartbeat queue. When a heartbeat frame is received in the # down queue we add it to the heartbeat queue for processing. - self._heartbeat_queue = WatchdogQueue(self, watchdog_enabled=enable_watchdog_timers) + self._heartbeat_queue = WatchdogQueue(self._task_manager) self._heartbeat_push_task: Optional[asyncio.Task] = None self._heartbeat_monitor_task: Optional[asyncio.Task] = None # This is the idle queue. When frames are received downstream they are # put in the queue. If no frame is received the pipeline is considered # idle. - self._idle_queue = WatchdogQueue(self, watchdog_enabled=enable_watchdog_timers) + self._idle_queue = WatchdogQueue(self._task_manager) self._idle_monitor_task: Optional[asyncio.Task] = None # This event is used to indicate a finalize frame (e.g. EndFrame, # StopFrame) has been received in the down queue. @@ -305,10 +308,6 @@ class PipelineTask(WatchdogReseter, BasePipelineTask): self._sink = PipelineTaskSink(self._down_queue) pipeline.link(self._sink) - # This task maneger will handle all the asyncio tasks created by this - # PipelineTask and its frame processors. - self._task_manager = task_manager or TaskManager() - # The task observer acts as a proxy to the provided observers. This way, # we only need to pass a single observer (using the StartFrame) which # then just acts as a proxy. @@ -440,9 +439,6 @@ class PipelineTask(WatchdogReseter, BasePipelineTask): for frame in frames: await self.queue_frame(frame) - def reset_watchdog(self): - self._task_manager.reset_watchdog(asyncio.current_task()) - async def _cancel(self): if not self._cancelled: logger.debug(f"Canceling pipeline task {self}") diff --git a/src/pipecat/pipeline/task_observer.py b/src/pipecat/pipeline/task_observer.py index ae4decbce..db03a73d1 100644 --- a/src/pipecat/pipeline/task_observer.py +++ b/src/pipecat/pipeline/task_observer.py @@ -13,7 +13,6 @@ from attr import dataclass from pipecat.observers.base_observer import BaseObserver, FramePushed from pipecat.utils.asyncio.task_manager import BaseTaskManager from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue -from pipecat.utils.asyncio.watchdog_reseter import WatchdogReseter @dataclass @@ -28,7 +27,7 @@ class Proxy: observer: BaseObserver -class TaskObserver(WatchdogReseter, BaseObserver): +class TaskObserver(BaseObserver): """This is a pipeline frame observer that is meant to be used as a proxy to the user provided observers. That is, this is the observer that should be passed to the frame processors. Then, every time a frame is pushed this @@ -54,7 +53,6 @@ class TaskObserver(WatchdogReseter, BaseObserver): self._proxies: Optional[Dict[BaseObserver, Proxy]] = ( None # Becomes a dict after start() is called ) - self._watchdog_timers_enabled = False def add_observer(self, observer: BaseObserver): # Add the observer to the list. @@ -81,7 +79,6 @@ class TaskObserver(WatchdogReseter, BaseObserver): async def start(self, watchdog_timers_enabled: bool = False): """Starts all proxy observer tasks.""" - self._watchdog_timers_enabled = watchdog_timers_enabled self._proxies = self._create_proxies(self._observers) async def stop(self): @@ -96,14 +93,11 @@ class TaskObserver(WatchdogReseter, BaseObserver): for proxy in self._proxies.values(): await proxy.queue.put(data) - def reset_watchdog(self): - self._task_manager.reset_watchdog(asyncio.current_task()) - def _started(self) -> bool: return self._proxies is not None def _create_proxy(self, observer: BaseObserver) -> Proxy: - queue = WatchdogQueue(self, watchdog_enabled=self._watchdog_timers_enabled) + queue = WatchdogQueue(self._task_manager) task = self._task_manager.create_task( self._proxy_task_handler(queue, observer), f"TaskObserver::{observer}::_proxy_task_handler", diff --git a/src/pipecat/processors/consumer_processor.py b/src/pipecat/processors/consumer_processor.py index 0440a74e1..977450181 100644 --- a/src/pipecat/processors/consumer_processor.py +++ b/src/pipecat/processors/consumer_processor.py @@ -32,7 +32,7 @@ class ConsumerProcessor(FrameProcessor): super().__init__(**kwargs) self._transformer = transformer self._direction = direction - self._queue: WatchdogQueue = producer.add_consumer(self) + self._producer = producer self._consumer_task: Optional[asyncio.Task] = None async def process_frame(self, frame: Frame, direction: FrameDirection): @@ -49,6 +49,7 @@ class ConsumerProcessor(FrameProcessor): async def _start(self, _: StartFrame): if not self._consumer_task: + self._queue: WatchdogQueue = self._producer.add_consumer() self._consumer_task = self.create_task(self._consumer_task_handler()) async def _stop(self, _: EndFrame): diff --git a/src/pipecat/processors/frame_processor.py b/src/pipecat/processors/frame_processor.py index 260e549fa..1935aeb2b 100644 --- a/src/pipecat/processors/frame_processor.py +++ b/src/pipecat/processors/frame_processor.py @@ -32,7 +32,6 @@ from pipecat.processors.metrics.frame_processor_metrics import FrameProcessorMet from pipecat.utils.asyncio.task_manager import BaseTaskManager from pipecat.utils.asyncio.watchdog_event import WatchdogEvent from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue -from pipecat.utils.asyncio.watchdog_reseter import WatchdogReseter from pipecat.utils.base_object import BaseObject @@ -49,7 +48,7 @@ class FrameProcessorSetup: watchdog_timers_enabled: bool = False -class FrameProcessor(WatchdogReseter, BaseObject): +class FrameProcessor(BaseObject): def __init__( self, *, @@ -89,7 +88,6 @@ class FrameProcessor(WatchdogReseter, BaseObject): self._enable_usage_metrics = False self._report_only_initial_ttfb = False self._interruption_strategies: List[BaseInterruptionStrategy] = [] - self._watchdog_timers_enabled = False # Indicates whether we have received the StartFrame. self.__started = False @@ -147,8 +145,10 @@ class FrameProcessor(WatchdogReseter, BaseObject): return self._interruption_strategies @property - def watchdog_timers_enabled(self): - return self._watchdog_timers_enabled + def task_manager(self) -> BaseTaskManager: + if not self._task_manager: + raise Exception(f"{self} TaskManager is still not initialized.") + return self._task_manager def can_generate_metrics(self) -> bool: return False @@ -205,7 +205,7 @@ class FrameProcessor(WatchdogReseter, BaseObject): name = f"{self}::{name}" else: name = f"{self}::{coroutine.cr_code.co_name}" - return self.get_task_manager().create_task( + return self.task_manager.create_task( coroutine, name, enable_watchdog_logging=( @@ -214,7 +214,7 @@ class FrameProcessor(WatchdogReseter, BaseObject): else self._enable_watchdog_logging ), enable_watchdog_timers=( - enable_watchdog_timers if enable_watchdog_timers else self.watchdog_timers_enabled + enable_watchdog_timers if enable_watchdog_timers else self._enable_watchdog_timers ), watchdog_timeout=( watchdog_timeout_secs if watchdog_timeout_secs else self._watchdog_timeout_secs @@ -222,13 +222,13 @@ class FrameProcessor(WatchdogReseter, BaseObject): ) async def cancel_task(self, task: asyncio.Task, timeout: Optional[float] = None): - await self.get_task_manager().cancel_task(task, timeout) + await self.task_manager.cancel_task(task, timeout) async def wait_for_task(self, task: asyncio.Task, timeout: Optional[float] = None): - await self.get_task_manager().wait_for_task(task, timeout) + await self.task_manager.wait_for_task(task, timeout) def reset_watchdog(self): - self.get_task_manager().reset_watchdog(asyncio.current_task()) + self.task_manager.task_reset_watchdog() async def setup(self, setup: FrameProcessorSetup): self._clock = setup.clock @@ -240,7 +240,7 @@ class FrameProcessor(WatchdogReseter, BaseObject): else setup.watchdog_timers_enabled ) if self._metrics is not None: - await self._metrics.setup(self._task_manager, self._watchdog_timers_enabled) + await self._metrics.setup(self._task_manager) async def cleanup(self): await super().cleanup() @@ -255,7 +255,7 @@ class FrameProcessor(WatchdogReseter, BaseObject): logger.debug(f"Linking {self} -> {self._next}") def get_event_loop(self) -> asyncio.AbstractEventLoop: - return self.get_task_manager().get_event_loop() + return self.task_manager.get_event_loop() def set_parent(self, parent: "FrameProcessor"): self._parent = parent @@ -268,11 +268,6 @@ class FrameProcessor(WatchdogReseter, BaseObject): raise Exception(f"{self} Clock is still not initialized.") return self._clock - def get_task_manager(self) -> BaseTaskManager: - if not self._task_manager: - raise Exception(f"{self} TaskManager is still not initialized.") - return self._task_manager - async def queue_frame( self, frame: Frame, @@ -417,11 +412,9 @@ class FrameProcessor(WatchdogReseter, BaseObject): if not self.__input_frame_task: self.__should_block_frames = False if not self.__input_event: - self.__input_event = WatchdogEvent( - self, watchdog_enabled=self.watchdog_timers_enabled - ) + self.__input_event = WatchdogEvent(self.task_manager) self.__input_event.clear() - self.__input_queue = WatchdogQueue(self, watchdog_enabled=self.watchdog_timers_enabled) + self.__input_queue = WatchdogQueue(self.task_manager) self.__input_frame_task = self.create_task(self.__input_frame_task_handler()) async def __cancel_input_task(self): @@ -453,7 +446,7 @@ class FrameProcessor(WatchdogReseter, BaseObject): def __create_push_task(self): if not self.__push_frame_task: - self.__push_queue = WatchdogQueue(self, watchdog_enabled=self.watchdog_timers_enabled) + self.__push_queue = WatchdogQueue(self.task_manager) self.__push_frame_task = self.create_task(self.__push_frame_task_handler()) async def __cancel_push_task(self): diff --git a/src/pipecat/processors/frameworks/rtvi.py b/src/pipecat/processors/frameworks/rtvi.py index b379e522a..09291c422 100644 --- a/src/pipecat/processors/frameworks/rtvi.py +++ b/src/pipecat/processors/frameworks/rtvi.py @@ -755,10 +755,10 @@ class RTVIProcessor(FrameProcessor): async def _start(self, frame: StartFrame): if not self._action_task: - self._action_queue = WatchdogQueue(self, watchdog_enabled=self.watchdog_timers_enabled) + self._action_queue = WatchdogQueue(self.task_manager) self._action_task = self.create_task(self._action_task_handler()) if not self._message_task: - self._message_queue = WatchdogQueue(self, watchdog_enabled=self.watchdog_timers_enabled) + self._message_queue = WatchdogQueue(self.task_manager) self._message_task = self.create_task(self._message_task_handler()) await self._call_event_handler("on_bot_started") diff --git a/src/pipecat/processors/metrics/frame_processor_metrics.py b/src/pipecat/processors/metrics/frame_processor_metrics.py index ec1501122..cf08f85f6 100644 --- a/src/pipecat/processors/metrics/frame_processor_metrics.py +++ b/src/pipecat/processors/metrics/frame_processor_metrics.py @@ -18,7 +18,7 @@ from pipecat.metrics.metrics import ( TTFBMetricsData, TTSUsageMetricsData, ) -from pipecat.utils.asyncio.task_manager import TaskManager +from pipecat.utils.asyncio.task_manager import BaseTaskManager from pipecat.utils.base_object import BaseObject @@ -31,14 +31,14 @@ class FrameProcessorMetrics(BaseObject): self._last_ttfb_time = 0 self._should_report_ttfb = True - async def setup(self, task_manager: TaskManager, watchdog_timers_enabled: bool = False): + async def setup(self, task_manager: BaseTaskManager): self._task_manager = task_manager async def cleanup(self): await super().cleanup() @property - def task_manager(self) -> TaskManager: + def task_manager(self) -> BaseTaskManager: return self._task_manager @property diff --git a/src/pipecat/processors/metrics/sentry.py b/src/pipecat/processors/metrics/sentry.py index b19e9aa04..32b04a59b 100644 --- a/src/pipecat/processors/metrics/sentry.py +++ b/src/pipecat/processors/metrics/sentry.py @@ -8,9 +8,8 @@ import asyncio from loguru import logger -from pipecat.utils.asyncio.task_manager import TaskManager +from pipecat.utils.asyncio.task_manager import BaseTaskManager from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue -from pipecat.utils.asyncio.watchdog_reseter import WatchdogReseter try: import sentry_sdk @@ -22,7 +21,7 @@ except ModuleNotFoundError as e: from pipecat.processors.metrics.frame_processor_metrics import FrameProcessorMetrics -class SentryMetrics(WatchdogReseter, FrameProcessorMetrics): +class SentryMetrics(FrameProcessorMetrics): def __init__(self): super().__init__() self._ttfb_metrics_tx = None @@ -32,10 +31,10 @@ class SentryMetrics(WatchdogReseter, FrameProcessorMetrics): logger.warning("Sentry SDK not initialized. Sentry features will be disabled.") self._sentry_task = None - async def setup(self, task_manager: TaskManager, watchdog_timers_enabled: bool = False): - await super().setup(task_manager, watchdog_timers_enabled) + async def setup(self, task_manager: BaseTaskManager): + await super().setup(task_manager) if self._sentry_available: - self._sentry_queue = WatchdogQueue(self, watchdog_enabled=watchdog_timers_enabled) + self._sentry_queue = WatchdogQueue(task_manager) self._sentry_task = self.task_manager.create_task( self._sentry_task_handler(), name=f"{self}::_sentry_task_handler" ) @@ -49,10 +48,6 @@ class SentryMetrics(WatchdogReseter, FrameProcessorMetrics): logger.trace(f"{self} Flushing Sentry metrics") sentry_sdk.flush(timeout=5.0) - def reset_watchdog(self): - if self._task_manager: - self._task_manager.reset_watchdog(asyncio.current_task()) - async def start_ttfb_metrics(self, report_only_initial_ttfb): await super().start_ttfb_metrics(report_only_initial_ttfb) diff --git a/src/pipecat/processors/producer_processor.py b/src/pipecat/processors/producer_processor.py index e00ac6e84..0a41269fb 100644 --- a/src/pipecat/processors/producer_processor.py +++ b/src/pipecat/processors/producer_processor.py @@ -37,14 +37,14 @@ class ProducerProcessor(FrameProcessor): self._passthrough = passthrough self._consumers: List[asyncio.Queue] = [] - def add_consumer(self, consumer: FrameProcessor): + def add_consumer(self): """ Adds a new consumer and returns its associated queue. Returns: asyncio.Queue: The queue for the newly added consumer. """ - queue = WatchdogQueue(consumer, watchdog_enabled=self.watchdog_timers_enabled) + queue = WatchdogQueue(self.task_manager) self._consumers.append(queue) return queue diff --git a/src/pipecat/services/anthropic/llm.py b/src/pipecat/services/anthropic/llm.py index 246efcf26..657903b90 100644 --- a/src/pipecat/services/anthropic/llm.py +++ b/src/pipecat/services/anthropic/llm.py @@ -204,9 +204,7 @@ class AnthropicLLMService(LLMService): json_accumulator = "" function_calls = [] - async for event in WatchdogAsyncIterator( - response, reseter=self, watchdog_enabled=self.watchdog_timers_enabled - ): + async for event in WatchdogAsyncIterator(response, manager=self.task_manager): # Aggregate streaming content, create frames, trigger events if event.type == "content_block_delta": diff --git a/src/pipecat/services/cartesia/tts.py b/src/pipecat/services/cartesia/tts.py index 2309c9d8c..0edbb1f11 100644 --- a/src/pipecat/services/cartesia/tts.py +++ b/src/pipecat/services/cartesia/tts.py @@ -329,7 +329,7 @@ class CartesiaTTSService(AudioContextWordTTSService): async def _receive_messages(self): async for message in WatchdogAsyncIterator( - self._get_websocket(), reseter=self, watchdog_enabled=self.watchdog_timers_enabled + self._get_websocket(), manager=self.task_manager ): msg = json.loads(message) if not msg or not self.audio_context_available(msg["context_id"]): diff --git a/src/pipecat/services/elevenlabs/tts.py b/src/pipecat/services/elevenlabs/tts.py index a8c4ae0c9..3b1a3a20c 100644 --- a/src/pipecat/services/elevenlabs/tts.py +++ b/src/pipecat/services/elevenlabs/tts.py @@ -396,7 +396,7 @@ class ElevenLabsTTSService(AudioContextWordTTSService): async def _receive_messages(self): async for message in WatchdogAsyncIterator( - self._get_websocket(), reseter=self, watchdog_enabled=self.watchdog_timers_enabled + self._get_websocket(), manager=self.task_manager ): msg = json.loads(message) diff --git a/src/pipecat/services/gemini_multimodal_live/gemini.py b/src/pipecat/services/gemini_multimodal_live/gemini.py index 8424a8625..6c25a97ad 100644 --- a/src/pipecat/services/gemini_multimodal_live/gemini.py +++ b/src/pipecat/services/gemini_multimodal_live/gemini.py @@ -687,9 +687,7 @@ class GeminiMultimodalLiveLLMService(LLMService): # async def _receive_task_handler(self): - async for message in WatchdogAsyncIterator( - self._websocket, reseter=self, watchdog_enabled=self.watchdog_timers_enabled - ): + async for message in WatchdogAsyncIterator(self._websocket, manager=self.task_manager): evt = events.parse_server_event(message) # logger.debug(f"Received event: {message[:500]}") # logger.debug(f"Received event: {evt}") diff --git a/src/pipecat/services/gladia/stt.py b/src/pipecat/services/gladia/stt.py index b0ba81470..27b6ff1d9 100644 --- a/src/pipecat/services/gladia/stt.py +++ b/src/pipecat/services/gladia/stt.py @@ -504,9 +504,7 @@ class GladiaSTTService(STTService): async def _receive_task_handler(self): try: - async for message in WatchdogAsyncIterator( - self._websocket, reseter=self, watchdog_enabled=self.watchdog_timers_enabled - ): + async for message in WatchdogAsyncIterator(self._websocket, manager=self.task_manager): content = json.loads(message) # Handle audio chunk acknowledgments diff --git a/src/pipecat/services/google/llm.py b/src/pipecat/services/google/llm.py index a7d9b018d..75d0bd0ad 100644 --- a/src/pipecat/services/google/llm.py +++ b/src/pipecat/services/google/llm.py @@ -558,9 +558,7 @@ class GoogleLLMService(LLMService): ) function_calls = [] - async for chunk in WatchdogAsyncIterator( - response, reseter=self, watchdog_enabled=self.watchdog_timers_enabled - ): + async for chunk in WatchdogAsyncIterator(response, manager=self.task_manager): # Stop TTFB metrics after the first chunk await self.stop_ttfb_metrics() if chunk.usage_metadata: diff --git a/src/pipecat/services/google/llm_openai.py b/src/pipecat/services/google/llm_openai.py index af49fa6e0..8677179c9 100644 --- a/src/pipecat/services/google/llm_openai.py +++ b/src/pipecat/services/google/llm_openai.py @@ -54,9 +54,7 @@ class GoogleLLMOpenAIBetaService(OpenAILLMService): context ) - async for chunk in WatchdogAsyncIterator( - chunk_stream, reseter=self, watchdog_enabled=self.watchdog_timers_enabled - ): + async for chunk in WatchdogAsyncIterator(chunk_stream, manager=self.task_manager): if chunk.usage: tokens = LLMTokenUsage( prompt_tokens=chunk.usage.prompt_tokens, diff --git a/src/pipecat/services/google/stt.py b/src/pipecat/services/google/stt.py index 157810dd7..274aba2fa 100644 --- a/src/pipecat/services/google/stt.py +++ b/src/pipecat/services/google/stt.py @@ -785,7 +785,7 @@ class GoogleSTTService(STTService): """Process streaming recognition responses.""" try: async for response in WatchdogAsyncIterator( - streaming_recognize, reseter=self, watchdog_enabled=self.watchdog_timers_enabled + streaming_recognize, manager=self.task_manager ): # Check streaming limit if (int(time.time() * 1000) - self._stream_start_time) > self.STREAMING_LIMIT: diff --git a/src/pipecat/services/neuphonic/tts.py b/src/pipecat/services/neuphonic/tts.py index 6d9a47a03..079a29420 100644 --- a/src/pipecat/services/neuphonic/tts.py +++ b/src/pipecat/services/neuphonic/tts.py @@ -222,9 +222,7 @@ class NeuphonicTTSService(InterruptibleTTSService): self._websocket = None async def _receive_messages(self): - async for message in WatchdogAsyncIterator( - self._websocket, reseter=self, watchdog_enabled=self.watchdog_timers_enabled - ): + async for message in WatchdogAsyncIterator(self._websocket, manager=self.task_manager): if isinstance(message, str): msg = json.loads(message) if msg.get("data", {}).get("audio") is not None: diff --git a/src/pipecat/services/openai/base_llm.py b/src/pipecat/services/openai/base_llm.py index f3e6b0bb3..b307b3e21 100644 --- a/src/pipecat/services/openai/base_llm.py +++ b/src/pipecat/services/openai/base_llm.py @@ -245,9 +245,7 @@ class BaseOpenAILLMService(LLMService): context ) - async for chunk in WatchdogAsyncIterator( - chunk_stream, reseter=self, watchdog_enabled=self.watchdog_timers_enabled - ): + async for chunk in WatchdogAsyncIterator(chunk_stream, manager=self.task_manager): if chunk.usage: tokens = LLMTokenUsage( prompt_tokens=chunk.usage.prompt_tokens, diff --git a/src/pipecat/services/openai_realtime_beta/openai.py b/src/pipecat/services/openai_realtime_beta/openai.py index da70b6118..d6ff23111 100644 --- a/src/pipecat/services/openai_realtime_beta/openai.py +++ b/src/pipecat/services/openai_realtime_beta/openai.py @@ -370,9 +370,7 @@ class OpenAIRealtimeBetaLLMService(LLMService): # async def _receive_task_handler(self): - async for message in WatchdogAsyncIterator( - self._websocket, reseter=self, watchdog_enabled=self.watchdog_timers_enabled - ): + async for message in WatchdogAsyncIterator(self._websocket, manager=self.task_manager): evt = events.parse_server_event(message) if evt.type == "session.created": await self._handle_evt_session_created(evt) diff --git a/src/pipecat/services/riva/stt.py b/src/pipecat/services/riva/stt.py index 22fde1f54..0d2330bef 100644 --- a/src/pipecat/services/riva/stt.py +++ b/src/pipecat/services/riva/stt.py @@ -199,9 +199,7 @@ class RivaSTTService(STTService): self._thread_task = self.create_task(self._thread_task_handler()) if not self._response_task: - self._response_queue = WatchdogQueue( - self, watchdog_enabled=self.watchdog_timers_enabled - ) + self._response_queue = WatchdogQueue(self.task_manager) self._response_task = self.create_task(self._response_task_handler()) async def stop(self, frame: EndFrame): diff --git a/src/pipecat/services/sambanova/llm.py b/src/pipecat/services/sambanova/llm.py index d70860a7e..19382819c 100644 --- a/src/pipecat/services/sambanova/llm.py +++ b/src/pipecat/services/sambanova/llm.py @@ -95,9 +95,7 @@ class SambaNovaLLMService(OpenAILLMService): # type: ignore context ) - async for chunk in WatchdogAsyncIterator( - chunk_stream, reseter=self, watchdog_enabled=self.watchdog_timers_enabled - ): + async for chunk in WatchdogAsyncIterator(chunk_stream, manager=self.task_manager): if chunk.usage: tokens = LLMTokenUsage( prompt_tokens=chunk.usage.prompt_tokens, diff --git a/src/pipecat/services/simli/video.py b/src/pipecat/services/simli/video.py index aef4d8022..5ddcbcba8 100644 --- a/src/pipecat/services/simli/video.py +++ b/src/pipecat/services/simli/video.py @@ -63,9 +63,7 @@ class SimliVideoService(FrameProcessor): async def _consume_and_process_audio(self): await self._pipecat_resampler_event.wait() audio_iterator = self._simli_client.getAudioStreamIterator() - async for audio_frame in WatchdogAsyncIterator( - audio_iterator, reseter=self, watchdog_enabled=self.watchdog_timers_enabled - ): + async for audio_frame in WatchdogAsyncIterator(audio_iterator, manager=self.task_manager): resampled_frames = self._pipecat_resampler.resample(audio_frame) for resampled_frame in resampled_frames: audio_array = resampled_frame.to_ndarray() @@ -82,9 +80,7 @@ class SimliVideoService(FrameProcessor): async def _consume_and_process_video(self): await self._pipecat_resampler_event.wait() video_iterator = self._simli_client.getVideoStreamIterator(targetFormat="rgb24") - async for video_frame in WatchdogAsyncIterator( - video_iterator, reseter=self, watchdog_enabled=self.watchdog_timers_enabled - ): + async for video_frame in WatchdogAsyncIterator(video_iterator, manager=self.task_manager): # Process the video frame convertedFrame: OutputImageRawFrame = OutputImageRawFrame( image=video_frame.to_rgb().to_image().tobytes(), diff --git a/src/pipecat/services/tavus/video.py b/src/pipecat/services/tavus/video.py index c71a5159c..e97da71b9 100644 --- a/src/pipecat/services/tavus/video.py +++ b/src/pipecat/services/tavus/video.py @@ -188,7 +188,7 @@ class TavusVideoService(AIService): async def _create_send_task(self): if not self._send_task: - self._queue = WatchdogQueue(self, watchdog_enabled=self.watchdog_timers_enabled) + self._queue = WatchdogQueue(self.task_manager) self._send_task = self.create_task(self._send_task_handler()) async def _cancel_send_task(self): diff --git a/src/pipecat/services/tts_service.py b/src/pipecat/services/tts_service.py index 6fb0f4988..e50244986 100644 --- a/src/pipecat/services/tts_service.py +++ b/src/pipecat/services/tts_service.py @@ -505,7 +505,7 @@ class WordTTSService(TTSService): def _create_words_task(self): if not self._words_task: - self._words_queue = WatchdogQueue(self, watchdog_enabled=self.watchdog_timers_enabled) + self._words_queue = WatchdogQueue(self.task_manager) self._words_task = self.create_task(self._words_task_handler()) async def _stop_words_task(self): @@ -787,9 +787,7 @@ class AudioContextWordTTSService(WebsocketWordTTSService): def _create_audio_context_task(self): if not self._audio_context_task: - self._contexts_queue = WatchdogQueue( - self, watchdog_enabled=self.watchdog_timers_enabled - ) + self._contexts_queue = WatchdogQueue(self.task_manager) self._contexts: Dict[str, asyncio.Queue] = {} self._audio_context_task = self.create_task(self._audio_context_task_handler()) diff --git a/src/pipecat/transports/base_output.py b/src/pipecat/transports/base_output.py index 95aba7320..36d0536d7 100644 --- a/src/pipecat/transports/base_output.py +++ b/src/pipecat/transports/base_output.py @@ -602,9 +602,7 @@ class BaseOutputTransport(FrameProcessor): def _create_clock_task(self): if not self._clock_task: - self._clock_queue = WatchdogPriorityQueue( - self._transport, watchdog_enabled=self._transport.watchdog_timers_enabled - ) + self._clock_queue = WatchdogPriorityQueue(self._transport.task_manager) self._clock_task = self._transport.create_task(self._clock_task_handler()) async def _cancel_clock_task(self): diff --git a/src/pipecat/transports/network/fastapi_websocket.py b/src/pipecat/transports/network/fastapi_websocket.py index 790f5f99a..5ddaacff7 100644 --- a/src/pipecat/transports/network/fastapi_websocket.py +++ b/src/pipecat/transports/network/fastapi_websocket.py @@ -180,7 +180,7 @@ class FastAPIWebsocketInputTransport(BaseInputTransport): async def _receive_messages(self): try: async for message in WatchdogAsyncIterator( - self._client.receive(), reseter=self, watchdog_enabled=self.watchdog_timers_enabled + self._client.receive(), manager=self.task_manager ): if not self._params.serializer: continue diff --git a/src/pipecat/transports/network/small_webrtc.py b/src/pipecat/transports/network/small_webrtc.py index 89895b8ab..9eedd7d95 100644 --- a/src/pipecat/transports/network/small_webrtc.py +++ b/src/pipecat/transports/network/small_webrtc.py @@ -425,7 +425,7 @@ class SmallWebRTCInputTransport(BaseInputTransport): try: audio_iterator = self._client.read_audio_frame() async for audio_frame in WatchdogAsyncIterator( - audio_iterator, reseter=self, watchdog_enabled=self.watchdog_timers_enabled + audio_iterator, manager=self.task_manager ): if audio_frame: await self.push_audio_frame(audio_frame) @@ -437,7 +437,7 @@ class SmallWebRTCInputTransport(BaseInputTransport): try: video_iterator = self._client.read_video_frame() async for video_frame in WatchdogAsyncIterator( - video_iterator, reseter=self, watchdog_enabled=self.watchdog_timers_enabled + video_iterator, manager=self.task_manager ): if video_frame: await self.push_video_frame(video_frame) diff --git a/src/pipecat/transports/services/daily.py b/src/pipecat/transports/services/daily.py index 42c126eb4..4c00fa44c 100644 --- a/src/pipecat/transports/services/daily.py +++ b/src/pipecat/transports/services/daily.py @@ -41,7 +41,6 @@ from pipecat.transports.base_output import BaseOutputTransport from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.utils.asyncio.task_manager import BaseTaskManager from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue -from pipecat.utils.asyncio.watchdog_reseter import WatchdogReseter try: from daily import ( @@ -252,7 +251,7 @@ class DailyAudioTrack: track: CustomAudioTrack -class DailyTransportClient(WatchdogReseter, EventHandler): +class DailyTransportClient(EventHandler): """Core client for interacting with Daily's API. Manages the connection to Daily rooms and handles all low-level API interactions. @@ -395,10 +394,6 @@ class DailyTransportClient(WatchdogReseter, EventHandler): if not frame.transport_destination and self._camera: self._camera.write_frame(frame.image) - def reset_watchdog(self): - if self._task_manager: - self._task_manager.reset_watchdog(asyncio.current_task()) - async def setup(self, setup: FrameProcessorSetup): if self._task_manager: return @@ -406,7 +401,7 @@ class DailyTransportClient(WatchdogReseter, EventHandler): self._task_manager = setup.task_manager self._watchdog_timers_enabled = setup.watchdog_timers_enabled - self._event_queue = WatchdogQueue(self, watchdog_enabled=self._watchdog_timers_enabled) + self._event_queue = WatchdogQueue(self._task_manager) self._event_task = self._task_manager.create_task( self._callback_task_handler(self._event_queue), f"{self}::event_callback_task", @@ -431,14 +426,14 @@ class DailyTransportClient(WatchdogReseter, EventHandler): self._out_sample_rate = self._params.audio_out_sample_rate or frame.audio_out_sample_rate if self._params.audio_in_enabled and not self._audio_task and self._task_manager: - self._audio_queue = WatchdogQueue(self, watchdog_enabled=self._watchdog_timers_enabled) + self._audio_queue = WatchdogQueue(self._task_manager) self._audio_task = self._task_manager.create_task( self._callback_task_handler(self._audio_queue), f"{self}::audio_callback_task", ) if self._params.video_in_enabled and not self._video_task and self._task_manager: - self._video_queue = WatchdogQueue(self, watchdog_enabled=self._watchdog_timers_enabled) + self._video_queue = WatchdogQueue(self._task_manager) self._video_task = self._task_manager.create_task( self._callback_task_handler(self._video_queue), f"{self}::video_callback_task", diff --git a/src/pipecat/transports/services/livekit.py b/src/pipecat/transports/services/livekit.py index 9524bb9e3..53dd091ef 100644 --- a/src/pipecat/transports/services/livekit.py +++ b/src/pipecat/transports/services/livekit.py @@ -416,9 +416,7 @@ class LiveKitInputTransport(BaseInputTransport): async def _audio_in_task_handler(self): logger.info("Audio input task started") audio_iterator = self._client.get_next_audio_frame() - async for audio_data in WatchdogAsyncIterator( - audio_iterator, reseter=self, watchdog_enabled=self.watchdog_timers_enabled - ): + async for audio_data in WatchdogAsyncIterator(audio_iterator, manager=self.task_manager): if audio_data: audio_frame_event, participant_id = audio_data pipecat_audio_frame = await self._convert_livekit_audio_to_pipecat( diff --git a/src/pipecat/utils/asyncio/task_manager.py b/src/pipecat/utils/asyncio/task_manager.py index a6f2f1a7f..844536186 100644 --- a/src/pipecat/utils/asyncio/task_manager.py +++ b/src/pipecat/utils/asyncio/task_manager.py @@ -97,13 +97,19 @@ class BaseTaskManager(ABC): pass @abstractmethod - def reset_watchdog(self, task: asyncio.Task): - """Resets the given task watchdog timer. If not reset, a warning will be - logged indicating the task is stalling. + def task_reset_watchdog(self): + """Resets the running task watchdog timer. If not reset, a warning will + be logged indicating the task is stalling. """ pass + @property + @abstractmethod + def task_watchdog_enabled(self) -> bool: + """Whether the current running task has a watchdog timer enabled.""" + pass + @dataclass class TaskData: @@ -253,18 +259,31 @@ class TaskManager(BaseTaskManager): logger.critical(f"{name}: fatal base exception while cancelling task: {e}") raise + def reset_watchdog(self, task: asyncio.Task): + name = task.get_name() + if name in self._tasks and self._tasks[name].enable_watchdog_timers: + self._tasks[name].watchdog_timer.set() + def current_tasks(self) -> Sequence[asyncio.Task]: """Returns the list of currently created/registered tasks.""" return [data.task for data in self._tasks.values()] - def reset_watchdog(self, task: asyncio.Task): - """Resets the given task watchdog timer. If not reset on time, a warning + def task_reset_watchdog(self): + """Resets the running task watchdog timer. If not reset on time, a warning will be logged indicating the task is stalling. """ + task = asyncio.current_task() + if task: + self.reset_watchdog(task) + + @property + def task_watchdog_enabled(self) -> bool: + task = asyncio.current_task() + if not task: + return False name = task.get_name() - if name in self._tasks and self._tasks[name].enable_watchdog_timers: - self._tasks[name].watchdog_timer.set() + return name in self._tasks and self._tasks[name].enable_watchdog_timers def _add_task(self, task_data: TaskData): name = task_data.task.get_name() diff --git a/src/pipecat/utils/asyncio/watchdog_async_iterator.py b/src/pipecat/utils/asyncio/watchdog_async_iterator.py index e35d3d54c..e71b37ae3 100644 --- a/src/pipecat/utils/asyncio/watchdog_async_iterator.py +++ b/src/pipecat/utils/asyncio/watchdog_async_iterator.py @@ -7,7 +7,7 @@ import asyncio from typing import AsyncIterator, Optional -from pipecat.utils.asyncio.watchdog_reseter import WatchdogReseter +from pipecat.utils.asyncio.task_manager import BaseTaskManager class WatchdogAsyncIterator: @@ -21,16 +21,14 @@ class WatchdogAsyncIterator: self, async_iterable, *, - reseter: WatchdogReseter, + manager: BaseTaskManager, timeout: float = 2.0, - watchdog_enabled: bool = False, ): self._async_iterable = async_iterable - self._reseter = reseter + self._manager = manager self._timeout = timeout self._iter: Optional[AsyncIterator] = None self._current_anext_task: Optional[asyncio.Task] = None - self._watchdog_enabled = watchdog_enabled def __aiter__(self): return self @@ -39,7 +37,7 @@ class WatchdogAsyncIterator: if not self._iter: self._iter = await self._ensure_async_iterator(self._async_iterable) - if self._watchdog_enabled: + if self._manager.task_watchdog_enabled: return await self._watchdog_anext() else: return await self._iter.__anext__() @@ -55,14 +53,14 @@ class WatchdogAsyncIterator: timeout=self._timeout, ) - self._reseter.reset_watchdog() + self._manager.task_reset_watchdog() # The task has finish, so we will create a new one for th next item. self._current_anext_task = None return item except asyncio.TimeoutError: - self._reseter.reset_watchdog() + self._manager.task_reset_watchdog() except StopAsyncIteration: self._current_anext_task = None raise diff --git a/src/pipecat/utils/asyncio/watchdog_event.py b/src/pipecat/utils/asyncio/watchdog_event.py index 823c0db82..65453f6ec 100644 --- a/src/pipecat/utils/asyncio/watchdog_event.py +++ b/src/pipecat/utils/asyncio/watchdog_event.py @@ -6,7 +6,7 @@ import asyncio -from pipecat.utils.asyncio.watchdog_reseter import WatchdogReseter +from pipecat.utils.asyncio.task_manager import BaseTaskManager class WatchdogEvent(asyncio.Event): @@ -18,18 +18,16 @@ class WatchdogEvent(asyncio.Event): def __init__( self, - reseter: WatchdogReseter, + manager: BaseTaskManager, *, timeout: float = 2.0, - watchdog_enabled: bool = False, ) -> None: super().__init__() - self._reseter = reseter + self._manager = manager self._timeout = timeout - self._watchdog_enabled = watchdog_enabled async def wait(self): - if self._watchdog_enabled: + if self._manager.task_watchdog_enabled: return await self._watchdog_wait() else: return await super().wait() @@ -38,7 +36,7 @@ class WatchdogEvent(asyncio.Event): while True: try: await asyncio.wait_for(super().wait(), timeout=self._timeout) - self._reseter.reset_watchdog() + self._manager.task_reset_watchdog() return True except asyncio.TimeoutError: - self._reseter.reset_watchdog() + self._manager.task_reset_watchdog() diff --git a/src/pipecat/utils/asyncio/watchdog_priority_queue.py b/src/pipecat/utils/asyncio/watchdog_priority_queue.py index fb1071c58..31d358fc7 100644 --- a/src/pipecat/utils/asyncio/watchdog_priority_queue.py +++ b/src/pipecat/utils/asyncio/watchdog_priority_queue.py @@ -6,7 +6,7 @@ import asyncio -from pipecat.utils.asyncio.watchdog_reseter import WatchdogReseter +from pipecat.utils.asyncio.task_manager import BaseTaskManager class WatchdogPriorityQueue(asyncio.PriorityQueue): @@ -18,33 +18,31 @@ class WatchdogPriorityQueue(asyncio.PriorityQueue): def __init__( self, - reseter: WatchdogReseter, + manager: BaseTaskManager, *, maxsize: int = 0, timeout: float = 2.0, - watchdog_enabled: bool = False, ) -> None: super().__init__(maxsize) - self._reseter = reseter + self._manager = manager self._timeout = timeout - self._watchdog_enabled = watchdog_enabled async def get(self): - if self._watchdog_enabled: + if self._manager.task_watchdog_enabled: return await self._watchdog_get() else: return await super().get() def task_done(self): - if self._watchdog_enabled: - self._reseter.reset_watchdog() + if self._manager.task_watchdog_enabled: + self._manager.task_reset_watchdog() super().task_done() async def _watchdog_get(self): while True: try: item = await asyncio.wait_for(super().get(), timeout=self._timeout) - self._reseter.reset_watchdog() + self._manager.task_reset_watchdog() return item except asyncio.TimeoutError: - self._reseter.reset_watchdog() + self._manager.task_reset_watchdog() diff --git a/src/pipecat/utils/asyncio/watchdog_queue.py b/src/pipecat/utils/asyncio/watchdog_queue.py index 5a9d86cb6..961324b7b 100644 --- a/src/pipecat/utils/asyncio/watchdog_queue.py +++ b/src/pipecat/utils/asyncio/watchdog_queue.py @@ -6,7 +6,7 @@ import asyncio -from pipecat.utils.asyncio.watchdog_reseter import WatchdogReseter +from pipecat.utils.asyncio.task_manager import BaseTaskManager class WatchdogQueue(asyncio.Queue): @@ -18,33 +18,31 @@ class WatchdogQueue(asyncio.Queue): def __init__( self, - reseter: WatchdogReseter, + manager: BaseTaskManager, *, maxsize: int = 0, timeout: float = 2.0, - watchdog_enabled: bool = False, ) -> None: super().__init__(maxsize) - self._reseter = reseter + self._manager = manager self._timeout = timeout - self._watchdog_enabled = watchdog_enabled async def get(self): - if self._watchdog_enabled: + if self._manager.task_watchdog_enabled: return await self._watchdog_get() else: return await super().get() def task_done(self): - if self._watchdog_enabled: - self._reseter.reset_watchdog() + if self._manager.task_watchdog_enabled: + self._manager.task_reset_watchdog() super().task_done() async def _watchdog_get(self): while True: try: item = await asyncio.wait_for(super().get(), timeout=self._timeout) - self._reseter.reset_watchdog() + self._manager.task_reset_watchdog() return item except asyncio.TimeoutError: - self._reseter.reset_watchdog() + self._manager.task_reset_watchdog() diff --git a/src/pipecat/utils/asyncio/watchdog_reseter.py b/src/pipecat/utils/asyncio/watchdog_reseter.py deleted file mode 100644 index ee70207b3..000000000 --- a/src/pipecat/utils/asyncio/watchdog_reseter.py +++ /dev/null @@ -1,13 +0,0 @@ -# -# Copyright (c) 2024–2025, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - -from abc import ABC, abstractmethod - - -class WatchdogReseter(ABC): - @abstractmethod - def reset_watchdog(self): - pass From 89c801f82c81d3cd36c449d43ca6bd31073ca145 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Thu, 26 Jun 2025 23:28:37 +0530 Subject: [PATCH 53/54] Start HeartBeat when all processors have processed StartFrame Some of the processors like STTService and TTSService don't push StartFrame ahead in the pipeline, unless they have connected with their service providers. This delays StartFrame in downstream processors. If we receive HeartBeat frame before StartFrame, we will get AttributeError `'Processor' object has no attribute '_FrameProcessor__input_queue'`. Idea is to start HeartBeats after StartFrame has been processed by all the Processors in the pipeline. --- src/pipecat/pipeline/task.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pipecat/pipeline/task.py b/src/pipecat/pipeline/task.py index 8b30d1a4e..62d8ccf71 100644 --- a/src/pipecat/pipeline/task.py +++ b/src/pipecat/pipeline/task.py @@ -472,7 +472,7 @@ class PipelineTask(WatchdogReseter, BasePipelineTask): return self._process_push_task def _maybe_start_heartbeat_tasks(self): - if self._params.enable_heartbeats: + if self._params.enable_heartbeats and self._heartbeat_push_task is None: self._heartbeat_push_task = self._task_manager.create_task( self._heartbeat_push_handler(), f"{self}::_heartbeat_push_handler" ) @@ -570,7 +570,6 @@ class PipelineTask(WatchdogReseter, BasePipelineTask): """ self._clock.start() - self._maybe_start_heartbeat_tasks() self._maybe_start_idle_task() start_frame = StartFrame( @@ -652,6 +651,10 @@ class PipelineTask(WatchdogReseter, BasePipelineTask): if isinstance(frame, StartFrame): await self._call_event_handler("on_pipeline_started", frame) + + # Start heartbeat tasks now that StartFrame has been processed + # by all processors in the pipeline + self._maybe_start_heartbeat_tasks() elif isinstance(frame, EndFrame): await self._call_event_handler("on_pipeline_ended", frame) self._pipeline_end_event.set() From 917394803c97f4ab33404646112a78a5ecff38fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Thu, 26 Jun 2025 11:30:11 -0700 Subject: [PATCH 54/54] update CHANGELOG for 0.0.72 --- CHANGELOG.md | 5 ++++- src/pipecat/pipeline/task.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33064fa80..b97203a9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to **Pipecat** will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.0.72] - 2025-06-26 ### Added @@ -86,6 +86,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed an issue that would cause heartbeat frames to be sent before processors + were started. + - Fixed an event loop blocking issue when using `SentryMetrics`. - Fixed an issue in `FastAPIWebsocketClient` to ensure proper disconnection diff --git a/src/pipecat/pipeline/task.py b/src/pipecat/pipeline/task.py index 4631f71c7..d17e8c771 100644 --- a/src/pipecat/pipeline/task.py +++ b/src/pipecat/pipeline/task.py @@ -647,8 +647,8 @@ class PipelineTask(BasePipelineTask): if isinstance(frame, StartFrame): await self._call_event_handler("on_pipeline_started", frame) - - # Start heartbeat tasks now that StartFrame has been processed + + # Start heartbeat tasks now that StartFrame has been processed # by all processors in the pipeline self._maybe_start_heartbeat_tasks() elif isinstance(frame, EndFrame):