diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index a9066762d..d26862766 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -59,6 +59,7 @@ jobs: DOCS_SYNC_TOKEN: ${{ secrets.DOCS_SYNC_TOKEN }} with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} prompt: | You are updating documentation for the pipecat-ai/docs repository based on changes merged in PR #${{ steps.pr.outputs.number }} of pipecat-ai/pipecat. diff --git a/CHANGELOG.md b/CHANGELOG.md index c917ec992..39dd6c194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,389 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## [0.0.104] - 2026-03-02 + +### Added + +- Added `TextAggregationMetricsData` metric measuring the time from the first + LLM token to the first complete sentence, representing the latency cost of + sentence aggregation in the TTS pipeline. + (PR [#3696](https://github.com/pipecat-ai/pipecat/pull/3696)) + +- Added support for using strongly-typed objects instead of dicts for updating + service settings at runtime. + + Instead of, say: + + ```python + await task.queue_frame( + STTUpdateSettingsFrame(settings={"language": Language.ES}) + ) + ``` + + you'd do: + + ```python + await task.queue_frame( + STTUpdateSettingsFrame(delta=DeepgramSTTSettings(language=Language.ES)) + ) + ``` + + Each service now vends strongly-typed classes like `DeepgramSTTSettings` + representing the service's runtime-updatable settings. + (PR [#3714](https://github.com/pipecat-ai/pipecat/pull/3714)) + +- Added support for specifying private endpoints for Azure Speech-to-Text, + enabling use in private networks behind firewalls. + (PR [#3764](https://github.com/pipecat-ai/pipecat/pull/3764)) + +- Added `LemonSliceTransport` and `LemonSliceApi` to support adding real-time + LemonSlice Avatars to any Daily room. + (PR [#3791](https://github.com/pipecat-ai/pipecat/pull/3791)) + +- Added `output_medium` parameter to `AgentInputParams` and + `OneShotInputParams` in Ultravox service to control initial output medium + (text or voice) at call creation time. + (PR [#3806](https://github.com/pipecat-ai/pipecat/pull/3806)) + +- Added `TurnMetricsData` as a generic metrics class for turn detection, with + e2e processing time measurement. `KrispVivaTurn` now emits `TurnMetricsData` + with `e2e_processing_time_ms` tracking the interval from VAD + speech-to-silence transition to turn completion. + (PR [#3809](https://github.com/pipecat-ai/pipecat/pull/3809)) + +- Added `on_audio_context_interrupted()` and `on_audio_context_completed()` + callbacks to `AudioContextTTSService`. Subclasses can override these to + perform provider-specific cleanup instead of overriding + `_handle_interruption()`. + (PR [#3814](https://github.com/pipecat-ai/pipecat/pull/3814)) + +- Added `on_summary_applied` event to `LLMContextSummarizer` for observability, + providing message counts before and after context summarization. + (PR [#3855](https://github.com/pipecat-ai/pipecat/pull/3855)) + +- Added `summary_message_template` to `LLMContextSummarizationConfig` for + customizing how summaries are formatted when injected into context (e.g., + wrapping in XML tags). + (PR [#3855](https://github.com/pipecat-ai/pipecat/pull/3855)) + +- Added `summarization_timeout` to `LLMContextSummarizationConfig` (default + 120s) to prevent hung LLM calls from permanently blocking future + summarizations. + (PR [#3855](https://github.com/pipecat-ai/pipecat/pull/3855)) + +- Added optional `llm` field to `LLMContextSummarizationConfig` for routing + summarization to a dedicated LLM service (e.g., a cheaper/faster model) + instead of the pipeline's primary model. + (PR [#3855](https://github.com/pipecat-ai/pipecat/pull/3855)) + +- Add AssemblyAI u3-rt-pro model support with built-in turn detection mode + (PR [#3856](https://github.com/pipecat-ai/pipecat/pull/3856)) + +- Added `LLMSummarizeContextFrame` to trigger on-demand context summarization + from anywhere in the pipeline (e.g. a function call tool). Accepts an + optional `config: LLMContextSummaryConfig` to override summary generation + settings per request. + (PR [#3863](https://github.com/pipecat-ai/pipecat/pull/3863)) + +- Added `LLMContextSummaryConfig` (summary generation params: + `target_context_tokens`, `min_messages_after_summary`, + `summarization_prompt`) and `LLMAutoContextSummarizationConfig` (auto-trigger + thresholds: `max_context_tokens`, `max_unsummarized_messages`, plus a nested + `summary_config`). These replace the monolithic + `LLMContextSummarizationConfig`. + (PR [#3863](https://github.com/pipecat-ai/pipecat/pull/3863)) + +- Added support for the `speed_alpha` parameter to the `arcana` model in + `RimeTTSService`. + (PR [#3873](https://github.com/pipecat-ai/pipecat/pull/3873)) + +- Added `ClientConnectedFrame`, a new `SystemFrame` pushed by all transports + (Daily, LiveKit, FastAPI WebSocket, WebSocket Server, SmallWebRTC, HeyGen, + Tavus) when a client connects. Enables observers to track transport readiness + timing. + (PR [#3881](https://github.com/pipecat-ai/pipecat/pull/3881)) + +- Added `StartupTimingObserver` for measuring how long each processor's + `start()` method takes during pipeline startup. Also measures transport + readiness — the time from `StartFrame` to first client connection — via the + `on_transport_timing_report` event. + (PR [#3881](https://github.com/pipecat-ai/pipecat/pull/3881)) + +- Added `BotConnectedFrame` for SFU transports and `on_transport_timing_report` + event to `StartupTimingObserver` with bot and client connection timing. + (PR [#3881](https://github.com/pipecat-ai/pipecat/pull/3881)) + +- Added optional `direction` parameter to `PipelineTask.queue_frame()` and + `PipelineTask.queue_frames()`, allowing frames to be pushed upstream from the + end of the pipeline. + (PR [#3883](https://github.com/pipecat-ai/pipecat/pull/3883)) + +- Added `on_latency_breakdown` event to `UserBotLatencyObserver` providing + per-service TTFB, text aggregation, user turn duration, and function call + latency metrics for each user-to-bot response cycle. + (PR [#3885](https://github.com/pipecat-ai/pipecat/pull/3885)) + +- Added `on_first_bot_speech_latency` event to `UserBotLatencyObserver` + measuring the time from client connection to first bot speech. An + `on_latency_breakdown` is also emitted for this first speech event. + (PR [#3885](https://github.com/pipecat-ai/pipecat/pull/3885)) + +- Added `broadcast_interruption()` to `FrameProcessor`. This method pushes an + `InterruptionFrame` both upstream and downstream directly from the calling + processor, avoiding the round-trip through the pipeline task that + `push_interruption_task_frame_and_wait()` required. + (PR [#3896](https://github.com/pipecat-ai/pipecat/pull/3896)) + +### Changed + +- Added `text_aggregation_mode` parameter to `TTSService` and all TTS + subclasses with a new `TextAggregationMode` enum (`SENTENCE`, `TOKEN`). All + text now flows through text aggregators regardless of mode, enabling pattern + detection and tag handling in TOKEN mode. + (PR [#3696](https://github.com/pipecat-ai/pipecat/pull/3696)) + +- ⚠️ Refactored runtime-updatable service settings to use strongly-typed + classes (`TTSSettings`, `STTSettings`, `LLMSettings`, and service-specific + subclasses) instead of plain dicts. Each service's `_settings` now holds + these strongly-typed objects. For service maintainers, see changes in + COMMUNITY_INTEGRATIONS.md. + (PR [#3714](https://github.com/pipecat-ai/pipecat/pull/3714)) + +- Word timestamp support has been moved from `WordTTSService` into `TTSService` + via a new `supports_word_timestamps` parameter. Services that previously + extended `WordTTSService`, `AudioContextWordTTSService`, or + `WebsocketWordTTSService` now pass `supports_word_timestamps=True` to their + parent `__init__` instead. + (PR [#3786](https://github.com/pipecat-ai/pipecat/pull/3786)) + +- Improved Ultravox TTFB measurement accuracy by using VAD speech end time + instead of `UserStoppedSpeakingFrame` timing. + (PR [#3806](https://github.com/pipecat-ai/pipecat/pull/3806)) + +- Aligned `UltravoxRealtimeLLMService` frame handling with OpenAI/Gemini + realtime services: added `InterruptionFrame` handling with metrics cleanup, + processing metrics at response boundaries, and improved agent transcript + handling for both voice and text output modalities. + (PR [#3806](https://github.com/pipecat-ai/pipecat/pull/3806)) + +- Updated `OpenAIRealtimeLLMService` default model to `gpt-realtime-1.5`. + (PR [#3807](https://github.com/pipecat-ai/pipecat/pull/3807)) + +- Added `api_key` parameter to `KrispVivaSDKManager`, `KrispVivaTurn`, and + `KrispVivaFilter` for Krisp SDK v1.6.1+ licensing. Falls back to + `KRISP_VIVA_API_KEY` environment variable. + (PR [#3809](https://github.com/pipecat-ai/pipecat/pull/3809)) + +- Bumped `nltk` minimum version from 3.9.1 to 3.9.3 to resolve a security + vulnerability. + (PR [#3811](https://github.com/pipecat-ai/pipecat/pull/3811)) + +- `ServiceSettingsUpdateFrame`s are now `UninterruptibleFrame`s. Generally + speaking, you don't want a user interruption to prevent a service setting + change from going into effect. Note that you usually don't use + `ServiceSettingsUpdateFrame` directly, you use one of its subclasses: + - `LLMUpdateSettingsFrame` + - `TTSUpdateSettingsFrame` + - `STTUpdateSettingsFrame` + (PR [#3819](https://github.com/pipecat-ai/pipecat/pull/3819)) + +- Updated context summarization to use `user` role instead of `assistant` for + summary messages. + (PR [#3855](https://github.com/pipecat-ai/pipecat/pull/3855)) + +- Rename `AssemblyAISTTService` parameter + `min_end_of_turn_silence_when_confident` parameter to `min_turn_silence` (old + name still supported with deprecation warning) + (PR [#3856](https://github.com/pipecat-ai/pipecat/pull/3856)) + +- ⚠️ Renamed `LLMAssistantAggregatorParams` fields: + `enable_context_summarization` → `enable_auto_context_summarization` and + `context_summarization_config` → `auto_context_summarization_config` (now + accepts `LLMAutoContextSummarizationConfig`). The old names still work with a + `DeprecationWarning` for one release cycle. + (PR [#3863](https://github.com/pipecat-ai/pipecat/pull/3863)) + +- `ElevenLabsRealtimeSTTService` now sets `TranscriptionFrame.finalized` to + `True` when using `CommitStrategy.MANUAL`. + (PR [#3865](https://github.com/pipecat-ai/pipecat/pull/3865)) + +- Updated numba version pin from == to >=0.61.2 + (PR [#3868](https://github.com/pipecat-ai/pipecat/pull/3868)) + +- Updated tracing code to use `ServiceSettings` dataclass API + (`given_fields()`, attribute access) instead of dict-style access + (`.items()`, `in`, subscript). + (PR [#3879](https://github.com/pipecat-ai/pipecat/pull/3879)) + +- ⚠️ Removed `event` field and `complete()` method from `InterruptionFrame`. + Removed `event` field from `InterruptionTaskFrame`. These are no longer + needed since `broadcast_interruption()` does not require a round-trip + completion signal. + (PR [#3896](https://github.com/pipecat-ai/pipecat/pull/3896)) + +- Moved `pipecat.services.deepgram.stt_sagemaker` and + `pipecat.services.deepgram.tts_sagemaker` to + `pipecat.services.deepgram.sagemaker.stt` and + `pipecat.services.deepgram.sagemaker.tts`. The old import paths still work + but emit a `DeprecationWarning`. + (PR [#3902](https://github.com/pipecat-ai/pipecat/pull/3902)) + +### Deprecated + +- ⚠️ Deprecated `aggregate_sentences` parameter on `TTSService` and all TTS + subclasses. Use `text_aggregation_mode=TextAggregationMode.SENTENCE` or + `text_aggregation_mode=TextAggregationMode.TOKEN` instead. + (PR [#3696](https://github.com/pipecat-ai/pipecat/pull/3696)) + +- Deprecated `set_model()`, `set_voice()`, and `set_language()` on AI services + in favor of runtime updates via `TTSUpdateSettingsFrame`, + `STTUpdateSettingsFrame`, and `LLMUpdateSettingsFrame`. + + ⚠️ Note, too, a subtle behavior change in these deprecated methods. Whereas + previously only `set_language()` caused the service to actually react to the + update (e.g. by reconnecting to a remote service so it an pick up the + change), now all these methods do. This change was made as part of a refactor + making them all work the same way under the hood. + (PR [#3714](https://github.com/pipecat-ai/pipecat/pull/3714)) + +- Dict-based `*UpdateSettingsFrame(settings={...})` is deprecated in favor of + passing typed settings delta objects with + `*UpdateSettingsFrame(delta={...})`. + (PR [#3714](https://github.com/pipecat-ai/pipecat/pull/3714)) + +- Deprecated `WordTTSService`, `WebsocketWordTTSService`, + `AudioContextWordTTSService`, and `InterruptibleWordTTSService`. Use their + non-word counterparts with `supports_word_timestamps=True` instead: + - `WordTTSService` → `TTSService(supports_word_timestamps=True)` + - `WebsocketWordTTSService` → + `WebsocketTTSService(supports_word_timestamps=True)` + - `AudioContextWordTTSService` → + `AudioContextTTSService(supports_word_timestamps=True)` + - `InterruptibleWordTTSService` → + `InterruptibleTTSService(supports_word_timestamps=True)` + (PR [#3786](https://github.com/pipecat-ai/pipecat/pull/3786)) + +- Deprecated `SmartTurnMetricsData` in favor of `TurnMetricsData`. + `BaseSmartTurn` now emits `TurnMetricsData` directly. + (PR [#3809](https://github.com/pipecat-ai/pipecat/pull/3809)) + +- Deprecated `LLMContextSummarizationConfig`. Use + `LLMAutoContextSummarizationConfig` with a nested `LLMContextSummaryConfig` + instead. The old class emits a `DeprecationWarning`. + (PR [#3863](https://github.com/pipecat-ai/pipecat/pull/3863)) + +- Deprecated `push_interruption_task_frame_and_wait()` in `FrameProcessor`. Use + `broadcast_interruption()` instead. The old method now delegates to + `broadcast_interruption()` and logs a deprecation warning. + (PR [#3896](https://github.com/pipecat-ai/pipecat/pull/3896)) + +### Removed + +- Removed `local-smart-turn-v3` optional extra from `pyproject.toml`. The + `transformers` and `onnxruntime` packages are now always installed as core + dependencies since they are required by the default turn stop strategy, + `TurnAnalyzerUserTurnStopStrategy` which uses `LocalSmartTurnAnalyzerV3`. + (PR [#3803](https://github.com/pipecat-ai/pipecat/pull/3803)) + +- ⚠️ Removed `PlayHTTTSService` and `PlayHTHttpTTSService`. PlayHT has been + shut down and is no longer available. + (PR [#3838](https://github.com/pipecat-ai/pipecat/pull/3838)) + +### Fixed + +- Added `LLMSpecificMessage` handling in `LLMContextSummarizationUtil` to skip + provider-specific messages during context summarization. + (PR [#3794](https://github.com/pipecat-ai/pipecat/pull/3794)) + +- Treated `response_cancel_not_active` as a non-fatal error in realtime + services (`OpenAIRealtimeLLMService`, `GrokRealtimeLLMService`, + `OpenAIRealtimeBetaLLMService`) to prevent WebSocket disconnection when + cancelling an inactive response. + (PR [#3795](https://github.com/pipecat-ai/pipecat/pull/3795)) + +- Fixed Poetry compatibility by inlining `local-smart-turn-v3` dependencies + (`transformers`, `onnxruntime`) into core dependencies instead of using a + self-referential extra. + (PR [#3803](https://github.com/pipecat-ai/pipecat/pull/3803)) + +- Fixed `SentryMetrics` method signatures to match updated + `FrameProcessorMetrics` base class, resolving `TypeError` when using + `start_time`/`end_time` keyword arguments. + (PR [#3808](https://github.com/pipecat-ai/pipecat/pull/3808)) + +- Fixed STT TTFB metrics not being reported for `SonioxSTTService` and + `AWSTranscribeSTTService` due to missing `can_generate_metrics()` override. + (PR [#3813](https://github.com/pipecat-ai/pipecat/pull/3813)) + +- Fixed an issue where `AudioContextTTSService`-based providers (AsyncAI, + ElevenLabs, Inworld, Rime) did not close or clean up their server-side audio + contexts after normal speech completion, only on interruption. + (PR [#3814](https://github.com/pipecat-ai/pipecat/pull/3814)) + +- Fixed STT TTFB metrics measuring timeout expiry time instead of actual + transcript arrival time. + (PR [#3822](https://github.com/pipecat-ai/pipecat/pull/3822)) + +- Fixed `InterimTranscriptionFrame` and `TranslationFrame` being + unintentionally pushed downstream in `LLMUserAggregator`. They are now + consumed like `TranscriptionFrame`. + (PR [#3825](https://github.com/pipecat-ai/pipecat/pull/3825)) + +- Fixed misleading "Empty audio frame received for STT service" warnings when + using audio filters (e.g. `RNNoiseFilter`, `KrispVivaFilter`, `AICFilter`) + that buffer audio internally. + (PR [#3828](https://github.com/pipecat-ai/pipecat/pull/3828)) + +- Fixed issues with `RimeNonJsonTTSService` where trailing punctuation is + sometimes vocalized + (PR [#3837](https://github.com/pipecat-ai/pipecat/pull/3837)) + +- Fixed `TTSSpeakFrame` not committing spoken text to the conversation context + when used outside of an LLM response (e.g., bot greetings or injected + speech). + (PR [#3845](https://github.com/pipecat-ai/pipecat/pull/3845)) + +- Removed verbose per-chunk audio logging from `GenesysAudioHookSerializer` + that flooded production logs. + (PR [#3850](https://github.com/pipecat-ai/pipecat/pull/3850)) + +- Add beta feature warning when using custom prompts with AssemblyAI + (PR [#3856](https://github.com/pipecat-ai/pipecat/pull/3856)) + +- Fixed `LocalSmartTurnAnalyzerV3` producing incorrect end-of-turn predictions + at non-16kHz sample rates (e.g. 8kHz Twilio telephony) by adding automatic + resampling to 16kHz before Whisper feature extraction. + (PR [#3857](https://github.com/pipecat-ai/pipecat/pull/3857)) + +- Fixed `PipelineTask` double-inserting `RTVIProcessor` into the frame chain + when the user provides both an `RTVIProcessor` in the pipeline and a custom + `RTVIObserver` subclass in observers. + (PR [#3867](https://github.com/pipecat-ai/pipecat/pull/3867)) + +- Fixed turn completion instructions being lost when `LLMMessagesUpdateFrame` + replaces the LLM context. When `filter_incomplete_user_turns` is enabled, the + turn completion system message is now re-injected after context replacement. + (PR [#3888](https://github.com/pipecat-ai/pipecat/pull/3888)) + +- Fixed Azure TTS and STT services silently swallowing cancellation errors + (invalid API key, network failures, rate limiting) instead of propagating + them as `ErrorFrame`s to the pipeline. + (PR [#3893](https://github.com/pipecat-ai/pipecat/pull/3893)) + +### Performance + +- Switched `GradiumTTSService` from `InterruptibleWordTTSService` to + `AudioContextWordTTSService`, eliminating websocket disconnect/reconnect on + every interruption by using `client_req_id`-based multiplexing. + (PR [#3759](https://github.com/pipecat-ai/pipecat/pull/3759)) + +### Other + +- Standardized Sarvam STT/TTS User-Agent header handling to consistently send + Pipecat SDK identity in websocket requests. + (PR [#3886](https://github.com/pipecat-ai/pipecat/pull/3886)) + ## [0.0.103] - 2026-02-20 ### Added diff --git a/README.md b/README.md index 05874be81..881b0ed5e 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Catch new features, interviews, and how-tos on our [Pipecat TV](https://www.yout | Speech-to-Speech | [AWS Nova Sonic](https://docs.pipecat.ai/server/services/s2s/aws), [Gemini Multimodal Live](https://docs.pipecat.ai/server/services/s2s/gemini), [Grok Voice Agent](https://docs.pipecat.ai/server/services/s2s/grok), [OpenAI Realtime](https://docs.pipecat.ai/server/services/s2s/openai), [Ultravox](https://docs.pipecat.ai/server/services/s2s/ultravox), | | Transport | [Daily (WebRTC)](https://docs.pipecat.ai/server/services/transport/daily), [FastAPI Websocket](https://docs.pipecat.ai/server/services/transport/fastapi-websocket), [SmallWebRTCTransport](https://docs.pipecat.ai/server/services/transport/small-webrtc), [WebSocket Server](https://docs.pipecat.ai/server/services/transport/websocket-server), Local | | Serializers | [Exotel](https://docs.pipecat.ai/server/utilities/serializers/exotel), [Plivo](https://docs.pipecat.ai/server/utilities/serializers/plivo), [Twilio](https://docs.pipecat.ai/server/utilities/serializers/twilio), [Telnyx](https://docs.pipecat.ai/server/utilities/serializers/telnyx), [Vonage](https://docs.pipecat.ai/server/utilities/serializers/vonage) | -| Video | [HeyGen](https://docs.pipecat.ai/server/services/video/heygen), [Tavus](https://docs.pipecat.ai/server/services/video/tavus), [Simli](https://docs.pipecat.ai/server/services/video/simli) | +| Video | [HeyGen](https://docs.pipecat.ai/server/services/video/heygen), [LemonSlice](https://docs.pipecat.ai/server/services/video/lemonslice), [Tavus](https://docs.pipecat.ai/server/services/video/tavus), [Simli](https://docs.pipecat.ai/server/services/video/simli) | | Memory | [mem0](https://docs.pipecat.ai/server/services/memory/mem0) | | Vision & Image | [fal](https://docs.pipecat.ai/server/services/image-generation/fal), [Google Imagen](https://docs.pipecat.ai/server/services/image-generation/google-imagen), [Moondream](https://docs.pipecat.ai/server/services/vision/moondream) | | Audio Processing | [Silero VAD](https://docs.pipecat.ai/server/utilities/audio/silero-vad-analyzer), [Krisp](https://docs.pipecat.ai/server/utilities/audio/krisp-filter), [Koala](https://docs.pipecat.ai/server/utilities/audio/koala-filter), [ai-coustics](https://docs.pipecat.ai/server/utilities/audio/aic-filter) | diff --git a/changelog/3696.added.md b/changelog/3696.added.md deleted file mode 100644 index 39726d930..000000000 --- a/changelog/3696.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `TextAggregationMetricsData` metric measuring the time from the first LLM token to the first complete sentence, representing the latency cost of sentence aggregation in the TTS pipeline. diff --git a/changelog/3696.changed.md b/changelog/3696.changed.md deleted file mode 100644 index a495560ba..000000000 --- a/changelog/3696.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Added `text_aggregation_mode` parameter to `TTSService` and all TTS subclasses with a new `TextAggregationMode` enum (`SENTENCE`, `TOKEN`). All text now flows through text aggregators regardless of mode, enabling pattern detection and tag handling in TOKEN mode. diff --git a/changelog/3696.deprecated.md b/changelog/3696.deprecated.md deleted file mode 100644 index 7b371fc21..000000000 --- a/changelog/3696.deprecated.md +++ /dev/null @@ -1 +0,0 @@ -- ⚠️ Deprecated `aggregate_sentences` parameter on `TTSService` and all TTS subclasses. Use `text_aggregation_mode=TextAggregationMode.SENTENCE` or `text_aggregation_mode=TextAggregationMode.TOKEN` instead. diff --git a/changelog/3714.added.md b/changelog/3714.added.md deleted file mode 100644 index efa54b7d5..000000000 --- a/changelog/3714.added.md +++ /dev/null @@ -1,19 +0,0 @@ -- Added support for using strongly-typed objects instead of dicts for updating service settings at runtime. - - Instead of, say: - - ```python - await task.queue_frame( - STTUpdateSettingsFrame(settings={"language": Language.ES}) - ) - ``` - - you'd do: - - ```python - await task.queue_frame( - STTUpdateSettingsFrame(delta=DeepgramSTTSettings(language=Language.ES)) - ) - ``` - - Each service now vends strongly-typed classes like `DeepgramSTTSettings` representing the service's runtime-updatable settings. diff --git a/changelog/3714.changed.md b/changelog/3714.changed.md deleted file mode 100644 index bcfb5cbf7..000000000 --- a/changelog/3714.changed.md +++ /dev/null @@ -1 +0,0 @@ -- ⚠️ Refactored runtime-updatable service settings to use strongly-typed classes (`TTSSettings`, `STTSettings`, `LLMSettings`, and service-specific subclasses) instead of plain dicts. Each service's `_settings` now holds these strongly-typed objects. For service maintainers, see changes in COMMUNITY_INTEGRATIONS.md. diff --git a/changelog/3714.deprecated.2.md b/changelog/3714.deprecated.2.md deleted file mode 100644 index d386fa5a4..000000000 --- a/changelog/3714.deprecated.2.md +++ /dev/null @@ -1 +0,0 @@ -- Dict-based `*UpdateSettingsFrame(settings={...})` is deprecated in favor of passing typed settings delta objects with `*UpdateSettingsFrame(delta={...})`. diff --git a/changelog/3714.deprecated.md b/changelog/3714.deprecated.md deleted file mode 100644 index 75337a642..000000000 --- a/changelog/3714.deprecated.md +++ /dev/null @@ -1,3 +0,0 @@ -- Deprecated `set_model()`, `set_voice()`, and `set_language()` on AI services in favor of runtime updates via `TTSUpdateSettingsFrame`, `STTUpdateSettingsFrame`, and `LLMUpdateSettingsFrame`. - - ⚠️ Note, too, a subtle behavior change in these deprecated methods. Whereas previously only `set_language()` caused the service to actually react to the update (e.g. by reconnecting to a remote service so it an pick up the change), now all these methods do. This change was made as part of a refactor making them all work the same way under the hood. diff --git a/changelog/3759.performance.md b/changelog/3759.performance.md deleted file mode 100644 index 1bdc17a17..000000000 --- a/changelog/3759.performance.md +++ /dev/null @@ -1 +0,0 @@ -- Switched `GradiumTTSService` from `InterruptibleWordTTSService` to `AudioContextWordTTSService`, eliminating websocket disconnect/reconnect on every interruption by using `client_req_id`-based multiplexing. diff --git a/changelog/3764.added.md b/changelog/3764.added.md deleted file mode 100644 index 5da82f0c1..000000000 --- a/changelog/3764.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added support for specifying private endpoints for Azure Speech-to-Text, enabling use in private networks behind firewalls. \ No newline at end of file diff --git a/changelog/3786.changed.md b/changelog/3786.changed.md deleted file mode 100644 index ed8e7e444..000000000 --- a/changelog/3786.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Word timestamp support has been moved from `WordTTSService` into `TTSService` via a new `supports_word_timestamps` parameter. Services that previously extended `WordTTSService`, `AudioContextWordTTSService`, or `WebsocketWordTTSService` now pass `supports_word_timestamps=True` to their parent `__init__` instead. diff --git a/changelog/3786.deprecated.md b/changelog/3786.deprecated.md deleted file mode 100644 index 7ac5a5b9c..000000000 --- a/changelog/3786.deprecated.md +++ /dev/null @@ -1,5 +0,0 @@ -- Deprecated `WordTTSService`, `WebsocketWordTTSService`, `AudioContextWordTTSService`, and `InterruptibleWordTTSService`. Use their non-word counterparts with `supports_word_timestamps=True` instead: - - `WordTTSService` → `TTSService(supports_word_timestamps=True)` - - `WebsocketWordTTSService` → `WebsocketTTSService(supports_word_timestamps=True)` - - `AudioContextWordTTSService` → `AudioContextTTSService(supports_word_timestamps=True)` - - `InterruptibleWordTTSService` → `InterruptibleTTSService(supports_word_timestamps=True)` diff --git a/changelog/3794.fixed.md b/changelog/3794.fixed.md deleted file mode 100644 index e2b3c7c00..000000000 --- a/changelog/3794.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Added `LLMSpecificMessage` handling in `LLMContextSummarizationUtil` to skip provider-specific messages during context summarization. diff --git a/changelog/3795.fixed.md b/changelog/3795.fixed.md deleted file mode 100644 index 8c231abac..000000000 --- a/changelog/3795.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Treated `response_cancel_not_active` as a non-fatal error in realtime services (`OpenAIRealtimeLLMService`, `GrokRealtimeLLMService`, `OpenAIRealtimeBetaLLMService`) to prevent WebSocket disconnection when cancelling an inactive response. \ No newline at end of file diff --git a/changelog/3803.fixed.md b/changelog/3803.fixed.md deleted file mode 100644 index 73d7c3f19..000000000 --- a/changelog/3803.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed Poetry compatibility by inlining `local-smart-turn-v3` dependencies (`transformers`, `onnxruntime`) into core dependencies instead of using a self-referential extra. diff --git a/changelog/3803.removed.md b/changelog/3803.removed.md deleted file mode 100644 index 867c3cfcc..000000000 --- a/changelog/3803.removed.md +++ /dev/null @@ -1 +0,0 @@ -- Removed `local-smart-turn-v3` optional extra from `pyproject.toml`. The `transformers` and `onnxruntime` packages are now always installed as core dependencies since they are required by the default turn stop strategy, `TurnAnalyzerUserTurnStopStrategy` which uses `LocalSmartTurnAnalyzerV3`. diff --git a/changelog/3806.added.md b/changelog/3806.added.md deleted file mode 100644 index eeddc9825..000000000 --- a/changelog/3806.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `output_medium` parameter to `AgentInputParams` and `OneShotInputParams` in Ultravox service to control initial output medium (text or voice) at call creation time. diff --git a/changelog/3806.changed.2.md b/changelog/3806.changed.2.md deleted file mode 100644 index 9d6dfdf76..000000000 --- a/changelog/3806.changed.2.md +++ /dev/null @@ -1 +0,0 @@ -- Improved Ultravox TTFB measurement accuracy by using VAD speech end time instead of `UserStoppedSpeakingFrame` timing. diff --git a/changelog/3806.changed.md b/changelog/3806.changed.md deleted file mode 100644 index c8e2fb68c..000000000 --- a/changelog/3806.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Aligned `UltravoxRealtimeLLMService` frame handling with OpenAI/Gemini realtime services: added `InterruptionFrame` handling with metrics cleanup, processing metrics at response boundaries, and improved agent transcript handling for both voice and text output modalities. diff --git a/changelog/3807.changed.md b/changelog/3807.changed.md deleted file mode 100644 index cc99f29fb..000000000 --- a/changelog/3807.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Updated `OpenAIRealtimeLLMService` default model to `gpt-realtime-1.5`. \ No newline at end of file diff --git a/changelog/3808.fixed.md b/changelog/3808.fixed.md deleted file mode 100644 index 6bf105bf6..000000000 --- a/changelog/3808.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed `SentryMetrics` method signatures to match updated `FrameProcessorMetrics` base class, resolving `TypeError` when using `start_time`/`end_time` keyword arguments. diff --git a/changelog/3809.added.md b/changelog/3809.added.md deleted file mode 100644 index 99047dc76..000000000 --- a/changelog/3809.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `TurnMetricsData` as a generic metrics class for turn detection, with e2e processing time measurement. `KrispVivaTurn` now emits `TurnMetricsData` with `e2e_processing_time_ms` tracking the interval from VAD speech-to-silence transition to turn completion. diff --git a/changelog/3809.changed.md b/changelog/3809.changed.md deleted file mode 100644 index 479eaf6ed..000000000 --- a/changelog/3809.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Added `api_key` parameter to `KrispVivaSDKManager`, `KrispVivaTurn`, and `KrispVivaFilter` for Krisp SDK v1.6.1+ licensing. Falls back to `KRISP_VIVA_API_KEY` environment variable. diff --git a/changelog/3809.deprecated.md b/changelog/3809.deprecated.md deleted file mode 100644 index f1498ec0b..000000000 --- a/changelog/3809.deprecated.md +++ /dev/null @@ -1 +0,0 @@ -- Deprecated `SmartTurnMetricsData` in favor of `TurnMetricsData`. `BaseSmartTurn` now emits `TurnMetricsData` directly. diff --git a/changelog/3811.changed.md b/changelog/3811.changed.md deleted file mode 100644 index eb3eb492e..000000000 --- a/changelog/3811.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Bumped `nltk` minimum version from 3.9.1 to 3.9.3 to resolve a security vulnerability. diff --git a/changelog/3813.fixed.md b/changelog/3813.fixed.md deleted file mode 100644 index 9d9115e77..000000000 --- a/changelog/3813.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed STT TTFB metrics not being reported for `SonioxSTTService` and `AWSTranscribeSTTService` due to missing `can_generate_metrics()` override. diff --git a/changelog/3814.added.md b/changelog/3814.added.md deleted file mode 100644 index b6b2ebbf8..000000000 --- a/changelog/3814.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `on_audio_context_interrupted()` and `on_audio_context_completed()` callbacks to `AudioContextTTSService`. Subclasses can override these to perform provider-specific cleanup instead of overriding `_handle_interruption()`. diff --git a/changelog/3814.fixed.md b/changelog/3814.fixed.md deleted file mode 100644 index ecd4871f6..000000000 --- a/changelog/3814.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed an issue where `AudioContextTTSService`-based providers (AsyncAI, ElevenLabs, Inworld, Rime) did not close or clean up their server-side audio contexts after normal speech completion, only on interruption. diff --git a/changelog/3819.changed.md b/changelog/3819.changed.md deleted file mode 100644 index 7b43c399c..000000000 --- a/changelog/3819.changed.md +++ /dev/null @@ -1,4 +0,0 @@ -- `ServiceSettingsUpdateFrame`s are now `UninterruptibleFrame`s. Generally speaking, you don't want a user interruption to prevent a service setting change from going into effect. Note that you usually don't use `ServiceSettingsUpdateFrame` directly, you use one of its subclasses: - - `LLMUpdateSettingsFrame` - - `TTSUpdateSettingsFrame` - - `STTUpdateSettingsFrame` diff --git a/changelog/3822.fixed.md b/changelog/3822.fixed.md deleted file mode 100644 index 48218845f..000000000 --- a/changelog/3822.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed STT TTFB metrics measuring timeout expiry time instead of actual transcript arrival time. \ No newline at end of file diff --git a/changelog/3825.fixed.md b/changelog/3825.fixed.md deleted file mode 100644 index 7cd9ba508..000000000 --- a/changelog/3825.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed `InterimTranscriptionFrame` and `TranslationFrame` being unintentionally pushed downstream in `LLMUserAggregator`. They are now consumed like `TranscriptionFrame`. diff --git a/changelog/3828.fixed.md b/changelog/3828.fixed.md deleted file mode 100644 index dd2ee257d..000000000 --- a/changelog/3828.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed misleading "Empty audio frame received for STT service" warnings when using audio filters (e.g. `RNNoiseFilter`, `KrispVivaFilter`, `AICFilter`) that buffer audio internally. diff --git a/changelog/3837.fixed.md b/changelog/3837.fixed.md deleted file mode 100644 index 767e79f45..000000000 --- a/changelog/3837.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed issues with `RimeNonJsonTTSService` where trailing punctuation is sometimes vocalized diff --git a/changelog/3838.removed.md b/changelog/3838.removed.md deleted file mode 100644 index fa811cb71..000000000 --- a/changelog/3838.removed.md +++ /dev/null @@ -1 +0,0 @@ -- ⚠️ Removed `PlayHTTTSService` and `PlayHTHttpTTSService`. PlayHT has been shut down and is no longer available. diff --git a/changelog/3845.fixed.md b/changelog/3845.fixed.md deleted file mode 100644 index 423853700..000000000 --- a/changelog/3845.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed `TTSSpeakFrame` not committing spoken text to the conversation context when used outside of an LLM response (e.g., bot greetings or injected speech). \ No newline at end of file diff --git a/changelog/3850.fixed.md b/changelog/3850.fixed.md deleted file mode 100644 index cfbdc6cf7..000000000 --- a/changelog/3850.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Removed verbose per-chunk audio logging from `GenesysAudioHookSerializer` that flooded production logs. diff --git a/changelog/3852.deprecated.md b/changelog/3852.deprecated.md deleted file mode 100644 index 666c7c58a..000000000 --- a/changelog/3852.deprecated.md +++ /dev/null @@ -1 +0,0 @@ -- Deprecated `ProcessingMetricsData` and `start_processing_metrics()`/`stop_processing_metrics()` on `FrameProcessor` and `FrameProcessorMetrics`. These metrics don't accurately depict a service's performance. Instead, TTFB metrics are recommended. Processing metrics will be removed in the 1.0.0 version. diff --git a/changelog/3855.added.2.md b/changelog/3855.added.2.md deleted file mode 100644 index 01cd23efe..000000000 --- a/changelog/3855.added.2.md +++ /dev/null @@ -1 +0,0 @@ -- Added optional `llm` field to `LLMContextSummarizationConfig` for routing summarization to a dedicated LLM service (e.g., a cheaper/faster model) instead of the pipeline's primary model. diff --git a/changelog/3855.added.3.md b/changelog/3855.added.3.md deleted file mode 100644 index b93fdec60..000000000 --- a/changelog/3855.added.3.md +++ /dev/null @@ -1 +0,0 @@ -- Added `summarization_timeout` to `LLMContextSummarizationConfig` (default 120s) to prevent hung LLM calls from permanently blocking future summarizations. diff --git a/changelog/3855.added.4.md b/changelog/3855.added.4.md deleted file mode 100644 index b712b4ac9..000000000 --- a/changelog/3855.added.4.md +++ /dev/null @@ -1 +0,0 @@ -- Added `on_summary_applied` event to `LLMContextSummarizer` for observability, providing message counts before and after context summarization. diff --git a/changelog/3855.added.md b/changelog/3855.added.md deleted file mode 100644 index 79d37eeba..000000000 --- a/changelog/3855.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `summary_message_template` to `LLMContextSummarizationConfig` for customizing how summaries are formatted when injected into context (e.g., wrapping in XML tags). diff --git a/changelog/3855.changed.md b/changelog/3855.changed.md deleted file mode 100644 index 2eac6785a..000000000 --- a/changelog/3855.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Updated context summarization to use `user` role instead of `assistant` for summary messages. diff --git a/changelog/3857.fixed.md b/changelog/3857.fixed.md deleted file mode 100644 index 869c54111..000000000 --- a/changelog/3857.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed `LocalSmartTurnAnalyzerV3` producing incorrect end-of-turn predictions at non-16kHz sample rates (e.g. 8kHz Twilio telephony) by adding automatic resampling to 16kHz before Whisper feature extraction. diff --git a/changelog/3863.added.2.md b/changelog/3863.added.2.md deleted file mode 100644 index 9c0ab90ba..000000000 --- a/changelog/3863.added.2.md +++ /dev/null @@ -1 +0,0 @@ -- Added `LLMContextSummaryConfig` (summary generation params: `target_context_tokens`, `min_messages_after_summary`, `summarization_prompt`) and `LLMAutoContextSummarizationConfig` (auto-trigger thresholds: `max_context_tokens`, `max_unsummarized_messages`, plus a nested `summary_config`). These replace the monolithic `LLMContextSummarizationConfig`. diff --git a/changelog/3863.added.md b/changelog/3863.added.md deleted file mode 100644 index d6214aed0..000000000 --- a/changelog/3863.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `LLMSummarizeContextFrame` to trigger on-demand context summarization from anywhere in the pipeline (e.g. a function call tool). Accepts an optional `config: LLMContextSummaryConfig` to override summary generation settings per request. diff --git a/changelog/3863.changed.md b/changelog/3863.changed.md deleted file mode 100644 index faf5712d8..000000000 --- a/changelog/3863.changed.md +++ /dev/null @@ -1 +0,0 @@ -- ⚠️ Renamed `LLMAssistantAggregatorParams` fields: `enable_context_summarization` → `enable_auto_context_summarization` and `context_summarization_config` → `auto_context_summarization_config` (now accepts `LLMAutoContextSummarizationConfig`). The old names still work with a `DeprecationWarning` for one release cycle. diff --git a/changelog/3863.deprecated.md b/changelog/3863.deprecated.md deleted file mode 100644 index ba2311fbd..000000000 --- a/changelog/3863.deprecated.md +++ /dev/null @@ -1 +0,0 @@ -- Deprecated `LLMContextSummarizationConfig`. Use `LLMAutoContextSummarizationConfig` with a nested `LLMContextSummaryConfig` instead. The old class emits a `DeprecationWarning`. diff --git a/changelog/3865.changed.md b/changelog/3865.changed.md deleted file mode 100644 index 7a70eb0d7..000000000 --- a/changelog/3865.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `ElevenLabsRealtimeSTTService` now sets `TranscriptionFrame.finalized` to `True` when using `CommitStrategy.MANUAL`. diff --git a/changelog/3867.fixed.md b/changelog/3867.fixed.md deleted file mode 100644 index 41ee584a2..000000000 --- a/changelog/3867.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed `PipelineTask` double-inserting `RTVIProcessor` into the frame chain when the user provides both an `RTVIProcessor` in the pipeline and a custom `RTVIObserver` subclass in observers. diff --git a/changelog/3868.changed.md b/changelog/3868.changed.md deleted file mode 100644 index 4f019cca2..000000000 --- a/changelog/3868.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Updated numba version pin from == to >=0.61.2 diff --git a/changelog/3879.changed.md b/changelog/3879.changed.md deleted file mode 100644 index 2b69f63ce..000000000 --- a/changelog/3879.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Updated tracing code to use `ServiceSettings` dataclass API (`given_fields()`, attribute access) instead of dict-style access (`.items()`, `in`, subscript). diff --git a/changelog/3883.added.md b/changelog/3883.added.md deleted file mode 100644 index 84360a891..000000000 --- a/changelog/3883.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added optional `direction` parameter to `PipelineTask.queue_frame()` and `PipelineTask.queue_frames()`, allowing frames to be pushed upstream from the end of the pipeline. diff --git a/changelog/3888.fixed.md b/changelog/3888.fixed.md deleted file mode 100644 index 99e9ad0e0..000000000 --- a/changelog/3888.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed turn completion instructions being lost when `LLMMessagesUpdateFrame` replaces the LLM context. When `filter_incomplete_user_turns` is enabled, the turn completion system message is now re-injected after context replacement. diff --git a/env.example b/env.example index 82308812e..81f3f895d 100644 --- a/env.example +++ b/env.example @@ -108,6 +108,10 @@ KRISP_VIVA_API_KEY=... KRISP_VIVA_FILTER_MODEL_PATH=... KRISP_VIVA_TURN_MODEL_PATH=... +# LemonSlice +LEMONSLICE_API_KEY=... +LEMONSLICE_AGENT_ID=... + # LiveKit LIVEKIT_API_KEY=... LIVEKIT_API_SECRET=... diff --git a/examples/foundational/07c-interruptible-deepgram-sagemaker.py b/examples/foundational/07c-interruptible-deepgram-sagemaker.py index aced7666f..7a4cd4297 100644 --- a/examples/foundational/07c-interruptible-deepgram-sagemaker.py +++ b/examples/foundational/07c-interruptible-deepgram-sagemaker.py @@ -23,8 +23,8 @@ 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_sagemaker import DeepgramSageMakerSTTService -from pipecat.services.deepgram.tts_sagemaker import DeepgramSageMakerTTSService +from pipecat.services.deepgram.sagemaker.stt import DeepgramSageMakerSTTService +from pipecat.services.deepgram.sagemaker.tts import DeepgramSageMakerTTSService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams diff --git a/examples/foundational/07o-interruptible-assemblyai-turn-detection.py b/examples/foundational/07o-interruptible-assemblyai-turn-detection.py new file mode 100644 index 000000000..cf052ada4 --- /dev/null +++ b/examples/foundational/07o-interruptible-assemblyai-turn-detection.py @@ -0,0 +1,179 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + + +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.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.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.assemblyai.models import AssemblyAIConnectionParams +from pipecat.services.assemblyai.stt import AssemblyAISTTService +from pipecat.services.cartesia.tts import CartesiaTTSService +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_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): + """AssemblyAI u3-rt-pro with Built-in Turn Detection + + This example demonstrates using AssemblyAI's u3-rt-pro Speech-to-Text model + with AssemblyAI's built-in turn detection for more natural conversation flow. + + Key features: + + 1. AssemblyAI Turn Detection + - Set `vad_force_turn_endpoint=False` to use AssemblyAI's built-in turn detection + - AssemblyAI's model determines when user starts/stops speaking + - Uses `ExternalUserTurnStrategies` to delegate turn control to AssemblyAI + - More natural turn detection based on speech patterns and pauses + + 2. Advanced Turn Detection Tuning + - `min_turn_silence`: Minimum silence (ms) when confident about end-of-turn. + Lower values = faster responses. Default: 100ms + - `max_turn_silence`: Maximum silence (ms) before forcing end-of-turn. + Prevents long pauses. Default: 1000ms + + 3. Prompt-Based Transcription Enhancement + - Use `prompt` parameter to improve accuracy for specific names/terms + - Particularly useful for proper nouns, technical terms, domain vocabulary + - Example: "Names: Xiomara, Saoirse, Krzystof. Technical terms: API, OAuth." + + 4. Speaker Diarization (Optional) + - Enable with `speaker_labels=True` + - Automatically identifies different speakers in multi-party conversations + - TranscriptionFrame includes speaker_id field (e.g., "Speaker A", "Speaker B") + + 5. Language Detection (Optional, multilingual model only) + - Enable with `language_detection=True` + - Automatically detects spoken language + - Available with universal-streaming-multilingual model + + For more information: https://www.assemblyai.com/docs/speech-to-text/streaming + """ + logger.info(f"Starting bot") + + stt = AssemblyAISTTService( + api_key=os.getenv("ASSEMBLYAI_API_KEY"), + vad_force_turn_endpoint=False, # Use AssemblyAI's built-in turn detection + connection_params=AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + # Optional: Tune turn detection timing (defaults shown below) + # min_turn_silence=100, # Default + # max_turn_silence=1000, # Default + # Optional: Boost accuracy for specific names/terms + # prompt="Names: Xiomara, Saoirse, Krzystof. Technical terms: API, OAuth.", + # Optional: Enable speaker diarization + # speaker_labels=True, + ), + ) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ) + + llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + + 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.", + }, + ] + + context = LLMContext(messages) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + user_turn_strategies=ExternalUserTurnStrategies(), + vad_analyzer=SileroVADAnalyzer(), + ), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, # STT + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + # Kick off the conversation. + messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info(f"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/examples/foundational/07r-interruptible-nvidia.py b/examples/foundational/07r-interruptible-nvidia.py index 18e0b5d5f..d3e34c61f 100644 --- a/examples/foundational/07r-interruptible-nvidia.py +++ b/examples/foundational/07r-interruptible-nvidia.py @@ -55,7 +55,8 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = NvidiaSTTService(api_key=os.getenv("NVIDIA_API_KEY")) llm = NvidiaLLMService( - api_key=os.getenv("NVIDIA_API_KEY"), model="meta/llama-3.1-405b-instruct" + api_key=os.getenv("NVIDIA_API_KEY"), + model="meta/llama-3.3-70b-instruct", ) tts = NvidiaTTSService(api_key=os.getenv("NVIDIA_API_KEY")) diff --git a/examples/foundational/13d-assemblyai-transcription.py b/examples/foundational/13d-assemblyai-transcription.py index 06ea52cd5..2dcbaf59b 100644 --- a/examples/foundational/13d-assemblyai-transcription.py +++ b/examples/foundational/13d-assemblyai-transcription.py @@ -16,6 +16,7 @@ from pipecat.pipeline.task import PipelineTask from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport +from pipecat.services.assemblyai.models import AssemblyAIConnectionParams from pipecat.services.assemblyai.stt import AssemblyAISTTService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams @@ -49,6 +50,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = AssemblyAISTTService( api_key=os.getenv("ASSEMBLYAI_API_KEY"), + connection_params=AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + ), ) tl = TranscriptionLogger() diff --git a/examples/foundational/29-turn-tracking-observer.py b/examples/foundational/29-turn-tracking-observer.py index 321197db2..cf85972e1 100644 --- a/examples/foundational/29-turn-tracking-observer.py +++ b/examples/foundational/29-turn-tracking-observer.py @@ -5,13 +5,17 @@ # +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 LLMRunFrame +from pipecat.observers.startup_timing_observer import StartupTimingObserver from pipecat.observers.user_bot_latency_observer import UserBotLatencyObserver from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -25,6 +29,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 @@ -32,6 +37,17 @@ from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams load_dotenv(override=True) + +async def fetch_weather_from_api(params: FunctionCallParams): + await asyncio.sleep(0.25) + await params.result_callback({"conditions": "nice", "temperature": "75"}) + + +async def fetch_restaurant_recommendation(params: FunctionCallParams): + await asyncio.sleep(0.1) + 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 = { @@ -62,6 +78,38 @@ 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) + + 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", @@ -69,7 +117,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(vad_analyzer=SileroVADAnalyzer()), @@ -87,8 +135,8 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ] ) - # Create latency tracking observer latency_observer = UserBotLatencyObserver() + startup_observer = StartupTimingObserver() task = PipelineTask( pipeline, @@ -97,14 +145,29 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): enable_usage_metrics=True, ), idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, - observers=[latency_observer], + observers=[latency_observer, startup_observer], ) - # Log latency measurements using the event handler + @latency_observer.event_handler("on_first_bot_speech_latency") + async def on_first_bot_speech_latency(observer, latency_seconds): + logger.info(f"First bot speech: {latency_seconds:.3f}s after client connected") + @latency_observer.event_handler("on_latency_measured") async def on_latency_measured(observer, latency_seconds): logger.info(f"⏱️ User-to-bot latency: {latency_seconds:.3f}s") + @startup_observer.event_handler("on_startup_timing_report") + async def on_startup_timing_report(observer, report): + logger.info(f"Total startup: {report.total_duration_secs:.3f}s") + for timing in report.processor_timings: + logger.info(f" {timing.processor_name}: {timing.duration_secs:.3f}s") + + @startup_observer.event_handler("on_transport_timing_report") + async def on_transport_timing_report(observer, report): + if report.bot_connected_secs is not None: + logger.info(f"Bot connected: {report.bot_connected_secs:.3f}s") + logger.info(f"Client connected: {report.client_connected_secs:.3f}s") + turn_observer = task.turn_tracking_observer if turn_observer: @@ -119,6 +182,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): else: logger.info(f"🏁 Turn {turn_number} completed in {duration:.2f}s") + @latency_observer.event_handler("on_latency_breakdown") + async def on_latency_breakdown(observer, breakdown): + for event in breakdown.chronological_events(): + logger.info(f" {event}") + @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): logger.info(f"Client connected") diff --git a/examples/foundational/55a-update-settings-deepgram-sagemaker-stt.py b/examples/foundational/55a-update-settings-deepgram-sagemaker-stt.py index e8094183a..04451e85c 100644 --- a/examples/foundational/55a-update-settings-deepgram-sagemaker-stt.py +++ b/examples/foundational/55a-update-settings-deepgram-sagemaker-stt.py @@ -24,7 +24,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.cartesia.tts import CartesiaTTSService -from pipecat.services.deepgram.stt_sagemaker import ( +from pipecat.services.deepgram.sagemaker.stt import ( DeepgramSageMakerSTTService, DeepgramSageMakerSTTSettings, ) diff --git a/examples/foundational/55d-update-settings-assemblyai-stt.py b/examples/foundational/55d-update-settings-assemblyai-stt.py index d37c3ec7b..f57865588 100644 --- a/examples/foundational/55d-update-settings-assemblyai-stt.py +++ b/examples/foundational/55d-update-settings-assemblyai-stt.py @@ -22,10 +22,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.assemblyai.models import AssemblyAIConnectionParams from pipecat.services.assemblyai.stt import AssemblyAISTTService, AssemblyAISTTSettings from pipecat.services.cartesia.tts import CartesiaTTSService from pipecat.services.openai.llm import OpenAILLMService -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 @@ -51,7 +51,12 @@ transport_params = { async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") - stt = AssemblyAISTTService(api_key=os.getenv("ASSEMBLYAI_API_KEY")) + stt = AssemblyAISTTService( + api_key=os.getenv("ASSEMBLYAI_API_KEY"), + connection_params=AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + ), + ) tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), @@ -63,7 +68,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): 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.", + "content": "You are a helpful LLM in a WebRTC call demonstrating dynamic keyterms updates. 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. Try saying difficult names like 'Xiomara', 'Saoirse', or 'Krzystof' to test transcription accuracy.", }, ] @@ -97,14 +102,24 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): logger.info(f"Client connected") + logger.info( + "Phase 1: No keyterms boosting - try saying 'Xiomara', 'Saoirse', or 'Krzystof'" + ) messages.append({"role": "system", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) - await asyncio.sleep(10) - logger.info("Updating AssemblyAI STT settings: language=es") + await asyncio.sleep(15) + logger.info("🔄 Updating keyterms: Adding difficult names for boosting") await task.queue_frame( - STTUpdateSettingsFrame(delta=AssemblyAISTTSettings(language=Language.ES)) + STTUpdateSettingsFrame( + delta=AssemblyAISTTSettings( + connection_params=AssemblyAIConnectionParams( + keyterms_prompt=["Xiomara", "Saoirse", "Krzystof", "Nguyen", "Pipecat"] + ) + ) + ) ) + logger.info("Phase 2: Keyterms active - same names should transcribe better now!") @transport.event_handler("on_client_disconnected") async def on_client_disconnected(transport, client): diff --git a/examples/foundational/55q-update-settings-deepgram-sagemaker-tts.py b/examples/foundational/55q-update-settings-deepgram-sagemaker-tts.py index 85087d0d2..14958b9d2 100644 --- a/examples/foundational/55q-update-settings-deepgram-sagemaker-tts.py +++ b/examples/foundational/55q-update-settings-deepgram-sagemaker-tts.py @@ -22,11 +22,11 @@ from pipecat.processors.aggregators.llm_response_universal import ( ) from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport -from pipecat.services.deepgram.stt import DeepgramSTTService -from pipecat.services.deepgram.tts_sagemaker import ( +from pipecat.services.deepgram.sagemaker.tts import ( DeepgramSageMakerTTSService, DeepgramSageMakerTTSSettings, ) +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 diff --git a/examples/foundational/56-lemonslice-transport.py b/examples/foundational/56-lemonslice-transport.py new file mode 100644 index 000000000..8b2e19a6e --- /dev/null +++ b/examples/foundational/56-lemonslice-transport.py @@ -0,0 +1,123 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import os +import sys + +import aiohttp +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.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.services.deepgram.stt import DeepgramSTTService +from pipecat.services.elevenlabs.tts import ElevenLabsTTSService +from pipecat.services.groq.llm import GroqLLMService +from pipecat.transports.lemonslice.transport import ( + LemonSliceNewSessionRequest, + LemonSliceParams, + LemonSliceTransport, +) + +load_dotenv(override=True) + +logger.remove(0) +logger.add(sys.stderr, level="DEBUG") + + +async def main(): + async with aiohttp.ClientSession() as session: + transport = LemonSliceTransport( + bot_name="Pipecat", + api_key=os.getenv("LEMONSLICE_API_KEY"), + session=session, + session_request=LemonSliceNewSessionRequest( + agent_id=os.getenv("LEMONSLICE_AGENT_ID"), + ), + params=LemonSliceParams( + audio_in_enabled=True, + audio_out_enabled=True, + microphone_out_enabled=False, + ), + ) + + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + llm = GroqLLMService(api_key=os.getenv("GROQ_API_KEY")) + + tts = ElevenLabsTTSService( + api_key=os.getenv("ELEVENLABS_API_KEY", ""), + voice_id=os.getenv("ELEVENLABS_VOICE_ID", ""), + ) + + 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.", + }, + ] + + context = LLMContext(messages) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, # STT + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + audio_in_sample_rate=16000, + audio_out_sample_rate=16000, + enable_metrics=True, + enable_usage_metrics=True, + ), + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, participant): + logger.info("Client connected") + # Kick off the conversation. + messages.append( + { + "role": "system", + "content": "Start by greeting the user and ask how you can help.", + } + ) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, participant): + logger.info("Client disconnected") + await task.cancel() + + runner = PipelineRunner() + + await runner.run(task) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/foundational/README.md b/examples/foundational/README.md index 9947dd1e6..04e88b7e7 100644 --- a/examples/foundational/README.md +++ b/examples/foundational/README.md @@ -121,6 +121,7 @@ uv run 07-interruptible.py -t twilio -x NGROK_HOST_NAME - **[19-openai-realtime-beta.py](./19-openai-realtime-beta.py)**: OpenAI Speech-to-Speech (Direct S2S, Function calls) - **[21-tavus-layer-tavus-transport.py](./21-tavus-layer-tavus-transport.py)**: Tavus digital twin (Avatar integration) - **[27-simli-layer.py](./27-simli-layer.py)**: Simli avatar integration (Video synchronization) +- **[56-lemonslice-transport.py](./56-lemonslice-transport.py)**: LemonSlice avatar integration (A/V Synced Avatar integration) ### Performance & Optimization diff --git a/pyproject.toml b/pyproject.toml index cb1958f62..12687a573 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,7 @@ koala = [ "pvkoala~=2.0.3" ] kokoro = [ "kokoro-onnx>=0.5.0,<1", "requests>=2.32.5,<3" ] krisp = [ "pipecat-ai-krisp~=0.4.0" ] langchain = [ "langchain~=0.3.20", "langchain-community~=0.3.20", "langchain-openai~=0.3.9" ] +lemonslice = [ "pipecat-ai[daily]" ] livekit = [ "livekit~=1.0.13", "livekit-api~=1.0.5", "tenacity>=8.2.3,<10.0.0", "pyjwt>=2.10.1" ] lmnt = [ "pipecat-ai[websockets-base]" ] local = [ "pyaudio~=0.2.14" ] diff --git a/src/pipecat/extensions/voicemail/voicemail_detector.py b/src/pipecat/extensions/voicemail/voicemail_detector.py index 7e22e535a..470f5dd54 100644 --- a/src/pipecat/extensions/voicemail/voicemail_detector.py +++ b/src/pipecat/extensions/voicemail/voicemail_detector.py @@ -368,7 +368,7 @@ class ClassificationProcessor(FrameProcessor): await self._voicemail_notifier.notify() # Clear buffered TTS frames # Interrupt the current pipeline to stop any ongoing processing - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() # Set the voicemail event to trigger the voicemail handler self._voicemail_event.clear() diff --git a/src/pipecat/frames/frames.py b/src/pipecat/frames/frames.py index 126f3c001..390eb93dd 100644 --- a/src/pipecat/frames/frames.py +++ b/src/pipecat/frames/frames.py @@ -11,7 +11,6 @@ including data frames, system frames, and control frames for audio, video, text, and LLM processing. """ -import asyncio import time from dataclasses import dataclass, field from typing import ( @@ -1141,24 +1140,9 @@ class InterruptionFrame(SystemFrame): This frame is used to interrupt the pipeline. For example, when a user starts speaking to cancel any in-progress bot output. It can also be pushed by any processor. - - Parameters: - event: Optional event set when the frame has fully traversed the - pipeline. - """ - event: Optional[asyncio.Event] = None - - def complete(self): - """Signal that this interruption has been fully processed. - - Called automatically when the frame reaches the pipeline sink, or - manually when the frame is consumed before reaching it (e.g. when - the user is muted). - """ - if self.event: - self.event.set() + pass @dataclass @@ -1825,16 +1809,11 @@ class InterruptionTaskFrame(TaskFrame): """Frame indicating the pipeline should be interrupted. This frame should be pushed upstream to indicate the pipeline should be - interrupted. The pipeline task converts this into an `InterruptionFrame` and - sends it downstream. The `event` is passed to the `InterruptionFrame` so it - can signal when the interruption has fully traversed the pipeline. - - Parameters: - event: Optional event passed to the corresponding `InterruptionFrame`. - + interrupted. The pipeline task converts this into an `InterruptionFrame` + and sends it downstream. """ - event: Optional[asyncio.Event] = None + pass @dataclass @@ -1910,6 +1889,29 @@ class StopFrame(ControlFrame, UninterruptibleFrame): pass +@dataclass +class BotConnectedFrame(SystemFrame): + """Frame indicating the bot has connected to the transport service. + + Pushed downstream by SFU transports (Daily, LiveKit, HeyGen, Tavus) + when the bot successfully joins the room. Non-SFU transports do not + emit this frame. + """ + + pass + + +@dataclass +class ClientConnectedFrame(SystemFrame): + """Frame indicating that a client has connected to the transport. + + Pushed downstream by the input transport when a client (participant) + connects. Used by observers to measure transport readiness timing. + """ + + pass + + @dataclass class OutputTransportReadyFrame(ControlFrame): """Frame indicating that the output transport is ready. diff --git a/src/pipecat/metrics/metrics.py b/src/pipecat/metrics/metrics.py index 37ab99447..2030306e5 100644 --- a/src/pipecat/metrics/metrics.py +++ b/src/pipecat/metrics/metrics.py @@ -41,10 +41,6 @@ class TTFBMetricsData(MetricsData): class ProcessingMetricsData(MetricsData): """General processing time metrics data. - .. deprecated:: 0.0.104 - Processing metrics are deprecated and will be removed in a future version. - Use TTFB metrics instead. - Parameters: value: Processing time measurement in seconds. """ diff --git a/src/pipecat/observers/base_observer.py b/src/pipecat/observers/base_observer.py index 78e36fec8..70c79224a 100644 --- a/src/pipecat/observers/base_observer.py +++ b/src/pipecat/observers/base_observer.py @@ -100,3 +100,11 @@ class BaseObserver(BaseObject): data: The event data containing details about the frame transfer. """ pass + + async def on_pipeline_started(self): + """Called when the pipeline has fully started. + + Fired after the ``StartFrame`` has been processed by all processors + in the pipeline, including nested ``ParallelPipeline`` branches. + """ + pass diff --git a/src/pipecat/observers/startup_timing_observer.py b/src/pipecat/observers/startup_timing_observer.py new file mode 100644 index 000000000..a1ea04d47 --- /dev/null +++ b/src/pipecat/observers/startup_timing_observer.py @@ -0,0 +1,328 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Observer for tracking pipeline startup timing. + +This module provides an observer that measures how long each processor's +``start()`` method takes during pipeline startup. It works by tracking +when a ``StartFrame`` arrives at a processor (``on_process_frame``) versus +when it leaves (``on_push_frame``), giving the exact ``start()`` duration +for each processor in the pipeline. + +It also measures transport timing — the time from ``StartFrame`` to the +first ``BotConnectedFrame`` (SFU transports only) and ``ClientConnectedFrame`` +— via a separate ``on_transport_timing_report`` event. + +Example:: + + observer = StartupTimingObserver() + + @observer.event_handler("on_startup_timing_report") + async def on_report(observer, report): + for t in report.processor_timings: + print(f"{t.processor_name}: {t.duration_secs:.3f}s") + + @observer.event_handler("on_transport_timing_report") + async def on_transport(observer, report): + if report.bot_connected_secs is not None: + print(f"Bot connected in {report.bot_connected_secs:.3f}s") + print(f"Client connected in {report.client_connected_secs:.3f}s") + + task = PipelineTask(pipeline, observers=[observer]) +""" + +import time +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple, Type + +from pydantic import BaseModel, Field + +from pipecat.frames.frames import BotConnectedFrame, ClientConnectedFrame, StartFrame +from pipecat.observers.base_observer import BaseObserver, FrameProcessed, FramePushed +from pipecat.pipeline.base_pipeline import BasePipeline +from pipecat.pipeline.pipeline import PipelineSource +from pipecat.processors.frame_processor import FrameProcessor + +# Internal pipeline types excluded from tracking by default. +_INTERNAL_TYPES = (PipelineSource, BasePipeline) + + +@dataclass +class _ArrivalInfo: + """Internal record of when a StartFrame arrived at a processor.""" + + processor: FrameProcessor + arrival_ts_ns: int + + +class ProcessorStartupTiming(BaseModel): + """Startup timing for a single processor. + + Parameters: + processor_name: The name of the processor. + start_offset_secs: Offset in seconds from the StartFrame to when this + processor's start() began. + duration_secs: How long the processor's start() took, in seconds. + """ + + processor_name: str + start_offset_secs: float + duration_secs: float + + +class StartupTimingReport(BaseModel): + """Report of startup timings for all measured processors. + + Parameters: + start_time: Unix timestamp when the first processor began starting. + total_duration_secs: Total wall-clock time from first to last processor start. + processor_timings: Per-processor timing data, in pipeline order. + """ + + start_time: float + total_duration_secs: float + processor_timings: List[ProcessorStartupTiming] = Field(default_factory=list) + + +class TransportTimingReport(BaseModel): + """Time from pipeline start to transport connection milestones. + + Parameters: + start_time: Unix timestamp of the StartFrame (pipeline start). + bot_connected_secs: Seconds from StartFrame to first BotConnectedFrame + (only set for SFU transports). + client_connected_secs: Seconds from StartFrame to first ClientConnectedFrame. + """ + + start_time: float + bot_connected_secs: Optional[float] = None + client_connected_secs: Optional[float] = None + + +class StartupTimingObserver(BaseObserver): + """Observer that measures processor startup times during pipeline initialization. + + Tracks how long each processor's ``start()`` method takes by measuring the + time between when a ``StartFrame`` arrives at a processor and when it is + pushed downstream. This captures WebSocket connections, API authentication, + model loading, and other initialization work. + + Also measures transport timing, the time from ``StartFrame`` to connection + milestones: + + - ``bot_connected_secs``: When the bot joins the transport room + (SFU transports only, triggered by ``BotConnectedFrame``). + - ``client_connected_secs``: When a remote participant connects + (triggered by ``ClientConnectedFrame``). + + By default, internal pipeline processors (``PipelineSource``, ``Pipeline``) + are excluded from the report. Pass ``processor_types`` to measure only + specific types. + + Event handlers available: + + - on_startup_timing_report: Called once after startup completes with the full + timing report. + - on_transport_timing_report: Called once when the first client connects with a + TransportTimingReport containing client_connected_secs and bot_connected_secs + (if available). + + Example:: + + observer = StartupTimingObserver( + processor_types=(STTService, TTSService) + ) + + @observer.event_handler("on_startup_timing_report") + async def on_report(observer, report): + for t in report.processor_timings: + logger.info(f"{t.processor_name}: {t.duration_secs:.3f}s") + + @observer.event_handler("on_transport_timing_report") + async def on_transport(observer, report): + if report.bot_connected_secs is not None: + logger.info(f"Bot connected in {report.bot_connected_secs:.3f}s") + logger.info(f"Client connected in {report.client_connected_secs:.3f}s") + + task = PipelineTask(pipeline, observers=[observer]) + + Args: + processor_types: Optional tuple of processor types to measure. If None, + all non-internal processors are measured. + """ + + def __init__( + self, + *, + processor_types: Optional[Tuple[Type[FrameProcessor], ...]] = None, + **kwargs, + ): + """Initialize the startup timing observer. + + Args: + processor_types: Optional tuple of processor types to measure. + If None, all non-internal processors are measured. + **kwargs: Additional arguments passed to parent class. + """ + super().__init__(**kwargs) + self._processor_types = processor_types + + # Map processor ID -> arrival info. + self._arrivals: Dict[int, _ArrivalInfo] = {} + + # Collected timings in pipeline order. + self._timings: List[ProcessorStartupTiming] = [] + + # Lock onto the first StartFrame we see (by frame ID). + self._start_frame_id: Optional[str] = None + + # Whether we've already emitted the startup timing report. + self._startup_timing_reported = False + + # Whether we've already measured transport timing. + self._transport_timing_reported = False + + # Timestamp (ns) when we first see a StartFrame arrive at a processor. + self._start_frame_arrival_ns: Optional[int] = None + + # Bot connected timing (stored for inclusion in the transport report). + self._bot_connected_secs: Optional[float] = None + + # Wall clock time when the StartFrame was first seen. + self._start_wall_clock: Optional[float] = None + + self._register_event_handler("on_startup_timing_report") + self._register_event_handler("on_transport_timing_report") + + def _should_track(self, processor: FrameProcessor) -> bool: + """Check if a processor should be tracked for timing. + + Args: + processor: The processor to check. + + Returns: + True if the processor matches the filter or no filter is set. + """ + if self._processor_types is not None: + return isinstance(processor, self._processor_types) + # Default: exclude internal pipeline plumbing. + return not isinstance(processor, _INTERNAL_TYPES) + + async def on_pipeline_started(self): + """Emit the startup timing report when the pipeline has fully started. + + Called by the ``PipelineTask`` after the ``StartFrame`` has been + processed by all processors, including nested ``ParallelPipeline`` + branches. + """ + if self._timings: + await self._emit_report() + + async def on_process_frame(self, data: FrameProcessed): + """Record when a StartFrame arrives at a processor. + + Args: + data: The frame processing event data. + """ + if self._startup_timing_reported: + return + + if not isinstance(data.frame, StartFrame): + return + + # Lock onto the first StartFrame. + if self._start_frame_id is None: + self._start_frame_id = data.frame.id + self._start_frame_arrival_ns = data.timestamp + self._start_wall_clock = time.time() + elif data.frame.id != self._start_frame_id: + return + + if self._should_track(data.processor): + self._arrivals[data.processor.id] = _ArrivalInfo( + processor=data.processor, arrival_ts_ns=data.timestamp + ) + + async def on_push_frame(self, data: FramePushed): + """Record when a StartFrame leaves a processor and compute the delta. + + Also handles ``BotConnectedFrame`` and ``ClientConnectedFrame`` to + measure transport timing. + + Args: + data: The frame push event data. + """ + if isinstance(data.frame, BotConnectedFrame): + self._handle_bot_connected(data) + return + + if isinstance(data.frame, ClientConnectedFrame): + await self._handle_client_connected(data) + return + + if self._startup_timing_reported: + return + + if not isinstance(data.frame, StartFrame): + return + + if self._start_frame_id is not None and data.frame.id != self._start_frame_id: + return + + arrival = self._arrivals.pop(data.source.id, None) + if arrival is None: + return + + duration_ns = data.timestamp - arrival.arrival_ts_ns + duration_secs = duration_ns / 1e9 + start_offset_secs = (arrival.arrival_ts_ns - self._start_frame_arrival_ns) / 1e9 + + self._timings.append( + ProcessorStartupTiming( + processor_name=arrival.processor.name, + start_offset_secs=start_offset_secs, + duration_secs=duration_secs, + ) + ) + + def _handle_bot_connected(self, data: FramePushed): + """Record bot connected timing on first BotConnectedFrame.""" + if self._bot_connected_secs is not None or self._start_frame_arrival_ns is None: + return + + delta_ns = data.timestamp - self._start_frame_arrival_ns + self._bot_connected_secs = delta_ns / 1e9 + + async def _handle_client_connected(self, data: FramePushed): + """Emit transport timing report on first ClientConnectedFrame.""" + if self._transport_timing_reported or self._start_frame_arrival_ns is None: + return + + self._transport_timing_reported = True + delta_ns = data.timestamp - self._start_frame_arrival_ns + client_connected_secs = delta_ns / 1e9 + report = TransportTimingReport( + start_time=self._start_wall_clock or 0.0, + bot_connected_secs=self._bot_connected_secs, + client_connected_secs=client_connected_secs, + ) + await self._call_event_handler("on_transport_timing_report", report) + + async def _emit_report(self): + """Build and emit the startup timing report.""" + if self._startup_timing_reported: + return + self._startup_timing_reported = True + + total = sum(t.duration_secs for t in self._timings) + + report = StartupTimingReport( + start_time=self._start_wall_clock or 0.0, + total_duration_secs=total, + processor_timings=self._timings, + ) + + await self._call_event_handler("on_startup_timing_report", report) diff --git a/src/pipecat/observers/user_bot_latency_observer.py b/src/pipecat/observers/user_bot_latency_observer.py index 37d5bc1a0..0672b689c 100644 --- a/src/pipecat/observers/user_bot_latency_observer.py +++ b/src/pipecat/observers/user_bot_latency_observer.py @@ -1,22 +1,146 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + """Observer for tracking user-to-bot response latency. This module provides an observer that monitors the time between when a user stops speaking and when the bot starts speaking, emitting events when latency -is measured. +is measured. Optionally collects per-service latency breakdown metrics +(TTFB, text aggregation) when ``enable_metrics=True``. """ import time -from typing import Optional, Set +from collections import deque +from typing import Dict, List, Optional + +from pydantic import BaseModel, Field from pipecat.frames.frames import ( BotStartedSpeakingFrame, + ClientConnectedFrame, + FunctionCallInProgressFrame, + FunctionCallResultFrame, + InterruptionFrame, + MetricsFrame, + UserStoppedSpeakingFrame, VADUserStartedSpeakingFrame, VADUserStoppedSpeakingFrame, ) +from pipecat.metrics.metrics import ( + TextAggregationMetricsData, + TTFBMetricsData, +) from pipecat.observers.base_observer import BaseObserver, FramePushed from pipecat.processors.frame_processor import FrameDirection +class TTFBBreakdownMetrics(BaseModel): + """TTFB measurement with timestamp for timeline placement. + + Parameters: + processor: Name of the processor that reported the TTFB. + model: Optional model name associated with the metric. + start_time: Unix timestamp when the TTFB measurement started. + duration_secs: TTFB duration in seconds. + """ + + processor: str + model: Optional[str] = None + start_time: float + duration_secs: float + + +class TextAggregationBreakdownMetrics(BaseModel): + """Text aggregation measurement with timestamp for timeline placement. + + Parameters: + processor: Name of the processor that reported the metric. + start_time: Unix timestamp when text aggregation started. + duration_secs: Aggregation duration in seconds. + """ + + processor: str + start_time: float + duration_secs: float + + +class FunctionCallMetrics(BaseModel): + """Latency for a single function call execution. + + Parameters: + function_name: Name of the function that was called. + start_time: Unix timestamp when execution started. + duration_secs: Time in seconds from execution start to result. + """ + + function_name: str + start_time: float + duration_secs: float + + +class LatencyBreakdown(BaseModel): + """Per-service latency breakdown for a single user-to-bot cycle. + + Collected between ``VADUserStoppedSpeakingFrame`` and + ``BotStartedSpeakingFrame`` when ``enable_metrics=True`` in + :class:`~pipecat.pipeline.task.PipelineParams`. + + Parameters: + ttfb: Time-to-first-byte metrics from each service in the pipeline. + text_aggregation: First text aggregation measurement, representing + the latency cost of sentence aggregation in the TTS pipeline. + user_turn_start_time: Unix timestamp when the user turn started + (actual user silence, adjusted for VAD stop_secs). ``None`` if + no ``VADUserStoppedSpeakingFrame`` was observed. + user_turn_secs: Duration in seconds of the user's turn, measured + from when the user actually stopped speaking to when the turn + was released (``UserStoppedSpeakingFrame``). This includes + VAD silence detection, STT finalization, and any turn analyzer + wait. ``None`` if no ``UserStoppedSpeakingFrame`` was observed + (e.g. no turn analyzer configured). + function_calls: Latency for each function call executed during + this cycle. Empty if no function calls occurred. + """ + + ttfb: List[TTFBBreakdownMetrics] = Field(default_factory=list) + text_aggregation: Optional[TextAggregationBreakdownMetrics] = None + user_turn_start_time: Optional[float] = None + user_turn_secs: Optional[float] = None + function_calls: List[FunctionCallMetrics] = Field(default_factory=list) + + def chronological_events(self) -> List[str]: + """Return human-readable event labels sorted by start time. + + Collects all sub-metrics into a flat list, sorts by ``start_time``, + and returns formatted strings suitable for logging. + + Returns: + List of formatted strings, one per event, in chronological order. + """ + events: List[tuple] = [] + + if self.user_turn_start_time is not None and self.user_turn_secs is not None: + events.append((self.user_turn_start_time, f"User turn: {self.user_turn_secs:.3f}s")) + + for t in self.ttfb: + events.append((t.start_time, f"{t.processor}: TTFB {t.duration_secs:.3f}s")) + + for fc in self.function_calls: + events.append((fc.start_time, f"{fc.function_name}: {fc.duration_secs:.3f}s")) + + if self.text_aggregation: + ta = self.text_aggregation + events.append( + (ta.start_time, f"{ta.processor}: text aggregation {ta.duration_secs:.3f}s") + ) + + events.sort(key=lambda e: e[0]) + return [label for _, label in events] + + class UserBotLatencyObserver(BaseObserver): """Observer that tracks user-to-bot response latency. @@ -25,34 +149,66 @@ class UserBotLatencyObserver(BaseObserver): latency is measured, allowing consumers to log, trace, or otherwise process the latency data. + When ``enable_metrics=True`` in pipeline params, also collects per-service + latency breakdown (TTFB, text aggregation) and emits an + ``on_latency_breakdown`` event alongside the existing latency measurement. + This observer follows the composition pattern used by TurnTrackingObserver, acting as a reusable component for latency measurement. Events: - on_latency_measured(observer, latency_seconds): Emitted when user-to-bot - latency is calculated. Includes the latency value in seconds as a float. + on_latency_measured(observer, latency_seconds): Emitted when + time-to-first-bot-speech is calculated. Measures the time from + when the user stopped speaking to when the bot starts speaking. + on_latency_breakdown(observer, breakdown): Emitted at each + ``BotStartedSpeakingFrame`` with a :class:`LatencyBreakdown` + containing per-service metrics collected during the user→bot cycle. + on_first_bot_speech_latency(observer, latency_seconds): Emitted once, + the first time ``BotStartedSpeakingFrame`` arrives after + ``ClientConnectedFrame``. Measures the time from client connection + to the first bot speech. """ - def __init__(self, **kwargs): + def __init__(self, *, max_frames=100, **kwargs): """Initialize the user-bot latency observer. Sets up tracking for processed frames and user speech timing to calculate response latencies. Args: + max_frames: Maximum number of frame IDs to keep in history for + duplicate detection. Defaults to 100. **kwargs: Additional arguments passed to parent class. """ super().__init__(**kwargs) self._user_stopped_time: Optional[float] = None - self._processed_frames: Set[str] = set() + self._user_turn_start_time: Optional[float] = None + self._user_turn: Optional[float] = None + + # First bot speech tracking + self._client_connected_time: Optional[float] = None + self._first_bot_speech_measured: bool = False + + # Frame deduplication (bounded deque + set pattern) + self._processed_frames: set = set() + self._frame_history: deque = deque(maxlen=max_frames) + + # Per-cycle metric accumulators + self._ttfb: List[TTFBBreakdownMetrics] = [] + self._text_aggregation: Optional[TextAggregationBreakdownMetrics] = None + self._function_call_starts: Dict[str, tuple[str, float]] = {} + self._function_call_metrics: List[FunctionCallMetrics] = [] self._register_event_handler("on_latency_measured") + self._register_event_handler("on_latency_breakdown") + self._register_event_handler("on_first_bot_speech_latency") async def on_push_frame(self, data: FramePushed): """Process frames to track speech timing and calculate latency. Tracks VAD events and bot speaking events to measure the time between - user stopping speech and bot starting speech. + user stopping speech and bot starting speech. Also accumulates metrics + from MetricsFrame for the latency breakdown. Args: data: Frame push event containing the frame and direction information. @@ -61,23 +217,135 @@ class UserBotLatencyObserver(BaseObserver): if data.direction != FrameDirection.DOWNSTREAM: return - # Skip already processed frames + # Skip already processed frames (bounded deque + set) if data.frame.id in self._processed_frames: return self._processed_frames.add(data.frame.id) + self._frame_history.append(data.frame.id) - # Track VAD and bot speaking events for latency + if len(self._processed_frames) > len(self._frame_history): + self._processed_frames = set(self._frame_history) + + # Track client connection (first occurrence only) + if isinstance(data.frame, ClientConnectedFrame): + if self._client_connected_time is None: + self._client_connected_time = time.time() + return + + # Track speech and pipeline events for latency if isinstance(data.frame, VADUserStartedSpeakingFrame): # Reset when user starts speaking self._user_stopped_time = None + self._user_turn_start_time = None + self._user_turn = None + self._reset_accumulators() + # If user speaks before the bot's first speech, abandon the + # first-bot-speech measurement — it's only meaningful for greetings. + self._first_bot_speech_measured = True elif isinstance(data.frame, VADUserStoppedSpeakingFrame): # Record the actual time the user stopped speaking, which is # the VAD determination time minus the stop_secs silence duration # that had to elapse before the VAD confirmed speech ended. self._user_stopped_time = data.frame.timestamp - data.frame.stop_secs - elif isinstance(data.frame, BotStartedSpeakingFrame) and self._user_stopped_time: - # Calculate and emit latency + self._user_turn_start_time = self._user_stopped_time + elif isinstance(data.frame, UserStoppedSpeakingFrame): + # Measure the user turn duration: from actual user silence to + # turn release. Includes VAD silence detection, STT finalization, + # and any turn analyzer wait. + if self._user_stopped_time is not None: + self._user_turn = time.time() - self._user_stopped_time + elif isinstance(data.frame, InterruptionFrame): + # Discard stale metrics from cancelled LLM/TTS cycles + self._reset_accumulators() + elif isinstance(data.frame, FunctionCallInProgressFrame): + self._function_call_starts[data.frame.tool_call_id] = ( + data.frame.function_name, + time.time(), + ) + elif isinstance(data.frame, FunctionCallResultFrame): + start = self._function_call_starts.pop(data.frame.tool_call_id, None) + if start is not None: + function_name, start_time = start + self._function_call_metrics.append( + FunctionCallMetrics( + function_name=function_name, + start_time=start_time, + duration_secs=time.time() - start_time, + ) + ) + elif isinstance(data.frame, MetricsFrame): + self._handle_metrics_frame(data.frame) + elif isinstance(data.frame, BotStartedSpeakingFrame): + await self._handle_bot_started_speaking() + + async def _handle_bot_started_speaking(self): + """Handle BotStartedSpeakingFrame to emit latency and breakdown.""" + emit_breakdown = False + + # One-time first bot speech measurement (client connect → first speech) + if self._client_connected_time is not None and not self._first_bot_speech_measured: + self._first_bot_speech_measured = True + latency = time.time() - self._client_connected_time + await self._call_event_handler("on_first_bot_speech_latency", latency) + emit_breakdown = True + + if self._user_stopped_time is not None: latency = time.time() - self._user_stopped_time self._user_stopped_time = None await self._call_event_handler("on_latency_measured", latency) + emit_breakdown = True + + if emit_breakdown: + breakdown = LatencyBreakdown( + ttfb=list(self._ttfb), + text_aggregation=self._text_aggregation, + user_turn_start_time=self._user_turn_start_time, + user_turn_secs=self._user_turn, + function_calls=list(self._function_call_metrics), + ) + await self._call_event_handler("on_latency_breakdown", breakdown) + self._reset_accumulators() + + def _handle_metrics_frame(self, frame: MetricsFrame): + """Extract latency metrics from a MetricsFrame. + + Accumulates metrics when a measurement is in progress: either a + user→bot cycle (after ``VADUserStoppedSpeakingFrame``) or the + first-bot-speech window (after ``ClientConnectedFrame``). + """ + waiting_for_first_speech = ( + self._client_connected_time is not None and not self._first_bot_speech_measured + ) + if self._user_stopped_time is None and not waiting_for_first_speech: + return + + now = time.time() + for metrics_data in frame.data: + if isinstance(metrics_data, TTFBMetricsData) and metrics_data.value > 0: + self._ttfb.append( + TTFBBreakdownMetrics( + processor=metrics_data.processor, + model=metrics_data.model, + start_time=now - metrics_data.value, + duration_secs=metrics_data.value, + ) + ) + elif isinstance(metrics_data, TextAggregationMetricsData): + # Only keep the first measurement — it's the one that + # impacts the initial speaking latency. + if self._text_aggregation is None: + self._text_aggregation = TextAggregationBreakdownMetrics( + processor=metrics_data.processor, + start_time=now - metrics_data.value, + duration_secs=metrics_data.value, + ) + + def _reset_accumulators(self): + """Clear per-cycle metric accumulators.""" + self._ttfb = [] + self._text_aggregation = None + self._user_turn_start_time = None + self._user_turn = None + self._function_call_starts = {} + self._function_call_metrics = [] diff --git a/src/pipecat/pipeline/task.py b/src/pipecat/pipeline/task.py index deae6290c..e795961a1 100644 --- a/src/pipecat/pipeline/task.py +++ b/src/pipecat/pipeline/task.py @@ -892,7 +892,7 @@ class PipelineTask(BasePipelineTask): # pipeline. This is in case the push task is blocked waiting for a # pipeline-ending frame to finish traversing the pipeline. logger.debug(f"{self}: received interruption task frame {frame}") - await self._pipeline.queue_frame(InterruptionFrame(event=frame.event)) + await self._pipeline.queue_frame(InterruptionFrame()) elif isinstance(frame, ErrorFrame): await self._call_event_handler("on_pipeline_error", frame) if frame.fatal: @@ -915,6 +915,7 @@ class PipelineTask(BasePipelineTask): if isinstance(frame, StartFrame): await self._call_event_handler("on_pipeline_started", frame) + await self._observer.on_pipeline_started() # Start heartbeat tasks now that StartFrame has been processed # by all processors in the pipeline @@ -931,8 +932,6 @@ class PipelineTask(BasePipelineTask): self._pipeline_end_event.set() elif isinstance(frame, CancelFrame): self._pipeline_end_event.set() - elif isinstance(frame, InterruptionFrame): - frame.complete() elif isinstance(frame, HeartbeatFrame): await self._heartbeat_queue.put(frame) diff --git a/src/pipecat/pipeline/task_observer.py b/src/pipecat/pipeline/task_observer.py index 4d33fd60e..dc2040e07 100644 --- a/src/pipecat/pipeline/task_observer.py +++ b/src/pipecat/pipeline/task_observer.py @@ -39,6 +39,12 @@ class Proxy: observer: BaseObserver +class _PipelineStartedSignal: + """Internal sentinel queued to observers when the pipeline has started.""" + + pass + + class TaskObserver(BaseObserver): """Proxy observer that manages multiple observers without blocking the pipeline. @@ -129,6 +135,10 @@ class TaskObserver(BaseObserver): for proxy in self._proxies: await proxy.cleanup() + async def on_pipeline_started(self): + """Forward pipeline started signal to all managed observers.""" + await self._send_to_proxy(_PipelineStartedSignal()) + async def on_process_frame(self, data: FrameProcessed): """Queue frame data for all managed observers. @@ -186,7 +196,9 @@ class TaskObserver(BaseObserver): while True: data = await queue.get() - if isinstance(data, FramePushed): + if isinstance(data, _PipelineStartedSignal): + await observer.on_pipeline_started() + elif isinstance(data, FramePushed): if on_push_frame_deprecated: await observer.on_push_frame( data.source, data.destination, data.frame, data.direction, data.timestamp diff --git a/src/pipecat/processors/aggregators/dtmf_aggregator.py b/src/pipecat/processors/aggregators/dtmf_aggregator.py index 1b9c59158..ea56ba6fc 100644 --- a/src/pipecat/processors/aggregators/dtmf_aggregator.py +++ b/src/pipecat/processors/aggregators/dtmf_aggregator.py @@ -104,7 +104,7 @@ class DTMFAggregator(FrameProcessor): # For first digit, schedule interruption. if is_first_digit: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() # Check for immediate flush conditions if frame.button == self._termination_digit: diff --git a/src/pipecat/processors/aggregators/llm_response.py b/src/pipecat/processors/aggregators/llm_response.py index 44e5ce252..7c246b209 100644 --- a/src/pipecat/processors/aggregators/llm_response.py +++ b/src/pipecat/processors/aggregators/llm_response.py @@ -581,7 +581,7 @@ class LLMUserContextAggregator(LLMContextResponseAggregator): logger.debug( "Interruption conditions met - pushing interruption and aggregation" ) - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() await self._process_aggregation() else: logger.debug("Interruption conditions not met - not pushing aggregation") diff --git a/src/pipecat/processors/aggregators/llm_response_universal.py b/src/pipecat/processors/aggregators/llm_response_universal.py index 96f3702be..cf6c81e5f 100644 --- a/src/pipecat/processors/aggregators/llm_response_universal.py +++ b/src/pipecat/processors/aggregators/llm_response_universal.py @@ -608,12 +608,6 @@ class LLMUserAggregator(LLMContextAggregator): if should_mute_frame: logger.trace(f"{frame.name} suppressed - user currently muted") - # When muted, the InterruptionFrame won't propagate further and - # will never reach the pipeline sink. Complete it here so - # push_interruption_task_frame_and_wait() doesn't hang. - if should_mute_frame and isinstance(frame, InterruptionFrame): - frame.complete() - should_mute_next_time = False for s in self._params.user_mute_strategies: should_mute_next_time |= await s.process_frame(frame) @@ -737,7 +731,7 @@ class LLMUserAggregator(LLMContextAggregator): await self._user_idle_controller.process_frame(UserStartedSpeakingFrame()) if params.enable_interruptions and self._allow_interruptions: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() await self._call_event_handler("on_user_turn_started", strategy) diff --git a/src/pipecat/processors/filters/stt_mute_filter.py b/src/pipecat/processors/filters/stt_mute_filter.py index f5d008e28..9f522a20d 100644 --- a/src/pipecat/processors/filters/stt_mute_filter.py +++ b/src/pipecat/processors/filters/stt_mute_filter.py @@ -234,12 +234,6 @@ class STTMuteFilter(FrameProcessor): await self.push_frame(frame, direction) else: logger.trace(f"{frame.__class__.__name__} suppressed - STT currently muted") - - # When muted, the InterruptionFrame won't propagate further - # and will never reach the pipeline sink. Complete it here so - # push_interruption_task_frame_and_wait() doesn't hang. - if isinstance(frame, InterruptionFrame): - frame.complete() else: # Pass all other frames through await self.push_frame(frame, direction) diff --git a/src/pipecat/processors/frame_processor.py b/src/pipecat/processors/frame_processor.py index 3e7b48442..f3d9fbdea 100644 --- a/src/pipecat/processors/frame_processor.py +++ b/src/pipecat/processors/frame_processor.py @@ -41,7 +41,6 @@ from pipecat.frames.frames import ( FrameProcessorResumeFrame, FrameProcessorResumeUrgentFrame, InterruptionFrame, - InterruptionTaskFrame, StartFrame, SystemFrame, UninterruptibleFrame, @@ -240,10 +239,6 @@ class FrameProcessor(BaseObject): self.__process_frame_task: Optional[asyncio.Task] = None self.__process_current_frame: Optional[Frame] = None - # Set while awaiting push_interruption_task_frame_and_wait() so that - # _start_interruption() knows not to cancel the process task. - self._wait_for_interruption = False - # Frame processor events. self._register_event_handler("on_before_process_frame", sync=True) self._register_event_handler("on_after_process_frame", sync=True) @@ -329,7 +324,7 @@ class FrameProcessor(BaseObject): warnings.simplefilter("always") warnings.warn( "`FrameProcessor.interruptions_allowed` is deprecated. " - "Use `LLMUserAggregator`'s new `user_mute_strategies` parameter instead.", + "Use `LLMUserAggregator`'s new `user_mute_strategies` parameter instead.", DeprecationWarning, stacklevel=2, ) @@ -441,35 +436,19 @@ class FrameProcessor(BaseObject): if frame: await self.push_frame(frame) - _processing_metrics_warned = False - async def start_processing_metrics(self, *, start_time: Optional[float] = None): """Start processing metrics collection. - .. deprecated:: 0.0.104 - Processing metrics are deprecated and will be removed in a future version. - Use TTFB metrics instead. - 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: - if not FrameProcessor._processing_metrics_warned: - FrameProcessor._processing_metrics_warned = True - logger.warning( - "Processing metrics are deprecated and will be removed in a future version. " - "Use TTFB metrics instead." - ) 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. - .. deprecated:: 0.0.104 - Processing metrics are deprecated and will be removed in a future version. - Use TTFB metrics instead. - Args: end_time: Optional timestamp to use as the end time. If None, uses the current time. @@ -647,15 +626,6 @@ class FrameProcessor(BaseObject): if self._cancelling: return - # If we are waiting for an interruption, bypass all queued system frames - # and process the frame right away. This is because a previous system - # frame might be waiting for the interruption frame blocking the input - # task, so this InterruptionFrame would never be dequeued and we'd - # deadlock. - if self._wait_for_interruption and isinstance(frame, InterruptionFrame): - await self.__process_frame(frame, direction, callback) - return - if self._enable_direct_mode: await self.__process_frame(frame, direction, callback) else: @@ -790,43 +760,32 @@ class FrameProcessor(BaseObject): await self._call_event_handler("on_after_push_frame", frame) + async def broadcast_interruption(self): + """Broadcast an `InterruptionFrame` both upstream and downstream.""" + logger.debug(f"{self}: broadcasting interruption") + self.__reset_process_task() + await self.stop_all_metrics() + await self.broadcast_frame(InterruptionFrame) + 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 - pipeline task. The task creates a corresponding `InterruptionFrame` - and sends it downstream through the pipeline. An `asyncio.Event` is - 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 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. + .. deprecated:: 0.0.104 + Use :meth:`broadcast_interruption` instead. This method now + delegates to ``broadcast_interruption()`` and ignores *timeout*. """ - self._wait_for_interruption = True + import warnings - event = asyncio.Event() + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "`FrameProcessor.push_interruption_task_frame_and_wait()` is deprecated. " + "Use `FrameProcessor.broadcast_interruption()` instead.", + DeprecationWarning, + stacklevel=2, + ) - await self.push_frame(InterruptionTaskFrame(event=event), FrameDirection.UPSTREAM) - - # 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=timeout) - except asyncio.TimeoutError: - logger.warning( - f"{self}: InterruptionFrame has not completed after" - 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 + await self.broadcast_interruption() async def broadcast_frame(self, frame_cls: Type[Frame], **kwargs): """Broadcasts a frame of the specified class upstream and downstream. @@ -933,15 +892,7 @@ class FrameProcessor(BaseObject): async def _start_interruption(self): """Start handling an interruption by cancelling current tasks.""" try: - if self._wait_for_interruption: - # If we get here we know the process task was just waiting for - # an interruption (push_interruption_task_frame_and_wait()), so - # we can't cancel the task because it might still need to do - # more things (e.g. pushing a frame after the - # interruption). Instead we just drain the queue because this is - # an interruption. - self.__reset_process_task() - elif isinstance(self.__process_current_frame, UninterruptibleFrame): + if isinstance(self.__process_current_frame, UninterruptibleFrame): # We don't want to cancel UninterruptibleFrame, so we simply # cleanup the queue. self.__reset_process_queue() diff --git a/src/pipecat/processors/frameworks/rtvi.py b/src/pipecat/processors/frameworks/rtvi.py index e01e95714..eb1e79f3e 100644 --- a/src/pipecat/processors/frameworks/rtvi.py +++ b/src/pipecat/processors/frameworks/rtvi.py @@ -1702,7 +1702,7 @@ class RTVIProcessor(FrameProcessor): async def interrupt_bot(self): """Send a bot interruption frame upstream.""" - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() async def send_server_message(self, data: Any): """Send a server message to the client.""" diff --git a/src/pipecat/processors/metrics/frame_processor_metrics.py b/src/pipecat/processors/metrics/frame_processor_metrics.py index ef637b5ad..7a52895a2 100644 --- a/src/pipecat/processors/metrics/frame_processor_metrics.py +++ b/src/pipecat/processors/metrics/frame_processor_metrics.py @@ -150,10 +150,6 @@ class FrameProcessorMetrics(BaseObject): async def start_processing_metrics(self, *, start_time: Optional[float] = None): """Start measuring processing time. - .. deprecated:: 0.0.104 - Processing metrics are deprecated and will be removed in a future version. - Use TTFB metrics instead. - Args: start_time: Optional timestamp to use as the start time. If None, uses the current time. @@ -163,10 +159,6 @@ class FrameProcessorMetrics(BaseObject): async def stop_processing_metrics(self, *, end_time: Optional[float] = None): """Stop processing time measurement and generate metrics frame. - .. deprecated:: 0.0.104 - Processing metrics are deprecated and will be removed in a future version. - Use TTFB metrics instead. - Args: end_time: Optional timestamp to use as the end time. If None, uses the current time. diff --git a/src/pipecat/services/assemblyai/models.py b/src/pipecat/services/assemblyai/models.py index ca58cb848..3a022dce1 100644 --- a/src/pipecat/services/assemblyai/models.py +++ b/src/pipecat/services/assemblyai/models.py @@ -12,7 +12,8 @@ transcription WebSocket messages and connection configuration. from typing import List, Literal, Optional -from pydantic import BaseModel, Field +from loguru import logger +from pydantic import BaseModel, ConfigDict, Field, model_validator class Word(BaseModel): @@ -68,8 +69,16 @@ class TurnMessage(BaseMessage): transcript: The transcribed text for this turn. end_of_turn_confidence: Confidence score for end-of-turn detection. words: List of individual words with timing and confidence data. + language_code: Detected language code (e.g., "es", "fr"). Only present with + complete utterances or when end_of_turn is True. + language_confidence: Confidence score (0-1) for language detection. Only present + with complete utterances or when end_of_turn is True. + speaker: Speaker label (e.g., "A", "B"). Only present when speaker_labels is + enabled and end_of_turn is True. Maps to 'speaker_label' in JSON response. """ + model_config = ConfigDict(populate_by_name=True) + type: Literal["Turn"] = "Turn" turn_order: int turn_is_formatted: bool @@ -77,6 +86,21 @@ class TurnMessage(BaseMessage): transcript: str end_of_turn_confidence: float words: List[Word] + language_code: Optional[str] = None + language_confidence: Optional[float] = None + speaker: Optional[str] = Field(default=None, alias="speaker_label") + + +class SpeechStartedMessage(BaseMessage): + """Message sent when speech is first detected in the audio stream. + + Parameters: + type: Always "SpeechStarted" for this message type. + timestamp: Audio timestamp in milliseconds when speech was detected. + """ + + type: Literal["SpeechStarted"] = "SpeechStarted" + timestamp: int class TerminationMessage(BaseMessage): @@ -94,7 +118,7 @@ class TerminationMessage(BaseMessage): # Union type for all possible message types -AnyMessage = BeginMessage | TurnMessage | TerminationMessage +AnyMessage = BeginMessage | TurnMessage | SpeechStartedMessage | TerminationMessage class AssemblyAIConnectionParams(BaseModel): @@ -106,10 +130,19 @@ class AssemblyAIConnectionParams(BaseModel): formatted_finals: Whether to enable transcript formatting. Defaults to True. word_finalization_max_wait_time: Maximum time to wait for word finalization in milliseconds. end_of_turn_confidence_threshold: Confidence threshold for end-of-turn detection. - min_end_of_turn_silence_when_confident: Minimum silence duration when confident about end-of-turn. + min_turn_silence: Minimum silence duration when confident about end-of-turn. + min_end_of_turn_silence_when_confident: DEPRECATED. Use min_turn_silence instead. max_turn_silence: Maximum silence duration before forcing end-of-turn. keyterms_prompt: List of key terms to guide transcription. Will be JSON serialized before sending. - speech_model: Select between English and multilingual models. Defaults to "universal-streaming-english". + prompt: Optional text prompt to guide the transcription. Only used when speech_model is "u3-rt-pro". + speech_model: Select between English, multilingual, and u3-rt-pro models. Defaults to "u3-rt-pro". + language_detection: Enable automatic language detection. Only applicable to + universal-streaming-multilingual. When enabled, Turn messages include + language_code and language_confidence fields. Defaults to None (not sent). + format_turns: Whether to format transcript turns. Defaults to True. + speaker_labels: Enable speaker diarization. When enabled, final transcripts + (end_of_turn=True) include a speaker field identifying the speaker + (e.g., "Speaker A", "Speaker B"). Defaults to None (not sent). """ sample_rate: int = 16000 @@ -117,9 +150,27 @@ class AssemblyAIConnectionParams(BaseModel): formatted_finals: bool = True word_finalization_max_wait_time: Optional[int] = None end_of_turn_confidence_threshold: Optional[float] = None - min_end_of_turn_silence_when_confident: Optional[int] = None + min_turn_silence: Optional[int] = None + min_end_of_turn_silence_when_confident: Optional[int] = None # Deprecated max_turn_silence: Optional[int] = None keyterms_prompt: Optional[List[str]] = None - speech_model: Literal["universal-streaming-english", "universal-streaming-multilingual"] = ( - "universal-streaming-english" - ) + prompt: Optional[str] = None + speech_model: Literal[ + "universal-streaming-english", "universal-streaming-multilingual", "u3-rt-pro" + ] = "u3-rt-pro" + language_detection: Optional[bool] = None + format_turns: bool = True + speaker_labels: Optional[bool] = None + + @model_validator(mode="after") + def handle_deprecated_param(self): + """Handle deprecated min_end_of_turn_silence_when_confident parameter.""" + if self.min_end_of_turn_silence_when_confident is not None: + logger.warning( + "The 'min_end_of_turn_silence_when_confident' parameter is deprecated and will be " + "removed in a future version. Please use 'min_turn_silence' instead." + ) + # If min_turn_silence is not set, use the deprecated value + if self.min_turn_silence is None: + self.min_turn_silence = self.min_end_of_turn_silence_when_confident + return self diff --git a/src/pipecat/services/assemblyai/stt.py b/src/pipecat/services/assemblyai/stt.py index a89f5fe52..c62ae959b 100644 --- a/src/pipecat/services/assemblyai/stt.py +++ b/src/pipecat/services/assemblyai/stt.py @@ -26,6 +26,8 @@ from pipecat.frames.frames import ( InterimTranscriptionFrame, StartFrame, TranscriptionFrame, + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, VADUserStartedSpeakingFrame, VADUserStoppedSpeakingFrame, ) @@ -41,6 +43,7 @@ from .models import ( AssemblyAIConnectionParams, BaseMessage, BeginMessage, + SpeechStartedMessage, TerminationMessage, TurnMessage, ) @@ -54,6 +57,28 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +def map_language_from_assemblyai(language_code: str) -> Language: + """Map AssemblyAI language codes to Pipecat Language enum. + + AssemblyAI returns simple language codes like "es", "fr", etc. + This function maps them to the corresponding Language enum values. + + Args: + language_code: AssemblyAI language code (e.g., "es", "fr", "de") + + Returns: + Corresponding Language enum value, defaulting to Language.EN if not found. + """ + try: + # Try to match the language code directly + return Language(language_code.lower()) + except ValueError: + logger.warning( + f"Unknown language code from AssemblyAI: {language_code}, defaulting to English" + ) + return Language.EN + + @dataclass class AssemblyAISTTSettings(STTSettings): """Settings for the AssemblyAI STT service. @@ -87,6 +112,8 @@ class AssemblyAISTTService(WebsocketSTTService): api_endpoint_base_url: str = "wss://streaming.assemblyai.com/v3/ws", connection_params: AssemblyAIConnectionParams = AssemblyAIConnectionParams(), vad_force_turn_endpoint: bool = True, + should_interrupt: bool = True, + speaker_format: Optional[str] = None, ttfs_p99_latency: Optional[float] = ASSEMBLYAI_TTFS_P99, **kwargs, ): @@ -97,18 +124,66 @@ class AssemblyAISTTService(WebsocketSTTService): language: Language code for transcription. Defaults to English (Language.EN). api_endpoint_base_url: WebSocket endpoint URL. Defaults to AssemblyAI's streaming endpoint. connection_params: Connection configuration parameters. Defaults to AssemblyAIConnectionParams(). - vad_force_turn_endpoint: Whether to force turn endpoint on VAD stop. When True, - disables AssemblyAI's model-based turn detection and relies on external VAD - to trigger turn endpoints. Automatically sets end_of_turn_confidence_threshold=1.0 - and max_turn_silence=2000 unless explicitly overridden. Defaults to True. + vad_force_turn_endpoint: Controls turn detection mode. + When True (Pipecat mode, default): Forces AssemblyAI to return finals ASAP + so Pipecat's turn detection (e.g., Smart Turn) decides when the user is done. + - min_turn_silence defaults to 100ms (user can override) + - max_turn_silence is ALWAYS set equal to min_turn_silence + - VAD stop sends ForceEndpoint as ceiling + - No UserStarted/StoppedSpeakingFrame emitted from STT + When False (AssemblyAI turn detection mode, u3-rt-pro only): AssemblyAI's model + controls turn endings using built-in turn detection. + - Uses AssemblyAI API defaults for all parameters (unless user explicitly sets them) + - Respects all user-provided connection_params as-is + - Emits UserStarted/StoppedSpeakingFrame from STT + - No ForceEndpoint on VAD stop + should_interrupt: Whether to interrupt the bot when the user starts speaking + in AssemblyAI turn detection mode (vad_force_turn_endpoint=False). Only applies + when using AssemblyAI's built-in turn detection. Defaults to True. + speaker_format: Optional format string for speaker labels when diarization is enabled. + Use {speaker} for speaker label and {text} for transcript text. + Example: "<{speaker}>{text}" or "{speaker}: {text}" + If None, transcript text is not modified. Defaults to None. 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 **kwargs: Additional arguments passed to parent STTService class. """ - # When vad_force_turn_endpoint is enabled, configure connection params for manual - # turn detection mode (disable model-based turn detection) + # AssemblyAI turn detection mode (vad_force_turn_endpoint=False) requires the + # SpeechStarted event for reliable barge-in. Only u3-rt-pro supports + # this. Other models must use Pipecat turn detection. + is_u3_pro = connection_params.speech_model == "u3-rt-pro" + if not vad_force_turn_endpoint and not is_u3_pro: + raise ValueError( + f"AssemblyAI turn detection mode (vad_force_turn_endpoint=False) requires " + f"u3-rt-pro for SpeechStarted support. Either set " + f"vad_force_turn_endpoint=True for {connection_params.speech_model}, " + f"or use speech_model='u3-rt-pro'." + ) + + # Validate that prompt and keyterms_prompt are not both set + if connection_params.prompt is not None and connection_params.keyterms_prompt is not None: + raise ValueError( + "The prompt and keyterms_prompt parameters cannot be used in the same request. " + "Please choose either one or the other based on your use case. When you use " + "keyterms_prompt, your boosted words are appended to the default prompt automatically. " + "Or to boost within prompt: + Make sure to boost the words in the audio. " + "For more info go to: https://www.assemblyai.com/docs/streaming/universal-3-pro" + ) + + # Warn if user sets a custom prompt (recommend testing without one first) + if connection_params.prompt is not None: + logger.warning( + "Custom prompt detected. Prompting is a beta feature. We recommend testing " + "with no prompt first, as this will use our optimized default prompt for " + "voice agents. Bad prompts may lead to bad results. If you'd like to create " + "your own prompt, check out our prompting guide at: " + "https://www.assemblyai.com/docs/streaming/prompting" + ) + + # When vad_force_turn_endpoint is enabled, configure connection params + # for Pipecat turn detection mode (fast finals for smart turn analyzer) if vad_force_turn_endpoint: - connection_params = self._configure_manual_turn_mode(connection_params) + connection_params = self._configure_pipecat_turn_mode(connection_params, is_u3_pro) super().__init__( sample_rate=connection_params.sample_rate, @@ -124,6 +199,8 @@ class AssemblyAISTTService(WebsocketSTTService): self._api_key = api_key self._api_endpoint_base_url = api_endpoint_base_url self._vad_force_turn_endpoint = vad_force_turn_endpoint + self._should_interrupt = should_interrupt + self._speaker_format = speaker_format self._termination_event = asyncio.Event() self._received_termination = False @@ -135,45 +212,64 @@ class AssemblyAISTTService(WebsocketSTTService): self._chunk_size_ms = 50 self._chunk_size_bytes = 0 - def _configure_manual_turn_mode( - self, connection_params: AssemblyAIConnectionParams - ) -> AssemblyAIConnectionParams: - """Configure connection params for manual turn detection mode. + self._user_speaking = False - When vad_force_turn_endpoint is enabled, we want to disable AssemblyAI's - model-based turn detection and rely on external VAD. This requires: - - end_of_turn_confidence_threshold=1.0 (disable semantic turn detection) - - max_turn_silence=2000 (high value since VAD handles turn endings) + def _configure_pipecat_turn_mode( + self, connection_params: AssemblyAIConnectionParams, is_u3_pro: bool + ) -> AssemblyAIConnectionParams: + """Configure connection params for Pipecat turn detection mode. + + When vad_force_turn_endpoint is enabled, force AssemblyAI to return + finals as fast as possible so Pipecat's smart turn analyzer can decide + when the user is done speaking. VAD stop is the absolute ceiling. + + u3-rt-pro: + - min_turn_silence defaults to 100ms (user can override) + - max_turn_silence is ALWAYS set equal to min_turn_silence + to avoid double turn detection (AssemblyAI + Pipecat both analyzing) + - If user sets max_turn_silence, it's ignored with a warning + - end_of_turn_confidence_threshold: not set (API default) + + universal-streaming-*: + - end_of_turn_confidence_threshold=0.0 (disable semantic turn detection) + - min_turn_silence=160 + - max_turn_silence: not set (API default) Args: connection_params: The user-provided connection parameters. + is_u3_pro: Whether using u3-rt-pro model. Returns: - Updated connection parameters configured for manual turn mode. + Updated connection parameters configured for Pipecat turn mode. """ updates = {} - # Check end_of_turn_confidence_threshold - if connection_params.end_of_turn_confidence_threshold is None: - updates["end_of_turn_confidence_threshold"] = 1.0 - elif connection_params.end_of_turn_confidence_threshold != 1.0: - logger.warning( - f"vad_force_turn_endpoint is enabled but end_of_turn_confidence_threshold " - f"is set to {connection_params.end_of_turn_confidence_threshold}. " - f"For manual turn detection mode, this should be 1.0 to disable " - f"model-based turn detection. The current value will be used." - ) + if is_u3_pro: + # u3-rt-pro: Synchronize max_turn_silence with min_turn_silence + min_silence = connection_params.min_turn_silence + if min_silence is None: + min_silence = 100 - # Check max_turn_silence - if connection_params.max_turn_silence is None: - updates["max_turn_silence"] = 2000 - elif connection_params.max_turn_silence < 1000: - logger.warning( - f"vad_force_turn_endpoint is enabled but max_turn_silence is set to " - f"{connection_params.max_turn_silence}ms. With manual turn detection, " - f"a higher value (e.g., 2000ms) is recommended to avoid premature " - f"turn endings. The current value will be used." - ) + # Warn if user set max_turn_silence (will be overridden) + if connection_params.max_turn_silence is not None: + logger.warning( + f"Your max_turn_silence value ({connection_params.max_turn_silence}ms) will be " + f"OVERRIDDEN in Pipecat mode (vad_force_turn_endpoint=True). It will be set to " + f"{min_silence}ms (matching min_turn_silence) and SENT to " + f"AssemblyAI to avoid double turn detection. To use your max_turn_silence as-is, " + f"switch to AssemblyAI turn detection mode (vad_force_turn_endpoint=False)." + ) + + updates = { + "min_turn_silence": min_silence, + "max_turn_silence": min_silence, + } + else: + # universal-streaming: Different configuration (works differently) + updates = { + "end_of_turn_confidence_threshold": 1.0, + "min_turn_silence": 160, + } # Apply updates if any if updates: @@ -190,9 +286,14 @@ class AssemblyAISTTService(WebsocketSTTService): return True async def _update_settings(self, delta: STTSettings) -> dict[str, Any]: - """Apply a settings delta. + """Apply a settings delta and send UpdateConfiguration if connected. - Settings are stored but not applied to the active connection. + Stores settings changes and sends UpdateConfiguration message to AssemblyAI + without reconnecting. Supports updating: + - keyterms_prompt: List of terms to boost (can be empty array to clear) + - prompt: Custom prompt text (u3-rt-pro only) + - max_turn_silence: Maximum silence before forcing turn end + - min_turn_silence: Silence before EOT check Args: delta: A :class:`STTSettings` (or ``AssemblyAISTTSettings``) delta. @@ -205,18 +306,72 @@ class AssemblyAISTTService(WebsocketSTTService): if not changed: return changed - # TODO: someday we could reconnect here to apply updated settings. - # Code might look something like the below: - # # Re-apply manual turn mode config if vad_force_turn_endpoint is active - # # and connection_params were updated. - # if self._vad_force_turn_endpoint and "connection_params" in changed: - # self._settings.connection_params = self._configure_manual_turn_mode( - # self._settings.connection_params - # ) - # await self._disconnect() - # await self._connect() + # If websocket is connected, send UpdateConfiguration for supported params + if ( + self._websocket + and self._websocket.state is State.OPEN + and "connection_params" in changed + ): + # Build UpdateConfiguration message + update_config = {"type": "UpdateConfiguration"} + conn_params = self._settings.connection_params - self._warn_unhandled_updated_settings(changed) + # Get the old connection_params to see what changed + old_conn_params = changed.get("connection_params") + + # Check each potentially changed parameter + if ( + old_conn_params is None + or conn_params.keyterms_prompt != old_conn_params.keyterms_prompt + ): + if conn_params.keyterms_prompt is not None: + update_config["keyterms_prompt"] = conn_params.keyterms_prompt + logger.info(f"Updating keyterms_prompt to: {conn_params.keyterms_prompt}") + + if old_conn_params is None or conn_params.prompt != old_conn_params.prompt: + if conn_params.prompt is not None: + if conn_params.speech_model != "u3-rt-pro": + logger.warning( + f"prompt parameter is only supported with u3-rt-pro model, " + f"current model is {conn_params.speech_model}" + ) + else: + update_config["prompt"] = conn_params.prompt + logger.info(f"Updating prompt") + + if ( + old_conn_params is None + or conn_params.max_turn_silence != old_conn_params.max_turn_silence + ): + if conn_params.max_turn_silence is not None: + update_config["max_turn_silence"] = conn_params.max_turn_silence + logger.info(f"Updating max_turn_silence to: {conn_params.max_turn_silence}ms") + + if ( + old_conn_params is None + or conn_params.min_turn_silence != old_conn_params.min_turn_silence + ): + if conn_params.min_turn_silence is not None: + update_config["min_turn_silence"] = conn_params.min_turn_silence + logger.info(f"Updating min_turn_silence to: {conn_params.min_turn_silence}ms") + + # Send update if we have parameters to update + if len(update_config) > 1: # More than just "type" + try: + await self._websocket.send(json.dumps(update_config)) + logger.info(f"Sent UpdateConfiguration: {update_config}") + except Exception as e: + logger.error(f"Failed to send UpdateConfiguration: {e}") + elif "connection_params" in changed: + logger.warning( + "Connection params changed but WebSocket not connected. " + "Settings will be applied on next connection." + ) + + # Warn about other settings that can't be changed dynamically + other_changes = {k: v for k, v in changed.items() if k not in ["connection_params"]} + if other_changes: + self._warn_unhandled_updated_settings(other_changes) return changed @@ -283,6 +438,7 @@ class AssemblyAISTTService(WebsocketSTTService): and self._websocket and self._websocket.state is State.OPEN ): + self.request_finalize() await self._websocket.send(json.dumps({"type": "ForceEndpoint"})) await self.start_processing_metrics() @@ -295,6 +451,9 @@ class AssemblyAISTTService(WebsocketSTTService): """Build WebSocket URL with query parameters using urllib.parse.urlencode.""" params = {} for k, v in self._settings.connection_params.model_dump().items(): + # Skip deprecated parameter - it's been migrated to min_turn_silence + if k == "min_end_of_turn_silence_when_confident": + continue if v is not None: if k == "keyterms_prompt": params[k] = json.dumps(v) @@ -421,6 +580,9 @@ class AssemblyAISTTService(WebsocketSTTService): async for message in self._get_websocket(): try: data = json.loads(message) + # Log raw JSON for Turn messages to debug speaker_label + if data.get("type") == "Turn": + logger.trace(f"{self} RAW JSON from AssemblyAI: {json.dumps(data, indent=2)}") await self._handle_message(data) except json.JSONDecodeError: logger.warning(f"Received non-JSON message: {message}") @@ -433,6 +595,8 @@ class AssemblyAISTTService(WebsocketSTTService): return BeginMessage.model_validate(message) elif msg_type == "Turn": return TurnMessage.model_validate(message) + elif msg_type == "SpeechStarted": + return SpeechStartedMessage.model_validate(message) elif msg_type == "Termination": return TerminationMessage.model_validate(message) else: @@ -449,11 +613,33 @@ class AssemblyAISTTService(WebsocketSTTService): ) elif isinstance(parsed_message, TurnMessage): await self._handle_transcription(parsed_message) + elif isinstance(parsed_message, SpeechStartedMessage): + await self._handle_speech_started(parsed_message) elif isinstance(parsed_message, TerminationMessage): await self._handle_termination(parsed_message) except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) + async def _handle_speech_started(self, message: SpeechStartedMessage): + """Handle SpeechStarted event — fast barge-in for AssemblyAI turn detection. + + Broadcasts UserStartedSpeakingFrame to signal the start of user + speech, then pushes an interruption to cancel any bot audio. + SpeechStarted fires before any transcript arrives, so the turn + is cleanly started before any transcription frames are pushed. + + Only applies when using AssemblyAI's built-in turn detection. When using + Pipecat turn detection, VAD + smart turn analyzer handle interruptions. + """ + if self._vad_force_turn_endpoint: + return # Pipecat mode: handled by aggregator + + await self.start_processing_metrics() + await self.broadcast_frame(UserStartedSpeakingFrame) + if self._should_interrupt: + await self.push_interruption_task_frame_and_wait() + self._user_speaking = True + async def _handle_termination(self, message: TerminationMessage): """Handle termination message.""" self._received_termination = True @@ -466,30 +652,109 @@ class AssemblyAISTTService(WebsocketSTTService): await self.push_frame(EndFrame()) async def _handle_transcription(self, message: TurnMessage): - """Handle transcription results.""" + """Handle transcription results with two turn detection modes. + + Pipecat turn detection (vad_force_turn_endpoint=True): + - No UserStarted/StoppedSpeakingFrame from STT + - end_of_turn → TranscriptionFrame (finalized set by base class + if this is a ForceEndpoint response) + - else → InterimTranscriptionFrame + + AssemblyAI turn detection (vad_force_turn_endpoint=False): + - UserStartedSpeakingFrame on first transcript + - end_of_turn → TranscriptionFrame + UserStoppedSpeakingFrame + - else → InterimTranscriptionFrame + """ if not message.transcript: return - if message.end_of_turn and ( - not self._settings.connection_params.formatted_finals or message.turn_is_formatted - ): - await self.push_frame( - TranscriptionFrame( - message.transcript, - self._user_id, - time_now_iso8601(), - self._settings.language, - message, + + # Use detected language if available with sufficient confidence + language = Language.EN + if message.language_code and message.language_confidence: + if message.language_confidence >= 0.7: + language = map_language_from_assemblyai(message.language_code) + else: + logger.warning( + f"Low language detection confidence ({message.language_confidence:.2f}) " + f"for language '{message.language_code}', falling back to English" + ) + + # Handle speaker diarization + speaker_id = self._user_id + transcript_text = message.transcript + + if message.speaker: + speaker_id = message.speaker + # Format transcript with speaker labels if format string provided + if self._speaker_format: + transcript_text = self._speaker_format.format( + speaker=message.speaker, text=message.transcript + ) + + # Determine if this is a final turn from AssemblyAI + is_final_turn = message.end_of_turn and ( + not self._settings.connection_params.format_turns or message.turn_is_formatted + ) + + if self._vad_force_turn_endpoint: + # --- Pipecat turn detection mode --- + # No UserStarted/StoppedSpeakingFrame — VAD + smart turn analyzer handle this + if is_final_turn: + finalize_confirmed = bool(message.turn_is_formatted) + if finalize_confirmed: + self.confirm_finalize() + logger.debug(f'{self} Transcript: "{transcript_text}"') + await self.push_frame( + TranscriptionFrame( + transcript_text, + speaker_id, + time_now_iso8601(), + language, + message, + ) + ) + await self._trace_transcription(transcript_text, True, language) + await self.stop_processing_metrics() + else: + await self.push_frame( + InterimTranscriptionFrame( + transcript_text, + speaker_id, + time_now_iso8601(), + language, + message, + ) ) - ) - await self._trace_transcription(message.transcript, True, self._settings.language) - await self.stop_processing_metrics() else: - await self.push_frame( - InterimTranscriptionFrame( - message.transcript, - self._user_id, - time_now_iso8601(), - self._settings.language, - message, + # --- AssemblyAI turn detection mode --- + # SpeechStarted always arrives before transcripts with u3-rt-pro, + # so UserStartedSpeakingFrame is guaranteed to be broadcast first. + if is_final_turn: + # AssemblyAI controls finalization, just mark as finalized + await self.push_frame( + TranscriptionFrame( + transcript_text, + speaker_id, + time_now_iso8601(), + language, + message, + finalized=True, + ) + ) + await self._trace_transcription(transcript_text, True, language) + await self.stop_processing_metrics() + # AAI is authoritative — emit UserStoppedSpeakingFrame immediately. + # broadcast_frame pushes downstream (same queue as TranscriptionFrame + # above, so ordering is preserved) and upstream. + await self.broadcast_frame(UserStoppedSpeakingFrame) + self._user_speaking = False + else: + await self.push_frame( + InterimTranscriptionFrame( + transcript_text, + speaker_id, + time_now_iso8601(), + language, + message, + ) ) - ) diff --git a/src/pipecat/services/azure/stt.py b/src/pipecat/services/azure/stt.py index c6cb96d2e..5533e350e 100644 --- a/src/pipecat/services/azure/stt.py +++ b/src/pipecat/services/azure/stt.py @@ -35,6 +35,7 @@ from pipecat.utils.tracing.service_decorators import traced_stt try: from azure.cognitiveservices.speech import ( + CancellationReason, ResultReason, SpeechConfig, SpeechRecognizer, @@ -209,6 +210,7 @@ class AzureSTTService(STTService): ) self._speech_recognizer.recognizing.connect(self._on_handle_recognizing) self._speech_recognizer.recognized.connect(self._on_handle_recognized) + self._speech_recognizer.canceled.connect(self._on_handle_canceled) self._speech_recognizer.start_continuous_recognition_async() except Exception as e: await self.push_error( @@ -280,3 +282,13 @@ class AzureSTTService(STTService): result=event, ) asyncio.run_coroutine_threadsafe(self.push_frame(frame), self.get_event_loop()) + + def _on_handle_canceled(self, event): + details = event.result.cancellation_details + if details.reason == CancellationReason.Error: + error_msg = f"Azure STT recognition canceled: {details.reason}" + if details.error_details: + error_msg += f" - {details.error_details}" + asyncio.run_coroutine_threadsafe( + self.push_error(error_msg=error_msg), self.get_event_loop() + ) diff --git a/src/pipecat/services/azure/tts.py b/src/pipecat/services/azure/tts.py index f68694eb5..6e62c73bf 100644 --- a/src/pipecat/services/azure/tts.py +++ b/src/pipecat/services/azure/tts.py @@ -561,9 +561,13 @@ class AzureTTSService(TTSService, AzureBaseTTSService): # User cancellation (from interruption) is expected, not an error if reason == CancellationReason.CancelledByUser: logger.debug(f"{self}: Speech synthesis canceled by user (interruption)") + self._audio_queue.put_nowait(None) else: - logger.warning(f"{self}: Speech synthesis canceled: {reason}") - self._audio_queue.put_nowait(None) + details = evt.result.cancellation_details + error_msg = f"Azure TTS synthesis canceled: {reason}" + if details.error_details: + error_msg += f" - {details.error_details}" + self._audio_queue.put_nowait(Exception(error_msg)) async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): """Push a frame and handle state changes. @@ -676,6 +680,9 @@ class AzureTTSService(TTSService, AzureBaseTTSService): chunk = await self._audio_queue.get() if chunk is None: # End of stream break + if isinstance(chunk, Exception): # Error from _handle_canceled + yield ErrorFrame(error=str(chunk)) + break if self._first_chunk: await self.stop_ttfb_metrics() diff --git a/src/pipecat/services/deepgram/__init__.py b/src/pipecat/services/deepgram/__init__.py index 4e1db3886..f67271abc 100644 --- a/src/pipecat/services/deepgram/__init__.py +++ b/src/pipecat/services/deepgram/__init__.py @@ -9,6 +9,7 @@ import sys from pipecat.services import DeprecatedModuleProxy from .flux import * +from .sagemaker import * from .stt import * from .tts import * diff --git a/src/pipecat/services/deepgram/flux/stt.py b/src/pipecat/services/deepgram/flux/stt.py index d509b267e..984906c6c 100644 --- a/src/pipecat/services/deepgram/flux/stt.py +++ b/src/pipecat/services/deepgram/flux/stt.py @@ -675,7 +675,7 @@ class DeepgramFluxSTTService(WebsocketSTTService): self._user_is_speaking = True await self.broadcast_frame(UserStartedSpeakingFrame) if self._should_interrupt: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() await self.start_metrics() await self._call_event_handler("on_start_of_turn", transcript) if transcript: diff --git a/src/pipecat/services/deepgram/sagemaker/stt.py b/src/pipecat/services/deepgram/sagemaker/stt.py new file mode 100644 index 000000000..ba4b7dfda --- /dev/null +++ b/src/pipecat/services/deepgram/sagemaker/stt.py @@ -0,0 +1,448 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Deepgram speech-to-text service for AWS SageMaker. + +This module provides a Pipecat STT service that connects to Deepgram models +deployed on AWS SageMaker endpoints. Uses HTTP/2 bidirectional streaming for +low-latency real-time transcription with support for interim results, multiple +languages, and various Deepgram features. +""" + +import asyncio +import json +from dataclasses import dataclass +from typing import Any, AsyncGenerator, Dict, Optional + +from loguru import logger + +from pipecat.frames.frames import ( + CancelFrame, + EndFrame, + ErrorFrame, + Frame, + InterimTranscriptionFrame, + StartFrame, + TranscriptionFrame, + VADUserStartedSpeakingFrame, + VADUserStoppedSpeakingFrame, +) +from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.aws.sagemaker.bidi_client import SageMakerBidiClient +from pipecat.services.deepgram.stt import _DeepgramSTTSettingsBase +from pipecat.services.settings import STTSettings +from pipecat.services.stt_latency import DEEPGRAM_SAGEMAKER_TTFS_P99 +from pipecat.services.stt_service import STTService +from pipecat.transcriptions.language import Language +from pipecat.utils.time import time_now_iso8601 +from pipecat.utils.tracing.service_decorators import traced_stt + +try: + from deepgram import LiveOptions +except ModuleNotFoundError as e: + logger.error(f"Exception: {e}") + logger.error( + "In order to use DeepgramSageMakerSTTService, you need to `pip install pipecat-ai[deepgram,sagemaker]`." + ) + raise Exception(f"Missing module: {e}") + + +@dataclass +class DeepgramSageMakerSTTSettings(_DeepgramSTTSettingsBase): + """Settings for the Deepgram SageMaker STT service. + + See ``_DeepgramSTTSettingsBase`` for full documentation. + """ + + pass + + +class DeepgramSageMakerSTTService(STTService): + """Deepgram speech-to-text service for AWS SageMaker. + + Provides real-time speech recognition using Deepgram models deployed on + AWS SageMaker endpoints. Uses HTTP/2 bidirectional streaming for low-latency + transcription with support for interim results, speaker diarization, and + multiple languages. + + Requirements: + + - AWS credentials configured (via environment variables, AWS CLI, or instance metadata) + - A deployed SageMaker endpoint with Deepgram model: https://developers.deepgram.com/docs/deploy-amazon-sagemaker + - Deepgram SDK for LiveOptions configuration + + Example:: + + stt = DeepgramSageMakerSTTService( + endpoint_name="my-deepgram-endpoint", + region="us-east-2", + live_options=LiveOptions( + model="nova-3", + language="en", + interim_results=True, + punctuate=True, + ), + ) + """ + + _settings: DeepgramSageMakerSTTSettings + + def __init__( + self, + *, + endpoint_name: str, + region: str, + sample_rate: Optional[int] = None, + live_options: Optional[LiveOptions] = None, + ttfs_p99_latency: Optional[float] = DEEPGRAM_SAGEMAKER_TTFS_P99, + **kwargs, + ): + """Initialize the Deepgram SageMaker STT service. + + Args: + endpoint_name: Name of the SageMaker endpoint with Deepgram model + deployed (e.g., "my-deepgram-nova-3-endpoint"). + region: AWS region where the endpoint is deployed (e.g., "us-east-2"). + sample_rate: Audio sample rate in Hz. If None, uses value from + live_options or defaults to the value from StartFrame. + live_options: Deepgram LiveOptions configuration. Treated as a + delta from a set of sensible defaults — only the fields you + set are overridden; all others keep their default values. + 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 + **kwargs: Additional arguments passed to the parent STTService. + """ + sample_rate = sample_rate or (live_options.sample_rate if live_options else None) + + default_options = LiveOptions( + encoding="linear16", + language=Language.EN, + model="nova-3", + channels=1, + interim_results=True, + punctuate=True, + ) + + settings = DeepgramSageMakerSTTSettings( + model=default_options.model, + language=default_options.language, + live_options=default_options, + ) + if live_options: + settings._merge_live_options_delta(live_options) + + super().__init__( + sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + settings=settings, + **kwargs, + ) + + self._endpoint_name = endpoint_name + self._region = region + + self._client: Optional[SageMakerBidiClient] = None + self._response_task: Optional[asyncio.Task] = None + self._keepalive_task: Optional[asyncio.Task] = None + + def can_generate_metrics(self) -> bool: + """Check if this service can generate processing metrics. + + Returns: + True, as Deepgram SageMaker service supports metrics generation. + """ + return True + + async def _update_settings(self, delta: STTSettings) -> dict[str, Any]: + """Apply a settings delta and warn about unhandled changes.""" + changed = await super()._update_settings(delta) + + if not changed: + return changed + + # TODO: someday we could reconnect here to apply updated settings. + # Code might look something like the below: + # await self._disconnect() + # await self._connect() + + self._warn_unhandled_updated_settings(changed) + + return changed + + async def start(self, frame: StartFrame): + """Start the Deepgram SageMaker STT 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 STT service. + + Args: + frame: The end frame. + """ + await super().stop(frame) + await self._disconnect() + + async def cancel(self, frame: CancelFrame): + """Cancel the Deepgram SageMaker STT service. + + Args: + frame: The cancel frame. + """ + await super().cancel(frame) + await self._disconnect() + + async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: + """Send audio data to Deepgram for transcription. + + Args: + audio: Raw audio bytes to transcribe. + + Yields: + Frame: None (transcription results come via BiDi stream callbacks). + """ + if self._client and self._client.is_active: + try: + await self._client.send_audio_chunk(audio) + except Exception as e: + yield ErrorFrame(error=f"Unknown error occurred: {e}") + yield None + + async def _connect(self): + """Connect to the SageMaker endpoint and start the BiDi session. + + Builds the Deepgram query string from settings, creates the BiDi client, + starts the streaming session, and launches background tasks for processing + responses and sending KeepAlive messages. + """ + logger.debug("Connecting to Deepgram on SageMaker...") + + live_options = LiveOptions( + **{**self._settings.live_options.to_dict(), "sample_rate": self.sample_rate} + ) + + # Build query string from live_options, converting booleans to strings + query_params = {} + for key, value in live_options.to_dict().items(): + if value is not None: + # Convert boolean values to lowercase strings for Deepgram API + if isinstance(value, bool): + query_params[key] = str(value).lower() + else: + query_params[key] = str(value) + + query_string = "&".join(f"{k}={v}" for k, v in query_params.items()) + + # Create BiDi client + self._client = SageMakerBidiClient( + endpoint_name=self._endpoint_name, + region=self._region, + model_invocation_path="v1/listen", + model_query_string=query_string, + ) + + try: + # Start the session + await self._client.start_session() + + # Start processing responses in the background + self._response_task = self.create_task(self._process_responses()) + + # Start keepalive task to maintain connection + self._keepalive_task = self.create_task(self._send_keepalive()) + + logger.debug("Connected to Deepgram 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 CloseStream message to Deepgram, cancels background tasks + (KeepAlive and response processing), and closes the BiDi session. + Safe to call multiple times. + """ + if self._client and self._client.is_active: + logger.debug("Disconnecting from Deepgram on SageMaker...") + + # Send CloseStream message to Deepgram + try: + await self._client.send_json({"type": "CloseStream"}) + except Exception as e: + logger.warning(f"Failed to send CloseStream message: {e}") + + # Cancel keepalive task + if self._keepalive_task and not self._keepalive_task.done(): + await self.cancel_task(self._keepalive_task) + + # Cancel response processing task + if self._response_task and not self._response_task.done(): + await self.cancel_task(self._response_task) + + # Close the BiDi session + await self._client.close_session() + + logger.debug("Disconnected from Deepgram on SageMaker") + await self._call_event_handler("on_disconnected") + + async def _send_keepalive(self): + """Send periodic KeepAlive messages to maintain the connection. + + Sends a KeepAlive JSON message to Deepgram every 5 seconds while the + connection is active. This prevents the connection from timing out during + periods of silence. + """ + while self._client and self._client.is_active: + await asyncio.sleep(5) + if self._client and self._client.is_active: + try: + await self._client.send_json({"type": "KeepAlive"}) + except Exception as e: + logger.warning(f"Failed to send KeepAlive: {e}") + + async def _process_responses(self): + """Process streaming responses from Deepgram on SageMaker. + + Continuously receives responses from the BiDi stream, decodes the payload, + parses JSON responses from Deepgram, and processes transcription results. + Runs as a background task until the connection is closed or cancelled. + """ + try: + while self._client and self._client.is_active: + result = await self._client.receive_response() + + if result is None: + break + + # Check if this is a PayloadPart with bytes + if hasattr(result, "value") and hasattr(result.value, "bytes_"): + if result.value.bytes_: + response_data = result.value.bytes_.decode("utf-8") + + try: + # Parse JSON response from Deepgram + parsed = json.loads(response_data) + + # Extract and process transcript if available + if "channel" in parsed: + await self._handle_transcript_response(parsed) + + except json.JSONDecodeError: + logger.warning(f"Non-JSON response: {response_data}") + + except asyncio.CancelledError: + logger.debug("Response processor cancelled") + except Exception as e: + await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) + finally: + logger.debug("Response processor stopped") + + async def _handle_transcript_response(self, parsed: dict): + """Handle a transcript response from Deepgram. + + Extracts the transcript text, determines if it's final or interim, extracts + language information, and pushes the appropriate frame (TranscriptionFrame + or InterimTranscriptionFrame) downstream. + + Args: + parsed: The parsed JSON response from Deepgram containing channel, + alternatives, transcript, and metadata. + """ + alternatives = parsed.get("channel", {}).get("alternatives", []) + if not alternatives or not alternatives[0].get("transcript"): + return + + transcript = alternatives[0]["transcript"] + if not transcript.strip(): + return + + is_final = parsed.get("is_final", False) + + # Extract language if available + language = None + if alternatives[0].get("languages"): + language = alternatives[0]["languages"][0] + language = Language(language) + + 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, + self._user_id, + time_now_iso8601(), + language, + result=parsed, + ) + ) + await self._handle_transcription(transcript, is_final, language) + await self.stop_processing_metrics() + else: + # Interim transcription + await self.push_frame( + InterimTranscriptionFrame( + transcript, + self._user_id, + time_now_iso8601(), + language, + result=parsed, + ) + ) + + @traced_stt + async def _handle_transcription( + self, transcript: str, is_final: bool, language: Optional[Language] = None + ): + """Handle a transcription result with tracing. + + This method is decorated with @traced_stt for observability and tracing + integration. The actual transcription processing is handled by the parent + class and observers. + + Args: + transcript: The transcribed text. + is_final: Whether this is a final transcription result. + language: The detected language of the transcription, if available. + """ + pass + + async def _start_metrics(self): + """Start processing metrics collection.""" + await self.start_processing_metrics() + + async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames with Deepgram SageMaker-specific handling. + + Args: + frame: The frame to process. + direction: The direction of frame processing. + """ + await super().process_frame(frame, direction) + + # Start metrics when user starts speaking (if VAD is not provided by Deepgram) + if isinstance(frame, VADUserStartedSpeakingFrame): + await self._start_metrics() + elif isinstance(frame, VADUserStoppedSpeakingFrame): + # 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=}") diff --git a/src/pipecat/services/deepgram/sagemaker/tts.py b/src/pipecat/services/deepgram/sagemaker/tts.py new file mode 100644 index 000000000..b583ce76c --- /dev/null +++ b/src/pipecat/services/deepgram/sagemaker/tts.py @@ -0,0 +1,360 @@ +# +# 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 dataclasses import dataclass, field +from typing import Any, 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.settings import NOT_GIVEN, TTSSettings, _NotGiven +from pipecat.services.tts_service import TTSService +from pipecat.utils.tracing.service_decorators import traced_tts + + +@dataclass +class DeepgramSageMakerTTSSettings(TTSSettings): + """Settings for Deepgram SageMaker TTS service. + + Parameters: + encoding: Audio encoding format (e.g. "linear16"). + """ + + encoding: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + +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", + ) + """ + + _settings: DeepgramSageMakerTTSSettings + + 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, + settings=DeepgramSageMakerTTSSettings( + model=voice, + voice=voice, + language=None, + encoding=encoding, + ), + **kwargs, + ) + + self._endpoint_name = endpoint_name + self._region = region + + 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._settings.voice}&encoding={self._settings.encoding}" + f"&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 _update_settings(self, delta: TTSSettings) -> dict[str, Any]: + """Apply a settings delta and reconnect if necessary. + + Since all settings are part of the SageMaker session query string, + any setting change requires reconnecting to apply the new values. + """ + changed = await super()._update_settings(delta) + + if not changed: + return changed + + # Deepgram uses voice as the model, so keep them in sync for metrics + if "voice" in changed: + self._settings.model = self._settings.voice + self._sync_model_name_to_metrics() + + # TODO: someday we could reconnect here to apply updated settings. + # Code might look something like the below: + # await self._disconnect() + # await self._connect() + + self._warn_unhandled_updated_settings(changed) + + return changed + + 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}") diff --git a/src/pipecat/services/deepgram/stt.py b/src/pipecat/services/deepgram/stt.py index e8c666d8d..343a87e2a 100644 --- a/src/pipecat/services/deepgram/stt.py +++ b/src/pipecat/services/deepgram/stt.py @@ -558,7 +558,7 @@ class DeepgramSTTService(STTService): await self._call_event_handler("on_speech_started", message) await self.broadcast_frame(UserStartedSpeakingFrame) if self._should_interrupt: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() async def _on_utterance_end(self, message): await self._call_event_handler("on_utterance_end", message) diff --git a/src/pipecat/services/deepgram/stt_sagemaker.py b/src/pipecat/services/deepgram/stt_sagemaker.py index 0b72b98ab..08cd0c5d3 100644 --- a/src/pipecat/services/deepgram/stt_sagemaker.py +++ b/src/pipecat/services/deepgram/stt_sagemaker.py @@ -4,444 +4,15 @@ # SPDX-License-Identifier: BSD 2-Clause License # -"""Deepgram speech-to-text service for AWS SageMaker. +"""Deprecated: use ``pipecat.services.deepgram.sagemaker.stt`` instead.""" -This module provides a Pipecat STT service that connects to Deepgram models -deployed on AWS SageMaker endpoints. Uses HTTP/2 bidirectional streaming for -low-latency real-time transcription with support for interim results, multiple -languages, and various Deepgram features. -""" +import warnings -import asyncio -import json -from dataclasses import dataclass -from typing import Any, AsyncGenerator, Dict, Optional - -from loguru import logger - -from pipecat.frames.frames import ( - CancelFrame, - EndFrame, - ErrorFrame, - Frame, - InterimTranscriptionFrame, - StartFrame, - TranscriptionFrame, - VADUserStartedSpeakingFrame, - VADUserStoppedSpeakingFrame, +warnings.warn( + "Module `pipecat.services.deepgram.stt_sagemaker` is deprecated, " + "use `pipecat.services.deepgram.sagemaker.stt` instead.", + DeprecationWarning, + stacklevel=2, ) -from pipecat.processors.frame_processor import FrameDirection -from pipecat.services.aws.sagemaker.bidi_client import SageMakerBidiClient -from pipecat.services.deepgram.stt import DeepgramSTTSettings -from pipecat.services.settings import STTSettings -from pipecat.services.stt_latency import DEEPGRAM_SAGEMAKER_TTFS_P99 -from pipecat.services.stt_service import STTService -from pipecat.transcriptions.language import Language -from pipecat.utils.time import time_now_iso8601 -from pipecat.utils.tracing.service_decorators import traced_stt -try: - from pipecat.services.deepgram.stt import LiveOptions -except ModuleNotFoundError as e: - logger.error(f"Exception: {e}") - logger.error( - "In order to use DeepgramSageMakerSTTService, you need to `pip install pipecat-ai[deepgram,sagemaker]`." - ) - raise Exception(f"Missing module: {e}") - - -@dataclass -class DeepgramSageMakerSTTSettings(DeepgramSTTSettings): - """Settings for the Deepgram SageMaker STT service. - - See ``DeepgramSTTSettings`` for full documentation. - """ - - pass - - -class DeepgramSageMakerSTTService(STTService): - """Deepgram speech-to-text service for AWS SageMaker. - - Provides real-time speech recognition using Deepgram models deployed on - AWS SageMaker endpoints. Uses HTTP/2 bidirectional streaming for low-latency - transcription with support for interim results, speaker diarization, and - multiple languages. - - Requirements: - - - AWS credentials configured (via environment variables, AWS CLI, or instance metadata) - - A deployed SageMaker endpoint with Deepgram model: https://developers.deepgram.com/docs/deploy-amazon-sagemaker - - Deepgram SDK for LiveOptions configuration - - Example:: - - stt = DeepgramSageMakerSTTService( - endpoint_name="my-deepgram-endpoint", - region="us-east-2", - live_options=LiveOptions( - model="nova-3", - language="en", - interim_results=True, - punctuate=True, - ), - ) - """ - - _settings: DeepgramSageMakerSTTSettings - - def __init__( - self, - *, - endpoint_name: str, - region: str, - sample_rate: Optional[int] = None, - live_options: Optional[LiveOptions] = None, - ttfs_p99_latency: Optional[float] = DEEPGRAM_SAGEMAKER_TTFS_P99, - **kwargs, - ): - """Initialize the Deepgram SageMaker STT service. - - Args: - endpoint_name: Name of the SageMaker endpoint with Deepgram model - deployed (e.g., "my-deepgram-nova-3-endpoint"). - region: AWS region where the endpoint is deployed (e.g., "us-east-2"). - sample_rate: Audio sample rate in Hz. If None, uses value from - live_options or defaults to the value from StartFrame. - live_options: Deepgram LiveOptions configuration. Treated as a - delta from a set of sensible defaults — only the fields you - set are overridden; all others keep their default values. - 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 - **kwargs: Additional arguments passed to the parent STTService. - """ - sample_rate = sample_rate or (live_options.sample_rate if live_options else None) - - settings = DeepgramSageMakerSTTSettings( - model="nova-3", - language=Language.EN, - encoding="linear16", - channels=1, - interim_results=True, - punctuate=True, - ) - - if live_options: - lo_dict = live_options.to_dict() - delta = DeepgramSageMakerSTTSettings.from_mapping( - {k: v for k, v in lo_dict.items() if k != "sample_rate"} - ) - settings.apply_update(delta) - - super().__init__( - sample_rate=sample_rate, - ttfs_p99_latency=ttfs_p99_latency, - settings=settings, - **kwargs, - ) - - self._endpoint_name = endpoint_name - self._region = region - - self._client: Optional[SageMakerBidiClient] = None - self._response_task: Optional[asyncio.Task] = None - self._keepalive_task: Optional[asyncio.Task] = None - - def can_generate_metrics(self) -> bool: - """Check if this service can generate processing metrics. - - Returns: - True, as Deepgram SageMaker service supports metrics generation. - """ - return True - - async def _update_settings(self, delta: STTSettings) -> dict[str, Any]: - """Apply a settings delta and warn about unhandled changes.""" - changed = await super()._update_settings(delta) - - if not changed: - return changed - - # TODO: someday we could reconnect here to apply updated settings. - # Code might look something like the below: - # await self._disconnect() - # await self._connect() - - self._warn_unhandled_updated_settings(changed) - - return changed - - async def start(self, frame: StartFrame): - """Start the Deepgram SageMaker STT 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 STT service. - - Args: - frame: The end frame. - """ - await super().stop(frame) - await self._disconnect() - - async def cancel(self, frame: CancelFrame): - """Cancel the Deepgram SageMaker STT service. - - Args: - frame: The cancel frame. - """ - await super().cancel(frame) - await self._disconnect() - - async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: - """Send audio data to Deepgram for transcription. - - Args: - audio: Raw audio bytes to transcribe. - - Yields: - Frame: None (transcription results come via BiDi stream callbacks). - """ - if self._client and self._client.is_active: - try: - await self._client.send_audio_chunk(audio) - except Exception as e: - yield ErrorFrame(error=f"Unknown error occurred: {e}") - yield None - - async def _connect(self): - """Connect to the SageMaker endpoint and start the BiDi session. - - Builds the Deepgram query string from settings, creates the BiDi client, - starts the streaming session, and launches background tasks for processing - responses and sending KeepAlive messages. - """ - logger.debug("Connecting to Deepgram on SageMaker...") - - # Reconstruct a LiveOptions from the flat settings to build the query string. - live_options = LiveOptions(**self._settings.given_fields()) - - # Build query string from live_options, converting booleans to strings - query_params = {} - for key, value in live_options.to_dict().items(): - if value is not None: - # Convert boolean values to lowercase strings for Deepgram API - if isinstance(value, bool): - query_params[key] = str(value).lower() - else: - query_params[key] = str(value) - query_params["sample_rate"] = str(self.sample_rate) - - query_string = "&".join(f"{k}={v}" for k, v in query_params.items()) - - # Create BiDi client - self._client = SageMakerBidiClient( - endpoint_name=self._endpoint_name, - region=self._region, - model_invocation_path="v1/listen", - model_query_string=query_string, - ) - - try: - # Start the session - await self._client.start_session() - - # Start processing responses in the background - self._response_task = self.create_task(self._process_responses()) - - # Start keepalive task to maintain connection - self._keepalive_task = self.create_task(self._send_keepalive()) - - logger.debug("Connected to Deepgram 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 CloseStream message to Deepgram, cancels background tasks - (KeepAlive and response processing), and closes the BiDi session. - Safe to call multiple times. - """ - if self._client and self._client.is_active: - logger.debug("Disconnecting from Deepgram on SageMaker...") - - # Send CloseStream message to Deepgram - try: - await self._client.send_json({"type": "CloseStream"}) - except Exception as e: - logger.warning(f"Failed to send CloseStream message: {e}") - - # Cancel keepalive task - if self._keepalive_task and not self._keepalive_task.done(): - await self.cancel_task(self._keepalive_task) - - # Cancel response processing task - if self._response_task and not self._response_task.done(): - await self.cancel_task(self._response_task) - - # Close the BiDi session - await self._client.close_session() - - logger.debug("Disconnected from Deepgram on SageMaker") - await self._call_event_handler("on_disconnected") - - async def _send_keepalive(self): - """Send periodic KeepAlive messages to maintain the connection. - - Sends a KeepAlive JSON message to Deepgram every 5 seconds while the - connection is active. This prevents the connection from timing out during - periods of silence. - """ - while self._client and self._client.is_active: - await asyncio.sleep(5) - if self._client and self._client.is_active: - try: - await self._client.send_json({"type": "KeepAlive"}) - except Exception as e: - logger.warning(f"Failed to send KeepAlive: {e}") - - async def _process_responses(self): - """Process streaming responses from Deepgram on SageMaker. - - Continuously receives responses from the BiDi stream, decodes the payload, - parses JSON responses from Deepgram, and processes transcription results. - Runs as a background task until the connection is closed or cancelled. - """ - try: - while self._client and self._client.is_active: - result = await self._client.receive_response() - - if result is None: - break - - # Check if this is a PayloadPart with bytes - if hasattr(result, "value") and hasattr(result.value, "bytes_"): - if result.value.bytes_: - response_data = result.value.bytes_.decode("utf-8") - - try: - # Parse JSON response from Deepgram - parsed = json.loads(response_data) - - # Extract and process transcript if available - if "channel" in parsed: - await self._handle_transcript_response(parsed) - - except json.JSONDecodeError: - logger.warning(f"Non-JSON response: {response_data}") - - except asyncio.CancelledError: - logger.debug("Response processor cancelled") - except Exception as e: - await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) - finally: - logger.debug("Response processor stopped") - - async def _handle_transcript_response(self, parsed: dict): - """Handle a transcript response from Deepgram. - - Extracts the transcript text, determines if it's final or interim, extracts - language information, and pushes the appropriate frame (TranscriptionFrame - or InterimTranscriptionFrame) downstream. - - Args: - parsed: The parsed JSON response from Deepgram containing channel, - alternatives, transcript, and metadata. - """ - alternatives = parsed.get("channel", {}).get("alternatives", []) - if not alternatives or not alternatives[0].get("transcript"): - return - - transcript = alternatives[0]["transcript"] - if not transcript.strip(): - return - - is_final = parsed.get("is_final", False) - - # Extract language if available - language = None - if alternatives[0].get("languages"): - language = alternatives[0]["languages"][0] - language = Language(language) - - 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, - self._user_id, - time_now_iso8601(), - language, - result=parsed, - ) - ) - await self._handle_transcription(transcript, is_final, language) - await self.stop_processing_metrics() - else: - # Interim transcription - await self.push_frame( - InterimTranscriptionFrame( - transcript, - self._user_id, - time_now_iso8601(), - language, - result=parsed, - ) - ) - - @traced_stt - async def _handle_transcription( - self, transcript: str, is_final: bool, language: Optional[Language] = None - ): - """Handle a transcription result with tracing. - - This method is decorated with @traced_stt for observability and tracing - integration. The actual transcription processing is handled by the parent - class and observers. - - Args: - transcript: The transcribed text. - is_final: Whether this is a final transcription result. - language: The detected language of the transcription, if available. - """ - pass - - async def _start_metrics(self): - """Start processing metrics collection.""" - await self.start_processing_metrics() - - async def process_frame(self, frame: Frame, direction: FrameDirection): - """Process frames with Deepgram SageMaker-specific handling. - - Args: - frame: The frame to process. - direction: The direction of frame processing. - """ - await super().process_frame(frame, direction) - - # Start metrics when user starts speaking (if VAD is not provided by Deepgram) - if isinstance(frame, VADUserStartedSpeakingFrame): - await self._start_metrics() - elif isinstance(frame, VADUserStoppedSpeakingFrame): - # 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 pipecat.services.deepgram.sagemaker.stt import * # noqa: E402, F401, F403 diff --git a/src/pipecat/services/deepgram/tts_sagemaker.py b/src/pipecat/services/deepgram/tts_sagemaker.py index b583ce76c..61ca2bceb 100644 --- a/src/pipecat/services/deepgram/tts_sagemaker.py +++ b/src/pipecat/services/deepgram/tts_sagemaker.py @@ -4,357 +4,15 @@ # SPDX-License-Identifier: BSD 2-Clause License # -"""Deepgram text-to-speech service for AWS SageMaker. +"""Deprecated: use ``pipecat.services.deepgram.sagemaker.tts`` instead.""" -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 warnings -import asyncio -import json -from dataclasses import dataclass, field -from typing import Any, AsyncGenerator, Optional - -from loguru import logger - -from pipecat.frames.frames import ( - BotStoppedSpeakingFrame, - CancelFrame, - EndFrame, - ErrorFrame, - Frame, - InterruptionFrame, - LLMFullResponseEndFrame, - StartFrame, - TTSAudioRawFrame, - TTSStartedFrame, +warnings.warn( + "Module `pipecat.services.deepgram.tts_sagemaker` is deprecated, " + "use `pipecat.services.deepgram.sagemaker.tts` instead.", + DeprecationWarning, + stacklevel=2, ) -from pipecat.processors.frame_processor import FrameDirection -from pipecat.services.aws.sagemaker.bidi_client import SageMakerBidiClient -from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven -from pipecat.services.tts_service import TTSService -from pipecat.utils.tracing.service_decorators import traced_tts - -@dataclass -class DeepgramSageMakerTTSSettings(TTSSettings): - """Settings for Deepgram SageMaker TTS service. - - Parameters: - encoding: Audio encoding format (e.g. "linear16"). - """ - - encoding: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) - - -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", - ) - """ - - _settings: DeepgramSageMakerTTSSettings - - 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, - settings=DeepgramSageMakerTTSSettings( - model=voice, - voice=voice, - language=None, - encoding=encoding, - ), - **kwargs, - ) - - self._endpoint_name = endpoint_name - self._region = region - - 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._settings.voice}&encoding={self._settings.encoding}" - f"&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 _update_settings(self, delta: TTSSettings) -> dict[str, Any]: - """Apply a settings delta and reconnect if necessary. - - Since all settings are part of the SageMaker session query string, - any setting change requires reconnecting to apply the new values. - """ - changed = await super()._update_settings(delta) - - if not changed: - return changed - - # Deepgram uses voice as the model, so keep them in sync for metrics - if "voice" in changed: - self._settings.model = self._settings.voice - self._sync_model_name_to_metrics() - - # TODO: someday we could reconnect here to apply updated settings. - # Code might look something like the below: - # await self._disconnect() - # await self._connect() - - self._warn_unhandled_updated_settings(changed) - - return changed - - 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 pipecat.services.deepgram.sagemaker.tts import * # noqa: E402, F401, F403 diff --git a/src/pipecat/services/gladia/stt.py b/src/pipecat/services/gladia/stt.py index 045a56613..bba554b4a 100644 --- a/src/pipecat/services/gladia/stt.py +++ b/src/pipecat/services/gladia/stt.py @@ -613,7 +613,7 @@ class GladiaSTTService(WebsocketSTTService): await self.broadcast_frame(UserStartedSpeakingFrame) if self._should_interrupt: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() async def _on_speech_ended(self): """Handle speech end event from Gladia. diff --git a/src/pipecat/services/google/gemini_live/llm.py b/src/pipecat/services/google/gemini_live/llm.py index d06f941c7..2ed11c739 100644 --- a/src/pipecat/services/google/gemini_live/llm.py +++ b/src/pipecat/services/google/gemini_live/llm.py @@ -1265,7 +1265,7 @@ class GeminiLiveLLMService(LLMService): # combination with the context aggregator default # turn strategies. logger.debug("Gemini VAD: interrupted signal received") - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() elif message.server_content and message.server_content.model_turn: await self._handle_msg_model_turn(message) elif ( diff --git a/src/pipecat/services/grok/realtime/llm.py b/src/pipecat/services/grok/realtime/llm.py index 6d148f6d7..7a4e73806 100644 --- a/src/pipecat/services/grok/realtime/llm.py +++ b/src/pipecat/services/grok/realtime/llm.py @@ -734,7 +734,7 @@ class GrokRealtimeLLMService(LLMService): """Handle speech started event from VAD.""" await self._truncate_current_audio_response() await self.broadcast_frame(UserStartedSpeakingFrame) - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() async def _handle_evt_speech_stopped(self, evt): """Handle speech stopped event from VAD.""" diff --git a/src/pipecat/services/heygen/client.py b/src/pipecat/services/heygen/client.py index 4018d3858..6d45d6114 100644 --- a/src/pipecat/services/heygen/client.py +++ b/src/pipecat/services/heygen/client.py @@ -62,10 +62,12 @@ class HeyGenCallbacks(BaseModel): """Callback handlers for HeyGen events. Parameters: - on_participant_connected: Called when a participant connects - on_participant_disconnected: Called when a participant disconnects + on_connected: Called when the bot connects to the LiveKit room. + on_participant_connected: Called when a participant connects. + on_participant_disconnected: Called when a participant disconnects. """ + on_connected: Callable[[], Awaitable[None]] on_participant_connected: Callable[[str], Awaitable[None]] on_participant_disconnected: Callable[[str], Awaitable[None]] @@ -251,6 +253,7 @@ class HeyGenClient: logger.debug(f"HeyGenClient send_interval: {self._send_interval}") await self._ws_connect() await self._livekit_connect() + self._call_event_callback(self._callbacks.on_connected) async def stop(self) -> None: """Stop the client and terminate all connections. diff --git a/src/pipecat/services/heygen/video.py b/src/pipecat/services/heygen/video.py index b97f4a5ed..7f3624f35 100644 --- a/src/pipecat/services/heygen/video.py +++ b/src/pipecat/services/heygen/video.py @@ -128,6 +128,7 @@ class HeyGenVideoService(AIService): session_request=self._session_request, service_type=self._service_type, callbacks=HeyGenCallbacks( + on_connected=self._on_connected, on_participant_connected=self._on_participant_connected, on_participant_disconnected=self._on_participant_disconnected, ), @@ -144,6 +145,10 @@ class HeyGenVideoService(AIService): await self._client.cleanup() self._client = None + async def _on_connected(self): + """Handle bot connected to LiveKit room.""" + logger.info("HeyGen bot connected to LiveKit room") + async def _on_participant_connected(self, participant_id: str): """Handle participant connected events.""" logger.info(f"Participant connected {participant_id}") diff --git a/src/pipecat/services/openai/realtime/llm.py b/src/pipecat/services/openai/realtime/llm.py index a6667c7c8..07b6aa82b 100644 --- a/src/pipecat/services/openai/realtime/llm.py +++ b/src/pipecat/services/openai/realtime/llm.py @@ -839,7 +839,7 @@ class OpenAIRealtimeLLMService(LLMService): async def _handle_evt_speech_started(self, evt): await self._truncate_current_audio_response() await self.broadcast_frame(UserStartedSpeakingFrame) - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() async def _handle_evt_speech_stopped(self, evt): await self.start_ttfb_metrics() diff --git a/src/pipecat/services/openai/stt.py b/src/pipecat/services/openai/stt.py index 9a52be114..32895f8b5 100644 --- a/src/pipecat/services/openai/stt.py +++ b/src/pipecat/services/openai/stt.py @@ -639,7 +639,7 @@ class OpenAIRealtimeSTTService(WebsocketSTTService): logger.debug("Server VAD: speech started") await self.broadcast_frame(UserStartedSpeakingFrame) if self._should_interrupt: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() await self.start_processing_metrics() async def _handle_speech_stopped(self, evt: dict): diff --git a/src/pipecat/services/openai_realtime_beta/openai.py b/src/pipecat/services/openai_realtime_beta/openai.py index 8614713ff..c912ed45c 100644 --- a/src/pipecat/services/openai_realtime_beta/openai.py +++ b/src/pipecat/services/openai_realtime_beta/openai.py @@ -709,7 +709,7 @@ class OpenAIRealtimeBetaLLMService(LLMService): async def _handle_evt_speech_started(self, evt): await self._truncate_current_audio_response() await self.broadcast_frame(UserStartedSpeakingFrame) - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() async def _handle_evt_speech_stopped(self, evt): await self.start_ttfb_metrics() diff --git a/src/pipecat/services/rime/tts.py b/src/pipecat/services/rime/tts.py index 2dbaf2760..944ff4e58 100644 --- a/src/pipecat/services/rime/tts.py +++ b/src/pipecat/services/rime/tts.py @@ -147,10 +147,10 @@ class RimeTTSService(AudioContextTTSService): Parameters: language: Language for synthesis. Defaults to English. segment: Text segmentation mode ("immediate", "bySentence", "never"). + speed_alpha: Speech speed multiplier. 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). @@ -160,12 +160,12 @@ class RimeTTSService(AudioContextTTSService): language: Optional[Language] = Language.EN segment: Optional[str] = None + speed_alpha: Optional[float] = 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 @@ -230,12 +230,12 @@ class RimeTTSService(AudioContextTTSService): else None, segment=params.segment, inlineSpeedAlpha=None, # Not applicable here + speedAlpha=params.speed_alpha, # Arcana params repetition_penalty=params.repetition_penalty, temperature=params.temperature, top_p=params.top_p, # Mistv2 params - speedAlpha=params.speed_alpha, reduceLatency=params.reduce_latency, pauseBetweenBrackets=params.pause_between_brackets, phonemizeBetweenBrackets=params.phonemize_between_brackets, diff --git a/src/pipecat/services/sarvam/stt.py b/src/pipecat/services/sarvam/stt.py index 379473c6f..e368ceb02 100644 --- a/src/pipecat/services/sarvam/stt.py +++ b/src/pipecat/services/sarvam/stt.py @@ -266,15 +266,10 @@ class SarvamSTTService(STTService): # Initialize Sarvam SDK client self._sdk_headers = sdk_headers() - # NOTE: We avoid passing non-standard kwargs here because different sarvamai - # versions expose different constructor signatures (static type checkers - # complain otherwise). We instead inject headers best-effort below. - self._sarvam_client = AsyncSarvamAI(api_subscription_key=api_key) - for attr in ("default_headers", "_default_headers", "headers", "_headers"): - d = getattr(self._sarvam_client, attr, None) - if isinstance(d, dict): - d.update(self._sdk_headers) - break + # Pass Pipecat SDK headers directly at client construction time so they are + # merged by the Sarvam SDK's client wrapper and consistently applied to + # WebSocket handshake requests. + self._sarvam_client = AsyncSarvamAI(api_subscription_key=api_key, headers=self._sdk_headers) self._websocket_context = None self._socket_client = None self._receive_task = None @@ -517,20 +512,26 @@ class SarvamSTTService(STTService): connect_kwargs["prompt"] = self._settings.prompt def _connect_with_sdk_headers(connect_fn, **kwargs): - # Different SDK versions may use different kwarg names. # If prompt is unsupported at connect-time, retry without it. + # Headers are supplied through request_options because this is a + # documented SDK parameter that survives SDK signature changes. + request_options = {"additional_headers": self._sdk_headers} + 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( + **attempt_kwargs, + request_options=request_options, + ) + except TypeError as e: + last_type_error = e + try: + # Fallback for SDK builds that don't expose request_options. return connect_fn(**attempt_kwargs) except TypeError as e: last_type_error = e @@ -643,7 +644,7 @@ class SarvamSTTService(STTService): logger.debug("User started speaking") await self._call_event_handler("on_speech_started") await self.broadcast_frame(UserStartedSpeakingFrame) - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() elif signal == "END_SPEECH": logger.debug("User stopped speaking") diff --git a/src/pipecat/services/sarvam/tts.py b/src/pipecat/services/sarvam/tts.py index 87604a9f9..c18933407 100644 --- a/src/pipecat/services/sarvam/tts.py +++ b/src/pipecat/services/sarvam/tts.py @@ -1013,12 +1013,14 @@ class SarvamTTSService(InterruptibleTTSService): if self._websocket and self._websocket.state is State.OPEN: return + ws_additional_headers = { + "api-subscription-key": self._api_key, + **sdk_headers(), + } + self._websocket = await websocket_connect( self._websocket_url, - additional_headers={ - "api-subscription-key": self._api_key, - **sdk_headers(), - }, + additional_headers=ws_additional_headers, ) logger.debug("Connected to Sarvam TTS Websocket") await self._send_config() diff --git a/src/pipecat/services/speechmatics/stt.py b/src/pipecat/services/speechmatics/stt.py index ac18a36e3..bdeb3b249 100644 --- a/src/pipecat/services/speechmatics/stt.py +++ b/src/pipecat/services/speechmatics/stt.py @@ -836,7 +836,7 @@ class SpeechmaticsSTTService(STTService): # await self.start_processing_metrics() await self.broadcast_frame(UserStartedSpeakingFrame) if self._should_interrupt: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() async def _handle_end_of_turn(self, message: dict[str, Any]) -> None: """Handle EndOfTurn events. diff --git a/src/pipecat/services/tavus/video.py b/src/pipecat/services/tavus/video.py index d9f259797..8c63ff354 100644 --- a/src/pipecat/services/tavus/video.py +++ b/src/pipecat/services/tavus/video.py @@ -94,6 +94,7 @@ class TavusVideoService(AIService): """ await super().setup(setup) callbacks = TavusCallbacks( + on_joined=self._on_joined, on_participant_joined=self._on_participant_joined, on_participant_left=self._on_participant_left, ) @@ -119,6 +120,10 @@ class TavusVideoService(AIService): await self._client.cleanup() self._client = None + async def _on_joined(self, data): + """Handle bot joined the Daily room.""" + logger.info("Tavus bot joined Daily room") + async def _on_participant_left(self, participant, reason): """Handle participant leaving the session.""" participant_id = participant["id"] diff --git a/src/pipecat/transports/base_input.py b/src/pipecat/transports/base_input.py index 49c28149a..1da672ab7 100644 --- a/src/pipecat/transports/base_input.py +++ b/src/pipecat/transports/base_input.py @@ -558,7 +558,7 @@ class BaseInputTransport(FrameProcessor): # Make sure we notify about interruptions quickly out-of-band. if should_push_immediate_interruption and self._allow_interruptions: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() elif self.interruption_strategies and self._bot_speaking: logger.debug( "User started speaking while bot is speaking with interruption config - " diff --git a/src/pipecat/transports/daily/transport.py b/src/pipecat/transports/daily/transport.py index 9575fd51b..dc9868426 100644 --- a/src/pipecat/transports/daily/transport.py +++ b/src/pipecat/transports/daily/transport.py @@ -24,7 +24,9 @@ from pydantic import BaseModel from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADParams from pipecat.frames.frames import ( + BotConnectedFrame, CancelFrame, + ClientConnectedFrame, DataFrame, EndFrame, Frame, @@ -2070,6 +2072,8 @@ class DailyTransport(BaseTransport): Event handlers available: - on_joined: Called when the bot joins the room. Args: (data: dict) + - on_connected: Called when the bot connects to the room (alias for + on_joined). 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) @@ -2187,6 +2191,7 @@ class DailyTransport(BaseTransport): # Register supported handlers. The user will only be able to register # these handlers. self._register_event_handler("on_active_speaker_changed") + self._register_event_handler("on_connected") self._register_event_handler("on_joined") self._register_event_handler("on_left") self._register_event_handler("on_error") @@ -2578,6 +2583,10 @@ class DailyTransport(BaseTransport): if error: await self._on_error(f"Unable to start transcription: {error}") await self._call_event_handler("on_joined", data) + # Also call on_connected for compatibility with other transports + await self._call_event_handler("on_connected", data) + if self._input: + await self._input.push_frame(BotConnectedFrame()) async def _on_left(self): """Handle room left events.""" @@ -2716,6 +2725,8 @@ class DailyTransport(BaseTransport): await self._call_event_handler("on_participant_joined", participant) # Also call on_client_connected for compatibility with other transports await self._call_event_handler("on_client_connected", participant) + if self._input: + await self._input.push_frame(ClientConnectedFrame()) async def _on_participant_left(self, participant, reason): """Handle participant left events.""" diff --git a/src/pipecat/transports/heygen/transport.py b/src/pipecat/transports/heygen/transport.py index dbeded3e5..d79d0080e 100644 --- a/src/pipecat/transports/heygen/transport.py +++ b/src/pipecat/transports/heygen/transport.py @@ -23,9 +23,11 @@ from loguru import logger from pipecat.frames.frames import ( AudioRawFrame, + BotConnectedFrame, BotStartedSpeakingFrame, BotStoppedSpeakingFrame, CancelFrame, + ClientConnectedFrame, EndFrame, Frame, InputAudioRawFrame, @@ -339,6 +341,7 @@ class HeyGenTransport(BaseTransport): session_request=session_request, service_type=service_type, callbacks=HeyGenCallbacks( + on_connected=self._on_connected, on_participant_connected=self._on_participant_connected, on_participant_disconnected=self._on_participant_disconnected, ), @@ -349,9 +352,16 @@ class HeyGenTransport(BaseTransport): # Register supported handlers. The user will only be able to register # these handlers. + self._register_event_handler("on_connected") self._register_event_handler("on_client_connected") self._register_event_handler("on_client_disconnected") + async def _on_connected(self): + """Handle bot connected to LiveKit room.""" + await self._call_event_handler("on_connected") + if self._input: + await self._input.push_frame(BotConnectedFrame()) + async def _on_participant_disconnected(self, participant_id: str): logger.debug(f"HeyGen participant {participant_id} disconnected") if participant_id != "heygen": @@ -387,6 +397,8 @@ class HeyGenTransport(BaseTransport): async def _on_client_connected(self, participant: Any): """Handle client connected events.""" await self._call_event_handler("on_client_connected", participant) + if self._input: + await self._input.push_frame(ClientConnectedFrame()) async def _on_client_disconnected(self, participant: Any): """Handle client disconnected events.""" diff --git a/src/pipecat/transports/lemonslice/__init__.py b/src/pipecat/transports/lemonslice/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pipecat/transports/lemonslice/api.py b/src/pipecat/transports/lemonslice/api.py new file mode 100644 index 000000000..cac341d7d --- /dev/null +++ b/src/pipecat/transports/lemonslice/api.py @@ -0,0 +1,110 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""LemonSlice API utilities for session management. + +This module provides helper classes for interacting with the LemonSlice API, +including session creation and termination. +""" + +from typing import Any, Optional + +import aiohttp +from loguru import logger + + +class LemonSliceApi: + """Helper class for interacting with the LemonSlice API. + + Provides methods for creating and managing sessions with LemonSlice avatars. + """ + + LEMONSLICE_URL = "https://lemonslice.com/api/liveai/sessions" + + def __init__(self, api_key: str, session: aiohttp.ClientSession): + """Initialize the LemonSliceApi client. + + Args: + api_key: LemonSlice API key for authentication. + session: An aiohttp session for making HTTP requests. + """ + self._api_key = api_key + self._session = session + self._headers = {"Content-Type": "application/json", "x-api-key": self._api_key} + + async def create_session( + self, + *, + agent_image_url: Optional[str] = None, + agent_id: Optional[str] = None, + agent_prompt: Optional[str] = None, + idle_timeout: Optional[int] = None, + daily_room_url: Optional[str] = None, + daily_token: Optional[str] = None, + properties: Optional[dict[str, Any]] = None, + ) -> dict: + """Create a new session with the specified agent_id or agent_image_url. + + Args: + agent_image_url: The URL to an agent image. Provide either agent_id or agent_image_url. + agent_id: ID of a LemonSlice agent. Provide either agent_id or agent_image_url. + agent_prompt: A high-level system prompt that subtly influences the avatar’s movements, expressions, and emotional demeanor. + idle_timeout: Idle timeout in seconds. + daily_room_url: Daily room URL to use for the session. + daily_token: Daily token for authenticating with the room. + properties: Additional properties to pass to the session. + + Returns: + Dictionary containing session_id, room_url, and control_url. + + Raises: + ValueError: If neither agent_id nor agent_image_url is provided. + """ + if not agent_id and not agent_image_url: + # Fallback to a default agent if none is provided + logger.debug("No agent_id or agent_image_url provided, using default agent") + agent_id = "agent_080308d8b6e99f47" + if agent_id and agent_image_url: + raise ValueError("Provide exactly one of agent_id or agent_image_url, not both") + + logger.debug( + f"Creating LemonSlice session: agent_id={agent_id}, agent_image_url={agent_image_url}" + ) + payload: dict[str, object] = {"transport_type": "daily"} + if agent_id is not None: + payload["agent_id"] = agent_id + if agent_image_url is not None: + payload["agent_image_url"] = agent_image_url + if agent_prompt is not None: + payload["agent_prompt"] = agent_prompt + if idle_timeout is not None: + payload["idle_timeout"] = idle_timeout + properties_dict: dict[str, Any] = dict(properties) if properties else {} + if daily_room_url is not None: + properties_dict["daily_url"] = daily_room_url + if daily_token is not None: + properties_dict["daily_token"] = daily_token + if properties_dict: + payload["properties"] = properties_dict + async with self._session.post( + self.LEMONSLICE_URL, headers=self._headers, json=payload + ) as r: + r.raise_for_status() + response = await r.json() + logger.debug(f"Created LemonSlice session: {response}") + return response + + async def end_session(self, session_id: str, control_url: str): + """End an existing session. + + Args: + session_id: ID of the session to end. + control_url: The control URL from the create_session response. + """ + payload = {"event": "terminate"} + async with self._session.post(control_url, headers=self._headers, json=payload) as r: + r.raise_for_status() + logger.debug(f"Ended LemonSlice session {session_id}") diff --git a/src/pipecat/transports/lemonslice/transport.py b/src/pipecat/transports/lemonslice/transport.py new file mode 100644 index 000000000..6a6894167 --- /dev/null +++ b/src/pipecat/transports/lemonslice/transport.py @@ -0,0 +1,790 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""LemonSlice transport for Pipecat. + +This module adds LemonSlice avatars to Daily rooms, enabling +real-time voice conversations with synchronized avatars. +""" + +from functools import partial +from typing import Any, Awaitable, Callable, Mapping, Optional + +import aiohttp +from daily.daily import AudioData +from loguru import logger +from pydantic import BaseModel + +from pipecat.frames.frames import ( + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + CancelFrame, + EndFrame, + Frame, + InputAudioRawFrame, + InterruptionFrame, + OutputAudioRawFrame, + OutputTransportMessageFrame, + OutputTransportMessageUrgentFrame, + StartFrame, +) +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, FrameProcessorSetup +from pipecat.transports.base_input import BaseInputTransport +from pipecat.transports.base_output import BaseOutputTransport +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import ( + DailyCallbacks, + DailyParams, + DailyTransportClient, +) +from pipecat.transports.lemonslice.api import LemonSliceApi + + +class LemonSliceNewSessionRequest(BaseModel): + """Request model for creating a new LemonSlice session. + + Parameters: + agent_image_url: URL to an agent image. Provide either agent_id or agent_image_url. + agent_id: ID of a LemonSlice agent. Provide either agent_id or agent_image_url. + agent_prompt: A high-level system prompt that subtly influences the avatar's movements, + expressions, and emotional demeanor. + idle_timeout: Idle timeout in seconds. + daily_room_url: Daily room URL to use for the session. + daily_token: Daily token for authenticating with the room. + lemonslice_properties: Additional properties to pass to the session. + """ + + agent_image_url: Optional[str] = None + agent_id: Optional[str] = None + agent_prompt: Optional[str] = None + idle_timeout: Optional[int] = None + daily_room_url: Optional[str] = None + daily_token: Optional[str] = None + lemonslice_properties: Optional[dict] = None + + +class LemonSliceCallbacks(BaseModel): + """Callback handlers for LemonSlice events. + + Parameters: + on_participant_joined: Called when a participant joins the conversation. + on_participant_left: Called when a participant leaves the conversation. + """ + + on_participant_joined: Callable[[Mapping[str, Any]], Awaitable[None]] + on_participant_left: Callable[[Mapping[str, Any], str], Awaitable[None]] + + +class LemonSliceParams(DailyParams): + """Configuration parameters for the LemonSlice transport. + + Parameters: + audio_in_enabled: Whether to enable audio input from participants. + audio_out_enabled: Whether to enable audio output to participants. + microphone_out_enabled: Whether to enable microphone output track. + """ + + audio_in_enabled: bool = True + audio_out_enabled: bool = True + microphone_out_enabled: bool = False + + +class LemonSliceTransportClient: + """Transport client that integrates Pipecat with the LemonSlice platform. + + A transport client that integrates a Pipecat Bot with the LemonSlice platform by managing + conversation sessions using the LemonSlice API. + + This client uses `LemonSliceApi` to interact with the LemonSlice backend. LemonSlice either provides + a room URL where the avatar is already present, or adds the LemonSlice avatar to a Daily room + the user supplies. + """ + + def __init__( + self, + *, + bot_name: str, + params: LemonSliceParams = LemonSliceParams(), + callbacks: LemonSliceCallbacks, + api_key: str, + session_request: Optional[LemonSliceNewSessionRequest] = None, + session: aiohttp.ClientSession, + ) -> None: + """Initialize the LemonSlice transport client. + + Args: + bot_name: The name of the Pipecat bot instance. + params: Optional parameters for LemonSlice operation. + callbacks: Callback handlers for LemonSlice-related events. + api_key: API key for authenticating with LemonSlice API. + session_request: Optional session creation parameters. If not provided, a default + agent will be used. + session: The aiohttp session for making async HTTP requests. + """ + self._bot_name = bot_name + self._api = LemonSliceApi(api_key, session) + self._session_request = session_request or LemonSliceNewSessionRequest() + self._session_id: Optional[str] = None + self._control_url: Optional[str] = None + self._daily_transport_client: Optional[DailyTransportClient] = None + self._callbacks = callbacks + self._params = params + + async def _initialize(self) -> str: + """Initialize the conversation and return the room URL.""" + response = await self._api.create_session( + agent_image_url=self._session_request.agent_image_url, + agent_id=self._session_request.agent_id, + agent_prompt=self._session_request.agent_prompt, + idle_timeout=self._session_request.idle_timeout, + daily_room_url=self._session_request.daily_room_url, + daily_token=self._session_request.daily_token, + properties=self._session_request.lemonslice_properties, + ) + self._session_id = response["session_id"] + self._control_url = response["control_url"] + return response["room_url"] + + async def setup(self, setup: FrameProcessorSetup): + """Setup the client and initialize the conversation. + + Args: + setup: The frame processor setup configuration. + """ + if self._session_id is not None: + logger.debug(f"Session ID already defined: {self._session_id}") + return + try: + room_url = await self._initialize() + daily_callbacks = DailyCallbacks( + on_active_speaker_changed=partial( + self._on_handle_callback, "on_active_speaker_changed" + ), + on_joined=self._on_joined, + on_left=self._on_left, + on_before_leave=partial(self._on_handle_callback, "on_before_leave"), + on_error=partial(self._on_handle_callback, "on_error"), + on_app_message=partial(self._on_handle_callback, "on_app_message"), + on_call_state_updated=partial(self._on_handle_callback, "on_call_state_updated"), + on_client_connected=partial(self._on_handle_callback, "on_client_connected"), + on_client_disconnected=partial(self._on_handle_callback, "on_client_disconnected"), + on_dialin_connected=partial(self._on_handle_callback, "on_dialin_connected"), + on_dialin_ready=partial(self._on_handle_callback, "on_dialin_ready"), + on_dialin_stopped=partial(self._on_handle_callback, "on_dialin_stopped"), + on_dialin_error=partial(self._on_handle_callback, "on_dialin_error"), + on_dialin_warning=partial(self._on_handle_callback, "on_dialin_warning"), + on_dialout_answered=partial(self._on_handle_callback, "on_dialout_answered"), + on_dialout_connected=partial(self._on_handle_callback, "on_dialout_connected"), + on_dialout_stopped=partial(self._on_handle_callback, "on_dialout_stopped"), + on_dialout_error=partial(self._on_handle_callback, "on_dialout_error"), + on_dialout_warning=partial(self._on_handle_callback, "on_dialout_warning"), + on_participant_joined=self._callbacks.on_participant_joined, + on_participant_left=self._callbacks.on_participant_left, + on_participant_updated=partial(self._on_handle_callback, "on_participant_updated"), + on_transcription_message=partial( + self._on_handle_callback, "on_transcription_message" + ), + on_recording_started=partial(self._on_handle_callback, "on_recording_started"), + on_recording_stopped=partial(self._on_handle_callback, "on_recording_stopped"), + on_recording_error=partial(self._on_handle_callback, "on_recording_error"), + on_transcription_stopped=partial( + self._on_handle_callback, "on_transcription_stopped" + ), + on_transcription_error=partial(self._on_handle_callback, "on_transcription_error"), + ) + self._daily_transport_client = DailyTransportClient( + room_url, None, self._bot_name, self._params, daily_callbacks, "LemonSlicePipecat" + ) + await self._daily_transport_client.setup(setup) + except Exception as e: + logger.error(f"Failed to setup LemonSliceTransportClient: {e}") + if self._session_id and self._control_url: + await self._api.end_session(self._session_id, self._control_url) + self._session_id = None + self._control_url = None + raise + + async def cleanup(self): + """Cleanup client resources.""" + try: + if self._daily_transport_client: + await self._daily_transport_client.cleanup() + except Exception as e: + logger.error(f"Exception during cleanup: {e}") + + async def _on_joined(self, data): + """Handle joined event.""" + logger.debug("LemonSliceTransportClient joined!") + + async def _on_left(self): + """Handle left event.""" + logger.debug("LemonSliceTransportClient left!") + + async def _on_handle_callback(self, event_name, *args, **kwargs): + """Handle generic callback events.""" + logger.trace(f"[Callback] {event_name} called with args={args}, kwargs={kwargs}") + + async def get_bot_name(self) -> str: + """Get the name of the LemonSlice participant. + + Returns: + The name of the LemonSlice participant. + """ + return "LemonSlice" + + async def start(self, frame: StartFrame): + """Start the client and join the room. + + Args: + frame: The start frame containing initialization parameters. + """ + await self._daily_transport_client.start(frame) + await self._daily_transport_client.join() + + async def stop(self): + """Stop the client and end the conversation.""" + await self._daily_transport_client.leave() + if self._session_id and self._control_url: + await self._api.end_session(self._session_id, self._control_url) + self._session_id = None + self._control_url = None + + async def capture_participant_video( + self, + participant_id: str, + callback: Callable, + framerate: int = 30, + video_source: str = "camera", + color_format: str = "RGB", + ): + """Capture video from a participant. + + Args: + participant_id: ID of the participant to capture video from. + callback: Callback function to handle video frames. + framerate: Desired framerate for video capture. + video_source: Video source to capture from. + color_format: Color format for video frames. + """ + await self._daily_transport_client.capture_participant_video( + participant_id, callback, framerate, video_source, color_format + ) + + async def capture_participant_audio( + self, + participant_id: str, + callback: Callable, + audio_source: str = "microphone", + sample_rate: int = 16000, + callback_interval_ms: int = 20, + ): + """Capture audio from a participant. + + Args: + participant_id: ID of the participant to capture audio from. + callback: Callback function to handle audio data. + audio_source: Audio source to capture from. + sample_rate: Desired sample rate for audio capture. + callback_interval_ms: Interval between audio callbacks in milliseconds. + """ + await self._daily_transport_client.capture_participant_audio( + participant_id, callback, audio_source, sample_rate, callback_interval_ms + ) + + async def send_message( + self, frame: OutputTransportMessageFrame | OutputTransportMessageUrgentFrame + ): + """Send a message to participants. + + Args: + frame: The message frame to send. + """ + await self._daily_transport_client.send_message(frame) + + @property + def out_sample_rate(self) -> int: + """Get the output sample rate. + + Returns: + The output sample rate in Hz. + """ + return self._daily_transport_client.out_sample_rate + + @property + def in_sample_rate(self) -> int: + """Get the input sample rate. + + Returns: + The input sample rate in Hz. + """ + return self._daily_transport_client.in_sample_rate + + async def send_interrupt_message(self) -> None: + """Send an interrupt message to the LemonSlice session.""" + logger.debug("Sending interrupt message") + transport_frame = OutputTransportMessageUrgentFrame( + message={ + "event": "interrupt", + "session_id": self._session_id, + } + ) + await self.send_message(transport_frame) + + async def send_response_started_message(self) -> None: + """Send a response_started message to the LemonSlice session.""" + logger.trace("Sending response_started message") + transport_frame = OutputTransportMessageUrgentFrame( + message={ + "event": "response_started", + "session_id": self._session_id, + } + ) + await self.send_message(transport_frame) + + async def send_response_finished_message(self) -> None: + """Send a response_finished message to the LemonSlice session.""" + logger.trace("Sending response_finished message") + transport_frame = OutputTransportMessageUrgentFrame( + message={ + "event": "response_finished", + "session_id": self._session_id, + } + ) + await self.send_message(transport_frame) + + async def update_subscriptions(self, participant_settings=None, profile_settings=None): + """Update subscription settings for participants. + + Args: + participant_settings: Per-participant subscription settings. + profile_settings: Global subscription profile settings. + """ + if not self._daily_transport_client: + return + + await self._daily_transport_client.update_subscriptions( + participant_settings=participant_settings, profile_settings=profile_settings + ) + + async def write_audio_frame(self, frame: OutputAudioRawFrame) -> bool: + """Write an audio frame to the transport. + + Args: + frame: The audio frame to write. + + Returns: + True if the audio frame was written successfully, False otherwise. + """ + if not self._daily_transport_client: + return False + + return await self._daily_transport_client.write_audio_frame(frame) + + async def register_audio_destination(self, destination: str): + """Register an audio destination for output. + + Args: + destination: The destination identifier to register. + """ + if not self._daily_transport_client: + return + + await self._daily_transport_client.register_audio_destination(destination) + + +class LemonSliceInputTransport(BaseInputTransport): + """Input transport for receiving audio and events from LemonSlice. + + Handles incoming audio streams from participants and manages audio capture + from the Daily room connected to LemonSlice. + """ + + def __init__( + self, + client: LemonSliceTransportClient, + params: TransportParams, + **kwargs, + ): + """Initialize the LemonSlice input transport. + + Args: + client: The LemonSlice transport client instance. + params: Transport configuration parameters. + **kwargs: Additional arguments passed to parent class. + """ + super().__init__(params, **kwargs) + self._client = client + self._params = params + # Whether we have seen a StartFrame already. + self._initialized = False + + async def setup(self, setup: FrameProcessorSetup): + """Setup the input transport. + + Args: + setup: The frame processor setup configuration. + """ + await super().setup(setup) + await self._client.setup(setup) + + async def cleanup(self): + """Cleanup input transport resources.""" + await super().cleanup() + await self._client.cleanup() + + async def start(self, frame: StartFrame): + """Start the input transport. + + Args: + frame: The start frame containing initialization parameters. + """ + await super().start(frame) + + if self._initialized: + return + + self._initialized = True + + await self._client.start(frame) + await self.set_transport_ready(frame) + + async def stop(self, frame: EndFrame): + """Stop the input transport. + + Args: + frame: The end frame signaling transport shutdown. + """ + await super().stop(frame) + await self._client.stop() + + async def cancel(self, frame: CancelFrame): + """Cancel the input transport. + + Args: + frame: The cancel frame signaling immediate cancellation. + """ + await super().cancel(frame) + await self._client.stop() + + async def start_capturing_audio(self, participant): + """Start capturing audio from a participant. + + Args: + participant: The participant to capture audio from. + """ + if self._params.audio_in_enabled: + logger.debug( + f"LemonSliceTransportClient start capturing audio for participant {participant['id']}" + ) + await self._client.capture_participant_audio( + participant_id=participant["id"], + callback=self._on_participant_audio_data, + sample_rate=self._client.in_sample_rate, + ) + + async def _on_participant_audio_data( + self, participant_id: str, audio: AudioData, audio_source: str + ): + """Handle received participant audio data. + + Args: + participant_id: ID of the participant who sent the audio. + audio: The audio data from the participant. + audio_source: The source of the audio (e.g., microphone). + """ + frame = InputAudioRawFrame( + audio=audio.audio_frames, + sample_rate=audio.sample_rate, + num_channels=audio.num_channels, + ) + frame.transport_source = audio_source + await self.push_audio_frame(frame) + + +class LemonSliceOutputTransport(BaseOutputTransport): + """Output transport for sending audio and events to LemonSlice. + + Handles outgoing audio streams to participants and manages the custom + audio track expected by the LemonSlice platform. + """ + + def __init__( + self, + client: LemonSliceTransportClient, + params: TransportParams, + **kwargs, + ): + """Initialize the LemonSlice output transport. + + Args: + client: The LemonSlice transport client instance. + params: Transport configuration parameters. + **kwargs: Additional arguments passed to parent class. + """ + super().__init__(params, **kwargs) + self._client = client + self._params = params + + # Whether we have seen a StartFrame already. + self._initialized = False + # This is the custom track destination expected by LemonSlice + self._transport_destination: Optional[str] = "stream" + + async def setup(self, setup: FrameProcessorSetup): + """Setup the output transport. + + Args: + setup: The frame processor setup configuration. + """ + await super().setup(setup) + await self._client.setup(setup) + + async def cleanup(self): + """Cleanup output transport resources.""" + await super().cleanup() + await self._client.cleanup() + + async def start(self, frame: StartFrame): + """Start the output transport. + + Args: + frame: The start frame containing initialization parameters. + """ + await super().start(frame) + + if self._initialized: + return + + self._initialized = True + + await self._client.start(frame) + + if self._transport_destination: + await self._client.register_audio_destination(self._transport_destination) + + await self.set_transport_ready(frame) + + async def stop(self, frame: EndFrame): + """Stop the output transport. + + Args: + frame: The end frame signaling transport shutdown. + """ + await super().stop(frame) + await self._client.stop() + + async def cancel(self, frame: CancelFrame): + """Cancel the output transport. + + Args: + frame: The cancel frame signaling immediate cancellation. + """ + await super().cancel(frame) + await self._client.stop() + + async def send_message( + self, frame: OutputTransportMessageFrame | OutputTransportMessageUrgentFrame + ): + """Send a message to participants. + + Args: + frame: The message frame to send. + """ + logger.trace(f"LemonSliceTransport sending message {frame}") + await self._client.send_message(frame) + + async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): + """Push a frame to the next processor in the pipeline. + + Args: + frame: The frame to push. + direction: The direction to push the frame. + """ + # The BotStartedSpeakingFrame and BotStoppedSpeakingFrame are created inside BaseOutputTransport + # This is a workaround, so we can more reliably be aware when the bot has started or stopped speaking + if direction == FrameDirection.DOWNSTREAM: + if isinstance(frame, BotStartedSpeakingFrame): + await self._handle_response_started() + if isinstance(frame, BotStoppedSpeakingFrame): + await self._handle_response_finished() + await super().push_frame(frame, direction) + + async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames and handle interruptions. + + Args: + frame: The frame to process. + direction: The direction of frame flow in the pipeline. + """ + await super().process_frame(frame, direction) + if isinstance(frame, InterruptionFrame): + await self._handle_interruptions() + + async def _handle_interruptions(self): + """Handle interruption events by sending interrupt message.""" + await self._client.send_interrupt_message() + + async def _handle_response_started(self): + """Handle bot started speaking events by sending response_started message.""" + await self._client.send_response_started_message() + + async def _handle_response_finished(self): + """Handle tts response stopped events by sending response_finished message.""" + await self._client.send_response_finished_message() + + async def write_audio_frame(self, frame: OutputAudioRawFrame) -> bool: + """Write an audio frame to the LemonSlice transport. + + Args: + frame: The audio frame to write. + + Returns: + True if the audio frame was written successfully, False otherwise. + """ + # This is the custom track destination expected by LemonSlice + frame.transport_destination = self._transport_destination + return await self._client.write_audio_frame(frame) + + async def register_audio_destination(self, destination: str): + """Register an audio destination. + + Args: + destination: The destination identifier to register. + """ + await self._client.register_audio_destination(destination) + + +class LemonSliceTransport(BaseTransport): + """Transport implementation to add a LemonSlice avatar to Daily calls. + + When used, the Pipecat bot joins the same virtual room as the LemonSlice Avatar and the user. + This is achieved by using `LemonSliceTransportClient`, which initiates the conversation via + `LemonSliceApi` 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__( + self, + bot_name: str, + session: aiohttp.ClientSession, + api_key: str, + session_request: Optional[LemonSliceNewSessionRequest] = None, + params: LemonSliceParams = LemonSliceParams(), + input_name: Optional[str] = None, + output_name: Optional[str] = None, + ): + """Initialize the LemonSlice transport. + + Args: + bot_name: The name of the Pipecat bot. + session: aiohttp session used for async HTTP requests. + api_key: LemonSlice API key for authentication. + session_request: Optional session creation parameters. If not provided, a default + agent will be used. + params: Optional LemonSlice-specific configuration parameters. + input_name: Optional name for the input transport. + output_name: Optional name for the output transport. + """ + super().__init__(input_name=input_name, output_name=output_name) + self._params = params + + callbacks = LemonSliceCallbacks( + on_participant_joined=self._on_participant_joined, + on_participant_left=self._on_participant_left, + ) + self._client = LemonSliceTransportClient( + bot_name=bot_name, + callbacks=callbacks, + api_key=api_key, + session_request=session_request, + session=session, + params=params, + ) + self._input: Optional[LemonSliceInputTransport] = None + self._output: Optional[LemonSliceOutputTransport] = None + self._lemonslice_participant_id = None + + # Register supported handlers. The user will only be able to register + # these handlers. + self._register_event_handler("on_client_connected") + self._register_event_handler("on_client_disconnected") + + async def _on_participant_left(self, participant, reason): + """Handle participant left events.""" + ls_bot_name = await self._client.get_bot_name() + if participant.get("info", {}).get("userName", "") != ls_bot_name: + await self._on_client_disconnected(participant) + + async def _on_participant_joined(self, participant): + """Handle participant joined events.""" + ls_bot_name = await self._client.get_bot_name() + + # Ignore the LemonSlice bot's microphone + if participant.get("info", {}).get("userName", "") == ls_bot_name: + self._lemonslice_participant_id = participant["id"] + else: + await self._on_client_connected(participant) + if self._lemonslice_participant_id: + logger.debug(f"Ignoring {self._lemonslice_participant_id}'s microphone") + await self.update_subscriptions( + participant_settings={ + self._lemonslice_participant_id: { + "media": {"microphone": "unsubscribed"}, + } + } + ) + if self._input: + await self._input.start_capturing_audio(participant) + + async def update_subscriptions(self, participant_settings=None, profile_settings=None): + """Update subscription settings for participants. + + Args: + participant_settings: Per-participant subscription settings. + profile_settings: Global subscription profile settings. + """ + await self._client.update_subscriptions( + participant_settings=participant_settings, + profile_settings=profile_settings, + ) + + def input(self) -> FrameProcessor: + """Get the input transport for receiving media and events. + + Returns: + The LemonSlice input transport instance. + """ + if not self._input: + self._input = LemonSliceInputTransport(client=self._client, params=self._params) + return self._input + + def output(self) -> FrameProcessor: + """Get the output transport for sending media and events. + + Returns: + The LemonSlice output transport instance. + """ + if not self._output: + self._output = LemonSliceOutputTransport(client=self._client, params=self._params) + return self._output + + async def _on_client_connected(self, participant: Any): + """Handle client connected events.""" + await self._call_event_handler("on_client_connected", participant) + + async def _on_client_disconnected(self, participant: Any): + """Handle client disconnected events.""" + await self._call_event_handler("on_client_disconnected", participant) diff --git a/src/pipecat/transports/livekit/transport.py b/src/pipecat/transports/livekit/transport.py index 1902e7cd3..7e9c1de35 100644 --- a/src/pipecat/transports/livekit/transport.py +++ b/src/pipecat/transports/livekit/transport.py @@ -23,7 +23,9 @@ from pipecat.audio.utils import create_stream_resampler from pipecat.audio.vad.vad_analyzer import VADAnalyzer from pipecat.frames.frames import ( AudioRawFrame, + BotConnectedFrame, CancelFrame, + ClientConnectedFrame, EndFrame, ImageRawFrame, OutputAudioRawFrame, @@ -1131,6 +1133,8 @@ class LiveKitTransport(BaseTransport): async def _on_connected(self): """Handle room connected events.""" await self._call_event_handler("on_connected") + if self._input: + await self._input.push_frame(BotConnectedFrame()) async def _on_disconnected(self): """Handle room disconnected events.""" @@ -1143,6 +1147,8 @@ class LiveKitTransport(BaseTransport): async def _on_participant_connected(self, participant_id: str): """Handle participant connected events.""" await self._call_event_handler("on_participant_connected", participant_id) + if self._input: + await self._input.push_frame(ClientConnectedFrame()) async def _on_participant_disconnected(self, participant_id: str): """Handle participant disconnected events.""" diff --git a/src/pipecat/transports/smallwebrtc/transport.py b/src/pipecat/transports/smallwebrtc/transport.py index dc91588a3..36f883278 100644 --- a/src/pipecat/transports/smallwebrtc/transport.py +++ b/src/pipecat/transports/smallwebrtc/transport.py @@ -23,6 +23,7 @@ from pydantic import BaseModel from pipecat.frames.frames import ( CancelFrame, + ClientConnectedFrame, EndFrame, Frame, InputAudioRawFrame, @@ -964,6 +965,8 @@ class SmallWebRTCTransport(BaseTransport): async def _on_client_connected(self, webrtc_connection): """Handle client connection events.""" await self._call_event_handler("on_client_connected", webrtc_connection) + if self._input: + await self._input.push_frame(ClientConnectedFrame()) async def _on_client_disconnected(self, webrtc_connection): """Handle client disconnection events.""" diff --git a/src/pipecat/transports/tavus/transport.py b/src/pipecat/transports/tavus/transport.py index dd63cb790..79be070c5 100644 --- a/src/pipecat/transports/tavus/transport.py +++ b/src/pipecat/transports/tavus/transport.py @@ -21,7 +21,9 @@ from loguru import logger from pydantic import BaseModel from pipecat.frames.frames import ( + BotConnectedFrame, CancelFrame, + ClientConnectedFrame, EndFrame, Frame, InputAudioRawFrame, @@ -132,10 +134,12 @@ class TavusCallbacks(BaseModel): """Callback handlers for Tavus events. Parameters: + on_joined: Called when the bot joins the Daily room. on_participant_joined: Called when a participant joins the conversation. on_participant_left: Called when a participant leaves the conversation. """ + on_joined: Callable[[Mapping[str, Any]], Awaitable[None]] on_participant_joined: Callable[[Mapping[str, Any]], Awaitable[None]] on_participant_left: Callable[[Mapping[str, Any], str], Awaitable[None]] @@ -270,6 +274,7 @@ class TavusTransportClient: async def _on_joined(self, data): """Handle joined event.""" logger.debug("TavusTransportClient joined!") + await self._callbacks.on_joined(data) async def _on_left(self): """Handle left event.""" @@ -664,6 +669,7 @@ class TavusTransport(BaseTransport): Event handlers available: + - on_connected(transport, data): Bot connected to the room - on_client_connected(transport, participant): Participant connected to the session - on_client_disconnected(transport, participant): Participant disconnected from the session @@ -702,6 +708,7 @@ class TavusTransport(BaseTransport): self._params = params callbacks = TavusCallbacks( + on_joined=self._on_joined, on_participant_joined=self._on_participant_joined, on_participant_left=self._on_participant_left, ) @@ -720,9 +727,16 @@ class TavusTransport(BaseTransport): # Register supported handlers. The user will only be able to register # these handlers. + self._register_event_handler("on_connected") self._register_event_handler("on_client_connected") self._register_event_handler("on_client_disconnected") + async def _on_joined(self, data): + """Handle bot joined room event.""" + await self._call_event_handler("on_connected", data) + if self._input: + await self._input.push_frame(BotConnectedFrame()) + async def _on_participant_left(self, participant, reason): """Handle participant left events.""" persona_name = await self._client.get_persona_name() @@ -786,6 +800,8 @@ class TavusTransport(BaseTransport): async def _on_client_connected(self, participant: Any): """Handle client connected events.""" await self._call_event_handler("on_client_connected", participant) + if self._input: + await self._input.push_frame(ClientConnectedFrame()) async def _on_client_disconnected(self, participant: Any): """Handle client disconnected events.""" diff --git a/src/pipecat/transports/websocket/fastapi.py b/src/pipecat/transports/websocket/fastapi.py index f52123e52..0fde2b9ae 100644 --- a/src/pipecat/transports/websocket/fastapi.py +++ b/src/pipecat/transports/websocket/fastapi.py @@ -23,6 +23,7 @@ from pydantic import BaseModel from pipecat.frames.frames import ( CancelFrame, + ClientConnectedFrame, EndFrame, Frame, InputAudioRawFrame, @@ -260,6 +261,7 @@ class FastAPIWebsocketInputTransport(BaseInputTransport): if not self._monitor_websocket_task and self._params.session_timeout: self._monitor_websocket_task = self.create_task(self._monitor_websocket()) await self._client.trigger_client_connected() + await self.push_frame(ClientConnectedFrame()) if not self._receive_task: self._receive_task = self.create_task(self._receive_messages()) await self.set_transport_ready(frame) diff --git a/src/pipecat/transports/websocket/server.py b/src/pipecat/transports/websocket/server.py index e5f628fa4..fa3645d37 100644 --- a/src/pipecat/transports/websocket/server.py +++ b/src/pipecat/transports/websocket/server.py @@ -22,11 +22,11 @@ from pydantic import BaseModel from pipecat.frames.frames import ( CancelFrame, + ClientConnectedFrame, EndFrame, Frame, InputAudioRawFrame, InputTransportMessageFrame, - InputTransportMessageUrgentFrame, InterruptionFrame, OutputAudioRawFrame, OutputTransportMessageFrame, @@ -504,6 +504,8 @@ class WebsocketServerTransport(BaseTransport): if self._output: await self._output.set_client_connection(websocket) await self._call_event_handler("on_client_connected", websocket) + if self._input: + await self._input.push_frame(ClientConnectedFrame()) else: logger.error("A WebsocketServerTransport output is missing in the pipeline") diff --git a/src/pipecat/turns/user_turn_processor.py b/src/pipecat/turns/user_turn_processor.py index 7f8995202..85bc658dd 100644 --- a/src/pipecat/turns/user_turn_processor.py +++ b/src/pipecat/turns/user_turn_processor.py @@ -182,7 +182,7 @@ class UserTurnProcessor(FrameProcessor): await self._user_idle_controller.process_frame(UserStartedSpeakingFrame()) if params.enable_interruptions and self._allow_interruptions: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() await self._call_event_handler("on_user_turn_started", strategy) diff --git a/tests/test_context_aggregators.py b/tests/test_context_aggregators.py index 24dae0b4c..37d36bfef 100644 --- a/tests/test_context_aggregators.py +++ b/tests/test_context_aggregators.py @@ -21,7 +21,6 @@ from pipecat.frames.frames import ( FunctionCallResultProperties, InterimTranscriptionFrame, InterruptionFrame, - InterruptionTaskFrame, LLMContextAssistantTimestampFrame, LLMContextFrame, LLMFullResponseEndFrame, @@ -567,7 +566,7 @@ class BaseTestUserContextAggregator: SleepFrame(), UserStoppedSpeakingFrame(), ] - expected_up_frames = [InterruptionTaskFrame] + expected_up_frames = [InterruptionFrame] expected_down_frames = [ BotStartedSpeakingFrame, UserStartedSpeakingFrame, diff --git a/tests/test_frame_processor.py b/tests/test_frame_processor.py index 138c8e6d8..a875741e3 100644 --- a/tests/test_frame_processor.py +++ b/tests/test_frame_processor.py @@ -9,8 +9,6 @@ import unittest from dataclasses import dataclass, field from typing import List -from loguru import logger - from pipecat.frames.frames import ( DataFrame, EndFrame, @@ -85,50 +83,38 @@ class TestFrameProcessor(unittest.IsolatedAsyncioTestCase): assert before_push_called assert after_push_called - async def test_interruption_and_wait(self): - class DelayFrameProcessor(FrameProcessor): - """This processors just gives time to the event loop to change - between tasks. Otherwise things happen to fast.""" - - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - await asyncio.sleep(0.1) - await self.push_frame(frame, direction) + async def test_broadcast_interruption(self): + """Test that broadcast_interruption() pushes InterruptionFrame both + directions and allows subsequent code to run.""" class InterruptFrameProcessor(FrameProcessor): 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.broadcast_interruption() await self.push_frame(OutputTransportMessageUrgentFrame(message=frame.text)) else: await self.push_frame(frame, direction) - pipeline = Pipeline([DelayFrameProcessor(), InterruptFrameProcessor()]) + pipeline = Pipeline([InterruptFrameProcessor()]) frames_to_send = [ - # Just a random interruption to make sure we don't clear anything - # before the actual `InterruptionTaskFrame` interruption. - InterruptionFrame(), - # This will generate an `InterruptionTaskFrame` and will wait for an - # `InterruptionFrame`. TextFrame(text="Hello from Pipecat!"), - # Just give time for everything to complete. SleepFrame(sleep=0.5), - EndFrame(), ] expected_down_frames = [ - InterruptionFrame, InterruptionFrame, OutputTransportMessageUrgentFrame, - EndFrame, + ] + expected_up_frames = [ + InterruptionFrame, ] await run_test( pipeline, frames_to_send=frames_to_send, expected_down_frames=expected_down_frames, - send_end_frame=False, + expected_up_frames=expected_up_frames, ) async def test_interruptible_frames(self): @@ -454,33 +440,20 @@ class TestFrameProcessor(unittest.IsolatedAsyncioTestCase): stop_frames = [f for f in received_frames if isinstance(f, StopFrame)] self.assertEqual(len(stop_frames), 1, "StopFrame should survive interruption") - async def test_interruption_frame_complete_sets_event(self): - """Test that InterruptionFrame.complete() sets the event.""" - event = asyncio.Event() - frame = InterruptionFrame(event=event) - self.assertFalse(event.is_set()) - frame.complete() - self.assertTrue(event.is_set()) - - async def test_interruption_frame_complete_without_event(self): - """Test that InterruptionFrame.complete() is safe without an event.""" - frame = InterruptionFrame() - frame.complete() # Should not raise - - async def test_interruption_event_set_at_pipeline_sink(self): - """Test that the event from push_interruption_task_frame_and_wait() - is set when the InterruptionFrame reaches the pipeline sink.""" - event_was_set = False + async def test_broadcast_interruption_allows_subsequent_code(self): + """Test that broadcast_interruption() returns immediately, allowing the + caller to run code afterwards (e.g. push an urgent frame).""" + code_after_ran = False class InterruptOnTextProcessor(FrameProcessor): async def process_frame(self, frame: Frame, direction: FrameDirection): - nonlocal event_was_set + nonlocal code_after_ran await super().process_frame(frame, direction) if isinstance(frame, TextFrame): - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() - event_was_set = True + code_after_ran = True await self.push_frame(OutputTransportMessageUrgentFrame(message="done")) else: await self.push_frame(frame, direction) @@ -499,63 +472,7 @@ class TestFrameProcessor(unittest.IsolatedAsyncioTestCase): frames_to_send=frames_to_send, expected_down_frames=expected_down_frames, ) - self.assertTrue(event_was_set, "Event should be set after InterruptionFrame completes") - - async def test_interruption_completion_timeout_warning(self): - """Test that a warning is logged when an InterruptionFrame is blocked - and never reaches the pipeline sink.""" - warnings = [] - handler_id = logger.add( - lambda msg: warnings.append(str(msg)), level="WARNING", format="{message}" - ) - - try: - - class BlockInterruptionProcessor(FrameProcessor): - """Blocks InterruptionFrames, completing them after a delay.""" - - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - if isinstance(frame, InterruptionFrame): - # Complete after the timeout so the warning fires - # but the test doesn't hang. - async def delayed_complete(): - await asyncio.sleep(1.0) - frame.complete() - - asyncio.create_task(delayed_complete()) - return - await self.push_frame(frame, direction) - - class InterruptOnTextProcessor(FrameProcessor): - 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(timeout=0.5) - await self.push_frame(OutputTransportMessageUrgentFrame(message="done")) - else: - await self.push_frame(frame, direction) - - pipeline = Pipeline([BlockInterruptionProcessor(), InterruptOnTextProcessor()]) - - frames_to_send = [ - TextFrame(text="trigger"), - ] - expected_down_frames = [ - OutputTransportMessageUrgentFrame, - ] - await run_test( - pipeline, - frames_to_send=frames_to_send, - expected_down_frames=expected_down_frames, - ) - finally: - logger.remove(handler_id) - - self.assertTrue( - any("InterruptionFrame has not completed" in w for w in warnings), - "Expected a timeout warning about InterruptionFrame not completing", - ) + self.assertTrue(code_after_ran, "Code after broadcast_interruption() should execute") if __name__ == "__main__": diff --git a/tests/test_startup_timing_observer.py b/tests/test_startup_timing_observer.py new file mode 100644 index 000000000..6355c6081 --- /dev/null +++ b/tests/test_startup_timing_observer.py @@ -0,0 +1,337 @@ +import asyncio +import unittest + +from pipecat.frames.frames import ( + BotConnectedFrame, + ClientConnectedFrame, + Frame, + StartFrame, + TextFrame, +) +from pipecat.observers.startup_timing_observer import ( + StartupTimingObserver, + StartupTimingReport, + TransportTimingReport, +) +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +from pipecat.tests.utils import run_test + + +class SlowStartProcessor(FrameProcessor): + """A processor that sleeps during start to simulate slow initialization.""" + + def __init__(self, delay: float = 0.1, **kwargs): + super().__init__(**kwargs) + self._delay = delay + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + if isinstance(frame, StartFrame): + await asyncio.sleep(self._delay) + await self.push_frame(frame, direction) + + +class FastProcessor(FrameProcessor): + """A processor with no start delay.""" + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + await self.push_frame(frame, direction) + + +class TestStartupTimingObserver(unittest.IsolatedAsyncioTestCase): + """Tests for StartupTimingObserver.""" + + async def test_timing_reported(self): + """Test that startup timing is measured and reported.""" + observer = StartupTimingObserver() + processor = SlowStartProcessor(delay=0.1) + + reports = [] + + @observer.event_handler("on_startup_timing_report") + async def on_report(obs, report): + reports.append(report) + + frames_to_send = [TextFrame(text="hello")] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[TextFrame], + observers=[observer], + ) + + self.assertEqual(len(reports), 1) + report = reports[0] + self.assertGreater(report.total_duration_secs, 0) + self.assertGreater(len(report.processor_timings), 0) + + # Find our slow processor in the timings. + slow_timings = [ + t for t in report.processor_timings if "SlowStartProcessor" in t.processor_name + ] + self.assertEqual(len(slow_timings), 1) + self.assertGreaterEqual(slow_timings[0].duration_secs, 0.05) + + async def test_processor_types_filter(self): + """Test that processor_types filter limits which processors appear.""" + observer = StartupTimingObserver(processor_types=(SlowStartProcessor,)) + processor = SlowStartProcessor(delay=0.05) + + reports = [] + + @observer.event_handler("on_startup_timing_report") + async def on_report(obs, report): + reports.append(report) + + frames_to_send = [TextFrame(text="hello")] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[TextFrame], + observers=[observer], + ) + + self.assertEqual(len(reports), 1) + report = reports[0] + + # Only SlowStartProcessor should be in the timings. + for t in report.processor_timings: + self.assertIn("SlowStartProcessor", t.processor_name) + + async def test_report_emits_once(self): + """Test that the report is emitted only once even with multiple frames.""" + observer = StartupTimingObserver() + processor = FastProcessor() + + reports = [] + + @observer.event_handler("on_startup_timing_report") + async def on_report(obs, report): + reports.append(report) + + frames_to_send = [ + TextFrame(text="first"), + TextFrame(text="second"), + TextFrame(text="third"), + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[TextFrame, TextFrame, TextFrame], + observers=[observer], + ) + + self.assertEqual(len(reports), 1) + + async def test_event_handler_receives_report(self): + """Test that the event handler receives a proper StartupTimingReport.""" + observer = StartupTimingObserver() + processor = SlowStartProcessor(delay=0.05) + + reports = [] + + @observer.event_handler("on_startup_timing_report") + async def on_report(obs, report): + reports.append(report) + + frames_to_send = [TextFrame(text="hello")] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[TextFrame], + observers=[observer], + ) + + self.assertEqual(len(reports), 1) + report = reports[0] + self.assertIsInstance(report, StartupTimingReport) + self.assertIsInstance(report.total_duration_secs, float) + self.assertGreater(report.start_time, 0) + for timing in report.processor_timings: + self.assertIsInstance(timing.processor_name, str) + self.assertIsInstance(timing.duration_secs, float) + self.assertGreaterEqual(timing.start_offset_secs, 0) + + async def test_excludes_internal_processors(self): + """Test that internal pipeline processors are excluded by default.""" + observer = StartupTimingObserver() + processor = FastProcessor() + + reports = [] + + @observer.event_handler("on_startup_timing_report") + async def on_report(obs, report): + reports.append(report) + + frames_to_send = [TextFrame(text="hello")] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[TextFrame], + observers=[observer], + ) + + self.assertEqual(len(reports), 1) + report = reports[0] + + # No internal processors (PipelineSource, PipelineSink, Pipeline) in the report. + internal_names = ("Pipeline#", "PipelineTask#") + for t in report.processor_timings: + for prefix in internal_names: + self.assertNotIn( + prefix, + t.processor_name, + f"Internal processor {t.processor_name} should be excluded by default", + ) + + async def test_transport_timing_client_only(self): + """Test that ClientConnectedFrame emits on_transport_timing_report.""" + observer = StartupTimingObserver() + processor = FastProcessor() + + transport_reports = [] + + @observer.event_handler("on_transport_timing_report") + async def on_transport(obs, report): + transport_reports.append(report) + + frames_to_send = [ClientConnectedFrame(), TextFrame(text="hello")] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[ClientConnectedFrame, TextFrame], + observers=[observer], + ) + + self.assertEqual(len(transport_reports), 1) + report = transport_reports[0] + self.assertIsInstance(report, TransportTimingReport) + self.assertGreater(report.start_time, 0) + self.assertGreater(report.client_connected_secs, 0) + self.assertIsNone(report.bot_connected_secs) + + async def test_transport_timing_only_first_client(self): + """Test that only the first ClientConnectedFrame triggers the event.""" + observer = StartupTimingObserver() + processor = FastProcessor() + + transport_reports = [] + + @observer.event_handler("on_transport_timing_report") + async def on_transport(obs, report): + transport_reports.append(report) + + frames_to_send = [ + ClientConnectedFrame(), + ClientConnectedFrame(), + TextFrame(text="hello"), + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[ClientConnectedFrame, ClientConnectedFrame, TextFrame], + observers=[observer], + ) + + self.assertEqual(len(transport_reports), 1) + + async def test_transport_timing_without_start_frame(self): + """Test that ClientConnectedFrame before StartFrame does not crash.""" + observer = StartupTimingObserver() + + # Directly call on_push_frame with a ClientConnectedFrame before any + # StartFrame has been seen. This should be a no-op (no crash). + from pipecat.observers.base_observer import FramePushed + + processor = FastProcessor() + destination = FastProcessor() + data = FramePushed( + source=processor, + destination=destination, + frame=ClientConnectedFrame(), + direction=FrameDirection.DOWNSTREAM, + timestamp=1000, + ) + await observer.on_push_frame(data) + + # No event should have been emitted. + self.assertFalse(observer._transport_timing_reported) + + async def test_bot_and_client_connected(self): + """Test that BotConnectedFrame timing is included in the transport report.""" + observer = StartupTimingObserver() + processor = FastProcessor() + + transport_reports = [] + + @observer.event_handler("on_transport_timing_report") + async def on_transport(obs, report): + transport_reports.append(report) + + frames_to_send = [ + BotConnectedFrame(), + ClientConnectedFrame(), + TextFrame(text="hello"), + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[BotConnectedFrame, ClientConnectedFrame, TextFrame], + observers=[observer], + ) + + self.assertEqual(len(transport_reports), 1) + report = transport_reports[0] + self.assertGreater(report.client_connected_secs, 0) + self.assertIsNotNone(report.bot_connected_secs) + self.assertGreater(report.bot_connected_secs, 0) + + # Client connected should be >= bot connected. + self.assertGreaterEqual(report.client_connected_secs, report.bot_connected_secs) + + async def test_bot_connected_only_first(self): + """Test that only the first BotConnectedFrame is recorded.""" + observer = StartupTimingObserver() + processor = FastProcessor() + + transport_reports = [] + + @observer.event_handler("on_transport_timing_report") + async def on_transport(obs, report): + transport_reports.append(report) + + frames_to_send = [ + BotConnectedFrame(), + BotConnectedFrame(), + ClientConnectedFrame(), + TextFrame(text="hello"), + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[ + BotConnectedFrame, + BotConnectedFrame, + ClientConnectedFrame, + TextFrame, + ], + observers=[observer], + ) + + # Only one transport report, with bot timing from first frame. + self.assertEqual(len(transport_reports), 1) + self.assertIsNotNone(transport_reports[0].bot_connected_secs) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_stt_mute_filter.py b/tests/test_stt_mute_filter.py index adf4611df..8f55bdecb 100644 --- a/tests/test_stt_mute_filter.py +++ b/tests/test_stt_mute_filter.py @@ -4,7 +4,6 @@ # SPDX-License-Identifier: BSD 2-Clause License # -import asyncio import unittest from pipecat.frames.frames import ( @@ -329,17 +328,13 @@ class TestSTTMuteFilter(unittest.IsolatedAsyncioTestCase): expected_down_frames=expected_returned_frames, ) - async def test_interruption_frame_completed_when_muted(self): - """Test that InterruptionFrame.complete() is called when the frame is - suppressed due to muting, so push_interruption_task_frame_and_wait() - doesn't hang.""" + async def test_interruption_frame_suppressed_when_muted(self): + """Test that InterruptionFrame is suppressed when the filter is muted.""" filter = STTMuteFilter(config=STTMuteConfig(strategies={STTMuteStrategy.ALWAYS})) - event = asyncio.Event() - frames_to_send = [ BotStartedSpeakingFrame(), - InterruptionFrame(event=event), + InterruptionFrame(), BotStoppedSpeakingFrame(), ] @@ -354,8 +349,6 @@ class TestSTTMuteFilter(unittest.IsolatedAsyncioTestCase): expected_down_frames=expected_returned_frames, ) - self.assertTrue(event.is_set(), "InterruptionFrame.complete() should be called when muted") - if __name__ == "__main__": unittest.main() diff --git a/tests/test_user_bot_latency_observer.py b/tests/test_user_bot_latency_observer.py index 1b7325d14..96c24724b 100644 --- a/tests/test_user_bot_latency_observer.py +++ b/tests/test_user_bot_latency_observer.py @@ -2,12 +2,28 @@ import unittest from pipecat.frames.frames import ( BotStartedSpeakingFrame, + ClientConnectedFrame, + FunctionCallInProgressFrame, + FunctionCallResultFrame, + InterruptionFrame, + MetricsFrame, + UserStoppedSpeakingFrame, VADUserStartedSpeakingFrame, VADUserStoppedSpeakingFrame, ) -from pipecat.observers.user_bot_latency_observer import UserBotLatencyObserver +from pipecat.metrics.metrics import ( + TextAggregationMetricsData, + TTFBMetricsData, +) +from pipecat.observers.user_bot_latency_observer import ( + FunctionCallMetrics, + LatencyBreakdown, + TextAggregationBreakdownMetrics, + TTFBBreakdownMetrics, + UserBotLatencyObserver, +) from pipecat.processors.filters.identity_filter import IdentityFilter -from pipecat.tests.utils import run_test +from pipecat.tests.utils import SleepFrame, run_test class TestUserBotLatencyObserver(unittest.IsolatedAsyncioTestCase): @@ -97,22 +113,226 @@ class TestUserBotLatencyObserver(unittest.IsolatedAsyncioTestCase): self.assertGreater(latencies[0], 0) self.assertGreater(latencies[1], 0) - async def test_no_measurement_without_user_stop(self): - """Test that latency is not measured if bot starts without user stopping first.""" - # Create observer + async def test_breakdown_with_metrics(self): + """Test that metrics collected between VADUserStopped and BotStarted appear in breakdown.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + stt_ttfb = TTFBMetricsData(processor="DeepgramSTTService#0", value=0.080) + llm_ttfb = TTFBMetricsData(processor="OpenAILLMService#0", model="gpt-4o", value=0.250) + tts_ttfb = TTFBMetricsData(processor="CartesiaTTSService#0", value=0.070) + text_agg = TextAggregationMetricsData(processor="CartesiaTTSService#0", value=0.030) + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + MetricsFrame(data=[stt_ttfb]), + MetricsFrame(data=[llm_ttfb, text_agg]), + MetricsFrame(data=[tts_ttfb]), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + VADUserStoppedSpeakingFrame, + MetricsFrame, + MetricsFrame, + MetricsFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + bd = breakdowns[0] + self.assertEqual(len(bd.ttfb), 3) + self.assertEqual(bd.ttfb[0].processor, "DeepgramSTTService#0") + self.assertEqual(bd.ttfb[1].processor, "OpenAILLMService#0") + self.assertEqual(bd.ttfb[2].processor, "CartesiaTTSService#0") + self.assertIsNotNone(bd.text_aggregation) + self.assertEqual(bd.text_aggregation.duration_secs, 0.030) + + async def test_interruption_resets_accumulators(self): + """Test that InterruptionFrame clears stale metrics from earlier cycles.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + # First cycle metrics (will be interrupted) + stale_llm = TTFBMetricsData(processor="OpenAILLMService#0", value=0.245) + # Second cycle metrics (the ones that matter) + final_llm = TTFBMetricsData(processor="OpenAILLMService#0", value=0.224) + final_tts = TTFBMetricsData(processor="CartesiaTTSService#0", value=0.142) + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + MetricsFrame(data=[stale_llm]), + InterruptionFrame(), + MetricsFrame(data=[final_llm]), + MetricsFrame(data=[final_tts]), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + VADUserStoppedSpeakingFrame, + MetricsFrame, + InterruptionFrame, + MetricsFrame, + MetricsFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + bd = breakdowns[0] + # Only the post-interruption metrics should be present + self.assertEqual(len(bd.ttfb), 2) + self.assertEqual(bd.ttfb[0].processor, "OpenAILLMService#0") + self.assertEqual(bd.ttfb[0].duration_secs, 0.224) + self.assertEqual(bd.ttfb[1].processor, "CartesiaTTSService#0") + self.assertEqual(bd.ttfb[1].duration_secs, 0.142) + + async def test_only_first_text_aggregation_kept(self): + """Test that only the first text aggregation metric is kept per cycle.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + text_agg_1 = TextAggregationMetricsData(processor="CartesiaTTSService#0", value=0.030) + text_agg_2 = TextAggregationMetricsData(processor="CartesiaTTSService#0", value=0.080) + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + MetricsFrame(data=[text_agg_1]), + MetricsFrame(data=[text_agg_2]), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + VADUserStoppedSpeakingFrame, + MetricsFrame, + MetricsFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + self.assertIsNotNone(breakdowns[0].text_aggregation) + self.assertEqual(breakdowns[0].text_aggregation.duration_secs, 0.030) + + async def test_user_turn_measured(self): + """Test that pre-LLM wait from user silence to UserStopped is captured.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + SleepFrame(sleep=0.1), # Simulate turn analyzer wait + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + VADUserStoppedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + self.assertIsNotNone(breakdowns[0].user_turn_secs) + self.assertGreaterEqual(breakdowns[0].user_turn_secs, 0.1) + + async def test_user_turn_none_without_user_stopped(self): + """Test that user_turn is None when no UserStoppedSpeakingFrame arrives.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + VADUserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + self.assertIsNone(breakdowns[0].user_turn_secs) + + async def test_no_measurement_without_user_stop(self): + """Test that BotStartedSpeaking without prior user stop emits nothing.""" observer = UserBotLatencyObserver() - - # Create identity filter processor = IdentityFilter() - # Capture latency events latencies = [] + breakdowns = [] @observer.event_handler("on_latency_measured") async def on_latency(obs, latency_seconds): latencies.append(latency_seconds) - # Define frame sequence - bot starts without user stop + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + frames_to_send = [ BotStartedSpeakingFrame(), ] @@ -121,7 +341,6 @@ class TestUserBotLatencyObserver(unittest.IsolatedAsyncioTestCase): BotStartedSpeakingFrame, ] - # Run test await run_test( processor, frames_to_send=frames_to_send, @@ -129,8 +348,283 @@ class TestUserBotLatencyObserver(unittest.IsolatedAsyncioTestCase): observers=[observer], ) - # Verify no latency was measured self.assertEqual(len(latencies), 0) + self.assertEqual(len(breakdowns), 0) + + async def test_first_bot_speech_latency(self): + """Test first bot speech latency and breakdown from ClientConnected to BotStartedSpeaking.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + first_speech_latencies = [] + breakdowns = [] + + @observer.event_handler("on_first_bot_speech_latency") + async def on_first_bot_speech(obs, latency_seconds): + first_speech_latencies.append(latency_seconds) + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + llm_ttfb = TTFBMetricsData(processor="OpenAILLMService#0", value=0.250) + tts_ttfb = TTFBMetricsData(processor="CartesiaTTSService#0", value=0.070) + + frames_to_send = [ + ClientConnectedFrame(), + MetricsFrame(data=[llm_ttfb]), + MetricsFrame(data=[tts_ttfb]), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + ClientConnectedFrame, + MetricsFrame, + MetricsFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(first_speech_latencies), 1) + self.assertGreater(first_speech_latencies[0], 0) + self.assertLess(first_speech_latencies[0], 1.0) + + # Breakdown should also be emitted with the accumulated metrics + self.assertEqual(len(breakdowns), 1) + self.assertEqual(len(breakdowns[0].ttfb), 2) + self.assertEqual(breakdowns[0].ttfb[0].processor, "OpenAILLMService#0") + self.assertEqual(breakdowns[0].ttfb[1].processor, "CartesiaTTSService#0") + + async def test_first_bot_speech_only_once(self): + """Test that first bot speech latency is only emitted once.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + first_speech_latencies = [] + + @observer.event_handler("on_first_bot_speech_latency") + async def on_first_bot_speech(obs, latency_seconds): + first_speech_latencies.append(latency_seconds) + + frames_to_send = [ + ClientConnectedFrame(), + BotStartedSpeakingFrame(), + # Second bot speech should not trigger the event again + VADUserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + ClientConnectedFrame, + BotStartedSpeakingFrame, + VADUserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(first_speech_latencies), 1) + + async def test_first_bot_speech_skipped_when_user_speaks_first(self): + """Test that first bot speech event is not emitted when user speaks before the bot.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + first_speech_latencies = [] + + @observer.event_handler("on_first_bot_speech_latency") + async def on_first_bot_speech(obs, latency_seconds): + first_speech_latencies.append(latency_seconds) + + frames_to_send = [ + ClientConnectedFrame(), + # User speaks before bot has a chance to greet + VADUserStartedSpeakingFrame(), + VADUserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + ClientConnectedFrame, + VADUserStartedSpeakingFrame, + VADUserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(first_speech_latencies), 0) + + async def test_function_call_latency_in_breakdown(self): + """Test that function call duration appears in the latency breakdown.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + tool_call_id = "call_abc123" + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + FunctionCallInProgressFrame( + function_name="get_weather", + tool_call_id=tool_call_id, + arguments={"location": "Atlanta"}, + ), + SleepFrame(sleep=0.1), + FunctionCallResultFrame( + function_name="get_weather", + tool_call_id=tool_call_id, + arguments={"location": "Atlanta"}, + result={"temperature": "75"}, + ), + BotStartedSpeakingFrame(), + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + self.assertEqual(len(breakdowns[0].function_calls), 1) + fc = breakdowns[0].function_calls[0] + self.assertEqual(fc.function_name, "get_weather") + self.assertGreaterEqual(fc.duration_secs, 0.1) + + async def test_function_call_reset_on_interruption(self): + """Test that function call metrics are cleared on interruption.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + FunctionCallInProgressFrame( + function_name="get_weather", + tool_call_id="call_1", + arguments={}, + ), + FunctionCallResultFrame( + function_name="get_weather", + tool_call_id="call_1", + arguments={}, + result={}, + ), + InterruptionFrame(), + BotStartedSpeakingFrame(), + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + self.assertEqual(len(breakdowns[0].function_calls), 0) + + +class TestLatencyBreakdownChronologicalEvents(unittest.TestCase): + """Tests for LatencyBreakdown.chronological_events().""" + + def test_events_sorted_by_start_time(self): + """Test that events are returned in chronological order.""" + breakdown = LatencyBreakdown( + user_turn_start_time=100.0, + user_turn_secs=0.150, + ttfb=[ + TTFBBreakdownMetrics( + processor="OpenAILLMService#0", + model="gpt-4o", + start_time=100.200, + duration_secs=0.250, + ), + TTFBBreakdownMetrics( + processor="DeepgramSTTService#0", + start_time=100.050, + duration_secs=0.080, + ), + TTFBBreakdownMetrics( + processor="CartesiaTTSService#0", + start_time=100.500, + duration_secs=0.070, + ), + ], + function_calls=[ + FunctionCallMetrics( + function_name="get_weather", + start_time=100.450, + duration_secs=0.120, + ), + ], + text_aggregation=TextAggregationBreakdownMetrics( + processor="CartesiaTTSService#0", + start_time=100.480, + duration_secs=0.030, + ), + ) + + events = breakdown.chronological_events() + + self.assertEqual(len(events), 6) + self.assertEqual(events[0], "User turn: 0.150s") + self.assertEqual(events[1], "DeepgramSTTService#0: TTFB 0.080s") + self.assertEqual(events[2], "OpenAILLMService#0: TTFB 0.250s") + self.assertEqual(events[3], "get_weather: 0.120s") + self.assertEqual(events[4], "CartesiaTTSService#0: text aggregation 0.030s") + self.assertEqual(events[5], "CartesiaTTSService#0: TTFB 0.070s") + + def test_empty_breakdown(self): + """Test that an empty breakdown returns no events.""" + breakdown = LatencyBreakdown() + self.assertEqual(breakdown.chronological_events(), []) + + def test_user_turn_requires_both_fields(self): + """Test that user turn is only included when both start_time and secs are set.""" + # Only start_time, no duration + breakdown = LatencyBreakdown(user_turn_start_time=100.0) + self.assertEqual(breakdown.chronological_events(), []) + + # Only duration, no start_time + breakdown = LatencyBreakdown(user_turn_secs=0.150) + self.assertEqual(breakdown.chronological_events(), []) + + def test_ttfb_only(self): + """Test breakdown with only TTFB metrics.""" + breakdown = LatencyBreakdown( + ttfb=[ + TTFBBreakdownMetrics(processor="LLM#0", start_time=100.0, duration_secs=0.200), + ], + ) + events = breakdown.chronological_events() + self.assertEqual(events, ["LLM#0: TTFB 0.200s"]) if __name__ == "__main__": diff --git a/uv.lock b/uv.lock index 4756deb46..4dd9e53f5 100644 --- a/uv.lock +++ b/uv.lock @@ -1731,11 +1731,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.24.3" +version = "3.25.0" source = { registry = "https://pypi.org/simple" } -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" } +sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" }, ] [[package]] @@ -2477,11 +2477,11 @@ wheels = [ [[package]] name = "identify" -version = "2.6.16" +version = "2.6.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/84/376a3b96e5a8d33a7aa2c5b3b31a4b3c364117184bf0b17418055f6ace66/identify-2.6.17.tar.gz", hash = "sha256:f816b0b596b204c9fdf076ded172322f2723cf958d02f9c3587504834c8ff04d", size = 99579, upload-time = "2026-03-01T20:04:12.702Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, + { url = "https://files.pythonhosted.org/packages/40/66/71c1227dff78aaeb942fed29dd5651f2aec166cc7c9aeea3e8b26a539b7d/identify-2.6.17-py2.py3-none-any.whl", hash = "sha256:be5f8412d5ed4b20f2bd41a65f920990bdccaa6a4a18a08f1eefdcd0bdd885f0", size = 99382, upload-time = "2026-03-01T20:04:11.439Z" }, ] [[package]] @@ -4571,6 +4571,9 @@ langchain = [ { name = "langchain-community" }, { name = "langchain-openai" }, ] +lemonslice = [ + { name = "daily-python" }, +] livekit = [ { name = "livekit" }, { name = "livekit-api" }, @@ -4716,7 +4719,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.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-autodoc-typehints", version = "3.9.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-markdown-builder" }, { name = "sphinx-rtd-theme" }, { name = "toml" }, @@ -4778,6 +4781,7 @@ requires-dist = [ { 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,<13" }, + { name = "pipecat-ai", extras = ["daily"], marker = "extra == 'lemonslice'" }, { name = "pipecat-ai", extras = ["nvidia"], marker = "extra == 'riva'" }, { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'assemblyai'" }, { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'asyncai'" }, @@ -4833,7 +4837,7 @@ requires-dist = [ { name = "wait-for2", marker = "python_full_version < '3.12'", specifier = ">=0.4.1" }, { name = "websockets", marker = "extra == 'websockets-base'", specifier = ">=13.1,<16.0" }, ] -provides-extras = ["aic", "anthropic", "assemblyai", "asyncai", "aws", "aws-nova-sonic", "azure", "cartesia", "camb", "cerebras", "daily", "deepgram", "deepseek", "elevenlabs", "fal", "fireworks", "fish", "gladia", "google", "gradium", "grok", "groq", "gstreamer", "heygen", "hume", "inworld", "koala", "kokoro", "krisp", "langchain", "livekit", "lmnt", "local", "local-smart-turn", "mcp", "mem0", "mistral", "mlx-whisper", "moondream", "neuphonic", "noisereduce", "nvidia", "openai", "rnnoise", "openpipe", "openrouter", "perplexity", "piper", "qwen", "remote-smart-turn", "resembleai", "rime", "riva", "runner", "sagemaker", "sambanova", "sarvam", "sentry", "silero", "simli", "soniox", "soundfile", "speechmatics", "strands", "tavus", "together", "tracing", "ultravox", "webrtc", "websocket", "websockets-base", "whisper"] +provides-extras = ["aic", "anthropic", "assemblyai", "asyncai", "aws", "aws-nova-sonic", "azure", "cartesia", "camb", "cerebras", "daily", "deepgram", "deepseek", "elevenlabs", "fal", "fireworks", "fish", "gladia", "google", "gradium", "grok", "groq", "gstreamer", "heygen", "hume", "inworld", "koala", "kokoro", "krisp", "langchain", "lemonslice", "livekit", "lmnt", "local", "local-smart-turn", "mcp", "mem0", "mistral", "mlx-whisper", "moondream", "neuphonic", "noisereduce", "nvidia", "openai", "rnnoise", "openpipe", "openrouter", "perplexity", "piper", "qwen", "remote-smart-turn", "resembleai", "rime", "riva", "runner", "sagemaker", "sambanova", "sarvam", "sentry", "silero", "simli", "soniox", "soundfile", "speechmatics", "strands", "tavus", "together", "tracing", "ultravox", "webrtc", "websocket", "websockets-base", "whisper"] [package.metadata.requires-dev] dev = [ @@ -4930,7 +4934,7 @@ wheels = [ [[package]] name = "posthog" -version = "7.9.4" +version = "7.9.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, @@ -4940,9 +4944,9 @@ dependencies = [ { name = "six" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/50/5c0d9232118fdc1434c1b7bbc1a14de5b310498ede09a7e2123ae1f5f8bd/posthog-7.9.4.tar.gz", hash = "sha256:50acc94ef6267d7030575d2ff54e89e748fac2e98525ac672aeb0423160f77cf", size = 172973, upload-time = "2026-02-25T15:28:47.065Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/1b/92ec2f7e598a969d3f58cad96c187fbf3d1b38b4b0d1e05c403054553dae/posthog-7.9.6.tar.gz", hash = "sha256:4e0ecb63885ce522d6c7ad4593871771995931764ae83914c364db0ad5de2bbf", size = 175454, upload-time = "2026-03-02T21:29:01.729Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/6f/794a4e94e3640282e75013ce18e65f0a01afc8d71f733664b4a272f98bce/posthog-7.9.4-py3-none-any.whl", hash = "sha256:414125ddd7a48b9c67feb24d723df1f666af41ad10f8a9a8bbaf5e3b536a2e26", size = 198651, upload-time = "2026-02-25T15:28:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/27/5b/3ece09ecbbbfb2f783e510b54d7170c1322a93bd404aa9b923a84827b5fa/posthog-7.9.6-py3-none-any.whl", hash = "sha256:b1ceda033c9a6660c5d21e2b1c0b4113aaa0969ff02914bf23942c99f602b0f7", size = 201145, upload-time = "2026-03-02T21:29:00.136Z" }, ] [[package]] @@ -5622,11 +5626,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] @@ -6506,15 +6510,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.53.0" +version = "2.54.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/e9/2e3a46c304e7fa21eaa70612f60354e32699c7102eb961f67448e222ad7c/sentry_sdk-2.54.0.tar.gz", hash = "sha256:2620c2575128d009b11b20f7feb81e4e4e8ae08ec1d36cbc845705060b45cc1b", size = 413813, upload-time = "2026-03-02T15:12:41.355Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/53/39/be412cc86bc6247b8f69e9383d7950711bd86f8d0a4a4b0fe8fad685bc21/sentry_sdk-2.54.0-py2.py3-none-any.whl", hash = "sha256:fd74e0e281dcda63afff095d23ebcd6e97006102cdc8e78a29f19ecdf796a0de", size = 439198, upload-time = "2026-03-02T15:12:39.546Z" }, ] [[package]] @@ -6886,7 +6890,7 @@ wheels = [ [[package]] name = "sphinx-autodoc-typehints" -version = "3.8.0" +version = "3.9.5" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14'", @@ -6896,9 +6900,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/ba/89/72f96fe27aa1cfdc882aa6e1309a86b94e4653c1e8acf9b143d34e89c619/sphinx_autodoc_typehints-3.8.0.tar.gz", hash = "sha256:155a30407e88ed3287eeeb1e9156b0ed0ad08c998b0391c652b540563132fd70", size = 59672, upload-time = "2026-02-25T15:00:35.909Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ec/21bd9babcfeb9930a73011257002d5cfa5fd30667b8de6d76dbaf8275dfb/sphinx_autodoc_typehints-3.9.5.tar.gz", hash = "sha256:60e646efb7c352a0e98f34dd7fdcde4527fbbdbdf30371ff8321b6b3eb1fd37d", size = 63249, upload-time = "2026-03-02T19:58:07.974Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/0e/36820830c766647d688dfc2b3fda76d76c1cf007eea58fffc1990195aca4/sphinx_autodoc_typehints-3.8.0-py3-none-any.whl", hash = "sha256:f348971f3d88eaee053668b61512e921086b8f0600f1e0887a39bc9476aca51c", size = 32616, upload-time = "2026-02-25T15:00:34.749Z" }, + { url = "https://files.pythonhosted.org/packages/7f/cb/80c250f47a0ca5ac67d82f14811b4068a551a12b4790b085ffdb900de427/sphinx_autodoc_typehints-3.9.5-py3-none-any.whl", hash = "sha256:c94f88a90b6c61a7a6686cb77b410e46e077712838387e6cf22d69e85cfd06a5", size = 34763, upload-time = "2026-03-02T19:58:06.028Z" }, ] [[package]] @@ -7005,62 +7009,62 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.47" +version = "2.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/4b/1e00561093fe2cd8eef09d406da003c8a118ff02d6548498c1ae677d68d9/sqlalchemy-2.0.47.tar.gz", hash = "sha256:e3e7feb57b267fe897e492b9721ae46d5c7de6f9e8dee58aacf105dc4e154f3d", size = 9886323, upload-time = "2026-02-24T16:34:27.947Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/75/17db77c57129c223c7d98518ad1e1faa24ee350c22a44b55390d8463c28c/sqlalchemy-2.0.47-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:33a917ede39406ddb93c3e642b5bc480be7c5fd0f3d0d6ae1036d466fb963f1a", size = 2157331, upload-time = "2026-02-24T16:43:52.693Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d6/3658f7e5c376de774c009f2bb9c0ddf88a35b89c5bfb15ee7174a17b1a5f/sqlalchemy-2.0.47-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:561d027c829b01e040bdade6b6f5b429249d056ef95d7bdcb9211539ecc82803", size = 3236939, upload-time = "2026-02-24T17:28:57.419Z" }, - { url = "https://files.pythonhosted.org/packages/4e/38/f4b94f85d1c26cb9ee0e57449754de816c326f9586b9a8c5247eb49146de/sqlalchemy-2.0.47-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fa5072a37e68c565363c009b7afa5b199b488c87940ec02719860093a08f34ca", size = 3235190, upload-time = "2026-02-24T17:27:07.884Z" }, - { url = "https://files.pythonhosted.org/packages/94/f2/36714f1de01e135a2bf142b662e416e5338ab63c47878e31051338c66e2d/sqlalchemy-2.0.47-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1e7ed17dd4312a298b6024bfd1baf51654bc49e3f03c798005babf0c7922d6a7", size = 3188064, upload-time = "2026-02-24T17:28:58.908Z" }, - { url = "https://files.pythonhosted.org/packages/ab/94/fcd978e7625cd1c97d9f1d7363e18e37d24314e572acd7c091e3a4210106/sqlalchemy-2.0.47-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6992e353fcb0593eb42d95ad84b3e58fe40b5e37fd332b9ccba28f4b2f36d1fc", size = 3209480, upload-time = "2026-02-24T17:27:09.823Z" }, - { url = "https://files.pythonhosted.org/packages/23/29/c633202b9900ab65f0162f59df737b57f30010f44d892b186810c9ed58b7/sqlalchemy-2.0.47-cp310-cp310-win32.whl", hash = "sha256:05a6d58ed99ebd01303c92d29a0c9cbf70f637b3ddd155f5172c5a7239940998", size = 2117652, upload-time = "2026-02-24T17:14:34.635Z" }, - { url = "https://files.pythonhosted.org/packages/00/39/54acf13913932b8508058d47a169e6fcde9adaa4cbfa16cbf30da1f6a482/sqlalchemy-2.0.47-cp310-cp310-win_amd64.whl", hash = "sha256:4a7aa4a584cc97e268c11e700dea0b763874eaebb435e75e7d0ffee5d90f5030", size = 2140883, upload-time = "2026-02-24T17:14:35.875Z" }, - { url = "https://files.pythonhosted.org/packages/94/13/886338d3e8ab5ddcfe84d54302c749b1793e16c4bba63d7004e3f7baa8ec/sqlalchemy-2.0.47-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a1dbf0913879c443617d6b64403cf2801c941651db8c60e96d204ed9388d6b0", size = 2157124, upload-time = "2026-02-24T16:43:54.706Z" }, - { url = "https://files.pythonhosted.org/packages/b6/bb/a897f6a66c9986aa9f27f5cf8550637d8a5ea368fd7fb42f6dac3105b4dc/sqlalchemy-2.0.47-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:775effbb97ea3b00c4dd3aeaf3ba8acba6e3e2b4b41d17d67a27e696843dbc95", size = 3313513, upload-time = "2026-02-24T17:29:00.527Z" }, - { url = "https://files.pythonhosted.org/packages/59/fb/69bfae022b681507565ab0d34f0c80aa1e9f954a5a7cbfb0ed054966ac8d/sqlalchemy-2.0.47-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56cc834a3ffac34270cc2a41875e0f40e97aa651f4f3ca1cfbbf421c044cb62b", size = 3313014, upload-time = "2026-02-24T17:27:11.679Z" }, - { url = "https://files.pythonhosted.org/packages/04/f3/0eba329f7c182d53205a228c4fd24651b95489b431ea2bd830887b4c13c4/sqlalchemy-2.0.47-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49b5e0c7244262f39e767c018e4fdb5e5dbc23cd54c5ddac8eea8f0ba32ef890", size = 3265389, upload-time = "2026-02-24T17:29:02.497Z" }, - { url = "https://files.pythonhosted.org/packages/5c/06/654edc084b3b46ac79e04200d7c46467ae80c759c4ee41c897f9272b036f/sqlalchemy-2.0.47-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cd822a3f1f6f77b5b841a30c1a07a07f7dee3385f17e638e1722de9ab683be", size = 3287604, upload-time = "2026-02-24T17:27:13.295Z" }, - { url = "https://files.pythonhosted.org/packages/78/33/c18c8f63b61981219d3aa12321bb7ccee605034d195e868ed94f9727b27c/sqlalchemy-2.0.47-cp311-cp311-win32.whl", hash = "sha256:9847a19548cd283a65e1ce0afd54016598d55ff72682d6fd3e493af6fc044064", size = 2116916, upload-time = "2026-02-24T17:14:37.392Z" }, - { url = "https://files.pythonhosted.org/packages/f5/c6/a59e3f9796fff844e16afbd821db9abfd6e12698db9441a231a96193a100/sqlalchemy-2.0.47-cp311-cp311-win_amd64.whl", hash = "sha256:722abf1c82aeca46a1a0803711244a48a298279eeaec9e02f7bfee9e064182e5", size = 2141587, upload-time = "2026-02-24T17:14:39.746Z" }, - { url = "https://files.pythonhosted.org/packages/80/88/74eb470223ff88ea6572a132c0b8de8c1d8ed7b843d3b44a8a3c77f31d39/sqlalchemy-2.0.47-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4fa91b19d6b9821c04cc8f7aa2476429cc8887b9687c762815aa629f5c0edec1", size = 2155687, upload-time = "2026-02-24T17:05:46.451Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ba/1447d3d558971b036cb93b557595cb5dcdfe728f1c7ac4dec16505ef5756/sqlalchemy-2.0.47-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c5bbbd14eff577c8c79cbfe39a0771eecd20f430f3678533476f0087138f356", size = 3336978, upload-time = "2026-02-24T17:18:04.597Z" }, - { url = "https://files.pythonhosted.org/packages/8a/07/b47472d2ffd0776826f17ccf0b4d01b224c99fbd1904aeb103dffbb4b1cc/sqlalchemy-2.0.47-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5a6c555da8d4280a3c4c78c5b7a3f990cee2b2884e5f934f87a226191682ff7", size = 3349939, upload-time = "2026-02-24T17:27:18.937Z" }, - { url = "https://files.pythonhosted.org/packages/bb/c6/95fa32b79b57769da3e16f054cf658d90940317b5ca0ec20eac84aa19c4f/sqlalchemy-2.0.47-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ed48a1701d24dff3bb49a5bce94d6bc84cbe33d98af2aa2d3cdcce3dea1709ec", size = 3279648, upload-time = "2026-02-24T17:18:07.038Z" }, - { url = "https://files.pythonhosted.org/packages/bb/c8/3d07e7c73928dc59a0bed40961ca4e313e797bce650b088e8d5fdd3ad939/sqlalchemy-2.0.47-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4f3178c920ad98158f0b6309382194df04b14808fa6052ae07099fdde29d5602", size = 3314695, upload-time = "2026-02-24T17:27:20.93Z" }, - { url = "https://files.pythonhosted.org/packages/6b/d2/ed32b1611c1e19fdb028eee1adc5a9aa138c2952d09ae11f1670170f80ae/sqlalchemy-2.0.47-cp312-cp312-win32.whl", hash = "sha256:b9c11ac9934dd59ece9619fe42780a08abe2faab7b0543bb00d5eabea4f421b9", size = 2115502, upload-time = "2026-02-24T17:22:52.546Z" }, - { url = "https://files.pythonhosted.org/packages/fd/52/9de590356a4dd8e9ef5a881dbba64b2bbc4cbc71bf02bc68e775fb9b1899/sqlalchemy-2.0.47-cp312-cp312-win_amd64.whl", hash = "sha256:db43b72cf8274a99e089755c9c1e0b947159b71adbc2c83c3de2e38d5d607acb", size = 2142435, upload-time = "2026-02-24T17:22:54.268Z" }, - { url = "https://files.pythonhosted.org/packages/4a/e5/0af64ce7d8f60ec5328c10084e2f449e7912a9b8bdbefdcfb44454a25f49/sqlalchemy-2.0.47-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:456a135b790da5d3c6b53d0ef71ac7b7d280b7f41eb0c438986352bf03ca7143", size = 2152551, upload-time = "2026-02-24T17:05:47.675Z" }, - { url = "https://files.pythonhosted.org/packages/63/79/746b8d15f6940e2ac469ce22d7aa5b1124b1ab820bad9b046eb3000c88a6/sqlalchemy-2.0.47-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09a2f7698e44b3135433387da5d8846cf7cc7c10e5425af7c05fee609df978b6", size = 3278782, upload-time = "2026-02-24T17:18:10.012Z" }, - { url = "https://files.pythonhosted.org/packages/91/b1/bd793ddb34345d1ed43b13ab2d88c95d7d4eb2e28f5b5a99128b9cc2bca2/sqlalchemy-2.0.47-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bbc72e6a177c78d724f9106aaddc0d26a2ada89c6332b5935414eccf04cbd5", size = 3295155, upload-time = "2026-02-24T17:27:22.827Z" }, - { url = "https://files.pythonhosted.org/packages/97/84/7213def33f94e5ca6f5718d259bc9f29de0363134648425aa218d4356b23/sqlalchemy-2.0.47-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:75460456b043b78b6006e41bdf5b86747ee42eafaf7fffa3b24a6e9a456a2092", size = 3226834, upload-time = "2026-02-24T17:18:11.465Z" }, - { url = "https://files.pythonhosted.org/packages/ef/06/456810204f4dc29b5f025b1b0a03b4bd6b600ebf3c1040aebd90a257fa33/sqlalchemy-2.0.47-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d9adaa616c3bc7d80f9ded57cd84b51d6617cad6a5456621d858c9f23aaee01", size = 3265001, upload-time = "2026-02-24T17:27:24.813Z" }, - { url = "https://files.pythonhosted.org/packages/fb/20/df3920a4b2217dbd7390a5bd277c1902e0393f42baaf49f49b3c935e7328/sqlalchemy-2.0.47-cp313-cp313-win32.whl", hash = "sha256:76e09f974382a496a5ed985db9343628b1cb1ac911f27342e4cc46a8bac10476", size = 2113647, upload-time = "2026-02-24T17:22:55.747Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/7873ddf69918efbfabd7211829f4bd8019739d0a719253112d305d3ba51d/sqlalchemy-2.0.47-cp313-cp313-win_amd64.whl", hash = "sha256:0664089b0bf6724a0bfb49a0cf4d4da24868a0a5c8e937cd7db356d5dcdf2c66", size = 2139425, upload-time = "2026-02-24T17:22:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/54/fa/61ad9731370c90ac7ea5bf8f5eaa12c48bb4beec41c0fa0360becf4ac10d/sqlalchemy-2.0.47-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed0c967c701ae13da98eb220f9ddab3044ab63504c1ba24ad6a59b26826ad003", size = 3558809, upload-time = "2026-02-24T17:12:15.232Z" }, - { url = "https://files.pythonhosted.org/packages/33/d5/221fac96f0529391fe374875633804c866f2b21a9c6d3a6ca57d9c12cfd7/sqlalchemy-2.0.47-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3537943a61fd25b241e976426a0c6814434b93cf9b09d39e8e78f3c9eb9a487", size = 3525480, upload-time = "2026-02-24T17:27:59.602Z" }, - { url = "https://files.pythonhosted.org/packages/ec/55/8247d53998c3673e4a8d1958eba75c6f5cc3b39082029d400bb1f2a911ae/sqlalchemy-2.0.47-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:57f7e336a64a0dba686c66392d46b9bc7af2c57d55ce6dc1697b4ef32b043ceb", size = 3466569, upload-time = "2026-02-24T17:12:16.94Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b5/c1f0eea1bac6790845f71420a7fe2f2a0566203aa57543117d4af3b77d1c/sqlalchemy-2.0.47-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dff735a621858680217cb5142b779bad40ef7322ddbb7c12062190db6879772e", size = 3475770, upload-time = "2026-02-24T17:28:02.034Z" }, - { url = "https://files.pythonhosted.org/packages/c5/ed/2f43f92474ea0c43c204657dc47d9d002cd738b96ca2af8e6d29a9b5e42d/sqlalchemy-2.0.47-cp313-cp313t-win32.whl", hash = "sha256:3893dc096bb3cca9608ea3487372ffcea3ae9b162f40e4d3c51dd49db1d1b2dc", size = 2141300, upload-time = "2026-02-24T17:14:37.024Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a9/8b73f9f1695b6e92f7aaf1711135a1e3bbeb78bca9eded35cb79180d3c6d/sqlalchemy-2.0.47-cp313-cp313t-win_amd64.whl", hash = "sha256:b5103427466f4b3e61f04833ae01f9a914b1280a2a8bcde3a9d7ab11f3755b42", size = 2173053, upload-time = "2026-02-24T17:14:38.688Z" }, - { url = "https://files.pythonhosted.org/packages/c1/30/98243209aae58ed80e090ea988d5182244ca7ab3ff59e6d850c3dfc7651e/sqlalchemy-2.0.47-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b03010a5a5dfe71676bc83f2473ebe082478e32d77e6f082c8fe15a31c3b42a6", size = 2154355, upload-time = "2026-02-24T17:05:48.959Z" }, - { url = "https://files.pythonhosted.org/packages/ab/62/12ca6ea92055fe486d6558a2a4efe93e194ff597463849c01f88e5adb99d/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8e3371aa9024520883a415a09cc20c33cfd3eeccf9e0f4f4c367f940b9cbd44", size = 3274486, upload-time = "2026-02-24T17:18:13.659Z" }, - { url = "https://files.pythonhosted.org/packages/97/88/7dfbdeaa8d42b1584e65d6cc713e9d33b6fa563e0d546d5cb87e545bb0e5/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9449f747e50d518c6e1b40cc379e48bfc796453c47b15e627ea901c201e48a6", size = 3279481, upload-time = "2026-02-24T17:27:26.491Z" }, - { url = "https://files.pythonhosted.org/packages/d0/b7/75e1c1970616a9dd64a8a6fd788248da2ddaf81c95f4875f2a1e8aee4128/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:21410f60d5cac1d6bfe360e05bd91b179be4fa0aa6eea6be46054971d277608f", size = 3224269, upload-time = "2026-02-24T17:18:15.078Z" }, - { url = "https://files.pythonhosted.org/packages/31/ac/eec1a13b891df9a8bc203334caf6e6aac60b02f61b018ef3b4124b8c4120/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:819841dd5bb4324c284c09e2874cf96fe6338bfb57a64548d9b81a4e39c9871f", size = 3246262, upload-time = "2026-02-24T17:27:27.986Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b0/661b0245b06421058610da39f8ceb34abcc90b49f90f256380968d761dbe/sqlalchemy-2.0.47-cp314-cp314-win32.whl", hash = "sha256:e255ee44821a7ef45649c43064cf94e74f81f61b4df70547304b97a351e9b7db", size = 2116528, upload-time = "2026-02-24T17:22:59.363Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ef/1035a90d899e61810791c052004958be622a2cf3eb3df71c3fe20778c5d0/sqlalchemy-2.0.47-cp314-cp314-win_amd64.whl", hash = "sha256:209467ff73ea1518fe1a5aaed9ba75bb9e33b2666e2553af9ccd13387bf192cb", size = 2142181, upload-time = "2026-02-24T17:23:01.001Z" }, - { url = "https://files.pythonhosted.org/packages/76/bb/17a1dd09cbba91258218ceb582225f14b5364d2683f9f5a274f72f2d764f/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e78fd9186946afaa287f8a1fe147ead06e5d566b08c0afcb601226e9c7322a64", size = 3563477, upload-time = "2026-02-24T17:12:18.46Z" }, - { url = "https://files.pythonhosted.org/packages/66/8f/1a03d24c40cc321ef2f2231f05420d140bb06a84f7047eaa7eaa21d230ba/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5740e2f31b5987ed9619d6912ae5b750c03637f2078850da3002934c9532f172", size = 3528568, upload-time = "2026-02-24T17:28:03.732Z" }, - { url = "https://files.pythonhosted.org/packages/fd/53/d56a213055d6b038a5384f0db5ece7343334aca230ff3f0fa1561106f22c/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb9ac00d03de93acb210e8ec7243fefe3e012515bf5fd2f0898c8dff38bc77a4", size = 3472284, upload-time = "2026-02-24T17:12:20.319Z" }, - { url = "https://files.pythonhosted.org/packages/ff/19/c235d81b9cfdd6130bf63143b7bade0dc4afa46c4b634d5d6b2a96bea233/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c72a0b9eb2672d70d112cb149fbaf172d466bc691014c496aaac594f1988e706", size = 3478410, upload-time = "2026-02-24T17:28:05.892Z" }, - { url = "https://files.pythonhosted.org/packages/0e/db/cafdeca5ecdaa3bb0811ba5449501da677ce0d83be8d05c5822da72d2e86/sqlalchemy-2.0.47-cp314-cp314t-win32.whl", hash = "sha256:c200db1128d72a71dc3c31c24b42eb9fd85b2b3e5a3c9ba1e751c11ac31250ff", size = 2147164, upload-time = "2026-02-24T17:14:40.783Z" }, - { url = "https://files.pythonhosted.org/packages/fc/5e/ff41a010e9e0f76418b02ad352060a4341bb15f0af66cedc924ab376c7c6/sqlalchemy-2.0.47-cp314-cp314t-win_amd64.whl", hash = "sha256:669837759b84e575407355dcff912835892058aea9b80bd1cb76d6a151cf37f7", size = 2182154, upload-time = "2026-02-24T17:14:43.205Z" }, - { url = "https://files.pythonhosted.org/packages/15/9f/7c378406b592fcf1fc157248607b495a40e3202ba4a6f1372a2ba6447717/sqlalchemy-2.0.47-py3-none-any.whl", hash = "sha256:e2647043599297a1ef10e720cf310846b7f31b6c841fee093d2b09d81215eb93", size = 1940159, upload-time = "2026-02-24T17:15:07.158Z" }, + { url = "https://files.pythonhosted.org/packages/9a/67/1235676e93dd3b742a4a8eddfae49eea46c85e3eed29f0da446a8dd57500/sqlalchemy-2.0.48-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7001dc9d5f6bb4deb756d5928eaefe1930f6f4179da3924cbd95ee0e9f4dce89", size = 2157384, upload-time = "2026-03-02T15:38:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d7/fa728b856daa18c10e1390e76f26f64ac890c947008284387451d56ca3d0/sqlalchemy-2.0.48-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a89ce07ad2d4b8cfc30bd5889ec40613e028ed80ef47da7d9dd2ce969ad30e0", size = 3236981, upload-time = "2026-03-02T15:58:53.53Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ad/6c4395649a212a6c603a72c5b9ab5dce3135a1546cfdffa3c427e71fd535/sqlalchemy-2.0.48-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10853a53a4a00417a00913d270dddda75815fcb80675874285f41051c094d7dd", size = 3235232, upload-time = "2026-03-02T15:52:25.654Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/58f845e511ac0509765a6f85eb24924c1ef0d54fb50de9d15b28c3601458/sqlalchemy-2.0.48-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fac0fa4e4f55f118fd87177dacb1c6522fe39c28d498d259014020fec9164c29", size = 3188106, upload-time = "2026-03-02T15:58:55.193Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f9/6dcc7bfa5f5794c3a095e78cd1de8269dfb5584dfd4c2c00a50d3c1ade44/sqlalchemy-2.0.48-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3713e21ea67bca727eecd4a24bf68bcd414c403faae4989442be60994301ded0", size = 3209522, upload-time = "2026-03-02T15:52:27.407Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/b632875ab35874d42657f079529f0745410604645c269a8c21fb4272ff7a/sqlalchemy-2.0.48-cp310-cp310-win32.whl", hash = "sha256:d404dc897ce10e565d647795861762aa2d06ca3f4a728c5e9a835096c7059018", size = 2117695, upload-time = "2026-03-02T15:46:51.389Z" }, + { url = "https://files.pythonhosted.org/packages/de/03/9752eb2a41afdd8568e41ac3c3128e32a0a73eada5ab80483083604a56d1/sqlalchemy-2.0.48-cp310-cp310-win_amd64.whl", hash = "sha256:841a94c66577661c1f088ac958cd767d7c9bf507698f45afffe7a4017049de76", size = 2140928, upload-time = "2026-03-02T15:46:52.992Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6d/b8b78b5b80f3c3ab3f7fa90faa195ec3401f6d884b60221260fd4d51864c/sqlalchemy-2.0.48-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b4c575df7368b3b13e0cebf01d4679f9a28ed2ae6c1cd0b1d5beffb6b2007dc", size = 2157184, upload-time = "2026-03-02T15:38:28.161Z" }, + { url = "https://files.pythonhosted.org/packages/21/4b/4f3d4a43743ab58b95b9ddf5580a265b593d017693df9e08bd55780af5bb/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e83e3f959aaa1c9df95c22c528096d94848a1bc819f5d0ebf7ee3df0ca63db6c", size = 3313555, upload-time = "2026-03-02T15:58:57.21Z" }, + { url = "https://files.pythonhosted.org/packages/21/dd/3b7c53f1dbbf736fd27041aee68f8ac52226b610f914085b1652c2323442/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f7b7243850edd0b8b97043f04748f31de50cf426e939def5c16bedb540698f7", size = 3313057, upload-time = "2026-03-02T15:52:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cc/3e600a90ae64047f33313d7d32e5ad025417f09d2ded487e8284b5e21a15/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82745b03b4043e04600a6b665cb98697c4339b24e34d74b0a2ac0a2488b6f94d", size = 3265431, upload-time = "2026-03-02T15:58:59.096Z" }, + { url = "https://files.pythonhosted.org/packages/8b/19/780138dacfe3f5024f4cf96e4005e91edf6653d53d3673be4844578faf1d/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5e088bf43f6ee6fec7dbf1ef7ff7774a616c236b5c0cb3e00662dd71a56b571", size = 3287646, upload-time = "2026-03-02T15:52:31.569Z" }, + { url = "https://files.pythonhosted.org/packages/40/fd/f32ced124f01a23151f4777e4c705f3a470adc7bd241d9f36a7c941a33bf/sqlalchemy-2.0.48-cp311-cp311-win32.whl", hash = "sha256:9c7d0a77e36b5f4b01ca398482230ab792061d243d715299b44a0b55c89fe617", size = 2116956, upload-time = "2026-03-02T15:46:54.535Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/dd767277f6feef12d05651538f280277e661698f617fa4d086cce6055416/sqlalchemy-2.0.48-cp311-cp311-win_amd64.whl", hash = "sha256:583849c743e0e3c9bb7446f5b5addeacedc168d657a69b418063dfdb2d90081c", size = 2141627, upload-time = "2026-03-02T15:46:55.849Z" }, + { url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" }, + { url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" }, + { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" }, + { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" }, + { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" }, + { url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" }, + { url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" }, + { url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" }, + { url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, ] [[package]] @@ -8219,128 +8223,142 @@ wheels = [ [[package]] name = "yarl" -version = "1.22.0" +version = "1.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/43/a2204825342f37c337f5edb6637040fa14e365b2fcc2346960201d457579/yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e", size = 140517, upload-time = "2025-10-06T14:08:42.494Z" }, - { url = "https://files.pythonhosted.org/packages/44/6f/674f3e6f02266428c56f704cd2501c22f78e8b2eeb23f153117cc86fb28a/yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f", size = 93495, upload-time = "2025-10-06T14:08:46.2Z" }, - { url = "https://files.pythonhosted.org/packages/b8/12/5b274d8a0f30c07b91b2f02cba69152600b47830fcfb465c108880fcee9c/yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf", size = 94400, upload-time = "2025-10-06T14:08:47.855Z" }, - { url = "https://files.pythonhosted.org/packages/e2/7f/df1b6949b1fa1aa9ff6de6e2631876ad4b73c4437822026e85d8acb56bb1/yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a", size = 347545, upload-time = "2025-10-06T14:08:49.683Z" }, - { url = "https://files.pythonhosted.org/packages/84/09/f92ed93bd6cd77872ab6c3462df45ca45cd058d8f1d0c9b4f54c1704429f/yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c", size = 319598, upload-time = "2025-10-06T14:08:51.215Z" }, - { url = "https://files.pythonhosted.org/packages/c3/97/ac3f3feae7d522cf7ccec3d340bb0b2b61c56cb9767923df62a135092c6b/yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147", size = 363893, upload-time = "2025-10-06T14:08:53.144Z" }, - { url = "https://files.pythonhosted.org/packages/06/49/f3219097403b9c84a4d079b1d7bda62dd9b86d0d6e4428c02d46ab2c77fc/yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb", size = 371240, upload-time = "2025-10-06T14:08:55.036Z" }, - { url = "https://files.pythonhosted.org/packages/35/9f/06b765d45c0e44e8ecf0fe15c9eacbbde342bb5b7561c46944f107bfb6c3/yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6", size = 346965, upload-time = "2025-10-06T14:08:56.722Z" }, - { url = "https://files.pythonhosted.org/packages/c5/69/599e7cea8d0fcb1694323b0db0dda317fa3162f7b90166faddecf532166f/yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0", size = 342026, upload-time = "2025-10-06T14:08:58.563Z" }, - { url = "https://files.pythonhosted.org/packages/95/6f/9dfd12c8bc90fea9eab39832ee32ea48f8e53d1256252a77b710c065c89f/yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda", size = 335637, upload-time = "2025-10-06T14:09:00.506Z" }, - { url = "https://files.pythonhosted.org/packages/57/2e/34c5b4eb9b07e16e873db5b182c71e5f06f9b5af388cdaa97736d79dd9a6/yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc", size = 359082, upload-time = "2025-10-06T14:09:01.936Z" }, - { url = "https://files.pythonhosted.org/packages/31/71/fa7e10fb772d273aa1f096ecb8ab8594117822f683bab7d2c5a89914c92a/yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737", size = 357811, upload-time = "2025-10-06T14:09:03.445Z" }, - { url = "https://files.pythonhosted.org/packages/26/da/11374c04e8e1184a6a03cf9c8f5688d3e5cec83ed6f31ad3481b3207f709/yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467", size = 351223, upload-time = "2025-10-06T14:09:05.401Z" }, - { url = "https://files.pythonhosted.org/packages/82/8f/e2d01f161b0c034a30410e375e191a5d27608c1f8693bab1a08b089ca096/yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea", size = 82118, upload-time = "2025-10-06T14:09:11.148Z" }, - { url = "https://files.pythonhosted.org/packages/62/46/94c76196642dbeae634c7a61ba3da88cd77bed875bf6e4a8bed037505aa6/yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca", size = 86852, upload-time = "2025-10-06T14:09:12.958Z" }, - { url = "https://files.pythonhosted.org/packages/af/af/7df4f179d3b1a6dcb9a4bd2ffbc67642746fcafdb62580e66876ce83fff4/yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b", size = 82012, upload-time = "2025-10-06T14:09:14.664Z" }, - { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, - { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, - { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, - { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, - { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, - { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, - { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, - { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, - { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, - { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, - { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, - { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, - { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, - { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, - { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, - { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, - { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, - { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, - { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, - { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, - { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, - { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, - { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, - { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, - { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, - { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, - { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, - { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, - { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, - { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, - { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, - { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, - { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, - { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, - { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, - { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, - { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, - { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, - { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, - { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, - { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, - { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, - { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, - { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, - { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, - { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, - { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, - { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, - { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, - { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, - { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, - { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, - { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, - { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, - { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, - { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, - { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, - { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, - { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, - { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, - { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, - { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, - { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, - { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, - { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0d/9cc638702f6fc3c7a3685bcc8cf2a9ed7d6206e932a49f5242658047ef51/yarl-1.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107", size = 123764, upload-time = "2026-03-01T22:04:09.7Z" }, + { url = "https://files.pythonhosted.org/packages/7a/35/5a553687c5793df5429cd1db45909d4f3af7eee90014888c208d086a44f0/yarl-1.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d", size = 86282, upload-time = "2026-03-01T22:04:11.892Z" }, + { url = "https://files.pythonhosted.org/packages/68/2e/c5a2234238f8ce37a8312b52801ee74117f576b1539eec8404a480434acc/yarl-1.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05", size = 86053, upload-time = "2026-03-01T22:04:13.292Z" }, + { url = "https://files.pythonhosted.org/packages/74/3f/bbd8ff36fb038622797ffbaf7db314918bb4d76f1cc8a4f9ca7a55fe5195/yarl-1.23.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed5f69ce7be7902e5c70ea19eb72d20abf7d725ab5d49777d696e32d4fc1811d", size = 99395, upload-time = "2026-03-01T22:04:15.133Z" }, + { url = "https://files.pythonhosted.org/packages/77/04/9516bc4e269d2a3ec9c6779fcdeac51ce5b3a9b0156f06ac7152e5bba864/yarl-1.23.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:389871e65468400d6283c0308e791a640b5ab5c83bcee02a2f51295f95e09748", size = 92143, upload-time = "2026-03-01T22:04:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/c7/63/88802d1f6b1cb1fc67d67a58cd0cf8a1790de4ce7946e434240f1d60ab4a/yarl-1.23.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dda608c88cf709b1d406bdfcd84d8d63cff7c9e577a403c6108ce8ce9dcc8764", size = 107643, upload-time = "2026-03-01T22:04:18.519Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/4f9b838f4d8bdd6f0f385aed8bbf21c71ed11a0b9983305c302cbd557815/yarl-1.23.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c4fe09e0780c6c3bf2b7d4af02ee2394439d11a523bbcf095cf4747c2932007", size = 108700, upload-time = "2026-03-01T22:04:20.373Z" }, + { url = "https://files.pythonhosted.org/packages/50/12/95a1d33f04a79c402664070d43b8b9f72dc18914e135b345b611b0b1f8cc/yarl-1.23.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c9921eb8bd12633b41ad27686bbb0b1a2a9b8452bfdf221e34f311e9942ed4", size = 102769, upload-time = "2026-03-01T22:04:23.055Z" }, + { url = "https://files.pythonhosted.org/packages/86/65/91a0285f51321369fd1a8308aa19207520c5f0587772cfc2e03fc2467e90/yarl-1.23.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5f10fd85e4b75967468af655228fbfd212bdf66db1c0d135065ce288982eda26", size = 101114, upload-time = "2026-03-01T22:04:25.031Z" }, + { url = "https://files.pythonhosted.org/packages/58/80/c7c8244fc3e5bc483dc71a09560f43b619fab29301a0f0a8f936e42865c7/yarl-1.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dbf507e9ef5688bada447a24d68b4b58dd389ba93b7afc065a2ba892bea54769", size = 98883, upload-time = "2026-03-01T22:04:27.281Z" }, + { url = "https://files.pythonhosted.org/packages/86/e7/71ca9cc9ca79c0b7d491216177d1aed559d632947b8ffb0ee60f7d8b23e3/yarl-1.23.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:85e9beda1f591bc73e77ea1c51965c68e98dafd0fec72cdd745f77d727466716", size = 94172, upload-time = "2026-03-01T22:04:28.554Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3f/6c6c8a0fe29c26fb2db2e8d32195bb84ec1bfb8f1d32e7f73b787fcf349b/yarl-1.23.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0e1fdaa14ef51366d7757b45bde294e95f6c8c049194e793eedb8387c86d5993", size = 107010, upload-time = "2026-03-01T22:04:30.385Z" }, + { url = "https://files.pythonhosted.org/packages/56/38/12730c05e5ad40a76374d440ed8b0899729a96c250516d91c620a6e38fc2/yarl-1.23.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:75e3026ab649bf48f9a10c0134512638725b521340293f202a69b567518d94e0", size = 100285, upload-time = "2026-03-01T22:04:31.752Z" }, + { url = "https://files.pythonhosted.org/packages/34/92/6a7be9239f2347234e027284e7a5f74b1140cc86575e7b469d13fba1ebfe/yarl-1.23.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:80e6d33a3d42a7549b409f199857b4fb54e2103fc44fb87605b6663b7a7ff750", size = 108230, upload-time = "2026-03-01T22:04:33.844Z" }, + { url = "https://files.pythonhosted.org/packages/5e/81/4aebccfa9376bd98b9d8bfad20621a57d3e8cfc5b8631c1fa5f62cdd03f4/yarl-1.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ec2f42d41ccbd5df0270d7df31618a8ee267bfa50997f5d720ddba86c4a83a6", size = 103008, upload-time = "2026-03-01T22:04:35.856Z" }, + { url = "https://files.pythonhosted.org/packages/38/0f/0b4e3edcec794a86b853b0c6396c0a888d72dfce19b2d88c02ac289fb6c1/yarl-1.23.0-cp310-cp310-win32.whl", hash = "sha256:debe9c4f41c32990771be5c22b56f810659f9ddf3d63f67abfdcaa2c6c9c5c1d", size = 83073, upload-time = "2026-03-01T22:04:38.268Z" }, + { url = "https://files.pythonhosted.org/packages/a0/71/ad95c33da18897e4c636528bbc24a1dd23fe16797de8bc4ec667b8db0ba4/yarl-1.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:ab5f043cb8a2d71c981c09c510da013bc79fd661f5c60139f00dd3c3cc4f2ffb", size = 87328, upload-time = "2026-03-01T22:04:39.558Z" }, + { url = "https://files.pythonhosted.org/packages/e2/14/dfa369523c79bccf9c9c746b0a63eb31f65db9418ac01275f7950962e504/yarl-1.23.0-cp310-cp310-win_arm64.whl", hash = "sha256:263cd4f47159c09b8b685890af949195b51d1aa82ba451c5847ca9bc6413c220", size = 82463, upload-time = "2026-03-01T22:04:41.454Z" }, + { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" }, + { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" }, + { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" }, + { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" }, + { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" }, + { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" }, + { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" }, + { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, + { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] [[package]]