From 12f27f9cdad0e2003c27184e11a96ba3a6550ba8 Mon Sep 17 00:00:00 2001 From: Om Chauhan Date: Sun, 1 Feb 2026 19:31:49 +0530 Subject: [PATCH 01/93] fix(daily): queue outbound messages until transport joins --- src/pipecat/transports/daily/transport.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/pipecat/transports/daily/transport.py b/src/pipecat/transports/daily/transport.py index 20a0be29f..37ae8a167 100644 --- a/src/pipecat/transports/daily/transport.py +++ b/src/pipecat/transports/daily/transport.py @@ -501,6 +501,8 @@ class DailyTransportClient(EventHandler): self._event_task = None self._audio_task = None self._video_task = None + self._message_queue: Optional[asyncio.Queue] = None + self._message_task = None # Input and ouput sample rates. They will be initialize on setup(). self._in_sample_rate = 0 @@ -567,7 +569,9 @@ class DailyTransportClient(EventHandler): error: An error description or None. """ if not self._joined: - return "Unable to send messages before joining." + if self._message_queue: + await self._message_queue.put((frame,)) + return None participant_id = None if isinstance( @@ -673,6 +677,12 @@ class DailyTransportClient(EventHandler): f"{self}::event_callback_task", ) + self._message_queue = asyncio.Queue() + self._message_task = self._task_manager.create_task( + self._message_task_handler(self._message_queue), + f"{self}::message_task", + ) + async def cleanup(self): """Cleanup client resources and cancel tasks.""" if self._event_task and self._task_manager: @@ -684,6 +694,9 @@ class DailyTransportClient(EventHandler): if self._video_task and self._task_manager: await self._task_manager.cancel_task(self._video_task) self._video_task = None + if self._message_task and self._task_manager: + await self._task_manager.cancel_task(self._message_task) + self._message_task = None # Make sure we don't block the event loop in case `client.release()` # takes extra time. await self._get_event_loop().run_in_executor(self._executor, self._cleanup) @@ -1541,6 +1554,14 @@ class DailyTransportClient(EventHandler): await callback(*args) queue.task_done() + async def _message_task_handler(self, queue: asyncio.Queue): + """Handle queued messages after transport is joined.""" + while True: + await self._joined_event.wait() + (frame,) = await queue.get() + await self.send_message(frame) + queue.task_done() + def _get_event_loop(self) -> asyncio.AbstractEventLoop: """Get the event loop from the task manager.""" if not self._task_manager: From 9f380170d718251a5300938cc36723a6c0117131 Mon Sep 17 00:00:00 2001 From: Om Chauhan Date: Sun, 1 Feb 2026 20:35:05 +0530 Subject: [PATCH 02/93] added changelog --- changelog/3615.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3615.fixed.md diff --git a/changelog/3615.fixed.md b/changelog/3615.fixed.md new file mode 100644 index 000000000..b14dfd70f --- /dev/null +++ b/changelog/3615.fixed.md @@ -0,0 +1 @@ +- Fixed race condition where `RTVIObserver` could send messages before `DailyTransport` join completed. Outbound messages are now queued & delivered after the transport is ready. From a4e187e13896cbf178f85cf522769a1cbb5e0865 Mon Sep 17 00:00:00 2001 From: Om Chauhan Date: Mon, 9 Feb 2026 06:04:08 +0530 Subject: [PATCH 03/93] replace background task with flush-on-join --- src/pipecat/transports/daily/transport.py | 27 +++++++---------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/src/pipecat/transports/daily/transport.py b/src/pipecat/transports/daily/transport.py index 37ae8a167..a955b1c12 100644 --- a/src/pipecat/transports/daily/transport.py +++ b/src/pipecat/transports/daily/transport.py @@ -501,8 +501,7 @@ class DailyTransportClient(EventHandler): self._event_task = None self._audio_task = None self._video_task = None - self._message_queue: Optional[asyncio.Queue] = None - self._message_task = None + self._join_message_queue: list = [] # Input and ouput sample rates. They will be initialize on setup(). self._in_sample_rate = 0 @@ -569,8 +568,7 @@ class DailyTransportClient(EventHandler): error: An error description or None. """ if not self._joined: - if self._message_queue: - await self._message_queue.put((frame,)) + self._join_message_queue.append(frame) return None participant_id = None @@ -677,12 +675,6 @@ class DailyTransportClient(EventHandler): f"{self}::event_callback_task", ) - self._message_queue = asyncio.Queue() - self._message_task = self._task_manager.create_task( - self._message_task_handler(self._message_queue), - f"{self}::message_task", - ) - async def cleanup(self): """Cleanup client resources and cancel tasks.""" if self._event_task and self._task_manager: @@ -694,9 +686,6 @@ class DailyTransportClient(EventHandler): if self._video_task and self._task_manager: await self._task_manager.cancel_task(self._video_task) self._video_task = None - if self._message_task and self._task_manager: - await self._task_manager.cancel_task(self._message_task) - self._message_task = None # Make sure we don't block the event loop in case `client.release()` # takes extra time. await self._get_event_loop().run_in_executor(self._executor, self._cleanup) @@ -781,6 +770,8 @@ class DailyTransportClient(EventHandler): await self._callbacks.on_joined(data) self._joined_event.set() + + await self._flush_join_messages() else: error_msg = f"Error joining {self._room_url}: {error}" logger.error(error_msg) @@ -1554,13 +1545,11 @@ class DailyTransportClient(EventHandler): await callback(*args) queue.task_done() - async def _message_task_handler(self, queue: asyncio.Queue): - """Handle queued messages after transport is joined.""" - while True: - await self._joined_event.wait() - (frame,) = await queue.get() + async def _flush_join_messages(self): + """Send any messages that were queued before join completed.""" + for frame in self._join_message_queue: await self.send_message(frame) - queue.task_done() + self._join_message_queue.clear() def _get_event_loop(self) -> asyncio.AbstractEventLoop: """Get the event loop from the task manager.""" From 67d39a97f7f97d97047d598081d3e26fc8835337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kmen=20G=C3=B6rgen?= Date: Mon, 9 Feb 2026 11:51:28 +0100 Subject: [PATCH 04/93] AIC model caching. --- src/pipecat/audio/filters/aic_filter.py | 196 ++++++++++++++++++++++-- tests/test_aic_filter.py | 125 ++++++++++++--- 2 files changed, 291 insertions(+), 30 deletions(-) diff --git a/src/pipecat/audio/filters/aic_filter.py b/src/pipecat/audio/filters/aic_filter.py index 7f0626776..399e6cd2e 100644 --- a/src/pipecat/audio/filters/aic_filter.py +++ b/src/pipecat/audio/filters/aic_filter.py @@ -12,10 +12,13 @@ the Koala filter and integrates with Pipecat's input transport pipeline. Classes: AICFilter: For aic-sdk (uses 'aic_sdk' module) + AICModelManager: Singleton manager for read-only AIC Model instances. """ +import asyncio from pathlib import Path -from typing import List, Optional +from threading import Lock +from typing import List, Optional, Tuple import numpy as np from aic_sdk import ( @@ -33,6 +36,174 @@ from pipecat.audio.vad.aic_vad import AICVADAnalyzer from pipecat.frames.frames import FilterControlFrame, FilterEnableFrame +class AICModelManager: + """Singleton manager for read-only AIC Model instances with reference counting. + + Caches Model instances by path or (model_id + download_dir). Multiple + AICFilter instances using the same model share one Model; the manager + acquires on first use and releases when the last reference is dropped. + """ + + _cache: dict[str, Tuple[Model, int]] = {} # key -> (model, ref_count) + _lock = Lock() + _loading: dict[ + str, asyncio.Task[Model] + ] = {} # key -> load task (deduplicates concurrent loads) + + @classmethod + def _increment_reference(cls, cache_key: str, entry: Tuple[Model, int]) -> Tuple[Model, str]: + """Increment reference count for cached entry. Caller must hold _lock.""" + cached_model, ref_count = entry + cls._cache[cache_key] = (cached_model, ref_count + 1) + logger.debug(f"AIC model cache key={cache_key!r} ref_count={ref_count + 1}") + return cached_model, cache_key + + @classmethod + def _store_new_reference(cls, cache_key: str, model: Model) -> Tuple[Model, str]: + """Store new model in cache with ref count 1. Caller must hold _lock.""" + cls._cache[cache_key] = (model, 1) + logger.debug(f"AIC model cached key={cache_key!r} ref_count=1") + return model, cache_key + + @classmethod + async def _load_model_from_file( + cls, + cache_key: str, + *, + model_path: Optional[Path] = None, + model_id: Optional[str] = None, + model_download_dir: Optional[Path] = None, + ) -> Model: + """Run the actual load (file or download). Separate to allow create_task and deduplication.""" + if model_path is not None: + logger.debug(f"Loading AIC model from file: {model_path}") + return Model.from_file(str(model_path)) + + if model_id is not None and model_download_dir is not None: + logger.debug(f"Downloading AIC model: {model_id}") + model_download_dir.mkdir(parents=True, exist_ok=True) + path = await Model.download_async(model_id, str(model_download_dir)) + logger.debug(f"Model downloaded to: {path}") + return Model.from_file(path) + + raise ValueError("Unexpected model_path or (model_id and model_download_dir) state.") + + @staticmethod + def _get_cache_key( + *, + model_path: Optional[Path] = None, + model_id: Optional[str] = None, + model_download_dir: Optional[Path] = None, + ) -> str: + """Build a stable cache key for the model. + + Args: + model_path: Path to a local .aicmodel file. + model_id: Model identifier (See https://artifacts.ai-coustics.io/ for available models). + model_download_dir: Directory used for downloading models. + + Returns: + A string key unique per (path) or (model_id + download_dir). + """ + if model_path is not None: + return f"path:{model_path.resolve()}" + + if model_id is not None and model_download_dir is not None: + return f"id:{model_id}:{model_download_dir.resolve()}" + + raise ValueError("Either model_path or (model_id and model_download_dir) must be set.") + + @classmethod + async def acquire( + cls, + *, + model_path: Optional[Path] = None, + model_id: Optional[str] = None, + model_download_dir: Optional[Path] = None, + ) -> Tuple[Model, str]: + """Get or load a Model and increment its reference count. + + Call this when starting a filter. Store the returned key and pass it + to release() when stopping the filter. + + Args: + model_path: Path to a local .aicmodel file. If set, model_id is ignored. + model_id: Model identifier to download from CDN. + model_download_dir: Directory for downloading models. Required if + model_id is used. + + Returns: + Tuple of (shared Model instance, cache key for release). + + Raises: + ValueError: If neither model_path nor (model_id + model_download_dir) + is provided, or if model_id is set without model_download_dir. + """ + cache_key = cls._get_cache_key( + model_path=model_path, + model_id=model_id, + model_download_dir=model_download_dir, + ) + + with cls._lock: + entry = cls._cache.get(cache_key) + if entry is not None: + return cls._increment_reference(cache_key, entry) + + # Deduplicate concurrent loads for the same key + load_task = cls._loading.get(cache_key) + if load_task is None: + load_task = asyncio.create_task( + cls._load_model_from_file( + cache_key, + model_path=model_path, + model_id=model_id, + model_download_dir=model_download_dir, + ) + ) + cls._loading[cache_key] = load_task + + try: + model = await load_task + finally: + with cls._lock: + cls._loading.pop(cache_key, None) + + with cls._lock: + entry = cls._cache.get(cache_key) + if entry is not None: + return cls._increment_reference(cache_key, entry) + return cls._store_new_reference(cache_key, model) + + @classmethod + def release(cls, key: str) -> None: + """Release a reference to a cached model. + + Call this when stopping a filter, with the key returned from + get_model(). When the last reference is released, the model + is removed from the cache. + + Args: + key: Cache key returned by get_model(). + """ + with cls._lock: + entry = cls._cache.get(key) + + if entry is None: + logger.warning(f"AIC model release unknown key={key!r}") + return + + model, ref_count = entry + ref_count -= 1 + + if ref_count <= 0: + del cls._cache[key] + logger.debug(f"AIC model evicted key={key!r}") + else: + cls._cache[key] = (model, ref_count) + logger.debug(f"AIC model key={key!r} ref_count={ref_count}") + + class AICFilter(BaseAudioFilter): """Audio filter using ai-coustics' AIC SDK for real-time enhancement. @@ -91,7 +262,8 @@ class AICFilter(BaseAudioFilter): 32768.0 # 2^15, for normalizing int16 (-32768 to 32767) to float32 (-1.0 to 1.0) ) - # AIC SDK objects + # AIC SDK objects; model is shared via AICModelManager + self._model_cache_key: Optional[str] = None self._model = None self._processor = None self._processor_ctx = None @@ -162,16 +334,12 @@ class AICFilter(BaseAudioFilter): """ self._sample_rate = sample_rate - # Load or download model - if self._model_path: - logger.debug(f"Loading AIC model from: {self._model_path}") - self._model = Model.from_file(str(self._model_path)) - else: - logger.debug(f"Downloading AIC model: {self._model_id}") - self._model_download_dir.mkdir(parents=True, exist_ok=True) - model_path = await Model.download_async(self._model_id, str(self._model_download_dir)) - logger.debug(f"Model downloaded to: {model_path}") - self._model = Model.from_file(model_path) + # Acquire shared read-only model from singleton manager + self._model, self._model_cache_key = await AICModelManager.acquire( + model_path=self._model_path, + model_id=self._model_id, + model_download_dir=self._model_download_dir, + ) # Get optimal frames for this sample rate self._frames_per_block = self._model.get_optimal_num_frames(self._sample_rate) @@ -242,6 +410,10 @@ class AICFilter(BaseAudioFilter): self._aic_ready = False self._audio_buffer.clear() + if self._model_cache_key is not None: + AICModelManager.release(self._model_cache_key) + self._model_cache_key = None + async def process_frame(self, frame: FilterControlFrame): """Process control frames to enable/disable filtering. diff --git a/tests/test_aic_filter.py b/tests/test_aic_filter.py index 6499084af..c36022a7b 100644 --- a/tests/test_aic_filter.py +++ b/tests/test_aic_filter.py @@ -5,6 +5,7 @@ # import asyncio +import time import unittest from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -23,6 +24,13 @@ except ImportError: AIC_FILTER_MODULE = "pipecat.audio.filters.aic_filter" +def _model_manager_ref_count(manager, key: str) -> int: + """Test helper: return reference count for a cache key (reads internal cache).""" + with manager._lock: + entry = manager._cache.get(key) + return entry[1] if entry else 0 + + class MockProcessor: """A lightweight mock for AIC ProcessorAsync that mimics real behavior.""" @@ -99,10 +107,11 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase): @classmethod def setUpClass(cls): """Import AICFilter after confirming aic_sdk is available.""" - from pipecat.audio.filters.aic_filter import AICFilter + from pipecat.audio.filters.aic_filter import AICFilter, AICModelManager from pipecat.frames.frames import FilterEnableFrame cls.AICFilter = AICFilter + cls.AICModelManager = AICModelManager cls.FilterEnableFrame = FilterEnableFrame def setUp(self): @@ -122,13 +131,13 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase): async def _start_filter_with_mocks(self, filter_instance, sample_rate=16000): """Start a filter with mocked SDK components.""" + cache_key = "test-cache-key" with ( - patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls, + patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls, patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls, patch(f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor), ): - mock_model_cls.from_file.return_value = self.mock_model - mock_model_cls.download_async = AsyncMock(return_value="/tmp/model") + mock_manager_cls.acquire = AsyncMock(return_value=(self.mock_model, cache_key)) mock_config_cls.optimal.return_value = MagicMock() await filter_instance.start(sample_rate) @@ -171,37 +180,44 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase): filter_instance = self._create_filter_with_mocks(model_id=None, model_path=model_path) with ( - patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls, + patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls, patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls, patch(f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor), ): - mock_model_cls.from_file.return_value = self.mock_model + mock_manager_cls.acquire = AsyncMock( + return_value=(self.mock_model, "path:/tmp/test.aicmodel") + ) mock_config_cls.optimal.return_value = MagicMock() await filter_instance.start(16000) - mock_model_cls.from_file.assert_called_once_with(str(model_path)) + mock_manager_cls.acquire.assert_called_once() + call_kw = mock_manager_cls.acquire.call_args[1] + self.assertEqual(call_kw["model_path"], model_path) + self.assertIsNone(call_kw["model_id"]) self.assertTrue(filter_instance._aic_ready) self.assertEqual(filter_instance._sample_rate, 16000) self.assertEqual(filter_instance._frames_per_block, 160) async def test_start_with_model_id_downloads(self): - """Test starting filter with model_id triggers download.""" + """Test starting filter with model_id uses manager (download happens in manager).""" filter_instance = self._create_filter_with_mocks() with ( - patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls, + patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls, patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls, patch(f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor), ): - mock_model_cls.from_file.return_value = self.mock_model - mock_model_cls.download_async = AsyncMock(return_value="/tmp/model") + mock_manager_cls.acquire = AsyncMock( + return_value=(self.mock_model, "id:test-model:/custom/cache") + ) mock_config_cls.optimal.return_value = MagicMock() await filter_instance.start(16000) - mock_model_cls.download_async.assert_called_once() - mock_model_cls.from_file.assert_called_once() + mock_manager_cls.acquire.assert_called_once() + call_kw = mock_manager_cls.acquire.call_args[1] + self.assertEqual(call_kw["model_id"], "test-model") self.assertTrue(filter_instance._aic_ready) async def test_start_creates_processor(self): @@ -209,14 +225,13 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase): filter_instance = self._create_filter_with_mocks() with ( - patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls, + patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls, patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls, patch( f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor ) as mock_processor_cls, ): - mock_model_cls.from_file.return_value = self.mock_model - mock_model_cls.download_async = AsyncMock(return_value="/tmp/model") + mock_manager_cls.acquire = AsyncMock(return_value=(self.mock_model, "test-cache-key")) mock_config_cls.optimal.return_value = MagicMock() await filter_instance.start(16000) @@ -241,17 +256,21 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase): self.assertEqual(bypass_params[-1][1], 0.0) async def test_stop_cleans_up_resources(self): - """Test that stop properly cleans up resources.""" + """Test that stop properly cleans up resources and releases model reference.""" filter_instance = self._create_filter_with_mocks() await self._start_filter_with_mocks(filter_instance) + cache_key = filter_instance._model_cache_key - await filter_instance.stop() + with patch(f"{AIC_FILTER_MODULE}.AICModelManager.release") as mock_release: + await filter_instance.stop() + mock_release.assert_called_once_with(cache_key) self.assertTrue(self.mock_processor.processor_ctx.reset_called) self.assertIsNone(filter_instance._processor) self.assertIsNone(filter_instance._processor_ctx) self.assertIsNone(filter_instance._vad_ctx) self.assertIsNone(filter_instance._model) + self.assertIsNone(filter_instance._model_cache_key) self.assertFalse(filter_instance._aic_ready) async def test_stop_without_start(self): @@ -261,6 +280,76 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase): # Should not raise await filter_instance.stop() + async def test_model_manager_reference_count(self): + """Test that AICModelManager reference count increments and decrements correctly.""" + model_path = Path("/tmp/refcount-test.aicmodel") + mock_model = MockModel() + manager = self.AICModelManager + + with patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls: + mock_model_cls.from_file.return_value = mock_model + + # Acquire first reference + model1, key = await manager.acquire(model_path=model_path) + self.assertEqual(model1, mock_model) + self.assertEqual(_model_manager_ref_count(manager, key), 1) + + # Acquire second reference (same key, cached) + model2, key2 = await manager.acquire(model_path=model_path) + self.assertIs(model2, model1) + self.assertEqual(key2, key) + self.assertEqual(_model_manager_ref_count(manager, key), 2) + + # Release one reference + manager.release(key) + self.assertEqual(_model_manager_ref_count(manager, key), 1) + + # Release last reference (model evicted from cache) + manager.release(key) + self.assertEqual(_model_manager_ref_count(manager, key), 0) + + async def test_model_manager_concurrent_load_deduplication(self): + """Test that concurrent acquire calls for the same key share a single load task.""" + model_path = Path("/tmp/concurrent-load-test.aicmodel") + mock_model = MockModel() + manager = self.AICModelManager + load_count = 0 + + def from_file_once(path): + nonlocal load_count + load_count += 1 + time.sleep(0.02) # yield so other acquire callers can hit _loading and await same task + return mock_model + + with patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls: + mock_model_cls.from_file.side_effect = from_file_once + + # Start several acquire calls concurrently before any completes + results = await asyncio.gather( + manager.acquire(model_path=model_path), + manager.acquire(model_path=model_path), + manager.acquire(model_path=model_path), + ) + + self.assertEqual( + load_count, 1, "Model.from_file should be called once for concurrent callers" + ) + model1, key1 = results[0] + model2, key2 = results[1] + model3, key3 = results[2] + self.assertIs(model1, mock_model) + self.assertIs(model2, mock_model) + self.assertIs(model3, mock_model) + self.assertEqual(key1, key2) + self.assertEqual(key2, key3) + self.assertEqual(_model_manager_ref_count(manager, key1), 3) + + # Release all references + manager.release(key1) + manager.release(key1) + manager.release(key1) + self.assertEqual(_model_manager_ref_count(manager, key1), 0) + async def test_process_frame_enable(self): """Test processing FilterEnableFrame to enable filtering.""" filter_instance = self._create_filter_with_mocks() From ed3ec045aae2775b2df2717d203f2270a325e3f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kmen=20G=C3=B6rgen?= Date: Mon, 9 Feb 2026 12:04:09 +0100 Subject: [PATCH 05/93] add changelog file. --- changelog/3684.changed.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog/3684.changed.md diff --git a/changelog/3684.changed.md b/changelog/3684.changed.md new file mode 100644 index 000000000..017fc9946 --- /dev/null +++ b/changelog/3684.changed.md @@ -0,0 +1,2 @@ +- `AICFilter` now shares read-only AIC models via a singleton `AICModelManager` in `aic_filter.py`. + - Multiple filters using the same `model path` or `(model_id, model_download_dir)` share one loaded model, with reference counting and concurrent load deduplication. From 2036757b84062dc0804f77ecf2466c39b3932461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kmen=20G=C3=B6rgen?= Date: Wed, 11 Feb 2026 15:22:37 +0100 Subject: [PATCH 06/93] add unit tests for `AICModelManager` and `AICFilter` error handling, model loading, and processor behavior --- changelog/3684.changed.md | 3 +- src/pipecat/audio/filters/aic_filter.py | 15 ++-- tests/test_aic_filter.py | 101 ++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 7 deletions(-) diff --git a/changelog/3684.changed.md b/changelog/3684.changed.md index 017fc9946..1bdb2c89c 100644 --- a/changelog/3684.changed.md +++ b/changelog/3684.changed.md @@ -1,2 +1,3 @@ - `AICFilter` now shares read-only AIC models via a singleton `AICModelManager` in `aic_filter.py`. - - Multiple filters using the same `model path` or `(model_id, model_download_dir)` share one loaded model, with reference counting and concurrent load deduplication. + - Multiple filters using the same model path or `(model_id, model_download_dir)` share one loaded model, with reference counting and concurrent load deduplication. + - Model file I/O runs off the event loop so the filter does not block. diff --git a/src/pipecat/audio/filters/aic_filter.py b/src/pipecat/audio/filters/aic_filter.py index 399e6cd2e..723b3da8f 100644 --- a/src/pipecat/audio/filters/aic_filter.py +++ b/src/pipecat/audio/filters/aic_filter.py @@ -77,16 +77,19 @@ class AICModelManager: """Run the actual load (file or download). Separate to allow create_task and deduplication.""" if model_path is not None: logger.debug(f"Loading AIC model from file: {model_path}") - return Model.from_file(str(model_path)) + model_path_str = str(model_path) - if model_id is not None and model_download_dir is not None: + elif model_id is not None and model_download_dir is not None: logger.debug(f"Downloading AIC model: {model_id}") model_download_dir.mkdir(parents=True, exist_ok=True) - path = await Model.download_async(model_id, str(model_download_dir)) - logger.debug(f"Model downloaded to: {path}") - return Model.from_file(path) + model_path_str = await Model.download_async(model_id, str(model_download_dir)) + logger.debug(f"Model downloaded to: {model_path_str}") - raise ValueError("Unexpected model_path or (model_id and model_download_dir) state.") + else: + raise ValueError("Unexpected model_path or (model_id and model_download_dir) state.") + + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, lambda: Model.from_file(model_path_str)) @staticmethod def _get_cache_key( diff --git a/tests/test_aic_filter.py b/tests/test_aic_filter.py index c36022a7b..83eca757c 100644 --- a/tests/test_aic_filter.py +++ b/tests/test_aic_filter.py @@ -350,6 +350,107 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase): manager.release(key1) self.assertEqual(_model_manager_ref_count(manager, key1), 0) + async def test_load_model_from_file_invalid_args_raises(self): + """Test _load_model_from_file defensive else: raises ValueError.""" + manager = self.AICModelManager + with self.assertRaises(ValueError) as ctx: + await manager._load_model_from_file( + "key", + model_path=None, + model_id=None, + model_download_dir=None, + ) + self.assertIn("Unexpected", str(ctx.exception)) + + async def test_model_manager_acquire_by_model_id_hits_download_path(self): + """Test acquire with model_id runs download path in _load_model_from_file.""" + model_id = "test-model-id" + model_download_dir = Path("/tmp/aic-downloads") + mock_model = MockModel() + manager = self.AICModelManager + + with patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls: + mock_model_cls.download_async = AsyncMock( + return_value="/tmp/aic-downloads/model.aicmodel" + ) + mock_model_cls.from_file.return_value = mock_model + + model, key = await manager.acquire( + model_id=model_id, + model_download_dir=model_download_dir, + ) + + mock_model_cls.download_async.assert_called_once() + mock_model_cls.from_file.assert_called_once_with("/tmp/aic-downloads/model.aicmodel") + self.assertIs(model, mock_model) + self.assertEqual(_model_manager_ref_count(manager, key), 1) + manager.release(key) + + def test_get_cache_key_invalid_raises(self): + """Test _get_cache_key raises ValueError for invalid args.""" + with self.assertRaises(ValueError) as ctx: + self.AICModelManager._get_cache_key(model_path=None, model_id=None) + self.assertIn("model_path", str(ctx.exception)) + + with self.assertRaises(ValueError) as ctx2: + self.AICModelManager._get_cache_key( + model_path=None, + model_id="x", + model_download_dir=None, + ) + self.assertIn("model_download_dir", str(ctx2.exception)) + + async def test_start_processor_init_failure(self): + """Test start() when ProcessorAsync raises: exception logged, _aic_ready False.""" + filter_instance = self._create_filter_with_mocks() + + with ( + patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls, + patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls, + patch( + f"{AIC_FILTER_MODULE}.ProcessorAsync", + side_effect=RuntimeError("SDK init failed"), + ), + ): + mock_manager_cls.acquire = AsyncMock(return_value=(self.mock_model, "test-key")) + mock_config_cls.optimal.return_value = MagicMock() + + await filter_instance.start(16000) + + self.assertIsNone(filter_instance._processor) + self.assertFalse(filter_instance._aic_ready) + + async def test_start_parameter_fixed_error_logged(self): + """Test start() when set_parameter raises ParameterFixedError: logged, no raise.""" + filter_instance = self._create_filter_with_mocks() + self.mock_processor.processor_ctx.set_parameter = MagicMock( + side_effect=aic_sdk.ParameterFixedError("fixed") + ) + + with ( + patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls, + patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls, + patch(f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor), + ): + mock_manager_cls.acquire = AsyncMock(return_value=(self.mock_model, "test-key")) + mock_config_cls.optimal.return_value = MagicMock() + + await filter_instance.start(16000) + + self.assertTrue(filter_instance._aic_ready) + + async def test_process_frame_set_parameter_exception_logged(self): + """Test process_frame when set_parameter raises: exception logged, no raise.""" + filter_instance = self._create_filter_with_mocks() + await self._start_filter_with_mocks(filter_instance) + filter_instance._processor_ctx.set_parameter = MagicMock( + side_effect=ValueError("param error") + ) + + await filter_instance.process_frame(self.FilterEnableFrame(enable=True)) + + self.assertFalse(filter_instance._bypass) + async def test_process_frame_enable(self): """Test processing FilterEnableFrame to enable filtering.""" filter_instance = self._create_filter_with_mocks() From ec2b38dc299d3f04416079aa62d6fdbed62f8527 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Wed, 11 Feb 2026 13:01:25 -0300 Subject: [PATCH 07/93] Fixing smallwebrtc transport input audio resampling logic. --- .../transports/smallwebrtc/transport.py | 36 +++++++------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/src/pipecat/transports/smallwebrtc/transport.py b/src/pipecat/transports/smallwebrtc/transport.py index 7e22fb00c..4389ff5d9 100644 --- a/src/pipecat/transports/smallwebrtc/transport.py +++ b/src/pipecat/transports/smallwebrtc/transport.py @@ -233,9 +233,8 @@ class SmallWebRTCClient: self._out_sample_rate = None self._leave_counter = 0 - # We are always resampling it for 16000 if the sample_rate that we receive is bigger than that. - # otherwise we face issues with Silero VAD - self._pipecat_resampler = AudioResampler("s16", "mono", 16000) + # Audio resampler - will be configured during setup with target sample rate + self._audio_in_resampler = None @self._webrtc_connection.event_handler("connected") async def on_connected(connection: SmallWebRTCConnection): @@ -375,32 +374,22 @@ class SmallWebRTCClient: await asyncio.sleep(0.01) continue - if frame.sample_rate > self._in_sample_rate: - resampled_frames = self._pipecat_resampler.resample(frame) - for resampled_frame in resampled_frames: - # 16-bit PCM bytes - pcm_array = resampled_frame.to_ndarray().astype(np.int16) - pcm_bytes = pcm_array.tobytes() - del pcm_array # free NumPy array immediately + # Resample if needed, otherwise use the frame as-is + frames_to_process = ( + self._audio_in_resampler.resample(frame) + if frame.sample_rate != self._in_sample_rate + else [frame] + ) - audio_frame = InputAudioRawFrame( - audio=pcm_bytes, - sample_rate=resampled_frame.sample_rate, - num_channels=self._audio_in_channels, - ) - audio_frame.pts = frame.pts - del pcm_bytes # reference kept in audio_frame - - yield audio_frame - else: - # 16-bit PCM bytes - pcm_array = frame.to_ndarray().astype(np.int16) + for processed_frame in frames_to_process: + # Convert to 16-bit PCM bytes + pcm_array = processed_frame.to_ndarray().astype(np.int16) pcm_bytes = pcm_array.tobytes() del pcm_array # free NumPy array immediately audio_frame = InputAudioRawFrame( audio=pcm_bytes, - sample_rate=frame.sample_rate, + sample_rate=self._in_sample_rate, num_channels=self._audio_in_channels, ) audio_frame.pts = frame.pts @@ -450,6 +439,7 @@ class SmallWebRTCClient: self._out_sample_rate = _params.audio_out_sample_rate or frame.audio_out_sample_rate self._params = _params self._leave_counter += 1 + self._audio_in_resampler = AudioResampler("s16", "mono", self._in_sample_rate) async def connect(self): """Establish the WebRTC connection.""" From 0c3e59ed61addc6643f4d8c586a909db767833c4 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Wed, 11 Feb 2026 13:07:52 -0300 Subject: [PATCH 08/93] Adding changelog entry for the SmallWebRTCTransport fix. --- changelog/3713.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3713.fixed.md diff --git a/changelog/3713.fixed.md b/changelog/3713.fixed.md new file mode 100644 index 000000000..241f0e56a --- /dev/null +++ b/changelog/3713.fixed.md @@ -0,0 +1 @@ +- Fixed `SmallWebRTCTransport` input audio resampling to properly handle all sample rates, including 8kHz audio. From 1fe4538982e0cafa03bbb576f5212f98d62f8ee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Wed, 11 Feb 2026 09:51:10 -0800 Subject: [PATCH 09/93] Update PR submission instructions in CLAUDE.md Expand the Pull Requests section with detailed step-by-step instructions including branch naming, commit guidance, changelog generation, and PR description updates. --- CLAUDE.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index cf4c5baec..0f4b55e22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,4 +155,9 @@ Test utilities live in `src/pipecat/tests/utils.py`. Use `run_test()` to send fr ## Pull Requests -After creating a PR, use `/changelog ` to generate the changelog file and `/pr-description ` to update the PR description. +To submit a PR you should: + +1. Create a new branch. Ask the user if they want a specific prefix for the branch name. +2. Commit the changes using multiple commits if the changes are unrelated. +3. After creating the PR, use `/changelog ` to generate the changelog files, commit and push them. +4. Use `/pr-description ` to update the PR description. From a80919ceff4a26c2b65a545996f282e5c853300b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Wed, 11 Feb 2026 09:54:39 -0800 Subject: [PATCH 10/93] Move PR submission instructions from CLAUDE.md to /pr-submit skill Extract the procedural PR workflow into an actionable skill that can be invoked with /pr-submit. CLAUDE.md is better suited for project context and conventions, not step-by-step procedures. --- .claude/skills/pr-submit/SKILL.md | 28 ++++++++++++++++++++++++++++ CLAUDE.md | 8 -------- 2 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 .claude/skills/pr-submit/SKILL.md diff --git a/.claude/skills/pr-submit/SKILL.md b/.claude/skills/pr-submit/SKILL.md new file mode 100644 index 000000000..5724ddb6e --- /dev/null +++ b/.claude/skills/pr-submit/SKILL.md @@ -0,0 +1,28 @@ +--- +name: pr-submit +description: Create and submit a GitHub PR from the current branch +--- + +Submit the current changes as a GitHub pull request. + +## Instructions + +1. Check the current state of the repository: + - Run `git status` to see staged, unstaged, and untracked changes + - Run `git diff` to see current changes + - Run `git log --oneline -10` to see recent commits + +2. If there are uncommitted changes relevant to the PR: + - Ask the user if they want a specific prefix for the branch name (e.g., `alice/`, `fix/`, `feat/`) + - Create a new branch based on the current branch + - Commit the changes using multiple commits if the changes are unrelated + +3. Push the branch and create the PR: + - Push with `-u` flag to set upstream tracking + - Create the PR using `gh pr create` + +4. After the PR is created: + - Run `/changelog ` to generate changelog files, then commit and push them + - Run `/pr-description ` to update the PR description + +5. Return the PR URL to the user. diff --git a/CLAUDE.md b/CLAUDE.md index 0f4b55e22..7b79fa168 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,11 +153,3 @@ When adding a new service: Test utilities live in `src/pipecat/tests/utils.py`. Use `run_test()` to send frames through a pipeline and assert expected output frames in each direction. Use `SleepFrame(sleep=N)` to add delays between frames. -## Pull Requests - -To submit a PR you should: - -1. Create a new branch. Ask the user if they want a specific prefix for the branch name. -2. Commit the changes using multiple commits if the changes are unrelated. -3. After creating the PR, use `/changelog ` to generate the changelog files, commit and push them. -4. Use `/pr-description ` to update the PR description. From beb4e86b5f6dab95fe702cd6295bcd0f90aea291 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Wed, 11 Feb 2026 16:17:28 -0300 Subject: [PATCH 11/93] Fixing an issue in RTVI where we were sometimes receiving bot output messages before the bot started speaking. --- src/pipecat/processors/frameworks/rtvi.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/pipecat/processors/frameworks/rtvi.py b/src/pipecat/processors/frameworks/rtvi.py index d85cae11f..c1497b40b 100644 --- a/src/pipecat/processors/frameworks/rtvi.py +++ b/src/pipecat/processors/frameworks/rtvi.py @@ -1116,6 +1116,10 @@ class RTVIObserver(BaseObserver): self._last_user_audio_level = 0 self._last_bot_audio_level = 0 + # Track bot speaking state for queuing aggregated text frames + self._bot_is_speaking = False + self._queued_aggregated_text_frames: List[AggregatedTextFrame] = [] + if self._params.system_logs_enabled: self._system_logger_id = logger.add(self._logger_sink) @@ -1384,17 +1388,30 @@ class RTVIObserver(BaseObserver): async def _handle_bot_speaking(self, frame: Frame): """Handle bot speaking event frames.""" - message = None if isinstance(frame, BotStartedSpeakingFrame): message = RTVIBotStartedSpeakingMessage() + await self.send_rtvi_message(message) + # Flush any queued aggregated text frames + for queued_frame in self._queued_aggregated_text_frames: + await self._send_aggregated_llm_text(queued_frame) + self._queued_aggregated_text_frames.clear() + self._bot_is_speaking = True elif isinstance(frame, BotStoppedSpeakingFrame): message = RTVIBotStoppedSpeakingMessage() - - if message: await self.send_rtvi_message(message) + self._bot_is_speaking = False async def _handle_aggregated_llm_text(self, frame: AggregatedTextFrame): """Handle aggregated LLM text output frames.""" + if self._bot_is_speaking: + # Bot has already started speaking, send directly + await self._send_aggregated_llm_text(frame) + else: + # Bot hasn't started speaking yet, queue the frame + self._queued_aggregated_text_frames.append(frame) + + async def _send_aggregated_llm_text(self, frame: AggregatedTextFrame): + """Send aggregated LLM text messages.""" # Skip certain aggregator types if configured to do so. if ( self._params.skip_aggregator_types From ed7fde324ed2dd40aefe613ddf9e7b4d4b7b0638 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Wed, 11 Feb 2026 16:23:42 -0300 Subject: [PATCH 12/93] Adding changelog entry for the RTVIObserver fix. --- changelog/3718.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3718.fixed.md diff --git a/changelog/3718.fixed.md b/changelog/3718.fixed.md new file mode 100644 index 000000000..68e1d2682 --- /dev/null +++ b/changelog/3718.fixed.md @@ -0,0 +1 @@ +- Fixed a race condition in `RTVIObserver` where bot output messages could be sent before the bot-started-speaking event. From 16b060d9e956f391bdbefd5261377fba77baee98 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 11 Feb 2026 18:04:20 -0500 Subject: [PATCH 13/93] Fix Grok Realtime voice type validation for server responses The Grok API now returns prefixed voice names (e.g. "human_Ara") in session.updated events, causing Pydantic validation errors. Widen the voice field type from GrokVoice to GrokVoice | str to accept both user-facing names and server-returned values. --- src/pipecat/services/grok/realtime/events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pipecat/services/grok/realtime/events.py b/src/pipecat/services/grok/realtime/events.py index 4069ff927..1f89a92f7 100644 --- a/src/pipecat/services/grok/realtime/events.py +++ b/src/pipecat/services/grok/realtime/events.py @@ -216,7 +216,7 @@ class SessionProperties(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) instructions: Optional[str] = None - voice: Optional[GrokVoice] = "Ara" + voice: Optional[GrokVoice | str] = "Ara" turn_detection: Optional[TurnDetection] = Field( default_factory=lambda: TurnDetection(type="server_vad") ) From a9669472202c789232d3170ca37f723ec9b5acd3 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 11 Feb 2026 18:04:58 -0500 Subject: [PATCH 14/93] Add changelog for #3720 --- changelog/3720.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3720.fixed.md diff --git a/changelog/3720.fixed.md b/changelog/3720.fixed.md new file mode 100644 index 000000000..c3cb69d34 --- /dev/null +++ b/changelog/3720.fixed.md @@ -0,0 +1 @@ +- Fixed Grok Realtime `session.updated` event parsing failure caused by the API returning prefixed voice names (e.g. `"human_Ara"` instead of `"Ara"`). From dcbcab154287a366e7a24213bef48f57a04c917c Mon Sep 17 00:00:00 2001 From: Ian Lee Date: Tue, 10 Feb 2026 17:52:38 -0800 Subject: [PATCH 15/93] [Inworld] add User-Agent and X-Request-Id for better traceability --- changelog/3706.changed.md | 1 + src/pipecat/services/inworld/tts.py | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 changelog/3706.changed.md diff --git a/changelog/3706.changed.md b/changelog/3706.changed.md new file mode 100644 index 000000000..0c9876bdc --- /dev/null +++ b/changelog/3706.changed.md @@ -0,0 +1 @@ +- Added `X-User-Agent` and `X-Request-Id` headers to `InworldTTSService` for better traceability. diff --git a/src/pipecat/services/inworld/tts.py b/src/pipecat/services/inworld/tts.py index 2ea94399b..7ba7d6b9d 100644 --- a/src/pipecat/services/inworld/tts.py +++ b/src/pipecat/services/inworld/tts.py @@ -16,11 +16,16 @@ Inworld’s text-to-speech (TTS) models offer ultra-realistic, context-aware spe import asyncio import base64 import json +import uuid from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple import aiohttp import websockets from loguru import logger + +from pipecat import version as pipecat_version + +USER_AGENT = f"pipecat/{pipecat_version()}" from pydantic import BaseModel try: @@ -236,9 +241,12 @@ class InworldHttpTTSService(WordTTSService): # Use WORD timestamps for simplicity and correct spacing/capitalization payload["timestampType"] = self._timestamp_type + request_id = str(uuid.uuid4()) headers = { "Authorization": f"Basic {self._api_key}", "Content-Type": "application/json", + "X-User-Agent": USER_AGENT, + "X-Request-Id": request_id, } try: @@ -252,7 +260,7 @@ class InworldHttpTTSService(WordTTSService): ) as response: if response.status != 200: error_text = await response.text() - logger.error(f"Inworld API error: {error_text}") + logger.error(f"Inworld API error (request_id={request_id}): {error_text}") yield ErrorFrame(error=f"Inworld API error: {error_text}") return @@ -693,8 +701,13 @@ class InworldTTSService(AudioContextWordTTSService): if self._websocket and self._websocket.state is State.OPEN: return - logger.debug("Connecting to Inworld WebSocket TTS") - headers = [("Authorization", f"Basic {self._api_key}")] + request_id = str(uuid.uuid4()) + logger.debug(f"Connecting to Inworld WebSocket TTS (request_id={request_id})") + headers = [ + ("Authorization", f"Basic {self._api_key}"), + ("X-User-Agent", USER_AGENT), + ("X-Request-Id", request_id), + ] self._websocket = await websocket_connect(self._url, additional_headers=headers) await self._call_event_handler("on_connected") except Exception as e: From 358f237507281b7cfbe908f718d5fe60deca614e Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 11 Feb 2026 21:58:10 -0500 Subject: [PATCH 16/93] Replace singleton context providers with pipeline-scoped TracingContext ConversationContextProvider and TurnContextProvider were singletons that stored tracing context as class-level state. When two PipelineTask instances ran concurrently, they would overwrite each other's context, causing service spans to attach to the wrong pipeline's turn span. Replace both singletons with a single TracingContext object owned by each PipelineTask, threaded to services via StartFrame. --- src/pipecat/frames/frames.py | 3 + src/pipecat/pipeline/task.py | 6 + src/pipecat/services/llm_service.py | 1 + src/pipecat/services/stt_service.py | 1 + src/pipecat/services/tts_service.py | 1 + .../tracing/conversation_context_provider.py | 114 ------------------ .../utils/tracing/service_decorators.py | 20 +-- src/pipecat/utils/tracing/tracing_context.py | 109 +++++++++++++++++ .../utils/tracing/turn_context_provider.py | 81 ------------- .../utils/tracing/turn_trace_observer.py | 36 +++--- 10 files changed, 149 insertions(+), 223 deletions(-) delete mode 100644 src/pipecat/utils/tracing/conversation_context_provider.py create mode 100644 src/pipecat/utils/tracing/tracing_context.py delete mode 100644 src/pipecat/utils/tracing/turn_context_provider.py diff --git a/src/pipecat/frames/frames.py b/src/pipecat/frames/frames.py index 5634d79ee..8d237defc 100644 --- a/src/pipecat/frames/frames.py +++ b/src/pipecat/frames/frames.py @@ -42,6 +42,7 @@ from pipecat.utils.utils import obj_count, obj_id if TYPE_CHECKING: from pipecat.processors.aggregators.llm_context import LLMContext, NotGiven from pipecat.processors.frame_processor import FrameProcessor + from pipecat.utils.tracing.tracing_context import TracingContext class DeprecatedKeypadEntry: @@ -1036,6 +1037,7 @@ class StartFrame(SystemFrame): Use `LLMUserAggregator`'s new `user_turn_strategies` parameter instead. report_only_initial_ttfb: Whether to report only initial time-to-first-byte. + tracing_context: Pipeline-scoped tracing context for span hierarchy. """ audio_in_sample_rate: int = 16000 @@ -1046,6 +1048,7 @@ class StartFrame(SystemFrame): enable_usage_metrics: bool = False interruption_strategies: List[BaseInterruptionStrategy] = field(default_factory=list) report_only_initial_ttfb: bool = False + tracing_context: Optional["TracingContext"] = None @dataclass diff --git a/src/pipecat/pipeline/task.py b/src/pipecat/pipeline/task.py index 8bda19b18..2cfe26606 100644 --- a/src/pipecat/pipeline/task.py +++ b/src/pipecat/pipeline/task.py @@ -53,6 +53,7 @@ from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, F from pipecat.processors.frameworks.rtvi import RTVIObserver, RTVIObserverParams, RTVIProcessor from pipecat.utils.asyncio.task_manager import BaseTaskManager, TaskManager, TaskManagerParams from pipecat.utils.tracing.setup import is_tracing_available +from pipecat.utils.tracing.tracing_context import TracingContext from pipecat.utils.tracing.turn_trace_observer import TurnTraceObserver HEARTBEAT_SECS = 1.0 @@ -290,10 +291,13 @@ class PipelineTask(BasePipelineTask): self._turn_tracking_observer: Optional[TurnTrackingObserver] = None self._user_bot_latency_observer: Optional[UserBotLatencyObserver] = None self._turn_trace_observer: Optional[TurnTraceObserver] = None + self._tracing_context: Optional[TracingContext] = None if self._enable_turn_tracking: self._turn_tracking_observer = TurnTrackingObserver() observers.append(self._turn_tracking_observer) if self._enable_tracing and self._turn_tracking_observer: + # Create pipeline-scoped tracing context + self._tracing_context = TracingContext() # Create latency observer for tracing self._user_bot_latency_observer = UserBotLatencyObserver() observers.append(self._user_bot_latency_observer) @@ -303,6 +307,7 @@ class PipelineTask(BasePipelineTask): latency_tracker=self._user_bot_latency_observer, conversation_id=self._conversation_id, additional_span_attributes=self._additional_span_attributes, + tracing_context=self._tracing_context, ) observers.append(self._turn_trace_observer) @@ -813,6 +818,7 @@ class PipelineTask(BasePipelineTask): enable_usage_metrics=self._params.enable_usage_metrics, report_only_initial_ttfb=self._params.report_only_initial_ttfb, interruption_strategies=self._params.interruption_strategies, + tracing_context=self._tracing_context, ) start_frame.metadata = self._create_start_metadata() await self._pipeline.queue_frame(start_frame) diff --git a/src/pipecat/services/llm_service.py b/src/pipecat/services/llm_service.py index af7e691b0..acbd6baae 100644 --- a/src/pipecat/services/llm_service.py +++ b/src/pipecat/services/llm_service.py @@ -286,6 +286,7 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): if not self._run_in_parallel: await self._create_sequential_runner_task() self._tracing_enabled = frame.enable_tracing + self._tracing_context = frame.tracing_context async def stop(self, frame: EndFrame): """Stop the LLM service. diff --git a/src/pipecat/services/stt_service.py b/src/pipecat/services/stt_service.py index b556bb23a..81f369572 100644 --- a/src/pipecat/services/stt_service.py +++ b/src/pipecat/services/stt_service.py @@ -203,6 +203,7 @@ class STTService(AIService): await super().start(frame) self._sample_rate = self._init_sample_rate or frame.audio_in_sample_rate self._tracing_enabled = frame.enable_tracing + self._tracing_context = frame.tracing_context async def cleanup(self): """Clean up STT service resources.""" diff --git a/src/pipecat/services/tts_service.py b/src/pipecat/services/tts_service.py index 239d2398b..00e3e81f8 100644 --- a/src/pipecat/services/tts_service.py +++ b/src/pipecat/services/tts_service.py @@ -350,6 +350,7 @@ class TTSService(AIService): if self._push_stop_frames and not self._stop_frame_task: self._stop_frame_task = self.create_task(self._stop_frame_handler()) self._tracing_enabled = frame.enable_tracing + self._tracing_context = frame.tracing_context async def stop(self, frame: EndFrame): """Stop the TTS service. diff --git a/src/pipecat/utils/tracing/conversation_context_provider.py b/src/pipecat/utils/tracing/conversation_context_provider.py deleted file mode 100644 index 4bb88fe14..000000000 --- a/src/pipecat/utils/tracing/conversation_context_provider.py +++ /dev/null @@ -1,114 +0,0 @@ -# -# Copyright (c) 2024-2026, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - -"""Conversation context provider for OpenTelemetry tracing in Pipecat. - -This module provides a singleton context provider that manages the current -conversation's tracing context, allowing services to create child spans -that are properly associated with the conversation. -""" - -import uuid -from typing import TYPE_CHECKING, Optional - -# Import types for type checking only -if TYPE_CHECKING: - from opentelemetry.context import Context - from opentelemetry.trace import SpanContext - -from pipecat.utils.tracing.setup import is_tracing_available - -if is_tracing_available(): - from opentelemetry.context import Context - from opentelemetry.trace import NonRecordingSpan, SpanContext, set_span_in_context - - -class ConversationContextProvider: - """Provides access to the current conversation's tracing context. - - This is a singleton that can be used to get the current conversation's - span context to create child spans (like turns). - """ - - _instance = None - _current_conversation_context: Optional["Context"] = None - _conversation_id: Optional[str] = None - - @classmethod - def get_instance(cls): - """Get the singleton instance. - - Returns: - The singleton ConversationContextProvider instance. - """ - if cls._instance is None: - cls._instance = ConversationContextProvider() - return cls._instance - - def set_current_conversation_context( - self, span_context: Optional["SpanContext"], conversation_id: Optional[str] = None - ): - """Set the current conversation context. - - Args: - span_context: The span context for the current conversation or None to clear it. - conversation_id: Optional ID for the conversation. - """ - if not is_tracing_available(): - return - - self._conversation_id = conversation_id - - if span_context: - # Create a non-recording span from the span context - non_recording_span = NonRecordingSpan(span_context) - self._current_conversation_context = set_span_in_context(non_recording_span) - else: - self._current_conversation_context = None - - def get_current_conversation_context(self) -> Optional["Context"]: - """Get the OpenTelemetry context for the current conversation. - - Returns: - The current conversation context or None if not available. - """ - return self._current_conversation_context - - def get_conversation_id(self) -> Optional[str]: - """Get the ID for the current conversation. - - Returns: - The current conversation ID or None if not available. - """ - return self._conversation_id - - def generate_conversation_id(self) -> str: - """Generate a new conversation ID. - - Returns: - A new randomly generated UUID string. - """ - return str(uuid.uuid4()) - - -def get_current_conversation_context() -> Optional["Context"]: - """Get the OpenTelemetry context for the current conversation. - - Returns: - The current conversation context or None if not available. - """ - provider = ConversationContextProvider.get_instance() - return provider.get_current_conversation_context() - - -def get_conversation_id() -> Optional[str]: - """Get the ID for the current conversation. - - Returns: - The current conversation ID or None if not available. - """ - provider = ConversationContextProvider.get_instance() - return provider.get_conversation_id() diff --git a/src/pipecat/utils/tracing/service_decorators.py b/src/pipecat/utils/tracing/service_decorators.py index 42babd5cd..a4f0d8c37 100644 --- a/src/pipecat/utils/tracing/service_decorators.py +++ b/src/pipecat/utils/tracing/service_decorators.py @@ -25,7 +25,6 @@ if TYPE_CHECKING: from pipecat.processors.aggregators.llm_context import NOT_GIVEN, LLMContext from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext -from pipecat.utils.tracing.conversation_context_provider import get_current_conversation_context from pipecat.utils.tracing.service_attributes import ( add_gemini_live_span_attributes, add_llm_span_attributes, @@ -34,7 +33,6 @@ from pipecat.utils.tracing.service_attributes import ( add_tts_span_attributes, ) from pipecat.utils.tracing.setup import is_tracing_available -from pipecat.utils.tracing.turn_context_provider import get_current_turn_context if is_tracing_available(): from opentelemetry import context as context_api @@ -76,7 +74,8 @@ def _get_parent_service_context(self): return trace.set_span_in_context(self._span) # Fall back to conversation context if available - conversation_context = get_current_conversation_context() + tracing_ctx = getattr(self, "_tracing_context", None) + conversation_context = tracing_ctx.get_conversation_context() if tracing_ctx else None if conversation_context: return conversation_context @@ -183,7 +182,8 @@ def traced_tts(func: Optional[Callable] = None, *, name: Optional[str] = None) - span_name = "tts" # Get parent context - turn_context = get_current_turn_context() + tracing_ctx = getattr(self, "_tracing_context", None) + turn_context = tracing_ctx.get_turn_context() if tracing_ctx else None parent_context = turn_context or _get_parent_service_context(self) # Create span @@ -290,7 +290,8 @@ def traced_stt(func: Optional[Callable] = None, *, name: Optional[str] = None) - span_name = "stt" # Get the turn context first, then fall back to service context - turn_context = get_current_turn_context() + tracing_ctx = getattr(self, "_tracing_context", None) + turn_context = tracing_ctx.get_turn_context() if tracing_ctx else None parent_context = turn_context or _get_parent_service_context(self) # Create a new span as child of the turn span or service span @@ -372,7 +373,8 @@ def traced_llm(func: Optional[Callable] = None, *, name: Optional[str] = None) - span_name = "llm" # Get the parent context - turn context if available, otherwise service context - turn_context = get_current_turn_context() + tracing_ctx = getattr(self, "_tracing_context", None) + turn_context = tracing_ctx.get_turn_context() if tracing_ctx else None parent_context = turn_context or _get_parent_service_context(self) # Create a new span as child of the turn span or service span @@ -582,7 +584,8 @@ def traced_gemini_live(operation: str) -> Callable: span_name = f"{operation}" # Get the parent context - turn context if available, otherwise service context - turn_context = get_current_turn_context() + tracing_ctx = getattr(self, "_tracing_context", None) + turn_context = tracing_ctx.get_turn_context() if tracing_ctx else None parent_context = turn_context or _get_parent_service_context(self) # Create a new span as child of the turn span or service span @@ -889,7 +892,8 @@ def traced_openai_realtime(operation: str) -> Callable: span_name = f"{operation}" # Get the parent context - turn context if available, otherwise service context - turn_context = get_current_turn_context() + tracing_ctx = getattr(self, "_tracing_context", None) + turn_context = tracing_ctx.get_turn_context() if tracing_ctx else None parent_context = turn_context or _get_parent_service_context(self) # Create a new span as child of the turn span or service span diff --git a/src/pipecat/utils/tracing/tracing_context.py b/src/pipecat/utils/tracing/tracing_context.py new file mode 100644 index 000000000..c84299701 --- /dev/null +++ b/src/pipecat/utils/tracing/tracing_context.py @@ -0,0 +1,109 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Pipeline-scoped tracing context for OpenTelemetry tracing in Pipecat. + +This module provides a per-pipeline tracing context that holds the current +conversation and turn span contexts. Each PipelineTask creates its own +TracingContext, ensuring concurrent pipelines do not interfere with each other. +""" + +import uuid +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from opentelemetry.context import Context + from opentelemetry.trace import SpanContext + +from pipecat.utils.tracing.setup import is_tracing_available + +if is_tracing_available(): + from opentelemetry.context import Context + from opentelemetry.trace import NonRecordingSpan, SpanContext, set_span_in_context + + +class TracingContext: + """Pipeline-scoped tracing context. + + Holds the current conversation and turn span contexts for a single pipeline. + Created by PipelineTask, passed to TurnTraceObserver (writer) and services + (readers) via StartFrame. + """ + + def __init__(self): + """Initialize the tracing context with empty state.""" + self._conversation_context: Optional["Context"] = None + self._turn_context: Optional["Context"] = None + self._conversation_id: Optional[str] = None + + def set_conversation_context( + self, span_context: Optional["SpanContext"], conversation_id: Optional[str] = None + ): + """Set the current conversation context. + + Args: + span_context: The span context for the current conversation or None to clear it. + conversation_id: Optional ID for the conversation. + """ + if not is_tracing_available(): + return + + self._conversation_id = conversation_id + + if span_context: + non_recording_span = NonRecordingSpan(span_context) + self._conversation_context = set_span_in_context(non_recording_span) + else: + self._conversation_context = None + + def get_conversation_context(self) -> Optional["Context"]: + """Get the OpenTelemetry context for the current conversation. + + Returns: + The current conversation context or None if not available. + """ + return self._conversation_context + + def set_turn_context(self, span_context: Optional["SpanContext"]): + """Set the current turn context. + + Args: + span_context: The span context for the current turn or None to clear it. + """ + if not is_tracing_available(): + return + + if span_context: + non_recording_span = NonRecordingSpan(span_context) + self._turn_context = set_span_in_context(non_recording_span) + else: + self._turn_context = None + + def get_turn_context(self) -> Optional["Context"]: + """Get the OpenTelemetry context for the current turn. + + Returns: + The current turn context or None if not available. + """ + return self._turn_context + + @property + def conversation_id(self) -> Optional[str]: + """Get the ID for the current conversation. + + Returns: + The current conversation ID or None if not available. + """ + return self._conversation_id + + @staticmethod + def generate_conversation_id() -> str: + """Generate a new conversation ID. + + Returns: + A new randomly generated UUID string. + """ + return str(uuid.uuid4()) diff --git a/src/pipecat/utils/tracing/turn_context_provider.py b/src/pipecat/utils/tracing/turn_context_provider.py deleted file mode 100644 index edb165561..000000000 --- a/src/pipecat/utils/tracing/turn_context_provider.py +++ /dev/null @@ -1,81 +0,0 @@ -# -# Copyright (c) 2024-2026, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - -"""Turn context provider for OpenTelemetry tracing in Pipecat. - -This module provides a singleton context provider that manages the current -turn's tracing context, allowing services to create child spans that are -properly associated with the conversation turn. -""" - -from typing import TYPE_CHECKING, Optional - -# Import types for type checking only -if TYPE_CHECKING: - from opentelemetry.context import Context - from opentelemetry.trace import SpanContext - -from pipecat.utils.tracing.setup import is_tracing_available - -if is_tracing_available(): - from opentelemetry.context import Context - from opentelemetry.trace import NonRecordingSpan, SpanContext, set_span_in_context - - -class TurnContextProvider: - """Provides access to the current turn's tracing context. - - This is a singleton that services can use to get the current turn's - span context to create child spans. - """ - - _instance = None - _current_turn_context: Optional["Context"] = None - - @classmethod - def get_instance(cls): - """Get the singleton instance. - - Returns: - The singleton TurnContextProvider instance. - """ - if cls._instance is None: - cls._instance = TurnContextProvider() - return cls._instance - - def set_current_turn_context(self, span_context: Optional["SpanContext"]): - """Set the current turn context. - - Args: - span_context: The span context for the current turn or None to clear it. - """ - if not is_tracing_available(): - return - - if span_context: - # Create a non-recording span from the span context - non_recording_span = NonRecordingSpan(span_context) - self._current_turn_context = set_span_in_context(non_recording_span) - else: - self._current_turn_context = None - - def get_current_turn_context(self) -> Optional["Context"]: - """Get the OpenTelemetry context for the current turn. - - Returns: - The current turn context or None if not available. - """ - return self._current_turn_context - - -def get_current_turn_context() -> Optional["Context"]: - """Get the OpenTelemetry context for the current turn. - - Returns: - The current turn context or None if not available. - """ - provider = TurnContextProvider.get_instance() - return provider.get_current_turn_context() diff --git a/src/pipecat/utils/tracing/turn_trace_observer.py b/src/pipecat/utils/tracing/turn_trace_observer.py index 78a9c9ea9..83c2bcdc2 100644 --- a/src/pipecat/utils/tracing/turn_trace_observer.py +++ b/src/pipecat/utils/tracing/turn_trace_observer.py @@ -19,9 +19,8 @@ from pipecat.frames.frames import StartFrame from pipecat.observers.base_observer import BaseObserver, FramePushed from pipecat.observers.turn_tracking_observer import TurnTrackingObserver from pipecat.observers.user_bot_latency_observer import UserBotLatencyObserver -from pipecat.utils.tracing.conversation_context_provider import ConversationContextProvider from pipecat.utils.tracing.setup import is_tracing_available -from pipecat.utils.tracing.turn_context_provider import TurnContextProvider +from pipecat.utils.tracing.tracing_context import TracingContext # Import types for type checking only if TYPE_CHECKING: @@ -49,6 +48,7 @@ class TurnTraceObserver(BaseObserver): latency_tracker: UserBotLatencyObserver, conversation_id: Optional[str] = None, additional_span_attributes: Optional[dict] = None, + tracing_context: Optional[TracingContext] = None, **kwargs, ): """Initialize the turn trace observer. @@ -58,11 +58,13 @@ class TurnTraceObserver(BaseObserver): latency_tracker: The latency tracking observer for user-bot latency. conversation_id: Optional conversation ID for grouping turns. additional_span_attributes: Additional attributes to add to spans. + tracing_context: Pipeline-scoped tracing context for span hierarchy. **kwargs: Additional arguments passed to parent class. """ super().__init__(**kwargs) self._turn_tracker = turn_tracker self._latency_tracker = latency_tracker + self._tracing_context = tracing_context or TracingContext() self._current_span: Optional["Span"] = None self._current_turn_number: int = 0 self._trace_context_map: Dict[int, "SpanContext"] = {} @@ -123,9 +125,8 @@ class TurnTraceObserver(BaseObserver): return # Generate a conversation ID if not provided - context_provider = ConversationContextProvider.get_instance() if conversation_id is None: - conversation_id = context_provider.generate_conversation_id() + conversation_id = TracingContext.generate_conversation_id() logger.debug(f"Generated new conversation ID: {conversation_id}") self._conversation_id = conversation_id @@ -140,8 +141,8 @@ class TurnTraceObserver(BaseObserver): for k, v in (self._additional_span_attributes or {}).items(): self._conversation_span.set_attribute(k, v) - # Update the conversation context provider - context_provider.set_current_conversation_context( + # Update the tracing context + self._tracing_context.set_conversation_context( self._conversation_span.get_span_context(), conversation_id ) @@ -161,9 +162,8 @@ class TurnTraceObserver(BaseObserver): self._current_span.end() self._current_span = None - # Clear the turn context provider - context_provider = TurnContextProvider.get_instance() - context_provider.set_current_turn_context(None) + # Clear the turn context + self._tracing_context.set_turn_context(None) # Now end the conversation span if it exists if self._conversation_span: @@ -171,9 +171,8 @@ class TurnTraceObserver(BaseObserver): self._conversation_span.end() self._conversation_span = None - # Clear the context provider - context_provider = ConversationContextProvider.get_instance() - context_provider.set_current_conversation_context(None) + # Clear the conversation context + self._tracing_context.set_conversation_context(None) logger.debug(f"Ended tracing for Conversation {self._conversation_id}") self._conversation_id = None @@ -189,8 +188,7 @@ class TurnTraceObserver(BaseObserver): # Get the parent context - conversation if available, otherwise use root context parent_context = None if self._conversation_span: - context_provider = ConversationContextProvider.get_instance() - parent_context = context_provider.get_current_conversation_context() + parent_context = self._tracing_context.get_conversation_context() # Create a new span for this turn self._current_span = self._tracer.start_span("turn", context=parent_context) @@ -207,9 +205,8 @@ class TurnTraceObserver(BaseObserver): # Store the span context so services can become children of this span self._trace_context_map[turn_number] = self._current_span.get_span_context() - # Update the context provider so services can access this span - context_provider = TurnContextProvider.get_instance() - context_provider.set_current_turn_context(self._current_span.get_span_context()) + # Update the tracing context so services can access this span + self._tracing_context.set_turn_context(self._current_span.get_span_context()) logger.debug(f"Started tracing for Turn {turn_number}") @@ -228,9 +225,8 @@ class TurnTraceObserver(BaseObserver): self._current_span.end() self._current_span = None - # Clear the context provider - context_provider = TurnContextProvider.get_instance() - context_provider.set_current_turn_context(None) + # Clear the turn context + self._tracing_context.set_turn_context(None) logger.debug(f"Ended tracing for Turn {turn_number}") From 71a752c971d9ef2231b4f3820df8d55d3b7a683d Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 11 Feb 2026 22:42:58 -0500 Subject: [PATCH 17/93] Add tests for TracingContext and TurnTraceObserver Cover pipeline-scoped tracing context lifecycle, span hierarchy, conversation/turn context management, and concurrent pipeline isolation. --- .github/workflows/coverage.yaml | 1 + .github/workflows/tests.yaml | 1 + tests/test_tracing_context.py | 127 ++++++++ tests/test_turn_trace_observer.py | 505 ++++++++++++++++++++++++++++++ 4 files changed, 634 insertions(+) create mode 100644 tests/test_tracing_context.py create mode 100644 tests/test_turn_trace_observer.py diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 852611eba..b78067c97 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -41,6 +41,7 @@ jobs: --extra livekit \ --extra local-smart-turn-v3 \ --extra piper \ + --extra tracing \ --extra websocket - name: Run tests with coverage diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 54495911b..5bdfb94f4 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -45,6 +45,7 @@ jobs: --extra livekit \ --extra local-smart-turn-v3 \ --extra piper \ + --extra tracing \ --extra websocket - name: Test with pytest diff --git a/tests/test_tracing_context.py b/tests/test_tracing_context.py new file mode 100644 index 000000000..06aa34683 --- /dev/null +++ b/tests/test_tracing_context.py @@ -0,0 +1,127 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import unittest + +try: + from opentelemetry.sdk.trace import TracerProvider + + HAS_OPENTELEMETRY = True +except ImportError: + HAS_OPENTELEMETRY = False + +from pipecat.utils.tracing.tracing_context import TracingContext + + +@unittest.skipUnless(HAS_OPENTELEMETRY, "opentelemetry not installed") +class TestTracingContext(unittest.TestCase): + """Tests for TracingContext.""" + + @classmethod + def setUpClass(cls): + """Set up a tracer provider for generating span contexts.""" + cls._provider = TracerProvider() + cls._tracer = cls._provider.get_tracer("test") + + def test_initial_state_is_empty(self): + """Test that a new TracingContext starts with no context set.""" + ctx = TracingContext() + self.assertIsNone(ctx.get_conversation_context()) + self.assertIsNone(ctx.get_turn_context()) + self.assertIsNone(ctx.conversation_id) + + def test_set_and_get_conversation_context(self): + """Test setting and retrieving conversation context.""" + ctx = TracingContext() + span = self._tracer.start_span("conv") + span_context = span.get_span_context() + + ctx.set_conversation_context(span_context, "conv-123") + + self.assertIsNotNone(ctx.get_conversation_context()) + self.assertEqual(ctx.conversation_id, "conv-123") + span.end() + + def test_clear_conversation_context(self): + """Test clearing conversation context by passing None.""" + ctx = TracingContext() + span = self._tracer.start_span("conv") + + ctx.set_conversation_context(span.get_span_context(), "conv-123") + self.assertIsNotNone(ctx.get_conversation_context()) + + ctx.set_conversation_context(None) + self.assertIsNone(ctx.get_conversation_context()) + self.assertIsNone(ctx.conversation_id) + span.end() + + def test_set_and_get_turn_context(self): + """Test setting and retrieving turn context.""" + ctx = TracingContext() + span = self._tracer.start_span("turn") + span_context = span.get_span_context() + + ctx.set_turn_context(span_context) + + self.assertIsNotNone(ctx.get_turn_context()) + span.end() + + def test_clear_turn_context(self): + """Test clearing turn context by passing None.""" + ctx = TracingContext() + span = self._tracer.start_span("turn") + + ctx.set_turn_context(span.get_span_context()) + self.assertIsNotNone(ctx.get_turn_context()) + + ctx.set_turn_context(None) + self.assertIsNone(ctx.get_turn_context()) + span.end() + + def test_generate_conversation_id(self): + """Test that generated conversation IDs are unique UUIDs.""" + id1 = TracingContext.generate_conversation_id() + id2 = TracingContext.generate_conversation_id() + self.assertIsInstance(id1, str) + self.assertNotEqual(id1, id2) + + def test_instances_are_isolated(self): + """Test that two TracingContext instances do not share state.""" + ctx_a = TracingContext() + ctx_b = TracingContext() + + span = self._tracer.start_span("turn") + + ctx_a.set_turn_context(span.get_span_context()) + ctx_a.set_conversation_context(span.get_span_context(), "conv-a") + + # ctx_b should still be empty + self.assertIsNone(ctx_b.get_turn_context()) + self.assertIsNone(ctx_b.get_conversation_context()) + self.assertIsNone(ctx_b.conversation_id) + span.end() + + def test_conversation_and_turn_are_independent(self): + """Test that clearing turn context does not affect conversation context.""" + ctx = TracingContext() + conv_span = self._tracer.start_span("conv") + turn_span = self._tracer.start_span("turn") + + ctx.set_conversation_context(conv_span.get_span_context(), "conv-1") + ctx.set_turn_context(turn_span.get_span_context()) + + # Clear turn but conversation should remain + ctx.set_turn_context(None) + self.assertIsNone(ctx.get_turn_context()) + self.assertIsNotNone(ctx.get_conversation_context()) + self.assertEqual(ctx.conversation_id, "conv-1") + + conv_span.end() + turn_span.end() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_turn_trace_observer.py b/tests/test_turn_trace_observer.py new file mode 100644 index 000000000..41ff41b8b --- /dev/null +++ b/tests/test_turn_trace_observer.py @@ -0,0 +1,505 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import threading +import unittest + +try: + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult + + HAS_OPENTELEMETRY = True +except ImportError: + HAS_OPENTELEMETRY = False + +from pipecat.frames.frames import ( + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, +) +from pipecat.observers.turn_tracking_observer import TurnTrackingObserver +from pipecat.observers.user_bot_latency_observer import UserBotLatencyObserver +from pipecat.processors.filters.identity_filter import IdentityFilter +from pipecat.tests.utils import SleepFrame, run_test +from pipecat.utils.tracing.tracing_context import TracingContext +from pipecat.utils.tracing.turn_trace_observer import TurnTraceObserver + +if HAS_OPENTELEMETRY: + + class _InMemorySpanExporter(SpanExporter): + """Simple in-memory span exporter for testing.""" + + def __init__(self): + """Initialize the exporter.""" + self._spans = [] + self._lock = threading.Lock() + + def export(self, spans): + """Export spans to memory.""" + with self._lock: + self._spans.extend(spans) + return SpanExportResult.SUCCESS + + def get_finished_spans(self): + """Return collected spans.""" + with self._lock: + return list(self._spans) + + def clear(self): + """Clear collected spans.""" + with self._lock: + self._spans.clear() + + +@unittest.skipUnless(HAS_OPENTELEMETRY, "opentelemetry not installed") +class TestTurnTraceObserver(unittest.IsolatedAsyncioTestCase): + """Tests for TurnTraceObserver.""" + + def setUp(self): + """Set up a fresh provider and exporter for each test. + + We create a dedicated TracerProvider per test and inject its tracer + directly into the observer, avoiding the global provider singleton. + """ + self._exporter = _InMemorySpanExporter() + self._provider = TracerProvider() + self._provider.add_span_processor(SimpleSpanProcessor(self._exporter)) + self._tracer = self._provider.get_tracer("pipecat.turn") + + def tearDown(self): + """Shut down the provider to flush spans.""" + self._provider.shutdown() + + def _create_observers(self, conversation_id=None, tracing_context=None): + """Create a standard set of turn/trace observers. + + Args: + conversation_id: Optional conversation ID. + tracing_context: Optional TracingContext instance. + + Returns: + Tuple of (turn_tracker, latency_tracker, trace_observer, tracing_context). + """ + tracing_context = tracing_context or TracingContext() + turn_tracker = TurnTrackingObserver(turn_end_timeout_secs=0.2) + latency_tracker = UserBotLatencyObserver() + trace_observer = TurnTraceObserver( + turn_tracker, + latency_tracker=latency_tracker, + conversation_id=conversation_id, + tracing_context=tracing_context, + ) + # Inject the test tracer so spans go to our in-memory exporter + trace_observer._tracer = self._tracer + return turn_tracker, latency_tracker, trace_observer, tracing_context + + def _all_observers(self, trace_observer): + """Return the list of observers needed for run_test.""" + return [trace_observer._turn_tracker, trace_observer._latency_tracker, trace_observer] + + def _get_spans_by_name(self, name): + """Return finished spans with the given name.""" + return [s for s in self._exporter.get_finished_spans() if s.name == name] + + async def test_conversation_span_created_on_start_frame(self): + """Test that a conversation span is created when StartFrame is observed.""" + _, _, trace_observer, _ = self._create_observers(conversation_id="test-conv") + processor = IdentityFilter() + + frames_to_send = [ + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + BotStoppedSpeakingFrame(), + SleepFrame(sleep=0.4), + ] + + expected_down_frames = [ + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=self._all_observers(trace_observer), + ) + + # End conversation to flush the conversation span (normally done by PipelineTask._cleanup) + trace_observer.end_conversation_tracing() + + conv_spans = self._get_spans_by_name("conversation") + self.assertEqual(len(conv_spans), 1) + self.assertEqual(conv_spans[0].attributes["conversation.id"], "test-conv") + self.assertEqual(conv_spans[0].attributes["conversation.type"], "voice") + + async def test_turn_spans_created_for_each_turn(self): + """Test that a turn span is created for each conversation turn.""" + _, _, trace_observer, _ = self._create_observers() + processor = IdentityFilter() + + frames_to_send = [ + # Turn 1 + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + BotStoppedSpeakingFrame(), + SleepFrame(sleep=0.05), + # Turn 2 + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + BotStoppedSpeakingFrame(), + SleepFrame(sleep=0.4), + ] + + expected_down_frames = [ + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=self._all_observers(trace_observer), + ) + + turn_spans = self._get_spans_by_name("turn") + self.assertEqual(len(turn_spans), 2) + turn_numbers = {s.attributes["turn.number"] for s in turn_spans} + self.assertEqual(turn_numbers, {1, 2}) + + async def test_turn_spans_are_children_of_conversation(self): + """Test that turn spans are parented under the conversation span.""" + _, _, trace_observer, _ = self._create_observers() + processor = IdentityFilter() + + frames_to_send = [ + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + BotStoppedSpeakingFrame(), + SleepFrame(sleep=0.4), + ] + + expected_down_frames = [ + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=self._all_observers(trace_observer), + ) + + # End conversation to flush the conversation span + trace_observer.end_conversation_tracing() + + conv_spans = self._get_spans_by_name("conversation") + turn_spans = self._get_spans_by_name("turn") + self.assertEqual(len(conv_spans), 1) + self.assertEqual(len(turn_spans), 1) + + # Turn span's parent should be the conversation span + conv_span_id = conv_spans[0].context.span_id + turn_parent_id = turn_spans[0].parent.span_id + self.assertEqual(turn_parent_id, conv_span_id) + + async def test_interrupted_turn_marked(self): + """Test that an interrupted turn span has was_interrupted=True.""" + _, _, trace_observer, _ = self._create_observers() + processor = IdentityFilter() + + frames_to_send = [ + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + # User interrupts + UserStartedSpeakingFrame(), + SleepFrame(sleep=0.4), + ] + + expected_down_frames = [ + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + UserStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=self._all_observers(trace_observer), + ) + + # End conversation to flush remaining spans + trace_observer.end_conversation_tracing() + + turn_spans = self._get_spans_by_name("turn") + self.assertGreaterEqual(len(turn_spans), 1) + # First turn should be interrupted + interrupted_turns = [s for s in turn_spans if s.attributes.get("turn.was_interrupted")] + self.assertGreaterEqual(len(interrupted_turns), 1) + + async def test_tracing_context_updated_during_turn(self): + """Test that TracingContext is populated during a turn and cleared after.""" + tracing_ctx = TracingContext() + _, _, trace_observer, _ = self._create_observers(tracing_context=tracing_ctx) + processor = IdentityFilter() + + frames_to_send = [ + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + BotStoppedSpeakingFrame(), + SleepFrame(sleep=0.4), + ] + + expected_down_frames = [ + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=self._all_observers(trace_observer), + ) + + # After the turn ends, turn context should be cleared + self.assertIsNone(tracing_ctx.get_turn_context()) + + async def test_tracing_context_cleared_after_conversation_end(self): + """Test that TracingContext is cleared when conversation tracing ends.""" + tracing_ctx = TracingContext() + _, _, trace_observer, _ = self._create_observers(tracing_context=tracing_ctx) + processor = IdentityFilter() + + frames_to_send = [ + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + BotStoppedSpeakingFrame(), + SleepFrame(sleep=0.4), + ] + + expected_down_frames = [ + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=self._all_observers(trace_observer), + ) + + # Manually end conversation tracing (as PipelineTask._cleanup does) + trace_observer.end_conversation_tracing() + + self.assertIsNone(tracing_ctx.get_conversation_context()) + self.assertIsNone(tracing_ctx.get_turn_context()) + self.assertIsNone(tracing_ctx.conversation_id) + + async def test_additional_span_attributes(self): + """Test that additional span attributes are added to the conversation span.""" + extra_attrs = {"deployment.id": "abc-123", "customer.tier": "premium"} + tracing_ctx = TracingContext() + turn_tracker = TurnTrackingObserver(turn_end_timeout_secs=0.2) + latency_tracker = UserBotLatencyObserver() + trace_observer = TurnTraceObserver( + turn_tracker, + latency_tracker=latency_tracker, + additional_span_attributes=extra_attrs, + tracing_context=tracing_ctx, + ) + trace_observer._tracer = self._tracer + processor = IdentityFilter() + + frames_to_send = [ + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + BotStoppedSpeakingFrame(), + SleepFrame(sleep=0.4), + ] + + expected_down_frames = [ + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[turn_tracker, latency_tracker, trace_observer], + ) + + # End conversation to flush the conversation span + trace_observer.end_conversation_tracing() + + conv_spans = self._get_spans_by_name("conversation") + self.assertEqual(len(conv_spans), 1) + self.assertEqual(conv_spans[0].attributes["deployment.id"], "abc-123") + self.assertEqual(conv_spans[0].attributes["customer.tier"], "premium") + + async def test_concurrent_pipelines_are_isolated(self): + """Test that two pipelines with separate TracingContexts don't interfere.""" + tracing_ctx_a = TracingContext() + tracing_ctx_b = TracingContext() + + _, _, trace_observer_a, _ = self._create_observers( + conversation_id="conv-a", tracing_context=tracing_ctx_a + ) + _, _, trace_observer_b, _ = self._create_observers( + conversation_id="conv-b", tracing_context=tracing_ctx_b + ) + + processor_a = IdentityFilter() + processor_b = IdentityFilter() + + frames = [ + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + BotStoppedSpeakingFrame(), + SleepFrame(sleep=0.4), + ] + + expected = [ + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + ] + + # Run both pipelines concurrently + await asyncio.gather( + run_test( + processor_a, + frames_to_send=frames, + expected_down_frames=expected, + observers=self._all_observers(trace_observer_a), + ), + run_test( + processor_b, + frames_to_send=frames, + expected_down_frames=expected, + observers=self._all_observers(trace_observer_b), + ), + ) + + # End both conversations to flush spans + trace_observer_a.end_conversation_tracing() + trace_observer_b.end_conversation_tracing() + + # Each TracingContext should have its own conversation ID + conv_spans = self._get_spans_by_name("conversation") + conv_ids = {s.attributes["conversation.id"] for s in conv_spans} + self.assertEqual(conv_ids, {"conv-a", "conv-b"}) + + # Turn spans should be children of their own conversation span, not cross-linked + turn_spans = self._get_spans_by_name("turn") + conv_span_map = {s.context.span_id: s.attributes["conversation.id"] for s in conv_spans} + for turn_span in turn_spans: + parent_id = turn_span.parent.span_id + turn_conv_id = turn_span.attributes["conversation.id"] + parent_conv_id = conv_span_map[parent_id] + self.assertEqual( + turn_conv_id, + parent_conv_id, + f"Turn span for {turn_conv_id} parented under {parent_conv_id}", + ) + + async def test_end_conversation_closes_active_turn(self): + """Test that end_conversation_tracing closes any active turn span.""" + _, _, trace_observer, _ = self._create_observers() + + # Manually start conversation and a turn + trace_observer.start_conversation_tracing("conv-end-test") + await trace_observer._handle_turn_started(1) + + self.assertIsNotNone(trace_observer._current_span) + self.assertIsNotNone(trace_observer._conversation_span) + + # End conversation — should close both turn and conversation + trace_observer.end_conversation_tracing() + + self.assertIsNone(trace_observer._current_span) + self.assertIsNone(trace_observer._conversation_span) + + # Check span attributes + turn_spans = self._get_spans_by_name("turn") + self.assertEqual(len(turn_spans), 1) + self.assertTrue(turn_spans[0].attributes["turn.was_interrupted"]) + self.assertTrue(turn_spans[0].attributes["turn.ended_by_conversation_end"]) + + async def test_conversation_id_auto_generated(self): + """Test that a conversation ID is auto-generated when none is provided.""" + _, _, trace_observer, _ = self._create_observers(conversation_id=None) + processor = IdentityFilter() + + frames_to_send = [ + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + BotStoppedSpeakingFrame(), + SleepFrame(sleep=0.4), + ] + + expected_down_frames = [ + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=self._all_observers(trace_observer), + ) + + # End conversation to flush the conversation span + trace_observer.end_conversation_tracing() + + conv_spans = self._get_spans_by_name("conversation") + self.assertEqual(len(conv_spans), 1) + # Should have an auto-generated UUID as conversation.id + conv_id = conv_spans[0].attributes["conversation.id"] + self.assertIsNotNone(conv_id) + self.assertGreater(len(conv_id), 0) + + +if __name__ == "__main__": + unittest.main() From 0bf2477d2ca53bb551aa730bc55f21641834df8c Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 12 Feb 2026 09:41:18 -0500 Subject: [PATCH 18/93] Bump Pillow upper bound from <12 to <13 --- pyproject.toml | 2 +- uv.lock | 196 +++++++++++++++++++++++++------------------------ 2 files changed, 100 insertions(+), 98 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fd3db5031..35b95f7b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "Markdown>=3.7,<4", "nltk>=3.9.1,<4", "numpy>=1.26.4,<3", - "Pillow>=11.1.0,<12", + "Pillow>=11.1.0,<13", "protobuf~=5.29.6", "pydantic>=2.10.6,<3", "pyloudnorm~=0.1.1", diff --git a/uv.lock b/uv.lock index 703fd4692..8a5eadbe7 100644 --- a/uv.lock +++ b/uv.lock @@ -2110,6 +2110,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/65/5b235b40581ad75ab97dcd8b4218022ae8e3ab77c13c919f1a1dfe9171fd/greenlet-3.3.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:04bee4775f40ecefcdaa9d115ab44736cd4b9c5fba733575bfe9379419582e13", size = 273723, upload-time = "2026-01-23T15:30:37.521Z" }, { url = "https://files.pythonhosted.org/packages/ce/ad/eb4729b85cba2d29499e0a04ca6fbdd8f540afd7be142fd571eea43d712f/greenlet-3.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e1457f4fed12a50e427988a07f0f9df53cf0ee8da23fab16e6732c2ec909d4", size = 574874, upload-time = "2026-01-23T16:00:54.551Z" }, { url = "https://files.pythonhosted.org/packages/87/32/57cad7fe4c8b82fdaa098c89498ef85ad92dfbb09d5eb713adedfc2ae1f5/greenlet-3.3.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:070472cd156f0656f86f92e954591644e158fd65aa415ffbe2d44ca77656a8f5", size = 586309, upload-time = "2026-01-23T16:05:25.18Z" }, + { url = "https://files.pythonhosted.org/packages/66/66/f041005cb87055e62b0d68680e88ec1a57f4688523d5e2fb305841bc8307/greenlet-3.3.1-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1108b61b06b5224656121c3c8ee8876161c491cbe74e5c519e0634c837cf93d5", size = 597461, upload-time = "2026-01-23T16:15:51.943Z" }, { url = "https://files.pythonhosted.org/packages/87/eb/8a1ec2da4d55824f160594a75a9d8354a5fe0a300fb1c48e7944265217e1/greenlet-3.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a300354f27dd86bae5fbf7002e6dd2b3255cd372e9242c933faf5e859b703fe", size = 586985, upload-time = "2026-01-23T15:32:47.968Z" }, { url = "https://files.pythonhosted.org/packages/15/1c/0621dd4321dd8c351372ee8f9308136acb628600658a49be1b7504208738/greenlet-3.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e84b51cbebf9ae573b5fbd15df88887815e3253fc000a7d0ff95170e8f7e9729", size = 1547271, upload-time = "2026-01-23T16:04:18.977Z" }, { url = "https://files.pythonhosted.org/packages/9d/53/24047f8924c83bea7a59c8678d9571209c6bfe5f4c17c94a78c06024e9f2/greenlet-3.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0093bd1a06d899892427217f0ff2a3c8f306182b8c754336d32e2d587c131b4", size = 1613427, upload-time = "2026-01-23T15:33:44.428Z" }, @@ -2117,6 +2118,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" }, { url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-01-23T16:00:56.213Z" }, { url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26.365Z" }, + { url = "https://files.pythonhosted.org/packages/70/ae/e2d5f0e59b94a2269b68a629173263fa40b63da32f5c231307c349315871/greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f", size = 601161, upload-time = "2026-01-23T16:15:53.456Z" }, { url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-01-23T15:32:49.411Z" }, { url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20.867Z" }, { url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45.743Z" }, @@ -2125,6 +2127,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" }, { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" }, { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" }, { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" }, { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" }, { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" }, @@ -2133,6 +2136,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, + { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, @@ -2141,6 +2145,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" }, { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, @@ -2149,6 +2154,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, + { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" }, { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, @@ -4255,104 +4261,100 @@ wheels = [ [[package]] name = "pillow" -version = "11.3.0" +version = "12.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/5d/45a3553a253ac8763f3561371432a90bdbe6000fbdcf1397ffe502aa206c/pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860", size = 5316554, upload-time = "2025-07-01T09:13:39.342Z" }, - { url = "https://files.pythonhosted.org/packages/7c/c8/67c12ab069ef586a25a4a79ced553586748fad100c77c0ce59bb4983ac98/pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad", size = 4686548, upload-time = "2025-07-01T09:13:41.835Z" }, - { url = "https://files.pythonhosted.org/packages/2f/bd/6741ebd56263390b382ae4c5de02979af7f8bd9807346d068700dd6d5cf9/pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0", size = 5859742, upload-time = "2025-07-03T13:09:47.439Z" }, - { url = "https://files.pythonhosted.org/packages/ca/0b/c412a9e27e1e6a829e6ab6c2dca52dd563efbedf4c9c6aa453d9a9b77359/pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b", size = 7633087, upload-time = "2025-07-03T13:09:51.796Z" }, - { url = "https://files.pythonhosted.org/packages/59/9d/9b7076aaf30f5dd17e5e5589b2d2f5a5d7e30ff67a171eb686e4eecc2adf/pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50", size = 5963350, upload-time = "2025-07-01T09:13:43.865Z" }, - { url = "https://files.pythonhosted.org/packages/f0/16/1a6bf01fb622fb9cf5c91683823f073f053005c849b1f52ed613afcf8dae/pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae", size = 6631840, upload-time = "2025-07-01T09:13:46.161Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e6/6ff7077077eb47fde78739e7d570bdcd7c10495666b6afcd23ab56b19a43/pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9", size = 6074005, upload-time = "2025-07-01T09:13:47.829Z" }, - { url = "https://files.pythonhosted.org/packages/c3/3a/b13f36832ea6d279a697231658199e0a03cd87ef12048016bdcc84131601/pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e", size = 6708372, upload-time = "2025-07-01T09:13:52.145Z" }, - { url = "https://files.pythonhosted.org/packages/6c/e4/61b2e1a7528740efbc70b3d581f33937e38e98ef3d50b05007267a55bcb2/pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6", size = 6277090, upload-time = "2025-07-01T09:13:53.915Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d3/60c781c83a785d6afbd6a326ed4d759d141de43aa7365725cbcd65ce5e54/pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f", size = 6985988, upload-time = "2025-07-01T09:13:55.699Z" }, - { url = "https://files.pythonhosted.org/packages/9f/28/4f4a0203165eefb3763939c6789ba31013a2e90adffb456610f30f613850/pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f", size = 2422899, upload-time = "2025-07-01T09:13:57.497Z" }, - { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, - { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, - { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, - { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" }, - { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" }, - { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" }, - { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" }, - { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" }, - { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" }, - { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" }, - { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" }, - { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, - { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, - { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, - { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, - { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, - { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, - { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, - { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, - { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, - { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, - { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, - { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, - { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, - { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, - { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, - { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, - { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, - { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, - { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, - { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, - { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, - { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, - { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, - { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, - { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, - { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, - { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, - { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, - { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, - { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, - { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, - { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, - { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, - { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, - { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, - { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, - { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, - { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, - { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, - { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, - { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, - { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, - { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, - { url = "https://files.pythonhosted.org/packages/6f/8b/209bd6b62ce8367f47e68a218bffac88888fdf2c9fcf1ecadc6c3ec1ebc7/pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967", size = 5270556, upload-time = "2025-07-01T09:16:09.961Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e6/231a0b76070c2cfd9e260a7a5b504fb72da0a95279410fa7afd99d9751d6/pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe", size = 4654625, upload-time = "2025-07-01T09:16:11.913Z" }, - { url = "https://files.pythonhosted.org/packages/13/f4/10cf94fda33cb12765f2397fc285fa6d8eb9c29de7f3185165b702fc7386/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c", size = 4874207, upload-time = "2025-07-03T13:11:10.201Z" }, - { url = "https://files.pythonhosted.org/packages/72/c9/583821097dc691880c92892e8e2d41fe0a5a3d6021f4963371d2f6d57250/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25", size = 6583939, upload-time = "2025-07-03T13:11:15.68Z" }, - { url = "https://files.pythonhosted.org/packages/3b/8e/5c9d410f9217b12320efc7c413e72693f48468979a013ad17fd690397b9a/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27", size = 4957166, upload-time = "2025-07-01T09:16:13.74Z" }, - { url = "https://files.pythonhosted.org/packages/62/bb/78347dbe13219991877ffb3a91bf09da8317fbfcd4b5f9140aeae020ad71/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a", size = 5581482, upload-time = "2025-07-01T09:16:16.107Z" }, - { url = "https://files.pythonhosted.org/packages/d9/28/1000353d5e61498aaeaaf7f1e4b49ddb05f2c6575f9d4f9f914a3538b6e1/pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f", size = 6984596, upload-time = "2025-07-01T09:16:18.07Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, - { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" }, - { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" }, - { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" }, - { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, + { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, + { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, ] [[package]] @@ -4707,7 +4709,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation", marker = "extra == 'tracing'", specifier = ">=0.54b0" }, { name = "opentelemetry-sdk", marker = "extra == 'tracing'", specifier = ">=1.33.0" }, { name = "ormsgpack", marker = "extra == 'fish'", specifier = "~=1.7.0" }, - { name = "pillow", specifier = ">=11.1.0,<12" }, + { name = "pillow", specifier = ">=11.1.0,<13" }, { name = "pipecat-ai", extras = ["local-smart-turn-v3"] }, { name = "pipecat-ai", extras = ["nvidia"], marker = "extra == 'riva'" }, { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'assemblyai'" }, From 4667a3d66d5774c823bf87ec3664c4fde0682e33 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 12 Feb 2026 09:42:23 -0500 Subject: [PATCH 19/93] Add changelog for #3728 --- changelog/3728.changed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3728.changed.md diff --git a/changelog/3728.changed.md b/changelog/3728.changed.md new file mode 100644 index 000000000..bc5ccc74d --- /dev/null +++ b/changelog/3728.changed.md @@ -0,0 +1 @@ +- Bumped Pillow dependency upper bound from `<12` to `<13` to allow Pillow 12.x. From 6d95a2425ca30f775d1da2f15a7546cfff0b30a6 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Thu, 12 Feb 2026 12:54:47 -0300 Subject: [PATCH 20/93] Fixing ElevenLabs TTS word timestamp interleaving across sentences. --- src/pipecat/services/elevenlabs/tts.py | 56 ++++++++++++++++---------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/src/pipecat/services/elevenlabs/tts.py b/src/pipecat/services/elevenlabs/tts.py index 4dab0c01a..7df891f0e 100644 --- a/src/pipecat/services/elevenlabs/tts.py +++ b/src/pipecat/services/elevenlabs/tts.py @@ -13,6 +13,7 @@ with support for streaming audio, word timestamps, and voice customization. import asyncio import base64 import json +import uuid from typing import Any, AsyncGenerator, Dict, List, Literal, Mapping, Optional, Tuple, Union import aiohttp @@ -680,6 +681,20 @@ class ElevenLabsTTSService(AudioContextWordTTSService): msg = {"text": text, "context_id": self._context_id} await self._websocket.send(json.dumps(msg)) + def create_context_id(self) -> str: + """Generate a unique context ID for a TTS request in case we don't have one already in progress. + + Returns: + A unique string identifier for the TTS context. + """ + # If a context ID does not exist, create a new one. + # If an ID exists, continue using the current ID. + # When interruptions happens, user speech results in + # an interruption, which resets the context ID. + if not self._context_id: + return str(uuid.uuid4()) + return self._context_id + @traced_tts async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using ElevenLabs' streaming WebSocket API. @@ -698,31 +713,28 @@ class ElevenLabsTTSService(AudioContextWordTTSService): await self._connect() try: - await self.start_ttfb_metrics() - yield TTSStartedFrame(context_id=context_id) - self._cumulative_time = 0 - self._partial_word = "" - self._partial_word_start_time = 0.0 - # If a context ID does not exist, use the provided one. - # If an ID exists, that means the Pipeline doesn't allow - # user interruptions, so continue using the current ID. - # When interruptions are allowed, user speech results in - # an interruption, which resets the context ID. if not self._context_id: + await self.start_ttfb_metrics() + yield TTSStartedFrame(context_id=context_id) self._context_id = context_id - if not self.audio_context_available(self._context_id): - await self.create_audio_context(self._context_id) + self._cumulative_time = 0 + self._partial_word = "" + self._partial_word_start_time = 0.0 - # Initialize context with voice settings and pronunciation dictionaries - msg = {"text": " ", "context_id": self._context_id} - if self._voice_settings: - msg["voice_settings"] = self._voice_settings - if self._pronunciation_dictionary_locators: - msg["pronunciation_dictionary_locators"] = [ - locator.model_dump() for locator in self._pronunciation_dictionary_locators - ] - await self._websocket.send(json.dumps(msg)) - logger.trace(f"Created new context {self._context_id}") + if not self.audio_context_available(self._context_id): + await self.create_audio_context(self._context_id) + + # Initialize context with voice settings and pronunciation dictionaries + msg = {"text": " ", "context_id": self._context_id} + if self._voice_settings: + msg["voice_settings"] = self._voice_settings + if self._pronunciation_dictionary_locators: + msg["pronunciation_dictionary_locators"] = [ + locator.model_dump() + for locator in self._pronunciation_dictionary_locators + ] + await self._websocket.send(json.dumps(msg)) + logger.trace(f"Created new context {self._context_id}") await self._send_text(text) await self.start_tts_usage_metrics(text) From 2e15b4842cf6f36338b867c79a861622d1dbee52 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 12 Feb 2026 11:09:39 -0500 Subject: [PATCH 21/93] Move STT keepalive mechanism from WebsocketSTTService to STTService base class This allows non-websocket STT services (like SarvamSTTService, which uses the Sarvam Python SDK for connection management) to reuse the same keepalive pattern. Subclasses override _send_keepalive() and _is_keepalive_ready() for their specific protocol. --- src/pipecat/services/sarvam/stt.py | 43 +++++++- src/pipecat/services/stt_service.py | 153 ++++++++++++++++------------ 2 files changed, 132 insertions(+), 64 deletions(-) diff --git a/src/pipecat/services/sarvam/stt.py b/src/pipecat/services/sarvam/stt.py index 998597956..aae1ae243 100644 --- a/src/pipecat/services/sarvam/stt.py +++ b/src/pipecat/services/sarvam/stt.py @@ -167,6 +167,8 @@ class SarvamSTTService(STTService): input_audio_codec: str = "wav", params: Optional[InputParams] = None, ttfs_p99_latency: Optional[float] = SARVAM_TTFS_P99, + keepalive_timeout: Optional[float] = None, + keepalive_interval: float = 5.0, **kwargs, ): """Initialize the Sarvam STT service. @@ -182,6 +184,9 @@ class SarvamSTTService(STTService): params: Configuration parameters for Sarvam STT service. ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark + keepalive_timeout: Seconds of no audio before sending silence to keep the + connection alive. None disables keepalive. + keepalive_interval: Seconds between idle checks when keepalive is enabled. **kwargs: Additional arguments passed to the parent STTService. """ params = params or SarvamSTTService.InputParams() @@ -203,7 +208,13 @@ class SarvamSTTService(STTService): f"Model '{model}' does not support language parameter (auto-detects language)." ) - super().__init__(sample_rate=sample_rate, ttfs_p99_latency=ttfs_p99_latency, **kwargs) + super().__init__( + sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + keepalive_timeout=keepalive_timeout, + keepalive_interval=keepalive_interval, + **kwargs, + ) self.set_model_name(model) self._api_key = api_key @@ -463,6 +474,8 @@ class SarvamSTTService(STTService): # Start receive task using Pipecat's task management self._receive_task = self.create_task(self._receive_task_handler()) + self._create_keepalive_task() + logger.info("Connected to Sarvam successfully") except ApiError as e: @@ -476,6 +489,8 @@ class SarvamSTTService(STTService): async def _disconnect(self): """Disconnect from Sarvam WebSocket API using SDK.""" + await self._cancel_keepalive_task() + if self._receive_task: await self.cancel_task(self._receive_task) self._receive_task = None @@ -600,6 +615,32 @@ class SarvamSTTService(STTService): } return mapping.get(language_code, Language.HI_IN) + def _is_keepalive_ready(self) -> bool: + """Check if the Sarvam SDK websocket client is connected.""" + return self._socket_client is not None + + async def _send_keepalive(self, silence: bytes): + """Send silent audio via the Sarvam SDK to keep the connection alive. + + Args: + silence: Silent 16-bit mono PCM audio bytes. + """ + audio_base64 = base64.b64encode(silence).decode("utf-8") + encoding = ( + self._input_audio_codec + if self._input_audio_codec.startswith("audio/") + else f"audio/{self._input_audio_codec}" + ) + method_kwargs = { + "audio": audio_base64, + "encoding": encoding, + "sample_rate": self.sample_rate, + } + if self._config.use_translate_method: + await self._socket_client.translate(**method_kwargs) + else: + await self._socket_client.transcribe(**method_kwargs) + async def _start_metrics(self): """Start processing metrics collection.""" await self.start_processing_metrics() diff --git a/src/pipecat/services/stt_service.py b/src/pipecat/services/stt_service.py index b556bb23a..50af598b1 100644 --- a/src/pipecat/services/stt_service.py +++ b/src/pipecat/services/stt_service.py @@ -49,6 +49,12 @@ class STTService(AIService): muting, settings management, and audio processing. Subclasses must implement the run_stt method to provide actual speech recognition. + Includes an optional keepalive mechanism that sends silent audio when no real + audio has been sent for a configurable timeout, preventing servers from closing + idle connections (e.g. when behind a ServiceSwitcher). Subclasses that enable + keepalive must override ``_send_keepalive()`` to deliver the silence in the + appropriate service-specific protocol. + Event handlers: on_connected: Called when connected to the STT service. on_disconnected: Called when disconnected from the STT service. @@ -76,6 +82,8 @@ class STTService(AIService): sample_rate: Optional[int] = None, stt_ttfb_timeout: float = 2.0, ttfs_p99_latency: Optional[float] = None, + keepalive_timeout: Optional[float] = None, + keepalive_interval: float = 5.0, **kwargs, ): """Initialize the STT service. @@ -95,6 +103,10 @@ class STTService(AIService): This is broadcast via STTMetadataFrame at pipeline start for downstream processors (e.g., turn strategies) to optimize timing. Subclasses provide measured defaults; pass a value here to override for your deployment. + keepalive_timeout: Seconds of no audio before sending silence to keep the + connection alive. None disables keepalive. Useful for services that + close idle connections (e.g. behind a ServiceSwitcher). + keepalive_interval: Seconds between idle checks when keepalive is enabled. **kwargs: Additional arguments passed to the parent AIService. """ super().__init__(**kwargs) @@ -116,6 +128,12 @@ class STTService(AIService): self._finalize_pending: bool = False self._finalize_requested: bool = False + # Keepalive state + self._keepalive_timeout = keepalive_timeout + self._keepalive_interval = keepalive_interval + self._keepalive_task: Optional[asyncio.Task] = None + self._last_audio_time: float = 0 + self._register_event_handler("on_connected") self._register_event_handler("on_disconnected") self._register_event_handler("on_connection_error") @@ -208,6 +226,7 @@ class STTService(AIService): """Clean up STT service resources.""" await super().cleanup() await self._cancel_ttfb_timeout() + await self._cancel_keepalive_task() async def _update_settings(self, settings: Mapping[str, Any]): logger.info(f"Updating STT settings: {self._settings}") @@ -239,6 +258,8 @@ class STTService(AIService): if self._muted: return + self._last_audio_time = time.monotonic() + # UserAudioRawFrame contains a user_id (e.g. Daily, Livekit) if hasattr(frame, "user_id"): self._user_id = frame.user_id @@ -436,6 +457,66 @@ class STTService(AIService): ) await super().push_frame(MetricsFrame(data=[ttfb_data])) + def _create_keepalive_task(self): + """Start the keepalive task if keepalive is enabled.""" + if self._keepalive_timeout is not None: + self._last_audio_time = time.monotonic() + self._keepalive_task = self.create_task( + self._keepalive_task_handler(), name="keepalive" + ) + + async def _cancel_keepalive_task(self): + """Stop the keepalive task if running.""" + if self._keepalive_task: + await self.cancel_task(self._keepalive_task) + self._keepalive_task = None + + async def _keepalive_task_handler(self): + """Send periodic silent audio to prevent the server from closing the connection. + + When keepalive is enabled, this task checks periodically if the connection + has been idle (no audio sent) for longer than keepalive_timeout seconds. + If so, it generates silent 16-bit mono PCM audio and passes it to + _send_keepalive() for service-specific formatting and sending. + """ + while True: + await asyncio.sleep(self._keepalive_interval) + try: + if not self._is_keepalive_ready(): + continue + elapsed = time.monotonic() - self._last_audio_time + if elapsed < self._keepalive_timeout: + continue + num_samples = int(self.sample_rate * _KEEPALIVE_SILENCE_DURATION) + silence = b"\x00" * (num_samples * 2) + await self._send_keepalive(silence) + self._last_audio_time = time.monotonic() + logger.trace(f"{self} sent keepalive silence") + except Exception as e: + logger.warning(f"{self} keepalive error: {e}") + break + + def _is_keepalive_ready(self) -> bool: + """Check if the service is ready to send keepalive. + + Subclasses should override this to check their connection state. + + Returns: + True if keepalive can be sent. + """ + return True + + async def _send_keepalive(self, silence: bytes): + """Send silent audio to keep the connection alive. + + Subclasses that enable keepalive must override this to deliver silence + in their service-specific protocol. + + Args: + silence: Silent 16-bit mono PCM audio bytes. + """ + raise NotImplementedError("Subclasses must override _send_keepalive") + class SegmentedSTTService(STTService): """STT service that processes speech in segments using VAD events. @@ -549,46 +630,27 @@ class WebsocketSTTService(STTService, WebsocketService): Combines STT functionality with websocket connectivity, providing automatic error handling, reconnection capabilities, and optional silence-based keepalive. - The keepalive feature sends silent audio when no real audio has been sent for - a configurable timeout, preventing servers from closing idle connections (e.g. - when behind a ServiceSwitcher). Subclasses can override ``_send_keepalive()`` - to wrap the silence in a service-specific protocol. + The keepalive feature (inherited from STTService) sends silent audio when no + real audio has been sent for a configurable timeout, preventing servers from + closing idle connections (e.g. when behind a ServiceSwitcher). Subclasses can + override ``_send_keepalive()`` to wrap the silence in a service-specific protocol. """ def __init__( self, *, reconnect_on_error: bool = True, - keepalive_timeout: Optional[float] = None, - keepalive_interval: float = 5.0, **kwargs, ): """Initialize the Websocket STT service. Args: reconnect_on_error: Whether to automatically reconnect on websocket errors. - keepalive_timeout: Seconds of no audio before sending silence to keep the - connection alive. None disables keepalive. Useful for services that - close idle connections (e.g. behind a ServiceSwitcher). - keepalive_interval: Seconds between idle checks when keepalive is enabled. - **kwargs: Additional arguments passed to parent classes. + **kwargs: Additional arguments passed to parent classes (including + keepalive_timeout and keepalive_interval for STTService). """ STTService.__init__(self, **kwargs) WebsocketService.__init__(self, reconnect_on_error=reconnect_on_error, **kwargs) - self._keepalive_timeout = keepalive_timeout - self._keepalive_interval = keepalive_interval - self._keepalive_task: Optional[asyncio.Task] = None - self._last_audio_time: float = 0 - - async def process_audio_frame(self, frame: AudioRawFrame, direction: FrameDirection): - """Process an audio frame, tracking the last audio time for keepalive. - - Args: - frame: The audio frame to process. - direction: The direction of frame processing. - """ - self._last_audio_time = time.monotonic() - await super().process_audio_frame(frame, direction) async def _connect(self): """Connect and start keepalive task if enabled.""" @@ -612,44 +674,9 @@ class WebsocketSTTService(STTService, WebsocketService): self._create_keepalive_task() return result - def _create_keepalive_task(self): - """Start the keepalive task if keepalive is enabled.""" - if self._keepalive_timeout is not None: - self._last_audio_time = time.monotonic() - self._keepalive_task = self.create_task( - self._keepalive_task_handler(), name="keepalive" - ) - - async def _cancel_keepalive_task(self): - """Stop the keepalive task if running.""" - if self._keepalive_task: - await self.cancel_task(self._keepalive_task) - self._keepalive_task = None - - async def _keepalive_task_handler(self): - """Send periodic silent audio to prevent the server from closing the connection. - - When keepalive is enabled, this task checks periodically if the connection - has been idle (no audio sent) for longer than keepalive_timeout seconds. - If so, it generates silent 16-bit mono PCM audio and passes it to - _send_keepalive() for service-specific formatting and sending. - """ - while True: - await asyncio.sleep(self._keepalive_interval) - try: - if not self._websocket or self._websocket.state is not State.OPEN: - continue - elapsed = time.monotonic() - self._last_audio_time - if elapsed < self._keepalive_timeout: - continue - num_samples = int(self.sample_rate * _KEEPALIVE_SILENCE_DURATION) - silence = b"\x00" * (num_samples * 2) - await self._send_keepalive(silence) - self._last_audio_time = time.monotonic() - logger.trace(f"{self} sent keepalive silence") - except Exception as e: - logger.warning(f"{self} keepalive error: {e}") - break + def _is_keepalive_ready(self) -> bool: + """Check if the websocket is open and ready for keepalive.""" + return self._websocket is not None and self._websocket.state is State.OPEN async def _send_keepalive(self, silence: bytes): """Send silent audio over the websocket to keep the connection alive. From 08beb0264acf4b6b1553d294a9e98d0de20cb76a Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 12 Feb 2026 13:14:11 -0500 Subject: [PATCH 22/93] Add changelog entries for PR #3730 --- changelog/3730.added.md | 1 + changelog/3730.changed.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 changelog/3730.added.md create mode 100644 changelog/3730.changed.md diff --git a/changelog/3730.added.md b/changelog/3730.added.md new file mode 100644 index 000000000..e3ac64278 --- /dev/null +++ b/changelog/3730.added.md @@ -0,0 +1 @@ +- Added keepalive support to `SarvamSTTService` to prevent idle connection timeouts (e.g. when used behind a `ServiceSwitcher`). diff --git a/changelog/3730.changed.md b/changelog/3730.changed.md new file mode 100644 index 000000000..697bc863c --- /dev/null +++ b/changelog/3730.changed.md @@ -0,0 +1 @@ +- Moved STT keepalive mechanism from `WebsocketSTTService` to the `STTService` base class, allowing any STT service (not just websocket-based ones) to use idle-connection keepalive via the `keepalive_timeout` and `keepalive_interval` parameters. From abea22ec574a379f90f6858c10580243f90cf2f1 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Thu, 12 Feb 2026 15:17:47 -0300 Subject: [PATCH 23/93] Fixing AsyncAITTSService to reuse the same context when needed. --- src/pipecat/services/asyncai/tts.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/pipecat/services/asyncai/tts.py b/src/pipecat/services/asyncai/tts.py index 4ff6c928d..e9170f8b6 100644 --- a/src/pipecat/services/asyncai/tts.py +++ b/src/pipecat/services/asyncai/tts.py @@ -9,6 +9,7 @@ import asyncio import base64 import json +import uuid from typing import AsyncGenerator, Optional import aiohttp @@ -270,6 +271,20 @@ class AsyncAITTSService(AudioContextTTSService): return self._websocket raise Exception("Websocket not connected") + def create_context_id(self) -> str: + """Generate a unique context ID for a TTS request in case we don't have one already in progress. + + Returns: + A unique string identifier for the TTS context. + """ + # If a context ID does not exist, create a new one. + # If an ID exists, continue using the current ID. + # When interruptions happen, user speech results in + # an interruption, which resets the context ID. + if not self._context_id: + return str(uuid.uuid4()) + return self._context_id + async def flush_audio(self): """Flush any pending audio.""" if not self._context_id or not self._websocket: @@ -379,13 +394,14 @@ class AsyncAITTSService(AudioContextTTSService): await self._connect() try: - await self.start_ttfb_metrics() - yield TTSStartedFrame(context_id=context_id) - if not self._context_id: + await self.start_ttfb_metrics() + yield TTSStartedFrame(context_id=context_id) + self._context_id = context_id - if not self.audio_context_available(self._context_id): - await self.create_audio_context(self._context_id) + + if not self.audio_context_available(self._context_id): + await self.create_audio_context(self._context_id) msg = self._build_msg(text=text, force=True, context_id=self._context_id) await self._get_websocket().send(msg) From 794811fbdbce6f5efc2a8d376198f7a2e4063966 Mon Sep 17 00:00:00 2001 From: Chad Bailey Date: Wed, 4 Feb 2026 22:21:10 +0000 Subject: [PATCH 24/93] Updated WSS endpoint for Rime Arcana v3 support --- src/pipecat/services/rime/tts.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/pipecat/services/rime/tts.py b/src/pipecat/services/rime/tts.py index e38e840e6..a1b9f8c58 100644 --- a/src/pipecat/services/rime/tts.py +++ b/src/pipecat/services/rime/tts.py @@ -98,8 +98,8 @@ class RimeTTSService(AudioContextWordTTSService): *, api_key: str, voice_id: str, - url: str = "wss://users.rime.ai/ws2", - model: str = "mistv2", + url: str = "wss://users-ws.rime.ai/ws3", + model: str = "arcana", sample_rate: Optional[int] = None, params: Optional[InputParams] = None, text_aggregator: Optional[BaseTextAggregator] = None, @@ -383,7 +383,7 @@ class RimeTTSService(AudioContextWordTTSService): async for message in self._get_websocket(): msg = json.loads(message) - if not msg or not self.audio_context_available(msg["contextId"]): + if not msg or not self.audio_context_available(msg.get("contextId")): continue context_id = msg["contextId"] @@ -626,20 +626,18 @@ class RimeHttpTTSService(TTSService): class RimeNonJsonTTSService(InterruptibleTTSService): """Pipecat TTS service for Rime's non-JSON WebSocket API. + .. deprecated:: 0.0.102 + Arcana now supports JSON WebSocket with word-level timestamps via the + ``wss://users-ws.rime.ai/ws3`` endpoint. Use :class:`RimeTTSService` + with ``model="arcana"`` instead. + This service enables Text-to-Speech synthesis over WebSocket endpoints that require plain text (not JSON) messages and return raw audio bytes. - It is designed for use with TTS models like Arcana, which currently do - not support JSON-based WebSocket protocols (though this may change in - the future). Limitations: - Does not support word-level timestamps or context IDs. - Intended specifically for integrations where the TTS provider only accepts and returns non-JSON messages. - - Note: - - Arcana and similar models may add JSON WebSocket support in the - future. This service focuses on the current plain text protocol. """ class InputParams(BaseModel): From 3410eb82b37803e84576112e7ab9d88a48cdf91b Mon Sep 17 00:00:00 2001 From: filipi87 Date: Thu, 12 Feb 2026 15:26:49 -0300 Subject: [PATCH 25/93] Fixing CartesiaTTSService to reuse the same context when needed. --- src/pipecat/services/cartesia/tts.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/pipecat/services/cartesia/tts.py b/src/pipecat/services/cartesia/tts.py index 791c60a18..1fa9a026a 100644 --- a/src/pipecat/services/cartesia/tts.py +++ b/src/pipecat/services/cartesia/tts.py @@ -8,6 +8,7 @@ import base64 import json +import uuid import warnings from enum import Enum from typing import AsyncGenerator, List, Literal, Optional @@ -539,6 +540,20 @@ class CartesiaTTSService(AudioContextWordTTSService): await self._get_websocket().send(cancel_msg) self._context_id = None + def create_context_id(self) -> str: + """Generate a unique context ID for a TTS request in case we don't have one already in progress. + + Returns: + A unique string identifier for the TTS context. + """ + # If a context ID does not exist, create a new one. + # If an ID exists, continue using the current ID. + # When interruptions happen, user speech results in + # an interruption, which resets the context ID. + if not self._context_id: + return str(uuid.uuid4()) + return self._context_id + async def flush_audio(self): """Flush any pending audio and finalize the current context.""" if not self._context_id or not self._websocket: From 136732afae720857f80a849f8b6221836d2481ac Mon Sep 17 00:00:00 2001 From: filipi87 Date: Thu, 12 Feb 2026 15:46:59 -0300 Subject: [PATCH 26/93] Fixing InworldTTSService to reuse the same context when needed. --- src/pipecat/services/inworld/tts.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/pipecat/services/inworld/tts.py b/src/pipecat/services/inworld/tts.py index 7ba7d6b9d..845a37bdd 100644 --- a/src/pipecat/services/inworld/tts.py +++ b/src/pipecat/services/inworld/tts.py @@ -924,6 +924,20 @@ class InworldTTSService(AudioContextWordTTSService): msg = {"close_context": {}, "contextId": context_id} await self.send_with_retry(json.dumps(msg), self._report_error) + def create_context_id(self) -> str: + """Generate a unique context ID for a TTS request in case we don't have one already in progress. + + Returns: + A unique string identifier for the TTS context. + """ + # If a context ID does not exist, create a new one. + # If an ID exists, continue using the current ID. + # When interruptions happen, user speech results in + # an interruption, which resets the context ID. + if not self._context_id: + return str(uuid.uuid4()) + return self._context_id + @traced_tts async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate TTS audio for the given text using the Inworld WebSocket TTS service. @@ -942,10 +956,9 @@ class InworldTTSService(AudioContextWordTTSService): await self._connect() try: - await self.start_ttfb_metrics() - yield TTSStartedFrame(context_id=context_id) - if not self._context_id: + await self.start_ttfb_metrics() + yield TTSStartedFrame(context_id=context_id) self._context_id = context_id logger.trace(f"{self}: Creating new context {self._context_id}") await self.create_audio_context(self._context_id) From f0995164d9ae8550422dde7afc950ff8c92ab6e8 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Thu, 12 Feb 2026 15:50:18 -0300 Subject: [PATCH 27/93] Fixing PlayHTTTSService to reuse the same context when needed. --- src/pipecat/services/playht/tts.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/pipecat/services/playht/tts.py b/src/pipecat/services/playht/tts.py index 2d4cd0427..287463186 100644 --- a/src/pipecat/services/playht/tts.py +++ b/src/pipecat/services/playht/tts.py @@ -13,6 +13,7 @@ supporting both WebSocket streaming and HTTP-based synthesis. import io import json import struct +import uuid import warnings from typing import AsyncGenerator, Optional @@ -323,6 +324,20 @@ class PlayHTTTSService(InterruptibleTTSService): return self._websocket raise Exception("Websocket not connected") + def create_context_id(self) -> str: + """Generate a unique context ID for a TTS request in case we don't have one already in progress. + + Returns: + A unique string identifier for the TTS context. + """ + # If a context ID does not exist, create a new one. + # If an ID exists, continue using the current ID. + # When interruptions happen, user speech results in + # an interruption, which resets the context ID. + if not self._context_id: + return str(uuid.uuid4()) + return self._context_id + async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): """Handle interruption by stopping metrics and clearing request ID.""" await super()._handle_interruption(frame, direction) From 8866ab1585e257d8f880b71bd46b0ad4091e98ab Mon Sep 17 00:00:00 2001 From: filipi87 Date: Thu, 12 Feb 2026 15:53:38 -0300 Subject: [PATCH 28/93] Fixing RimeTTSService to reuse the same context when needed. --- src/pipecat/services/rime/tts.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/pipecat/services/rime/tts.py b/src/pipecat/services/rime/tts.py index e38e840e6..22f1cf4e1 100644 --- a/src/pipecat/services/rime/tts.py +++ b/src/pipecat/services/rime/tts.py @@ -12,6 +12,7 @@ using Rime's API for streaming and batch audio synthesis. import base64 import json +import uuid from typing import Any, AsyncGenerator, Mapping, Optional import aiohttp @@ -369,6 +370,20 @@ class RimeTTSService(AudioContextWordTTSService): return word_pairs + def create_context_id(self) -> str: + """Generate a unique context ID for a TTS request in case we don't have one already in progress. + + Returns: + A unique string identifier for the TTS context. + """ + # If a context ID does not exist, create a new one. + # If an ID exists, continue using the current ID. + # When interruptions happen, user speech results in + # an interruption, which resets the context ID. + if not self._context_id: + return str(uuid.uuid4()) + return self._context_id + async def flush_audio(self): """Flush any pending audio synthesis.""" if not self._context_id or not self._websocket: From 2b9777b812e8cdb77d13760aebc32726f75db2f2 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 12 Feb 2026 14:01:41 -0500 Subject: [PATCH 29/93] Update RimeTTSService InputParams for arcana and mistv2 model support Add model-specific params (arcana: repetition_penalty, temperature, top_p; mistv2: no_text_normalization, save_oovs, segment) with dynamic query param building via _build_settings(). Model/voice/param changes now trigger WebSocket reconnection since all settings are URL query params. Co-Authored-By: Claude Opus 4.6 --- .../foundational/07q-interruptible-rime.py | 9 +- src/pipecat/services/rime/tts.py | 162 ++++++++++++++---- 2 files changed, 141 insertions(+), 30 deletions(-) diff --git a/examples/foundational/07q-interruptible-rime.py b/examples/foundational/07q-interruptible-rime.py index 1042d05ee..d381c33dc 100644 --- a/examples/foundational/07q-interruptible-rime.py +++ b/examples/foundational/07q-interruptible-rime.py @@ -25,6 +25,7 @@ from pipecat.runner.utils import create_transport from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.rime.tts import RimeTTSService +from pipecat.transcriptions.language import Language from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams @@ -56,7 +57,13 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = RimeTTSService( api_key=os.getenv("RIME_API_KEY", ""), - voice_id="rex", + voice_id="luna", + params=RimeTTSService.InputParams( + language=Language.EN, + repetition_penalty=1.0, + temperature=0.5, + top_p=0.9, + ), ) llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) diff --git a/src/pipecat/services/rime/tts.py b/src/pipecat/services/rime/tts.py index a1b9f8c58..d8574c75f 100644 --- a/src/pipecat/services/rime/tts.py +++ b/src/pipecat/services/rime/tts.py @@ -81,17 +81,31 @@ class RimeTTSService(AudioContextWordTTSService): Parameters: language: Language for synthesis. Defaults to English. - speed_alpha: Speech speed multiplier. Defaults to 1.0. - reduce_latency: Whether to reduce latency at potential quality cost. - pause_between_brackets: Whether to add pauses between bracketed content. - phonemize_between_brackets: Whether to phonemize bracketed content. + segment: Text segmentation mode ("immediate", "bySentence", "never"). + repetition_penalty: Token repetition penalty (arcana only). + temperature: Sampling temperature (arcana only). + top_p: Cumulative probability threshold (arcana only). + speed_alpha: Speech speed multiplier (mistv2 only). + reduce_latency: Whether to reduce latency at potential quality cost (mistv2 only). + pause_between_brackets: Whether to add pauses between bracketed content (mistv2 only). + phonemize_between_brackets: Whether to phonemize bracketed content (mistv2 only). + no_text_normalization: Whether to disable text normalization (mistv2 only). + save_oovs: Whether to save out-of-vocabulary words (mistv2 only). """ language: Optional[Language] = Language.EN - speed_alpha: Optional[float] = 1.0 - reduce_latency: Optional[bool] = False - pause_between_brackets: Optional[bool] = False - phonemize_between_brackets: Optional[bool] = False + segment: Optional[str] = None + # Arcana params + repetition_penalty: Optional[float] = None + temperature: Optional[float] = None + top_p: Optional[float] = None + # Mistv2 params + speed_alpha: Optional[float] = None + reduce_latency: Optional[bool] = None + pause_between_brackets: Optional[bool] = None + phonemize_between_brackets: Optional[bool] = None + no_text_normalization: Optional[bool] = None + save_oovs: Optional[bool] = None def __init__( self, @@ -142,26 +156,14 @@ class RimeTTSService(AudioContextWordTTSService): # and insert these tags for the purpose of the TTS service alone. self._text_aggregator = SkipTagsAggregator([("spell(", ")")]) - params = params or RimeTTSService.InputParams() + self._params = params or RimeTTSService.InputParams() # Store service configuration self._api_key = api_key self._url = url self._voice_id = voice_id self._model = model - self._settings = { - "speaker": voice_id, - "modelId": model, - "audioFormat": "pcm", - "samplingRate": 0, - "lang": self.language_to_service_language(params.language) - if params.language - else "eng", - "speedAlpha": params.speed_alpha, - "reduceLatency": params.reduce_latency, - "pauseBetweenBrackets": json.dumps(params.pause_between_brackets), - "phonemizeBetweenBrackets": json.dumps(params.phonemize_between_brackets), - } + self._settings = self._build_settings() # State tracking self._context_id = None # Tracks current turn @@ -188,14 +190,60 @@ class RimeTTSService(AudioContextWordTTSService): """ return language_to_rime_language(language) + def _build_settings(self) -> dict: + """Build query params for the WebSocket URL based on the current model and params. + + Returns: + Dictionary of query parameters. Only explicitly-set values are included. + """ + settings = { + "speaker": self._voice_id, + "modelId": self._model, + "audioFormat": "pcm", + "samplingRate": self.sample_rate or 0, + } + if self._params.language: + settings["lang"] = self.language_to_service_language(self._params.language) or "eng" + if self._params.segment is not None: + settings["segment"] = self._params.segment + + if self._model == "arcana": + if self._params.repetition_penalty is not None: + settings["repetition_penalty"] = self._params.repetition_penalty + if self._params.temperature is not None: + settings["temperature"] = self._params.temperature + if self._params.top_p is not None: + settings["top_p"] = self._params.top_p + else: # mistv2/mist + if self._params.speed_alpha is not None: + settings["speedAlpha"] = self._params.speed_alpha + if self._params.reduce_latency is not None: + settings["reduceLatency"] = self._params.reduce_latency + if self._params.pause_between_brackets is not None: + settings["pauseBetweenBrackets"] = json.dumps(self._params.pause_between_brackets) + if self._params.phonemize_between_brackets is not None: + settings["phonemizeBetweenBrackets"] = json.dumps( + self._params.phonemize_between_brackets + ) + if self._params.no_text_normalization is not None: + settings["noTextNormalization"] = json.dumps(self._params.no_text_normalization) + if self._params.save_oovs is not None: + settings["saveOovs"] = json.dumps(self._params.save_oovs) + + return settings + async def set_model(self, model: str): - """Update the TTS model. + """Update the TTS model and reconnect. Args: model: The model name to use for synthesis. """ self._model = model + self._settings = self._build_settings() await super().set_model(model) + if self._websocket: + await self._disconnect() + await self._connect() # A set of Rime-specific helpers for text transformations def SPELL(text: str) -> str: @@ -223,12 +271,68 @@ class RimeTTSService(AudioContextWordTTSService): return f"[{text}]" async def _update_settings(self, settings: Mapping[str, Any]): - """Update service settings and reconnect if voice changed.""" - prev_voice = self._voice_id + """Update service settings and reconnect if necessary. + + Since all settings are WebSocket URL query parameters, + any setting change requires reconnecting to apply the new values. + """ + prev_settings = self._settings.copy() await super()._update_settings(settings) - if not prev_voice == self._voice_id: + + needs_reconnect = False + + if "voice" in settings or "voice_id" in settings: self._settings["speaker"] = self._voice_id - logger.info(f"Switching TTS voice to: [{self._voice_id}]") + if prev_settings.get("speaker") != self._voice_id: + logger.info(f"Switching TTS voice to: [{self._voice_id}]") + needs_reconnect = True + + if "model" in settings: + self._settings = self._build_settings() + needs_reconnect = True + + if "language" in settings: + new_lang = self.language_to_service_language(settings["language"]) + if new_lang and new_lang != prev_settings.get("lang"): + logger.info(f"Updating language to: [{new_lang}]") + self._settings["lang"] = new_lang + needs_reconnect = True + + # Arcana params + for key, settings_key in [ + ("repetition_penalty", "repetition_penalty"), + ("temperature", "temperature"), + ("top_p", "top_p"), + ]: + if key in settings and settings[key] != prev_settings.get(settings_key): + self._settings[settings_key] = settings[key] + needs_reconnect = True + + # Mistv2 params + for key, settings_key in [ + ("speed_alpha", "speedAlpha"), + ("reduce_latency", "reduceLatency"), + ]: + if key in settings and settings[key] != prev_settings.get(settings_key): + self._settings[settings_key] = settings[key] + needs_reconnect = True + + # Mistv2 boolean params (need json.dumps) + for key, settings_key in [ + ("pause_between_brackets", "pauseBetweenBrackets"), + ("phonemize_between_brackets", "phonemizeBetweenBrackets"), + ("no_text_normalization", "noTextNormalization"), + ("save_oovs", "saveOovs"), + ]: + if key in settings and json.dumps(settings[key]) != prev_settings.get(settings_key): + self._settings[settings_key] = json.dumps(settings[key]) + needs_reconnect = True + + if "segment" in settings and settings["segment"] != prev_settings.get("segment"): + self._settings["segment"] = settings["segment"] + needs_reconnect = True + + if needs_reconnect and self._websocket: await self._disconnect() await self._connect() @@ -255,7 +359,7 @@ class RimeTTSService(AudioContextWordTTSService): frame: The start frame containing initialization parameters. """ await super().start(frame) - self._settings["samplingRate"] = self.sample_rate + self._settings = self._build_settings() await self._connect() async def stop(self, frame: EndFrame): @@ -301,7 +405,7 @@ class RimeTTSService(AudioContextWordTTSService): if self._websocket and self._websocket.state is State.OPEN: return - params = "&".join(f"{k}={v}" for k, v in self._settings.items()) + params = "&".join(f"{k}={v}" for k, v in self._settings.items() if v is not None) url = f"{self._url}?{params}" headers = {"Authorization": f"Bearer {self._api_key}"} self._websocket = await websocket_connect(url, additional_headers=headers) From 18afe37bd16f255067301d470743c802c1ea8c3c Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 12 Feb 2026 14:04:56 -0500 Subject: [PATCH 30/93] Add changelog entries for PR #3642 --- changelog/3642.added.md | 1 + changelog/3642.changed.md | 1 + examples/foundational/07q-interruptible-rime.py | 7 ------- 3 files changed, 2 insertions(+), 7 deletions(-) create mode 100644 changelog/3642.added.md create mode 100644 changelog/3642.changed.md diff --git a/changelog/3642.added.md b/changelog/3642.added.md new file mode 100644 index 000000000..47668bf59 --- /dev/null +++ b/changelog/3642.added.md @@ -0,0 +1 @@ +- Added model-specific `InputParams` to `RimeTTSService`: arcana params (`repetition_penalty`, `temperature`, `top_p`) and mistv2 params (`no_text_normalization`, `save_oovs`, `segment`). Model, voice, and param changes now trigger WebSocket reconnection. diff --git a/changelog/3642.changed.md b/changelog/3642.changed.md new file mode 100644 index 000000000..96a43fbb8 --- /dev/null +++ b/changelog/3642.changed.md @@ -0,0 +1 @@ +- ⚠️ `RimeTTSService` now defaults to `model="arcana"` and the `wss://users-ws.rime.ai/ws3` endpoint. `InputParams` defaults changed from mistv2-specific values to `None` — only explicitly-set params are sent as query params. diff --git a/examples/foundational/07q-interruptible-rime.py b/examples/foundational/07q-interruptible-rime.py index d381c33dc..27e010273 100644 --- a/examples/foundational/07q-interruptible-rime.py +++ b/examples/foundational/07q-interruptible-rime.py @@ -25,7 +25,6 @@ from pipecat.runner.utils import create_transport from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.rime.tts import RimeTTSService -from pipecat.transcriptions.language import Language from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams @@ -58,12 +57,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = RimeTTSService( api_key=os.getenv("RIME_API_KEY", ""), voice_id="luna", - params=RimeTTSService.InputParams( - language=Language.EN, - repetition_penalty=1.0, - temperature=0.5, - top_p=0.9, - ), ) llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) From 9569625f03ad013945e00d335adc5c9a2bd25e44 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Thu, 12 Feb 2026 16:11:02 -0300 Subject: [PATCH 31/93] Changelog entries for the TTS fixes. --- changelog/3729.fixed.2.md | 1 + changelog/3729.fixed.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 changelog/3729.fixed.2.md create mode 100644 changelog/3729.fixed.md diff --git a/changelog/3729.fixed.2.md b/changelog/3729.fixed.2.md new file mode 100644 index 000000000..6d4f33d93 --- /dev/null +++ b/changelog/3729.fixed.2.md @@ -0,0 +1 @@ +- Fixed context ID reuse issue in `ElevenLabsTTSService`, `InworldTTSService`, `RimeTTSService`, `CartesiaTTSService`, `AsyncAITTSService`, and `PlayHTTTSService`. Services now properly reuse the same context ID across multiple `run_tts()` invocations within a single LLM turn, preventing context tracking issues and incorrect lifecycle signaling. diff --git a/changelog/3729.fixed.md b/changelog/3729.fixed.md new file mode 100644 index 000000000..b8be759fb --- /dev/null +++ b/changelog/3729.fixed.md @@ -0,0 +1 @@ +- Fixed word timestamp interleaving issue in `ElevenLabsTTSService` when processing multiple sentences within a single LLM turn. From 94f01af54514c4f14786a35dd444069bfaa1a99a Mon Sep 17 00:00:00 2001 From: Ian Lee Date: Mon, 2 Feb 2026 18:18:02 -0800 Subject: [PATCH 32/93] [inworld] Allow Async delivery of timestamps info * speed up first audio chunk latency --- changelog/3625.added.md | 1 + src/pipecat/services/inworld/tts.py | 30 +++++++++++++++++++++-------- 2 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 changelog/3625.added.md diff --git a/changelog/3625.added.md b/changelog/3625.added.md new file mode 100644 index 000000000..ddf787567 --- /dev/null +++ b/changelog/3625.added.md @@ -0,0 +1 @@ +- Added `"timestampTransportStrategy": "ASYNC"` to `InworldAITTSService`. This allows timestamps info to trail audio chunks arrival, resulting in much better first audio chunk latency diff --git a/src/pipecat/services/inworld/tts.py b/src/pipecat/services/inworld/tts.py index 845a37bdd..9f7c0cff1 100644 --- a/src/pipecat/services/inworld/tts.py +++ b/src/pipecat/services/inworld/tts.py @@ -17,7 +17,7 @@ import asyncio import base64 import json import uuid -from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple +from typing import Any, AsyncGenerator, Dict, List, Literal, Optional, Tuple import aiohttp import websockets @@ -65,10 +65,12 @@ class InworldHttpTTSService(WordTTSService): Parameters: temperature: Temperature for speech synthesis. speaking_rate: Speaking rate for speech synthesis. + timestamp_transport_strategy: The strategy to use for timestamp transport. """ temperature: Optional[float] = None speaking_rate: Optional[float] = None + timestamp_transport_strategy: Optional[Literal["ASYNC", "SYNC"]] = None def __init__( self, @@ -128,6 +130,8 @@ class InworldHttpTTSService(WordTTSService): self._settings["temperature"] = params.temperature if params.speaking_rate is not None: self._settings["audioConfig"]["speakingRate"] = params.speaking_rate + if params.timestamp_transport_strategy is not None: + self._settings["timestampTransportStrategy"] = params.timestamp_transport_strategy self._cumulative_time = 0.0 @@ -240,6 +244,8 @@ class InworldHttpTTSService(WordTTSService): # Use WORD timestamps for simplicity and correct spacing/capitalization payload["timestampType"] = self._timestamp_type + if "timestampTransportStrategy" in self._settings: + payload["timestampTransportStrategy"] = self._settings["timestampTransportStrategy"] request_id = str(uuid.uuid4()) headers = { @@ -427,6 +433,7 @@ class InworldTTSService(AudioContextWordTTSService): flushing of buffered text to achieve minimal latency while maintaining high quality audio output. If None (default), automatically set based on aggregate_sentences. + timestamp_transport_strategy: The strategy to use for timestamp transport. """ temperature: Optional[float] = None @@ -434,7 +441,8 @@ class InworldTTSService(AudioContextWordTTSService): apply_text_normalization: Optional[str] = None max_buffer_delay_ms: Optional[int] = None buffer_char_threshold: Optional[int] = None - auto_mode: Optional[bool] = None + auto_mode: Optional[bool] = True + timestamp_transport_strategy: Optional[Literal["ASYNC", "SYNC"]] = None def __init__( self, @@ -494,6 +502,8 @@ class InworldTTSService(AudioContextWordTTSService): self._settings["audioConfig"]["speakingRate"] = params.speaking_rate if params.apply_text_normalization is not None: self._settings["applyTextNormalization"] = params.apply_text_normalization + if params.timestamp_transport_strategy is not None: + self._settings["timestampTransportStrategy"] = params.timestamp_transport_strategy if params.auto_mode is not None: self._settings["autoMode"] = params.auto_mode @@ -815,12 +825,12 @@ class InworldTTSService(AudioContextWordTTSService): if ctx_id: await self.append_to_audio_context(ctx_id, frame) - # timestampInfo is inside audioChunk - timestamp_info = audio_chunk.get("timestampInfo") - if timestamp_info: - word_times = self._calculate_word_times(timestamp_info) - if word_times: - await self.add_word_timestamps(word_times, ctx_id) + # timestampInfo is inside audioChunk + timestamp_info = audio_chunk.get("timestampInfo") + if timestamp_info: + word_times = self._calculate_word_times(timestamp_info) + if word_times: + await self.add_word_timestamps(word_times, ctx_id) # Handle context created confirmation if "contextCreated" in result: @@ -884,6 +894,10 @@ class InworldTTSService(AudioContextWordTTSService): create_config["applyTextNormalization"] = self._settings["applyTextNormalization"] if "autoMode" in self._settings: create_config["autoMode"] = self._settings["autoMode"] + if "timestampTransportStrategy" in self._settings: + create_config["timestampTransportStrategy"] = self._settings[ + "timestampTransportStrategy" + ] # Set buffer settings for timely audio generation. # Use provided values or defaults that work well for streaming LLM output. From 3fce88555fc2aba5ac5dea80e218100a5b12c43c Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Fri, 13 Feb 2026 09:39:44 -0500 Subject: [PATCH 33/93] Improve events docstrings --- src/pipecat/services/deepgram/flux/stt.py | 14 +++++ src/pipecat/services/deepgram/stt.py | 11 ++++ src/pipecat/services/sarvam/stt.py | 12 ++++ src/pipecat/services/speechmatics/stt.py | 10 ++++ src/pipecat/transports/daily/transport.py | 55 +++++++++++++++++++ src/pipecat/transports/heygen/transport.py | 11 ++++ src/pipecat/transports/livekit/transport.py | 35 ++++++++++++ .../transports/smallwebrtc/transport.py | 12 ++++ src/pipecat/transports/tavus/transport.py | 11 ++++ src/pipecat/transports/websocket/client.py | 11 ++++ src/pipecat/transports/websocket/fastapi.py | 12 ++++ src/pipecat/transports/websocket/server.py | 13 +++++ 12 files changed, 207 insertions(+) diff --git a/src/pipecat/services/deepgram/flux/stt.py b/src/pipecat/services/deepgram/flux/stt.py index 547eca0de..5b091862c 100644 --- a/src/pipecat/services/deepgram/flux/stt.py +++ b/src/pipecat/services/deepgram/flux/stt.py @@ -73,6 +73,20 @@ class DeepgramFluxSTTService(WebsocketSTTService): Provides real-time speech recognition using Deepgram's WebSocket API with Flux capabilities. Supports configurable models, VAD events, and various audio processing options including advanced turn detection and EagerEndOfTurn events for improved conversational AI performance. + + Event handlers available (in addition to WebsocketSTTService events): + + - on_speech_started(service): Deepgram detected start of speech + - on_utterance_end(service): Deepgram detected end of utterance + - on_end_of_turn(service): Deepgram detected end of turn (EOT) + - on_eager_end_of_turn(service): Deepgram predicted end of turn (EagerEOT) + - on_turn_resumed(service): User resumed speaking after EagerEOT + + Example:: + + @stt.event_handler("on_end_of_turn") + async def on_end_of_turn(service): + ... """ class InputParams(BaseModel): diff --git a/src/pipecat/services/deepgram/stt.py b/src/pipecat/services/deepgram/stt.py index 0f79499ba..c4f72e6c3 100644 --- a/src/pipecat/services/deepgram/stt.py +++ b/src/pipecat/services/deepgram/stt.py @@ -50,6 +50,17 @@ class DeepgramSTTService(STTService): Provides real-time speech recognition using Deepgram's WebSocket API. Supports configurable models, languages, and various audio processing options. + + Event handlers available (in addition to STTService events): + + - on_speech_started(service): Deepgram detected start of speech + - on_utterance_end(service): Deepgram detected end of utterance + + Example:: + + @stt.event_handler("on_speech_started") + async def on_speech_started(service): + ... """ def __init__( diff --git a/src/pipecat/services/sarvam/stt.py b/src/pipecat/services/sarvam/stt.py index aae1ae243..13277fe96 100644 --- a/src/pipecat/services/sarvam/stt.py +++ b/src/pipecat/services/sarvam/stt.py @@ -134,6 +134,18 @@ class SarvamSTTService(STTService): """Sarvam speech-to-text service. Provides real-time speech recognition using Sarvam's WebSocket API. + + Event handlers available (in addition to STTService events): + + - on_connected(service): Connected to Sarvam WebSocket + - on_disconnected(service): Disconnected from Sarvam WebSocket + - on_connection_error(service, error): Connection error occurred + + Example:: + + @stt.event_handler("on_connected") + async def on_connected(service): + ... """ class InputParams(BaseModel): diff --git a/src/pipecat/services/speechmatics/stt.py b/src/pipecat/services/speechmatics/stt.py index ca949a9fd..72f3f3990 100644 --- a/src/pipecat/services/speechmatics/stt.py +++ b/src/pipecat/services/speechmatics/stt.py @@ -86,6 +86,16 @@ class SpeechmaticsSTTService(STTService): This service provides real-time speech-to-text transcription using the Speechmatics API. It supports partial and final transcriptions, multiple languages, various audio formats, and speaker diarization. + + Event handlers available (in addition to STTService events): + + - on_speakers_result(service, speakers): Speaker diarization results received + + Example:: + + @stt.event_handler("on_speakers_result") + async def on_speakers_result(service, speakers): + ... """ # Export related classes as class attributes diff --git a/src/pipecat/transports/daily/transport.py b/src/pipecat/transports/daily/transport.py index 20a0be29f..e3b246253 100644 --- a/src/pipecat/transports/daily/transport.py +++ b/src/pipecat/transports/daily/transport.py @@ -2039,6 +2039,61 @@ class DailyTransport(BaseTransport): Provides comprehensive Daily integration including audio/video streaming, transcription, recording, dial-in/out functionality, and real-time communication features for conversational AI applications. + + Event handlers available: + + - on_joined: Called when the bot joins the room. Args: (data: dict) + - on_left: Called when the bot leaves the room. + - on_before_leave: [sync] Called just before the bot leaves the room. + - on_error: Called when a transport error occurs. Args: (error: str) + - on_call_state_updated: Called when the call state changes. Args: (state: str) + - on_first_participant_joined: Called when the first participant joins. + Args: (participant: dict) + - on_participant_joined: Called when any participant joins. + Args: (participant: dict) + - on_participant_left: Called when a participant leaves. + Args: (participant: dict, reason: str) + - on_participant_updated: Called when a participant's state changes. + Args: (participant: dict) + - on_client_connected: Called when a participant connects (alias for + on_participant_joined). Args: (participant: dict) + - on_client_disconnected: Called when a participant disconnects (alias for + on_participant_left). Args: (participant: dict) + - on_active_speaker_changed: Called when the active speaker changes. + Args: (participant: dict) + - on_app_message: Called when an app message is received. + Args: (message: Any, sender: str) + - on_transcription_message: Called when a transcription message is received. + Args: (message: dict) + - on_recording_started: Called when recording starts. Args: (status: str) + - on_recording_stopped: Called when recording stops. Args: (stream_id: str) + - on_recording_error: Called when a recording error occurs. + Args: (stream_id: str, message: str) + - on_dialin_connected: Called when a dial-in call connects. Args: (data: dict) + - on_dialin_ready: Called when the SIP endpoint is ready. + Args: (sip_endpoint: str) + - on_dialin_stopped: Called when a dial-in call stops. Args: (data: dict) + - on_dialin_error: Called when a dial-in error occurs. Args: (data: dict) + - on_dialin_warning: Called when a dial-in warning occurs. Args: (data: dict) + - on_dialout_answered: Called when a dial-out call is answered. Args: (data: dict) + - on_dialout_connected: Called when a dial-out call connects. Args: (data: dict) + - on_dialout_stopped: Called when a dial-out call stops. Args: (data: dict) + - on_dialout_error: Called when a dial-out error occurs. Args: (data: dict) + - on_dialout_warning: Called when a dial-out warning occurs. Args: (data: dict) + + Example:: + + @transport.event_handler("on_first_participant_joined") + async def on_first_participant_joined(transport, participant): + await task.queue_frame(TTSSpeakFrame("Hello!")) + + @transport.event_handler("on_participant_left") + async def on_participant_left(transport, participant, reason): + await task.queue_frame(EndFrame()) + + @transport.event_handler("on_app_message") + async def on_app_message(transport, message, sender): + logger.info(f"Message from {sender}: {message}") """ def __init__( diff --git a/src/pipecat/transports/heygen/transport.py b/src/pipecat/transports/heygen/transport.py index 2ff1c7d56..dbeded3e5 100644 --- a/src/pipecat/transports/heygen/transport.py +++ b/src/pipecat/transports/heygen/transport.py @@ -289,6 +289,17 @@ class HeyGenTransport(BaseTransport): When used, the Pipecat bot joins the same virtual room as the HeyGen Avatar and the user. This is achieved by using `HeyGenTransport`, which initiates the conversation via `HeyGenApi` and obtains a room URL that all participants connect to. + + Event handlers available: + + - on_client_connected(transport, participant): Participant connected to the session + - on_client_disconnected(transport, participant): Participant disconnected from the session + + Example:: + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, participant): + ... """ def __init__( diff --git a/src/pipecat/transports/livekit/transport.py b/src/pipecat/transports/livekit/transport.py index ab7325c68..1902e7cd3 100644 --- a/src/pipecat/transports/livekit/transport.py +++ b/src/pipecat/transports/livekit/transport.py @@ -950,6 +950,41 @@ class LiveKitTransport(BaseTransport): Provides comprehensive LiveKit integration including audio streaming, data messaging, participant management, and room event handling for conversational AI applications. + + Event handlers available: + + - on_connected: Called when the bot connects to the room. + - on_disconnected: Called when the bot disconnects from the room. + - on_before_disconnect: [sync] Called just before the bot disconnects. + - on_call_state_updated: Called when the call state changes. Args: (state: str) + - on_first_participant_joined: Called when the first participant joins. + Args: (participant_id: str) + - on_participant_connected: Called when a participant connects. + Args: (participant_id: str) + - on_participant_disconnected: Called when a participant disconnects. + Args: (participant_id: str) + - on_participant_left: Called when a participant leaves. + Args: (participant_id: str, reason: str) + - on_audio_track_subscribed: Called when an audio track is subscribed. + Args: (participant_id: str) + - on_audio_track_unsubscribed: Called when an audio track is unsubscribed. + Args: (participant_id: str) + - on_video_track_subscribed: Called when a video track is subscribed. + Args: (participant_id: str) + - on_video_track_unsubscribed: Called when a video track is unsubscribed. + Args: (participant_id: str) + - on_data_received: Called when data is received from a participant. + Args: (data: bytes, participant_id: str) + + Example:: + + @transport.event_handler("on_first_participant_joined") + async def on_first_participant_joined(transport, participant_id): + await task.queue_frame(TTSSpeakFrame("Hello!")) + + @transport.event_handler("on_participant_disconnected") + async def on_participant_disconnected(transport, participant_id): + await task.queue_frame(EndFrame()) """ def __init__( diff --git a/src/pipecat/transports/smallwebrtc/transport.py b/src/pipecat/transports/smallwebrtc/transport.py index 4389ff5d9..dc91588a3 100644 --- a/src/pipecat/transports/smallwebrtc/transport.py +++ b/src/pipecat/transports/smallwebrtc/transport.py @@ -864,6 +864,18 @@ class SmallWebRTCTransport(BaseTransport): Provides bidirectional audio and video streaming over WebRTC connections with support for application messaging and connection event handling. + + Event handlers available: + + - on_client_connected(transport, client): Client connected to WebRTC session + - on_client_disconnected(transport, client): Client disconnected from WebRTC session + - on_client_message(transport, message, client): Received a data channel message + + Example:: + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + ... """ def __init__( diff --git a/src/pipecat/transports/tavus/transport.py b/src/pipecat/transports/tavus/transport.py index 8e91e2713..c8a79d386 100644 --- a/src/pipecat/transports/tavus/transport.py +++ b/src/pipecat/transports/tavus/transport.py @@ -661,6 +661,17 @@ class TavusTransport(BaseTransport): When used, the Pipecat bot joins the same virtual room as the Tavus Avatar and the user. This is achieved by using `TavusTransportClient`, which initiates the conversation via `TavusApi` and obtains a room URL that all participants connect to. + + Event handlers available: + + - on_client_connected(transport, participant): Participant connected to the session + - on_client_disconnected(transport, participant): Participant disconnected from the session + + Example:: + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, participant): + ... """ def __init__( diff --git a/src/pipecat/transports/websocket/client.py b/src/pipecat/transports/websocket/client.py index e24352575..b5b99ee97 100644 --- a/src/pipecat/transports/websocket/client.py +++ b/src/pipecat/transports/websocket/client.py @@ -471,6 +471,17 @@ class WebsocketClientTransport(BaseTransport): Provides a complete WebSocket client transport implementation with input and output capabilities, connection management, and event handling. + + Event handlers available: + + - on_connected(transport): Connected to WebSocket server + - on_disconnected(transport): Disconnected from WebSocket server + + Example:: + + @transport.event_handler("on_connected") + async def on_connected(transport): + ... """ def __init__( diff --git a/src/pipecat/transports/websocket/fastapi.py b/src/pipecat/transports/websocket/fastapi.py index d4f5809d9..f52123e52 100644 --- a/src/pipecat/transports/websocket/fastapi.py +++ b/src/pipecat/transports/websocket/fastapi.py @@ -534,6 +534,18 @@ class FastAPIWebsocketTransport(BaseTransport): Provides bidirectional WebSocket communication with frame serialization, session management, and event handling for client connections and timeouts. + + Event handlers available: + + - on_client_connected(transport, websocket): Client WebSocket connected + - on_client_disconnected(transport, websocket): Client WebSocket disconnected + - on_session_timeout(transport, websocket): Session timed out + + Example:: + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, websocket): + ... """ def __init__( diff --git a/src/pipecat/transports/websocket/server.py b/src/pipecat/transports/websocket/server.py index 16964cb9b..e5f628fa4 100644 --- a/src/pipecat/transports/websocket/server.py +++ b/src/pipecat/transports/websocket/server.py @@ -421,6 +421,19 @@ class WebsocketServerTransport(BaseTransport): Provides a complete WebSocket server implementation with separate input and output transports, client connection management, and event handling for real-time audio and data streaming applications. + + Event handlers available: + + - on_client_connected(transport, websocket): Client WebSocket connected + - on_client_disconnected(transport, websocket): Client WebSocket disconnected + - on_session_timeout(transport, websocket): Session timed out + - on_websocket_ready(transport): WebSocket server is ready to accept connections + + Example:: + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, websocket): + ... """ def __init__( From 25ca2964777311d81b955816c7d5ac67da86f793 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Fri, 13 Feb 2026 11:21:24 -0500 Subject: [PATCH 34/93] Move tracing fields to AIService and extract _get_turn_context helper Consolidate _tracing_enabled and _tracing_context from LLMService, STTService, and TTSService into the shared AIService base class. Extract _get_turn_context() helper in service_decorators.py to encapsulate the repeated pattern across all traced decorators. --- src/pipecat/services/ai_service.py | 5 ++- src/pipecat/services/llm_service.py | 3 -- src/pipecat/services/stt_service.py | 3 -- src/pipecat/services/tts_service.py | 4 --- .../utils/tracing/service_decorators.py | 33 ++++++++++--------- 5 files changed, 22 insertions(+), 26 deletions(-) diff --git a/src/pipecat/services/ai_service.py b/src/pipecat/services/ai_service.py index c03ab9d0e..52b42663f 100644 --- a/src/pipecat/services/ai_service.py +++ b/src/pipecat/services/ai_service.py @@ -44,6 +44,8 @@ class AIService(FrameProcessor): self._model_name: str = "" self._settings: Dict[str, Any] = {} self._session_properties: Dict[str, Any] = {} + self._tracing_enabled: bool = False + self._tracing_context = None @property def model_name(self) -> str: @@ -72,7 +74,8 @@ class AIService(FrameProcessor): Args: frame: The start frame containing initialization parameters. """ - pass + self._tracing_enabled = frame.enable_tracing + self._tracing_context = frame.tracing_context async def stop(self, frame: EndFrame): """Stop the AI service. diff --git a/src/pipecat/services/llm_service.py b/src/pipecat/services/llm_service.py index acbd6baae..c8af00b80 100644 --- a/src/pipecat/services/llm_service.py +++ b/src/pipecat/services/llm_service.py @@ -198,7 +198,6 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): self._functions: Dict[Optional[str], FunctionCallRegistryItem] = {} self._function_call_tasks: Dict[Optional[asyncio.Task], FunctionCallRunnerItem] = {} self._sequential_runner_task: Optional[asyncio.Task] = None - self._tracing_enabled: bool = False self._skip_tts: Optional[bool] = None self._summary_task: Optional[asyncio.Task] = None @@ -285,8 +284,6 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): await super().start(frame) if not self._run_in_parallel: await self._create_sequential_runner_task() - self._tracing_enabled = frame.enable_tracing - self._tracing_context = frame.tracing_context async def stop(self, frame: EndFrame): """Stop the LLM service. diff --git a/src/pipecat/services/stt_service.py b/src/pipecat/services/stt_service.py index 81f369572..1d8d1590f 100644 --- a/src/pipecat/services/stt_service.py +++ b/src/pipecat/services/stt_service.py @@ -102,7 +102,6 @@ class STTService(AIService): self._init_sample_rate = sample_rate self._sample_rate = 0 self._settings: Dict[str, Any] = {} - self._tracing_enabled: bool = False self._muted: bool = False self._user_id: str = "" self._ttfs_p99_latency = ttfs_p99_latency @@ -202,8 +201,6 @@ class STTService(AIService): """ await super().start(frame) self._sample_rate = self._init_sample_rate or frame.audio_in_sample_rate - self._tracing_enabled = frame.enable_tracing - self._tracing_context = frame.tracing_context async def cleanup(self): """Clean up STT service resources.""" diff --git a/src/pipecat/services/tts_service.py b/src/pipecat/services/tts_service.py index 00e3e81f8..02c799d0f 100644 --- a/src/pipecat/services/tts_service.py +++ b/src/pipecat/services/tts_service.py @@ -208,8 +208,6 @@ class TTSService(AIService): # TODO: Deprecate _text_filters when added to LLMTextProcessor self._text_filters: Sequence[BaseTextFilter] = text_filters or [] self._transport_destination: Optional[str] = transport_destination - self._tracing_enabled: bool = False - if text_filter: import warnings @@ -349,8 +347,6 @@ class TTSService(AIService): 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()) - self._tracing_enabled = frame.enable_tracing - self._tracing_context = frame.tracing_context async def stop(self, frame: EndFrame): """Stop the TTS service. diff --git a/src/pipecat/utils/tracing/service_decorators.py b/src/pipecat/utils/tracing/service_decorators.py index a4f0d8c37..fae7f7e77 100644 --- a/src/pipecat/utils/tracing/service_decorators.py +++ b/src/pipecat/utils/tracing/service_decorators.py @@ -54,6 +54,19 @@ def _noop_decorator(func): return func +def _get_turn_context(self): + """Get the current turn's tracing context if available. + + Args: + self: The service instance. + + Returns: + The turn context, or None if unavailable. + """ + tracing_ctx = getattr(self, "_tracing_context", None) + return tracing_ctx.get_turn_context() if tracing_ctx else None + + def _get_parent_service_context(self): """Get the parent service span context (internal use only). @@ -182,9 +195,7 @@ def traced_tts(func: Optional[Callable] = None, *, name: Optional[str] = None) - span_name = "tts" # Get parent context - tracing_ctx = getattr(self, "_tracing_context", None) - turn_context = tracing_ctx.get_turn_context() if tracing_ctx else None - parent_context = turn_context or _get_parent_service_context(self) + parent_context = _get_turn_context(self) or _get_parent_service_context(self) # Create span tracer = trace.get_tracer("pipecat") @@ -290,9 +301,7 @@ def traced_stt(func: Optional[Callable] = None, *, name: Optional[str] = None) - span_name = "stt" # Get the turn context first, then fall back to service context - tracing_ctx = getattr(self, "_tracing_context", None) - turn_context = tracing_ctx.get_turn_context() if tracing_ctx else None - parent_context = turn_context or _get_parent_service_context(self) + parent_context = _get_turn_context(self) or _get_parent_service_context(self) # Create a new span as child of the turn span or service span tracer = trace.get_tracer("pipecat") @@ -373,9 +382,7 @@ def traced_llm(func: Optional[Callable] = None, *, name: Optional[str] = None) - span_name = "llm" # Get the parent context - turn context if available, otherwise service context - tracing_ctx = getattr(self, "_tracing_context", None) - turn_context = tracing_ctx.get_turn_context() if tracing_ctx else None - parent_context = turn_context or _get_parent_service_context(self) + parent_context = _get_turn_context(self) or _get_parent_service_context(self) # Create a new span as child of the turn span or service span tracer = trace.get_tracer("pipecat") @@ -584,9 +591,7 @@ def traced_gemini_live(operation: str) -> Callable: span_name = f"{operation}" # Get the parent context - turn context if available, otherwise service context - tracing_ctx = getattr(self, "_tracing_context", None) - turn_context = tracing_ctx.get_turn_context() if tracing_ctx else None - parent_context = turn_context or _get_parent_service_context(self) + parent_context = _get_turn_context(self) or _get_parent_service_context(self) # Create a new span as child of the turn span or service span tracer = trace.get_tracer("pipecat") @@ -892,9 +897,7 @@ def traced_openai_realtime(operation: str) -> Callable: span_name = f"{operation}" # Get the parent context - turn context if available, otherwise service context - tracing_ctx = getattr(self, "_tracing_context", None) - turn_context = tracing_ctx.get_turn_context() if tracing_ctx else None - parent_context = turn_context or _get_parent_service_context(self) + parent_context = _get_turn_context(self) or _get_parent_service_context(self) # Create a new span as child of the turn span or service span tracer = trace.get_tracer("pipecat") From 01b7a93e08844e28f9371ef93b71eaa2d465d98b Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 12 Feb 2026 18:40:33 -0500 Subject: [PATCH 35/93] Deprecate unused Traceable/class_decorators module and fix stale comments The class_decorators.py module (Traceable, @traceable, @traced) is not used anywhere in the codebase. Mark it deprecated and fix the misleading comment in service_decorators.py that referenced it as if it were active. --- changelog/3733.deprecated.md | 1 + src/pipecat/utils/tracing/class_decorators.py | 13 +++++++++++++ src/pipecat/utils/tracing/service_decorators.py | 5 +++-- 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 changelog/3733.deprecated.md diff --git a/changelog/3733.deprecated.md b/changelog/3733.deprecated.md new file mode 100644 index 000000000..8b1fb29bb --- /dev/null +++ b/changelog/3733.deprecated.md @@ -0,0 +1 @@ +- Deprecated unused `Traceable`, `@traceable`, `@traced`, and `AttachmentStrategy` in `pipecat.utils.tracing.class_decorators`. This module will be removed in a future release. diff --git a/src/pipecat/utils/tracing/class_decorators.py b/src/pipecat/utils/tracing/class_decorators.py index 571a74804..73dacbe51 100644 --- a/src/pipecat/utils/tracing/class_decorators.py +++ b/src/pipecat/utils/tracing/class_decorators.py @@ -7,6 +7,11 @@ """Base OpenTelemetry tracing decorators and utilities for Pipecat. +.. deprecated:: 0.0.103 + This module is unused and will be removed in a future release. + Service tracing is handled by the decorators in + :mod:`pipecat.utils.tracing.service_decorators`. + This module provides class and method level tracing capabilities similar to the original NVIDIA implementation. """ @@ -16,8 +21,16 @@ import contextlib import enum import functools import inspect +import warnings from typing import Callable, Optional, TypeVar +warnings.warn( + "pipecat.utils.tracing.class_decorators is deprecated and will be removed in a future " + "release. Use pipecat.utils.tracing.service_decorators instead.", + DeprecationWarning, + stacklevel=2, +) + from pipecat.utils.tracing.setup import is_tracing_available # Import OpenTelemetry if available diff --git a/src/pipecat/utils/tracing/service_decorators.py b/src/pipecat/utils/tracing/service_decorators.py index fae7f7e77..0fd939162 100644 --- a/src/pipecat/utils/tracing/service_decorators.py +++ b/src/pipecat/utils/tracing/service_decorators.py @@ -82,11 +82,12 @@ def _get_parent_service_context(self): if not is_tracing_available(): return None - # The parent span was created when Traceable was initialized and stored as self._span + # TODO: Remove this block and delete class_decorators.py once Traceable is removed. + # Legacy: support for classes inheriting from Traceable (currently unused, deprecated). if hasattr(self, "_span") and self._span: return trace.set_span_in_context(self._span) - # Fall back to conversation context if available + # Use the conversation context set by TurnTraceObserver via TracingContext. tracing_ctx = getattr(self, "_tracing_context", None) conversation_context = tracing_ctx.get_conversation_context() if tracing_ctx else None if conversation_context: From 3adb2f50a6d03e11b619bba1730ea8c4d07ecd9c Mon Sep 17 00:00:00 2001 From: Luke Payyapilli Date: Fri, 13 Feb 2026 11:59:56 -0500 Subject: [PATCH 36/93] Fix LLMUserAggregator broadcasting mute events before StartFrame --- changelog/3737.fixed.md | 1 + .../aggregators/llm_response_universal.py | 9 ++++ tests/test_context_aggregators_universal.py | 44 ++++++++++++++++++- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 changelog/3737.fixed.md diff --git a/changelog/3737.fixed.md b/changelog/3737.fixed.md new file mode 100644 index 000000000..6dee96f82 --- /dev/null +++ b/changelog/3737.fixed.md @@ -0,0 +1 @@ +- Fixed `LLMUserAggregator` broadcasting mute events before `StartFrame` reaches downstream processors. diff --git a/src/pipecat/processors/aggregators/llm_response_universal.py b/src/pipecat/processors/aggregators/llm_response_universal.py index 6b28b5bf2..8450842c8 100644 --- a/src/pipecat/processors/aggregators/llm_response_universal.py +++ b/src/pipecat/processors/aggregators/llm_response_universal.py @@ -549,6 +549,15 @@ class LLMUserAggregator(LLMContextAggregator): await s.cleanup() async def _maybe_mute_frame(self, frame: Frame): + # Control frames must flow unconditionally — never feed them to mute + # strategies. Without this guard, strategies like + # MuteUntilFirstBotCompleteUserMuteStrategy fire on_user_mute_started + # and broadcast UserMuteStartedFrame before StartFrame is pushed + # downstream, causing downstream processors to receive frames before + # StartFrame and log errors. + if isinstance(frame, (StartFrame, EndFrame, CancelFrame)): + return False + should_mute_frame = self._user_is_muted and isinstance( frame, ( diff --git a/tests/test_context_aggregators_universal.py b/tests/test_context_aggregators_universal.py index 7e09a5449..1bba463b0 100644 --- a/tests/test_context_aggregators_universal.py +++ b/tests/test_context_aggregators_universal.py @@ -24,7 +24,9 @@ from pipecat.frames.frames import ( LLMThoughtEndFrame, LLMThoughtStartFrame, LLMThoughtTextFrame, + StartFrame, TranscriptionFrame, + UserMuteStartedFrame, UserStartedSpeakingFrame, UserStoppedSpeakingFrame, VADUserStartedSpeakingFrame, @@ -40,7 +42,11 @@ from pipecat.processors.aggregators.llm_response_universal import ( LLMUserAggregatorParams, ) from pipecat.tests.utils import SleepFrame, run_test -from pipecat.turns.user_mute import FirstSpeechUserMuteStrategy, FunctionCallUserMuteStrategy +from pipecat.turns.user_mute import ( + FirstSpeechUserMuteStrategy, + FunctionCallUserMuteStrategy, + MuteUntilFirstBotCompleteUserMuteStrategy, +) from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy from pipecat.turns.user_turn_strategies import UserTurnStrategies @@ -386,6 +392,42 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): self.assertIsNone(strategy) # strategy is None for end/cancel self.assertEqual(message.content, "Hello!") + async def test_start_frame_before_mute_event(self): + """StartFrame must reach downstream before mute events are broadcast. + + With MuteUntilFirstBotCompleteUserMuteStrategy, the mute logic should + not run on control frames (StartFrame, EndFrame, CancelFrame). This + ensures StartFrame reaches downstream processors before + UserMuteStartedFrame is broadcast. + + The default TurnAnalyzerUserTurnStopStrategy broadcasts a + SpeechControlParamsFrame when it processes StartFrame, which gets + re-queued to the aggregator. That non-control frame legitimately + triggers the mute state change, so UserMuteStartedFrame follows + StartFrame — but crucially, after it. + """ + context = LLMContext() + + user_aggregator = LLMUserAggregator( + context, + params=LLMUserAggregatorParams( + user_mute_strategies=[MuteUntilFirstBotCompleteUserMuteStrategy()], + ), + ) + + pipeline = Pipeline([user_aggregator]) + + # run_test internally sends StartFrame via PipelineRunner. With + # ignore_start=False we can verify ordering: StartFrame must arrive + # before UserMuteStartedFrame. Before the fix, UserMuteStartedFrame + # was broadcast before StartFrame reached downstream processors. + (down_frames, _) = await run_test( + pipeline, + frames_to_send=[], + expected_down_frames=[StartFrame, UserMuteStartedFrame], + ignore_start=False, + ) + class TestLLMAssistantAggregator(unittest.IsolatedAsyncioTestCase): async def test_empty(self): From 2454bedf29b42f0964373e55f6b43429368834f2 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Fri, 13 Feb 2026 12:28:18 -0500 Subject: [PATCH 37/93] Add /update-docs skill for keeping docs in sync with source changes Adds a Claude Code skill that analyzes the current branch diff against main, maps changed source files to their doc pages, and makes targeted updates to Configuration, InputParams, Usage, Notes, and Event Handlers sections. --- .claude/skills/update-docs/SKILL.md | 250 ++++++++++++++++++ .../skills/update-docs/SOURCE_DOC_MAPPING.md | 79 ++++++ 2 files changed, 329 insertions(+) create mode 100644 .claude/skills/update-docs/SKILL.md create mode 100644 .claude/skills/update-docs/SOURCE_DOC_MAPPING.md diff --git a/.claude/skills/update-docs/SKILL.md b/.claude/skills/update-docs/SKILL.md new file mode 100644 index 000000000..f70febf96 --- /dev/null +++ b/.claude/skills/update-docs/SKILL.md @@ -0,0 +1,250 @@ +--- +name: update-docs +description: Update documentation pages to match source code changes on the current branch +--- + +Update documentation pages to reflect source code changes on the current branch. Analyzes the diff against main, maps changed source files to their corresponding doc pages, and makes targeted edits. + +## Arguments + +``` +/update-docs [DOCS_PATH] +``` + +- `DOCS_PATH` (optional): Path to the docs repository root. If not provided, ask the user. + +Examples: +- `/update-docs /Users/me/src/docs` +- `/update-docs` + +## Instructions + +### Step 1: Resolve docs path + +If `DOCS_PATH` was provided as an argument, use it. Otherwise, ask the user for the path to their docs repository. + +Verify the path exists and contains `server/services/` subdirectory. + +### Step 2: Create docs branch + +Get the current pipecat branch name: +```bash +git rev-parse --abbrev-ref HEAD +``` + +In the docs repo, create a new branch off main with a matching name: +```bash +cd DOCS_PATH && git checkout main && git pull && git checkout -b {branch-name}-docs +``` + +For example, if the pipecat branch is `feat/new-service`, the docs branch becomes `feat/new-service-docs`. + +All doc edits in subsequent steps are made on this branch. + +### Step 3: Detect changed source files + +Run: +```bash +git diff main..HEAD --name-only +``` + +Filter to files that could affect documentation: +- `src/pipecat/services/**/*.py` (service implementations) +- `src/pipecat/transports/**/*.py` (transport implementations) +- `src/pipecat/serializers/**/*.py` (serializer implementations) +- `src/pipecat/processors/**/*.py` (processor implementations) +- `src/pipecat/audio/**/*.py` (audio utilities) +- `src/pipecat/turns/**/*.py` (turn management) +- `src/pipecat/observers/**/*.py` (observers) +- `src/pipecat/pipeline/**/*.py` (pipeline core) + +Ignore `__init__.py`, `__pycache__`, test files, and files that only contain type re-exports. + +### Step 4: Map source files to doc pages + +For each changed source file, find the corresponding doc page. Read the mapping file at `.claude/skills/update-docs/SOURCE_DOC_MAPPING.md` and apply its tiered lookup: tier 1 (known exceptions) → tier 2 (pattern matching) → tier 3 (search fallback). **First match wins.** + +### Step 5: Analyze each source-doc pair + +For each mapped pair: + +1. **Read the full source file** to understand current state +2. **Read the diff** for that file: `git diff main..HEAD -- ` +3. **Read the current doc page** in full + +Identify what changed by comparing source to docs: + +- **Constructor parameters**: Compare `__init__` signature to the Configuration section's `` entries +- **InputParams fields**: Compare `InputParams(BaseModel)` class fields to the InputParams table +- **Event handlers**: Compare `_register_event_handler` calls and event handler definitions to Event Handlers section +- **Class names / imports**: Check if Usage examples reference correct names +- **Behavioral changes**: Check if Notes section needs updating + +### Step 6: Make targeted edits + +For each doc page that needs updates, edit **only the sections that need changes**. Preserve all other content exactly as-is. + +#### Rules + +- **Never remove content** unless the corresponding source code was removed +- **Never rewrite sections** that are already accurate +- **Match existing formatting** — if the page uses `` tags, use them; if it uses tables, use tables +- **Keep descriptions concise** — match the tone and length of surrounding content +- **Preserve CardGroup, links, and examples** unless they reference removed functionality +- **Don't touch frontmatter** unless the class was renamed + +#### Section-specific guidance + +**Configuration** (constructor params): +- Use `` format if the page already uses it +- Add new params in logical order (required first, then optional) +- Remove params that no longer exist in source +- Update types/defaults that changed + +**InputParams** (runtime settings): +- Use markdown table format: `| Parameter | Type | Default | Description |` +- Match the field names and types from the `InputParams(BaseModel)` class +- Include the default values from the source + +**Usage** (code examples): +- Update import paths, class names, and parameter names +- Only modify examples if they would break or be misleading with the new API +- Don't rewrite working examples just to add new optional params + +**Notes**: +- Add notes for new behavioral gotchas or breaking changes +- Remove notes about limitations that were fixed +- Keep existing notes that are still accurate + +**Event Handlers**: +- Update the event table and example code +- Add new events, remove deleted ones +- Update handler signatures if they changed + +**Overview / Key Features / Prerequisites**: +- Only update if the PR fundamentally changes what the service does (new capability, removed capability, renamed class) +- Most PRs will NOT need changes to these sections + +### Step 7: Update guides + +Guides at `DOCS_PATH/guides/` reference specific class names, parameters, imports, and code patterns. After completing reference doc edits, check if any guides need updates too. + +For each changed source file, collect the class names, renamed parameters, and changed imports from the diff. Search the guides directory: +```bash +grep -rl "ClassName\|old_param_name" DOCS_PATH/guides/ +``` + +For each guide that references changed code: +1. Read the full guide +2. Update class names, parameter names, import paths, and code examples that are now incorrect +3. **Don't rewrite prose** — only fix the specific references that changed +4. Leave guides alone if they reference the service generally but don't use any changed APIs + +Guide directories: +- `guides/learn/` — conceptual tutorials (pipeline, LLM, STT, TTS, etc.) +- `guides/fundamentals/` — practical how-tos (metrics, recording, transcripts, etc.) +- `guides/features/` — feature-specific guides (Gemini Live, OpenAI audio, WhatsApp, etc.) +- `guides/telephony/` — telephony integration guides (Twilio, Plivo, Telnyx, etc.) + +### Step 8: Handle unmapped files + +After processing all mapped pairs, report any source files that: +- Had no doc page mapping (neither tier 1, 2, nor 3) +- Are not marked as "(skip)" in the tier-1 table + +For each unmapped file, tell the user: +- The source file path +- The main class(es) it defines +- Whether a new doc page should be created + +If the user wants a new page, create it using this template structure: +``` +--- +title: "Service Name" +description: "Brief description" +--- + +## Overview + +[Description from class docstring or source analysis] + + + [Cards for API reference and examples if available] + + +## Installation + +```bash +pip install "pipecat-ai[package-name]" +``` + +## Prerequisites + +[Environment variables and account setup] + +## Configuration + +[ParamField entries for constructor params] + +## InputParams + +[Table of InputParams fields, if the service has them] + +## Usage + +### Basic Setup + +```python +[Minimal working example] +``` + +## Notes + +[Important caveats] + +## Event Handlers + +[Event table and example code] +``` + +### Step 9: Output summary + +After all edits are complete, print a summary: + +``` +## Documentation Updates + +### Updated reference pages +- `server/services/stt/deepgram.mdx` — Updated Configuration (added `new_param`), InputParams (updated `language` default) +- `server/services/tts/elevenlabs.mdx` — Updated Event Handlers (added `on_connected`) + +### Updated guides +- `guides/learn/speech-to-text.mdx` — Updated code example (renamed `old_param` → `new_param`) + +### Unmapped source files +- `src/pipecat/services/newprovider/tts.py` — NewProviderTTSService (no doc page exists) + +### Skipped files +- `src/pipecat/services/ai_service.py` — internal base class +``` + +## Guidelines + +- **Be conservative** — only change what the diff warrants. Don't "improve" docs beyond what changed in source. +- **Read before editing** — always read the full doc page before making changes so you understand the existing structure. +- **Preserve voice** — match the writing style of the existing doc page, don't impose a different tone. +- **One PR at a time** — this skill operates on the current branch's diff against main. Don't look at other branches. +- **Parallel analysis** — when multiple source files map to different doc pages, analyze and edit them in parallel for efficiency. +- **Shared source files** — files like `services/google/google.py` are shared bases. Check which services import from them and update all affected doc pages. + +## Checklist + +Before finishing, verify: + +- [ ] All changed source files were checked against the mapping table +- [ ] Each doc page edit matches the actual source code change (not guessed) +- [ ] No content was removed unless the corresponding source was removed +- [ ] New parameters have accurate types and defaults from source +- [ ] Formatting matches the existing page style +- [ ] Guides referencing changed APIs were checked and updated +- [ ] Unmapped files were reported to the user diff --git a/.claude/skills/update-docs/SOURCE_DOC_MAPPING.md b/.claude/skills/update-docs/SOURCE_DOC_MAPPING.md new file mode 100644 index 000000000..d2e200be7 --- /dev/null +++ b/.claude/skills/update-docs/SOURCE_DOC_MAPPING.md @@ -0,0 +1,79 @@ +# Source-to-Doc Mapping + +Maps pipecat source files to their documentation pages. Source paths are relative to `src/pipecat/`. Doc paths are relative to `DOCS_PATH`. + +## Name mismatches + +These source paths don't follow the standard `services/{provider}/{type}.py` → `server/services/{type}/{provider}.mdx` pattern. + +| Source path | Doc page | +|---|---| +| `services/google/llm.py` | `server/services/llm/gemini.mdx` | +| `services/google/llm_vertex.py` | `server/services/llm/google-vertex.mdx` | +| `services/google/google.py` | (shared base — check which services use it) | +| `services/google/gemini_live/**` | `server/services/s2s/gemini-live.mdx` | +| `services/google/gemini_live/llm_vertex.py` | `server/services/s2s/gemini-live-vertex.mdx` | +| `services/aws_nova_sonic/**` | `server/services/s2s/aws.mdx` | +| `services/ultravox/**` | `server/services/s2s/ultravox.mdx` | +| `services/grok/realtime/**` | `server/services/s2s/grok.mdx` | +| `services/openai/realtime/**` | `server/services/s2s/openai.mdx` | +| `processors/frameworks/rtvi.py` | `server/frameworks/rtvi/rtvi-processor.mdx` and `server/frameworks/rtvi/rtvi-observer.mdx` | +| `processors/transcript_processor.py` | `server/utilities/transcript-processor.mdx` | +| `processors/user_idle_processor.py` | `server/utilities/user-idle-processor.mdx` | +| `processors/idle_frame_processor.py` | `server/pipeline/pipeline-idle-detection.mdx` | +| `pipeline/task.py` | `server/pipeline/pipeline-task.mdx` | +| `runner/run.py` | `server/utilities/runner/guide.mdx` | + +## Skip list + +These files should never trigger doc updates. + +| Pattern | Reason | +|---|---| +| `services/ai_service.py` | Internal base class | +| `services/stt_service.py` | Internal base class | +| `services/tts_service.py` | Internal base class | +| `services/llm_service.py` | Internal base class | +| `services/websocket_service.py` | Internal base class | +| `services/openai_realtime_beta/**` | Deprecated | +| `services/openai_realtime/**` | Deprecated | +| `services/gemini_multimodal_live/**` | Deprecated | +| `services/aws/agent_core.py` | Internal | +| `services/aws/sagemaker/**` | No doc page | +| `transports/base_transport.py` | Internal base class | +| `transports/base_input.py` | Internal base class | +| `transports/base_output.py` | Internal base class | +| `transports/websocket/client.py` | No doc page | +| `serializers/base_serializer.py` | Internal base class | +| `serializers/protobuf.py` | Internal | +| `processors/audio/**` | Internal | +| `pipeline/pipeline.py` | Core architecture, not a service doc | + +## Pattern matching + +For files not in the tables above, apply these patterns. Convert underscores to hyphens in provider names for doc filenames. + +| Source pattern | Doc pattern | +|---|---| +| `services/{provider}/stt*.py` | `server/services/stt/{provider}.mdx` | +| `services/{provider}/tts*.py` | `server/services/tts/{provider}.mdx` | +| `services/{provider}/llm*.py` | `server/services/llm/{provider}.mdx` | +| `services/{provider}/image*.py` | `server/services/image-generation/{provider}.mdx` | +| `services/{provider}/video*.py` | `server/services/video/{provider}.mdx` | +| `services/{provider}/realtime/**` | `server/services/s2s/{provider}.mdx` | +| `transports/{name}/**` | `server/services/transport/{name}.mdx` | +| `serializers/{name}.py` | `server/services/serializers/{name}.mdx` | +| `observers/**` | `server/utilities/observers/` (match by class name) | +| `audio/vad/**` | `server/utilities/audio/` (match by class name) | +| `audio/filters/**` | `server/utilities/audio/` (match by class name) | +| `audio/mixers/**` | `server/utilities/audio/` (match by class name) | +| `processors/filters/**` | `server/utilities/filters/` (match by class name) | + +If the doc file doesn't exist at the resolved path, the file is **unmapped**. + +## Search fallback + +For files that don't match any table or pattern above: +1. Extract the main class name(s) from the source file +2. Search the docs directory for that class name: `grep -r "ClassName" DOCS_PATH/server/` +3. If found in a doc page, use that as the mapping From e50b138ab21da65fad3182c91d66a286054d6615 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 12 Feb 2026 22:03:55 -0500 Subject: [PATCH 38/93] Fix double execution of service functions when tracing errors occur The outer try/except in each service decorator caught both tracing setup errors and application errors from the wrapped function. If the function itself raised (e.g. LLM rate limit, TTS timeout), the exception was caught and the function was called a second time. Fix by tracking whether the original function was called via a fn_called flag. If the function was already called, re-raise the exception instead of falling back to untraced re-execution. --- .../utils/tracing/service_decorators.py | 76 +++++++++++-------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/src/pipecat/utils/tracing/service_decorators.py b/src/pipecat/utils/tracing/service_decorators.py index 0fd939162..968fe8e8a 100644 --- a/src/pipecat/utils/tracing/service_decorators.py +++ b/src/pipecat/utils/tracing/service_decorators.py @@ -230,19 +230,21 @@ def traced_tts(func: Optional[Callable] = None, *, name: Optional[str] = None) - @functools.wraps(f) async def gen_wrapper(self, text, *args, **kwargs): - try: - # Check if tracing is enabled for this service instance - if not getattr(self, "_tracing_enabled", False): - async for item in f(self, text, *args, **kwargs): - yield item - return + if not getattr(self, "_tracing_enabled", False): + async for item in f(self, text, *args, **kwargs): + yield item + return + fn_called = False + try: async with tracing_context(self, text): + fn_called = True async for item in f(self, text, *args, **kwargs): yield item except Exception as e: + if fn_called: + raise logging.error(f"Error in TTS tracing (continuing without tracing): {e}") - # If tracing fails, fall back to the original function async for item in f(self, text, *args, **kwargs): yield item @@ -251,16 +253,18 @@ def traced_tts(func: Optional[Callable] = None, *, name: Optional[str] = None) - @functools.wraps(f) async def wrapper(self, text, *args, **kwargs): - try: - # Check if tracing is enabled for this service instance - if not getattr(self, "_tracing_enabled", False): - return await f(self, text, *args, **kwargs) + if not getattr(self, "_tracing_enabled", False): + return await f(self, text, *args, **kwargs) + fn_called = False + try: async with tracing_context(self, text): + fn_called = True return await f(self, text, *args, **kwargs) except Exception as e: + if fn_called: + raise logging.error(f"Error in TTS tracing (continuing without tracing): {e}") - # If tracing fails, fall back to the original function return await f(self, text, *args, **kwargs) return wrapper @@ -293,11 +297,11 @@ def traced_stt(func: Optional[Callable] = None, *, name: Optional[str] = None) - def decorator(f): @functools.wraps(f) async def wrapper(self, transcript, is_final, language=None): - try: - # Check if tracing is enabled for this service instance - if not getattr(self, "_tracing_enabled", False): - return await f(self, transcript, is_final, language) + if not getattr(self, "_tracing_enabled", False): + return await f(self, transcript, is_final, language) + fn_called = False + try: service_class_name = self.__class__.__name__ span_name = "stt" @@ -332,14 +336,16 @@ def traced_stt(func: Optional[Callable] = None, *, name: Optional[str] = None) - ) # Call the original function + fn_called = True return await f(self, transcript, is_final, language) except Exception as e: # Log any exception but don't disrupt the main flow logging.warning(f"Error in STT transcription tracing: {e}") raise except Exception as e: + if fn_called: + raise logging.error(f"Error in STT tracing (continuing without tracing): {e}") - # If tracing fails, fall back to the original function return await f(self, transcript, is_final, language) return wrapper @@ -374,11 +380,11 @@ def traced_llm(func: Optional[Callable] = None, *, name: Optional[str] = None) - def decorator(f): @functools.wraps(f) async def wrapper(self, context, *args, **kwargs): - try: - # Check if tracing is enabled for this service instance - if not getattr(self, "_tracing_enabled", False): - return await f(self, context, *args, **kwargs) + if not getattr(self, "_tracing_enabled", False): + return await f(self, context, *args, **kwargs) + fn_called = False + try: service_class_name = self.__class__.__name__ span_name = "llm" @@ -525,6 +531,7 @@ def traced_llm(func: Optional[Callable] = None, *, name: Optional[str] = None) - # Don't raise - let the function execute anyway # Run function with modified push_frame to capture the output + fn_called = True result = await f(self, context, *args, **kwargs) # Add aggregated output after function completes, if available @@ -550,8 +557,9 @@ def traced_llm(func: Optional[Callable] = None, *, name: Optional[str] = None) - if ttfb is not None: current_span.set_attribute("metrics.ttfb", ttfb) except Exception as e: + if fn_called: + raise logging.error(f"Error in LLM tracing (continuing without tracing): {e}") - # If tracing fails, fall back to the original function return await f(self, context, *args, **kwargs) return wrapper @@ -583,11 +591,11 @@ def traced_gemini_live(operation: str) -> Callable: def decorator(func): @functools.wraps(func) async def wrapper(self, *args, **kwargs): - try: - # Check if tracing is enabled for this service instance - if not getattr(self, "_tracing_enabled", False): - return await func(self, *args, **kwargs) + if not getattr(self, "_tracing_enabled", False): + return await func(self, *args, **kwargs) + fn_called = False + try: service_class_name = self.__class__.__name__ span_name = f"{operation}" @@ -849,6 +857,7 @@ def traced_gemini_live(operation: str) -> Callable: current_span.set_attribute("metrics.ttfb", ttfb) # Run the original function + fn_called = True result = await func(self, *args, **kwargs) return result @@ -859,8 +868,9 @@ def traced_gemini_live(operation: str) -> Callable: raise except Exception as e: + if fn_called: + raise logging.error(f"Error in Gemini Live tracing (continuing without tracing): {e}") - # If tracing fails, fall back to the original function return await func(self, *args, **kwargs) return wrapper @@ -889,11 +899,11 @@ def traced_openai_realtime(operation: str) -> Callable: def decorator(func): @functools.wraps(func) async def wrapper(self, *args, **kwargs): - try: - # Check if tracing is enabled for this service instance - if not getattr(self, "_tracing_enabled", False): - return await func(self, *args, **kwargs) + if not getattr(self, "_tracing_enabled", False): + return await func(self, *args, **kwargs) + fn_called = False + try: service_class_name = self.__class__.__name__ span_name = f"{operation}" @@ -1072,6 +1082,7 @@ def traced_openai_realtime(operation: str) -> Callable: current_span.set_attribute("metrics.ttfb", ttfb) # Run the original function + fn_called = True result = await func(self, *args, **kwargs) return result @@ -1082,8 +1093,9 @@ def traced_openai_realtime(operation: str) -> Callable: raise except Exception as e: + if fn_called: + raise logging.error(f"Error in OpenAI Realtime tracing (continuing without tracing): {e}") - # If tracing fails, fall back to the original function return await func(self, *args, **kwargs) return wrapper From a5f95acaf59e3e8a526362e65cc546b970048257 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 12 Feb 2026 22:07:09 -0500 Subject: [PATCH 39/93] Add changelog for PR #3735 --- changelog/3735.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3735.fixed.md diff --git a/changelog/3735.fixed.md b/changelog/3735.fixed.md new file mode 100644 index 000000000..02de936c7 --- /dev/null +++ b/changelog/3735.fixed.md @@ -0,0 +1 @@ +- Fixed tracing service decorators executing the wrapped function twice when the function itself raised an exception (e.g., LLM rate limit, TTS timeout). From f7af9f1efd992ddc6d7564278a120540b669cc53 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Fri, 13 Feb 2026 13:14:45 -0500 Subject: [PATCH 40/93] Broaden /update-docs scope to detect missing doc sections --- .claude/skills/update-docs/SKILL.md | 10 +++++----- .claude/skills/update-docs/SOURCE_DOC_MAPPING.md | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.claude/skills/update-docs/SKILL.md b/.claude/skills/update-docs/SKILL.md index f70febf96..f24d56f00 100644 --- a/.claude/skills/update-docs/SKILL.md +++ b/.claude/skills/update-docs/SKILL.md @@ -146,17 +146,17 @@ Guide directories: - `guides/features/` — feature-specific guides (Gemini Live, OpenAI audio, WhatsApp, etc.) - `guides/telephony/` — telephony integration guides (Twilio, Plivo, Telnyx, etc.) -### Step 8: Handle unmapped files +### Step 8: Identify doc gaps -After processing all mapped pairs, report any source files that: -- Had no doc page mapping (neither tier 1, 2, nor 3) -- Are not marked as "(skip)" in the tier-1 table +After processing all mapped pairs, check for two kinds of gaps: -For each unmapped file, tell the user: +**Missing pages**: Source files that had no doc page mapping (neither tier 1, 2, nor 3) and are not marked as "(skip)". For each, tell the user: - The source file path - The main class(es) it defines - Whether a new doc page should be created +**Missing sections**: Mapped doc pages that are missing standard sections compared to the source. For example, a transport page with no Configuration section, or a service page with no InputParams table when the source defines `InputParams(BaseModel)`. Flag these and offer to add the missing sections. + If the user wants a new page, create it using this template structure: ``` --- diff --git a/.claude/skills/update-docs/SOURCE_DOC_MAPPING.md b/.claude/skills/update-docs/SOURCE_DOC_MAPPING.md index d2e200be7..03e6cbbf1 100644 --- a/.claude/skills/update-docs/SOURCE_DOC_MAPPING.md +++ b/.claude/skills/update-docs/SOURCE_DOC_MAPPING.md @@ -22,7 +22,8 @@ These source paths don't follow the standard `services/{provider}/{type}.py` → | `processors/user_idle_processor.py` | `server/utilities/user-idle-processor.mdx` | | `processors/idle_frame_processor.py` | `server/pipeline/pipeline-idle-detection.mdx` | | `pipeline/task.py` | `server/pipeline/pipeline-task.mdx` | -| `runner/run.py` | `server/utilities/runner/guide.mdx` | +| `pipeline/runner.py` | `server/utilities/runner/guide.mdx` | +| `transports/base_transport.py` | `server/services/transport/transport-params.mdx` | ## Skip list @@ -40,7 +41,6 @@ These files should never trigger doc updates. | `services/gemini_multimodal_live/**` | Deprecated | | `services/aws/agent_core.py` | Internal | | `services/aws/sagemaker/**` | No doc page | -| `transports/base_transport.py` | Internal base class | | `transports/base_input.py` | Internal base class | | `transports/base_output.py` | Internal base class | | `transports/websocket/client.py` | No doc page | From 2489c76bc6c3127964b2b2a81ca2799daf698226 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Fri, 13 Feb 2026 16:43:25 -0300 Subject: [PATCH 41/93] Using the latest version of pipecat-ai-small-webrtc-prebuilt. --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 35b95f7b4..71baec1f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,7 +105,7 @@ remote-smart-turn = [] resembleai = [ "pipecat-ai[websockets-base]" ] rime = [ "pipecat-ai[websockets-base]" ] riva = [ "pipecat-ai[nvidia]" ] -runner = [ "python-dotenv>=1.0.0,<2.0.0", "uvicorn>=0.32.0,<1.0.0", "fastapi>=0.115.6,<0.128.0", "pipecat-ai-small-webrtc-prebuilt>=2.1.0"] +runner = [ "python-dotenv>=1.0.0,<2.0.0", "uvicorn>=0.32.0,<1.0.0", "fastapi>=0.115.6,<0.128.0", "pipecat-ai-small-webrtc-prebuilt>=2.2.0"] sagemaker = ["aws_sdk_sagemaker_runtime_http2; python_version>='3.12'"] sambanova = [] sarvam = [ "sarvamai==0.1.21", "pipecat-ai[websockets-base]" ] diff --git a/uv.lock b/uv.lock index 8a5eadbe7..0a3206c0f 100644 --- a/uv.lock +++ b/uv.lock @@ -4734,7 +4734,7 @@ requires-dist = [ { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'ultravox'" }, { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'websocket'" }, { name = "pipecat-ai-krisp", marker = "extra == 'krisp'", specifier = "~=0.4.0" }, - { name = "pipecat-ai-small-webrtc-prebuilt", marker = "extra == 'runner'", specifier = ">=2.1.0" }, + { name = "pipecat-ai-small-webrtc-prebuilt", marker = "extra == 'runner'", specifier = ">=2.2.0" }, { name = "piper-tts", marker = "extra == 'piper'", specifier = ">=1.3.0,<2" }, { name = "protobuf", specifier = "~=5.29.6" }, { name = "pvkoala", marker = "extra == 'koala'", specifier = "~=2.0.3" }, @@ -4805,14 +4805,14 @@ sdist = { url = "https://files.pythonhosted.org/packages/1d/37/0f1d11d1dc33234a3 [[package]] name = "pipecat-ai-small-webrtc-prebuilt" -version = "2.1.0" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastapi", extra = ["all"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4a/8b/c945a27503560d50c56f497e43209f43c81616ede5d3d2b38eeb4044dbbc/pipecat_ai_small_webrtc_prebuilt-2.1.0.tar.gz", hash = "sha256:c883304a159dee01eec74b162e51352b32c362ca19d281226a71d92b5ba1c600", size = 596971, upload-time = "2026-02-05T17:01:08.074Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/9f/b06cc0e2eaeda811959c216dade3ed38c30d20e6327a2b22f80125072c5a/pipecat_ai_small_webrtc_prebuilt-2.2.0.tar.gz", hash = "sha256:5d73fe619225b97e383863a901060d1c986f088f4de004477856b085aaba76c4", size = 466005, upload-time = "2026-02-13T19:28:54.626Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/f6/5754d86d513822fb4466549eb6bca05c7246676f1b5b199c9646a5475182/pipecat_ai_small_webrtc_prebuilt-2.1.0-py3-none-any.whl", hash = "sha256:0882d147f69d8cc07397ee68b75c554f4d2d75d8023207e7fb86760616bc58aa", size = 597535, upload-time = "2026-02-05T17:01:06.622Z" }, + { url = "https://files.pythonhosted.org/packages/26/71/20a015cea25dc57129ed6426fdf37a09aefe37f4dd60e3a42ba2d9e3bd1b/pipecat_ai_small_webrtc_prebuilt-2.2.0-py3-none-any.whl", hash = "sha256:e7917d23f51e5418667541a3e241b2de28a43eea35a5a9486721be3da04e719d", size = 466257, upload-time = "2026-02-13T19:28:53.188Z" }, ] [[package]] From 012ef41ff4b9ffe2f71423100476f748ed7b3a7f Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Sat, 14 Feb 2026 08:22:33 -0500 Subject: [PATCH 42/93] Redesign UserIdleController to use BotStoppedSpeakingFrame Replace the continuous heartbeat-based timer (UserSpeakingFrame/BotSpeakingFrame + asyncio.Event loop) with a simple one-shot timer that starts when BotStoppedSpeakingFrame is received and cancels on UserStartedSpeakingFrame or BotStartedSpeakingFrame. This eliminates false idle triggers caused by gaps between the user finishing speaking and the bot starting to speak (LLM/TTS latency). Guard the timer start with two conditions to prevent false triggers: - User turn in progress: during interruptions, BotStoppedSpeaking arrives while the user is still speaking mid-turn. - Function calls in progress: FunctionCallsStarted arrives before BotStoppedSpeaking because the bot speaks concurrently with the function call starting, so the timer must wait for the result and subsequent bot response. --- examples/foundational/17-detect-user-idle.py | 54 +++- .../aggregators/llm_response_universal.py | 6 + src/pipecat/turns/user_idle_controller.py | 146 ++++------ src/pipecat/turns/user_turn_processor.py | 6 + tests/test_user_idle_controller.py | 272 ++++++++++-------- 5 files changed, 277 insertions(+), 207 deletions(-) diff --git a/examples/foundational/17-detect-user-idle.py b/examples/foundational/17-detect-user-idle.py index ae2727c6a..bb0ea8873 100644 --- a/examples/foundational/17-detect-user-idle.py +++ b/examples/foundational/17-detect-user-idle.py @@ -5,11 +5,14 @@ # +import asyncio import os from dotenv import load_dotenv from loguru import logger +from pipecat.adapters.schemas.function_schema import FunctionSchema +from pipecat.adapters.schemas.tools_schema import ToolsSchema from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.frames.frames import ( EndTaskFrame, @@ -30,6 +33,7 @@ from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.cartesia.tts import CartesiaTTSService from pipecat.services.deepgram.stt import DeepgramSTTService +from pipecat.services.llm_service import FunctionCallParams from pipecat.services.openai.llm import OpenAILLMService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams @@ -74,6 +78,17 @@ class IdleHandler: await aggregator.push_frame(EndTaskFrame(), FrameDirection.UPSTREAM) +async def fetch_weather_from_api(params: FunctionCallParams): + # Simulate a slow API call, waiting longer than the user idle timeout. + await asyncio.sleep(3) + await params.result_callback({"conditions": "nice", "temperature": "75"}) + + +async def fetch_restaurant_recommendation(params: FunctionCallParams): + await asyncio.sleep(6) + await params.result_callback({"name": "The Golden Dragon"}) + + # We use lambdas to defer transport parameter creation until the transport # type is selected at runtime. transport_params = { @@ -104,6 +119,42 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm.register_function("get_current_weather", fetch_weather_from_api) + llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation) + + @llm.event_handler("on_function_calls_started") + async def on_function_calls_started(service, function_calls): + await tts.queue_frame(TTSSpeakFrame("Let me check on that.")) + + weather_function = FunctionSchema( + name="get_current_weather", + description="Get the current weather", + properties={ + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + }, + "format": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + "description": "The temperature unit to use. Infer this from the user's location.", + }, + }, + required=["location", "format"], + ) + restaurant_function = FunctionSchema( + name="get_restaurant_recommendation", + description="Get a restaurant recommendation", + properties={ + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + }, + }, + required=["location"], + ) + tools = ToolsSchema(standard_tools=[weather_function, restaurant_function]) + messages = [ { "role": "system", @@ -111,7 +162,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): }, ] - context = LLMContext(messages) + context = LLMContext(messages, tools) user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, user_params=LLMUserAggregatorParams( @@ -146,6 +197,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): @user_aggregator.event_handler("on_user_turn_idle") async def on_user_turn_idle(aggregator): + logger.info(f"User turn idle") await idle_handler.handle_idle(aggregator) @user_aggregator.event_handler("on_user_turn_started") diff --git a/src/pipecat/processors/aggregators/llm_response_universal.py b/src/pipecat/processors/aggregators/llm_response_universal.py index 6b28b5bf2..0fb538b1a 100644 --- a/src/pipecat/processors/aggregators/llm_response_universal.py +++ b/src/pipecat/processors/aggregators/llm_response_universal.py @@ -689,6 +689,9 @@ class LLMUserAggregator(LLMContextAggregator): if params.enable_user_speaking_frames: await self.broadcast_frame(UserStartedSpeakingFrame) + if self._user_idle_controller: + await self._user_idle_controller.process_frame(UserStartedSpeakingFrame()) + if params.enable_interruptions and self._allow_interruptions: await self.push_interruption_task_frame_and_wait() @@ -705,6 +708,9 @@ class LLMUserAggregator(LLMContextAggregator): if params.enable_user_speaking_frames: await self.broadcast_frame(UserStoppedSpeakingFrame) + if self._user_idle_controller: + await self._user_idle_controller.process_frame(UserStoppedSpeakingFrame()) + await self._maybe_emit_user_turn_stopped(strategy) async def _on_user_turn_stop_timeout(self, controller): diff --git a/src/pipecat/turns/user_idle_controller.py b/src/pipecat/turns/user_idle_controller.py index cce35b9cb..b4dc80772 100644 --- a/src/pipecat/turns/user_idle_controller.py +++ b/src/pipecat/turns/user_idle_controller.py @@ -10,12 +10,14 @@ import asyncio from typing import Optional from pipecat.frames.frames import ( - BotSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, Frame, + FunctionCallCancelFrame, FunctionCallResultFrame, FunctionCallsStartedFrame, - UserSpeakingFrame, UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, ) from pipecat.utils.asyncio.task_manager import BaseTaskManager from pipecat.utils.base_object import BaseObject @@ -25,14 +27,14 @@ class UserIdleController(BaseObject): """Controller for managing user idle detection. This class monitors user activity and triggers an event when the user has been - idle (not speaking) for a configured timeout period. It only starts monitoring - after the first conversation activity and does not trigger while the bot is - speaking or function calls are in progress. + idle (not speaking) for a configured timeout period after the bot finishes + speaking. The timer starts when BotStoppedSpeakingFrame is received and is + cancelled when someone starts speaking again (UserStartedSpeakingFrame or + BotStartedSpeakingFrame). - The controller tracks activity using continuous frames (UserSpeakingFrame and - BotSpeakingFrame) which are emitted repeatedly while speaking is happening, and - state-based tracking for function calls (FunctionCallsStartedFrame and - FunctionCallResultFrame) which are only sent at start and end. + The timer is suppressed while a user turn is in progress to avoid false + triggers during interruptions (where BotStoppedSpeakingFrame arrives while + the user is still speaking). Event handlers available: @@ -62,11 +64,9 @@ class UserIdleController(BaseObject): self._task_manager: Optional[BaseTaskManager] = None - self._conversation_started = False - self._function_call_in_progress = False - - self.user_idle_event = asyncio.Event() - self.user_idle_task: Optional[asyncio.Task] = None + self._user_turn_in_progress: bool = False + self._function_calls_in_progress: int = 0 + self._idle_timer_task: Optional[asyncio.Task] = None self._register_event_handler("on_user_turn_idle", sync=True) @@ -85,19 +85,10 @@ class UserIdleController(BaseObject): """ self._task_manager = task_manager - if not self.user_idle_task: - self.user_idle_task = self.task_manager.create_task( - self.user_idle_task_handler(), - f"{self}::user_idle_task_handler", - ) - async def cleanup(self): """Cleanup the controller.""" await super().cleanup() - - if self.user_idle_task: - await self.task_manager.cancel_task(self.user_idle_task) - self.user_idle_task = None + await self._cancel_idle_timer() async def process_frame(self, frame: Frame): """Process an incoming frame to track user activity state. @@ -105,69 +96,52 @@ class UserIdleController(BaseObject): Args: frame: The frame to be processed. """ - # Start monitoring on first conversation activity - if not self._conversation_started: - if isinstance(frame, (UserStartedSpeakingFrame, BotSpeakingFrame)): - self._conversation_started = True - self.user_idle_event.set() - else: - return - - # Reset idle timer on continuous activity frames - if isinstance(frame, (UserSpeakingFrame, BotSpeakingFrame)): - await self._handle_activity(frame) - # Track function call state (start/end frames, not continuous) + if isinstance(frame, BotStoppedSpeakingFrame): + # Only start the timer if the user isn't mid-turn and no function + # calls are pending. + # + # Interruption case: the frame order is UserStartedSpeaking → + # BotStoppedSpeaking → (user keeps talking) → UserStoppedSpeaking. + # Without the user-turn guard the timer would start while the user + # is still speaking. + # + # Function call case: normally FunctionCallsStarted arrives after + # BotStoppedSpeaking and cancels the timer directly. But a race + # condition can cause FunctionCallsStarted to arrive before + # BotStoppedSpeaking when pushing a TTSSpeakFrame in the + # on_function_calls_started event handler, so the counter guard + # prevents the timer from starting while a function call is in progress. + if not self._user_turn_in_progress and self._function_calls_in_progress == 0: + await self._start_idle_timer() + elif isinstance(frame, BotStartedSpeakingFrame): + await self._cancel_idle_timer() + elif isinstance(frame, UserStartedSpeakingFrame): + self._user_turn_in_progress = True + await self._cancel_idle_timer() + elif isinstance(frame, UserStoppedSpeakingFrame): + self._user_turn_in_progress = False elif isinstance(frame, FunctionCallsStartedFrame): - await self._handle_function_calls_started(frame) - elif isinstance(frame, FunctionCallResultFrame): - await self._handle_function_call_result(frame) + self._function_calls_in_progress += len(frame.function_calls) + await self._cancel_idle_timer() + elif isinstance(frame, (FunctionCallResultFrame, FunctionCallCancelFrame)): + self._function_calls_in_progress = max(0, self._function_calls_in_progress - 1) - async def _handle_activity(self, _: UserSpeakingFrame | BotSpeakingFrame): - """Handle continuous activity frames that should reset the idle timer. + async def _start_idle_timer(self): + """Start (or restart) the idle timer.""" + await self._cancel_idle_timer() + self._idle_timer_task = self.task_manager.create_task( + self._idle_timer_expired(), + f"{self}::idle_timer", + ) - These frames are emitted continuously while the user or bot is speaking, - so we simply reset the timer whenever we receive them. + async def _cancel_idle_timer(self): + """Cancel the idle timer if running.""" + if self._idle_timer_task: + await self.task_manager.cancel_task(self._idle_timer_task) + self._idle_timer_task = None - Args: - frame: The activity frame to process. - """ - self.user_idle_event.set() - - async def _handle_function_calls_started(self, _: FunctionCallsStartedFrame): - """Handle function calls started event. - - Function calls can take longer than the timeout, so we track their state - to prevent idle callbacks while they're in progress. - - Args: - frame: The FunctionCallsStartedFrame to process. - """ - self._function_call_in_progress = True - self.user_idle_event.set() - - async def _handle_function_call_result(self, _: FunctionCallResultFrame): - """Handle function call result event. - - Args: - frame: The FunctionCallResultFrame to process. - """ - self._function_call_in_progress = False - self.user_idle_event.set() - - async def user_idle_task_handler(self): - """Monitors for idle timeout and triggers events. - - Runs in a loop until cancelled. The idle timer is reset whenever activity - frames are received (UserSpeakingFrame or BotSpeakingFrame). Function calls - are tracked via state since they only send start/end frames. If no activity - is detected for the configured timeout period and no function call is in - progress, the on_user_turn_idle event is triggered. - """ - while True: - try: - await asyncio.wait_for(self.user_idle_event.wait(), timeout=self._user_idle_timeout) - self.user_idle_event.clear() - except asyncio.TimeoutError: - # Only trigger if conversation has started and no function call is in progress - if self._conversation_started and not self._function_call_in_progress: - await self._call_event_handler("on_user_turn_idle") + async def _idle_timer_expired(self): + """Sleep for the timeout duration then fire the idle event.""" + await asyncio.sleep(self._user_idle_timeout) + self._idle_timer_task = None + await self._call_event_handler("on_user_turn_idle") diff --git a/src/pipecat/turns/user_turn_processor.py b/src/pipecat/turns/user_turn_processor.py index 6c771811e..720a8b854 100644 --- a/src/pipecat/turns/user_turn_processor.py +++ b/src/pipecat/turns/user_turn_processor.py @@ -189,6 +189,9 @@ class UserTurnProcessor(FrameProcessor): if params.enable_user_speaking_frames: await self.broadcast_frame(UserStartedSpeakingFrame) + if self._user_idle_controller: + await self._user_idle_controller.process_frame(UserStartedSpeakingFrame()) + if params.enable_interruptions and self._allow_interruptions: await self.push_interruption_task_frame_and_wait() @@ -205,6 +208,9 @@ class UserTurnProcessor(FrameProcessor): if params.enable_user_speaking_frames: await self.broadcast_frame(UserStoppedSpeakingFrame) + if self._user_idle_controller: + await self._user_idle_controller.process_frame(UserStoppedSpeakingFrame()) + await self._call_event_handler("on_user_turn_stopped", strategy) async def _on_user_turn_stop_timeout(self, controller): diff --git a/tests/test_user_idle_controller.py b/tests/test_user_idle_controller.py index 4b6cbe1d3..6975d6e74 100644 --- a/tests/test_user_idle_controller.py +++ b/tests/test_user_idle_controller.py @@ -6,12 +6,13 @@ import asyncio import unittest +import unittest.mock from pipecat.frames.frames import ( - BotSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, FunctionCallResultFrame, FunctionCallsStartedFrame, - UserSpeakingFrame, UserStartedSpeakingFrame, ) from pipecat.turns.user_idle_controller import UserIdleController @@ -25,8 +26,8 @@ class TestUserIdleController(unittest.IsolatedAsyncioTestCase): self.task_manager = TaskManager() self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop())) - async def test_basic_idle_detection(self): - """Test that idle event is triggered after timeout when no activity.""" + async def test_idle_after_bot_stops_speaking(self): + """Test that idle event fires after BotStoppedSpeakingFrame + timeout.""" controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) await controller.setup(self.task_manager) @@ -37,18 +38,16 @@ class TestUserIdleController(unittest.IsolatedAsyncioTestCase): nonlocal idle_triggered idle_triggered = True - # Start conversation - await controller.process_frame(UserStartedSpeakingFrame()) + await controller.process_frame(BotStoppedSpeakingFrame()) - # Wait for idle timeout await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) self.assertTrue(idle_triggered) await controller.cleanup() - async def test_user_speaking_resets_idle_timer(self): - """Test that continuous UserSpeakingFrame frames reset the idle timer.""" + async def test_user_speaking_cancels_timer(self): + """Test that UserStartedSpeakingFrame cancels the idle timer.""" controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) await controller.setup(self.task_manager) @@ -59,20 +58,18 @@ class TestUserIdleController(unittest.IsolatedAsyncioTestCase): nonlocal idle_triggered idle_triggered = True - # Start conversation + await controller.process_frame(BotStoppedSpeakingFrame()) + await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3) await controller.process_frame(UserStartedSpeakingFrame()) - # Send UserSpeakingFrame continuously to reset timer - for _ in range(5): - await asyncio.sleep(USER_IDLE_TIMEOUT * 0.5) # 50% of timeout period - await controller.process_frame(UserSpeakingFrame()) + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) self.assertFalse(idle_triggered) await controller.cleanup() - async def test_bot_speaking_resets_idle_timer(self): - """Test that BotSpeakingFrame frames reset the idle timer.""" + async def test_bot_speaking_cancels_timer(self): + """Test that BotStartedSpeakingFrame cancels the idle timer.""" controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) await controller.setup(self.task_manager) @@ -83,102 +80,61 @@ class TestUserIdleController(unittest.IsolatedAsyncioTestCase): nonlocal idle_triggered idle_triggered = True - # Start conversation + await controller.process_frame(BotStoppedSpeakingFrame()) + await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3) + await controller.process_frame(BotStartedSpeakingFrame()) + + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + + self.assertFalse(idle_triggered) + + await controller.cleanup() + + async def test_no_idle_before_bot_speaks(self): + """Test that idle does not fire if no BotStoppedSpeakingFrame is received.""" + controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) + await controller.setup(self.task_manager) + + idle_triggered = False + + @controller.event_handler("on_user_turn_idle") + async def on_user_turn_idle(controller): + nonlocal idle_triggered + idle_triggered = True + + # Wait without any frames + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + + self.assertFalse(idle_triggered) + + await controller.cleanup() + + async def test_interruption_no_false_trigger(self): + """Test that BotStoppedSpeakingFrame during a user turn does not start the timer.""" + controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) + await controller.setup(self.task_manager) + + idle_triggered = False + + @controller.event_handler("on_user_turn_idle") + async def on_user_turn_idle(controller): + nonlocal idle_triggered + idle_triggered = True + + # User starts speaking (interruption) await controller.process_frame(UserStartedSpeakingFrame()) + # Bot stops speaking due to interruption + await controller.process_frame(BotStoppedSpeakingFrame()) - # Bot speaking should reset timer - for _ in range(5): - await asyncio.sleep(USER_IDLE_TIMEOUT * 0.6) # 60% of timeout - await controller.process_frame(BotSpeakingFrame()) - - self.assertFalse(idle_triggered) - - await controller.cleanup() - - async def test_function_call_prevents_idle(self): - """Test that function calls in progress prevent idle event.""" - controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) - await controller.setup(self.task_manager) - - idle_triggered = False - - @controller.event_handler("on_user_turn_idle") - async def on_user_turn_idle(controller): - nonlocal idle_triggered - idle_triggered = True - - # Start conversation - await controller.process_frame(UserStartedSpeakingFrame()) - - # Start function call - await controller.process_frame(FunctionCallsStartedFrame(function_calls=[])) - - # Wait longer than idle timeout - await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) - - # Should not trigger idle because function call is in progress - self.assertFalse(idle_triggered) - - # Complete function call - await controller.process_frame( - FunctionCallResultFrame( - function_name="test", - tool_call_id="123", - arguments={}, - result=None, - run_llm=False, - ) - ) - - # Now idle should trigger - await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) - self.assertTrue(idle_triggered) - - await controller.cleanup() - - async def test_no_idle_before_conversation_starts(self): - """Test that idle monitoring doesn't start before first conversation activity.""" - controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) - await controller.setup(self.task_manager) - - idle_triggered = False - - @controller.event_handler("on_user_turn_idle") - async def on_user_turn_idle(controller): - nonlocal idle_triggered - idle_triggered = True - - # Wait without starting conversation + # Wait - timer should NOT have started because user turn is in progress await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) self.assertFalse(idle_triggered) await controller.cleanup() - async def test_idle_starts_with_bot_speaking(self): - """Test that monitoring starts with BotSpeakingFrame, not just user speech.""" - controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) - await controller.setup(self.task_manager) - - idle_triggered = False - - @controller.event_handler("on_user_turn_idle") - async def on_user_turn_idle(controller): - nonlocal idle_triggered - idle_triggered = True - - # Start conversation with bot speaking - await controller.process_frame(BotSpeakingFrame()) - - # Wait for idle timeout - await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) - - self.assertTrue(idle_triggered) - - await controller.cleanup() - - async def test_multiple_idle_events(self): - """Test that idle event can trigger multiple times.""" + async def test_idle_cycle(self): + """Test that idle fires, then can fire again after another bot speaking cycle.""" controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) await controller.setup(self.task_manager) @@ -189,29 +145,105 @@ class TestUserIdleController(unittest.IsolatedAsyncioTestCase): nonlocal idle_count idle_count += 1 - # Start conversation - await controller.process_frame(UserStartedSpeakingFrame()) - - # First idle + # First cycle: bot stops → idle fires + await controller.process_frame(BotStoppedSpeakingFrame()) await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) - first_count = idle_count - self.assertGreaterEqual(first_count, 1) + self.assertEqual(idle_count, 1) - # Second idle + # Second cycle: bot starts → bot stops → idle fires again + await controller.process_frame(BotStartedSpeakingFrame()) + await controller.process_frame(BotStoppedSpeakingFrame()) await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) - second_count = idle_count - self.assertGreater(second_count, first_count) + self.assertEqual(idle_count, 2) - # User activity resets timer - await controller.process_frame(UserSpeakingFrame()) + await controller.cleanup() - # Give a moment for the timer to reset - await asyncio.sleep(0.1) + async def test_cleanup_cancels_timer(self): + """Test that cleanup cancels a pending idle timer.""" + controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) + await controller.setup(self.task_manager) + + idle_triggered = False + + @controller.event_handler("on_user_turn_idle") + async def on_user_turn_idle(controller): + nonlocal idle_triggered + idle_triggered = True + + await controller.process_frame(BotStoppedSpeakingFrame()) + await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3) + await controller.cleanup() - # Third idle await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) - third_count = idle_count - self.assertGreater(third_count, second_count) + + self.assertFalse(idle_triggered) + + async def test_function_call_cancels_timer(self): + """Test normal ordering: BotStopped starts timer, FunctionCallsStarted cancels it.""" + controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) + await controller.setup(self.task_manager) + + idle_triggered = False + + @controller.event_handler("on_user_turn_idle") + async def on_user_turn_idle(controller): + nonlocal idle_triggered + idle_triggered = True + + # Bot finishes speaking, timer starts + await controller.process_frame(BotStoppedSpeakingFrame()) + # Function call starts shortly after, cancels the timer + await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3) + await controller.process_frame( + FunctionCallsStartedFrame(function_calls=[unittest.mock.Mock()]) + ) + + # Wait longer than timeout — should not fire + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + self.assertFalse(idle_triggered) + + await controller.cleanup() + + async def test_function_call_suppresses_timer(self): + """Test race condition: FunctionCallsStarted arrives before BotStopped. + + A race condition can cause FunctionCallsStarted to arrive before + BotStoppedSpeaking. The counter guard prevents the timer from starting + while a function call is in progress. + """ + controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) + await controller.setup(self.task_manager) + + idle_triggered = False + + @controller.event_handler("on_user_turn_idle") + async def on_user_turn_idle(controller): + nonlocal idle_triggered + idle_triggered = True + + # LLM emits function call and "let me check" concurrently + await controller.process_frame( + FunctionCallsStartedFrame(function_calls=[unittest.mock.Mock()]) + ) + await controller.process_frame(BotStartedSpeakingFrame()) + await controller.process_frame(BotStoppedSpeakingFrame()) + + # Wait longer than timeout — should not fire (function call in progress) + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + self.assertFalse(idle_triggered) + + # Function call completes, bot speaks result + await controller.process_frame( + FunctionCallResultFrame( + function_name="test", tool_call_id="123", arguments={}, result="ok" + ) + ) + await controller.process_frame(BotStartedSpeakingFrame()) + await controller.process_frame(BotStoppedSpeakingFrame()) + + # Now the timer should start and fire + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + self.assertTrue(idle_triggered) await controller.cleanup() From cb7023681f0ce157945c7abcfed9e038a71d0041 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Sat, 14 Feb 2026 08:57:46 -0500 Subject: [PATCH 43/93] Add changelog for PR #3744 --- changelog/3744.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3744.fixed.md diff --git a/changelog/3744.fixed.md b/changelog/3744.fixed.md new file mode 100644 index 000000000..d2b3f665f --- /dev/null +++ b/changelog/3744.fixed.md @@ -0,0 +1 @@ +- Fixed `UserIdleController` false idle triggers caused by gaps between user and bot activity frames. The idle timer now starts only after `BotStoppedSpeakingFrame` and is suppressed during active user turns and function calls. From 8f5e5e8e7c4a2d923ce9255708aac5ef6f895147 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Sat, 14 Feb 2026 09:41:42 -0500 Subject: [PATCH 44/93] Update comment in _maybe_mute_frame --- .../processors/aggregators/llm_response_universal.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/pipecat/processors/aggregators/llm_response_universal.py b/src/pipecat/processors/aggregators/llm_response_universal.py index 8450842c8..0a6e05d6b 100644 --- a/src/pipecat/processors/aggregators/llm_response_universal.py +++ b/src/pipecat/processors/aggregators/llm_response_universal.py @@ -549,12 +549,10 @@ class LLMUserAggregator(LLMContextAggregator): await s.cleanup() async def _maybe_mute_frame(self, frame: Frame): - # Control frames must flow unconditionally — never feed them to mute - # strategies. Without this guard, strategies like - # MuteUntilFirstBotCompleteUserMuteStrategy fire on_user_mute_started - # and broadcast UserMuteStartedFrame before StartFrame is pushed - # downstream, causing downstream processors to receive frames before - # StartFrame and log errors. + # Lifecycle frames should never be muted and should not trigger mute + # state changes. Evaluating mute strategies on StartFrame would + # broadcast UserMuteStartedFrame before StartFrame reaches downstream + # processors. if isinstance(frame, (StartFrame, EndFrame, CancelFrame)): return False From 507765625fcbc5e144b2f82e22464161925d79dd Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Sat, 14 Feb 2026 09:47:59 -0500 Subject: [PATCH 45/93] Make UserIdleController always-on with dynamic timeout updates Always create UserIdleController (timeout=0 means disabled), removing all Optional guards. Add UserIdleTimeoutUpdateFrame to allow changing the idle timeout at runtime. --- changelog/3748.added.md | 1 + changelog/3748.changed.md | 1 + examples/foundational/17-detect-user-idle.py | 7 ++ src/pipecat/frames/frames.py | 14 ++++ .../aggregators/llm_response_universal.py | 37 ++++------ src/pipecat/turns/user_idle_controller.py | 12 +++- src/pipecat/turns/user_turn_processor.py | 34 +++------ tests/test_user_idle_controller.py | 71 +++++++++++++++++++ 8 files changed, 129 insertions(+), 48 deletions(-) create mode 100644 changelog/3748.added.md create mode 100644 changelog/3748.changed.md diff --git a/changelog/3748.added.md b/changelog/3748.added.md new file mode 100644 index 000000000..223f8bf4b --- /dev/null +++ b/changelog/3748.added.md @@ -0,0 +1 @@ +- Added `UserIdleTimeoutUpdateFrame` to enable or disable user idle detection at runtime by updating the timeout dynamically. diff --git a/changelog/3748.changed.md b/changelog/3748.changed.md new file mode 100644 index 000000000..61be61c6b --- /dev/null +++ b/changelog/3748.changed.md @@ -0,0 +1 @@ +- `UserIdleController` is now always created with a default timeout of 0 (disabled). The `user_idle_timeout` parameter changed from `Optional[float] = None` to `float = 0` in `UserTurnProcessor`, `LLMUserAggregatorParams`, and `UserIdleController`. diff --git a/examples/foundational/17-detect-user-idle.py b/examples/foundational/17-detect-user-idle.py index bb0ea8873..e6af5a364 100644 --- a/examples/foundational/17-detect-user-idle.py +++ b/examples/foundational/17-detect-user-idle.py @@ -19,6 +19,7 @@ from pipecat.frames.frames import ( LLMMessagesAppendFrame, LLMRunFrame, TTSSpeakFrame, + UserIdleTimeoutUpdateFrame, ) from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -210,6 +211,12 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # Kick off the conversation. messages.append({"role": "system", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) + await asyncio.sleep(30) + logger.info(f"Disabling idle detection") + await task.queue_frames([UserIdleTimeoutUpdateFrame(timeout=0)]) + await asyncio.sleep(30) + logger.info(f"Enabling idle detection") + await task.queue_frames([UserIdleTimeoutUpdateFrame(timeout=5)]) @transport.event_handler("on_client_disconnected") async def on_client_disconnected(transport, client): diff --git a/src/pipecat/frames/frames.py b/src/pipecat/frames/frames.py index 8d237defc..c6c4421cd 100644 --- a/src/pipecat/frames/frames.py +++ b/src/pipecat/frames/frames.py @@ -2145,6 +2145,20 @@ class STTUpdateSettingsFrame(ServiceUpdateSettingsFrame): pass +@dataclass +class UserIdleTimeoutUpdateFrame(SystemFrame): + """Frame for updating the user idle timeout at runtime. + + Setting timeout to 0 disables idle detection. Setting a positive value + enables it. + + Parameters: + timeout: The new idle timeout in seconds. 0 disables idle detection. + """ + + timeout: float + + @dataclass class VADParamsUpdateFrame(ControlFrame): """Frame for updating VAD parameters. diff --git a/src/pipecat/processors/aggregators/llm_response_universal.py b/src/pipecat/processors/aggregators/llm_response_universal.py index 0fb538b1a..f05f064fb 100644 --- a/src/pipecat/processors/aggregators/llm_response_universal.py +++ b/src/pipecat/processors/aggregators/llm_response_universal.py @@ -92,9 +92,9 @@ class LLMUserAggregatorParams: user_mute_strategies: List of user mute strategies. user_turn_stop_timeout: Time in seconds to wait before considering the user's turn finished. - user_idle_timeout: Optional timeout in seconds for detecting user idle state. - If set, the aggregator will emit an `on_user_turn_idle` event when the user - has been idle (not speaking) for this duration. Set to None to disable + user_idle_timeout: Timeout in seconds for detecting user idle state. + The aggregator will emit an `on_user_turn_idle` event when the user + has been idle (not speaking) for this duration. Set to 0 to disable idle detection. vad_analyzer: Voice Activity Detection analyzer instance. filter_incomplete_user_turns: Whether to filter out incomplete user turns. @@ -109,7 +109,7 @@ class LLMUserAggregatorParams: user_turn_strategies: Optional[UserTurnStrategies] = None user_mute_strategies: List[BaseUserMuteStrategy] = field(default_factory=list) user_turn_stop_timeout: float = 5.0 - user_idle_timeout: Optional[float] = None + user_idle_timeout: float = 0 vad_analyzer: Optional[VADAnalyzer] = None filter_incomplete_user_turns: bool = False user_turn_completion_config: Optional[UserTurnCompletionConfig] = None @@ -404,15 +404,10 @@ class LLMUserAggregator(LLMContextAggregator): "on_user_turn_stop_timeout", self._on_user_turn_stop_timeout ) - # Optional user idle controller - self._user_idle_controller: Optional[UserIdleController] = None - if self._params.user_idle_timeout: - self._user_idle_controller = UserIdleController( - user_idle_timeout=self._params.user_idle_timeout - ) - self._user_idle_controller.add_event_handler( - "on_user_turn_idle", self._on_user_turn_idle - ) + self._user_idle_controller = UserIdleController( + user_idle_timeout=self._params.user_idle_timeout + ) + self._user_idle_controller.add_event_handler("on_user_turn_idle", self._on_user_turn_idle) # VAD controller self._vad_controller: Optional[VADController] = None @@ -489,8 +484,7 @@ class LLMUserAggregator(LLMContextAggregator): await self._user_turn_controller.process_frame(frame) - if self._user_idle_controller: - await self._user_idle_controller.process_frame(frame) + await self._user_idle_controller.process_frame(frame) async def push_aggregation(self) -> str: """Push the current aggregation.""" @@ -507,8 +501,7 @@ class LLMUserAggregator(LLMContextAggregator): async def _start(self, frame: StartFrame): await self._user_turn_controller.setup(self.task_manager) - if self._user_idle_controller: - await self._user_idle_controller.setup(self.task_manager) + await self._user_idle_controller.setup(self.task_manager) for s in self._params.user_mute_strategies: await s.setup(self.task_manager) @@ -541,9 +534,7 @@ class LLMUserAggregator(LLMContextAggregator): async def _cleanup(self): await self._user_turn_controller.cleanup() - - if self._user_idle_controller: - await self._user_idle_controller.cleanup() + await self._user_idle_controller.cleanup() for s in self._params.user_mute_strategies: await s.cleanup() @@ -689,8 +680,7 @@ class LLMUserAggregator(LLMContextAggregator): if params.enable_user_speaking_frames: await self.broadcast_frame(UserStartedSpeakingFrame) - if self._user_idle_controller: - await self._user_idle_controller.process_frame(UserStartedSpeakingFrame()) + await self._user_idle_controller.process_frame(UserStartedSpeakingFrame()) if params.enable_interruptions and self._allow_interruptions: await self.push_interruption_task_frame_and_wait() @@ -708,8 +698,7 @@ class LLMUserAggregator(LLMContextAggregator): if params.enable_user_speaking_frames: await self.broadcast_frame(UserStoppedSpeakingFrame) - if self._user_idle_controller: - await self._user_idle_controller.process_frame(UserStoppedSpeakingFrame()) + await self._user_idle_controller.process_frame(UserStoppedSpeakingFrame()) await self._maybe_emit_user_turn_stopped(strategy) diff --git a/src/pipecat/turns/user_idle_controller.py b/src/pipecat/turns/user_idle_controller.py index b4dc80772..b3b7e8074 100644 --- a/src/pipecat/turns/user_idle_controller.py +++ b/src/pipecat/turns/user_idle_controller.py @@ -16,6 +16,7 @@ from pipecat.frames.frames import ( FunctionCallCancelFrame, FunctionCallResultFrame, FunctionCallsStartedFrame, + UserIdleTimeoutUpdateFrame, UserStartedSpeakingFrame, UserStoppedSpeakingFrame, ) @@ -51,12 +52,13 @@ class UserIdleController(BaseObject): def __init__( self, *, - user_idle_timeout: float, + user_idle_timeout: float = 0, ): """Initialize the user idle controller. Args: user_idle_timeout: Timeout in seconds before considering the user idle. + 0 disables idle detection. """ super().__init__() @@ -96,6 +98,12 @@ class UserIdleController(BaseObject): Args: frame: The frame to be processed. """ + if isinstance(frame, UserIdleTimeoutUpdateFrame): + self._user_idle_timeout = frame.timeout + if self._user_idle_timeout <= 0: + await self._cancel_idle_timer() + return + if isinstance(frame, BotStoppedSpeakingFrame): # Only start the timer if the user isn't mid-turn and no function # calls are pending. @@ -128,6 +136,8 @@ class UserIdleController(BaseObject): async def _start_idle_timer(self): """Start (or restart) the idle timer.""" + if self._user_idle_timeout <= 0: + return await self._cancel_idle_timer() self._idle_timer_task = self.task_manager.create_task( self._idle_timer_expired(), diff --git a/src/pipecat/turns/user_turn_processor.py b/src/pipecat/turns/user_turn_processor.py index 720a8b854..7f8995202 100644 --- a/src/pipecat/turns/user_turn_processor.py +++ b/src/pipecat/turns/user_turn_processor.py @@ -66,7 +66,7 @@ class UserTurnProcessor(FrameProcessor): *, user_turn_strategies: Optional[UserTurnStrategies] = None, user_turn_stop_timeout: float = 5.0, - user_idle_timeout: Optional[float] = None, + user_idle_timeout: float = 0, **kwargs, ): """Initialize the user turn processor. @@ -75,9 +75,9 @@ class UserTurnProcessor(FrameProcessor): user_turn_strategies: Configured strategies for starting and stopping user turns. user_turn_stop_timeout: Timeout in seconds to automatically stop a user turn if no activity is detected. - user_idle_timeout: Optional timeout in seconds for detecting user idle state. - If set, the processor will emit an `on_user_turn_idle` event when the user - has been idle (not speaking) for this duration. Set to None to disable + user_idle_timeout: Timeout in seconds for detecting user idle state. + The processor will emit an `on_user_turn_idle` event when the user + has been idle (not speaking) for this duration. Set to 0 to disable idle detection. **kwargs: Additional keyword arguments. """ @@ -104,13 +104,8 @@ class UserTurnProcessor(FrameProcessor): "on_user_turn_stop_timeout", self._on_user_turn_stop_timeout ) - # Optional user idle controller - self._user_idle_controller: Optional[UserIdleController] = None - if user_idle_timeout: - self._user_idle_controller = UserIdleController(user_idle_timeout=user_idle_timeout) - self._user_idle_controller.add_event_handler( - "on_user_turn_idle", self._on_user_turn_idle - ) + self._user_idle_controller = UserIdleController(user_idle_timeout=user_idle_timeout) + self._user_idle_controller.add_event_handler("on_user_turn_idle", self._on_user_turn_idle) async def cleanup(self): """Clean up processor resources.""" @@ -149,14 +144,11 @@ class UserTurnProcessor(FrameProcessor): await self._user_turn_controller.process_frame(frame) - if self._user_idle_controller: - await self._user_idle_controller.process_frame(frame) + await self._user_idle_controller.process_frame(frame) async def _start(self, frame: StartFrame): await self._user_turn_controller.setup(self.task_manager) - - if self._user_idle_controller: - await self._user_idle_controller.setup(self.task_manager) + await self._user_idle_controller.setup(self.task_manager) async def _stop(self, frame: EndFrame): await self._cleanup() @@ -166,9 +158,7 @@ class UserTurnProcessor(FrameProcessor): async def _cleanup(self): await self._user_turn_controller.cleanup() - - if self._user_idle_controller: - await self._user_idle_controller.cleanup() + await self._user_idle_controller.cleanup() async def _on_push_frame( self, controller, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM @@ -189,8 +179,7 @@ class UserTurnProcessor(FrameProcessor): if params.enable_user_speaking_frames: await self.broadcast_frame(UserStartedSpeakingFrame) - if self._user_idle_controller: - await self._user_idle_controller.process_frame(UserStartedSpeakingFrame()) + await self._user_idle_controller.process_frame(UserStartedSpeakingFrame()) if params.enable_interruptions and self._allow_interruptions: await self.push_interruption_task_frame_and_wait() @@ -208,8 +197,7 @@ class UserTurnProcessor(FrameProcessor): if params.enable_user_speaking_frames: await self.broadcast_frame(UserStoppedSpeakingFrame) - if self._user_idle_controller: - await self._user_idle_controller.process_frame(UserStoppedSpeakingFrame()) + await self._user_idle_controller.process_frame(UserStoppedSpeakingFrame()) await self._call_event_handler("on_user_turn_stopped", strategy) diff --git a/tests/test_user_idle_controller.py b/tests/test_user_idle_controller.py index 6975d6e74..646223d37 100644 --- a/tests/test_user_idle_controller.py +++ b/tests/test_user_idle_controller.py @@ -13,6 +13,7 @@ from pipecat.frames.frames import ( BotStoppedSpeakingFrame, FunctionCallResultFrame, FunctionCallsStartedFrame, + UserIdleTimeoutUpdateFrame, UserStartedSpeakingFrame, ) from pipecat.turns.user_idle_controller import UserIdleController @@ -247,6 +248,76 @@ class TestUserIdleController(unittest.IsolatedAsyncioTestCase): await controller.cleanup() + async def test_disabled_by_default(self): + """Test that timeout=0 means idle detection is disabled.""" + controller = UserIdleController() + await controller.setup(self.task_manager) + + idle_triggered = False + + @controller.event_handler("on_user_turn_idle") + async def on_user_turn_idle(controller): + nonlocal idle_triggered + idle_triggered = True + + await controller.process_frame(BotStoppedSpeakingFrame()) + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + + self.assertFalse(idle_triggered) + + await controller.cleanup() + + async def test_enable_via_frame(self): + """Test enabling idle detection at runtime via UserIdleTimeoutUpdateFrame.""" + controller = UserIdleController() + await controller.setup(self.task_manager) + + idle_triggered = False + + @controller.event_handler("on_user_turn_idle") + async def on_user_turn_idle(controller): + nonlocal idle_triggered + idle_triggered = True + + # Initially disabled — no idle fires + await controller.process_frame(BotStoppedSpeakingFrame()) + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + self.assertFalse(idle_triggered) + + # Enable idle detection + await controller.process_frame(UserIdleTimeoutUpdateFrame(timeout=USER_IDLE_TIMEOUT)) + await controller.process_frame(BotStoppedSpeakingFrame()) + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + + self.assertTrue(idle_triggered) + + await controller.cleanup() + + async def test_disable_via_frame(self): + """Test disabling idle detection at runtime via UserIdleTimeoutUpdateFrame.""" + controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) + await controller.setup(self.task_manager) + + idle_triggered = False + + @controller.event_handler("on_user_turn_idle") + async def on_user_turn_idle(controller): + nonlocal idle_triggered + idle_triggered = True + + # Start the timer + await controller.process_frame(BotStoppedSpeakingFrame()) + await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3) + + # Disable — should cancel running timer + await controller.process_frame(UserIdleTimeoutUpdateFrame(timeout=0)) + + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + + self.assertFalse(idle_triggered) + + await controller.cleanup() + if __name__ == "__main__": unittest.main() From 36de6003d05de621e5a70d48eab4bbe911bd0093 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Mon, 16 Feb 2026 11:34:16 -0700 Subject: [PATCH 46/93] Switch Gradium TTS to AudioContextWordTTSService for multiplexing Use client_req_id-based multiplexing instead of disconnecting and reconnecting the websocket on every interruption. This follows the same pattern used by Cartesia, ElevenLabs, and other services via AudioContextWordTTSService. Key changes: - Base class: InterruptibleWordTTSService -> AudioContextWordTTSService - Add close_ws_on_eos: False to setup message to keep connection alive - Add client_req_id to text, end_of_stream messages for demultiplexing - Route audio via append_to_audio_context() instead of push_frame() - Silently drop messages for cancelled/unknown contexts on interruption - Add _handle_interruption() that resets context without reconnecting - Remove no-op push_frame() override --- src/pipecat/services/gradium/tts.py | 79 ++++++++++++++++++----------- 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/src/pipecat/services/gradium/tts.py b/src/pipecat/services/gradium/tts.py index 0e9865cf0..bde77f846 100644 --- a/src/pipecat/services/gradium/tts.py +++ b/src/pipecat/services/gradium/tts.py @@ -16,13 +16,14 @@ from pipecat.frames.frames import ( EndFrame, ErrorFrame, Frame, + InterruptionFrame, StartFrame, TTSAudioRawFrame, TTSStartedFrame, TTSStoppedFrame, ) from pipecat.processors.frame_processor import FrameDirection -from pipecat.services.tts_service import InterruptibleWordTTSService +from pipecat.services.tts_service import AudioContextWordTTSService from pipecat.utils.tracing.service_decorators import traced_tts try: @@ -37,7 +38,7 @@ except ModuleNotFoundError as e: SAMPLE_RATE = 48000 -class GradiumTTSService(InterruptibleWordTTSService): +class GradiumTTSService(AudioContextWordTTSService): """Text-to-Speech service using Gradium's websocket API.""" class InputParams(BaseModel): @@ -71,7 +72,6 @@ class GradiumTTSService(InterruptibleWordTTSService): params: Additional configuration parameters. **kwargs: Additional arguments passed to parent class. """ - # Initialize with parent class settings for proper frame handling super().__init__( push_stop_frames=True, pause_frame_processing=True, @@ -95,7 +95,7 @@ class GradiumTTSService(InterruptibleWordTTSService): # State tracking self._receive_task = None - self._current_context_id: Optional[str] = None + self._context_id: Optional[str] = None def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -126,7 +126,10 @@ class GradiumTTSService(InterruptibleWordTTSService): def _build_msg(self, text: str = "") -> dict: """Build JSON message for Gradium API.""" - return {"text": text, "type": "text"} + msg = {"text": text, "type": "text"} + if self._context_id: + msg["client_req_id"] = self._context_id + return msg async def start(self, frame: StartFrame): """Start the service and establish websocket connection. @@ -197,6 +200,7 @@ class GradiumTTSService(InterruptibleWordTTSService): "type": "setup", "output_format": "pcm", "voice_id": self._voice_id, + "close_ws_on_eos": False, } if self._json_config is not None: setup_msg["json_config"] = self._json_config @@ -234,18 +238,35 @@ class GradiumTTSService(InterruptibleWordTTSService): async def flush_audio(self): """Flush any pending audio synthesis.""" - if not self._websocket: + if not self._context_id or not self._websocket: return try: - msg = {"type": "end_of_stream"} + msg = {"type": "end_of_stream", "client_req_id": self._context_id} await self._websocket.send(json.dumps(msg)) + self._context_id = None except ConnectionClosedOK: logger.debug(f"{self}: connection closed normally during flush") except Exception as e: logger.error(f"{self} exception: {e}") + async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): + """Handle interruption by resetting context state. + + The parent AudioContextTTSService._handle_interruption() cancels the audio context + task and creates a new one. We reset _context_id so the next run_tts() creates a + fresh context. No websocket reconnection needed — audio from the old client_req_id + will be silently dropped since the audio context no longer exists. + + Args: + frame: The interruption frame. + direction: The direction of the frame. + """ + await super()._handle_interruption(frame, direction) + await self.stop_all_metrics() + self._context_id = None + async def _receive_messages(self): - """Process incoming websocket messages.""" + """Process incoming websocket messages, demultiplexing by client_req_id.""" # TODO(laurent): This should not be necessary as it should happen when # receiving the messages but this does not seem to always be the case # and that may lead to a busy polling loop. @@ -253,41 +274,35 @@ class GradiumTTSService(InterruptibleWordTTSService): raise ConnectionClosedOK(None, None) async for message in self._get_websocket(): msg = json.loads(message) + ctx_id = msg.get("client_req_id") if msg["type"] == "audio": - # Process audio chunk + if not ctx_id or not self.audio_context_available(ctx_id): + continue await self.stop_ttfb_metrics() await self.start_word_timestamps() frame = TTSAudioRawFrame( audio=base64.b64decode(msg["audio"]), sample_rate=self.sample_rate, num_channels=1, - context_id=self._current_context_id, + context_id=ctx_id, ) - await self.push_frame(frame) + await self.append_to_audio_context(ctx_id, frame) elif msg["type"] == "text": - if self._current_context_id: - await self.add_word_timestamps( - [(msg["text"], msg["start_s"])], self._current_context_id - ) + if ctx_id and self.audio_context_available(ctx_id): + await self.add_word_timestamps([(msg["text"], msg["start_s"])], ctx_id) + elif msg["type"] == "end_of_stream": - await self.push_frame(TTSStoppedFrame()) + if ctx_id and self.audio_context_available(ctx_id): + await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)], ctx_id) + await self.remove_audio_context(ctx_id) await self.stop_all_metrics() elif msg["type"] == "error": - await self.push_frame(TTSStoppedFrame()) + await self.push_frame(TTSStoppedFrame(context_id=ctx_id)) await self.stop_all_metrics() - await self.push_error(error_msg=f"Error: {msg['message']}") - - async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): - """Push frame and handle end-of-turn conditions. - - Args: - frame: The frame to push. - direction: The direction to push the frame. - """ - await super().push_frame(frame, direction) + await self.push_error(error_msg=f"Error: {msg.get('message', msg)}") @traced_tts async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: @@ -300,16 +315,18 @@ class GradiumTTSService(InterruptibleWordTTSService): Yields: Frame: Audio frames containing the synthesized speech. """ - _state = self._websocket.state if self._websocket is not None else None - logger.debug(f"{self}: Generating TTS [{text}] {_state}") + logger.debug(f"{self}: Generating TTS [{text}]") try: if not self._websocket or self._websocket.state is State.CLOSED: self._websocket = None await self._connect() try: - self._current_context_id = context_id - yield TTSStartedFrame(context_id=context_id) + if not self._context_id: + await self.start_ttfb_metrics() + yield TTSStartedFrame(context_id=context_id) + self._context_id = context_id + await self.create_audio_context(self._context_id) msg = self._build_msg(text=text) await self._get_websocket().send(json.dumps(msg)) From f181e12d8f458517e237db5c2ad8cfc0fa5cf084 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Mon, 16 Feb 2026 11:35:45 -0700 Subject: [PATCH 47/93] Add changelog for PR #3759 --- changelog/3759.performance.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3759.performance.md diff --git a/changelog/3759.performance.md b/changelog/3759.performance.md new file mode 100644 index 000000000..1bdc17a17 --- /dev/null +++ b/changelog/3759.performance.md @@ -0,0 +1 @@ +- Switched `GradiumTTSService` from `InterruptibleWordTTSService` to `AudioContextWordTTSService`, eliminating websocket disconnect/reconnect on every interruption by using `client_req_id`-based multiplexing. From b345f48ac119412c213472879dc704bbe6123344 Mon Sep 17 00:00:00 2001 From: Sam Sykes Date: Tue, 17 Feb 2026 09:55:43 +0000 Subject: [PATCH 48/93] fix: update dependency specifier for speechmatics-voice Change the version specifier from >=0.2.8 to ~=0.2.8 for the speechmatics-voice package to ensure compatibility with future patch versions. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 71baec1f1..28c1a7e8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,7 +114,7 @@ silero = [ "onnxruntime~=1.23.2" ] simli = [ "simli-ai~=1.0.3"] soniox = [ "pipecat-ai[websockets-base]" ] soundfile = [ "soundfile~=0.13.1" ] -speechmatics = [ "speechmatics-voice[smart]>=0.2.8" ] +speechmatics = [ "speechmatics-voice[smart]~=0.2.8" ] strands = [ "strands-agents>=1.9.1,<2" ] tavus=[] together = [] From 65fb88e61efcb09f08634eb0e72e892efc40b94d Mon Sep 17 00:00:00 2001 From: Sam Sykes Date: Tue, 17 Feb 2026 09:58:17 +0000 Subject: [PATCH 49/93] chore: update version specifier for speechmatics-voice Change the version specifier from `>=0.2.8` to `~=0.2.8` for the `speechmatics-voice` package. This ensures compatibility with future patch versions while preventing potential breaking changes from minor updates. --- changelog/3761.changed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3761.changed.md diff --git a/changelog/3761.changed.md b/changelog/3761.changed.md new file mode 100644 index 000000000..71618502c --- /dev/null +++ b/changelog/3761.changed.md @@ -0,0 +1 @@ +- Change the version specifier from `>=0.2.8` to `~=0.2.8` for the `speechmatics-voice` package to ensure compatibility with future patch versions. From 247f0bbcd33c995ad3b7da70780b0617c09f1e18 Mon Sep 17 00:00:00 2001 From: Luke Payyapilli Date: Tue, 17 Feb 2026 13:04:10 -0500 Subject: [PATCH 50/93] Fix async generator cleanup to prevent uvloop crash on Python 3.12+ --- changelog/3698.fixed.md | 1 + src/pipecat/services/openai/base_llm.py | 25 ++++++--- tests/test_openai_llm_timeout.py | 74 +++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 changelog/3698.fixed.md diff --git a/changelog/3698.fixed.md b/changelog/3698.fixed.md new file mode 100644 index 000000000..c040e9efb --- /dev/null +++ b/changelog/3698.fixed.md @@ -0,0 +1 @@ +- Fixed async generator cleanup in OpenAI LLM streaming to prevent `AttributeError` with uvloop on Python 3.12+ (MagicStack/uvloop#699). diff --git a/src/pipecat/services/openai/base_llm.py b/src/pipecat/services/openai/base_llm.py index 2cdde51ea..ebe9eda91 100644 --- a/src/pipecat/services/openai/base_llm.py +++ b/src/pipecat/services/openai/base_llm.py @@ -375,20 +375,29 @@ class BaseOpenAILLMService(LLMService): else self._stream_chat_completions_universal_context(context) ) - # Ensure stream is closed on cancellation/exception to prevent socket - # leaks. OpenAI's AsyncStream uses close(), async generators use aclose(). + # Ensure stream and its async iterator are closed on cancellation/exception + # to prevent socket leaks and uvloop crashes. Closing the iterator first + # cascades cleanup through nested async generators (httpx/httpcore internals), + # preventing uvloop's broken asyncgen finalizer from firing on Python 3.12+ + # (MagicStack/uvloop#699). @asynccontextmanager async def _closing(stream): + chunk_iter = stream.__aiter__() try: - yield stream + yield chunk_iter finally: - if hasattr(stream, "aclose"): - await stream.aclose() - elif hasattr(stream, "close"): + # Close the iterator first to cascade cleanup through + # nested async generators (httpx/httpcore internals). + if hasattr(chunk_iter, "aclose"): + await chunk_iter.aclose() + # Then close the stream to release HTTP resources. + if hasattr(stream, "close"): await stream.close() + elif hasattr(stream, "aclose"): + await stream.aclose() - async with _closing(chunk_stream): - async for chunk in chunk_stream: + async with _closing(chunk_stream) as chunk_iter: + async for chunk in chunk_iter: if chunk.usage: cached_tokens = ( chunk.usage.prompt_tokens_details.cached_tokens diff --git a/tests/test_openai_llm_timeout.py b/tests/test_openai_llm_timeout.py index f7876f02f..8ee776a9b 100644 --- a/tests/test_openai_llm_timeout.py +++ b/tests/test_openai_llm_timeout.py @@ -223,3 +223,77 @@ async def test_openai_llm_emits_error_frame_on_exception(): assert "Error during completion" in pushed_errors[0]["error_msg"] assert "API Error" in pushed_errors[0]["error_msg"] assert isinstance(pushed_errors[0]["exception"], RuntimeError) + + +@pytest.mark.asyncio +async def test_openai_llm_async_iterator_closed_on_stream_end(): + """Test that the async iterator is explicitly closed after stream consumption. + + This prevents uvloop's broken asyncgen finalizer from firing on Python 3.12+ + when async generators are garbage-collected without explicit cleanup. + See MagicStack/uvloop#699. + """ + with patch.object(OpenAILLMService, "create_client"): + service = OpenAILLMService(model="gpt-4") + service._client = AsyncMock() + + # Track if the iterator's aclose was called + iterator_aclosed = False + stream_closed = False + + class MockAsyncIterator: + """Mock async iterator that tracks aclose() calls.""" + + def __init__(self): + self.iteration_count = 0 + + def __aiter__(self): + return self + + async def __anext__(self): + self.iteration_count += 1 + if self.iteration_count > 2: + raise StopAsyncIteration() + # Return a minimal chunk + mock_chunk = AsyncMock() + mock_chunk.usage = None + mock_chunk.model = None + mock_chunk.choices = [] + return mock_chunk + + async def aclose(self): + nonlocal iterator_aclosed + iterator_aclosed = True + + class MockAsyncStream: + """Mock stream whose __aiter__ returns a separate iterator object.""" + + def __init__(self, iterator): + self._iterator = iterator + + def __aiter__(self): + return self._iterator + + async def close(self): + nonlocal stream_closed + stream_closed = True + + mock_iterator = MockAsyncIterator() + mock_stream = MockAsyncStream(mock_iterator) + + service._stream_chat_completions_specific_context = AsyncMock(return_value=mock_stream) + service._stream_chat_completions_universal_context = AsyncMock(return_value=mock_stream) + service.start_ttfb_metrics = AsyncMock() + service.stop_ttfb_metrics = AsyncMock() + service.start_llm_usage_metrics = AsyncMock() + + context = LLMContext( + messages=[{"role": "user", "content": "Hello"}], + ) + + await service._process_context(context) + + # Verify the iterator was explicitly closed (prevents uvloop crash) + assert iterator_aclosed, "Async iterator should be explicitly closed" + # Verify the stream was also closed (releases HTTP resources) + assert stream_closed, "Stream should be closed to release HTTP resources" From 80062239116b0c3bf071f9e9c0474397a2324694 Mon Sep 17 00:00:00 2001 From: Ian Lee Date: Tue, 17 Feb 2026 15:07:39 -0800 Subject: [PATCH 51/93] [inworld] default timestamp transport strategy to ASYNC --- changelog/3765.changed.md | 1 + src/pipecat/services/inworld/tts.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 changelog/3765.changed.md diff --git a/changelog/3765.changed.md b/changelog/3765.changed.md new file mode 100644 index 000000000..11c4866d5 --- /dev/null +++ b/changelog/3765.changed.md @@ -0,0 +1 @@ +- Update `InworldTTSService` and `InworldHttpTTSService` to use `ASYNC` timestamp transport strategy by default diff --git a/src/pipecat/services/inworld/tts.py b/src/pipecat/services/inworld/tts.py index 9f7c0cff1..9767c1b0a 100644 --- a/src/pipecat/services/inworld/tts.py +++ b/src/pipecat/services/inworld/tts.py @@ -70,7 +70,7 @@ class InworldHttpTTSService(WordTTSService): temperature: Optional[float] = None speaking_rate: Optional[float] = None - timestamp_transport_strategy: Optional[Literal["ASYNC", "SYNC"]] = None + timestamp_transport_strategy: Optional[Literal["ASYNC", "SYNC"]] = "ASYNC" def __init__( self, @@ -442,7 +442,7 @@ class InworldTTSService(AudioContextWordTTSService): max_buffer_delay_ms: Optional[int] = None buffer_char_threshold: Optional[int] = None auto_mode: Optional[bool] = True - timestamp_transport_strategy: Optional[Literal["ASYNC", "SYNC"]] = None + timestamp_transport_strategy: Optional[Literal["ASYNC", "SYNC"]] = "ASYNC" def __init__( self, From cd379671aa5a91d77afc5dbbb5a005fdb2b20623 Mon Sep 17 00:00:00 2001 From: Tanmay Chaudhari <7712083+tanmayc25@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:18:16 +0530 Subject: [PATCH 52/93] fix: use audio.sample_rate instead of audio.audio_frames in TavusInputTransport --- src/pipecat/transports/tavus/transport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pipecat/transports/tavus/transport.py b/src/pipecat/transports/tavus/transport.py index c8a79d386..dd63cb790 100644 --- a/src/pipecat/transports/tavus/transport.py +++ b/src/pipecat/transports/tavus/transport.py @@ -519,7 +519,7 @@ class TavusInputTransport(BaseInputTransport): """Handle received participant audio data.""" frame = InputAudioRawFrame( audio=audio.audio_frames, - sample_rate=audio.audio_frames, + sample_rate=audio.sample_rate, num_channels=audio.num_channels, ) frame.transport_source = audio_source From 6066eec8532a97387e7e3d71b8f729daff5b84d9 Mon Sep 17 00:00:00 2001 From: Tanmay Chaudhari <7712083+tanmayc25@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:31:16 +0530 Subject: [PATCH 53/93] Add changelog for PR #3768 --- changelog/3768.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3768.fixed.md diff --git a/changelog/3768.fixed.md b/changelog/3768.fixed.md new file mode 100644 index 000000000..4c8d6438e --- /dev/null +++ b/changelog/3768.fixed.md @@ -0,0 +1 @@ +- Fixed incorrect `sample_rate` assignment in `TavusInputTransport._on_participant_audio_data` (was using `audio.audio_frames` instead of `audio.sample_rate`). From 1daea78b913fe2ca4f64efc1038eaedcfefba9e7 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Wed, 18 Feb 2026 12:12:49 -0300 Subject: [PATCH 54/93] Fix GradiumTTSService to reuse context IDs across multiple run_tts calls and prevent the parent class from pushing text frames. --- src/pipecat/services/gradium/tts.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/pipecat/services/gradium/tts.py b/src/pipecat/services/gradium/tts.py index bde77f846..ef3cbbfde 100644 --- a/src/pipecat/services/gradium/tts.py +++ b/src/pipecat/services/gradium/tts.py @@ -6,6 +6,7 @@ import base64 import json +import uuid from typing import Any, AsyncGenerator, Mapping, Optional from loguru import logger @@ -74,6 +75,7 @@ class GradiumTTSService(AudioContextWordTTSService): """ super().__init__( push_stop_frames=True, + push_text_frames=False, pause_frame_processing=True, sample_rate=SAMPLE_RATE, **kwargs, @@ -304,6 +306,20 @@ class GradiumTTSService(AudioContextWordTTSService): await self.stop_all_metrics() await self.push_error(error_msg=f"Error: {msg.get('message', msg)}") + def create_context_id(self) -> str: + """Generate a unique context ID for a TTS request in case we don't have one already in progress. + + Returns: + A unique string identifier for the TTS context. + """ + # If a context ID does not exist, create a new one. + # If an ID exists, continue using the current ID. + # When interruptions happens, user speech results in + # an interruption, which resets the context ID. + if not self._context_id: + return str(uuid.uuid4()) + return self._context_id + @traced_tts async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Gradium's streaming API. From 39dc4ba99c90d9e228950e62cc845f36184aa461 Mon Sep 17 00:00:00 2001 From: Filipi da Silva Fuchter Date: Wed, 18 Feb 2026 16:58:27 -0500 Subject: [PATCH 55/93] Updated changelog/3765.changed.md --- changelog/3765.changed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/3765.changed.md b/changelog/3765.changed.md index 11c4866d5..5d3e758d5 100644 --- a/changelog/3765.changed.md +++ b/changelog/3765.changed.md @@ -1 +1 @@ -- Update `InworldTTSService` and `InworldHttpTTSService` to use `ASYNC` timestamp transport strategy by default +- Updated `InworldTTSService` and `InworldHttpTTSService` to use `ASYNC` timestamp transport strategy by default From fb1bfd03dd60872998218e45bd8748d794bff695 Mon Sep 17 00:00:00 2001 From: antonyesk601 Date: Thu, 19 Feb 2026 10:35:50 +0000 Subject: [PATCH 56/93] update SimliClient to latest --- pyproject.toml | 2 +- src/pipecat/services/simli/video.py | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 28c1a7e8c..e71202252 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,7 @@ sambanova = [] sarvam = [ "sarvamai==0.1.21", "pipecat-ai[websockets-base]" ] sentry = [ "sentry-sdk>=2.28.0,<3" ] silero = [ "onnxruntime~=1.23.2" ] -simli = [ "simli-ai~=1.0.3"] +simli = [ "simli-ai~=2.0.1"] soniox = [ "pipecat-ai[websockets-base]" ] soundfile = [ "soundfile~=0.13.1" ] speechmatics = [ "speechmatics-voice[smart]~=0.2.8" ] diff --git a/src/pipecat/services/simli/video.py b/src/pipecat/services/simli/video.py index b1f7961af..9fc3665b6 100644 --- a/src/pipecat/services/simli/video.py +++ b/src/pipecat/services/simli/video.py @@ -6,6 +6,8 @@ """Simli video service for real-time avatar generation.""" +from cgitb import enable + import asyncio import warnings from typing import Optional @@ -131,7 +133,6 @@ class SimliVideoService(FrameProcessor): # Build SimliConfig from new parameters # Only pass optional parameters if explicitly provided to use SimliConfig defaults config_kwargs = { - "apiKey": api_key, "faceId": face_id, } if params.max_session_length is not None: @@ -153,10 +154,10 @@ class SimliVideoService(FrameProcessor): config.maxIdleTime += 5 config.maxSessionLength += 5 self._simli_client = SimliClient( + api_key=api_key, config=config, - latencyInterval=latency_interval, simliURL=simli_url, - enable_logging=params.enable_logging or False, + enableSFU=True, ) self._pipecat_resampler: AudioResampler = None @@ -173,7 +174,7 @@ class SimliVideoService(FrameProcessor): """Start the connection to Simli service and begin processing tasks.""" try: if not self._initialized: - await self._simli_client.Initialize() + await self._simli_client.start() self._initialized = True # Create task to consume and process audio and video From a55ba4092113426c03ea901221d7f59334c721f4 Mon Sep 17 00:00:00 2001 From: antonyesk601 Date: Thu, 19 Feb 2026 10:41:17 +0000 Subject: [PATCH 57/93] fix: remove misimport --- src/pipecat/services/simli/video.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pipecat/services/simli/video.py b/src/pipecat/services/simli/video.py index 9fc3665b6..880b40428 100644 --- a/src/pipecat/services/simli/video.py +++ b/src/pipecat/services/simli/video.py @@ -6,8 +6,6 @@ """Simli video service for real-time avatar generation.""" -from cgitb import enable - import asyncio import warnings from typing import Optional From 92c380ee7718708aa6cac7ade4836790c3897dfb Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 19 Feb 2026 07:01:07 -0700 Subject: [PATCH 58/93] Add apt-get update before installing system packages in CI The CI was failing because the runner's package index was stale, causing a 404 when fetching libasound2-dev (a dependency of portaudio19-dev). Running apt-get update first refreshes the index. --- .github/workflows/coverage.yaml | 1 + .github/workflows/tests.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index b78067c97..df9c388bf 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -29,6 +29,7 @@ jobs: - name: Install system packages run: | + sudo apt-get update sudo apt-get install -y portaudio19-dev - name: Install dependencies diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 5bdfb94f4..5941448f3 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -33,6 +33,7 @@ jobs: - name: Install system packages run: | + sudo apt-get update sudo apt-get install -y portaudio19-dev - name: Install dependencies From 63df4642b5fdcf01df84185795986f2b2bbeafa6 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 19 Feb 2026 07:43:20 -0700 Subject: [PATCH 59/93] Fix RTVIObserver missing upstream-only frames by adding broadcasted flag RTVIObserver previously filtered out all upstream frames to avoid duplicate messages from broadcasted frames. This caused upstream-only frames to be silently ignored. Instead, add a `broadcasted` field to the Frame base class that is set by broadcast_frame() and broadcast_frame_instance(), and only skip upstream copies of broadcasted frames. --- src/pipecat/frames/frames.py | 2 ++ src/pipecat/processors/frame_processor.py | 10 ++++++++-- src/pipecat/processors/frameworks/rtvi.py | 7 +++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/pipecat/frames/frames.py b/src/pipecat/frames/frames.py index 8d237defc..c3f6226c6 100644 --- a/src/pipecat/frames/frames.py +++ b/src/pipecat/frames/frames.py @@ -131,6 +131,7 @@ class Frame: id: int = field(init=False) name: str = field(init=False) pts: Optional[int] = field(init=False) + broadcasted: bool = field(init=False) metadata: Dict[str, Any] = field(init=False) transport_source: Optional[str] = field(init=False) transport_destination: Optional[str] = field(init=False) @@ -139,6 +140,7 @@ class Frame: self.id: int = obj_id() self.name: str = f"{self.__class__.__name__}#{obj_count(self)}" self.pts: Optional[int] = None + self.broadcasted: bool = False self.metadata: Dict[str, Any] = {} self.transport_source: Optional[str] = None self.transport_destination: Optional[str] = None diff --git a/src/pipecat/processors/frame_processor.py b/src/pipecat/processors/frame_processor.py index f0c9e7183..48c8c718c 100644 --- a/src/pipecat/processors/frame_processor.py +++ b/src/pipecat/processors/frame_processor.py @@ -787,8 +787,12 @@ class FrameProcessor(BaseObject): frame_cls: The class of the frame to be broadcasted. **kwargs: Keyword arguments to be passed to the frame's constructor. """ - await self.push_frame(frame_cls(**kwargs)) - await self.push_frame(frame_cls(**kwargs), FrameDirection.UPSTREAM) + downstream_frame = frame_cls(**kwargs) + downstream_frame.broadcasted = True + await self.push_frame(downstream_frame) + upstream_frame = frame_cls(**kwargs) + upstream_frame.broadcasted = True + await self.push_frame(upstream_frame, FrameDirection.UPSTREAM) async def broadcast_frame_instance(self, frame: Frame): """Broadcasts a frame instance upstream and downstream. @@ -815,11 +819,13 @@ class FrameProcessor(BaseObject): new_frame = frame_cls(**init_fields) for k, v in extra_fields.items(): setattr(new_frame, k, v) + new_frame.broadcasted = True await self.push_frame(new_frame) new_frame = frame_cls(**init_fields) for k, v in extra_fields.items(): setattr(new_frame, k, v) + new_frame.broadcasted = True await self.push_frame(new_frame, FrameDirection.UPSTREAM) async def __start(self, frame: StartFrame): diff --git a/src/pipecat/processors/frameworks/rtvi.py b/src/pipecat/processors/frameworks/rtvi.py index c1497b40b..8c7bb15ef 100644 --- a/src/pipecat/processors/frameworks/rtvi.py +++ b/src/pipecat/processors/frameworks/rtvi.py @@ -1220,10 +1220,9 @@ class RTVIObserver(BaseObserver): frame = data.frame direction = data.direction - # Only process downstream frames. Some frames are broadcast in both - # directions (e.g. UserStartedSpeakingFrame, FunctionCallResultFrame), - # and we only want to send one RTVI message per event. - if direction != FrameDirection.DOWNSTREAM: + # For broadcasted frames (pushed in both directions), only process + # the downstream copy to avoid sending duplicate RTVI messages. + if frame.broadcasted and direction != FrameDirection.DOWNSTREAM: return # If we have already seen this frame, let's skip it. From 50ef4909e3a97020b103d27716d3a7c9c9e28a10 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 19 Feb 2026 07:44:52 -0700 Subject: [PATCH 60/93] Add changelog entries for PR #3774 --- changelog/3774.added.md | 1 + changelog/3774.fixed.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 changelog/3774.added.md create mode 100644 changelog/3774.fixed.md diff --git a/changelog/3774.added.md b/changelog/3774.added.md new file mode 100644 index 000000000..032d8238f --- /dev/null +++ b/changelog/3774.added.md @@ -0,0 +1 @@ +- Added `broadcasted` field to the base `Frame` class. This field is automatically set to `True` by `broadcast_frame()` and `broadcast_frame_instance()` to distinguish broadcasted frames from single-direction frames. diff --git a/changelog/3774.fixed.md b/changelog/3774.fixed.md new file mode 100644 index 000000000..a839f56ed --- /dev/null +++ b/changelog/3774.fixed.md @@ -0,0 +1 @@ +- Fixed `RTVIObserver` not processing upstream-only frames. Previously, all upstream frames were filtered out to avoid duplicate messages from broadcasted frames. Now only upstream copies of broadcasted frames are skipped. From 200716e8fecb1a3265f59680244d7bf35d8cada6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Wed, 11 Feb 2026 11:41:31 -0800 Subject: [PATCH 61/93] Add SIP transfer and SIP REFER frames to Daily transport Add write_transport_frame() hook to BaseOutputTransport so subclasses can handle custom frame types that flow through the audio queue. Add DailySIPTransferFrame and DailySIPReferFrame as DataFrame subclasses that queue with audio, ensuring SIP operations execute only after the bot finishes its current utterance. Override write_transport_frame in DailyOutputTransport to dispatch these frames to the existing sip_call_transfer() and sip_refer() client methods. Also switch DailyOutputTransport.send_message error handling from logger.error to push_error for consistency. --- src/pipecat/transports/base_output.py | 14 +++++++ src/pipecat/transports/daily/transport.py | 50 ++++++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/pipecat/transports/base_output.py b/src/pipecat/transports/base_output.py index c4f59a61d..6fe31aab5 100644 --- a/src/pipecat/transports/base_output.py +++ b/src/pipecat/transports/base_output.py @@ -237,6 +237,18 @@ class BaseOutputTransport(FrameProcessor): else: await self._write_dtmf_audio(frame) + async def write_transport_frame(self, frame: Frame): + """Handle a queued frame after preceding audio has been sent. + + Override in transport subclasses to handle custom frame types that + flow through the audio queue. Called by the media sender after the + frame has waited for any preceding audio to finish. + + Args: + frame: The frame to handle. + """ + pass + def _supports_native_dtmf(self) -> bool: """Override in transport implementations that support native DTMF. @@ -681,6 +693,8 @@ class BaseOutputTransport(FrameProcessor): await self._transport.send_message(frame) elif isinstance(frame, OutputDTMFFrame): await self._transport.write_dtmf(frame) + else: + await self._transport.write_transport_frame(frame) def _next_frame(self) -> AsyncGenerator[Frame, None]: """Generate the next frame for audio processing. diff --git a/src/pipecat/transports/daily/transport.py b/src/pipecat/transports/daily/transport.py index e3b246253..fd3f4e30b 100644 --- a/src/pipecat/transports/daily/transport.py +++ b/src/pipecat/transports/daily/transport.py @@ -15,7 +15,7 @@ import asyncio import time from concurrent.futures import CancelledError as FuturesCancelledError from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Awaitable, Callable, Dict, Mapping, Optional, Tuple import aiohttp @@ -26,6 +26,7 @@ from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADParams from pipecat.frames.frames import ( CancelFrame, ControlFrame, + DataFrame, EndFrame, Frame, InputAudioRawFrame, @@ -182,6 +183,36 @@ class DailyInputTransportMessageUrgentFrame(DailyInputTransportMessageFrame): ) +@dataclass +class DailySIPTransferFrame(DataFrame): + """SIP call transfer frame for transport queuing. + + A SIP call transfer that will be queued. The transfer will happen after any + preceding audio finishes playing, allowing the bot to complete its current + utterance before the transfer occurs. + + Parameters: + settings: SIP call transfer settings. + """ + + settings: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass +class DailySIPReferFrame(DataFrame): + """SIP REFER frame for transport queuing. + + A SIP REFER that will be queued. The REFER will happen after any preceding + audio finishes playing, allowing the bot to complete its current utterance + before the REFER occurs. + + Parameters: + settings: SIP REFER settings. + """ + + settings: Mapping[str, Any] = field(default_factory=dict) + + @dataclass class DailyUpdateRemoteParticipantsFrame(ControlFrame): """Frame to update remote participants in Daily calls. @@ -1968,7 +1999,7 @@ class DailyOutputTransport(BaseOutputTransport): """ error = await self._client.send_message(frame) if error: - logger.error(f"Unable to send message: {error}") + await self.push_error(f"{self}: Unable to send message: {error}") async def register_video_destination(self, destination: str): """Register a video output destination. @@ -2011,6 +2042,21 @@ class DailyOutputTransport(BaseOutputTransport): """ return await self._client.write_video_frame(frame) + async def write_transport_frame(self, frame: Frame): + """Handle queued SIP frames after preceding audio has been sent. + + Args: + frame: The frame to handle. + """ + if isinstance(frame, DailySIPTransferFrame): + error = await self._client.sip_call_transfer(frame.settings) + if error: + await self.push_error(f"{self}: Unable to transfer SIP call: {error}") + elif isinstance(frame, DailySIPReferFrame): + error = await self._client.sip_refer(frame.settings) + if error: + await self.push_error(f"{self}: Unable to perform SIP REFER: {error}") + def _supports_native_dtmf(self) -> bool: """Daily supports native DTMF via telephone events. From 7501ba2e45f2c4d9521b621ef5390c7819da5e7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Wed, 11 Feb 2026 11:58:14 -0800 Subject: [PATCH 62/93] Undeprecate DailyUpdateRemoteParticipantsFrame Remove the deprecation warning and __post_init__ override. Also fix the default value for remote_participants to use field(default_factory=dict) instead of None. --- changelog/3719.changed.md | 1 + src/pipecat/transports/daily/transport.py | 47 +++++------------------ 2 files changed, 10 insertions(+), 38 deletions(-) create mode 100644 changelog/3719.changed.md diff --git a/changelog/3719.changed.md b/changelog/3719.changed.md new file mode 100644 index 000000000..f42d0303b --- /dev/null +++ b/changelog/3719.changed.md @@ -0,0 +1 @@ +- `DailyUpdateRemoteParticipantsFrame` is no longer deprecated and is now queued with audio like other transport frames. diff --git a/src/pipecat/transports/daily/transport.py b/src/pipecat/transports/daily/transport.py index fd3f4e30b..5df7bcffd 100644 --- a/src/pipecat/transports/daily/transport.py +++ b/src/pipecat/transports/daily/transport.py @@ -25,7 +25,6 @@ from pydantic import BaseModel from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADParams from pipecat.frames.frames import ( CancelFrame, - ControlFrame, DataFrame, EndFrame, Frame, @@ -214,34 +213,14 @@ class DailySIPReferFrame(DataFrame): @dataclass -class DailyUpdateRemoteParticipantsFrame(ControlFrame): +class DailyUpdateRemoteParticipantsFrame(DataFrame): """Frame to update remote participants in Daily calls. - .. deprecated:: 0.0.87 - `DailyUpdateRemoteParticipantsFrame` is deprecated and will be removed in a future version. - Create your own custom frame and use a custom processor to handle it or use, for example, - `on_after_push_frame` event instead in the output transport. - Parameters: remote_participants: See https://reference-python.daily.co/api_reference.html#daily.CallClient.update_remote_participants. """ - remote_participants: Mapping[str, Any] = None - - def __post_init__(self): - super().__post_init__() - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "DailyUpdateRemoteParticipantsFrame is deprecated and will be removed in a future version." - "Instead, create your own custom frame and handle it in the " - '`@transport.output().event_handler("on_after_push_frame")` event handler or a ' - "custom processor.", - DeprecationWarning, - stacklevel=2, - ) + remote_participants: Mapping[str, Any] = field(default_factory=dict) class WebRTCVADAnalyzer(VADAnalyzer): @@ -1977,18 +1956,6 @@ class DailyOutputTransport(BaseOutputTransport): # Leave the room. await self._client.leave() - async def process_frame(self, frame: Frame, direction: FrameDirection): - """Process outgoing frames, including transport messages. - - Args: - frame: The frame to process. - direction: The direction of frame flow in the pipeline. - """ - await super().process_frame(frame, direction) - - if isinstance(frame, DailyUpdateRemoteParticipantsFrame): - await self._client.update_remote_participants(frame.remote_participants) - async def send_message( self, frame: OutputTransportMessageFrame | OutputTransportMessageUrgentFrame ): @@ -1999,7 +1966,7 @@ class DailyOutputTransport(BaseOutputTransport): """ error = await self._client.send_message(frame) if error: - await self.push_error(f"{self}: Unable to send message: {error}") + await self.push_error(f"Unable to send message: {error}") async def register_video_destination(self, destination: str): """Register a video output destination. @@ -2051,11 +2018,15 @@ class DailyOutputTransport(BaseOutputTransport): if isinstance(frame, DailySIPTransferFrame): error = await self._client.sip_call_transfer(frame.settings) if error: - await self.push_error(f"{self}: Unable to transfer SIP call: {error}") + await self.push_error(f"Unable to transfer SIP call: {error}") elif isinstance(frame, DailySIPReferFrame): error = await self._client.sip_refer(frame.settings) if error: - await self.push_error(f"{self}: Unable to perform SIP REFER: {error}") + await self.push_error(f"Unable to perform SIP REFER: {error}") + elif isinstance(frame, DailyUpdateRemoteParticipantsFrame): + error = await self._client.update_remote_participants(frame.remote_participants) + if error: + await self.push_error(f"Unable to update remote participants: {error}") def _supports_native_dtmf(self) -> bool: """Daily supports native DTMF via telephone events. From baa61468a1848e4023b027b7fbbd277466a7876e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Wed, 11 Feb 2026 12:09:12 -0800 Subject: [PATCH 63/93] Add changelog entries for PR #3719 --- changelog/3719.added.2.md | 1 + changelog/3719.added.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 changelog/3719.added.2.md create mode 100644 changelog/3719.added.md diff --git a/changelog/3719.added.2.md b/changelog/3719.added.2.md new file mode 100644 index 000000000..77d8956d7 --- /dev/null +++ b/changelog/3719.added.2.md @@ -0,0 +1 @@ +- Added `write_transport_frame()` hook to `BaseOutputTransport` allowing transport subclasses to handle custom frame types that flow through the audio queue. diff --git a/changelog/3719.added.md b/changelog/3719.added.md new file mode 100644 index 000000000..bc1c2d6b1 --- /dev/null +++ b/changelog/3719.added.md @@ -0,0 +1 @@ +- Added `DailySIPTransferFrame` and `DailySIPReferFrame` to the Daily transport. These frames queue SIP transfer and SIP REFER operations with audio, so the operation executes only after the bot finishes its current utterance. From 352361bdd2d6abfb85b875883603f2152f3411b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Wed, 11 Feb 2026 12:09:07 -0800 Subject: [PATCH 64/93] Update changelog skill to avoid line wrapping --- .claude/skills/changelog/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/changelog/SKILL.md b/.claude/skills/changelog/SKILL.md index 89e13a40e..1ef8f324e 100644 --- a/.claude/skills/changelog/SKILL.md +++ b/.claude/skills/changelog/SKILL.md @@ -26,7 +26,7 @@ Create changelog files for the important commits in this PR. The PR number is pr - `{PR_NUMBER}.performance.md` - for performance improvements - `{PR_NUMBER}.other.md` - for other changes -4. Each changelog file should at least contain a main single line starting with `- ` followed by a clear description of the change. +4. Each changelog file should at least contain a main single line starting with `- ` followed by a clear description of the change. No line wrapping. 5. If the change is complicated, changelog files can have indented lines after the main line with additional details or code samples. From b1cee140b9cabc6fb7bc78727a85e47a2251d134 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Thu, 19 Feb 2026 15:06:50 -0300 Subject: [PATCH 65/93] Refactoring to use broadcasted_sibling_id instead of broadcasted field. --- changelog/3774.added.md | 2 +- src/pipecat/frames/frames.py | 7 +++++-- src/pipecat/processors/frame_processor.py | 23 ++++++++++++----------- src/pipecat/processors/frameworks/rtvi.py | 2 +- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/changelog/3774.added.md b/changelog/3774.added.md index 032d8238f..8e442999c 100644 --- a/changelog/3774.added.md +++ b/changelog/3774.added.md @@ -1 +1 @@ -- Added `broadcasted` field to the base `Frame` class. This field is automatically set to `True` by `broadcast_frame()` and `broadcast_frame_instance()` to distinguish broadcasted frames from single-direction frames. +- Added `broadcasted_sibling_id` field to the base `Frame` class. This field is automatically set by `broadcast_frame()` and `broadcast_frame_instance()` to the ID of the paired frame pushed in the opposite direction, allowing receivers to identify broadcast pairs. diff --git a/src/pipecat/frames/frames.py b/src/pipecat/frames/frames.py index c3f6226c6..e6bc008ab 100644 --- a/src/pipecat/frames/frames.py +++ b/src/pipecat/frames/frames.py @@ -123,6 +123,9 @@ class Frame: id: Unique identifier for the frame instance. name: Human-readable name combining class name and instance count. pts: Presentation timestamp in nanoseconds. + broadcasted_sibling_id: ID of the paired frame when this frame was + broadcast in both directions. Set automatically by + ``broadcast_frame()`` and ``broadcast_frame_instance()``. metadata: Dictionary for arbitrary frame metadata. transport_source: Name of the transport source that created this frame. transport_destination: Name of the transport destination for this frame. @@ -131,7 +134,7 @@ class Frame: id: int = field(init=False) name: str = field(init=False) pts: Optional[int] = field(init=False) - broadcasted: bool = field(init=False) + broadcasted_sibling_id: Optional[int] = field(init=False) metadata: Dict[str, Any] = field(init=False) transport_source: Optional[str] = field(init=False) transport_destination: Optional[str] = field(init=False) @@ -140,7 +143,7 @@ class Frame: self.id: int = obj_id() self.name: str = f"{self.__class__.__name__}#{obj_count(self)}" self.pts: Optional[int] = None - self.broadcasted: bool = False + self.broadcasted_sibling_id: Optional[int] = None self.metadata: Dict[str, Any] = {} self.transport_source: Optional[str] = None self.transport_destination: Optional[str] = None diff --git a/src/pipecat/processors/frame_processor.py b/src/pipecat/processors/frame_processor.py index 48c8c718c..6bdd9ae1d 100644 --- a/src/pipecat/processors/frame_processor.py +++ b/src/pipecat/processors/frame_processor.py @@ -788,10 +788,10 @@ class FrameProcessor(BaseObject): **kwargs: Keyword arguments to be passed to the frame's constructor. """ downstream_frame = frame_cls(**kwargs) - downstream_frame.broadcasted = True - await self.push_frame(downstream_frame) upstream_frame = frame_cls(**kwargs) - upstream_frame.broadcasted = True + downstream_frame.broadcasted_sibling_id = upstream_frame.id + upstream_frame.broadcasted_sibling_id = downstream_frame.id + await self.push_frame(downstream_frame) await self.push_frame(upstream_frame, FrameDirection.UPSTREAM) async def broadcast_frame_instance(self, frame: Frame): @@ -816,17 +816,18 @@ class FrameProcessor(BaseObject): if not f.init and f.name not in ("id", "name") } - new_frame = frame_cls(**init_fields) + downstream_frame = frame_cls(**init_fields) for k, v in extra_fields.items(): - setattr(new_frame, k, v) - new_frame.broadcasted = True - await self.push_frame(new_frame) + setattr(downstream_frame, k, v) - new_frame = frame_cls(**init_fields) + upstream_frame = frame_cls(**init_fields) for k, v in extra_fields.items(): - setattr(new_frame, k, v) - new_frame.broadcasted = True - await self.push_frame(new_frame, FrameDirection.UPSTREAM) + setattr(upstream_frame, k, v) + + downstream_frame.broadcasted_sibling_id = upstream_frame.id + upstream_frame.broadcasted_sibling_id = downstream_frame.id + await self.push_frame(downstream_frame) + await self.push_frame(upstream_frame, FrameDirection.UPSTREAM) async def __start(self, frame: StartFrame): """Handle the start frame to initialize processor state. diff --git a/src/pipecat/processors/frameworks/rtvi.py b/src/pipecat/processors/frameworks/rtvi.py index 8c7bb15ef..55be46452 100644 --- a/src/pipecat/processors/frameworks/rtvi.py +++ b/src/pipecat/processors/frameworks/rtvi.py @@ -1222,7 +1222,7 @@ class RTVIObserver(BaseObserver): # For broadcasted frames (pushed in both directions), only process # the downstream copy to avoid sending duplicate RTVI messages. - if frame.broadcasted and direction != FrameDirection.DOWNSTREAM: + if frame.broadcasted_sibling_id is not None and direction != FrameDirection.DOWNSTREAM: return # If we have already seen this frame, let's skip it. From d608c400f93eba752aee43d534a4d5f089bf6555 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Thu, 19 Feb 2026 15:49:22 -0300 Subject: [PATCH 66/93] Preventing the duplicated BotStartedSpeakingFrame and BotStoppedSpeakingFrame. --- src/pipecat/transports/base_output.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/pipecat/transports/base_output.py b/src/pipecat/transports/base_output.py index c4f59a61d..4f072c0df 100644 --- a/src/pipecat/transports/base_output.py +++ b/src/pipecat/transports/base_output.py @@ -613,6 +613,11 @@ class BaseOutputTransport(FrameProcessor): downstream_frame.transport_destination = self._destination upstream_frame = BotStartedSpeakingFrame() upstream_frame.transport_destination = self._destination + + # Setting the siblings id + upstream_frame.broadcasted_sibling_id = downstream_frame.id + downstream_frame.broadcasted_sibling_id = upstream_frame.id + await self._transport.push_frame(downstream_frame) await self._transport.push_frame(upstream_frame, FrameDirection.UPSTREAM) @@ -635,6 +640,11 @@ class BaseOutputTransport(FrameProcessor): downstream_frame.transport_destination = self._destination upstream_frame = BotStoppedSpeakingFrame() upstream_frame.transport_destination = self._destination + + # Setting the siblings id + upstream_frame.broadcasted_sibling_id = downstream_frame.id + downstream_frame.broadcasted_sibling_id = upstream_frame.id + await self._transport.push_frame(downstream_frame) await self._transport.push_frame(upstream_frame, FrameDirection.UPSTREAM) From 859cd7c9205c5675903784af5503fcfe79245dbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Thu, 19 Feb 2026 10:52:24 -0800 Subject: [PATCH 67/93] Refactor STT TTFB metrics to use base class start/stop pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminate custom _emit_stt_ttfb_metric and manual timestamp tracking in STTService by reusing FrameProcessor's start_ttfb_metrics/stop_ttfb_metrics with new start_time/end_time parameters. This keeps the chronological start→stop ordering and removes _speech_end_time and _last_transcription_time state from STTService. --- src/pipecat/processors/frame_processor.py | 50 +++++++++++++------ .../metrics/frame_processor_metrics.py | 43 ++++++++++++---- src/pipecat/services/stt_service.py | 48 +++--------------- 3 files changed, 74 insertions(+), 67 deletions(-) diff --git a/src/pipecat/processors/frame_processor.py b/src/pipecat/processors/frame_processor.py index f0c9e7183..7423c4845 100644 --- a/src/pipecat/processors/frame_processor.py +++ b/src/pipecat/processors/frame_processor.py @@ -419,27 +419,49 @@ class FrameProcessor(BaseObject): """ self._metrics.set_core_metrics_data(data) - async def start_ttfb_metrics(self): - """Start time-to-first-byte metrics collection.""" - if self.can_generate_metrics() and self.metrics_enabled: - await self._metrics.start_ttfb_metrics(self._report_only_initial_ttfb) + async def start_ttfb_metrics(self, *, start_time: Optional[float] = None): + """Start time-to-first-byte metrics collection. - async def stop_ttfb_metrics(self): - """Stop time-to-first-byte metrics collection and push results.""" + Args: + start_time: Optional timestamp to use as the start time. If None, + uses the current time. + """ if self.can_generate_metrics() and self.metrics_enabled: - frame = await self._metrics.stop_ttfb_metrics() + await self._metrics.start_ttfb_metrics( + start_time=start_time, report_only_initial_ttfb=self._report_only_initial_ttfb + ) + + async def stop_ttfb_metrics(self, *, end_time: Optional[float] = None): + """Stop time-to-first-byte metrics collection and push results. + + Args: + end_time: Optional timestamp to use as the end time. If None, uses + the current time. + """ + if self.can_generate_metrics() and self.metrics_enabled: + frame = await self._metrics.stop_ttfb_metrics(end_time=end_time) if frame: await self.push_frame(frame) - async def start_processing_metrics(self): - """Start processing metrics collection.""" - if self.can_generate_metrics() and self.metrics_enabled: - await self._metrics.start_processing_metrics() + async def start_processing_metrics(self, *, start_time: Optional[float] = None): + """Start processing metrics collection. - async def stop_processing_metrics(self): - """Stop processing metrics collection and push results.""" + Args: + start_time: Optional timestamp to use as the start time. If None, + uses the current time. + """ if self.can_generate_metrics() and self.metrics_enabled: - frame = await self._metrics.stop_processing_metrics() + await self._metrics.start_processing_metrics(start_time=start_time) + + async def stop_processing_metrics(self, *, end_time: Optional[float] = None): + """Stop processing metrics collection and push results. + + Args: + end_time: Optional timestamp to use as the end time. If None, uses + the current time. + """ + if self.can_generate_metrics() and self.metrics_enabled: + frame = await self._metrics.stop_processing_metrics(end_time=end_time) if frame: await self.push_frame(frame) diff --git a/src/pipecat/processors/metrics/frame_processor_metrics.py b/src/pipecat/processors/metrics/frame_processor_metrics.py index b8beba6e2..c82fd9698 100644 --- a/src/pipecat/processors/metrics/frame_processor_metrics.py +++ b/src/pipecat/processors/metrics/frame_processor_metrics.py @@ -107,49 +107,70 @@ class FrameProcessorMetrics(BaseObject): """ self._core_metrics_data = MetricsData(processor=name) - async def start_ttfb_metrics(self, report_only_initial_ttfb): + async def start_ttfb_metrics( + self, *, start_time: Optional[float] = None, report_only_initial_ttfb: bool + ): """Start measuring time-to-first-byte (TTFB). Args: + start_time: Optional timestamp to use as the start time. If None, + uses the current time. report_only_initial_ttfb: Whether to report only the first TTFB measurement. """ if self._should_report_ttfb: - self._start_ttfb_time = time.time() + self._start_ttfb_time = start_time or time.time() self._last_ttfb_time = 0 self._should_report_ttfb = not report_only_initial_ttfb - async def stop_ttfb_metrics(self): + async def stop_ttfb_metrics(self, *, end_time: Optional[float] = None): """Stop TTFB measurement and generate metrics frame. + Args: + end_time: Optional timestamp to use as the end time. If None, uses + the current time. + Returns: MetricsFrame containing TTFB data, or None if not measuring. """ if self._start_ttfb_time == 0: return None - self._last_ttfb_time = time.time() - self._start_ttfb_time - logger.debug(f"{self._processor_name()} TTFB: {self._last_ttfb_time}") + end_time = end_time or time.time() + + self._last_ttfb_time = end_time - self._start_ttfb_time + logger.debug(f"{self._processor_name()} TTFB: {self._last_ttfb_time:.3f}s") ttfb = TTFBMetricsData( processor=self._processor_name(), value=self._last_ttfb_time, model=self._model_name() ) self._start_ttfb_time = 0 return MetricsFrame(data=[ttfb]) - async def start_processing_metrics(self): - """Start measuring processing time.""" - self._start_processing_time = time.time() + async def start_processing_metrics(self, *, start_time: Optional[float] = None): + """Start measuring processing time. - async def stop_processing_metrics(self): + Args: + start_time: Optional timestamp to use as the start time. If None, + uses the current time. + """ + self._start_processing_time = start_time or time.time() + + async def stop_processing_metrics(self, *, end_time: Optional[float] = None): """Stop processing time measurement and generate metrics frame. + Args: + end_time: Optional timestamp to use as the end time. If None, uses + the current time. + Returns: MetricsFrame containing processing duration data, or None if not measuring. """ if self._start_processing_time == 0: return None - value = time.time() - self._start_processing_time - logger.debug(f"{self._processor_name()} processing time: {value}") + end_time = end_time or time.time() + + value = end_time - self._start_processing_time + logger.debug(f"{self._processor_name()} processing time: {value:.3f}s") processing = ProcessingMetricsData( processor=self._processor_name(), value=value, model=self._model_name() ) diff --git a/src/pipecat/services/stt_service.py b/src/pipecat/services/stt_service.py index 6d431c523..bad8e2e28 100644 --- a/src/pipecat/services/stt_service.py +++ b/src/pipecat/services/stt_service.py @@ -21,7 +21,6 @@ from pipecat.frames.frames import ( ErrorFrame, Frame, InterruptionFrame, - MetricsFrame, ServiceSwitcherRequestMetadataFrame, StartFrame, STTMetadataFrame, @@ -31,7 +30,6 @@ from pipecat.frames.frames import ( VADUserStartedSpeakingFrame, VADUserStoppedSpeakingFrame, ) -from pipecat.metrics.metrics import TTFBMetricsData from pipecat.processors.frame_processor import FrameDirection from pipecat.services.ai_service import AIService from pipecat.services.stt_latency import DEFAULT_TTFS_P99 @@ -121,9 +119,7 @@ class STTService(AIService): # STT TTFB tracking state self._stt_ttfb_timeout = stt_ttfb_timeout self._ttfb_timeout_task: Optional[asyncio.Task] = None - self._speech_end_time: Optional[float] = None self._user_speaking: bool = False - self._last_transcription_time: Optional[float] = None self._finalize_pending: bool = False self._finalize_requested: bool = False @@ -327,23 +323,16 @@ class STTService(AIService): direction: The direction to push the frame. """ if isinstance(frame, TranscriptionFrame): - # Store the transcription time for TTFB calculation - self._last_transcription_time = time.time() - # Set finalized from pending state and auto-reset if self._finalize_pending: frame.finalized = True self._finalize_pending = False # If this is a finalized transcription, report TTFB immediately - if frame.finalized and self._speech_end_time is not None: - ttfb = self._last_transcription_time - self._speech_end_time - await self._emit_stt_ttfb_metric(ttfb) + if frame.finalized: + await self.stop_ttfb_metrics() # Cancel the timeout since we've already reported await self._cancel_ttfb_timeout() - # Clear state - self._speech_end_time = None - self._last_transcription_time = None await super().push_frame(frame, direction) @@ -373,8 +362,6 @@ class STTService(AIService): while user is still speaking. """ await self._cancel_ttfb_timeout() - self._speech_end_time = None - self._last_transcription_time = None async def _handle_vad_user_started_speaking(self, frame: VADUserStartedSpeakingFrame): """Handle VAD user started speaking frame to start tracking transcriptions. @@ -408,7 +395,8 @@ class STTService(AIService): # Calculate the actual speech end time (current time minus VAD stop delay). # This approximates when the last user audio was sent to the STT service, # which we use to measure against the eventual transcription response. - self._speech_end_time = frame.timestamp - frame.stop_secs + speech_end_time = frame.timestamp - frame.stop_secs + await self.start_ttfb_metrics(start_time=speech_end_time) # Start timeout task (any previous timeout was cancelled by VADUserStartedSpeakingFrame # or InterruptionFrame) @@ -417,44 +405,20 @@ class STTService(AIService): ) async def _ttfb_timeout_handler(self): - """Wait for timeout then report TTFB using the last transcription timestamp. + """Wait for timeout then report TTFB. This timeout allows the final transcription to arrive before we calculate and report TTFB. If no transcription arrived, no TTFB is reported. """ try: await asyncio.sleep(self._stt_ttfb_timeout) - - # Report TTFB if we have both speech end time and transcription time - if self._speech_end_time is not None and self._last_transcription_time is not None: - ttfb = self._last_transcription_time - self._speech_end_time - await self._emit_stt_ttfb_metric(ttfb) - - # Clear state after reporting - self._speech_end_time = None - self._last_transcription_time = None + await self.stop_ttfb_metrics() except asyncio.CancelledError: # Task was cancelled (new utterance or interruption), which is expected behavior pass finally: self._ttfb_timeout_task = None - async def _emit_stt_ttfb_metric(self, ttfb: float): - """Emit STT TTFB metric if value is non-negative. - - Args: - ttfb: The TTFB value in seconds. - """ - if ttfb >= 0: - logger.debug(f"{self} TTFB: {ttfb:.3f}s") - if self.metrics_enabled: - ttfb_data = TTFBMetricsData( - processor=self.name, - model=self.model_name, - value=ttfb, - ) - await super().push_frame(MetricsFrame(data=[ttfb_data])) - def _create_keepalive_task(self): """Start the keepalive task if keepalive is enabled.""" if self._keepalive_timeout is not None: From bae4211369e6ab0003a377ac2c0ad3762ca9f7b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Thu, 19 Feb 2026 10:52:28 -0800 Subject: [PATCH 68/93] Update dependency lock file --- uv.lock | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/uv.lock b/uv.lock index 0a3206c0f..512a6a49d 100644 --- a/uv.lock +++ b/uv.lock @@ -2110,7 +2110,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/65/5b235b40581ad75ab97dcd8b4218022ae8e3ab77c13c919f1a1dfe9171fd/greenlet-3.3.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:04bee4775f40ecefcdaa9d115ab44736cd4b9c5fba733575bfe9379419582e13", size = 273723, upload-time = "2026-01-23T15:30:37.521Z" }, { url = "https://files.pythonhosted.org/packages/ce/ad/eb4729b85cba2d29499e0a04ca6fbdd8f540afd7be142fd571eea43d712f/greenlet-3.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e1457f4fed12a50e427988a07f0f9df53cf0ee8da23fab16e6732c2ec909d4", size = 574874, upload-time = "2026-01-23T16:00:54.551Z" }, { url = "https://files.pythonhosted.org/packages/87/32/57cad7fe4c8b82fdaa098c89498ef85ad92dfbb09d5eb713adedfc2ae1f5/greenlet-3.3.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:070472cd156f0656f86f92e954591644e158fd65aa415ffbe2d44ca77656a8f5", size = 586309, upload-time = "2026-01-23T16:05:25.18Z" }, - { url = "https://files.pythonhosted.org/packages/66/66/f041005cb87055e62b0d68680e88ec1a57f4688523d5e2fb305841bc8307/greenlet-3.3.1-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1108b61b06b5224656121c3c8ee8876161c491cbe74e5c519e0634c837cf93d5", size = 597461, upload-time = "2026-01-23T16:15:51.943Z" }, { url = "https://files.pythonhosted.org/packages/87/eb/8a1ec2da4d55824f160594a75a9d8354a5fe0a300fb1c48e7944265217e1/greenlet-3.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a300354f27dd86bae5fbf7002e6dd2b3255cd372e9242c933faf5e859b703fe", size = 586985, upload-time = "2026-01-23T15:32:47.968Z" }, { url = "https://files.pythonhosted.org/packages/15/1c/0621dd4321dd8c351372ee8f9308136acb628600658a49be1b7504208738/greenlet-3.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e84b51cbebf9ae573b5fbd15df88887815e3253fc000a7d0ff95170e8f7e9729", size = 1547271, upload-time = "2026-01-23T16:04:18.977Z" }, { url = "https://files.pythonhosted.org/packages/9d/53/24047f8924c83bea7a59c8678d9571209c6bfe5f4c17c94a78c06024e9f2/greenlet-3.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0093bd1a06d899892427217f0ff2a3c8f306182b8c754336d32e2d587c131b4", size = 1613427, upload-time = "2026-01-23T15:33:44.428Z" }, @@ -2118,7 +2117,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" }, { url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-01-23T16:00:56.213Z" }, { url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26.365Z" }, - { url = "https://files.pythonhosted.org/packages/70/ae/e2d5f0e59b94a2269b68a629173263fa40b63da32f5c231307c349315871/greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f", size = 601161, upload-time = "2026-01-23T16:15:53.456Z" }, { url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-01-23T15:32:49.411Z" }, { url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20.867Z" }, { url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45.743Z" }, @@ -2127,7 +2125,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" }, { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" }, { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" }, - { url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" }, { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" }, { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" }, { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" }, @@ -2136,7 +2133,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, - { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, @@ -2145,7 +2141,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, - { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" }, { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, @@ -2154,7 +2149,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, - { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" }, { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, @@ -4751,10 +4745,10 @@ requires-dist = [ { name = "resampy", specifier = "~=0.4.3" }, { name = "sarvamai", marker = "extra == 'sarvam'", specifier = "==0.1.21" }, { name = "sentry-sdk", marker = "extra == 'sentry'", specifier = ">=2.28.0,<3" }, - { name = "simli-ai", marker = "extra == 'simli'", specifier = "~=1.0.3" }, + { name = "simli-ai", marker = "extra == 'simli'", specifier = "~=2.0.1" }, { name = "soundfile", marker = "extra == 'soundfile'", specifier = "~=0.13.1" }, { name = "soxr", specifier = "~=0.5.0" }, - { name = "speechmatics-voice", extras = ["smart"], marker = "extra == 'speechmatics'", specifier = ">=0.2.8" }, + { name = "speechmatics-voice", extras = ["smart"], marker = "extra == 'speechmatics'", specifier = "~=0.2.8" }, { name = "strands-agents", marker = "extra == 'strands'", specifier = ">=1.9.1,<2" }, { name = "tenacity", marker = "extra == 'livekit'", specifier = ">=8.2.3,<10.0.0" }, { name = "timm", marker = "extra == 'moondream'", specifier = "~=1.0.13" }, @@ -6467,19 +6461,18 @@ wheels = [ [[package]] name = "simli-ai" -version = "1.0.3" +version = "2.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiortc" }, { name = "av" }, { name = "httpx" }, - { name = "livekit" }, { name = "numpy" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/03/b0b3e12c68fd3f9c57f6afeee67841349e4866b88760f413357af3043ae4/simli_ai-1.0.3.tar.gz", hash = "sha256:e96b0621a1dbd9582b2ae3d51eefd4995983b49c1f1061eb9239707b15a1ee27", size = 13350, upload-time = "2025-11-13T12:22:32.514Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/b5/6021990871daf9f5b6eb744aff68c83f2c7b257cfd2ee5b9883d0acd9cf4/simli_ai-2.0.1.tar.gz", hash = "sha256:1f63eb76900d4dac0c18406a854219e54ebab51acb0c01e245c7a0738dc72413", size = 16104, upload-time = "2026-02-17T12:46:09.743Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/d1/dc382ba529de0d2d51f35e9bfd20b41d8f5c96404a3aa24bae97a5a5e51f/simli_ai-1.0.3-py3-none-any.whl", hash = "sha256:ffafa7540aa28833e207be8f3b199367c7f500dac1a8ba0108395bfb7d8362bc", size = 13863, upload-time = "2025-11-13T12:22:31.218Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c1/e7aed0f59d04628c0ac738e2bf8cb6bf020870d1909f3ec9fcf265136663/simli_ai-2.0.1-py3-none-any.whl", hash = "sha256:0a48e38fe289568e56236266843484a1f0e28aca694dd8e2b96610fe40d6c687", size = 19456, upload-time = "2026-02-17T12:46:08.727Z" }, ] [[package]] From 8e52df7f034309f372da3e76a9865cc6bd9b0cd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Thu, 19 Feb 2026 10:52:31 -0800 Subject: [PATCH 69/93] Add changelog entries for PR #3776 --- changelog/3776.changed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3776.changed.md diff --git a/changelog/3776.changed.md b/changelog/3776.changed.md new file mode 100644 index 000000000..87b5d6128 --- /dev/null +++ b/changelog/3776.changed.md @@ -0,0 +1 @@ +- Added `start_time` and `end_time` parameters to `start_ttfb_metrics()`, `stop_ttfb_metrics()`, `start_processing_metrics()`, and `stop_processing_metrics()` in `FrameProcessor` and `FrameProcessorMetrics`, allowing custom timestamps for metrics measurement. `STTService` now uses these instead of custom TTFB tracking. From 5b2fa69bdc5f6a888508a0c52189d2add6899b32 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Thu, 19 Feb 2026 16:24:07 -0300 Subject: [PATCH 70/93] Renaming from broadcasted_sibling_id to broadcast_sibling_id --- changelog/3774.added.md | 2 +- src/pipecat/frames/frames.py | 6 +++--- src/pipecat/processors/frame_processor.py | 8 ++++---- src/pipecat/processors/frameworks/rtvi.py | 2 +- src/pipecat/transports/base_output.py | 8 ++++---- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/changelog/3774.added.md b/changelog/3774.added.md index 8e442999c..e72599e60 100644 --- a/changelog/3774.added.md +++ b/changelog/3774.added.md @@ -1 +1 @@ -- Added `broadcasted_sibling_id` field to the base `Frame` class. This field is automatically set by `broadcast_frame()` and `broadcast_frame_instance()` to the ID of the paired frame pushed in the opposite direction, allowing receivers to identify broadcast pairs. +- Added `broadcast_sibling_id` field to the base `Frame` class. This field is automatically set by `broadcast_frame()` and `broadcast_frame_instance()` to the ID of the paired frame pushed in the opposite direction, allowing receivers to identify broadcast pairs. diff --git a/src/pipecat/frames/frames.py b/src/pipecat/frames/frames.py index e6bc008ab..62c0d1352 100644 --- a/src/pipecat/frames/frames.py +++ b/src/pipecat/frames/frames.py @@ -123,7 +123,7 @@ class Frame: id: Unique identifier for the frame instance. name: Human-readable name combining class name and instance count. pts: Presentation timestamp in nanoseconds. - broadcasted_sibling_id: ID of the paired frame when this frame was + broadcast_sibling_id: ID of the paired frame when this frame was broadcast in both directions. Set automatically by ``broadcast_frame()`` and ``broadcast_frame_instance()``. metadata: Dictionary for arbitrary frame metadata. @@ -134,7 +134,7 @@ class Frame: id: int = field(init=False) name: str = field(init=False) pts: Optional[int] = field(init=False) - broadcasted_sibling_id: Optional[int] = field(init=False) + broadcast_sibling_id: Optional[int] = field(init=False) metadata: Dict[str, Any] = field(init=False) transport_source: Optional[str] = field(init=False) transport_destination: Optional[str] = field(init=False) @@ -143,7 +143,7 @@ class Frame: self.id: int = obj_id() self.name: str = f"{self.__class__.__name__}#{obj_count(self)}" self.pts: Optional[int] = None - self.broadcasted_sibling_id: Optional[int] = None + self.broadcast_sibling_id: Optional[int] = None self.metadata: Dict[str, Any] = {} self.transport_source: Optional[str] = None self.transport_destination: Optional[str] = None diff --git a/src/pipecat/processors/frame_processor.py b/src/pipecat/processors/frame_processor.py index 6bdd9ae1d..0d20214b2 100644 --- a/src/pipecat/processors/frame_processor.py +++ b/src/pipecat/processors/frame_processor.py @@ -789,8 +789,8 @@ class FrameProcessor(BaseObject): """ downstream_frame = frame_cls(**kwargs) upstream_frame = frame_cls(**kwargs) - downstream_frame.broadcasted_sibling_id = upstream_frame.id - upstream_frame.broadcasted_sibling_id = downstream_frame.id + downstream_frame.broadcast_sibling_id = upstream_frame.id + upstream_frame.broadcast_sibling_id = downstream_frame.id await self.push_frame(downstream_frame) await self.push_frame(upstream_frame, FrameDirection.UPSTREAM) @@ -824,8 +824,8 @@ class FrameProcessor(BaseObject): for k, v in extra_fields.items(): setattr(upstream_frame, k, v) - downstream_frame.broadcasted_sibling_id = upstream_frame.id - upstream_frame.broadcasted_sibling_id = downstream_frame.id + downstream_frame.broadcast_sibling_id = upstream_frame.id + upstream_frame.broadcast_sibling_id = downstream_frame.id await self.push_frame(downstream_frame) await self.push_frame(upstream_frame, FrameDirection.UPSTREAM) diff --git a/src/pipecat/processors/frameworks/rtvi.py b/src/pipecat/processors/frameworks/rtvi.py index 55be46452..a8b66ccdd 100644 --- a/src/pipecat/processors/frameworks/rtvi.py +++ b/src/pipecat/processors/frameworks/rtvi.py @@ -1222,7 +1222,7 @@ class RTVIObserver(BaseObserver): # For broadcasted frames (pushed in both directions), only process # the downstream copy to avoid sending duplicate RTVI messages. - if frame.broadcasted_sibling_id is not None and direction != FrameDirection.DOWNSTREAM: + if frame.broadcast_sibling_id is not None and direction != FrameDirection.DOWNSTREAM: return # If we have already seen this frame, let's skip it. diff --git a/src/pipecat/transports/base_output.py b/src/pipecat/transports/base_output.py index 4f072c0df..1a5083128 100644 --- a/src/pipecat/transports/base_output.py +++ b/src/pipecat/transports/base_output.py @@ -615,8 +615,8 @@ class BaseOutputTransport(FrameProcessor): upstream_frame.transport_destination = self._destination # Setting the siblings id - upstream_frame.broadcasted_sibling_id = downstream_frame.id - downstream_frame.broadcasted_sibling_id = upstream_frame.id + upstream_frame.broadcast_sibling_id = downstream_frame.id + downstream_frame.broadcast_sibling_id = upstream_frame.id await self._transport.push_frame(downstream_frame) await self._transport.push_frame(upstream_frame, FrameDirection.UPSTREAM) @@ -642,8 +642,8 @@ class BaseOutputTransport(FrameProcessor): upstream_frame.transport_destination = self._destination # Setting the siblings id - upstream_frame.broadcasted_sibling_id = downstream_frame.id - downstream_frame.broadcasted_sibling_id = upstream_frame.id + upstream_frame.broadcast_sibling_id = downstream_frame.id + downstream_frame.broadcast_sibling_id = upstream_frame.id await self._transport.push_frame(downstream_frame) await self._transport.push_frame(upstream_frame, FrameDirection.UPSTREAM) From 63caa403cb51f092329d6ef554e4361a0c75ad6b Mon Sep 17 00:00:00 2001 From: filipi87 Date: Thu, 19 Feb 2026 17:31:25 -0300 Subject: [PATCH 71/93] Improving RTVI doc description. --- src/pipecat/processors/frameworks/rtvi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pipecat/processors/frameworks/rtvi.py b/src/pipecat/processors/frameworks/rtvi.py index a8b66ccdd..eb074699a 100644 --- a/src/pipecat/processors/frameworks/rtvi.py +++ b/src/pipecat/processors/frameworks/rtvi.py @@ -1220,7 +1220,7 @@ class RTVIObserver(BaseObserver): frame = data.frame direction = data.direction - # For broadcasted frames (pushed in both directions), only process + # For broadcast frames (pushed in both directions), only process # the downstream copy to avoid sending duplicate RTVI messages. if frame.broadcast_sibling_id is not None and direction != FrameDirection.DOWNSTREAM: return From 3a8d3cc8419228530c4c520210004c74181378c6 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Thu, 19 Feb 2026 18:36:12 -0300 Subject: [PATCH 72/93] Allowing to define the list of frame processors whose frames should be silently ignored by the RTVI observer. --- .../53-concurrent-llm-rtvi-ignored-sources.py | 193 ++++++++++++++++++ src/pipecat/processors/frameworks/rtvi.py | 37 ++++ 2 files changed, 230 insertions(+) create mode 100644 examples/foundational/53-concurrent-llm-rtvi-ignored-sources.py diff --git a/examples/foundational/53-concurrent-llm-rtvi-ignored-sources.py b/examples/foundational/53-concurrent-llm-rtvi-ignored-sources.py new file mode 100644 index 000000000..c679a4cc4 --- /dev/null +++ b/examples/foundational/53-concurrent-llm-rtvi-ignored-sources.py @@ -0,0 +1,193 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""RTVIObserver ignored sources example. + +This example shows how to suppress RTVI messages from a specific pipeline +processor so that secondary branches don't leak events to the client. + +""" + +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame +from pipecat.pipeline.parallel_pipeline import ParallelPipeline +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.processors.audio.vad_processor import VADProcessor +from pipecat.processors.frameworks.rtvi import RTVIObserverParams +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.cartesia.tts import CartesiaTTSService +from pipecat.services.deepgram.stt import DeepgramSTTService +from pipecat.services.openai.llm import OpenAILLMService +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import DailyParams +from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams +from pipecat.turns.user_turn_processor import UserTurnProcessor +from pipecat.turns.user_turn_strategies import ExternalUserTurnStrategies + +load_dotenv(override=True) + +# We use lambdas to defer transport parameter creation until the transport +# type is selected at runtime. +transport_params = { + "daily": lambda: DailyParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "twilio": lambda: FastAPIWebsocketParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "webrtc": lambda: TransportParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), +} + + +async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): + logger.info("Starting bot") + + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ) + + # Main LLM — drives the conversation. Its RTVI events reach the client. + main_llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + + main_messages = [ + { + "role": "system", + "content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. Respond to what the user said in a creative and helpful way.", + }, + ] + + # Evaluator LLM — silently grades the user's message in the background. + # Its RTVI events will be suppressed so the client is unaware of this branch. + evaluator_llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + name="EvaluatorLLM", + ) + + evaluator_messages = [ + { + "role": "system", + "content": ( + "You are a silent quality evaluator. When given a user message, " + "respond with a single JSON object: " + '{"score": <1-5>, "reason": ""}. ' + "Do not respond conversationally." + ), + }, + ] + + main_context = LLMContext(main_messages) + evaluator_context = LLMContext(evaluator_messages) + + # We use an external VADProcessor because the UserTurnProcessor is shared + # across multiple parallel aggregators. The VADProcessor emits + # VADUserStartedSpeakingFrame and VADUserStoppedSpeakingFrame which the + # UserTurnProcessor needs to manage turn lifecycle. + vad_processor = VADProcessor(vad_analyzer=SileroVADAnalyzer()) + + # We use this external user turn processor. This processor will push + # UserStartedSpeakingFrame and UserStoppedSpeakingFrame as well as + # interruptions. This can be used in advanced cases when there are multiple + # aggregators in the pipeline. + user_turn_processor = UserTurnProcessor() + + # We use external user turn strategies for both aggregators since the turn + # management is done by the common UserTurnProcessor. + main_context_aggregator = LLMContextAggregatorPair( + main_context, + user_params=LLMUserAggregatorParams(user_turn_strategies=ExternalUserTurnStrategies()), + ) + evaluator_context_aggregator = LLMContextAggregatorPair( + evaluator_context, + user_params=LLMUserAggregatorParams(user_turn_strategies=ExternalUserTurnStrategies()), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, # STT + vad_processor, + user_turn_processor, + ParallelPipeline( + # Main branch: speaks to the user. + [ + main_context_aggregator.user(), + main_llm, + tts, + transport.output(), + main_context_aggregator.assistant(), + ], + # Evaluator branch: silent background scoring, no audio output. + [ + evaluator_context_aggregator.user(), + evaluator_llm, + evaluator_context_aggregator.assistant(), + ], + ), + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + rtvi_observer_params=RTVIObserverParams( + ignored_sources=[evaluator_llm] + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info("Client connected") + main_messages.append( + {"role": "system", "content": "Please introduce yourself to the user."} + ) + evaluator_messages.append({"role": "system", "content": "Ready to evaluate user messages."}) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info("Client disconnected") + await task.cancel() + + runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) + + await runner.run(task) + + +async def bot(runner_args: RunnerArguments): + """Main bot entry point compatible with Pipecat Cloud.""" + transport = await create_transport(runner_args, transport_params) + await run_bot(transport, runner_args) + + +if __name__ == "__main__": + from pipecat.runner.run import main + + main() diff --git a/src/pipecat/processors/frameworks/rtvi.py b/src/pipecat/processors/frameworks/rtvi.py index eb074699a..e01e95714 100644 --- a/src/pipecat/processors/frameworks/rtvi.py +++ b/src/pipecat/processors/frameworks/rtvi.py @@ -25,6 +25,7 @@ from typing import ( Literal, Mapping, Optional, + Set, Tuple, Union, ) @@ -1026,6 +1027,11 @@ class RTVIObserverParams: metrics_enabled: Indicates if metrics messages should be sent. system_logs_enabled: Indicates if system logs should be sent. errors_enabled: [Deprecated] Indicates if errors messages should be sent. + ignored_sources: List of frame processors whose frames should be silently ignored + by this observer. Useful for suppressing RTVI messages from secondary pipeline + branches (e.g. a silent evaluation LLM) that should not be visible to clients. + Sources can also be added and removed dynamically via ``add_ignored_source()`` + and ``remove_ignored_source()``. skip_aggregator_types: List of aggregation types to skip sending as tts/output messages. Note: if using this to avoid sending secure information, be sure to also disable bot_llm_enabled to avoid leaking through LLM messages. @@ -1065,6 +1071,7 @@ class RTVIObserverParams: metrics_enabled: bool = True system_logs_enabled: bool = False errors_enabled: Optional[bool] = None + ignored_sources: List[FrameProcessor] = field(default_factory=list) skip_aggregator_types: Optional[List[AggregationType | str]] = None bot_output_transforms: Optional[ List[ @@ -1110,6 +1117,7 @@ class RTVIObserver(BaseObserver): self._rtvi = rtvi self._params = params or RTVIObserverParams() + self._ignored_sources: Set[FrameProcessor] = set(self._params.ignored_sources) self._frames_seen = set() self._bot_transcription = "" @@ -1170,6 +1178,31 @@ class RTVIObserver(BaseObserver): if not (agg_type == aggregation_type and func == transform_function) ] + def add_ignored_source(self, source: FrameProcessor): + """Ignore all frames pushed by the given processor. + + Any frame whose source matches ``source`` will be silently skipped, + preventing RTVI messages from being emitted for activity in that + processor. Useful for suppressing events from secondary pipeline + branches (e.g. a silent evaluation LLM) that should not be visible + to clients. + + Args: + source: The frame processor to ignore. + """ + self._ignored_sources.add(source) + + def remove_ignored_source(self, source: FrameProcessor): + """Stop ignoring frames pushed by the given processor. + + Reverses a previous call to ``add_ignored_source()``. If ``source`` + was not previously ignored this is a no-op. + + Args: + source: The frame processor to stop ignoring. + """ + self._ignored_sources.discard(source) + def _get_function_call_report_level(self, function_name: str) -> RTVIFunctionCallReportLevel: """Get the report level for a specific function call. @@ -1220,6 +1253,10 @@ class RTVIObserver(BaseObserver): frame = data.frame direction = data.direction + # Frames from explicitly ignored sources are always skipped. + if self._ignored_sources and src in self._ignored_sources: + return + # For broadcast frames (pushed in both directions), only process # the downstream copy to avoid sending duplicate RTVI messages. if frame.broadcast_sibling_id is not None and direction != FrameDirection.DOWNSTREAM: From 18630c94788f18d32b68751c9fe1a9383b6742eb Mon Sep 17 00:00:00 2001 From: filipi87 Date: Thu, 19 Feb 2026 18:41:05 -0300 Subject: [PATCH 73/93] Adding changelog entry for RTVI observer ignored_sources feature. --- changelog/3779.added.md | 1 + .../foundational/53-concurrent-llm-rtvi-ignored-sources.py | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) create mode 100644 changelog/3779.added.md diff --git a/changelog/3779.added.md b/changelog/3779.added.md new file mode 100644 index 000000000..8800cfc04 --- /dev/null +++ b/changelog/3779.added.md @@ -0,0 +1 @@ +- Added `ignored_sources` parameter to `RTVIObserverParams` and `add_ignored_source()`/`remove_ignored_source()` methods to `RTVIObserver` to suppress RTVI messages from specific pipeline processors (e.g. a silent evaluation LLM). diff --git a/examples/foundational/53-concurrent-llm-rtvi-ignored-sources.py b/examples/foundational/53-concurrent-llm-rtvi-ignored-sources.py index c679a4cc4..b16f8831f 100644 --- a/examples/foundational/53-concurrent-llm-rtvi-ignored-sources.py +++ b/examples/foundational/53-concurrent-llm-rtvi-ignored-sources.py @@ -156,9 +156,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): enable_metrics=True, enable_usage_metrics=True, ), - rtvi_observer_params=RTVIObserverParams( - ignored_sources=[evaluator_llm] - ), + rtvi_observer_params=RTVIObserverParams(ignored_sources=[evaluator_llm]), idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, ) From bc830c16f1f0695dd336b4e04bf0e65849b432c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Thu, 19 Feb 2026 20:52:00 -0800 Subject: [PATCH 74/93] Fix mutable default arguments in LLMContextAggregatorPair Replace mutable default parameter values with None and instantiate inside the method body to avoid shared state across calls. --- .../processors/aggregators/llm_response_universal.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pipecat/processors/aggregators/llm_response_universal.py b/src/pipecat/processors/aggregators/llm_response_universal.py index c41885655..e5884a868 100644 --- a/src/pipecat/processors/aggregators/llm_response_universal.py +++ b/src/pipecat/processors/aggregators/llm_response_universal.py @@ -1257,8 +1257,8 @@ class LLMContextAggregatorPair: self, context: LLMContext, *, - user_params: LLMUserAggregatorParams = LLMUserAggregatorParams(), - assistant_params: LLMAssistantAggregatorParams = LLMAssistantAggregatorParams(), + user_params: Optional[LLMUserAggregatorParams] = None, + assistant_params: Optional[LLMAssistantAggregatorParams] = None, ): """Initialize the LLM context aggregator pair. @@ -1267,6 +1267,8 @@ class LLMContextAggregatorPair: user_params: Parameters for the user context aggregator. assistant_params: Parameters for the assistant context aggregator. """ + user_params = user_params or LLMUserAggregatorParams() + assistant_params = assistant_params or LLMAssistantAggregatorParams() self._user = LLMUserAggregator(context, params=user_params) self._assistant = LLMAssistantAggregator(context, params=assistant_params) From 2024285c751ed382789e2d34c648bb2d87ae8a1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Thu, 19 Feb 2026 20:52:31 -0800 Subject: [PATCH 75/93] Add changelog entries for PR #3782 --- changelog/3782.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3782.fixed.md diff --git a/changelog/3782.fixed.md b/changelog/3782.fixed.md new file mode 100644 index 000000000..7d21fdeab --- /dev/null +++ b/changelog/3782.fixed.md @@ -0,0 +1 @@ +- Fixed mutable default arguments in `LLMContextAggregatorPair.__init__()` that could cause shared state across instances. From 4d136e1e286696827859a2a3d4b366d2726b1325 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Fri, 20 Feb 2026 07:15:38 -0700 Subject: [PATCH 76/93] Align DeepgramSageMakerSTTService finalize pattern with DeepgramSTTService --- src/pipecat/services/deepgram/stt_sagemaker.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/pipecat/services/deepgram/stt_sagemaker.py b/src/pipecat/services/deepgram/stt_sagemaker.py index 99f6cf487..8fc95b726 100644 --- a/src/pipecat/services/deepgram/stt_sagemaker.py +++ b/src/pipecat/services/deepgram/stt_sagemaker.py @@ -368,7 +368,6 @@ class DeepgramSageMakerSTTService(STTService): return is_final = parsed.get("is_final", False) - speech_final = parsed.get("speech_final", False) # Extract language if available language = None @@ -376,8 +375,12 @@ class DeepgramSageMakerSTTService(STTService): language = alternatives[0]["languages"][0] language = Language(language) - if is_final and speech_final: - # Final transcription + if is_final: + # Check if this response is from a finalize() call. + # Only mark as finalized when both we requested it AND Deepgram confirms it. + from_finalize = parsed.get("from_finalize", False) + if from_finalize: + self.confirm_finalize() await self.push_frame( TranscriptionFrame( transcript, @@ -435,10 +438,12 @@ class DeepgramSageMakerSTTService(STTService): if isinstance(frame, VADUserStartedSpeakingFrame): await self._start_metrics() elif isinstance(frame, VADUserStoppedSpeakingFrame): - # Send finalize message to Deepgram when user stops speaking - # This tells Deepgram to flush any remaining audio and return final results + # https://developers.deepgram.com/docs/finalize + # Mark that we're awaiting a from_finalize response + self.request_finalize() if self._client and self._client.is_active: try: await self._client.send_json({"type": "Finalize"}) except Exception as e: logger.warning(f"Error sending Finalize message: {e}") + logger.trace(f"Triggered finalize event on: {frame.name=}, {direction=}") From 43d686c622ebbb6b7eaa38477b1cbaf4aba77d9e Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Fri, 20 Feb 2026 07:17:36 -0700 Subject: [PATCH 77/93] Add changelog entry for PR #3784 --- changelog/3784.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3784.fixed.md diff --git a/changelog/3784.fixed.md b/changelog/3784.fixed.md new file mode 100644 index 000000000..e88431f16 --- /dev/null +++ b/changelog/3784.fixed.md @@ -0,0 +1 @@ +- Fixed `DeepgramSageMakerSTTService` to properly track finalize lifecycle using `request_finalize()` / `confirm_finalize()` and use `is_final` (instead of `is_final and speech_final`) for final transcription detection, matching `DeepgramSTTService` behavior. From 273692421fab018b3bff661ca281e931010cdada Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Fri, 20 Feb 2026 08:08:00 -0700 Subject: [PATCH 78/93] Add DeepgramSageMakerTTSService for Deepgram TTS on AWS SageMaker Adds a TTS service that connects to Deepgram models deployed on AWS SageMaker endpoints via HTTP/2 bidirectional streaming. Supports the Deepgram TTS protocol (Speak, Flush, Clear, Close) over the BiDi client, with interruption handling and per-turn TTFB metrics. Updates the example and env.example with separate STT/TTS endpoint names. --- env.example | 3 +- .../07c-interruptible-deepgram-sagemaker.py | 21 +- .../services/deepgram/tts_sagemaker.py | 315 ++++++++++++++++++ 3 files changed, 333 insertions(+), 6 deletions(-) create mode 100644 src/pipecat/services/deepgram/tts_sagemaker.py diff --git a/env.example b/env.example index 6e7db21e2..bc14ea0bf 100644 --- a/env.example +++ b/env.example @@ -47,7 +47,8 @@ DAILY_ROOM_URL=https://... # Deepgram DEEPGRAM_API_KEY=... -SAGEMAKER_ENDPOINT_NAME=... +SAGEMAKER_STT_ENDPOINT_NAME=... +SAGEMAKER_TTS_ENDPOINT_NAME=... # DeepSeek DEEPSEEK_API_KEY=... diff --git a/examples/foundational/07c-interruptible-deepgram-sagemaker.py b/examples/foundational/07c-interruptible-deepgram-sagemaker.py index 51a4b1bcb..f6d1c9354 100644 --- a/examples/foundational/07c-interruptible-deepgram-sagemaker.py +++ b/examples/foundational/07c-interruptible-deepgram-sagemaker.py @@ -23,8 +23,10 @@ from pipecat.processors.aggregators.llm_response_universal import ( from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.aws.llm import AWSBedrockLLMService +from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.deepgram.stt_sagemaker import DeepgramSageMakerSTTService from pipecat.services.deepgram.tts import DeepgramTTSService +from pipecat.services.deepgram.tts_sagemaker import DeepgramSageMakerTTSService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams @@ -57,12 +59,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # This requires: # - AWS credentials configured (via environment variables or AWS CLI) # - A deployed SageMaker endpoint with Deepgram model - stt = DeepgramSageMakerSTTService( - endpoint_name=os.getenv("SAGEMAKER_ENDPOINT_NAME"), - region=os.getenv("AWS_REGION"), - ) + # stt = DeepgramSageMakerSTTService( + # endpoint_name=os.getenv("SAGEMAKER_STT_ENDPOINT_NAME"), + # region=os.getenv("AWS_REGION"), + # ) + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) - tts = DeepgramTTSService(api_key=os.getenv("DEEPGRAM_API_KEY"), voice="aura-2-andromeda-en") + # Initialize Deepgram SageMaker TTS Service + # This requires: + # - AWS credentials configured (via environment variables or AWS CLI) + # - A deployed SageMaker endpoint with Deepgram TTS model + tts = DeepgramSageMakerTTSService( + endpoint_name=os.getenv("SAGEMAKER_TTS_ENDPOINT_NAME"), + region=os.getenv("AWS_REGION"), + voice="aura-2-andromeda-en", + ) llm = AWSBedrockLLMService( aws_region=os.getenv("AWS_REGION"), diff --git a/src/pipecat/services/deepgram/tts_sagemaker.py b/src/pipecat/services/deepgram/tts_sagemaker.py new file mode 100644 index 000000000..7c04bc299 --- /dev/null +++ b/src/pipecat/services/deepgram/tts_sagemaker.py @@ -0,0 +1,315 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Deepgram text-to-speech service for AWS SageMaker. + +This module provides a Pipecat TTS service that connects to Deepgram models +deployed on AWS SageMaker endpoints. Uses HTTP/2 bidirectional streaming for +low-latency real-time speech synthesis with support for interruptions and +streaming audio output. +""" + +import asyncio +import json +from typing import AsyncGenerator, Optional + +from loguru import logger + +from pipecat.frames.frames import ( + BotStoppedSpeakingFrame, + CancelFrame, + EndFrame, + ErrorFrame, + Frame, + InterruptionFrame, + LLMFullResponseEndFrame, + StartFrame, + TTSAudioRawFrame, + TTSStartedFrame, +) +from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.aws.sagemaker.bidi_client import SageMakerBidiClient +from pipecat.services.tts_service import TTSService +from pipecat.utils.tracing.service_decorators import traced_tts + + +class DeepgramSageMakerTTSService(TTSService): + """Deepgram text-to-speech service for AWS SageMaker. + + Provides real-time speech synthesis using Deepgram models deployed on + AWS SageMaker endpoints. Uses HTTP/2 bidirectional streaming for low-latency + audio generation with support for interruptions via the Clear message. + + Requirements: + + - AWS credentials configured (via environment variables, AWS CLI, or instance metadata) + - A deployed SageMaker endpoint with Deepgram TTS model: https://developers.deepgram.com/docs/deploy-amazon-sagemaker + - ``pipecat-ai[sagemaker]`` installed + + Example:: + + tts = DeepgramSageMakerTTSService( + endpoint_name="my-deepgram-tts-endpoint", + region="us-east-2", + voice="aura-2-helena-en", + ) + """ + + def __init__( + self, + *, + endpoint_name: str, + region: str, + voice: str = "aura-2-helena-en", + sample_rate: Optional[int] = None, + encoding: str = "linear16", + **kwargs, + ): + """Initialize the Deepgram SageMaker TTS service. + + Args: + endpoint_name: Name of the SageMaker endpoint with Deepgram TTS model + deployed (e.g., "my-deepgram-tts-endpoint"). + region: AWS region where the endpoint is deployed (e.g., "us-east-2"). + voice: Voice model to use for synthesis. Defaults to "aura-2-helena-en". + sample_rate: Audio sample rate in Hz. If None, uses the value from StartFrame. + encoding: Audio encoding format. Defaults to "linear16". + **kwargs: Additional arguments passed to the parent TTSService. + """ + super().__init__( + sample_rate=sample_rate, + push_stop_frames=True, + pause_frame_processing=True, + append_trailing_space=True, + **kwargs, + ) + + self._endpoint_name = endpoint_name + self._region = region + self._encoding = encoding + self.set_voice(voice) + + self._client: Optional[SageMakerBidiClient] = None + self._response_task: Optional[asyncio.Task] = None + self._context_id: Optional[str] = None + self._ttfb_started: bool = False + + def can_generate_metrics(self) -> bool: + """Check if this service can generate processing metrics. + + Returns: + True, as Deepgram SageMaker TTS service supports metrics generation. + """ + return True + + async def start(self, frame: StartFrame): + """Start the Deepgram SageMaker TTS service. + + Args: + frame: The start frame containing initialization parameters. + """ + await super().start(frame) + await self._connect() + + async def stop(self, frame: EndFrame): + """Stop the Deepgram SageMaker TTS service. + + Args: + frame: The end frame. + """ + await super().stop(frame) + await self._disconnect() + + async def cancel(self, frame: CancelFrame): + """Cancel the Deepgram SageMaker TTS service. + + Args: + frame: The cancel frame. + """ + await super().cancel(frame) + await self._disconnect() + + async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames with special handling for LLM response end. + + Args: + frame: The frame to process. + direction: The direction of frame processing. + """ + await super().process_frame(frame, direction) + + if isinstance(frame, (LLMFullResponseEndFrame, EndFrame)): + await self.flush_audio() + elif isinstance(frame, BotStoppedSpeakingFrame): + self._ttfb_started = False + + async def _connect(self): + """Connect to the SageMaker endpoint and start the BiDi session. + + Builds the Deepgram TTS query string, creates the BiDi client, + starts the streaming session, and launches a background task for processing + responses. + """ + logger.debug("Connecting to Deepgram TTS on SageMaker...") + + query_string = ( + f"model={self._voice_id}&encoding={self._encoding}&sample_rate={self.sample_rate}" + ) + + self._client = SageMakerBidiClient( + endpoint_name=self._endpoint_name, + region=self._region, + model_invocation_path="v1/speak", + model_query_string=query_string, + ) + + try: + await self._client.start_session() + + self._response_task = self.create_task(self._process_responses()) + + logger.debug("Connected to Deepgram TTS on SageMaker") + await self._call_event_handler("on_connected") + + except Exception as e: + await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) + await self._call_event_handler("on_connection_error", str(e)) + + async def _disconnect(self): + """Disconnect from the SageMaker endpoint. + + Sends a Close message to Deepgram, cancels the response processing task, + and closes the BiDi session. Safe to call multiple times. + """ + if self._client and self._client.is_active: + logger.debug("Disconnecting from Deepgram TTS on SageMaker...") + + try: + await self._client.send_json({"type": "Close"}) + except Exception as e: + logger.warning(f"Failed to send Close message: {e}") + + if self._response_task and not self._response_task.done(): + await self.cancel_task(self._response_task) + + await self._client.close_session() + + logger.debug("Disconnected from Deepgram TTS on SageMaker") + await self._call_event_handler("on_disconnected") + + async def _process_responses(self): + """Process streaming responses from Deepgram TTS on SageMaker. + + Continuously receives responses from the BiDi stream. Attempts to decode + each payload as UTF-8 JSON for control messages (Flushed, Cleared, Metadata, + Warning). If decoding fails, treats the payload as raw audio bytes and pushes + a TTSAudioRawFrame downstream. + """ + try: + while self._client and self._client.is_active: + result = await self._client.receive_response() + + if result is None: + break + + if hasattr(result, "value") and hasattr(result.value, "bytes_"): + if result.value.bytes_: + payload = result.value.bytes_ + + # Try to decode as JSON control message first + try: + response_data = payload.decode("utf-8") + parsed = json.loads(response_data) + msg_type = parsed.get("type") + + if msg_type == "Metadata": + logger.trace(f"Received metadata: {parsed}") + elif msg_type == "Flushed": + logger.trace(f"Received Flushed: {parsed}") + elif msg_type == "Cleared": + logger.trace(f"Received Cleared: {parsed}") + elif msg_type == "Warning": + logger.warning( + f"{self} warning: " + f"{parsed.get('description', 'Unknown warning')}" + ) + else: + logger.debug(f"Received unknown message type: {parsed}") + + except (UnicodeDecodeError, json.JSONDecodeError): + # Not JSON — treat as raw audio bytes + await self.stop_ttfb_metrics() + frame = TTSAudioRawFrame( + payload, + self.sample_rate, + 1, + context_id=self._context_id, + ) + await self.push_frame(frame) + + except asyncio.CancelledError: + logger.debug("TTS response processor cancelled") + except Exception as e: + await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) + finally: + logger.debug("TTS response processor stopped") + + async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): + """Handle interruption by sending Clear message to Deepgram. + + The Clear message will clear Deepgram's internal text buffer and stop + sending audio, allowing for a new response to be generated. + """ + await super()._handle_interruption(frame, direction) + self._ttfb_started = False + + if self._client and self._client.is_active: + try: + await self._client.send_json({"type": "Clear"}) + except Exception as e: + logger.error(f"{self} error sending Clear message: {e}") + + async def flush_audio(self): + """Flush any pending audio synthesis by sending Flush command. + + This should be called when the LLM finishes a complete response to force + generation of audio from Deepgram's internal text buffer. + """ + if self._client and self._client.is_active: + try: + await self._client.send_json({"type": "Flush"}) + except Exception as e: + logger.error(f"{self} error sending Flush message: {e}") + + @traced_tts + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: + """Generate speech from text using Deepgram TTS on SageMaker. + + Args: + text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. + + Yields: + Frame: TTSStartedFrame, then None (audio comes asynchronously via + the response processor). + """ + logger.debug(f"{self}: Generating TTS [{text}]") + + try: + if not self._ttfb_started: + await self.start_ttfb_metrics() + self._ttfb_started = True + await self.start_tts_usage_metrics(text) + + yield TTSStartedFrame(context_id=context_id) + self._context_id = context_id + + await self._client.send_json({"type": "Speak", "text": text}) + + yield None + + except Exception as e: + yield ErrorFrame(error=f"Unknown error occurred: {e}") From 62ada92188aff008e43971a5833db53afc6bb0a8 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Fri, 20 Feb 2026 08:09:57 -0700 Subject: [PATCH 79/93] Add changelog for PR #3785 --- changelog/3785.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3785.added.md diff --git a/changelog/3785.added.md b/changelog/3785.added.md new file mode 100644 index 000000000..90a4172d4 --- /dev/null +++ b/changelog/3785.added.md @@ -0,0 +1 @@ +- Added `DeepgramSageMakerTTSService` for running Deepgram TTS models deployed on AWS SageMaker endpoints via HTTP/2 bidirectional streaming. Supports the Deepgram TTS protocol (Speak, Flush, Clear, Close), interruption handling, and per-turn TTFB metrics. From 82ce3ea8de2b0e0577976195c9690dbf7d41ae76 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Fri, 20 Feb 2026 08:10:41 -0700 Subject: [PATCH 80/93] Update 07c example to use DeepgramSageMakerTTSService --- .../07c-interruptible-deepgram-sagemaker.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/examples/foundational/07c-interruptible-deepgram-sagemaker.py b/examples/foundational/07c-interruptible-deepgram-sagemaker.py index f6d1c9354..aced7666f 100644 --- a/examples/foundational/07c-interruptible-deepgram-sagemaker.py +++ b/examples/foundational/07c-interruptible-deepgram-sagemaker.py @@ -23,9 +23,7 @@ from pipecat.processors.aggregators.llm_response_universal import ( from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.aws.llm import AWSBedrockLLMService -from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.deepgram.stt_sagemaker import DeepgramSageMakerSTTService -from pipecat.services.deepgram.tts import DeepgramTTSService from pipecat.services.deepgram.tts_sagemaker import DeepgramSageMakerTTSService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams @@ -59,11 +57,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # This requires: # - AWS credentials configured (via environment variables or AWS CLI) # - A deployed SageMaker endpoint with Deepgram model - # stt = DeepgramSageMakerSTTService( - # endpoint_name=os.getenv("SAGEMAKER_STT_ENDPOINT_NAME"), - # region=os.getenv("AWS_REGION"), - # ) - stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + stt = DeepgramSageMakerSTTService( + endpoint_name=os.getenv("SAGEMAKER_STT_ENDPOINT_NAME"), + region=os.getenv("AWS_REGION"), + ) # Initialize Deepgram SageMaker TTS Service # This requires: From 125c42335607d4f4641a68cf3b66ba502ce09d94 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Fri, 20 Feb 2026 14:57:44 -0300 Subject: [PATCH 81/93] Refactored audio context management in TTS services to improve encapsulation and reduce code duplication --- src/pipecat/services/asyncai/tts.py | 56 +++++++------------- src/pipecat/services/cartesia/tts.py | 38 ++++---------- src/pipecat/services/elevenlabs/tts.py | 73 +++++++++++--------------- src/pipecat/services/gradium/tts.py | 35 ++++-------- src/pipecat/services/inworld/tts.py | 71 +++++++++---------------- src/pipecat/services/resembleai/tts.py | 3 +- src/pipecat/services/rime/tts.py | 36 ++++--------- src/pipecat/services/tts_service.py | 63 +++++++++++++++++++++- 8 files changed, 167 insertions(+), 208 deletions(-) diff --git a/src/pipecat/services/asyncai/tts.py b/src/pipecat/services/asyncai/tts.py index e9170f8b6..69ed90ca1 100644 --- a/src/pipecat/services/asyncai/tts.py +++ b/src/pipecat/services/asyncai/tts.py @@ -9,7 +9,6 @@ import asyncio import base64 import json -import uuid from typing import AsyncGenerator, Optional import aiohttp @@ -148,7 +147,6 @@ class AsyncAITTSService(AudioContextTTSService): self._receive_task = None self._keepalive_task = None - self._context_id = None def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -255,7 +253,7 @@ class AsyncAITTSService(AudioContextTTSService): if self._websocket: logger.debug("Disconnecting from Async") # Close all contexts and the socket - if self._context_id: + if self.has_active_audio_context(): await self._websocket.send(json.dumps({"terminate": True})) await self._websocket.close() logger.debug("Disconnected from Async") @@ -263,7 +261,7 @@ class AsyncAITTSService(AudioContextTTSService): await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) finally: self._websocket = None - self._context_id = None + await self.remove_active_audio_context() await self._call_event_handler("on_disconnected") def _get_websocket(self): @@ -271,26 +269,13 @@ class AsyncAITTSService(AudioContextTTSService): return self._websocket raise Exception("Websocket not connected") - def create_context_id(self) -> str: - """Generate a unique context ID for a TTS request in case we don't have one already in progress. - - Returns: - A unique string identifier for the TTS context. - """ - # If a context ID does not exist, create a new one. - # If an ID exists, continue using the current ID. - # When interruptions happen, user speech results in - # an interruption, which resets the context ID. - if not self._context_id: - return str(uuid.uuid4()) - return self._context_id - async def flush_audio(self): """Flush any pending audio.""" - if not self._context_id or not self._websocket: + context_id = self.get_active_audio_context_id() + if not context_id or not self._websocket: return logger.trace(f"{self}: flushing audio") - msg = self._build_msg(text=" ", context_id=self._context_id, force=True) + msg = self._build_msg(text=" ", context_id=context_id, force=True) await self._websocket.send(msg) async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): @@ -318,11 +303,11 @@ class AsyncAITTSService(AudioContextTTSService): # Check if this message belongs to the current context. if not self.audio_context_available(received_ctx_id): - if self._context_id == received_ctx_id: + if self.get_active_audio_context_id() == received_ctx_id: logger.debug( - f"Received a delayed message, recreating the context: {self._context_id}" + f"Received a delayed message, recreating the context: {received_ctx_id}" ) - await self.create_audio_context(self._context_id) + await self.create_audio_context(received_ctx_id) else: # This can happen if a message is received _after_ we have closed a context # due to user interruption but _before_ the `isFinal` message for the context @@ -343,10 +328,11 @@ class AsyncAITTSService(AudioContextTTSService): await asyncio.sleep(KEEPALIVE_SLEEP) try: if self._websocket and self._websocket.state is State.OPEN: - if self._context_id: + context_id = self.get_active_audio_context_id() + if context_id: keepalive_message = { "transcript": " ", - "context_id": self._context_id, + "context_id": context_id, } logger.trace("Sending keepalive message") else: @@ -362,19 +348,16 @@ class AsyncAITTSService(AudioContextTTSService): async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): """Handle interruption by closing the current context.""" + context_id = self.get_active_audio_context_id() await super()._handle_interruption(frame, direction) - # Close the current context when interrupted without closing the websocket - if self._context_id and self._websocket: + if context_id and self._websocket: try: await self._websocket.send( - json.dumps( - {"context_id": self._context_id, "close_context": True, "transcript": ""} - ) + json.dumps({"context_id": context_id, "close_context": True, "transcript": ""}) ) except Exception as e: logger.error(f"Error closing context on interruption: {e}") - self._context_id = None @traced_tts async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: @@ -394,16 +377,13 @@ class AsyncAITTSService(AudioContextTTSService): await self._connect() try: - if not self._context_id: + if not self.has_active_audio_context(): await self.start_ttfb_metrics() yield TTSStartedFrame(context_id=context_id) + if not self.audio_context_available(context_id): + await self.create_audio_context(context_id) - self._context_id = context_id - - if not self.audio_context_available(self._context_id): - await self.create_audio_context(self._context_id) - - msg = self._build_msg(text=text, force=True, context_id=self._context_id) + msg = self._build_msg(text=text, force=True, context_id=context_id) await self._get_websocket().send(msg) await self.start_tts_usage_metrics(text) diff --git a/src/pipecat/services/cartesia/tts.py b/src/pipecat/services/cartesia/tts.py index 1fa9a026a..e30acdcd1 100644 --- a/src/pipecat/services/cartesia/tts.py +++ b/src/pipecat/services/cartesia/tts.py @@ -8,7 +8,6 @@ import base64 import json -import uuid import warnings from enum import Enum from typing import AsyncGenerator, List, Literal, Optional @@ -307,7 +306,6 @@ class CartesiaTTSService(AudioContextWordTTSService): self.set_model_name(model) self.set_voice(voice_id) - self._context_id = None self._receive_task = None def can_generate_metrics(self) -> bool: @@ -430,7 +428,7 @@ class CartesiaTTSService(AudioContextWordTTSService): msg = { "transcript": text, "continue": continue_transcript, - "context_id": self._context_id, + "context_id": self.get_active_audio_context_id(), "model_id": self.model_name, "voice": voice_config, "output_format": self._settings["output_format"], @@ -523,7 +521,7 @@ class CartesiaTTSService(AudioContextWordTTSService): except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) finally: - self._context_id = None + await self.remove_active_audio_context() self._websocket = None await self._call_event_handler("on_disconnected") @@ -533,35 +531,22 @@ class CartesiaTTSService(AudioContextWordTTSService): raise Exception("Websocket not connected") async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): + context_id = self.get_active_audio_context_id() await super()._handle_interruption(frame, direction) await self.stop_all_metrics() - if self._context_id: - cancel_msg = json.dumps({"context_id": self._context_id, "cancel": True}) + if context_id: + cancel_msg = json.dumps({"context_id": context_id, "cancel": True}) await self._get_websocket().send(cancel_msg) - self._context_id = None - - def create_context_id(self) -> str: - """Generate a unique context ID for a TTS request in case we don't have one already in progress. - - Returns: - A unique string identifier for the TTS context. - """ - # If a context ID does not exist, create a new one. - # If an ID exists, continue using the current ID. - # When interruptions happen, user speech results in - # an interruption, which resets the context ID. - if not self._context_id: - return str(uuid.uuid4()) - return self._context_id async def flush_audio(self): """Flush any pending audio and finalize the current context.""" - if not self._context_id or not self._websocket: + context_id = self.get_active_audio_context_id() + if not context_id or not self._websocket: return logger.trace(f"{self}: flushing audio") msg = self._build_msg(text="", continue_transcript=False) await self._websocket.send(msg) - self._context_id = None + self.reset_active_audio_context() async def _process_messages(self): async for message in self._get_websocket(): @@ -593,7 +578,7 @@ class CartesiaTTSService(AudioContextWordTTSService): await self.push_frame(TTSStoppedFrame(context_id=ctx_id)) await self.stop_all_metrics() await self.push_error(error_msg=f"Error: {msg}") - self._context_id = None + self.reset_active_audio_context() else: await self.push_error(error_msg=f"Error, unknown message type: {msg}") @@ -622,11 +607,10 @@ class CartesiaTTSService(AudioContextWordTTSService): if not self._websocket or self._websocket.state is State.CLOSED: await self._connect() - if not self._context_id: + if not self.has_active_audio_context(): await self.start_ttfb_metrics() yield TTSStartedFrame(context_id=context_id) - self._context_id = context_id - await self.create_audio_context(self._context_id) + await self.create_audio_context(context_id) msg = self._build_msg(text=text) diff --git a/src/pipecat/services/elevenlabs/tts.py b/src/pipecat/services/elevenlabs/tts.py index 7df891f0e..be57a1a3d 100644 --- a/src/pipecat/services/elevenlabs/tts.py +++ b/src/pipecat/services/elevenlabs/tts.py @@ -13,7 +13,6 @@ with support for streaming audio, word timestamps, and voice customization. import asyncio import base64 import json -import uuid from typing import Any, AsyncGenerator, Dict, List, Literal, Mapping, Optional, Tuple, Union import aiohttp @@ -343,7 +342,6 @@ class ElevenLabsTTSService(AudioContextWordTTSService): self._partial_word_start_time = 0.0 # Context management for v1 multi API - self._context_id = None self._receive_task = None self._keepalive_task = None @@ -411,18 +409,19 @@ class ElevenLabsTTSService(AudioContextWordTTSService): ) await self._disconnect() await self._connect() - elif voice_settings_changed and self._context_id: + elif voice_settings_changed and self.has_active_audio_context(): # Voice settings can be updated by closing current context # so new one gets created with updated voice settings logger.debug(f"Voice settings changed, closing current context to apply changes") + context_id = self.get_active_audio_context_id() try: if self._websocket: await self._websocket.send( - json.dumps({"context_id": self._context_id, "close_context": True}) + json.dumps({"context_id": context_id, "close_context": True}) ) except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) - self._context_id = None + self.reset_active_audio_context() async def start(self, frame: StartFrame): """Start the ElevenLabs TTS service. @@ -454,10 +453,11 @@ class ElevenLabsTTSService(AudioContextWordTTSService): async def flush_audio(self): """Flush any pending audio and finalize the current context.""" - if not self._context_id or not self._websocket: + context_id = self.get_active_audio_context_id() + if not context_id or not self._websocket: return logger.trace(f"{self}: flushing audio") - msg = {"context_id": self._context_id, "flush": True} + msg = {"context_id": context_id, "flush": True} await self._websocket.send(json.dumps(msg)) async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): @@ -470,7 +470,7 @@ class ElevenLabsTTSService(AudioContextWordTTSService): await super().push_frame(frame, direction) if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)): if isinstance(frame, TTSStoppedFrame): - await self.add_word_timestamps([("Reset", 0)], self._context_id) + await self.add_word_timestamps([("Reset", 0)], self.get_active_audio_context_id()) async def _connect(self): await super()._connect() @@ -545,14 +545,14 @@ class ElevenLabsTTSService(AudioContextWordTTSService): if self._websocket: logger.debug("Disconnecting from ElevenLabs") # Close all contexts and the socket - if self._context_id: + if self.has_active_audio_context(): await self._websocket.send(json.dumps({"close_socket": True})) await self._websocket.close() logger.debug("Disconnected from ElevenLabs") except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) finally: - self._context_id = None + await self.remove_active_audio_context() self._websocket = None await self._call_event_handler("on_disconnected") @@ -563,11 +563,12 @@ class ElevenLabsTTSService(AudioContextWordTTSService): async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): """Handle interruption by closing the current context.""" + # Close the current context when interrupted without closing the websocket + context_id = self.get_active_audio_context_id() await super()._handle_interruption(frame, direction) - # Close the current context when interrupted without closing the websocket - if self._context_id and self._websocket: - logger.trace(f"Closing context {self._context_id} due to interruption") + if context_id and self._websocket: + logger.trace(f"Closing context {context_id} due to interruption") try: # ElevenLabs requires that Pipecat manages the contexts and closes them # when they're not longer in use. Since an InterruptionFrame is pushed @@ -576,11 +577,10 @@ class ElevenLabsTTSService(AudioContextWordTTSService): # Note: We do not need to call remove_audio_context here, as the context is # automatically reset when super ()._handle_interruption is called. await self._websocket.send( - json.dumps({"context_id": self._context_id, "close_context": True}) + json.dumps({"context_id": context_id, "close_context": True}) ) except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) - self._context_id = None self._partial_word = "" self._partial_word_start_time = 0.0 @@ -600,11 +600,11 @@ class ElevenLabsTTSService(AudioContextWordTTSService): # Check if this message belongs to the current context. if not self.audio_context_available(received_ctx_id): - if self._context_id == received_ctx_id: + if self.get_active_audio_context_id() == received_ctx_id: logger.debug( - f"Received a delayed message, recreating the context: {self._context_id}" + f"Received a delayed message, recreating the context: {received_ctx_id}" ) - await self.create_audio_context(self._context_id) + await self.create_audio_context(received_ctx_id) else: # This can happen if a message is received _after_ we have closed a context # due to user interruption but _before_ the `isFinal` message for the context @@ -657,13 +657,14 @@ class ElevenLabsTTSService(AudioContextWordTTSService): await asyncio.sleep(KEEPALIVE_SLEEP) try: if self._websocket and self._websocket.state is State.OPEN: - if self._context_id: + context_id = self.get_active_audio_context_id() + if context_id: # Send keepalive with context ID to keep the connection alive keepalive_message = { "text": "", - "context_id": self._context_id, + "context_id": context_id, } - logger.trace(f"Sending keepalive for context {self._context_id}") + logger.trace(f"Sending keepalive for context {context_id}") else: # It's possible to have a user interruption which clears the context # without generating a new TTS response. In this case, we'll just send @@ -677,24 +678,11 @@ class ElevenLabsTTSService(AudioContextWordTTSService): async def _send_text(self, text: str): """Send text to the WebSocket for synthesis.""" - if self._websocket and self._context_id: - msg = {"text": text, "context_id": self._context_id} + context_id = self.get_active_audio_context_id() + if self._websocket and context_id: + msg = {"text": text, "context_id": context_id} await self._websocket.send(json.dumps(msg)) - def create_context_id(self) -> str: - """Generate a unique context ID for a TTS request in case we don't have one already in progress. - - Returns: - A unique string identifier for the TTS context. - """ - # If a context ID does not exist, create a new one. - # If an ID exists, continue using the current ID. - # When interruptions happens, user speech results in - # an interruption, which resets the context ID. - if not self._context_id: - return str(uuid.uuid4()) - return self._context_id - @traced_tts async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using ElevenLabs' streaming WebSocket API. @@ -713,19 +701,18 @@ class ElevenLabsTTSService(AudioContextWordTTSService): await self._connect() try: - if not self._context_id: + if not self.has_active_audio_context(): await self.start_ttfb_metrics() yield TTSStartedFrame(context_id=context_id) - self._context_id = context_id self._cumulative_time = 0 self._partial_word = "" self._partial_word_start_time = 0.0 - if not self.audio_context_available(self._context_id): - await self.create_audio_context(self._context_id) + if not self.audio_context_available(context_id): + await self.create_audio_context(context_id) # Initialize context with voice settings and pronunciation dictionaries - msg = {"text": " ", "context_id": self._context_id} + msg = {"text": " ", "context_id": context_id} if self._voice_settings: msg["voice_settings"] = self._voice_settings if self._pronunciation_dictionary_locators: @@ -734,7 +721,7 @@ class ElevenLabsTTSService(AudioContextWordTTSService): for locator in self._pronunciation_dictionary_locators ] await self._websocket.send(json.dumps(msg)) - logger.trace(f"Created new context {self._context_id}") + logger.trace(f"Created new context {context_id}") await self._send_text(text) await self.start_tts_usage_metrics(text) diff --git a/src/pipecat/services/gradium/tts.py b/src/pipecat/services/gradium/tts.py index ef3cbbfde..98e08a9d3 100644 --- a/src/pipecat/services/gradium/tts.py +++ b/src/pipecat/services/gradium/tts.py @@ -6,7 +6,6 @@ import base64 import json -import uuid from typing import Any, AsyncGenerator, Mapping, Optional from loguru import logger @@ -97,7 +96,6 @@ class GradiumTTSService(AudioContextWordTTSService): # State tracking self._receive_task = None - self._context_id: Optional[str] = None def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -129,8 +127,9 @@ class GradiumTTSService(AudioContextWordTTSService): def _build_msg(self, text: str = "") -> dict: """Build JSON message for Gradium API.""" msg = {"text": text, "type": "text"} - if self._context_id: - msg["client_req_id"] = self._context_id + context_id = self.get_active_audio_context_id() + if context_id: + msg["client_req_id"] = context_id return msg async def start(self, frame: StartFrame): @@ -229,6 +228,7 @@ class GradiumTTSService(AudioContextWordTTSService): except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) finally: + await self.remove_active_audio_context() self._websocket = None await self._call_event_handler("on_disconnected") @@ -240,12 +240,13 @@ class GradiumTTSService(AudioContextWordTTSService): async def flush_audio(self): """Flush any pending audio synthesis.""" - if not self._context_id or not self._websocket: + context_id = self.get_active_audio_context_id() + if not context_id or not self._websocket: return try: - msg = {"type": "end_of_stream", "client_req_id": self._context_id} + msg = {"type": "end_of_stream", "client_req_id": context_id} await self._websocket.send(json.dumps(msg)) - self._context_id = None + self.reset_active_audio_context() except ConnectionClosedOK: logger.debug(f"{self}: connection closed normally during flush") except Exception as e: @@ -265,7 +266,6 @@ class GradiumTTSService(AudioContextWordTTSService): """ await super()._handle_interruption(frame, direction) await self.stop_all_metrics() - self._context_id = None async def _receive_messages(self): """Process incoming websocket messages, demultiplexing by client_req_id.""" @@ -306,20 +306,6 @@ class GradiumTTSService(AudioContextWordTTSService): await self.stop_all_metrics() await self.push_error(error_msg=f"Error: {msg.get('message', msg)}") - def create_context_id(self) -> str: - """Generate a unique context ID for a TTS request in case we don't have one already in progress. - - Returns: - A unique string identifier for the TTS context. - """ - # If a context ID does not exist, create a new one. - # If an ID exists, continue using the current ID. - # When interruptions happens, user speech results in - # an interruption, which resets the context ID. - if not self._context_id: - return str(uuid.uuid4()) - return self._context_id - @traced_tts async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Gradium's streaming API. @@ -338,11 +324,10 @@ class GradiumTTSService(AudioContextWordTTSService): await self._connect() try: - if not self._context_id: + if not self.has_active_audio_context(): await self.start_ttfb_metrics() yield TTSStartedFrame(context_id=context_id) - self._context_id = context_id - await self.create_audio_context(self._context_id) + await self.create_audio_context(context_id) msg = self._build_msg(text=text) await self._get_websocket().send(json.dumps(msg)) diff --git a/src/pipecat/services/inworld/tts.py b/src/pipecat/services/inworld/tts.py index 9767c1b0a..adf72ce9f 100644 --- a/src/pipecat/services/inworld/tts.py +++ b/src/pipecat/services/inworld/tts.py @@ -517,7 +517,6 @@ class InworldTTSService(AudioContextWordTTSService): self._receive_task = None self._keepalive_task = None - self._context_id = None # Track cumulative time across generations for monotonic timestamps within a turn. # When auto_mode is enabled, the server controls generations and timestamps reset @@ -573,9 +572,10 @@ class InworldTTSService(AudioContextWordTTSService): keeping the context open for subsequent text. The context is only closed on interruption, disconnect, or end of session. """ - if self._context_id and self._websocket: - logger.trace(f"Flushing audio for context {self._context_id}") - await self._send_flush(self._context_id) + context_id = self.get_active_audio_context_id() + if context_id and self._websocket: + logger.trace(f"Flushing audio for context {context_id}") + await self._send_flush(context_id) async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): """Push a frame and handle state changes. @@ -640,7 +640,7 @@ class InworldTTSService(AudioContextWordTTSService): frame: The interruption frame. direction: The direction of the interruption. """ - old_context_id = self._context_id + old_context_id = self.get_active_audio_context_id() logger.trace(f"{self}: Handling interruption, old context: {old_context_id}") await super()._handle_interruption(frame, direction) @@ -652,7 +652,6 @@ class InworldTTSService(AudioContextWordTTSService): except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) - self._context_id = None self._cumulative_time = 0.0 self._generation_end_time = 0.0 logger.trace(f"{self}: Interruption handled, context reset to None") @@ -736,9 +735,10 @@ class InworldTTSService(AudioContextWordTTSService): if self._websocket: logger.debug("Disconnecting from Inworld WebSocket TTS") - if self._context_id: + context_id = self.get_active_audio_context_id() + if context_id: try: - await self._send_close_context(self._context_id) + await self._send_close_context(context_id) except Exception: pass await self._websocket.close() @@ -746,7 +746,7 @@ class InworldTTSService(AudioContextWordTTSService): except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) finally: - self._context_id = None + await self.remove_active_audio_context() self._websocket = None self._cumulative_time = 0.0 self._generation_end_time = 0.0 @@ -772,7 +772,7 @@ class InworldTTSService(AudioContextWordTTSService): ] logger.debug( f"{self}: Received message types={msg_types}, ctx_id={ctx_id}, " - f"current_ctx={self._context_id}, available={self.audio_context_available(ctx_id) if ctx_id else 'N/A'}" + f"current_ctx={self.get_active_audio_context_id()}, available={self.audio_context_available(ctx_id) if ctx_id else 'N/A'}" ) # Check for errors @@ -784,7 +784,9 @@ class InworldTTSService(AudioContextWordTTSService): # Handle "Context not found" error (code 5) # This can happen when a keepalive message is sent but no context is available. if error_code == 5 and "not found" in error_msg.lower(): - logger.debug(f"{self}: Context {ctx_id or self._context_id} not found.") + logger.debug( + f"{self}: Context {ctx_id or self.get_active_audio_context_id()} not found." + ) continue # For other errors, push error frame @@ -799,11 +801,9 @@ class InworldTTSService(AudioContextWordTTSService): # If the context isn't available but matches our current context ID, # recreate it (handles race conditions during interruption recovery). if ctx_id and not self.audio_context_available(ctx_id): - if self._context_id == ctx_id: - logger.trace( - f"{self}: Recreating audio context for current context: {self._context_id}" - ) - await self.create_audio_context(self._context_id) + if self.get_active_audio_context_id() == ctx_id: + logger.trace(f"{self}: Recreating audio context for current context: {ctx_id}") + await self.create_audio_context(ctx_id) else: # This is a message from an old/closed context - skip it logger.trace(f"{self}: Skipping message from unavailable context: {ctx_id}") @@ -849,8 +849,8 @@ class InworldTTSService(AudioContextWordTTSService): logger.trace(f"{self}: Context closed on server: {ctx_id}") await self.stop_ttfb_metrics() # Only reset if this is our current context - if ctx_id == self._context_id: - self._context_id = None + if ctx_id == self.get_active_audio_context_id(): + self.reset_active_audio_context() if ctx_id and self.audio_context_available(ctx_id): await self.remove_audio_context(ctx_id) await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)], ctx_id) @@ -862,12 +862,13 @@ class InworldTTSService(AudioContextWordTTSService): await asyncio.sleep(KEEPALIVE_SLEEP) try: if self._websocket and self._websocket.state is State.OPEN: - if self._context_id: + context_id = self.get_active_audio_context_id() + if context_id: keepalive_message = { "send_text": {"text": ""}, - "contextId": self._context_id, + "contextId": context_id, } - logger.trace(f"Sending keepalive for context {self._context_id}") + logger.trace(f"Sending keepalive for context {context_id}") else: keepalive_message = {"send_text": {"text": ""}} logger.trace("Sending keepalive without context") @@ -938,20 +939,6 @@ class InworldTTSService(AudioContextWordTTSService): msg = {"close_context": {}, "contextId": context_id} await self.send_with_retry(json.dumps(msg), self._report_error) - def create_context_id(self) -> str: - """Generate a unique context ID for a TTS request in case we don't have one already in progress. - - Returns: - A unique string identifier for the TTS context. - """ - # If a context ID does not exist, create a new one. - # If an ID exists, continue using the current ID. - # When interruptions happen, user speech results in - # an interruption, which resets the context ID. - if not self._context_id: - return str(uuid.uuid4()) - return self._context_id - @traced_tts async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate TTS audio for the given text using the Inworld WebSocket TTS service. @@ -970,19 +957,13 @@ class InworldTTSService(AudioContextWordTTSService): await self._connect() try: - if not self._context_id: + if not self.has_active_audio_context(): await self.start_ttfb_metrics() yield TTSStartedFrame(context_id=context_id) - self._context_id = context_id - logger.trace(f"{self}: Creating new context {self._context_id}") - await self.create_audio_context(self._context_id) - await self._send_context(self._context_id) - elif not self.audio_context_available(self._context_id): - # Context exists on server but local tracking was removed - logger.trace(f"{self}: Recreating local audio context {self._context_id}") - await self.create_audio_context(self._context_id) + await self.create_audio_context(context_id) + await self._send_context(context_id) - await self._send_text(self._context_id, text) + await self._send_text(context_id, text) await self.start_tts_usage_metrics(text) except Exception as e: diff --git a/src/pipecat/services/resembleai/tts.py b/src/pipecat/services/resembleai/tts.py index 964b9fa18..c51ccc07b 100644 --- a/src/pipecat/services/resembleai/tts.py +++ b/src/pipecat/services/resembleai/tts.py @@ -25,8 +25,6 @@ from pipecat.frames.frames import ( ) from pipecat.processors.frame_processor import FrameDirection from pipecat.services.tts_service import AudioContextWordTTSService -from pipecat.transcriptions.language import Language -from pipecat.utils.text.base_text_aggregator import BaseTextAggregator from pipecat.utils.tracing.service_decorators import traced_tts try: @@ -70,6 +68,7 @@ class ResembleAITTSService(AudioContextWordTTSService): """ super().__init__( sample_rate=sample_rate, + reuse_context_id_within_turn=False, **kwargs, ) diff --git a/src/pipecat/services/rime/tts.py b/src/pipecat/services/rime/tts.py index cf3c6d5ca..c4b1c870a 100644 --- a/src/pipecat/services/rime/tts.py +++ b/src/pipecat/services/rime/tts.py @@ -12,7 +12,6 @@ using Rime's API for streaming and batch audio synthesis. import base64 import json -import uuid from typing import Any, AsyncGenerator, Mapping, Optional import aiohttp @@ -167,7 +166,6 @@ class RimeTTSService(AudioContextWordTTSService): self._settings = self._build_settings() # State tracking - self._context_id = None # Tracks current turn self._receive_task = None self._cumulative_time = 0 # Accumulates time across messages self._extra_msg_fields = {} # Extra fields for next message @@ -339,7 +337,7 @@ class RimeTTSService(AudioContextWordTTSService): def _build_msg(self, text: str = "") -> dict: """Build JSON message for Rime API.""" - msg = {"text": text, "contextId": self._context_id} + msg = {"text": text, "contextId": self.get_active_audio_context_id()} if self._extra_msg_fields: msg |= self._extra_msg_fields self._extra_msg_fields = {} @@ -427,7 +425,7 @@ class RimeTTSService(AudioContextWordTTSService): except Exception as e: await self.push_error(error_msg=f"Error disconnecting: {e}", exception=e) finally: - self._context_id = None + await self.remove_active_audio_context() self._websocket = None await self._call_event_handler("on_disconnected") @@ -439,11 +437,11 @@ class RimeTTSService(AudioContextWordTTSService): async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): """Handle interruption by clearing current context.""" + context_id = self.get_active_audio_context_id() await super()._handle_interruption(frame, direction) await self.stop_all_metrics() - if self._context_id: + if context_id: await self._get_websocket().send(json.dumps(self._build_clear_msg())) - self._context_id = None def _calculate_word_times(self, words: list, starts: list, ends: list) -> list: """Calculate word timing pairs with proper spacing and punctuation. @@ -474,28 +472,15 @@ class RimeTTSService(AudioContextWordTTSService): return word_pairs - def create_context_id(self) -> str: - """Generate a unique context ID for a TTS request in case we don't have one already in progress. - - Returns: - A unique string identifier for the TTS context. - """ - # If a context ID does not exist, create a new one. - # If an ID exists, continue using the current ID. - # When interruptions happen, user speech results in - # an interruption, which resets the context ID. - if not self._context_id: - return str(uuid.uuid4()) - return self._context_id - async def flush_audio(self): """Flush any pending audio synthesis.""" - if not self._context_id or not self._websocket: + context_id = self.get_active_audio_context_id() + if not context_id or not self._websocket: return logger.trace(f"{self}: flushing audio") await self._get_websocket().send(json.dumps({"operation": "flush"})) - self._context_id = None + self.reset_active_audio_context() async def _receive_messages(self): """Process incoming websocket messages.""" @@ -537,7 +522,7 @@ class RimeTTSService(AudioContextWordTTSService): await self.push_frame(TTSStoppedFrame()) await self.stop_all_metrics() await self.push_error(error_msg=f"Error: {msg['message']}") - self._context_id = None + self.reset_active_audio_context() async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): """Push frame and handle end-of-turn conditions. @@ -568,12 +553,11 @@ class RimeTTSService(AudioContextWordTTSService): await self._connect() try: - if not self._context_id: + if not self.has_active_audio_context(): await self.start_ttfb_metrics() yield TTSStartedFrame(context_id=context_id) self._cumulative_time = 0 - self._context_id = context_id - await self.create_audio_context(self._context_id) + await self.create_audio_context(context_id) msg = self._build_msg(text=text) await self._get_websocket().send(json.dumps(msg)) diff --git a/src/pipecat/services/tts_service.py b/src/pipecat/services/tts_service.py index 02c799d0f..1948d3da2 100644 --- a/src/pipecat/services/tts_service.py +++ b/src/pipecat/services/tts_service.py @@ -1042,14 +1042,23 @@ class AudioContextTTSService(WebsocketTTSService): audio from context ID "A" will be played first. """ - def __init__(self, *, reconnect_on_error: bool = True, **kwargs): + def __init__( + self, + *, + reuse_context_id_within_turn: bool = True, + reconnect_on_error: bool = True, + **kwargs, + ): """Initialize the Audio Context TTS service. Args: + reuse_context_id_within_turn: Whether the service should reuse context IDs within the same turn. reconnect_on_error: Whether to automatically reconnect on websocket errors. **kwargs: Additional arguments passed to the parent WebsocketTTSService. """ super().__init__(reconnect_on_error=reconnect_on_error, **kwargs) + self._reuse_context_id_within_turn = reuse_context_id_within_turn + self._context_id = None self._contexts: Dict[str, asyncio.Queue] = {} self._audio_context_task = None @@ -1059,6 +1068,10 @@ class AudioContextTTSService(WebsocketTTSService): Args: context_id: Unique identifier for the audio context. """ + # Set the context ID if not already set + if not self._context_id: + self._context_id = context_id + await self._contexts_queue.put(context_id) self._contexts[context_id] = asyncio.Queue() logger.trace(f"{self} created audio context {context_id}") @@ -1091,6 +1104,32 @@ class AudioContextTTSService(WebsocketTTSService): else: logger.warning(f"{self} unable to remove context {context_id}") + def has_active_audio_context(self) -> bool: + """Check if there is an active audio context. + + Returns: + True if an active audio context exists, False otherwise. + """ + return self._context_id is not None and self.audio_context_available(self._context_id) + + def get_active_audio_context_id(self) -> Optional[str]: + """Get the active audio context ID. + + Returns: + The active context ID, or None if no context is active. + """ + return self._context_id + + async def remove_active_audio_context(self): + """Remove the active audio context.""" + if self._context_id: + await self.remove_audio_context(self._context_id) + self.reset_active_audio_context() + + def reset_active_audio_context(self): + """Reset the active audio context.""" + self._context_id = None + def audio_context_available(self, context_id: str) -> bool: """Check whether the given audio context is registered. @@ -1102,6 +1141,20 @@ class AudioContextTTSService(WebsocketTTSService): """ return context_id in self._contexts + def create_context_id(self) -> str: + """Generate or reuse a context ID based on concurrent TTS support. + + If _reuse_context_id_within_turn is False and a context already exists, + the existing context ID is returned. Otherwise, a new unique context + ID is generated. + + Returns: + A context ID string for the TTS request. + """ + if self._reuse_context_id_within_turn and self._context_id: + return self._context_id + return super().create_context_id() + async def start(self, frame: StartFrame): """Start the audio context TTS service. @@ -1137,6 +1190,7 @@ class AudioContextTTSService(WebsocketTTSService): async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): await super()._handle_interruption(frame, direction) await self._stop_audio_context_task() + self.reset_active_audio_context() self._create_audio_context_task() def _create_audio_context_task(self): @@ -1155,6 +1209,7 @@ class AudioContextTTSService(WebsocketTTSService): running = True while running: context_id = await self._contexts_queue.get() + self._context_id = context_id if context_id: # Process the audio context until the context doesn't have more @@ -1163,11 +1218,15 @@ class AudioContextTTSService(WebsocketTTSService): # We just finished processing the context, so we can safely remove it. del self._contexts[context_id] + self.reset_active_audio_context() # Append some silence between sentences. silence = b"\x00" * self.sample_rate frame = TTSAudioRawFrame( - audio=silence, sample_rate=self.sample_rate, num_channels=1 + audio=silence, + sample_rate=self.sample_rate, + num_channels=1, + context_id=context_id, ) await self.push_frame(frame) else: From fa659311b6c962e78ff7bc374a26112ceffd1f0a Mon Sep 17 00:00:00 2001 From: filipi87 Date: Fri, 20 Feb 2026 14:57:59 -0300 Subject: [PATCH 82/93] Changelog entry --- changelog/3732.changed.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog/3732.changed.md diff --git a/changelog/3732.changed.md b/changelog/3732.changed.md new file mode 100644 index 000000000..22681cf04 --- /dev/null +++ b/changelog/3732.changed.md @@ -0,0 +1,3 @@ +- Improved audio context management in `AudioContextTTSService` by moving context ID tracking to the base class and adding `reuse_context_id_within_turn` parameter to control concurrent TTS request handling. + - Added helper methods: `has_active_audio_context()`, `get_active_audio_context_id()`, `remove_active_audio_context()`, `reset_active_audio_context()` + - Simplified Cartesia, ElevenLabs, Inworld, Rime, AsyncAI, and Gradium TTS implementations by removing duplicate context management code From c49eda98e79aaf8441c7508dfa3efe5ac1c74547 Mon Sep 17 00:00:00 2001 From: Daksh Dua Date: Fri, 20 Feb 2026 15:37:14 -0300 Subject: [PATCH 83/93] Fix race condition where context times out after sending second transcript --- src/pipecat/services/tts_service.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/pipecat/services/tts_service.py b/src/pipecat/services/tts_service.py index 1948d3da2..1e5bdf73f 100644 --- a/src/pipecat/services/tts_service.py +++ b/src/pipecat/services/tts_service.py @@ -1042,6 +1042,8 @@ class AudioContextTTSService(WebsocketTTSService): audio from context ID "A" will be played first. """ + _CONTEXT_KEEPALIVE = object() + def __init__( self, *, @@ -1152,9 +1154,15 @@ class AudioContextTTSService(WebsocketTTSService): A context ID string for the TTS request. """ if self._reuse_context_id_within_turn and self._context_id: + self._refresh_active_audio_context() return self._context_id return super().create_context_id() + def _refresh_active_audio_context(self): + """Signal that the audio context is still in use, resetting the timeout.""" + if self.has_active_audio_context(): + self._contexts[self._context_id].put_nowait(AudioContextTTSService._CONTEXT_KEEPALIVE) + async def start(self, frame: StartFrame): """Start the audio context TTS service. @@ -1242,6 +1250,10 @@ class AudioContextTTSService(WebsocketTTSService): while running: try: frame = await asyncio.wait_for(queue.get(), timeout=AUDIO_CONTEXT_TIMEOUT) + if frame is AudioContextTTSService._CONTEXT_KEEPALIVE: + # Context is still in use, reset the timeout. + continue + if frame: await self.push_frame(frame) running = frame is not None From 023063759aa3ae38289ba23a140289169da5ea1b Mon Sep 17 00:00:00 2001 From: Daksh Dua Date: Fri, 20 Feb 2026 16:00:34 -0300 Subject: [PATCH 84/93] Changelog entry for TTS race condition fix. --- changelog/3787.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3787.fixed.md diff --git a/changelog/3787.fixed.md b/changelog/3787.fixed.md new file mode 100644 index 000000000..ff11ada71 --- /dev/null +++ b/changelog/3787.fixed.md @@ -0,0 +1 @@ +- Fixed a race condition in `AudioContextTTSService` where the audio context could time out between consecutive TTS requests within the same turn, causing audio to be discarded. From fecf462139b0f4af9e4a8385f8233f737ae63bcf Mon Sep 17 00:00:00 2001 From: dhruvladia-sarvam Date: Sat, 21 Feb 2026 01:02:37 +0530 Subject: [PATCH 85/93] initial --- pyproject.toml | 2 +- src/pipecat/services/sarvam/stt.py | 43 ++++++++++++++++++++++-------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e71202252..db76fa24e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,7 +108,7 @@ riva = [ "pipecat-ai[nvidia]" ] runner = [ "python-dotenv>=1.0.0,<2.0.0", "uvicorn>=0.32.0,<1.0.0", "fastapi>=0.115.6,<0.128.0", "pipecat-ai-small-webrtc-prebuilt>=2.2.0"] sagemaker = ["aws_sdk_sagemaker_runtime_http2; python_version>='3.12'"] sambanova = [] -sarvam = [ "sarvamai==0.1.21", "pipecat-ai[websockets-base]" ] +sarvam = [ "sarvamai==0.1.26a2", "pipecat-ai[websockets-base]" ] sentry = [ "sentry-sdk>=2.28.0,<3" ] silero = [ "onnxruntime~=1.23.2" ] simli = [ "simli-ai~=2.0.1"] diff --git a/src/pipecat/services/sarvam/stt.py b/src/pipecat/services/sarvam/stt.py index 13277fe96..e8f2d55ce 100644 --- a/src/pipecat/services/sarvam/stt.py +++ b/src/pipecat/services/sarvam/stt.py @@ -119,10 +119,10 @@ MODEL_CONFIGS: Dict[str, ModelConfig] = { use_translate_method=True, ), "saaras:v3": ModelConfig( - supports_prompt=True, + supports_prompt=False, supports_mode=True, supports_language=True, - default_language="en-IN", + default_language="unknown", default_mode="transcribe", use_translate_endpoint=False, use_translate_method=False, @@ -155,9 +155,9 @@ class SarvamSTTService(STTService): language: Target language for transcription. - saarika:v2.5: Defaults to "unknown" (auto-detect supported) - saaras:v2.5: Not used (auto-detects language) - - saaras:v3: Defaults to "en-IN" + - saaras:v3: Defaults to "unknown" (auto-detect supported) prompt: Optional prompt to guide transcription/translation style/context. - Only applicable to saaras models (v2.5 and v3). Defaults to None. + Only applicable to saaras:v2.5. Defaults to None. mode: Mode of operation for saaras:v3 models only. Options: transcribe, translate, verbatim, translit, codemix. Defaults to "transcribe" for saaras:v3. vad_signals: Enable VAD signals in response. Defaults to None. @@ -190,7 +190,7 @@ class SarvamSTTService(STTService): model: Sarvam model to use for transcription. Allowed values: - "saarika:v2.5": Standard STT model - "saaras:v2.5": STT-Translate model (auto-detects language, supports prompts) - - "saaras:v3": Advanced STT model (supports mode and prompts) + - "saaras:v3": Advanced STT model (supports mode) sample_rate: Audio sample rate. Defaults to 16000 if not specified. input_audio_codec: Audio codec/format of the input file. Defaults to "wav". params: Configuration parameters for Sarvam STT service. @@ -447,13 +447,32 @@ class SarvamSTTService(STTService): if self._config.supports_mode and self._mode is not None: connect_kwargs["mode"] = self._mode + # Prompt support differs across sarvamai versions. Prefer connect-time prompt + # when available and gracefully degrade if the SDK doesn't accept it. + if self._prompt is not None and self._config.supports_prompt: + connect_kwargs["prompt"] = self._prompt + def _connect_with_sdk_headers(connect_fn, **kwargs): # Different SDK versions may use different kwarg names. - for header_kw in ("headers", "additional_headers", "extra_headers"): + # If prompt is unsupported at connect-time, retry without it. + attempts = [kwargs] + if "prompt" in kwargs: + attempts.append({k: v for k, v in kwargs.items() if k != "prompt"}) + + last_type_error = None + for attempt_kwargs in attempts: + for header_kw in ("headers", "additional_headers", "extra_headers"): + try: + return connect_fn(**attempt_kwargs, **{header_kw: self._sdk_headers}) + except TypeError as e: + last_type_error = e try: - return connect_fn(**kwargs, **{header_kw: self._sdk_headers}) - except TypeError: - pass + return connect_fn(**attempt_kwargs) + except TypeError as e: + last_type_error = e + + if last_type_error is not None: + raise last_type_error return connect_fn(**kwargs) # Choose the appropriate endpoint based on model configuration @@ -471,9 +490,11 @@ class SarvamSTTService(STTService): # Enter the async context manager self._socket_client = await self._websocket_context.__aenter__() - # Set prompt if provided (only for models that support prompts) + # Fallback for SDKs that support runtime prompt updates. if self._prompt is not None and self._config.supports_prompt: - await self._socket_client.set_prompt(self._prompt) + prompt_setter = getattr(self._socket_client, "set_prompt", None) + if callable(prompt_setter): + await prompt_setter(self._prompt) # Register event handler for incoming messages def _message_handler(message): From 0370bb15e416bad38938368c09f8d1b1ca7a85ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Fri, 20 Feb 2026 13:47:04 -0800 Subject: [PATCH 86/93] update uv.lock --- uv.lock | 493 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 247 insertions(+), 246 deletions(-) diff --git a/uv.lock b/uv.lock index 512a6a49d..06563ab45 100644 --- a/uv.lock +++ b/uv.lock @@ -579,15 +579,15 @@ wheels = [ [[package]] name = "azure-core" -version = "1.38.0" +version = "1.38.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/1b/e503e08e755ea94e7d3419c9242315f888fc664211c90d032e40479022bf/azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993", size = 363033, upload-time = "2026-01-12T17:03:05.535Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/fe/5c7710bc611a4070d06ba801de9a935cc87c3d4b689c644958047bdf2cba/azure_core-1.38.2.tar.gz", hash = "sha256:67562857cb979217e48dc60980243b61ea115b77326fa93d83b729e7ff0482e7", size = 363734, upload-time = "2026-02-18T19:33:05.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/d8/b8fcba9464f02b121f39de2db2bf57f0b216fe11d014513d666e8634380d/azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335", size = 217825, upload-time = "2026-01-12T17:03:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/42/23/6371a551800d3812d6019cd813acd985f9fac0fedc1290129211a73da4ae/azure_core-1.38.2-py3-none-any.whl", hash = "sha256:074806c75cf239ea284a33a66827695ef7aeddac0b4e19dda266a93e4665ead9", size = 217957, upload-time = "2026-02-18T19:33:07.696Z" }, ] [[package]] @@ -1329,10 +1329,10 @@ wheels = [ [[package]] name = "cuda-pathfinder" -version = "1.3.3" +version = "1.3.4" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/02/4dbe7568a42e46582248942f54dc64ad094769532adbe21e525e4edf7bc4/cuda_pathfinder-1.3.3-py3-none-any.whl", hash = "sha256:9984b664e404f7c134954a771be8775dfd6180ea1e1aef4a5a37d4be05d9bbb1", size = 27154, upload-time = "2025-12-04T22:35:08.996Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5e/db279a3bfbd18d59d0598922a3b3c1454908d0969e8372260afec9736376/cuda_pathfinder-1.3.4-py3-none-any.whl", hash = "sha256:fb983f6e0d43af27ef486e14d5989b5f904ef45cedf40538bfdcbffa6bb01fb2", size = 30878, upload-time = "2026-02-11T18:50:31.008Z" }, ] [[package]] @@ -1573,7 +1573,7 @@ all = [ [[package]] name = "fastapi-cli" -version = "0.0.20" +version = "0.0.23" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "rich-toolkit" }, @@ -1581,9 +1581,9 @@ dependencies = [ { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/ca/d90fb3bfbcbd6e56c77afd9d114dd6ce8955d8bb90094399d1c70e659e40/fastapi_cli-0.0.20.tar.gz", hash = "sha256:d17c2634f7b96b6b560bc16b0035ed047d523c912011395f49f00a421692bc3a", size = 19786, upload-time = "2025-12-22T17:13:33.794Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/9f/cbd463e57de4e977b8ea0403f95347f9150441568b1d3fe3e4949ef80ef3/fastapi_cli-0.0.23.tar.gz", hash = "sha256:210ac280ea41e73aac5a57688781256beb23c2cba3a41266896fa43e6445c8e7", size = 19763, upload-time = "2026-02-16T19:45:53.358Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/89/5c4eef60524d0fd704eb0706885b82cd5623a43396b94e4a5b17d3a3f516/fastapi_cli-0.0.20-py3-none-any.whl", hash = "sha256:e58b6a0038c0b1532b7a0af690656093dee666201b6b19d3c87175b358e9f783", size = 12390, upload-time = "2025-12-22T17:13:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/68/89/19dcfd5cd289b306abdcabac68b88a4f54b7710a2c33adc16a337ecdcdfa/fastapi_cli-0.0.23-py3-none-any.whl", hash = "sha256:7e9634fc212da0b6cfc75bd3ac366cc9dfdb43b5e9ec12e58bfd1acdd2697f25", size = 12305, upload-time = "2026-02-16T19:45:52.554Z" }, ] [package.optional-dependencies] @@ -1594,7 +1594,7 @@ standard = [ [[package]] name = "fastapi-cloud-cli" -version = "0.11.0" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastar" }, @@ -1606,9 +1606,9 @@ dependencies = [ { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/11/15/6c3d85d63964340fde6f36cc80f3f365d35f371e6a918d68ff3a3d588ef2/fastapi_cloud_cli-0.11.0.tar.gz", hash = "sha256:ecc83a5db106be35af528eccb01aa9bced1d29783efd48c8c1c831cf111eea99", size = 36170, upload-time = "2026-01-15T09:51:33.681Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/0b/f07f4976784978ef159fd2e8f5c16f1f9d610578fb1fd976ff1315c11ea6/fastapi_cloud_cli-0.13.0.tar.gz", hash = "sha256:4d8f42337e8021c648f6cb0672de7d5b31b0fc7387a83d7b12f974600ac3f2fd", size = 38436, upload-time = "2026-02-17T05:18:19.033Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/07/60f79270a3320780be7e2ae8a1740cb98a692920b569ba420b97bcc6e175/fastapi_cloud_cli-0.11.0-py3-none-any.whl", hash = "sha256:76857b0f09d918acfcb50ade34682ba3b2079ca0c43fda10215de301f185a7f8", size = 26884, upload-time = "2026-01-15T09:51:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/b4/88/71a1e989d17b9edb483f32e28b7891ffdd3005271518c98ba6415987c430/fastapi_cloud_cli-0.13.0-py3-none-any.whl", hash = "sha256:874a9ed8dba34ec828f198c72de9f9a38de77ac1b15083d6bc3a4d772b0bc477", size = 27631, upload-time = "2026-02-17T05:18:18.094Z" }, ] [[package]] @@ -1751,11 +1751,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.20.3" +version = "3.24.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, ] [[package]] @@ -2070,9 +2070,10 @@ wheels = [ [[package]] name = "google-genai" -version = "1.62.0" +version = "1.64.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "aiohttp" }, { name = "anyio" }, { name = "distro" }, { name = "google-auth", extra = ["requests"] }, @@ -2084,9 +2085,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/4c/71b32b5c8db420cf2fd0d5ef8a672adbde97d85e5d44a0b4fca712264ef1/google_genai-1.62.0.tar.gz", hash = "sha256:709468a14c739a080bc240a4f3191df597bf64485b1ca3728e0fb67517774c18", size = 490888, upload-time = "2026-02-04T22:48:41.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/14/344b450d4387845fc5c8b7f168ffbe734b831b729ece3333fc0fe8556f04/google_genai-1.64.0.tar.gz", hash = "sha256:8db94ab031f745d08c45c69674d1892f7447c74ed21542abe599f7888e28b924", size = 496434, upload-time = "2026-02-19T02:06:13.95Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/5f/4645d8a28c6e431d0dd6011003a852563f3da7037d36af53154925b099fd/google_genai-1.62.0-py3-none-any.whl", hash = "sha256:4c3daeff3d05fafee4b9a1a31f9c07f01bc22051081aa58b4d61f58d16d1bcc0", size = 724166, upload-time = "2026-02-04T22:48:39.956Z" }, + { url = "https://files.pythonhosted.org/packages/54/56/765eca90c781fedbe2a7e7dc873ef6045048e28ba5f2d4a5bcb13e13062b/google_genai-1.64.0-py3-none-any.whl", hash = "sha256:78a4d2deeb33b15ad78eaa419f6f431755e7f0e03771254f8000d70f717e940b", size = 728836, upload-time = "2026-02-19T02:06:11.655Z" }, ] [[package]] @@ -2103,56 +2104,56 @@ wheels = [ [[package]] name = "greenlet" -version = "3.3.1" +version = "3.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/65/5b235b40581ad75ab97dcd8b4218022ae8e3ab77c13c919f1a1dfe9171fd/greenlet-3.3.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:04bee4775f40ecefcdaa9d115ab44736cd4b9c5fba733575bfe9379419582e13", size = 273723, upload-time = "2026-01-23T15:30:37.521Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ad/eb4729b85cba2d29499e0a04ca6fbdd8f540afd7be142fd571eea43d712f/greenlet-3.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e1457f4fed12a50e427988a07f0f9df53cf0ee8da23fab16e6732c2ec909d4", size = 574874, upload-time = "2026-01-23T16:00:54.551Z" }, - { url = "https://files.pythonhosted.org/packages/87/32/57cad7fe4c8b82fdaa098c89498ef85ad92dfbb09d5eb713adedfc2ae1f5/greenlet-3.3.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:070472cd156f0656f86f92e954591644e158fd65aa415ffbe2d44ca77656a8f5", size = 586309, upload-time = "2026-01-23T16:05:25.18Z" }, - { url = "https://files.pythonhosted.org/packages/87/eb/8a1ec2da4d55824f160594a75a9d8354a5fe0a300fb1c48e7944265217e1/greenlet-3.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a300354f27dd86bae5fbf7002e6dd2b3255cd372e9242c933faf5e859b703fe", size = 586985, upload-time = "2026-01-23T15:32:47.968Z" }, - { url = "https://files.pythonhosted.org/packages/15/1c/0621dd4321dd8c351372ee8f9308136acb628600658a49be1b7504208738/greenlet-3.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e84b51cbebf9ae573b5fbd15df88887815e3253fc000a7d0ff95170e8f7e9729", size = 1547271, upload-time = "2026-01-23T16:04:18.977Z" }, - { url = "https://files.pythonhosted.org/packages/9d/53/24047f8924c83bea7a59c8678d9571209c6bfe5f4c17c94a78c06024e9f2/greenlet-3.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0093bd1a06d899892427217f0ff2a3c8f306182b8c754336d32e2d587c131b4", size = 1613427, upload-time = "2026-01-23T15:33:44.428Z" }, - { url = "https://files.pythonhosted.org/packages/ff/07/ac9bf1ec008916d1a3373cae212884c1dcff4a4ba0d41127ce81a8deb4e9/greenlet-3.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:7932f5f57609b6a3b82cc11877709aa7a98e3308983ed93552a1c377069b20c8", size = 226100, upload-time = "2026-01-23T15:30:56.957Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-01-23T16:00:56.213Z" }, - { url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26.365Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-01-23T15:32:49.411Z" }, - { url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20.867Z" }, - { url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45.743Z" }, - { url = "https://files.pythonhosted.org/packages/1f/54/dcf9f737b96606f82f8dd05becfb8d238db0633dd7397d542a296fe9cad3/greenlet-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:32e4ca9777c5addcbf42ff3915d99030d8e00173a56f80001fb3875998fe410b", size = 226462, upload-time = "2026-01-23T15:36:50.422Z" }, - { url = "https://files.pythonhosted.org/packages/91/37/61e1015cf944ddd2337447d8e97fb423ac9bc21f9963fb5f206b53d65649/greenlet-3.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:da19609432f353fed186cc1b85e9440db93d489f198b4bdf42ae19cc9d9ac9b4", size = 225715, upload-time = "2026-01-23T15:33:17.298Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" }, - { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" }, - { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" }, - { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" }, - { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" }, - { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" }, - { url = "https://files.pythonhosted.org/packages/34/2f/5e0e41f33c69655300a5e54aeb637cf8ff57f1786a3aba374eacc0228c1d/greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a", size = 227156, upload-time = "2026-01-23T15:34:34.808Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, - { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, - { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, - { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, - { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" }, - { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" }, - { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, - { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, - { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, - { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, - { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, - { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" }, - { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" }, - { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, - { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, - { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, - { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, - { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, - { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, + { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, + { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, + { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, + { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, + { url = "https://files.pythonhosted.org/packages/ac/78/f93e840cbaef8becaf6adafbaf1319682a6c2d8c1c20224267a5c6c8c891/greenlet-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:5d0e35379f93a6d0222de929a25ab47b5eb35b5ef4721c2b9cbcc4036129ff1f", size = 230092, upload-time = "2026-02-20T20:17:09.379Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, + { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, + { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, + { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3a/efb2cf697fbccdf75b24e2c18025e7dfa54c4f31fab75c51d0fe79942cef/greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5", size = 230389, upload-time = "2026-02-20T20:17:18.772Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a1/65bbc059a43a7e2143ec4fc1f9e3f673e04f9c7b371a494a101422ac4fd5/greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd", size = 229645, upload-time = "2026-02-20T20:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, ] [[package]] @@ -3054,7 +3055,7 @@ wheels = [ [[package]] name = "langsmith" -version = "0.7.1" +version = "0.7.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -3067,9 +3068,9 @@ dependencies = [ { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/48/3151de6df96e0977b8d319b03905e29db0df6929a85df1d922a030b7e68d/langsmith-0.7.1.tar.gz", hash = "sha256:e3fec2f97f7c5192f192f4873d6a076b8c6469768022323dded07087d8cb70a4", size = 984367, upload-time = "2026-02-10T01:55:24.696Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/55/a3641cae990c842d3f4c52e5308b391267c98ce531a7a586dfedf1a78c42/langsmith-0.7.5.tar.gz", hash = "sha256:e3bfc2d7ff0a6f9a719125e1e136b5f4fa11828a2be8979f47ee1a4c0510030e", size = 1038926, upload-time = "2026-02-19T20:47:51.144Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/87/6f2b008a456b4f5fd0fb1509bb7e1e9368c1a0c9641a535f224a9ddc10f3/langsmith-0.7.1-py3-none-any.whl", hash = "sha256:92cfa54253d35417184c297ad25bfd921d95f15d60a1ca75f14d4e7acd152a29", size = 322515, upload-time = "2026-02-10T01:55:22.531Z" }, + { url = "https://files.pythonhosted.org/packages/32/0e/65b3fab6db843150ed38f226b39213565c644f0aaa515e0168bb1eaee5ae/langsmith-0.7.5-py3-none-any.whl", hash = "sha256:c120c43c98af5f5af8877341f8256aba1a170a292645b31572f06b0cf703c683", size = 324337, upload-time = "2026-02-19T20:47:47.537Z" }, ] [[package]] @@ -4642,7 +4643,7 @@ docs = [ { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autodoc-typehints", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx-autodoc-typehints", version = "3.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-autodoc-typehints", version = "3.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-markdown-builder" }, { name = "sphinx-rtd-theme" }, { name = "toml" }, @@ -4743,7 +4744,7 @@ requires-dist = [ { name = "requests", marker = "extra == 'kokoro'", specifier = ">=2.32.5,<3" }, { name = "requests", marker = "extra == 'piper'", specifier = ">=2.32.5,<3" }, { name = "resampy", specifier = "~=0.4.3" }, - { name = "sarvamai", marker = "extra == 'sarvam'", specifier = "==0.1.21" }, + { name = "sarvamai", marker = "extra == 'sarvam'", specifier = "==0.1.26a2" }, { name = "sentry-sdk", marker = "extra == 'sentry'", specifier = ">=2.28.0,<3" }, { name = "simli-ai", marker = "extra == 'simli'", specifier = "~=2.0.1" }, { name = "soundfile", marker = "extra == 'soundfile'", specifier = "~=0.13.1" }, @@ -4827,11 +4828,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.5.1" +version = "4.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, ] [[package]] @@ -4857,7 +4858,7 @@ wheels = [ [[package]] name = "posthog" -version = "7.8.5" +version = "7.9.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, @@ -4867,9 +4868,9 @@ dependencies = [ { name = "six" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b0/10/8e74a5e997c8286f0b63c69da522e503b1ab11627217ab76a06c7b62d647/posthog-7.8.5.tar.gz", hash = "sha256:e4f3796ce18323d8e05139bf419a04d318ccc4ad77b210f4d9d7c7546aea4f35", size = 169117, upload-time = "2026-02-09T22:59:49.207Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/06/bcffcd262c861695fbaa74490b872e37d6fc41d3dcc1a43207d20525522f/posthog-7.9.3.tar.gz", hash = "sha256:55f7580265d290936ac4c112a4e2031a41743be4f90d4183ac9f85b721ff13ae", size = 172336, upload-time = "2026-02-18T22:20:24.085Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/b3/59b61d4b90e2efd138abaa34d98c7a89a4a352850cc3a079a60a46780655/posthog-7.8.5-py3-none-any.whl", hash = "sha256:979d306f07e61a8e837746e5dc432aafc49827fecac91bd6c624dcf3a1967448", size = 194647, upload-time = "2026-02-09T22:59:47.744Z" }, + { url = "https://files.pythonhosted.org/packages/11/7e/0e06a96823fa7c11ce73920e6ff77e82445db62ac4eae0b6f211edb4c4c2/posthog-7.9.3-py3-none-any.whl", hash = "sha256:2ddcacdef6c4afb124ebfcf27d7be58388943a7e24f8d4a51a52732c9b90bad6", size = 197819, upload-time = "2026-02-18T22:20:22.015Z" }, ] [[package]] @@ -5301,16 +5302,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.12.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] [[package]] @@ -5324,14 +5325,14 @@ wheels = [ [[package]] name = "pyee" -version = "13.0.0" +version = "13.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, ] [[package]] @@ -5700,15 +5701,15 @@ wheels = [ [[package]] name = "rdflib" -version = "7.5.0" +version = "7.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "isodate", marker = "python_full_version < '3.11'" }, { name = "pyparsing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/1b/4cd9a29841951371304828d13282e27a5f25993702c7c87dcb7e0604bd25/rdflib-7.5.0.tar.gz", hash = "sha256:663083443908b1830e567350d72e74d9948b310f827966358d76eebdc92bf592", size = 4903859, upload-time = "2025-11-28T05:51:54.562Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/18bb77b7af9526add0c727a3b2048959847dc5fb030913e2918bf384fec3/rdflib-7.6.0.tar.gz", hash = "sha256:6c831288d5e4a5a7ece85d0ccde9877d512a3d0f02d7c06455d00d6d0ea379df", size = 4943826, upload-time = "2026-02-13T07:15:55.938Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/20/35d2baebacf357b562bd081936b66cd845775442973cb033a377fd639a84/rdflib-7.5.0-py3-none-any.whl", hash = "sha256:b011dfc40d0fc8a44252e906dcd8fc806a7859bc231be190c37e9568a31ac572", size = 587215, upload-time = "2025-11-28T05:51:38.178Z" }, + { url = "https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl", hash = "sha256:30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd", size = 615416, upload-time = "2026-02-13T07:15:46.487Z" }, ] [[package]] @@ -5727,123 +5728,123 @@ wheels = [ [[package]] name = "regex" -version = "2026.1.15" +version = "2026.2.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/c0/d8079d4f6342e4cec5c3e7d7415b5cd3e633d5f4124f7a4626908dbe84c7/regex-2026.2.19.tar.gz", hash = "sha256:6fb8cb09b10e38f3ae17cc6dc04a1df77762bd0351b6ba9041438e7cc85ec310", size = 414973, upload-time = "2026-02-19T19:03:47.899Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/d2/e6ee96b7dff201a83f650241c52db8e5bd080967cb93211f57aa448dc9d6/regex-2026.1.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e3dd93c8f9abe8aa4b6c652016da9a3afa190df5ad822907efe6b206c09896e", size = 488166, upload-time = "2026-01-14T23:13:46.408Z" }, - { url = "https://files.pythonhosted.org/packages/23/8a/819e9ce14c9f87af026d0690901b3931f3101160833e5d4c8061fa3a1b67/regex-2026.1.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97499ff7862e868b1977107873dd1a06e151467129159a6ffd07b66706ba3a9f", size = 290632, upload-time = "2026-01-14T23:13:48.688Z" }, - { url = "https://files.pythonhosted.org/packages/d5/c3/23dfe15af25d1d45b07dfd4caa6003ad710dcdcb4c4b279909bdfe7a2de8/regex-2026.1.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bda75ebcac38d884240914c6c43d8ab5fb82e74cde6da94b43b17c411aa4c2b", size = 288500, upload-time = "2026-01-14T23:13:50.503Z" }, - { url = "https://files.pythonhosted.org/packages/c6/31/1adc33e2f717df30d2f4d973f8776d2ba6ecf939301efab29fca57505c95/regex-2026.1.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7dcc02368585334f5bc81fc73a2a6a0bbade60e7d83da21cead622faf408f32c", size = 781670, upload-time = "2026-01-14T23:13:52.453Z" }, - { url = "https://files.pythonhosted.org/packages/23/ce/21a8a22d13bc4adcb927c27b840c948f15fc973e21ed2346c1bd0eae22dc/regex-2026.1.15-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:693b465171707bbe882a7a05de5e866f33c76aa449750bee94a8d90463533cc9", size = 850820, upload-time = "2026-01-14T23:13:54.894Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4f/3eeacdf587a4705a44484cd0b30e9230a0e602811fb3e2cc32268c70d509/regex-2026.1.15-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b0d190e6f013ea938623a58706d1469a62103fb2a241ce2873a9906e0386582c", size = 898777, upload-time = "2026-01-14T23:13:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/79/a9/1898a077e2965c35fc22796488141a22676eed2d73701e37c73ad7c0b459/regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ff818702440a5878a81886f127b80127f5d50563753a28211482867f8318106", size = 791750, upload-time = "2026-01-14T23:13:58.527Z" }, - { url = "https://files.pythonhosted.org/packages/4c/84/e31f9d149a178889b3817212827f5e0e8c827a049ff31b4b381e76b26e2d/regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f052d1be37ef35a54e394de66136e30fa1191fab64f71fc06ac7bc98c9a84618", size = 782674, upload-time = "2026-01-14T23:13:59.874Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ff/adf60063db24532add6a1676943754a5654dcac8237af024ede38244fd12/regex-2026.1.15-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6bfc31a37fd1592f0c4fc4bfc674b5c42e52efe45b4b7a6a14f334cca4bcebe4", size = 767906, upload-time = "2026-01-14T23:14:01.298Z" }, - { url = "https://files.pythonhosted.org/packages/af/3e/e6a216cee1e2780fec11afe7fc47b6f3925d7264e8149c607ac389fd9b1a/regex-2026.1.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d6ce5ae80066b319ae3bc62fd55a557c9491baa5efd0d355f0de08c4ba54e79", size = 774798, upload-time = "2026-01-14T23:14:02.715Z" }, - { url = "https://files.pythonhosted.org/packages/0f/98/23a4a8378a9208514ed3efc7e7850c27fa01e00ed8557c958df0335edc4a/regex-2026.1.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1704d204bd42b6bb80167df0e4554f35c255b579ba99616def38f69e14a5ccb9", size = 845861, upload-time = "2026-01-14T23:14:04.824Z" }, - { url = "https://files.pythonhosted.org/packages/f8/57/d7605a9d53bd07421a8785d349cd29677fe660e13674fa4c6cbd624ae354/regex-2026.1.15-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e3174a5ed4171570dc8318afada56373aa9289eb6dc0d96cceb48e7358b0e220", size = 755648, upload-time = "2026-01-14T23:14:06.371Z" }, - { url = "https://files.pythonhosted.org/packages/6f/76/6f2e24aa192da1e299cc1101674a60579d3912391867ce0b946ba83e2194/regex-2026.1.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:87adf5bd6d72e3e17c9cb59ac4096b1faaf84b7eb3037a5ffa61c4b4370f0f13", size = 836250, upload-time = "2026-01-14T23:14:08.343Z" }, - { url = "https://files.pythonhosted.org/packages/11/3a/1f2a1d29453299a7858eab7759045fc3d9d1b429b088dec2dc85b6fa16a2/regex-2026.1.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e85dc94595f4d766bd7d872a9de5ede1ca8d3063f3bdf1e2c725f5eb411159e3", size = 779919, upload-time = "2026-01-14T23:14:09.954Z" }, - { url = "https://files.pythonhosted.org/packages/c0/67/eab9bc955c9dcc58e9b222c801e39cff7ca0b04261792a2149166ce7e792/regex-2026.1.15-cp310-cp310-win32.whl", hash = "sha256:21ca32c28c30d5d65fc9886ff576fc9b59bbca08933e844fa2363e530f4c8218", size = 265888, upload-time = "2026-01-14T23:14:11.35Z" }, - { url = "https://files.pythonhosted.org/packages/1d/62/31d16ae24e1f8803bddb0885508acecaec997fcdcde9c243787103119ae4/regex-2026.1.15-cp310-cp310-win_amd64.whl", hash = "sha256:3038a62fc7d6e5547b8915a3d927a0fbeef84cdbe0b1deb8c99bbd4a8961b52a", size = 277830, upload-time = "2026-01-14T23:14:12.908Z" }, - { url = "https://files.pythonhosted.org/packages/e5/36/5d9972bccd6417ecd5a8be319cebfd80b296875e7f116c37fb2a2deecebf/regex-2026.1.15-cp310-cp310-win_arm64.whl", hash = "sha256:505831646c945e3e63552cc1b1b9b514f0e93232972a2d5bedbcc32f15bc82e3", size = 270376, upload-time = "2026-01-14T23:14:14.782Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c9/0c80c96eab96948363d270143138d671d5731c3a692b417629bf3492a9d6/regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a", size = 488168, upload-time = "2026-01-14T23:14:16.129Z" }, - { url = "https://files.pythonhosted.org/packages/17/f0/271c92f5389a552494c429e5cc38d76d1322eb142fb5db3c8ccc47751468/regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f", size = 290636, upload-time = "2026-01-14T23:14:17.715Z" }, - { url = "https://files.pythonhosted.org/packages/a0/f9/5f1fd077d106ca5655a0f9ff8f25a1ab55b92128b5713a91ed7134ff688e/regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1", size = 288496, upload-time = "2026-01-14T23:14:19.326Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e1/8f43b03a4968c748858ec77f746c286d81f896c2e437ccf050ebc5d3128c/regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b", size = 793503, upload-time = "2026-01-14T23:14:20.922Z" }, - { url = "https://files.pythonhosted.org/packages/8d/4e/a39a5e8edc5377a46a7c875c2f9a626ed3338cb3bb06931be461c3e1a34a/regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8", size = 860535, upload-time = "2026-01-14T23:14:22.405Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1c/9dce667a32a9477f7a2869c1c767dc00727284a9fa3ff5c09a5c6c03575e/regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413", size = 907225, upload-time = "2026-01-14T23:14:23.897Z" }, - { url = "https://files.pythonhosted.org/packages/a4/3c/87ca0a02736d16b6262921425e84b48984e77d8e4e572c9072ce96e66c30/regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026", size = 800526, upload-time = "2026-01-14T23:14:26.039Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/647d5715aeea7c87bdcbd2f578f47b415f55c24e361e639fe8c0cc88878f/regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785", size = 773446, upload-time = "2026-01-14T23:14:28.109Z" }, - { url = "https://files.pythonhosted.org/packages/af/89/bf22cac25cb4ba0fe6bff52ebedbb65b77a179052a9d6037136ae93f42f4/regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e", size = 783051, upload-time = "2026-01-14T23:14:29.929Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f4/6ed03e71dca6348a5188363a34f5e26ffd5db1404780288ff0d79513bce4/regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763", size = 854485, upload-time = "2026-01-14T23:14:31.366Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9a/8e8560bd78caded8eb137e3e47612430a05b9a772caf60876435192d670a/regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb", size = 762195, upload-time = "2026-01-14T23:14:32.802Z" }, - { url = "https://files.pythonhosted.org/packages/38/6b/61fc710f9aa8dfcd764fe27d37edfaa023b1a23305a0d84fccd5adb346ea/regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2", size = 845986, upload-time = "2026-01-14T23:14:34.898Z" }, - { url = "https://files.pythonhosted.org/packages/fd/2e/fbee4cb93f9d686901a7ca8d94285b80405e8c34fe4107f63ffcbfb56379/regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1", size = 788992, upload-time = "2026-01-14T23:14:37.116Z" }, - { url = "https://files.pythonhosted.org/packages/ed/14/3076348f3f586de64b1ab75a3fbabdaab7684af7f308ad43be7ef1849e55/regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569", size = 265893, upload-time = "2026-01-14T23:14:38.426Z" }, - { url = "https://files.pythonhosted.org/packages/0f/19/772cf8b5fc803f5c89ba85d8b1870a1ca580dc482aa030383a9289c82e44/regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7", size = 277840, upload-time = "2026-01-14T23:14:39.785Z" }, - { url = "https://files.pythonhosted.org/packages/78/84/d05f61142709474da3c0853222d91086d3e1372bcdab516c6fd8d80f3297/regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec", size = 270374, upload-time = "2026-01-14T23:14:41.592Z" }, - { url = "https://files.pythonhosted.org/packages/92/81/10d8cf43c807d0326efe874c1b79f22bfb0fb226027b0b19ebc26d301408/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1", size = 489398, upload-time = "2026-01-14T23:14:43.741Z" }, - { url = "https://files.pythonhosted.org/packages/90/b0/7c2a74e74ef2a7c32de724658a69a862880e3e4155cba992ba04d1c70400/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681", size = 291339, upload-time = "2026-01-14T23:14:45.183Z" }, - { url = "https://files.pythonhosted.org/packages/19/4d/16d0773d0c818417f4cc20aa0da90064b966d22cd62a8c46765b5bd2d643/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f", size = 289003, upload-time = "2026-01-14T23:14:47.25Z" }, - { url = "https://files.pythonhosted.org/packages/c6/e4/1fc4599450c9f0863d9406e944592d968b8d6dfd0d552a7d569e43bceada/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa", size = 798656, upload-time = "2026-01-14T23:14:48.77Z" }, - { url = "https://files.pythonhosted.org/packages/b2/e6/59650d73a73fa8a60b3a590545bfcf1172b4384a7df2e7fe7b9aab4e2da9/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804", size = 864252, upload-time = "2026-01-14T23:14:50.528Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ab/1d0f4d50a1638849a97d731364c9a80fa304fec46325e48330c170ee8e80/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c", size = 912268, upload-time = "2026-01-14T23:14:52.952Z" }, - { url = "https://files.pythonhosted.org/packages/dd/df/0d722c030c82faa1d331d1921ee268a4e8fb55ca8b9042c9341c352f17fa/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5", size = 803589, upload-time = "2026-01-14T23:14:55.182Z" }, - { url = "https://files.pythonhosted.org/packages/66/23/33289beba7ccb8b805c6610a8913d0131f834928afc555b241caabd422a9/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3", size = 775700, upload-time = "2026-01-14T23:14:56.707Z" }, - { url = "https://files.pythonhosted.org/packages/e7/65/bf3a42fa6897a0d3afa81acb25c42f4b71c274f698ceabd75523259f6688/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb", size = 787928, upload-time = "2026-01-14T23:14:58.312Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f5/13bf65864fc314f68cdd6d8ca94adcab064d4d39dbd0b10fef29a9da48fc/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410", size = 858607, upload-time = "2026-01-14T23:15:00.657Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/040e589834d7a439ee43fb0e1e902bc81bd58a5ba81acffe586bb3321d35/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4", size = 763729, upload-time = "2026-01-14T23:15:02.248Z" }, - { url = "https://files.pythonhosted.org/packages/9b/84/6921e8129687a427edf25a34a5594b588b6d88f491320b9de5b6339a4fcb/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d", size = 850697, upload-time = "2026-01-14T23:15:03.878Z" }, - { url = "https://files.pythonhosted.org/packages/8a/87/3d06143d4b128f4229158f2de5de6c8f2485170c7221e61bf381313314b2/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22", size = 789849, upload-time = "2026-01-14T23:15:06.102Z" }, - { url = "https://files.pythonhosted.org/packages/77/69/c50a63842b6bd48850ebc7ab22d46e7a2a32d824ad6c605b218441814639/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913", size = 266279, upload-time = "2026-01-14T23:15:07.678Z" }, - { url = "https://files.pythonhosted.org/packages/f2/36/39d0b29d087e2b11fd8191e15e81cce1b635fcc845297c67f11d0d19274d/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a", size = 277166, upload-time = "2026-01-14T23:15:09.257Z" }, - { url = "https://files.pythonhosted.org/packages/28/32/5b8e476a12262748851fa8ab1b0be540360692325975b094e594dfebbb52/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056", size = 270415, upload-time = "2026-01-14T23:15:10.743Z" }, - { url = "https://files.pythonhosted.org/packages/f8/2e/6870bb16e982669b674cce3ee9ff2d1d46ab80528ee6bcc20fb2292efb60/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e", size = 489164, upload-time = "2026-01-14T23:15:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/9774542e203849b0286badf67199970a44ebdb0cc5fb739f06e47ada72f8/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10", size = 291218, upload-time = "2026-01-14T23:15:15.647Z" }, - { url = "https://files.pythonhosted.org/packages/b2/87/b0cda79f22b8dee05f774922a214da109f9a4c0eca5da2c9d72d77ea062c/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc", size = 288895, upload-time = "2026-01-14T23:15:17.788Z" }, - { url = "https://files.pythonhosted.org/packages/3b/6a/0041f0a2170d32be01ab981d6346c83a8934277d82c780d60b127331f264/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599", size = 798680, upload-time = "2026-01-14T23:15:19.342Z" }, - { url = "https://files.pythonhosted.org/packages/58/de/30e1cfcdbe3e891324aa7568b7c968771f82190df5524fabc1138cb2d45a/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae", size = 864210, upload-time = "2026-01-14T23:15:22.005Z" }, - { url = "https://files.pythonhosted.org/packages/64/44/4db2f5c5ca0ccd40ff052ae7b1e9731352fcdad946c2b812285a7505ca75/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5", size = 912358, upload-time = "2026-01-14T23:15:24.569Z" }, - { url = "https://files.pythonhosted.org/packages/79/b6/e6a5665d43a7c42467138c8a2549be432bad22cbd206f5ec87162de74bd7/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6", size = 803583, upload-time = "2026-01-14T23:15:26.526Z" }, - { url = "https://files.pythonhosted.org/packages/e7/53/7cd478222169d85d74d7437e74750005e993f52f335f7c04ff7adfda3310/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788", size = 775782, upload-time = "2026-01-14T23:15:29.352Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b5/75f9a9ee4b03a7c009fe60500fe550b45df94f0955ca29af16333ef557c5/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714", size = 787978, upload-time = "2026-01-14T23:15:31.295Z" }, - { url = "https://files.pythonhosted.org/packages/72/b3/79821c826245bbe9ccbb54f6eadb7879c722fd3e0248c17bfc90bf54e123/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d", size = 858550, upload-time = "2026-01-14T23:15:33.558Z" }, - { url = "https://files.pythonhosted.org/packages/4a/85/2ab5f77a1c465745bfbfcb3ad63178a58337ae8d5274315e2cc623a822fa/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3", size = 763747, upload-time = "2026-01-14T23:15:35.206Z" }, - { url = "https://files.pythonhosted.org/packages/6d/84/c27df502d4bfe2873a3e3a7cf1bdb2b9cc10284d1a44797cf38bed790470/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31", size = 850615, upload-time = "2026-01-14T23:15:37.523Z" }, - { url = "https://files.pythonhosted.org/packages/7d/b7/658a9782fb253680aa8ecb5ccbb51f69e088ed48142c46d9f0c99b46c575/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3", size = 789951, upload-time = "2026-01-14T23:15:39.582Z" }, - { url = "https://files.pythonhosted.org/packages/fc/2a/5928af114441e059f15b2f63e188bd00c6529b3051c974ade7444b85fcda/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f", size = 266275, upload-time = "2026-01-14T23:15:42.108Z" }, - { url = "https://files.pythonhosted.org/packages/4f/16/5bfbb89e435897bff28cf0352a992ca719d9e55ebf8b629203c96b6ce4f7/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e", size = 277145, upload-time = "2026-01-14T23:15:44.244Z" }, - { url = "https://files.pythonhosted.org/packages/56/c1/a09ff7392ef4233296e821aec5f78c51be5e91ffde0d163059e50fd75835/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337", size = 270411, upload-time = "2026-01-14T23:15:45.858Z" }, - { url = "https://files.pythonhosted.org/packages/3c/38/0cfd5a78e5c6db00e6782fdae70458f89850ce95baa5e8694ab91d89744f/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be", size = 492068, upload-time = "2026-01-14T23:15:47.616Z" }, - { url = "https://files.pythonhosted.org/packages/50/72/6c86acff16cb7c959c4355826bbf06aad670682d07c8f3998d9ef4fee7cd/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8", size = 292756, upload-time = "2026-01-14T23:15:49.307Z" }, - { url = "https://files.pythonhosted.org/packages/4e/58/df7fb69eadfe76526ddfce28abdc0af09ffe65f20c2c90932e89d705153f/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd", size = 291114, upload-time = "2026-01-14T23:15:51.484Z" }, - { url = "https://files.pythonhosted.org/packages/ed/6c/a4011cd1cf96b90d2cdc7e156f91efbd26531e822a7fbb82a43c1016678e/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a", size = 807524, upload-time = "2026-01-14T23:15:53.102Z" }, - { url = "https://files.pythonhosted.org/packages/1d/25/a53ffb73183f69c3e9f4355c4922b76d2840aee160af6af5fac229b6201d/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93", size = 873455, upload-time = "2026-01-14T23:15:54.956Z" }, - { url = "https://files.pythonhosted.org/packages/66/0b/8b47fc2e8f97d9b4a851736f3890a5f786443aa8901061c55f24c955f45b/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af", size = 915007, upload-time = "2026-01-14T23:15:57.041Z" }, - { url = "https://files.pythonhosted.org/packages/c2/fa/97de0d681e6d26fabe71968dbee06dd52819e9a22fdce5dac7256c31ed84/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09", size = 812794, upload-time = "2026-01-14T23:15:58.916Z" }, - { url = "https://files.pythonhosted.org/packages/22/38/e752f94e860d429654aa2b1c51880bff8dfe8f084268258adf9151cf1f53/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5", size = 781159, upload-time = "2026-01-14T23:16:00.817Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a7/d739ffaef33c378fc888302a018d7f81080393d96c476b058b8c64fd2b0d/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794", size = 795558, upload-time = "2026-01-14T23:16:03.267Z" }, - { url = "https://files.pythonhosted.org/packages/3e/c4/542876f9a0ac576100fc73e9c75b779f5c31e3527576cfc9cb3009dcc58a/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a", size = 868427, upload-time = "2026-01-14T23:16:05.646Z" }, - { url = "https://files.pythonhosted.org/packages/fc/0f/d5655bea5b22069e32ae85a947aa564912f23758e112cdb74212848a1a1b/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80", size = 769939, upload-time = "2026-01-14T23:16:07.542Z" }, - { url = "https://files.pythonhosted.org/packages/20/06/7e18a4fa9d326daeda46d471a44ef94201c46eaa26dbbb780b5d92cbfdda/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2", size = 854753, upload-time = "2026-01-14T23:16:10.395Z" }, - { url = "https://files.pythonhosted.org/packages/3b/67/dc8946ef3965e166f558ef3b47f492bc364e96a265eb4a2bb3ca765c8e46/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60", size = 799559, upload-time = "2026-01-14T23:16:12.347Z" }, - { url = "https://files.pythonhosted.org/packages/a5/61/1bba81ff6d50c86c65d9fd84ce9699dd106438ee4cdb105bf60374ee8412/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952", size = 268879, upload-time = "2026-01-14T23:16:14.049Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5e/cef7d4c5fb0ea3ac5c775fd37db5747f7378b29526cc83f572198924ff47/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10", size = 280317, upload-time = "2026-01-14T23:16:15.718Z" }, - { url = "https://files.pythonhosted.org/packages/b4/52/4317f7a5988544e34ab57b4bde0f04944c4786128c933fb09825924d3e82/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829", size = 271551, upload-time = "2026-01-14T23:16:17.533Z" }, - { url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" }, - { url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" }, - { url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" }, - { url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" }, - { url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" }, - { url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" }, - { url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" }, - { url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" }, - { url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" }, - { url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" }, - { url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" }, - { url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" }, - { url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" }, - { url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" }, - { url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" }, - { url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" }, - { url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" }, - { url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" }, - { url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" }, - { url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" }, - { url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" }, - { url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" }, - { url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/af/de/f10b4506acfd684de4e42b0aa56ccea1a778a18864da8f6d319a40591062/regex-2026.2.19-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f5a37a17d110f9d5357a43aa7e3507cb077bf3143d1c549a45c4649e90e40a70", size = 488369, upload-time = "2026-02-19T18:59:45.01Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2f/b4eaef1f0b4d0bf2a73eaf07c08f6c13422918a4180c9211ce0521746d0c/regex-2026.2.19-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:676c4e6847a83a1d5732b4ed553881ad36f0a8133627bb695a89ecf3571499d3", size = 290743, upload-time = "2026-02-19T18:59:48.527Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/805413bd0a88d04688c0725c222cfb811bd54a2f571004c24199a1ae55d6/regex-2026.2.19-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:82336faeecac33297cd42857c3b36f12b91810e3fdd276befdd128f73a2b43fa", size = 288652, upload-time = "2026-02-19T18:59:50.2Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/2c4cd530a878b1975398e76faef4285f11e7c9ccf1aaedfd528bfcc1f580/regex-2026.2.19-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52136f5b71f095cb74b736cc3a1b578030dada2e361ef2f07ca582240b703946", size = 781759, upload-time = "2026-02-19T18:59:51.836Z" }, + { url = "https://files.pythonhosted.org/packages/37/45/9608ab1b41f6740ff4076eabadde8e8b3f3400942b348ac41e8599ccc131/regex-2026.2.19-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4192464fe3e6cb0ef6751f7d3b16f886d8270d359ed1590dd555539d364f0ff7", size = 850947, upload-time = "2026-02-19T18:59:53.739Z" }, + { url = "https://files.pythonhosted.org/packages/90/3a/66471b6c4f7cac17e14bf5300e46661bba2b17ffb0871bd2759e837a6f82/regex-2026.2.19-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e561dd47a85d2660d3d3af4e6cb2da825cf20f121e577147963f875b83d32786", size = 898794, upload-time = "2026-02-19T18:59:55.993Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d2/38c53929a5931f7398e5e49f5a5a3079cb2aba30119b4350608364cfad8c/regex-2026.2.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00ec994d7824bf01cd6c7d14c7a6a04d9aeaf7c42a2bc22d2359d715634d539b", size = 791922, upload-time = "2026-02-19T18:59:58.216Z" }, + { url = "https://files.pythonhosted.org/packages/8b/bd/b046e065630fa25059d9c195b7b5308ea94da45eee65d40879772500f74c/regex-2026.2.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2cb00aabd96b345d56a8c2bc328c8d6c4d29935061e05078bf1f02302e12abf5", size = 783345, upload-time = "2026-02-19T18:59:59.948Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8f/045c643d2fa255a985e8f87d848e4be230b711a8935e4bdc58e60b8f7b84/regex-2026.2.19-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f374366ed35673ea81b86a8859c457d4fae6ba092b71024857e9e237410c7404", size = 768055, upload-time = "2026-02-19T19:00:01.65Z" }, + { url = "https://files.pythonhosted.org/packages/72/9f/ab7ae9f5447559562f1a788bbc85c0e526528c5e6c20542d18e4afc86aad/regex-2026.2.19-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9417fd853fcd00b7d55167e692966dd12d95ba1a88bf08a62002ccd85030790", size = 774955, upload-time = "2026-02-19T19:00:03.368Z" }, + { url = "https://files.pythonhosted.org/packages/37/5c/f16fc23c56f60b6f4ff194604a6e53bb8aec7b6e8e4a23a482dee8d77235/regex-2026.2.19-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:12e86a01594031abf892686fcb309b041bf3de3d13d99eb7e2b02a8f3c687df1", size = 846010, upload-time = "2026-02-19T19:00:05.079Z" }, + { url = "https://files.pythonhosted.org/packages/51/c8/6be4c854135d7c9f35d4deeafdaf124b039ecb4ffcaeb7ed0495ad2c97ca/regex-2026.2.19-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:79014115e6fdf18fd9b32e291d58181bf42d4298642beaa13fd73e69810e4cb6", size = 755938, upload-time = "2026-02-19T19:00:07.148Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8d/f683d49b9663a5324b95a328e69d397f6dade7cb84154eec116bf79fe150/regex-2026.2.19-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:31aefac2506967b7dd69af2c58eca3cc8b086d4110b66d6ac6e9026f0ee5b697", size = 835773, upload-time = "2026-02-19T19:00:08.939Z" }, + { url = "https://files.pythonhosted.org/packages/16/cd/619224b90da09f167fe4497c350a0d0b30edc539ee9244bf93e604c073c3/regex-2026.2.19-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:49cef7bb2a491f91a8869c7cdd90babf0a417047ab0bf923cd038ed2eab2ccb8", size = 780075, upload-time = "2026-02-19T19:00:10.838Z" }, + { url = "https://files.pythonhosted.org/packages/5b/88/19cfb0c262d6f9d722edef29157125418bf90eb3508186bf79335afeedae/regex-2026.2.19-cp310-cp310-win32.whl", hash = "sha256:3a039474986e7a314ace6efb9ce52f5da2bdb80ac4955358723d350ec85c32ad", size = 266004, upload-time = "2026-02-19T19:00:12.371Z" }, + { url = "https://files.pythonhosted.org/packages/82/af/5b487e0287ef72545d7ae92edecdacbe3d44e531cac24fda7de5598ba8dd/regex-2026.2.19-cp310-cp310-win_amd64.whl", hash = "sha256:5b81ff4f9cad99f90c807a00c5882fbcda86d8b3edd94e709fb531fc52cb3d25", size = 277895, upload-time = "2026-02-19T19:00:13.75Z" }, + { url = "https://files.pythonhosted.org/packages/4c/19/b6715a187ffca4d2979af92a46ce922445ba41f910bf187ccd666a2d52ef/regex-2026.2.19-cp310-cp310-win_arm64.whl", hash = "sha256:a032bc01a4bc73fc3cadba793fce28eb420da39338f47910c59ffcc11a5ba5ef", size = 270465, upload-time = "2026-02-19T19:00:15.127Z" }, + { url = "https://files.pythonhosted.org/packages/6f/93/43f405a98f54cc59c786efb4fc0b644615ed2392fc89d57d30da11f35b5b/regex-2026.2.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:93b16a18cadb938f0f2306267161d57eb33081a861cee9ffcd71e60941eb5dfc", size = 488365, upload-time = "2026-02-19T19:00:17.857Z" }, + { url = "https://files.pythonhosted.org/packages/66/46/da0efce22cd8f5ae28eeb25ac69703f49edcad3331ac22440776f4ea0867/regex-2026.2.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:78af1e499cab704131f6f4e2f155b7f54ce396ca2acb6ef21a49507e4752e0be", size = 290737, upload-time = "2026-02-19T19:00:19.869Z" }, + { url = "https://files.pythonhosted.org/packages/fb/19/f735078448132c1c974974d30d5306337bc297fe6b6f126164bff72c1019/regex-2026.2.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eb20c11aa4c3793c9ad04c19a972078cdadb261b8429380364be28e867a843f2", size = 288654, upload-time = "2026-02-19T19:00:21.307Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/6d7c24a2f423c03ad03e3fbddefa431057186ac1c4cb4fa98b03c7f39808/regex-2026.2.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db5fd91eec71e7b08de10011a2223d0faa20448d4e1380b9daa179fa7bf58906", size = 793785, upload-time = "2026-02-19T19:00:22.926Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/fdb8107504b3122a79bde6705ac1f9d495ed1fe35b87d7cfc1864471999a/regex-2026.2.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fdbade8acba71bb45057c2b72f477f0b527c4895f9c83e6cfc30d4a006c21726", size = 860731, upload-time = "2026-02-19T19:00:25.196Z" }, + { url = "https://files.pythonhosted.org/packages/9a/fd/cc8c6f05868defd840be6e75919b1c3f462357969ac2c2a0958363b4dc23/regex-2026.2.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:31a5f561eb111d6aae14202e7043fb0b406d3c8dddbbb9e60851725c9b38ab1d", size = 907350, upload-time = "2026-02-19T19:00:27.093Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1b/4590db9caa8db3d5a3fe31197c4e42c15aab3643b549ef6a454525fa3a61/regex-2026.2.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4584a3ee5f257b71e4b693cc9be3a5104249399f4116fe518c3f79b0c6fc7083", size = 800628, upload-time = "2026-02-19T19:00:29.392Z" }, + { url = "https://files.pythonhosted.org/packages/76/05/513eaa5b96fa579fd0b813e19ec047baaaf573d7374ff010fa139b384bf7/regex-2026.2.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:196553ba2a2f47904e5dc272d948a746352e2644005627467e055be19d73b39e", size = 773711, upload-time = "2026-02-19T19:00:30.996Z" }, + { url = "https://files.pythonhosted.org/packages/95/65/5aed06d8c54563d37fea496cf888be504879a3981a7c8e12c24b2c92c209/regex-2026.2.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0c10869d18abb759a3317c757746cc913d6324ce128b8bcec99350df10419f18", size = 783186, upload-time = "2026-02-19T19:00:34.598Z" }, + { url = "https://files.pythonhosted.org/packages/2c/57/79a633ad90f2371b4ef9cd72ba3a69a1a67d0cfaab4fe6fa8586d46044ef/regex-2026.2.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e689fed279cbe797a6b570bd18ff535b284d057202692c73420cb93cca41aa32", size = 854854, upload-time = "2026-02-19T19:00:37.306Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/0f113d477d9e91ec4545ec36c82e58be25038d06788229c91ad52da2b7f5/regex-2026.2.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0782bd983f19ac7594039c9277cd6f75c89598c1d72f417e4d30d874105eb0c7", size = 762279, upload-time = "2026-02-19T19:00:39.793Z" }, + { url = "https://files.pythonhosted.org/packages/39/cb/237e9fa4f61469fd4f037164dbe8e675a376c88cf73aaaa0aedfd305601c/regex-2026.2.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:dbb240c81cfed5d4a67cb86d7676d9f7ec9c3f186310bec37d8a1415210e111e", size = 846172, upload-time = "2026-02-19T19:00:42.134Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7c/104779c5915cc4eb557a33590f8a3f68089269c64287dd769afd76c7ce61/regex-2026.2.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80d31c3f1fe7e4c6cd1831cd4478a0609903044dfcdc4660abfe6fb307add7f0", size = 789078, upload-time = "2026-02-19T19:00:43.908Z" }, + { url = "https://files.pythonhosted.org/packages/a8/4a/eae4e88b1317fb2ff57794915e0099198f51e760f6280b320adfa0ad396d/regex-2026.2.19-cp311-cp311-win32.whl", hash = "sha256:66e6a43225ff1064f8926adbafe0922b370d381c3330edaf9891cade52daa790", size = 266013, upload-time = "2026-02-19T19:00:47.274Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/ba89eb8fae79705e07ad1bd69e568f776159d2a8093c9dbc5303ee618298/regex-2026.2.19-cp311-cp311-win_amd64.whl", hash = "sha256:59a7a5216485a1896c5800e9feb8ff9213e11967b482633b6195d7da11450013", size = 277906, upload-time = "2026-02-19T19:00:49.011Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1a/042d8f04b28e318df92df69d8becb0f42221eb3dd4fe5e976522f4337c76/regex-2026.2.19-cp311-cp311-win_arm64.whl", hash = "sha256:ec661807ffc14c8d14bb0b8c1bb3d5906e476bc96f98b565b709d03962ee4dd4", size = 270463, upload-time = "2026-02-19T19:00:50.988Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/13b39c7c9356f333e564ab4790b6cb0df125b8e64e8d6474e73da49b1955/regex-2026.2.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c1665138776e4ac1aa75146669236f7a8a696433ec4e525abf092ca9189247cc", size = 489541, upload-time = "2026-02-19T19:00:52.728Z" }, + { url = "https://files.pythonhosted.org/packages/15/77/fcc7bd9a67000d07fbcc11ed226077287a40d5c84544e62171d29d3ef59c/regex-2026.2.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d792b84709021945597e05656aac059526df4e0c9ef60a0eaebb306f8fafcaa8", size = 291414, upload-time = "2026-02-19T19:00:54.51Z" }, + { url = "https://files.pythonhosted.org/packages/f9/87/3997fc72dc59233426ef2e18dfdd105bb123812fff740ee9cc348f1a3243/regex-2026.2.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db970bcce4d63b37b3f9eb8c893f0db980bbf1d404a1d8d2b17aa8189de92c53", size = 289140, upload-time = "2026-02-19T19:00:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d0/b7dd3883ed1cff8ee0c0c9462d828aaf12be63bf5dc55453cbf423523b13/regex-2026.2.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03d706fbe7dfec503c8c3cb76f9352b3e3b53b623672aa49f18a251a6c71b8e6", size = 798767, upload-time = "2026-02-19T19:00:59.014Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7e/8e2d09103832891b2b735a2515abf377db21144c6dd5ede1fb03c619bf09/regex-2026.2.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dbff048c042beef60aa1848961384572c5afb9e8b290b0f1203a5c42cf5af65", size = 864436, upload-time = "2026-02-19T19:01:00.772Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2e/afea8d23a6db1f67f45e3a0da3057104ce32e154f57dd0c8997274d45fcd/regex-2026.2.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccaaf9b907ea6b4223d5cbf5fa5dff5f33dc66f4907a25b967b8a81339a6e332", size = 912391, upload-time = "2026-02-19T19:01:02.865Z" }, + { url = "https://files.pythonhosted.org/packages/59/3c/ea5a4687adaba5e125b9bd6190153d0037325a0ba3757cc1537cc2c8dd90/regex-2026.2.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75472631eee7898e16a8a20998d15106cb31cfde21cdf96ab40b432a7082af06", size = 803702, upload-time = "2026-02-19T19:01:05.298Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c5/624a0705e8473a26488ec1a3a4e0b8763ecfc682a185c302dfec71daea35/regex-2026.2.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d89f85a5ccc0cec125c24be75610d433d65295827ebaf0d884cbe56df82d4774", size = 775980, upload-time = "2026-02-19T19:01:07.047Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4b/ed776642533232b5599b7c1f9d817fe11faf597e8a92b7a44b841daaae76/regex-2026.2.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9f81806abdca3234c3dd582b8a97492e93de3602c8772013cb4affa12d1668", size = 788122, upload-time = "2026-02-19T19:01:08.744Z" }, + { url = "https://files.pythonhosted.org/packages/8c/58/e93e093921d13b9784b4f69896b6e2a9e09580a265c59d9eb95e87d288f2/regex-2026.2.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9dadc10d1c2bbb1326e572a226d2ec56474ab8aab26fdb8cf19419b372c349a9", size = 858910, upload-time = "2026-02-19T19:01:10.488Z" }, + { url = "https://files.pythonhosted.org/packages/85/77/ff1d25a0c56cd546e0455cbc93235beb33474899690e6a361fa6b52d265b/regex-2026.2.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6bc25d7e15f80c9dc7853cbb490b91c1ec7310808b09d56bd278fe03d776f4f6", size = 764153, upload-time = "2026-02-19T19:01:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ef/8ec58df26d52d04443b1dc56f9be4b409f43ed5ae6c0248a287f52311fc4/regex-2026.2.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:965d59792f5037d9138da6fed50ba943162160443b43d4895b182551805aff9c", size = 850348, upload-time = "2026-02-19T19:01:14.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b3/c42fd5ed91639ce5a4225b9df909180fc95586db071f2bf7c68d2ccbfbe6/regex-2026.2.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:38d88c6ed4a09ed61403dbdf515d969ccba34669af3961ceb7311ecd0cef504a", size = 789977, upload-time = "2026-02-19T19:01:15.838Z" }, + { url = "https://files.pythonhosted.org/packages/b6/22/bc3b58ebddbfd6ca5633e71fd41829ee931963aad1ebeec55aad0c23044e/regex-2026.2.19-cp312-cp312-win32.whl", hash = "sha256:5df947cabab4b643d4791af5e28aecf6bf62e6160e525651a12eba3d03755e6b", size = 266381, upload-time = "2026-02-19T19:01:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4a/6ff550b63e67603ee60e69dc6bd2d5694e85046a558f663b2434bdaeb285/regex-2026.2.19-cp312-cp312-win_amd64.whl", hash = "sha256:4146dc576ea99634ae9c15587d0c43273b4023a10702998edf0fa68ccb60237a", size = 277274, upload-time = "2026-02-19T19:01:19.826Z" }, + { url = "https://files.pythonhosted.org/packages/cc/29/9ec48b679b1e87e7bc8517dff45351eab38f74fbbda1fbcf0e9e6d4e8174/regex-2026.2.19-cp312-cp312-win_arm64.whl", hash = "sha256:cdc0a80f679353bd68450d2a42996090c30b2e15ca90ded6156c31f1a3b63f3b", size = 270509, upload-time = "2026-02-19T19:01:22.075Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2d/a849835e76ac88fcf9e8784e642d3ea635d183c4112150ca91499d6703af/regex-2026.2.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8df08decd339e8b3f6a2eb5c05c687fe9d963ae91f352bc57beb05f5b2ac6879", size = 489329, upload-time = "2026-02-19T19:01:23.841Z" }, + { url = "https://files.pythonhosted.org/packages/da/aa/78ff4666d3855490bae87845a5983485e765e1f970da20adffa2937b241d/regex-2026.2.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3aa0944f1dc6e92f91f3b306ba7f851e1009398c84bfd370633182ee4fc26a64", size = 291308, upload-time = "2026-02-19T19:01:25.605Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/714384efcc07ae6beba528a541f6e99188c5cc1bc0295337f4e8a868296d/regex-2026.2.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c13228fbecb03eadbfd8f521732c5fda09ef761af02e920a3148e18ad0e09968", size = 289033, upload-time = "2026-02-19T19:01:27.243Z" }, + { url = "https://files.pythonhosted.org/packages/75/ec/6438a9344d2869cf5265236a06af1ca6d885e5848b6561e10629bc8e5a11/regex-2026.2.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d0e72703c60d68b18b27cde7cdb65ed2570ae29fb37231aa3076bfb6b1d1c13", size = 798798, upload-time = "2026-02-19T19:01:28.877Z" }, + { url = "https://files.pythonhosted.org/packages/c2/be/b1ce2d395e3fd2ce5f2fde2522f76cade4297cfe84cd61990ff48308749c/regex-2026.2.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46e69a4bf552e30e74a8aa73f473c87efcb7f6e8c8ece60d9fd7bf13d5c86f02", size = 864444, upload-time = "2026-02-19T19:01:30.933Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/a3406460c504f7136f140d9461960c25f058b0240e4424d6fb73c7a067ab/regex-2026.2.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8edda06079bd770f7f0cf7f3bba1a0b447b96b4a543c91fe0c142d034c166161", size = 912633, upload-time = "2026-02-19T19:01:32.744Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d9/e5dbef95008d84e9af1dc0faabbc34a7fbc8daa05bc5807c5cf86c2bec49/regex-2026.2.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cbc69eae834afbf634f7c902fc72ff3e993f1c699156dd1af1adab5d06b7fe7", size = 803718, upload-time = "2026-02-19T19:01:34.61Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e5/61d80132690a1ef8dc48e0f44248036877aebf94235d43f63a20d1598888/regex-2026.2.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bcf57d30659996ee5c7937999874504c11b5a068edc9515e6a59221cc2744dd1", size = 775975, upload-time = "2026-02-19T19:01:36.525Z" }, + { url = "https://files.pythonhosted.org/packages/05/32/ae828b3b312c972cf228b634447de27237d593d61505e6ad84723f8eabba/regex-2026.2.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8e6e77cd92216eb489e21e5652a11b186afe9bdefca8a2db739fd6b205a9e0a4", size = 788129, upload-time = "2026-02-19T19:01:38.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/25/d74f34676f22bec401eddf0e5e457296941e10cbb2a49a571ca7a2c16e5a/regex-2026.2.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b9ab8dec42afefa6314ea9b31b188259ffdd93f433d77cad454cd0b8d235ce1c", size = 858818, upload-time = "2026-02-19T19:01:40.409Z" }, + { url = "https://files.pythonhosted.org/packages/1e/eb/0bc2b01a6b0b264e1406e5ef11cae3f634c3bd1a6e61206fd3227ce8e89c/regex-2026.2.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:294c0fb2e87c6bcc5f577c8f609210f5700b993151913352ed6c6af42f30f95f", size = 764186, upload-time = "2026-02-19T19:01:43.009Z" }, + { url = "https://files.pythonhosted.org/packages/eb/37/5fe5a630d0d99ecf0c3570f8905dafbc160443a2d80181607770086c9812/regex-2026.2.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c0924c64b082d4512b923ac016d6e1dcf647a3560b8a4c7e55cbbd13656cb4ed", size = 850363, upload-time = "2026-02-19T19:01:45.015Z" }, + { url = "https://files.pythonhosted.org/packages/c3/45/ef68d805294b01ec030cfd388724ba76a5a21a67f32af05b17924520cb0b/regex-2026.2.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:790dbf87b0361606cb0d79b393c3e8f4436a14ee56568a7463014565d97da02a", size = 790026, upload-time = "2026-02-19T19:01:47.51Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/40d3b66923dfc5aeba182f194f0ca35d09afe8c031a193e6ae46971a0a0e/regex-2026.2.19-cp313-cp313-win32.whl", hash = "sha256:43cdde87006271be6963896ed816733b10967baaf0e271d529c82e93da66675b", size = 266372, upload-time = "2026-02-19T19:01:49.469Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f2/39082e8739bfd553497689e74f9d5e5bb531d6f8936d0b94f43e18f219c0/regex-2026.2.19-cp313-cp313-win_amd64.whl", hash = "sha256:127ea69273485348a126ebbf3d6052604d3c7da284f797bba781f364c0947d47", size = 277253, upload-time = "2026-02-19T19:01:51.208Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c2/852b9600d53fb47e47080c203e2cdc0ac7e84e37032a57e0eaa37446033a/regex-2026.2.19-cp313-cp313-win_arm64.whl", hash = "sha256:5e56c669535ac59cbf96ca1ece0ef26cb66809990cda4fa45e1e32c3b146599e", size = 270505, upload-time = "2026-02-19T19:01:52.865Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a2/e0b4575b93bc84db3b1fab24183e008691cd2db5c0ef14ed52681fbd94dd/regex-2026.2.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:93d881cab5afdc41a005dba1524a40947d6f7a525057aa64aaf16065cf62faa9", size = 492202, upload-time = "2026-02-19T19:01:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/24/b5/b84fec8cbb5f92a7eed2b6b5353a6a9eed9670fee31817c2da9eb85dc797/regex-2026.2.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:80caaa1ddcc942ec7be18427354f9d58a79cee82dea2a6b3d4fd83302e1240d7", size = 292884, upload-time = "2026-02-19T19:01:58.254Z" }, + { url = "https://files.pythonhosted.org/packages/70/0c/fe89966dfae43da46f475362401f03e4d7dc3a3c955b54f632abc52669e0/regex-2026.2.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d793c5b4d2b4c668524cd1651404cfc798d40694c759aec997e196fe9729ec60", size = 291236, upload-time = "2026-02-19T19:01:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f7/bda2695134f3e63eb5cccbbf608c2a12aab93d261ff4e2fe49b47fabc948/regex-2026.2.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5100acb20648d9efd3f4e7e91f51187f95f22a741dcd719548a6cf4e1b34b3f", size = 807660, upload-time = "2026-02-19T19:02:01.632Z" }, + { url = "https://files.pythonhosted.org/packages/11/56/6e3a4bf5e60d17326b7003d91bbde8938e439256dec211d835597a44972d/regex-2026.2.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e3a31e94d10e52a896adaa3adf3621bd526ad2b45b8c2d23d1bbe74c7423007", size = 873585, upload-time = "2026-02-19T19:02:03.522Z" }, + { url = "https://files.pythonhosted.org/packages/35/5e/c90c6aa4d1317cc11839359479cfdd2662608f339e84e81ba751c8a4e461/regex-2026.2.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8497421099b981f67c99eba4154cf0dfd8e47159431427a11cfb6487f7791d9e", size = 915243, upload-time = "2026-02-19T19:02:05.608Z" }, + { url = "https://files.pythonhosted.org/packages/90/7c/981ea0694116793001496aaf9524e5c99e122ec3952d9e7f1878af3a6bf1/regex-2026.2.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e7a08622f7d51d7a068f7e4052a38739c412a3e74f55817073d2e2418149619", size = 812922, upload-time = "2026-02-19T19:02:08.115Z" }, + { url = "https://files.pythonhosted.org/packages/2d/be/9eda82afa425370ffdb3fa9f3ea42450b9ae4da3ff0a4ec20466f69e371b/regex-2026.2.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8abe671cf0f15c26b1ad389bf4043b068ce7d3b1c5d9313e12895f57d6738555", size = 781318, upload-time = "2026-02-19T19:02:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d5/50f0bbe56a8199f60a7b6c714e06e54b76b33d31806a69d0703b23ce2a9e/regex-2026.2.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5a8f28dd32a4ce9c41758d43b5b9115c1c497b4b1f50c457602c1d571fa98ce1", size = 795649, upload-time = "2026-02-19T19:02:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/d039f081e44a8b0134d0bb2dd805b0ddf390b69d0b58297ae098847c572f/regex-2026.2.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:654dc41a5ba9b8cc8432b3f1aa8906d8b45f3e9502442a07c2f27f6c63f85db5", size = 868844, upload-time = "2026-02-19T19:02:14.043Z" }, + { url = "https://files.pythonhosted.org/packages/ef/53/e2903b79a19ec8557fe7cd21cd093956ff2dbc2e0e33969e3adbe5b184dd/regex-2026.2.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4a02faea614e7fdd6ba8b3bec6c8e79529d356b100381cec76e638f45d12ca04", size = 770113, upload-time = "2026-02-19T19:02:16.161Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e2/784667767b55714ebb4e59bf106362327476b882c0b2f93c25e84cc99b1a/regex-2026.2.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d96162140bb819814428800934c7b71b7bffe81fb6da2d6abc1dcca31741eca3", size = 854922, upload-time = "2026-02-19T19:02:18.155Z" }, + { url = "https://files.pythonhosted.org/packages/59/78/9ef4356bd4aed752775bd18071034979b85f035fec51f3a4f9dea497a254/regex-2026.2.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c227f2922153ee42bbeb355fd6d009f8c81d9d7bdd666e2276ce41f53ed9a743", size = 799636, upload-time = "2026-02-19T19:02:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/cf/54/fcfc9287f20c5c9bd8db755aafe3e8cf4d99a6a3f1c7162ee182e0ca9374/regex-2026.2.19-cp313-cp313t-win32.whl", hash = "sha256:a178df8ec03011153fbcd2c70cb961bc98cbbd9694b28f706c318bee8927c3db", size = 268968, upload-time = "2026-02-19T19:02:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a0/ff24c6cb1273e42472706d277147fc38e1f9074a280fb6034b0fc9b69415/regex-2026.2.19-cp313-cp313t-win_amd64.whl", hash = "sha256:2c1693ca6f444d554aa246b592355b5cec030ace5a2729eae1b04ab6e853e768", size = 280390, upload-time = "2026-02-19T19:02:25.231Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/a3f6ad89d780ffdeebb4d5e2e3e30bd2ef1f70f6a94d1760e03dd1e12c60/regex-2026.2.19-cp313-cp313t-win_arm64.whl", hash = "sha256:c0761d7ae8d65773e01515ebb0b304df1bf37a0a79546caad9cbe79a42c12af7", size = 271643, upload-time = "2026-02-19T19:02:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e2/7ad4e76a6dddefc0d64dbe12a4d3ca3947a19ddc501f864a5df2a8222ddd/regex-2026.2.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:03d191a9bcf94d31af56d2575210cb0d0c6a054dbcad2ea9e00aa4c42903b919", size = 489306, upload-time = "2026-02-19T19:02:29.058Z" }, + { url = "https://files.pythonhosted.org/packages/14/95/ee1736135733afbcf1846c58671046f99c4d5170102a150ebb3dd8d701d9/regex-2026.2.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:516ee067c6c721d0d0bfb80a2004edbd060fffd07e456d4e1669e38fe82f922e", size = 291218, upload-time = "2026-02-19T19:02:31.083Z" }, + { url = "https://files.pythonhosted.org/packages/ef/08/180d1826c3d7065200a5168c6b993a44947395c7bb6e04b2c2a219c34225/regex-2026.2.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:997862c619994c4a356cb7c3592502cbd50c2ab98da5f61c5c871f10f22de7e5", size = 289097, upload-time = "2026-02-19T19:02:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/28/93/0651924c390c5740f5f896723f8ddd946a6c63083a7d8647231c343912ff/regex-2026.2.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02b9e1b8a7ebe2807cd7bbdf662510c8e43053a23262b9f46ad4fc2dfc9d204e", size = 799147, upload-time = "2026-02-19T19:02:35.669Z" }, + { url = "https://files.pythonhosted.org/packages/a7/00/2078bd8bcd37d58a756989adbfd9f1d0151b7ca4085a9c2a07e917fbac61/regex-2026.2.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6c8fb3b19652e425ff24169dad3ee07f99afa7996caa9dfbb3a9106cd726f49a", size = 865239, upload-time = "2026-02-19T19:02:38.012Z" }, + { url = "https://files.pythonhosted.org/packages/2a/13/75195161ec16936b35a365fa8c1dd2ab29fd910dd2587765062b174d8cfc/regex-2026.2.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50f1ee9488dd7a9fda850ec7c68cad7a32fa49fd19733f5403a3f92b451dcf73", size = 911904, upload-time = "2026-02-19T19:02:40.737Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/ac42f6012179343d1c4bd0ffee8c948d841cb32ea188d37e96d80527fcc9/regex-2026.2.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab780092b1424d13200aa5a62996e95f65ee3db8509be366437439cdc0af1a9f", size = 803518, upload-time = "2026-02-19T19:02:42.923Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d1/75a08e2269b007b9783f0f86aa64488e023141219cb5f14dc1e69cda56c6/regex-2026.2.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17648e1a88e72d88641b12635e70e6c71c5136ba14edba29bf8fc6834005a265", size = 775866, upload-time = "2026-02-19T19:02:45.189Z" }, + { url = "https://files.pythonhosted.org/packages/92/41/70e7d05faf6994c2ca7a9fcaa536da8f8e4031d45b0ec04b57040ede201f/regex-2026.2.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f914ae8c804c8a8a562fe216100bc156bfb51338c1f8d55fe32cf407774359a", size = 788224, upload-time = "2026-02-19T19:02:47.804Z" }, + { url = "https://files.pythonhosted.org/packages/c8/83/34a2dd601f9deb13c20545c674a55f4a05c90869ab73d985b74d639bac43/regex-2026.2.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c7e121a918bbee3f12ac300ce0a0d2f2c979cf208fb071ed8df5a6323281915c", size = 859682, upload-time = "2026-02-19T19:02:50.583Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/136db9a09a7f222d6e48b806f3730e7af6499a8cad9c72ac0d49d52c746e/regex-2026.2.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2fedd459c791da24914ecc474feecd94cf7845efb262ac3134fe27cbd7eda799", size = 764223, upload-time = "2026-02-19T19:02:52.777Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/bb947743c78a16df481fa0635c50aa1a439bb80b0e6dc24cd4e49c716679/regex-2026.2.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:ea8dfc99689240e61fb21b5fc2828f68b90abf7777d057b62d3166b7c1543c4c", size = 850101, upload-time = "2026-02-19T19:02:55.87Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/e3bfe6e97a99f7393665926be02fef772da7f8aa59e50bc3134e4262a032/regex-2026.2.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fff45852160960f29e184ec8a5be5ab4063cfd0b168d439d1fc4ac3744bf29e", size = 789904, upload-time = "2026-02-19T19:02:58.523Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/7e2be6f00cea59d08761b027ad237002e90cac74b1607200ebaa2ba3d586/regex-2026.2.19-cp314-cp314-win32.whl", hash = "sha256:5390b130cce14a7d1db226a3896273b7b35be10af35e69f1cca843b6e5d2bb2d", size = 271784, upload-time = "2026-02-19T19:03:00.418Z" }, + { url = "https://files.pythonhosted.org/packages/f7/f6/639911530335773e7ec60bcaa519557b719586024c1d7eaad1daf87b646b/regex-2026.2.19-cp314-cp314-win_amd64.whl", hash = "sha256:e581f75d5c0b15669139ca1c2d3e23a65bb90e3c06ba9d9ea194c377c726a904", size = 280506, upload-time = "2026-02-19T19:03:02.302Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ec/2582b56b4e036d46bb9b5d74a18548439ffa16c11cf59076419174d80f48/regex-2026.2.19-cp314-cp314-win_arm64.whl", hash = "sha256:7187fdee1be0896c1499a991e9bf7c78e4b56b7863e7405d7bb687888ac10c4b", size = 273557, upload-time = "2026-02-19T19:03:04.836Z" }, + { url = "https://files.pythonhosted.org/packages/49/0b/f901cfeb4efd83e4f5c3e9f91a6de77e8e5ceb18555698aca3a27e215ed3/regex-2026.2.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5ec1d7c080832fdd4e150c6f5621fe674c70c63b3ae5a4454cebd7796263b175", size = 492196, upload-time = "2026-02-19T19:03:08.188Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/349b959e3da874e15eda853755567b4cde7e5309dbb1e07bfe910cfde452/regex-2026.2.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8457c1bc10ee9b29cdfd897ccda41dce6bde0e9abd514bcfef7bcd05e254d411", size = 292878, upload-time = "2026-02-19T19:03:10.272Z" }, + { url = "https://files.pythonhosted.org/packages/98/b0/9d81b3c2c5ddff428f8c506713737278979a2c476f6e3675a9c51da0c389/regex-2026.2.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cce8027010d1ffa3eb89a0b19621cdc78ae548ea2b49fea1f7bfb3ea77064c2b", size = 291235, upload-time = "2026-02-19T19:03:12.5Z" }, + { url = "https://files.pythonhosted.org/packages/04/e7/be7818df8691dbe9508c381ea2cc4c1153e4fdb1c4b06388abeaa93bd712/regex-2026.2.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11c138febb40546ff9e026dbbc41dc9fb8b29e61013fa5848ccfe045f5b23b83", size = 807893, upload-time = "2026-02-19T19:03:15.064Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b6/b898a8b983190cfa0276031c17beb73cfd1db07c03c8c37f606d80b655e2/regex-2026.2.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:74ff212aa61532246bb3036b3dfea62233414b0154b8bc3676975da78383cac3", size = 873696, upload-time = "2026-02-19T19:03:17.848Z" }, + { url = "https://files.pythonhosted.org/packages/1a/98/126ba671d54f19080ec87cad228fb4f3cc387fff8c4a01cb4e93f4ff9d94/regex-2026.2.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d00c95a2b6bfeb3ea1cb68d1751b1dfce2b05adc2a72c488d77a780db06ab867", size = 915493, upload-time = "2026-02-19T19:03:20.343Z" }, + { url = "https://files.pythonhosted.org/packages/b2/10/550c84a1a1a7371867fe8be2bea7df55e797cbca4709974811410e195c5d/regex-2026.2.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311fcccb76af31be4c588d5a17f8f1a059ae8f4b097192896ebffc95612f223a", size = 813094, upload-time = "2026-02-19T19:03:23.287Z" }, + { url = "https://files.pythonhosted.org/packages/29/fb/ba221d2fc76a27b6b7d7a60f73a7a6a7bac21c6ba95616a08be2bcb434b0/regex-2026.2.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77cfd6b5e7c4e8bf7a39d243ea05882acf5e3c7002b0ef4756de6606893b0ecd", size = 781583, upload-time = "2026-02-19T19:03:26.872Z" }, + { url = "https://files.pythonhosted.org/packages/26/f1/af79231301297c9e962679efc04a31361b58dc62dec1fc0cb4b8dd95956a/regex-2026.2.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6380f29ff212ec922b6efb56100c089251940e0526a0d05aa7c2d9b571ddf2fe", size = 795875, upload-time = "2026-02-19T19:03:29.223Z" }, + { url = "https://files.pythonhosted.org/packages/a0/90/1e1d76cb0a2d0a4f38a039993e1c5cd971ae50435d751c5bae4f10e1c302/regex-2026.2.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:655f553a1fa3ab8a7fd570eca793408b8d26a80bfd89ed24d116baaf13a38969", size = 868916, upload-time = "2026-02-19T19:03:31.415Z" }, + { url = "https://files.pythonhosted.org/packages/9a/67/a1c01da76dbcfed690855a284c665cc0a370e7d02d1bd635cf9ff7dd74b8/regex-2026.2.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:015088b8558502f1f0bccd58754835aa154a7a5b0bd9d4c9b7b96ff4ae9ba876", size = 770386, upload-time = "2026-02-19T19:03:33.972Z" }, + { url = "https://files.pythonhosted.org/packages/49/6f/94842bf294f432ff3836bfd91032e2ecabea6d284227f12d1f935318c9c4/regex-2026.2.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9e6693b8567a59459b5dda19104c4a4dbbd4a1c78833eacc758796f2cfef1854", size = 855007, upload-time = "2026-02-19T19:03:36.238Z" }, + { url = "https://files.pythonhosted.org/packages/ff/93/393cd203ca0d1d368f05ce12d2c7e91a324bc93c240db2e6d5ada05835f4/regex-2026.2.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4071209fd4376ab5ceec72ad3507e9d3517c59e38a889079b98916477a871868", size = 799863, upload-time = "2026-02-19T19:03:38.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/35afda99bd92bf1a5831e55a4936d37ea4bed6e34c176a3c2238317faf4f/regex-2026.2.19-cp314-cp314t-win32.whl", hash = "sha256:2905ff4a97fad42f2d0834d8b1ea3c2f856ec209837e458d71a061a7d05f9f01", size = 274742, upload-time = "2026-02-19T19:03:40.804Z" }, + { url = "https://files.pythonhosted.org/packages/ae/42/7edc3344dcc87b698e9755f7f685d463852d481302539dae07135202d3ca/regex-2026.2.19-cp314-cp314t-win_amd64.whl", hash = "sha256:64128549b600987e0f335c2365879895f860a9161f283b14207c800a6ed623d3", size = 284443, upload-time = "2026-02-19T19:03:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/3a/45/affdf2d851b42adf3d13fc5b3b059372e9bd299371fd84cf5723c45871fa/regex-2026.2.19-cp314-cp314t-win_arm64.whl", hash = "sha256:a09ae430e94c049dc6957f6baa35ee3418a3a77f3c12b6e02883bd80a2b679b0", size = 274932, upload-time = "2026-02-19T19:03:45.488Z" }, ] [[package]] @@ -5897,29 +5898,29 @@ wheels = [ [[package]] name = "rich" -version = "14.3.2" +version = "14.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] [[package]] name = "rich-toolkit" -version = "0.19.2" +version = "0.19.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/11/6f/07f3e1cb44ca43c882382ecacb60232e2ed61c9275a81441d54e0b0348fb/rich_toolkit-0.19.2.tar.gz", hash = "sha256:c920006b146639fae5975485c86b41be0d83638107e428babc811dad3bd00404", size = 193414, upload-time = "2026-02-10T17:30:01.54Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/c9/4bbf4bfee195ed1b7d7a6733cc523ca61dbfb4a3e3c12ea090aaffd97597/rich_toolkit-0.19.4.tar.gz", hash = "sha256:52e23d56f9dc30d1343eb3b3f6f18764c313fbfea24e52e6a1d6069bec9c18eb", size = 193951, upload-time = "2026-02-12T10:08:15.814Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/36/bc918ef04846d2e064edd34f5b3fadc4234e471110cfcd849a4795ed08c8/rich_toolkit-0.19.2-py3-none-any.whl", hash = "sha256:6dc07320f69f5893146fe1a21afc250e91710b72c13a2324e3c323ca762134dd", size = 32711, upload-time = "2026-02-10T17:30:03.391Z" }, + { url = "https://files.pythonhosted.org/packages/28/31/97d39719def09c134385bfcfbedfed255168b571e7beb3ad7765aae660ca/rich_toolkit-0.19.4-py3-none-any.whl", hash = "sha256:34ac344de8862801644be8b703e26becf44b047e687f208d7829e8f7cfc311d6", size = 32757, upload-time = "2026-02-12T10:08:15.037Z" }, ] [[package]] @@ -6188,27 +6189,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.0" +version = "0.15.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, - { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, - { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, - { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, - { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, - { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, - { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, - { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, - { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, - { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, - { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, - { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, - { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, - { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, + { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, + { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, + { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, + { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, + { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, ] [[package]] @@ -6251,7 +6252,7 @@ wheels = [ [[package]] name = "sarvamai" -version = "0.1.21" +version = "0.1.26a2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -6260,9 +6261,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/08/e5efcb30818ed220b818319255c22fd91e379489ebaa93efd6f444fb4987/sarvamai-0.1.21.tar.gz", hash = "sha256:865065635b2b99d40f5519308832954015627938e06a6333b5f62ae9c36278bb", size = 87386, upload-time = "2025-10-07T07:37:47.085Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/6c/80ab26743586532a3e9d68385549b0992e5318b5499db815889c8527cce5/sarvamai-0.1.26a2.tar.gz", hash = "sha256:0cbd1a95d13c1f8f0d1bf8fbeb37e86d3c2dc75a7ac402743bf0e571378f79e4", size = 112445, upload-time = "2026-02-16T13:16:28.392Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/4e/b9933f72681b7aed91b86913337dd3981fad97027881fbc66c3c5eb03568/sarvamai-0.1.21-py3-none-any.whl", hash = "sha256:daa4e5d16635fe434f5f270cee416849249285369141d77132a17f0bf670f120", size = 175204, upload-time = "2025-10-07T07:37:46.024Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3e/76c8ea81e790a5dab2ec9cd9fdb02cd600f90b1dfd9c895bea2fb5e6aa7f/sarvamai-0.1.26a2-py3-none-any.whl", hash = "sha256:2b0549a18e093ea382725240035a0bea18fff2a0d5207ad6c95ff7189e03264a", size = 227413, upload-time = "2026-02-16T13:16:27.045Z" }, ] [[package]] @@ -6416,15 +6417,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.52.0" +version = "2.53.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/eb/1b497650eb564701f9a7b8a95c51b2abe9347ed2c0b290ba78f027ebe4ea/sentry_sdk-2.52.0.tar.gz", hash = "sha256:fa0bec872cfec0302970b2996825723d67390cdd5f0229fb9efed93bd5384899", size = 410273, upload-time = "2026-02-04T15:03:54.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/06/66c8b705179bc54087845f28fd1b72f83751b6e9a195628e2e9af9926505/sentry_sdk-2.53.0.tar.gz", hash = "sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77", size = 412369, upload-time = "2026-02-16T11:11:14.743Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/63/2c6daf59d86b1c30600bff679d039f57fd1932af82c43c0bde1cbc55e8d4/sentry_sdk-2.52.0-py2.py3-none-any.whl", hash = "sha256:931c8f86169fc6f2752cb5c4e6480f0d516112e78750c312e081ababecbaf2ed", size = 435547, upload-time = "2026-02-04T15:03:51.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/d4/2fdf854bc3b9c7f55219678f812600a20a138af2dd847d99004994eada8f/sentry_sdk-2.53.0-py2.py3-none-any.whl", hash = "sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899", size = 437908, upload-time = "2026-02-16T11:11:13.227Z" }, ] [[package]] @@ -6792,7 +6793,7 @@ wheels = [ [[package]] name = "sphinx-autodoc-typehints" -version = "3.6.2" +version = "3.6.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14'", @@ -6802,9 +6803,9 @@ resolution-markers = [ dependencies = [ { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/51/6603ed3786a2d52366c66f49bc8afb31ae5c0e33d4a156afcb38d2bac62c/sphinx_autodoc_typehints-3.6.2.tar.gz", hash = "sha256:3d37709a21b7b765ad6e20a04ecefcb229b9eb0007cb24f6ebaa8a4576ea7f06", size = 37574, upload-time = "2026-01-02T21:25:28.216Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/5f/ebcaed1a67e623e4a7622808a8be6b0fd8344313e185f62e85a26b0ce26a/sphinx_autodoc_typehints-3.6.3.tar.gz", hash = "sha256:6c387b47d9ad5e75b157810af5bad46901f0a22708ed5e4adf466885a9c60910", size = 38288, upload-time = "2026-02-18T04:22:08.384Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/6a/877e8a6ea52fc86d88ce110ebcfe4f8474ff590d8a8d322909673af3da7b/sphinx_autodoc_typehints-3.6.2-py3-none-any.whl", hash = "sha256:9e70bee1f487b087c83ba0f4949604a4630bee396e263a324aae1dc4268d2c0f", size = 20853, upload-time = "2026-01-02T21:25:26.853Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bd/2b853836d152e40a27655828fdc02c5128f294ac452ad9a13424bb7f92fa/sphinx_autodoc_typehints-3.6.3-py3-none-any.whl", hash = "sha256:46ebc68fa85b320d55887a8d836a01e12e3b7744da973e70af8cedc74072aad5", size = 20882, upload-time = "2026-02-18T04:22:07.238Z" }, ] [[package]] @@ -6993,7 +6994,7 @@ wheels = [ [[package]] name = "strands-agents" -version = "1.25.0" +version = "1.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "boto3" }, @@ -7008,9 +7009,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "watchdog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/30/1b437b74d1854c9704fa3a59390bddf71c158425a76088c8b94f620756ea/strands_agents-1.25.0.tar.gz", hash = "sha256:831a91d38d82f2051efb3d2ad013b4d6d2bbdad2353421796371a7e94503bc59", size = 697397, upload-time = "2026-02-05T20:05:30.275Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/54/bf0910a1c40feacaedcf5d30840be990eabd09eff5375fa40525ba530c8d/strands_agents-1.27.0.tar.gz", hash = "sha256:84d0b670e534d7c281104a22035c10de8d43e9ad8ee589bde16f54a8387b2c56", size = 712878, upload-time = "2026-02-19T17:18:23.327Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/52/f07b14af7d71c467999ebb7f6c5ccc681cbd579a3993def21dd9b3507564/strands_agents-1.25.0-py3-none-any.whl", hash = "sha256:16b3a6331c8a1a8a79a249202c591a9cd2d777893371bcd8c12e458ada23587c", size = 345783, upload-time = "2026-02-05T20:05:28.023Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ca/d5c269f83929bdc753dce3c6091a1671e50268769b0ace009264424bf165/strands_agents-1.27.0-py3-none-any.whl", hash = "sha256:d9012515a7b4f324a600cacc539e837a51b3f7fe21da7efe1764186ade3be498", size = 351988, upload-time = "2026-02-19T17:18:19Z" }, ] [[package]] @@ -7429,7 +7430,7 @@ wheels = [ [[package]] name = "typer" -version = "0.21.2" +version = "0.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -7437,9 +7438,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/1e/a27cc02a0cd715118c71fa2aef2c687fdefc3c28d90fd0dd789c5118154c/typer-0.21.2.tar.gz", hash = "sha256:1abd95a3b675e17ff61b0838ac637fe9478d446d62ad17fa4bb81ea57cc54028", size = 120426, upload-time = "2026-02-10T19:33:46.182Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b6/3e681d3b6bb22647509bdbfdd18055d5adc0dce5c5585359fa46ff805fdc/typer-0.24.0.tar.gz", hash = "sha256:f9373dc4eff901350694f519f783c29b6d7a110fc0dcc11b1d7e353b85ca6504", size = 118380, upload-time = "2026-02-16T22:08:48.496Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/cc/d59f893fbdfb5f58770c05febfc4086a46875f1084453621c35605cec946/typer-0.21.2-py3-none-any.whl", hash = "sha256:c3d8de54d00347ef90b82131ca946274f017cffb46683ae3883c360fa958f55c", size = 56728, upload-time = "2026-02-10T19:33:48.01Z" }, + { url = "https://files.pythonhosted.org/packages/85/d0/4da85c2a45054bb661993c93524138ace4956cb075a7ae0c9d1deadc331b/typer-0.24.0-py3-none-any.whl", hash = "sha256:5fc435a9c8356f6160ed6e85a6301fdd6e3d8b2851da502050d1f92c5e9eddc8", size = 56441, upload-time = "2026-02-16T22:08:47.535Z" }, ] [[package]] @@ -7614,16 +7615,16 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.40.0" +version = "0.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, ] [package.optional-dependencies] @@ -7683,7 +7684,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "20.36.1" +version = "20.38.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -7691,9 +7692,9 @@ dependencies = [ { name = "platformdirs" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/03/a94d404ca09a89a7301a7008467aed525d4cdeb9186d262154dd23208709/virtualenv-20.38.0.tar.gz", hash = "sha256:94f39b1abaea5185bf7ea5a46702b56f1d0c9aa2f41a6c2b8b0af4ddc74c10a7", size = 5864558, upload-time = "2026-02-19T07:48:02.385Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, + { url = "https://files.pythonhosted.org/packages/42/d7/394801755d4c8684b655d35c665aea7836ec68320304f62ab3c94395b442/virtualenv-20.38.0-py3-none-any.whl", hash = "sha256:d6e78e5889de3a4742df2d3d44e779366325a90cf356f15621fddace82431794", size = 5837778, upload-time = "2026-02-19T07:47:59.778Z" }, ] [[package]] From af4ef95dc68625b6827816104e54f32b3d228cd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Fri, 20 Feb 2026 14:24:22 -0800 Subject: [PATCH 87/93] Fix missing await on add_audio_frames_message in Google audio examples The method is async but was being called without await, silently discarding the coroutine. --- examples/foundational/07s-interruptible-google-audio-in.py | 2 +- examples/foundational/25-google-audio-in.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/foundational/07s-interruptible-google-audio-in.py b/examples/foundational/07s-interruptible-google-audio-in.py index 707db107e..1de374a3f 100644 --- a/examples/foundational/07s-interruptible-google-audio-in.py +++ b/examples/foundational/07s-interruptible-google-audio-in.py @@ -96,7 +96,7 @@ class UserAudioCollector(FrameProcessor): self._user_speaking = True elif isinstance(frame, UserStoppedSpeakingFrame): self._user_speaking = False - self._context.add_audio_frames_message(audio_frames=self._audio_frames) + await self._context.add_audio_frames_message(audio_frames=self._audio_frames) await self._user_context_aggregator.push_frame(LLMRunFrame()) elif isinstance(frame, InputAudioRawFrame): diff --git a/examples/foundational/25-google-audio-in.py b/examples/foundational/25-google-audio-in.py index 002aeaa1c..40903e1d5 100644 --- a/examples/foundational/25-google-audio-in.py +++ b/examples/foundational/25-google-audio-in.py @@ -98,7 +98,7 @@ class UserAudioCollector(FrameProcessor): self._user_speaking = True elif isinstance(frame, UserStoppedSpeakingFrame): self._user_speaking = False - self._context.add_audio_frames_message(audio_frames=self._audio_frames) + await self._context.add_audio_frames_message(audio_frames=self._audio_frames) await self._user_context_aggregator.push_frame(LLMContextFrame(context=self._context)) elif isinstance(frame, InputAudioRawFrame): if self._user_speaking: From 827032fefb65246415af137b47dfbe307e958734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Fri, 20 Feb 2026 14:26:41 -0800 Subject: [PATCH 88/93] Unblock push_interruption_task_frame_and_wait after timeout When the InterruptionFrame does not complete within the timeout the caller was stuck in an infinite loop logging warnings. Now the event is set after the first timeout so the processor can continue. Also adds a keyword timeout parameter so callers can customize the wait duration. --- src/pipecat/processors/frame_processor.py | 25 ++++++++++++----------- tests/test_frame_processor.py | 5 ++--- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/pipecat/processors/frame_processor.py b/src/pipecat/processors/frame_processor.py index 4cf32c800..bcdb2d57b 100644 --- a/src/pipecat/processors/frame_processor.py +++ b/src/pipecat/processors/frame_processor.py @@ -52,8 +52,6 @@ from pipecat.processors.metrics.frame_processor_metrics import FrameProcessorMet from pipecat.utils.asyncio.task_manager import BaseTaskManager from pipecat.utils.base_object import BaseObject -INTERRUPTION_COMPLETION_TIMEOUT = 2.0 - class FrameDirection(Enum): """Direction of frame flow in the processing pipeline. @@ -763,7 +761,7 @@ class FrameProcessor(BaseObject): await self._call_event_handler("on_after_push_frame", frame) - async def push_interruption_task_frame_and_wait(self): + async def push_interruption_task_frame_and_wait(self, *, timeout: float = 5.0): """Push an interruption task frame upstream and wait for the interruption. This function sends an `InterruptionTaskFrame` upstream to the @@ -772,9 +770,11 @@ class FrameProcessor(BaseObject): attached to both frames so the caller can wait until the interruption has fully traversed the pipeline. The event is set when the `InterruptionFrame` reaches the pipeline sink. If the frame does - not complete within `INTERRUPTION_COMPLETION_TIMEOUT` seconds, a - warning is logged periodically until it completes. + not complete within the given timeout, a warning is logged and the + event is forcibly set so the caller is unblocked. + Args: + timeout: Maximum seconds to wait for the interruption to complete. """ self._wait_for_interruption = True @@ -782,19 +782,20 @@ class FrameProcessor(BaseObject): await self.push_frame(InterruptionTaskFrame(event=event), FrameDirection.UPSTREAM) - # Wait for the `InterruptionFrame` to complete and log a warning - # periodically if it takes too long. + # Wait for the `InterruptionFrame` to complete and log a warning if it + # takes too long. If it does take too long make sure we unblock it, + # otherwise we will hang here forever. while not event.is_set(): try: - await asyncio.wait_for(event.wait(), timeout=INTERRUPTION_COMPLETION_TIMEOUT) + await asyncio.wait_for(event.wait(), timeout=timeout) except asyncio.TimeoutError: logger.warning( f"{self}: InterruptionFrame has not completed after" - f" {INTERRUPTION_COMPLETION_TIMEOUT}s. Make sure" - " InterruptionFrame.complete() is being called (e.g. if the" - " frame is being blocked or consumed before reaching the" - " pipeline sink)." + f" {timeout}s. Make sure InterruptionFrame.complete()" + " is being called (e.g. if the frame is being blocked" + " or consumed before reaching the pipeline sink)." ) + event.set() self._wait_for_interruption = False diff --git a/tests/test_frame_processor.py b/tests/test_frame_processor.py index 2ce4b7880..138c8e6d8 100644 --- a/tests/test_frame_processor.py +++ b/tests/test_frame_processor.py @@ -25,7 +25,6 @@ from pipecat.frames.frames import ( from pipecat.pipeline.pipeline import Pipeline from pipecat.processors.filters.identity_filter import IdentityFilter from pipecat.processors.frame_processor import ( - INTERRUPTION_COMPLETION_TIMEOUT, FrameDirection, FrameProcessor, ) @@ -521,7 +520,7 @@ class TestFrameProcessor(unittest.IsolatedAsyncioTestCase): # Complete after the timeout so the warning fires # but the test doesn't hang. async def delayed_complete(): - await asyncio.sleep(INTERRUPTION_COMPLETION_TIMEOUT + 1.0) + await asyncio.sleep(1.0) frame.complete() asyncio.create_task(delayed_complete()) @@ -532,7 +531,7 @@ class TestFrameProcessor(unittest.IsolatedAsyncioTestCase): async def process_frame(self, frame: Frame, direction: FrameDirection): await super().process_frame(frame, direction) if isinstance(frame, TextFrame): - await self.push_interruption_task_frame_and_wait() + await self.push_interruption_task_frame_and_wait(timeout=0.5) await self.push_frame(OutputTransportMessageUrgentFrame(message="done")) else: await self.push_frame(frame, direction) From f610fb95f98386d1225ff2cf5b6a7fe26bfced4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Fri, 20 Feb 2026 14:56:46 -0800 Subject: [PATCH 89/93] Add changelog entries for PR #3789 --- changelog/3789.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3789.fixed.md diff --git a/changelog/3789.fixed.md b/changelog/3789.fixed.md new file mode 100644 index 000000000..1bf2be1a3 --- /dev/null +++ b/changelog/3789.fixed.md @@ -0,0 +1 @@ +- Fixed `push_interruption_task_frame_and_wait()` hanging indefinitely when the `InterruptionFrame` does not reach the pipeline sink within the timeout. Added a `timeout` keyword argument to customize the wait duration. From abb20f34ba8eb36fec9d96c22f183093b626da1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Fri, 20 Feb 2026 16:17:51 -0800 Subject: [PATCH 90/93] Update default Anthropic model to claude-sonnet-4-6 Update the default model in AnthropicLLMService and remove the now-unnecessary explicit model from the function calling example. --- examples/foundational/14a-function-calling-anthropic.py | 5 +---- src/pipecat/services/anthropic/llm.py | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/examples/foundational/14a-function-calling-anthropic.py b/examples/foundational/14a-function-calling-anthropic.py index 165d4b220..36030bc2b 100644 --- a/examples/foundational/14a-function-calling-anthropic.py +++ b/examples/foundational/14a-function-calling-anthropic.py @@ -72,10 +72,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady ) - llm = AnthropicLLMService( - api_key=os.getenv("ANTHROPIC_API_KEY"), - model="claude-3-7-sonnet-latest", - ) + llm = AnthropicLLMService(api_key=os.getenv("ANTHROPIC_API_KEY")) llm.register_function("get_weather", get_weather) llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation) diff --git a/src/pipecat/services/anthropic/llm.py b/src/pipecat/services/anthropic/llm.py index a21296fe3..e715c242d 100644 --- a/src/pipecat/services/anthropic/llm.py +++ b/src/pipecat/services/anthropic/llm.py @@ -184,7 +184,7 @@ class AnthropicLLMService(LLMService): self, *, api_key: str, - model: str = "claude-sonnet-4-5-20250929", + model: str = "claude-sonnet-4-6", params: Optional[InputParams] = None, client=None, retry_timeout_secs: Optional[float] = 5.0, @@ -195,7 +195,7 @@ class AnthropicLLMService(LLMService): Args: api_key: Anthropic API key for authentication. - model: Model name to use. Defaults to "claude-sonnet-4-5-20250929". + model: Model name to use. Defaults to "claude-sonnet-4-6". params: Optional model parameters for inference. client: Optional custom Anthropic client instance. retry_timeout_secs: Request timeout in seconds for retry logic. From 521f669051b29da4b312a87d07a59165513aa9d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Fri, 20 Feb 2026 16:18:21 -0800 Subject: [PATCH 91/93] Add changelog entries for PR #3792 --- changelog/3792.changed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3792.changed.md diff --git a/changelog/3792.changed.md b/changelog/3792.changed.md new file mode 100644 index 000000000..ddf7fdc1e --- /dev/null +++ b/changelog/3792.changed.md @@ -0,0 +1 @@ +- Updated default Anthropic model from `claude-sonnet-4-5-20250929` to `claude-sonnet-4-6`. From 18429f80f1c289e47efbe5fec54d4f8ce908a322 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Fri, 20 Feb 2026 16:32:40 -0800 Subject: [PATCH 92/93] github(changelog): allow performance type --- .github/workflows/generate-changelog.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index 005eb94f1..496b3381c 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -86,7 +86,7 @@ jobs: fi # Validate fragment types - VALID_TYPES="added changed deprecated removed fixed security other" + VALID_TYPES="added changed deprecated removed fixed performance security other" INVALID_FRAGMENTS="" for file in changelog/*.md; do From 6d9c07b9458b8127ea9420250cbc956de977aab4 Mon Sep 17 00:00:00 2001 From: aconchillo <951761+aconchillo@users.noreply.github.com> Date: Sat, 21 Feb 2026 00:33:43 +0000 Subject: [PATCH 93/93] Update changelog for version 0.0.103 --- CHANGELOG.md | 209 +++++++++++++++++++++++++++++++++++ changelog/3615.fixed.md | 1 - changelog/3625.added.md | 1 - changelog/3642.added.md | 1 - changelog/3642.changed.md | 1 - changelog/3684.changed.md | 3 - changelog/3698.fixed.md | 1 - changelog/3706.changed.md | 1 - changelog/3713.fixed.md | 1 - changelog/3718.fixed.md | 1 - changelog/3719.added.2.md | 1 - changelog/3719.added.md | 1 - changelog/3719.changed.md | 1 - changelog/3720.fixed.md | 1 - changelog/3728.changed.md | 1 - changelog/3729.fixed.2.md | 1 - changelog/3729.fixed.md | 1 - changelog/3730.added.md | 1 - changelog/3730.changed.md | 1 - changelog/3732.changed.md | 3 - changelog/3733.deprecated.md | 1 - changelog/3735.fixed.md | 1 - changelog/3737.fixed.md | 1 - changelog/3744.fixed.md | 1 - changelog/3748.added.md | 1 - changelog/3748.changed.md | 1 - changelog/3761.changed.md | 1 - changelog/3765.changed.md | 1 - changelog/3768.fixed.md | 1 - changelog/3774.added.md | 1 - changelog/3774.fixed.md | 1 - changelog/3776.changed.md | 1 - changelog/3779.added.md | 1 - changelog/3782.fixed.md | 1 - changelog/3784.fixed.md | 1 - changelog/3785.added.md | 1 - changelog/3787.fixed.md | 1 - changelog/3789.fixed.md | 1 - changelog/3792.changed.md | 1 - 39 files changed, 209 insertions(+), 42 deletions(-) delete mode 100644 changelog/3615.fixed.md delete mode 100644 changelog/3625.added.md delete mode 100644 changelog/3642.added.md delete mode 100644 changelog/3642.changed.md delete mode 100644 changelog/3684.changed.md delete mode 100644 changelog/3698.fixed.md delete mode 100644 changelog/3706.changed.md delete mode 100644 changelog/3713.fixed.md delete mode 100644 changelog/3718.fixed.md delete mode 100644 changelog/3719.added.2.md delete mode 100644 changelog/3719.added.md delete mode 100644 changelog/3719.changed.md delete mode 100644 changelog/3720.fixed.md delete mode 100644 changelog/3728.changed.md delete mode 100644 changelog/3729.fixed.2.md delete mode 100644 changelog/3729.fixed.md delete mode 100644 changelog/3730.added.md delete mode 100644 changelog/3730.changed.md delete mode 100644 changelog/3732.changed.md delete mode 100644 changelog/3733.deprecated.md delete mode 100644 changelog/3735.fixed.md delete mode 100644 changelog/3737.fixed.md delete mode 100644 changelog/3744.fixed.md delete mode 100644 changelog/3748.added.md delete mode 100644 changelog/3748.changed.md delete mode 100644 changelog/3761.changed.md delete mode 100644 changelog/3765.changed.md delete mode 100644 changelog/3768.fixed.md delete mode 100644 changelog/3774.added.md delete mode 100644 changelog/3774.fixed.md delete mode 100644 changelog/3776.changed.md delete mode 100644 changelog/3779.added.md delete mode 100644 changelog/3782.fixed.md delete mode 100644 changelog/3784.fixed.md delete mode 100644 changelog/3785.added.md delete mode 100644 changelog/3787.fixed.md delete mode 100644 changelog/3789.fixed.md delete mode 100644 changelog/3792.changed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ab41e8163..c917ec992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,215 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## [0.0.103] - 2026-02-20 + +### Added + +- Added `"timestampTransportStrategy": "ASYNC"` to `InworldAITTSService`. This + allows timestamps info to trail audio chunks arrival, resulting in much + better first audio chunk latency + (PR [#3625](https://github.com/pipecat-ai/pipecat/pull/3625)) + +- Added model-specific `InputParams` to `RimeTTSService`: arcana params + (`repetition_penalty`, `temperature`, `top_p`) and mistv2 params + (`no_text_normalization`, `save_oovs`, `segment`). Model, voice, and param + changes now trigger WebSocket reconnection. + (PR [#3642](https://github.com/pipecat-ai/pipecat/pull/3642)) + +- Added `write_transport_frame()` hook to `BaseOutputTransport` allowing + transport subclasses to handle custom frame types that flow through the audio + queue. + (PR [#3719](https://github.com/pipecat-ai/pipecat/pull/3719)) + +- Added `DailySIPTransferFrame` and `DailySIPReferFrame` to the Daily + transport. These frames queue SIP transfer and SIP REFER operations with + audio, so the operation executes only after the bot finishes its current + utterance. + (PR [#3719](https://github.com/pipecat-ai/pipecat/pull/3719)) + +- Added keepalive support to `SarvamSTTService` to prevent idle connection + timeouts (e.g. when used behind a `ServiceSwitcher`). + (PR [#3730](https://github.com/pipecat-ai/pipecat/pull/3730)) + +- Added `UserIdleTimeoutUpdateFrame` to enable or disable user idle detection + at runtime by updating the timeout dynamically. + (PR [#3748](https://github.com/pipecat-ai/pipecat/pull/3748)) + +- Added `broadcast_sibling_id` field to the base `Frame` class. This field is + automatically set by `broadcast_frame()` and `broadcast_frame_instance()` to + the ID of the paired frame pushed in the opposite direction, allowing + receivers to identify broadcast pairs. + (PR [#3774](https://github.com/pipecat-ai/pipecat/pull/3774)) + +- Added `ignored_sources` parameter to `RTVIObserverParams` and + `add_ignored_source()`/`remove_ignored_source()` methods to `RTVIObserver` to + suppress RTVI messages from specific pipeline processors (e.g. a silent + evaluation LLM). + (PR [#3779](https://github.com/pipecat-ai/pipecat/pull/3779)) + +- Added `DeepgramSageMakerTTSService` for running Deepgram TTS models deployed + on AWS SageMaker endpoints via HTTP/2 bidirectional streaming. Supports the + Deepgram TTS protocol (Speak, Flush, Clear, Close), interruption handling, + and per-turn TTFB metrics. + (PR [#3785](https://github.com/pipecat-ai/pipecat/pull/3785)) + +### Changed + +- ⚠️ `RimeTTSService` now defaults to `model="arcana"` and the + `wss://users-ws.rime.ai/ws3` endpoint. `InputParams` defaults changed from + mistv2-specific values to `None` — only explicitly-set params are sent as + query params. + (PR [#3642](https://github.com/pipecat-ai/pipecat/pull/3642)) + +- `AICFilter` now shares read-only AIC models via a singleton `AICModelManager` + in `aic_filter.py`. + - Multiple filters using the same model path or `(model_id, + model_download_dir)` share one loaded model, with reference counting and + concurrent load deduplication. + - Model file I/O runs off the event loop so the filter does not block. + (PR [#3684](https://github.com/pipecat-ai/pipecat/pull/3684)) + +- Added `X-User-Agent` and `X-Request-Id` headers to `InworldTTSService` for + better traceability. + (PR [#3706](https://github.com/pipecat-ai/pipecat/pull/3706)) + +- `DailyUpdateRemoteParticipantsFrame` is no longer deprecated and is now + queued with audio like other transport frames. + (PR [#3719](https://github.com/pipecat-ai/pipecat/pull/3719)) + +- Bumped Pillow dependency upper bound from `<12` to `<13` to allow Pillow + 12.x. + (PR [#3728](https://github.com/pipecat-ai/pipecat/pull/3728)) + +- Moved STT keepalive mechanism from `WebsocketSTTService` to the `STTService` + base class, allowing any STT service (not just websocket-based ones) to use + idle-connection keepalive via the `keepalive_timeout` and + `keepalive_interval` parameters. + (PR [#3730](https://github.com/pipecat-ai/pipecat/pull/3730)) + +- Improved audio context management in `AudioContextTTSService` by moving + context ID tracking to the base class and adding + `reuse_context_id_within_turn` parameter to control concurrent TTS request + handling. + - Added helper methods: `has_active_audio_context()`, + `get_active_audio_context_id()`, `remove_active_audio_context()`, + `reset_active_audio_context()` + - Simplified Cartesia, ElevenLabs, Inworld, Rime, AsyncAI, and Gradium TTS + implementations by removing duplicate context management code + (PR [#3732](https://github.com/pipecat-ai/pipecat/pull/3732)) + +- `UserIdleController` is now always created with a default timeout of 0 + (disabled). The `user_idle_timeout` parameter changed from `Optional[float] = + None` to `float = 0` in `UserTurnProcessor`, `LLMUserAggregatorParams`, and + `UserIdleController`. + (PR [#3748](https://github.com/pipecat-ai/pipecat/pull/3748)) + +- Change the version specifier from `>=0.2.8` to `~=0.2.8` for the + `speechmatics-voice` package to ensure compatibility with future patch + versions. + (PR [#3761](https://github.com/pipecat-ai/pipecat/pull/3761)) + +- Updated `InworldTTSService` and `InworldHttpTTSService` to use `ASYNC` + timestamp transport strategy by default + (PR [#3765](https://github.com/pipecat-ai/pipecat/pull/3765)) + +- Added `start_time` and `end_time` parameters to `start_ttfb_metrics()`, + `stop_ttfb_metrics()`, `start_processing_metrics()`, and + `stop_processing_metrics()` in `FrameProcessor` and `FrameProcessorMetrics`, + allowing custom timestamps for metrics measurement. `STTService` now uses + these instead of custom TTFB tracking. + (PR [#3776](https://github.com/pipecat-ai/pipecat/pull/3776)) + +- Updated default Anthropic model from `claude-sonnet-4-5-20250929` to + `claude-sonnet-4-6`. + (PR [#3792](https://github.com/pipecat-ai/pipecat/pull/3792)) + +### Deprecated + +- Deprecated unused `Traceable`, `@traceable`, `@traced`, and + `AttachmentStrategy` in `pipecat.utils.tracing.class_decorators`. This module + will be removed in a future release. + (PR [#3733](https://github.com/pipecat-ai/pipecat/pull/3733)) + +### Fixed + +- Fixed race condition where `RTVIObserver` could send messages before + `DailyTransport` join completed. Outbound messages are now queued & delivered + after the transport is ready. + (PR [#3615](https://github.com/pipecat-ai/pipecat/pull/3615)) + +- Fixed async generator cleanup in OpenAI LLM streaming to prevent + `AttributeError` with uvloop on Python 3.12+ (MagicStack/uvloop#699). + (PR [#3698](https://github.com/pipecat-ai/pipecat/pull/3698)) + +- Fixed `SmallWebRTCTransport` input audio resampling to properly handle all + sample rates, including 8kHz audio. + (PR [#3713](https://github.com/pipecat-ai/pipecat/pull/3713)) + +- Fixed a race condition in `RTVIObserver` where bot output messages could be + sent before the bot-started-speaking event. + (PR [#3718](https://github.com/pipecat-ai/pipecat/pull/3718)) + +- Fixed Grok Realtime `session.updated` event parsing failure caused by the API + returning prefixed voice names (e.g. `"human_Ara"` instead of `"Ara"`). + (PR [#3720](https://github.com/pipecat-ai/pipecat/pull/3720)) + +- Fixed context ID reuse issue in `ElevenLabsTTSService`, `InworldTTSService`, + `RimeTTSService`, `CartesiaTTSService`, `AsyncAITTSService`, and + `PlayHTTTSService`. Services now properly reuse the same context ID across + multiple `run_tts()` invocations within a single LLM turn, preventing context + tracking issues and incorrect lifecycle signaling. + (PR [#3729](https://github.com/pipecat-ai/pipecat/pull/3729)) + +- Fixed word timestamp interleaving issue in `ElevenLabsTTSService` when + processing multiple sentences within a single LLM turn. + (PR [#3729](https://github.com/pipecat-ai/pipecat/pull/3729)) + +- Fixed tracing service decorators executing the wrapped function twice when + the function itself raised an exception (e.g., LLM rate limit, TTS timeout). + (PR [#3735](https://github.com/pipecat-ai/pipecat/pull/3735)) + +- Fixed `LLMUserAggregator` broadcasting mute events before `StartFrame` + reaches downstream processors. + (PR [#3737](https://github.com/pipecat-ai/pipecat/pull/3737)) + +- Fixed `UserIdleController` false idle triggers caused by gaps between user + and bot activity frames. The idle timer now starts only after + `BotStoppedSpeakingFrame` and is suppressed during active user turns and + function calls. + (PR [#3744](https://github.com/pipecat-ai/pipecat/pull/3744)) + +- Fixed incorrect `sample_rate` assignment in + `TavusInputTransport._on_participant_audio_data` (was using + `audio.audio_frames` instead of `audio.sample_rate`). + (PR [#3768](https://github.com/pipecat-ai/pipecat/pull/3768)) + +- Fixed `RTVIObserver` not processing upstream-only frames. Previously, all + upstream frames were filtered out to avoid duplicate messages from + broadcasted frames. Now only upstream copies of broadcasted frames are + skipped. + (PR [#3774](https://github.com/pipecat-ai/pipecat/pull/3774)) + +- Fixed mutable default arguments in `LLMContextAggregatorPair.__init__()` that + could cause shared state across instances. + (PR [#3782](https://github.com/pipecat-ai/pipecat/pull/3782)) + +- Fixed `DeepgramSageMakerSTTService` to properly track finalize lifecycle + using `request_finalize()` / `confirm_finalize()` and use `is_final` (instead + of `is_final and speech_final`) for final transcription detection, matching + `DeepgramSTTService` behavior. + (PR [#3784](https://github.com/pipecat-ai/pipecat/pull/3784)) + +- Fixed a race condition in `AudioContextTTSService` where the audio context + could time out between consecutive TTS requests within the same turn, causing + audio to be discarded. + (PR [#3787](https://github.com/pipecat-ai/pipecat/pull/3787)) + +- Fixed `push_interruption_task_frame_and_wait()` hanging indefinitely when the + `InterruptionFrame` does not reach the pipeline sink within the timeout. + Added a `timeout` keyword argument to customize the wait duration. + (PR [#3789](https://github.com/pipecat-ai/pipecat/pull/3789)) + ## [0.0.102] - 2026-02-10 ### Added diff --git a/changelog/3615.fixed.md b/changelog/3615.fixed.md deleted file mode 100644 index b14dfd70f..000000000 --- a/changelog/3615.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed race condition where `RTVIObserver` could send messages before `DailyTransport` join completed. Outbound messages are now queued & delivered after the transport is ready. diff --git a/changelog/3625.added.md b/changelog/3625.added.md deleted file mode 100644 index ddf787567..000000000 --- a/changelog/3625.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `"timestampTransportStrategy": "ASYNC"` to `InworldAITTSService`. This allows timestamps info to trail audio chunks arrival, resulting in much better first audio chunk latency diff --git a/changelog/3642.added.md b/changelog/3642.added.md deleted file mode 100644 index 47668bf59..000000000 --- a/changelog/3642.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added model-specific `InputParams` to `RimeTTSService`: arcana params (`repetition_penalty`, `temperature`, `top_p`) and mistv2 params (`no_text_normalization`, `save_oovs`, `segment`). Model, voice, and param changes now trigger WebSocket reconnection. diff --git a/changelog/3642.changed.md b/changelog/3642.changed.md deleted file mode 100644 index 96a43fbb8..000000000 --- a/changelog/3642.changed.md +++ /dev/null @@ -1 +0,0 @@ -- ⚠️ `RimeTTSService` now defaults to `model="arcana"` and the `wss://users-ws.rime.ai/ws3` endpoint. `InputParams` defaults changed from mistv2-specific values to `None` — only explicitly-set params are sent as query params. diff --git a/changelog/3684.changed.md b/changelog/3684.changed.md deleted file mode 100644 index 1bdb2c89c..000000000 --- a/changelog/3684.changed.md +++ /dev/null @@ -1,3 +0,0 @@ -- `AICFilter` now shares read-only AIC models via a singleton `AICModelManager` in `aic_filter.py`. - - Multiple filters using the same model path or `(model_id, model_download_dir)` share one loaded model, with reference counting and concurrent load deduplication. - - Model file I/O runs off the event loop so the filter does not block. diff --git a/changelog/3698.fixed.md b/changelog/3698.fixed.md deleted file mode 100644 index c040e9efb..000000000 --- a/changelog/3698.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed async generator cleanup in OpenAI LLM streaming to prevent `AttributeError` with uvloop on Python 3.12+ (MagicStack/uvloop#699). diff --git a/changelog/3706.changed.md b/changelog/3706.changed.md deleted file mode 100644 index 0c9876bdc..000000000 --- a/changelog/3706.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Added `X-User-Agent` and `X-Request-Id` headers to `InworldTTSService` for better traceability. diff --git a/changelog/3713.fixed.md b/changelog/3713.fixed.md deleted file mode 100644 index 241f0e56a..000000000 --- a/changelog/3713.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed `SmallWebRTCTransport` input audio resampling to properly handle all sample rates, including 8kHz audio. diff --git a/changelog/3718.fixed.md b/changelog/3718.fixed.md deleted file mode 100644 index 68e1d2682..000000000 --- a/changelog/3718.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed a race condition in `RTVIObserver` where bot output messages could be sent before the bot-started-speaking event. diff --git a/changelog/3719.added.2.md b/changelog/3719.added.2.md deleted file mode 100644 index 77d8956d7..000000000 --- a/changelog/3719.added.2.md +++ /dev/null @@ -1 +0,0 @@ -- Added `write_transport_frame()` hook to `BaseOutputTransport` allowing transport subclasses to handle custom frame types that flow through the audio queue. diff --git a/changelog/3719.added.md b/changelog/3719.added.md deleted file mode 100644 index bc1c2d6b1..000000000 --- a/changelog/3719.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `DailySIPTransferFrame` and `DailySIPReferFrame` to the Daily transport. These frames queue SIP transfer and SIP REFER operations with audio, so the operation executes only after the bot finishes its current utterance. diff --git a/changelog/3719.changed.md b/changelog/3719.changed.md deleted file mode 100644 index f42d0303b..000000000 --- a/changelog/3719.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `DailyUpdateRemoteParticipantsFrame` is no longer deprecated and is now queued with audio like other transport frames. diff --git a/changelog/3720.fixed.md b/changelog/3720.fixed.md deleted file mode 100644 index c3cb69d34..000000000 --- a/changelog/3720.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed Grok Realtime `session.updated` event parsing failure caused by the API returning prefixed voice names (e.g. `"human_Ara"` instead of `"Ara"`). diff --git a/changelog/3728.changed.md b/changelog/3728.changed.md deleted file mode 100644 index bc5ccc74d..000000000 --- a/changelog/3728.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Bumped Pillow dependency upper bound from `<12` to `<13` to allow Pillow 12.x. diff --git a/changelog/3729.fixed.2.md b/changelog/3729.fixed.2.md deleted file mode 100644 index 6d4f33d93..000000000 --- a/changelog/3729.fixed.2.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed context ID reuse issue in `ElevenLabsTTSService`, `InworldTTSService`, `RimeTTSService`, `CartesiaTTSService`, `AsyncAITTSService`, and `PlayHTTTSService`. Services now properly reuse the same context ID across multiple `run_tts()` invocations within a single LLM turn, preventing context tracking issues and incorrect lifecycle signaling. diff --git a/changelog/3729.fixed.md b/changelog/3729.fixed.md deleted file mode 100644 index b8be759fb..000000000 --- a/changelog/3729.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed word timestamp interleaving issue in `ElevenLabsTTSService` when processing multiple sentences within a single LLM turn. diff --git a/changelog/3730.added.md b/changelog/3730.added.md deleted file mode 100644 index e3ac64278..000000000 --- a/changelog/3730.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added keepalive support to `SarvamSTTService` to prevent idle connection timeouts (e.g. when used behind a `ServiceSwitcher`). diff --git a/changelog/3730.changed.md b/changelog/3730.changed.md deleted file mode 100644 index 697bc863c..000000000 --- a/changelog/3730.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Moved STT keepalive mechanism from `WebsocketSTTService` to the `STTService` base class, allowing any STT service (not just websocket-based ones) to use idle-connection keepalive via the `keepalive_timeout` and `keepalive_interval` parameters. diff --git a/changelog/3732.changed.md b/changelog/3732.changed.md deleted file mode 100644 index 22681cf04..000000000 --- a/changelog/3732.changed.md +++ /dev/null @@ -1,3 +0,0 @@ -- Improved audio context management in `AudioContextTTSService` by moving context ID tracking to the base class and adding `reuse_context_id_within_turn` parameter to control concurrent TTS request handling. - - Added helper methods: `has_active_audio_context()`, `get_active_audio_context_id()`, `remove_active_audio_context()`, `reset_active_audio_context()` - - Simplified Cartesia, ElevenLabs, Inworld, Rime, AsyncAI, and Gradium TTS implementations by removing duplicate context management code diff --git a/changelog/3733.deprecated.md b/changelog/3733.deprecated.md deleted file mode 100644 index 8b1fb29bb..000000000 --- a/changelog/3733.deprecated.md +++ /dev/null @@ -1 +0,0 @@ -- Deprecated unused `Traceable`, `@traceable`, `@traced`, and `AttachmentStrategy` in `pipecat.utils.tracing.class_decorators`. This module will be removed in a future release. diff --git a/changelog/3735.fixed.md b/changelog/3735.fixed.md deleted file mode 100644 index 02de936c7..000000000 --- a/changelog/3735.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed tracing service decorators executing the wrapped function twice when the function itself raised an exception (e.g., LLM rate limit, TTS timeout). diff --git a/changelog/3737.fixed.md b/changelog/3737.fixed.md deleted file mode 100644 index 6dee96f82..000000000 --- a/changelog/3737.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed `LLMUserAggregator` broadcasting mute events before `StartFrame` reaches downstream processors. diff --git a/changelog/3744.fixed.md b/changelog/3744.fixed.md deleted file mode 100644 index d2b3f665f..000000000 --- a/changelog/3744.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed `UserIdleController` false idle triggers caused by gaps between user and bot activity frames. The idle timer now starts only after `BotStoppedSpeakingFrame` and is suppressed during active user turns and function calls. diff --git a/changelog/3748.added.md b/changelog/3748.added.md deleted file mode 100644 index 223f8bf4b..000000000 --- a/changelog/3748.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `UserIdleTimeoutUpdateFrame` to enable or disable user idle detection at runtime by updating the timeout dynamically. diff --git a/changelog/3748.changed.md b/changelog/3748.changed.md deleted file mode 100644 index 61be61c6b..000000000 --- a/changelog/3748.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `UserIdleController` is now always created with a default timeout of 0 (disabled). The `user_idle_timeout` parameter changed from `Optional[float] = None` to `float = 0` in `UserTurnProcessor`, `LLMUserAggregatorParams`, and `UserIdleController`. diff --git a/changelog/3761.changed.md b/changelog/3761.changed.md deleted file mode 100644 index 71618502c..000000000 --- a/changelog/3761.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Change the version specifier from `>=0.2.8` to `~=0.2.8` for the `speechmatics-voice` package to ensure compatibility with future patch versions. diff --git a/changelog/3765.changed.md b/changelog/3765.changed.md deleted file mode 100644 index 5d3e758d5..000000000 --- a/changelog/3765.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Updated `InworldTTSService` and `InworldHttpTTSService` to use `ASYNC` timestamp transport strategy by default diff --git a/changelog/3768.fixed.md b/changelog/3768.fixed.md deleted file mode 100644 index 4c8d6438e..000000000 --- a/changelog/3768.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed incorrect `sample_rate` assignment in `TavusInputTransport._on_participant_audio_data` (was using `audio.audio_frames` instead of `audio.sample_rate`). diff --git a/changelog/3774.added.md b/changelog/3774.added.md deleted file mode 100644 index e72599e60..000000000 --- a/changelog/3774.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `broadcast_sibling_id` field to the base `Frame` class. This field is automatically set by `broadcast_frame()` and `broadcast_frame_instance()` to the ID of the paired frame pushed in the opposite direction, allowing receivers to identify broadcast pairs. diff --git a/changelog/3774.fixed.md b/changelog/3774.fixed.md deleted file mode 100644 index a839f56ed..000000000 --- a/changelog/3774.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed `RTVIObserver` not processing upstream-only frames. Previously, all upstream frames were filtered out to avoid duplicate messages from broadcasted frames. Now only upstream copies of broadcasted frames are skipped. diff --git a/changelog/3776.changed.md b/changelog/3776.changed.md deleted file mode 100644 index 87b5d6128..000000000 --- a/changelog/3776.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Added `start_time` and `end_time` parameters to `start_ttfb_metrics()`, `stop_ttfb_metrics()`, `start_processing_metrics()`, and `stop_processing_metrics()` in `FrameProcessor` and `FrameProcessorMetrics`, allowing custom timestamps for metrics measurement. `STTService` now uses these instead of custom TTFB tracking. diff --git a/changelog/3779.added.md b/changelog/3779.added.md deleted file mode 100644 index 8800cfc04..000000000 --- a/changelog/3779.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `ignored_sources` parameter to `RTVIObserverParams` and `add_ignored_source()`/`remove_ignored_source()` methods to `RTVIObserver` to suppress RTVI messages from specific pipeline processors (e.g. a silent evaluation LLM). diff --git a/changelog/3782.fixed.md b/changelog/3782.fixed.md deleted file mode 100644 index 7d21fdeab..000000000 --- a/changelog/3782.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed mutable default arguments in `LLMContextAggregatorPair.__init__()` that could cause shared state across instances. diff --git a/changelog/3784.fixed.md b/changelog/3784.fixed.md deleted file mode 100644 index e88431f16..000000000 --- a/changelog/3784.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed `DeepgramSageMakerSTTService` to properly track finalize lifecycle using `request_finalize()` / `confirm_finalize()` and use `is_final` (instead of `is_final and speech_final`) for final transcription detection, matching `DeepgramSTTService` behavior. diff --git a/changelog/3785.added.md b/changelog/3785.added.md deleted file mode 100644 index 90a4172d4..000000000 --- a/changelog/3785.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `DeepgramSageMakerTTSService` for running Deepgram TTS models deployed on AWS SageMaker endpoints via HTTP/2 bidirectional streaming. Supports the Deepgram TTS protocol (Speak, Flush, Clear, Close), interruption handling, and per-turn TTFB metrics. diff --git a/changelog/3787.fixed.md b/changelog/3787.fixed.md deleted file mode 100644 index ff11ada71..000000000 --- a/changelog/3787.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed a race condition in `AudioContextTTSService` where the audio context could time out between consecutive TTS requests within the same turn, causing audio to be discarded. diff --git a/changelog/3789.fixed.md b/changelog/3789.fixed.md deleted file mode 100644 index 1bf2be1a3..000000000 --- a/changelog/3789.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed `push_interruption_task_frame_and_wait()` hanging indefinitely when the `InterruptionFrame` does not reach the pipeline sink within the timeout. Added a `timeout` keyword argument to customize the wait duration. diff --git a/changelog/3792.changed.md b/changelog/3792.changed.md deleted file mode 100644 index ddf7fdc1e..000000000 --- a/changelog/3792.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Updated default Anthropic model from `claude-sonnet-4-5-20250929` to `claude-sonnet-4-6`.